+2

Mastering Android ViewModels : Những điều nên và không nên - Part 1

Trong loạt bài viết này, mình sẽ đi sâu vào các phương pháp hay nhất để sử dụng Android ViewModels, nhấn mạnh những điều nên làm và không nên làm để nâng cao chất lượng code. Chúng ta sẽ đề cập đến vai trò của ViewModels trong việc quản lý UI state và logic nghiệp vụ, các chiến lược lazy dependency injection và tầm quan trọng của reactive programming. Ngoài ra, cùng thảo luận về những cạm bẫy phổ biến cần tránh, chẳng hạn như khởi tạo trạng thái không đúng cách và để lộ các trạng thái có thể thay đổi, ....

image.png

Hiểu về ViewModels

Theo tài liệu của Android, lớp ViewModel hoạt động như một logic nghiệp vụ hoặc chủ sở hữu trạng thái screen-level. Nó hiển thị trạng thái cho giao diện người dùng và đóng gói logic nghiệp vụ liên quan. Ưu điểm chính của nó là lưu trữ trạng thái vào bộ nhớ đệm và duy trì trạng thái đó thông qua các thay đổi cấu hình. Điều này có nghĩa là giao diện người dùng của bạn không phải fetch lại data khi điều hướng giữa các activities hoặc thực hiện các thay đổi về cấu hình, chẳng hạn như khi xoay màn hình.

Các điểm thảo luận chính cho loạt bài này

  1. Tránh khởi tạo trạng thái trong khối init {}.
  2. Tránh để lộ các mutable states.
  3. Sử dụng update{} khi sử dụng MutableStateFlows:
  4. Lazily inject dependencies trong constructor.
  5. Sử dụng code reactive hơn và ít ràng buộc hơn.
  6. Tránh khởi tạo ViewModel từ bên ngoài.
  7. Tránh truyền tham số từ bên ngoài.
  8. Tránh hardcode Coroutine Dispatchers.
  9. Unit test ViewModels.
  10. Tránh để lộ suspended functions.
  11. Tận dụng onCleared() callback trong ViewModels.
  12. Xử lý sự cố của quá trình và thay đổi cấu hình.
  13. Inject các UseCase trong Repositories, sau đó gọi các DataSource .
  14. Chỉ include domain objects trong ViewModels.
  15. Tận dụng các toán tử shareIn() và stateIn() để tránh phải upstream nhiều lần.

Cùng bắt đầu với item đầu tiền trong list trên

1. Tránh khởi tạo trạng thái trong khối init {}.

Việc bắt đầu tải dữ liệu trong khối init {} của Android ViewModel có vẻ thuận tiện cho việc khởi tạo dữ liệu ngay khi ViewModel được tạo. Tuy nhiên, cách tiếp cận này có một số nhược điểm, chẳng hạn như kết hợp chặt chẽ (tight coupling) với việc tạo ViewModel, khó khăn cho việc testing , tính linh hoạt bị hạn chế, xử lý các thay đổi cấu hình, quản lý tài nguyên và khả năng phản hồi của giao diện người dùng. Để giảm thiểu những vấn đề này, bạn nên sử dụng phương pháp tải dữ liệu có chủ ý hơn, tận dụng LiveData hoặc các thành phần nhận biết vòng đời khác để quản lý dữ liệu theo cách tôn trọng vòng đời của Android.

Tight Coupling với quá trình khởi tạo ViewModel:

Việc loading data trong khối init{} kết hợp việc fetch data có sự kết hợp chặt chẽ với vòng đời của ViewModel. Điều này có thể dẫn đến khó khăn trong việc kiểm soát thời gian tải dữ liệu, đặc biệt là trong các giao diện người dùng phức tạp nơi bạn có thể muốn kiểm soát chi tiết hơn khi dữ liệu được fetch dựa trên tương tác của người dùng hoặc các sự kiện khác.

Khó khăn trong Testing:

Testing trở nên khó khăn hơn vì quá trình tải dữ liệu bắt đầu ngay khi ViewModel được khởi tạo. Điều này có thể gây khó khăn cho việc test ViewModel một cách riêng biệt mà không kích hoạt các yêu cầu mạng hoặc truy vấn cơ sở dữ liệu, làm phức tạp quá trình thiết lập kiểm tra và có khả năng dẫn đến các kiểm thử không ổn định.

Tính linh hoạt hạn chế::

Việc tự động bắt đầu tải dữ liệu khi khởi tạo ViewModel hạn chế tính linh hoạt của bạn trong việc xử lý các luồng người dùng hoặc trạng thái giao diện người dùng khác nhau. Ví dụ: bạn có thể muốn trì hoãn việc tìm nạp dữ liệu cho đến khi cấp một số quyền nhất định của người dùng hoặc cho đến khi người dùng điều hướng đến một phần cụ thể trong ứng dụng của bạn.

Xử lý các thay đổi cấu hình:

