เหตุการณ์ UI (มุมมอง)

แนวคิดและการใช้งาน Jetpack Compose

เหตุการณ์ UI คือการดำเนินการที่ควรจัดการในเลเยอร์ UI ไม่ว่าจะเป็นโดย UI หรือ ViewModel เหตุการณ์ประเภทที่พบบ่อยที่สุดคือ เหตุการณ์ของผู้ใช้ ผู้ใช้จะสร้างเหตุการณ์ของผู้ใช้โดยการโต้ตอบกับแอป เช่น แตะหน้าจอหรือสร้างท่าทางสัมผัส จากนั้น UI จะใช้เหตุการณ์เหล่านี้โดยใช้การเรียกกลับ เช่น Listener onClick()

โดยปกติแล้ว ViewModel จะมีหน้าที่จัดการตรรกะทางธุรกิจของเหตุการณ์ของผู้ใช้หนึ่งๆ เช่น ผู้ใช้คลิกปุ่มเพื่อรีเฟรชข้อมูลบางอย่าง โดยปกติแล้ว ViewModel จะจัดการเรื่องนี้ด้วยการเปิดเผยฟังก์ชันที่ UI เรียกได้ เหตุการณ์ของผู้ใช้อาจมีตรรกะลักษณะการทำงานของ UI ที่ UI จัดการได้ โดยตรง เช่น การไปยังหน้าจออื่นหรือแสดง Snackbar

แม้ว่า ตรรกะทางธุรกิจ จะยังคงเหมือนเดิมสำหรับแอปเดียวกันในแพลตฟอร์มอุปกรณ์เคลื่อนที่หรือฟอร์มแฟกเตอร์ต่างๆ แต่ ตรรกะลักษณะการทำงานของ UI เป็นรายละเอียดการใช้งานที่อาจแตกต่างกันไปในแต่ละกรณี หน้าเลเยอร์ UI หน้าจะกำหนดตรรกะประเภทเหล่านี้ไว้ดัง นี้

  • ตรรกะทางธุรกิจ หมายถึง สิ่งที่ต้องทำ กับการเปลี่ยนแปลงสถานะ เช่น การชำระเงินหรือการจัดเก็บค่ากำหนดของผู้ใช้ โดยปกติแล้วเลเยอร์โดเมนและเลเยอร์ข้อมูลจะเป็นผู้จัดการตรรกะนี้ ตลอดทั้งคู่มือนี้ เราจะใช้คลาส Architecture Components ViewModel ของคอมโพเนนต์สถาปัตยกรรมเป็นโซลูชันที่แนะนำสำหรับคลาสที่จัดการตรรกะทางธุรกิจ
  • ตรรกะลักษณะการทำงานของ UI หรือ ตรรกะ UI หมายถึง วิธีแสดง การเปลี่ยนแปลงสถานะ เช่น ตรรกะการนำทางหรือวิธีแสดงข้อความแก่ผู้ใช้ โดย UI จะเป็นผู้จัดการตรรกะนี้

จัดการเหตุการณ์ของผู้ใช้

UI สามารถจัดการเหตุการณ์ของผู้ใช้ได้โดยตรงหากเหตุการณ์เหล่านั้นเกี่ยวข้องกับการแก้ไขสถานะขององค์ประกอบ UI เช่น สถานะของรายการที่ขยายได้ หากเหตุการณ์ต้องใช้ตรรกะทางธุรกิจ เช่น การรีเฟรชข้อมูลบนหน้าจอ ViewModel ควรเป็นผู้ประมวลผลเหตุการณ์

ตัวอย่างต่อไปนี้แสดงวิธีใช้ปุ่มต่างๆ เพื่อขยายองค์ประกอบ UI (ตรรกะ UI) และรีเฟรชข้อมูลบนหน้าจอ (ตรรกะทางธุรกิจ)

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        // The expand details event is processed by the UI that
        // modifies a View's internal state.
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the business logic.
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

เหตุการณ์ของผู้ใช้ใน RecyclerView

หากการดำเนินการเกิดขึ้นในส่วนลึกของแผนผัง UI เช่น ใน RecyclerView รายการ หรือ View ที่กำหนดเอง ViewModel ควรเป็นผู้จัดการเหตุการณ์ของผู้ใช้

ตัวอย่างเช่น สมมติว่ารายการข่าวทั้งหมดจาก NewsActivity มีปุ่มบุ๊กมาร์ก ViewModel ต้องทราบรหัสของรายการข่าวที่บุ๊กมาร์ก เมื่อผู้ใช้บุ๊กมาร์กรายการข่าว อะแดปเตอร์ RecyclerView จะไม่เรียกใช้ฟังก์ชัน addBookmark(newsId) ที่เปิดเผยจาก ViewModel ซึ่งจะต้องมีการพึ่งพา ViewModel แต่ ViewModel จะเปิดเผยออบเจ็กต์สถานะที่เรียกว่า NewsItemUiState ซึ่งมีการใช้งานสำหรับการจัดการเหตุการณ์ดังนี้

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
)
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // Business logic is passed as a lambda function that the
            // UI calls on click events.
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

วิธีนี้จะช่วยให้อะแดปเตอร์ RecyclerView ทำงานกับข้อมูลที่จำเป็นเท่านั้น นั่นคือ รายการออบเจ็กต์ NewsItemUiState อะแดปเตอร์จะไม่มีสิทธิ์เข้าถึง ViewModel ทั้งหมด ซึ่งจะช่วยลดโอกาสที่จะมีการใช้ฟังก์ชันการทำงานที่ ViewModel เปิดเผยในทางที่ผิด เมื่ออนุญาตให้เฉพาะคลาสกิจกรรมทำงานกับ ViewModel คุณจะแยกความรับผิดชอบออกจากกัน วิธีนี้จะช่วยให้มั่นใจได้ว่าออบเจ็กต์เฉพาะของ UI เช่น View หรืออะแดปเตอร์ RecyclerView จะไม่โต้ตอบกับ ViewModel โดยตรง

หลักเกณฑ์การตั้งชื่อฟังก์ชันเหตุการณ์ของผู้ใช้

ในคู่มือนี้ ฟังก์ชัน ViewModel ที่จัดการเหตุการณ์ของผู้ใช้จะตั้งชื่อด้วยคำกริยาตามการดำเนินการที่ฟังก์ชันนั้นจัดการ เช่น addBookmark(id) หรือ logIn(username, password)

จัดการเหตุการณ์ ViewModel

การดำเนินการ UI ที่มาจาก ViewModel หรือเหตุการณ์ ViewModel ควร ส่งผลให้มีการอัปเดตสถานะ UI เสมอ ซึ่งเป็นไปตามหลักการของโฟลว์ข้อมูลแบบทิศทางเดียว วิธีนี้จะทำให้เหตุการณ์สามารถทำซ้ำได้หลังจากการเปลี่ยนแปลงการกำหนดค่า และรับประกันว่าการดำเนินการ UI จะไม่สูญหาย นอกจากนี้ คุณยังทำให้เหตุการณ์สามารถทำซ้ำได้หลังจากการสิ้นสุดการประมวลผลหากใช้โมดูลสถานะที่บันทึกไว้

การแมปการดำเนินการ UI กับสถานะ UI อาจไม่ใช่กระบวนการที่ง่ายเสมอไป แต่จะนำไปสู่ตรรกะที่ง่ายขึ้น กระบวนการคิดของคุณไม่ควรจบลงด้วยการกำหนดวิธีทำให้ UI ไปยังหน้าจอหนึ่งๆ เช่น คุณต้องคิดให้ไกลกว่านั้นและพิจารณาวิธีแสดงโฟลว์ของผู้ใช้ในสถานะ UI กล่าวอีกนัยหนึ่งคือ อย่าคิดว่า UI ต้องดำเนินการอะไรบ้าง แต่ให้คิดว่า การดำเนินการเหล่านั้นส่งผลต่อสถานะ UI อย่างไร

ตัวอย่างเช่น พิจารณากรณีการไปยังหน้าจอหลักเมื่อผู้ใช้เข้าสู่ระบบในหน้าจอเข้าสู่ระบบ คุณสามารถสร้างโมเดลนี้ในสถานะ UI ได้ดังนี้