Android ViewModels được thiết kế để tồn tại khi thay đổi cấu hình, chẳng hạn như xoay màn hình. Nếu quá trình tải dữ liệu được bắt đầu trong khối init{}, thì thay đổi cấu hình có thể dẫn đến hành vi không mong muốn hoặc việc tìm nạp lại dữ liệu không cần thiết nếu không được quản lý cẩn thận.

Quản lý tài nguyên:

Tải dữ liệu tức thì có thể dẫn đến việc sử dụng tài nguyên không hiệu quả, đặc biệt nếu người dùng không cần dữ liệu ngay khi vào ứng dụng hoặc màn hình. Điều này có thể đặc biệt khó giải quyết đối với các ứng dụng tiêu thụ một lượng dữ liệu đáng kể hoặc sử dụng các hoạt động tốn kém để tìm nạp hoặc xử lý dữ liệu này.

UI Responsiveness:

Việc bắt đầu tải dữ liệu trong khối init{} có thể ảnh hưởng đến khả năng phản hồi của giao diện người dùng, đặc biệt nếu thao tác tải dữ liệu kéo dài hoặc chặn luồng chính. Nói chung, cách tốt nhất là giữ cho khối init{} nhẹ và giảm tải các hoạt động nặng hoặc không đồng bộ xuống luồng nền hoặc sử dụng LiveData/Flow để quan sát các thay đổi của dữ liệu.

Để giảm thiểu những vấn đề này, bạn nên sử dụng cách tiếp cận có chủ ý hơn để tải dữ liệu, chẳng hạn như trigger nó để phản hồi các hành động cụ thể của người dùng hoặc UI event và tận dụng LiveData hoặc các thành phần nhận biết vòng đời khác để quản lý dữ liệu theo cách tôn trọng Android vòng đời. Điều này có thể giúp đảm bảo rằng ứng dụng của bạn vẫn phản hồi nhanh, dễ kiểm tra hơn và sử dụng tài nguyên hiệu quả hơn.

Hãy cùng khám phá một số ví dụ về anti-pattern này:

Ví dụ 1