data class LoginUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

UI นี้จะตอบสนองต่อการเปลี่ยนแปลงสถานะ isUserLoggedIn และไปยังปลายทางที่ถูกต้องตามความจำเป็น

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
    /* ... */
}

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

การใช้เหตุการณ์อาจทริกเกอร์การอัปเดตสถานะ

การใช้เหตุการณ์ ViewModel บางอย่างใน UI อาจส่งผลให้มีการอัปเดตสถานะ UI อื่นๆ ตัวอย่างเช่น เมื่อแสดงข้อความชั่วคราวบนหน้าจอเพื่อแจ้งให้ผู้ใช้ทราบว่ามีบางอย่างเกิดขึ้น UI ต้องแจ้งให้ ViewModel ทริกเกอร์การอัปเดตสถานะอื่นเมื่อข้อความแสดงบนหน้าจอ เหตุการณ์ที่เกิดขึ้นเมื่อผู้ใช้ใช้ข้อความแล้ว (โดยการปิดข้อความหรือหลังจากหมดเวลา) สามารถถือเป็น "ข้อมูลจากผู้ใช้" และ ViewModel ควรทราบถึงเหตุการณ์ดังกล่าว ในสถานการณ์นี้ สถานะ UI สามารถสร้างโมเดลได้ดังนี้

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

ViewModel จะอัปเดตสถานะ UI ดังนี้เมื่อตรรกะทางธุรกิจกำหนดให้แสดงข้อความชั่วคราวใหม่แก่ผู้ใช้

class LatestNewsViewModel(/* ... */) : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                _uiState.update { currentUiState ->
                    currentUiState.copy(userMessage = "No Internet connection")
                }
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null)
        }
    }
}

ViewModel ไม่จำเป็นต้องทราบวิธีที่ UI แสดงข้อความบนหน้าจอ เพียงแค่ทราบว่ามีข้อความของผู้ใช้ที่ต้องแสดง เมื่อข้อความชั่วคราวแสดงขึ้นแล้ว UI ต้องแจ้งให้ ViewModel ทราบ ซึ่งจะทำให้มีการอัปเดตสถานะ UI อีกครั้งเพื่อล้างพร็อพเพอร์ตี้ userMessage

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

แม้ว่าข้อความจะเป็นข้อความชั่วคราว แต่สถานะ UI ก็แสดงสิ่งที่แสดงบนหน้าจออย่างถูกต้องในทุกช่วงเวลา ข้อความของผู้ใช้จะแสดงหรือไม่แสดง

ส่วนการใช้เหตุการณ์อาจทริกเกอร์การอัปเดตสถานะ จะอธิบายรายละเอียดวิธีใช้สถานะ UI เพื่อแสดงข้อความของผู้ใช้บน หน้าจอ เหตุการณ์การนำทางยังเป็นเหตุการณ์ประเภทที่พบบ่อยในแอป Android

หากเหตุการณ์ทริกเกอร์ใน UI เนื่องจากผู้ใช้แตะปุ่ม UI จะจัดการเรื่องนี้โดยการเรียกใช้ตัวควบคุมการนำทาง

class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        binding.helpButton.setOnClickListener {
            navController.navigate(...) // Open help screen
        }
    }
}

หากการป้อนข้อมูลต้องมีการตรวจสอบตรรกะทางธุรกิจก่อนการนำทาง ViewModel จะต้องเปิดเผยสถานะดังกล่าวต่อ UI UI จะตอบสนองต่อการเปลี่ยนแปลงสถานะนั้นและนำทางตามความเหมาะสม ส่วนจัดการเหตุการณ์ ViewModel จะครอบคลุม กรณีการใช้งานนี้ นี่คือโค้ดที่คล้ายกัน

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

ในตัวอย่างข้างต้น แอปจะทำงานตามที่คาดไว้เนื่องจากระบบจะไม่เก็บปลายทางปัจจุบัน ซึ่งก็คือหน้าเข้าสู่ระบบ ไว้ใน Back Stack ผู้ใช้จะกลับไปยังหน้าดังกล่าวไม่ได้หากกดปุ่มย้อนกลับ อย่างไรก็ตาม ในกรณีที่อาจเกิดขึ้นได้ โซลูชันจะต้องมีตรรกะเพิ่มเติม

เมื่อ ViewModel ตั้งค่าสถานะบางอย่างที่สร้างเหตุการณ์การนำทางจากหน้าจอ A ไปยังหน้าจอ B และระบบเก็บหน้าจอ A ไว้ใน Back Stack ของการนำทาง คุณอาจต้องใช้ตรรกะเพิ่มเติมเพื่อไม่ให้ระบบไปยังหน้าจอ B โดยอัตโนมัติ ในการใช้งานฟังก์ชันนี้ คุณต้องมีสถานะเพิ่มเติมที่ระบุว่า UI ควรพิจารณาการนำทางไปยังหน้าจออื่นหรือไม่ โดยปกติแล้ว สถานะดังกล่าวจะอยู่ใน UI เนื่องจากตรรกะการนำทางเป็นหน้าที่ของ UI ไม่ใช่ ViewModel ลองพิจารณากรณีการใช้งานต่อไปนี้เพื่อแสดงให้เห็น

สมมติว่าคุณอยู่ในโฟลว์การลงทะเบียนของแอป ในหน้าจอการตรวจสอบ วันเกิด เมื่อผู้ใช้ป้อนวันที่ ViewModel จะตรวจสอบวันที่เมื่อผู้ใช้แตะปุ่ม "ดำเนินการต่อ" ViewModel จะมอบหมายตรรกะการตรวจสอบไปยังเลเยอร์ข้อมูล หากวันที่ถูกต้อง ผู้ใช้จะไปยังหน้าจอถัดไป ผู้ใช้สามารถย้อนกลับไปมาระหว่างหน้าจอการลงทะเบียนต่างๆ ได้ในกรณีที่ต้องการเปลี่ยนข้อมูลบางอย่าง ดังนั้น ระบบจะเก็บปลายทางทั้งหมดในโฟลว์การลงทะเบียนไว้ใน Back Stack เดียวกัน เมื่อพิจารณาข้อกำหนดเหล่านี้ คุณสามารถใช้หน้าจอนี้ได้ดังนี้

// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"

class DobValidationFragment : Fragment() {

    private var validationInProgress: Boolean = false
    private val viewModel: DobValidationViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = // ...
        validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false

        binding.continueButton.setOnClickListener {
            viewModel.validateDob()
            validationInProgress = true
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState
                .flowWithLifecycle(viewLifecycleOwner.lifecycle)
                .collect { uiState ->
                    // Update other parts of the UI ...

                    // If the input is valid and the user wants
                    // to navigate, navigate to the next screen
                    // and reset `validationInProgress` flag
                    if (uiState.isDobValid && validationInProgress) {
                        validationInProgress = false
                        navController.navigate(...) // Navigate to next screen
                    }
                }
        }

        return binding
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
    }
}

การตรวจสอบวันเกิดเป็น ตรรกะทางธุรกิจ ที่ ViewModel มีหน้าที่รับผิดชอบ โดยส่วนใหญ่แล้ว ViewModel จะมอบหมายตรรกะดังกล่าวไปยังเลเยอร์ข้อมูล ตรรกะในการนำผู้ใช้ไปยังหน้าจอถัดไปคือ ตรรกะ UI เนื่องจากข้อกำหนดเหล่านี้อาจเปลี่ยนแปลงไปตามการกำหนดค่า UI ตัวอย่างเช่น คุณอาจไม่ต้องการไปยังหน้าจออื่นโดยอัตโนมัติในแท็บเล็ตหากแสดงขั้นตอนการลงทะเบียนหลายขั้นตอนพร้อมกัน ตัวแปร validationInProgress ในโค้ดด้านบนจะใช้ฟังก์ชันนี้และจัดการว่า UI ควรนำทางโดยอัตโนมัติหรือไม่เมื่อใดก็ตามที่วันเกิดถูกต้องและผู้ใช้ต้องการดำเนินการต่อในขั้นตอนการลงทะเบียนถัดไป