class SearchViewModel @Inject constructor(
    private val searchUseCase: dagger.Lazy<SearchUseCase>,
    private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {

    data class UiState(
        val isLoading: Boolean,
        val words: List<String> = emptyList()
    )
    
    init {
        getWords()
    }

    val _state = MutableStateFlow(UiState(isLoading = true))
    val state: StateFlow<UiState>
        get() = _state.asStateFlow()

    private fun getWords() {
        viewModelScope.launch {
            _state.update { UiState(isLoading = true) }
            val words = wordsUseCase.invoke()
            _state.update { UiState(isLoading = false, words = words) }
        }

    }
}

Trong SearchViewModel này, việc tải dữ liệu được kích hoạt ngay trong khối init, kết hợp chặt chẽ việc tìm nạp dữ liệu với quá trình khởi tạo ViewModel và làm giảm tính linh hoạt. Việc hiển thị trạng thái state có thể thay đổi bên trong lớp và không xử lý các lỗi tiềm ẩn hoặc các trạng thái giao diện người dùng khác nhau (loading, success, error) có thể dẫn đến việc triển khai kém mạnh mẽ hơn và khó kiểm tra hơn. Cách tiếp cận này làm suy yếu lợi ích của nhận thức về vòng đời của ViewModel và hiệu quả của việc lazy initialization.

Làm thế nào chúng ta có thể code n ok hơn?

class SearchViewModel @Inject constructor(
    private val searchUseCase: dagger.Lazy<SearchUseCase>,
    private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {


    data class UiState(
        val isLoading: Boolean = true,
        val words: List<String> = emptyList()
    )
    
    val state: StateFlow<UiState> = flow { 
        emit(UiState(isLoading = true))
        val words = wordsUseCase.invoke()
        emit(UiState(isLoading = false, words = words))
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState())

}

Việc refactor sẽ loại bỏ việc tìm nạp dữ liệu khỏi init block của ViewModel, thay vào đó dựa vào việc thu thập để bắt đầu tải dữ liệu. Thay đổi này cải thiện đáng kể tính linh hoạt trong việc quản lý việc tìm nạp dữ liệu và giảm các hoạt động không cần thiết khi khởi tạo ViewModel, giải quyết trực tiếp các vấn đề tải dữ liệu sớm và nâng cao khả năng phản hồi cũng như hiệu quả của ViewModel.

Ví dụ 2

class SearchViewModel @Inject constructor(
        private val searchUseCase: SearchUseCase,
        @IoDispatcher val ioDispatcher: CoroutineDispatcher
) : ViewModel() {

    private val searchQuery = MutableStateFlow("")

    private val _uiState = MutableLiveData<SearchUiState>()
    val uiState = _uiState

    init {
        viewModelScope.launch {
            searchQuery.debounce(DEBOUNCE_TIME_IN_MILLIS)
                    .collectLatest { query ->
                        Timber.d("collectLatest(), query:[%s]", query)
                        if (query.isEmpty()) {
                            _uiState.value = SearchUiState.Idle
                            return@collectLatest
                        }
                        try {
                            _uiState.value = SearchUiState.Loading
                            val photos = withContext(ioDispatcher){
                                searchUseCase.invoke(query)
                            }
                            if (photos.isEmpty()) {
                                _uiState.value = SearchUiState.EmptyResult
                            } else {
                                _uiState.value = SearchUiState.Success(photos)
                            }
                        } catch (e: Exception) {
                            _uiState.value = SearchUiState.Error(e)
                        }
                    }
        }
    }

    fun onQueryChanged(query: String?) {
        query ?: return
        searchQuery.value = query
    }

    sealed class SearchUiState {
        object Loading : SearchUiState()
        object Idle : SearchUiState()
        data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
        object EmptyResult : SearchUiState()
        data class Error(val exception: Throwable) : SearchUiState()
    }

    companion object {
        private const val DEBOUNCE_TIME_IN_MILLIS = 300L
    }
}

Việc khởi chạy một coroutine trong khối init của SearchViewModel để xử lý dữ liệu ngay lập tức sẽ liên kết việc tìm nạp dữ liệu quá chặt chẽ với vòng đời của ViewModel, có khả năng dẫn đến sự thiếu hiệu quả và các vấn đề về quản lý vòng đời. Cách tiếp cận này có nguy cơ gây ra các network call không cần thiết và làm phức tạp việc xử lý lỗi, đặc biệt là trước khi UI sẵn sàng xử lý hoặc hiển thị thông tin đó. Hơn nữa, nó giả định sự quay trở lại luồng chính để cập nhật UI, điều này có thể không phải lúc nào cũng an toàn hoặc hiệu quả và khiến việc kiểm tra trở nên khó khăn hơn bằng cách bắt đầu tìm nạp dữ liệu ngay khi khởi tạo ViewModel.

Refactor lại đoạn code trên nhé :

class SearchViewModel @Inject constructor(
    private val searchUseCase: dagger.Lazy<SearchUseCase>,
) : ViewModel() {

    private val searchQuery = MutableStateFlow("")

    val uiState: LiveData<SearchUiState> = searchQuery
        .debounce(DEBOUNCE_TIME_IN_MILLIS)
        .asLiveData()
        .switchMap(::createUiState)


    private fun createUiState(query: @JvmSuppressWildcards String) = liveData {
        Timber.d("collectLatest(), query:[%s]", query)
        if (query.isEmpty()) {
            emit(SearchUiState.Idle)
            return@liveData
        }
        try {
            emit(SearchUiState.Loading)
            val photos = searchUseCase.get().invoke(query)
            if (photos.isEmpty()) {
                emit(SearchUiState.EmptyResult)
            } else {
                emit(SearchUiState.Success(photos))
            }
        } catch (e: Exception) {
            emit(SearchUiState.Error(e))
        }
    }

    fun onQueryChanged(query: String?) {
        query ?: return
        searchQuery.value = query
    }

    sealed class SearchUiState {
        data object Loading : SearchUiState()
        data object Idle : SearchUiState()
        data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
        data object EmptyResult : SearchUiState()
        data class Error(val exception: Throwable) : SearchUiState()
    }

    companion object {
        private const val DEBOUNCE_TIME_IN_MILLIS = 300L
    }
}

Việc triển khai sửa đổi tránh việc khởi chạy coroutine trực tiếp trong khối init để quan sát các thay đổi của searchQuery, thay vào đó chọn thiết lập để chuyển searchQuery thành LiveData bên ngoài coroutine context. Điều này giúp loại bỏ các vấn đề tiềm ẩn liên quan đến quản lý vòng đời và hủy coroutine, đảm bảo rằng việc tìm nạp dữ liệu vốn có tính năng nhận biết vòng đời và tiết kiệm tài nguyên hơn. Bằng cách không dựa vào khối init để bắt đầu quan sát và xử lý thông tin đầu vào của người dùng, nó cũng tách quá trình khởi tạo của ViewModel khỏi logic tìm nạp dữ liệu của nó, dẫn đến sự phân tách rõ ràng hơn các mối quan tâm và cấu trúc mã dễ bảo trì hơn.

Túm cái váy lại thì :

Chúng ta đã đi sâu vào lý do tại sao việc bắt đầu tải dữ liệu trong khối init{} có thể cản trở tiến trình và đã khám phá các phương pháp ok hơn, hợp lý hơn để điều phối logic và giao diện người dùng của ứng dụng thông qua ViewModels. Xuyên suốt bài viết, chúng ta đã thảo luận về các giải pháp đơn giản và các chiến thuật cần thiết để tránh những cạm bẫy thường gặp.

Bài viết hôm nay đến đây là kết thúc hẹn gặp mọi người ở bài viết tiếp theo nhé

Nguồn : https://proandroiddev.com/mastering-android-viewmodels-essential-dos-and-donts-part-1-️-bdf05287bca9


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.