掌握 Android ViewModels: 必要的 "应做 "和 "不应做" 第 1 部分

如果您正在使用 ViewModels,请记住以下几点以提高代码质量

在本系列文章中,我们将深入探讨使用 Android ViewModels 的最佳实践,强调提高代码质量的基本注意事项。我们将介绍 ViewModels 在管理 UI 状态和业务逻辑方面的作用、懒惰依赖注入策略以及反应式编程的重要性。此外,我们还将讨论应避免的常见陷阱,如不正确的状态初始化和暴露可变状态,为开发人员提供全面的指南。

了解 VsiewModel

根据 Android 文档,ViewModel 类充当业务逻辑或屏幕级状态的持有者。它将状态暴露给用户界面,并封装相关的业务逻辑。它的主要优点是缓存状态,并通过配置更改将其持久化。这意味着用户界面在活动间导航或配置更改(如旋转屏幕)后无需再次获取数据。

本系列讨论要点

1.避免在 init {} 块中初始化状态。

2.避免暴露可变状态。

3.使用 MutableStateFlows 时使用 update{}:

4.懒得在构造函数中注入依赖关系。

5.多采用反应式编码,少采用命令式编码。

6.避免从外部初始化 ViewModel。

7.避免从外部传递参数。

8.避免对Coroutine Dispatchers进行硬编码。

9.对 ViewModel 进行单元测试。

10.避免暴露悬浮函数

11.充分利用 ViewModels 中的 onCleared() 回调。

12.处理进程死亡和配置更改。

13.注入调用存储库的用例,存储库再调用数据源。

14.在 ViewModels 中只包含域对象。

15.利用 shareIn() 和 stateIn() 操作符,避免多次冲击上游。

这一章我们讨论第1点

1-避免在 init {} 块中初始化状态:

在 Android ViewModel 的 init {} 块中启动数据加载似乎很方便,可以在 ViewModel 创建后立即初始化数据。但是,这种方法有几个缺点,如与 ViewModel 创建紧密耦合、测试难题、灵活性有限、处理配置更改、资源管理和 UI 响应速度。为了减少这些问题,建议使用更谨慎的数据加载方法,利用 LiveData 或其他生命周期感知组件,以尊重 Android 生命周期的方式管理数据。

与创建 ViewModel 紧密耦合:

在 init{} 块中加载数据会将数据获取与 ViewModel 的生命周期紧密联系在一起。这可能会导致难以控制数据加载的时间,尤其是在复杂的用户界面中,您可能希望对根据用户交互或其他事件获取数据的时间进行更精细的控制。

对测试来讲,增加了挑战:

测试变得更加困难,因为一旦 ViewModel 实例化,数据加载就会开始。这样就很难在不触发网络请求或数据库查询的情况下孤立地测试 ViewModel,从而使测试设置变得复杂,并可能导致测试不稳定。

灵活性有限:

在 ViewModel 实例化时自动开始数据加载会限制您处理不同用户流或 UI 状态的灵活性。例如,您可能希望延迟获取数据,直到授予某些用户权限或用户导航到应用程序的特定部分。

处理配置更改:

Android ViewModels 设计用于在配置发生变化(如屏幕旋转)后继续运行。如果数据加载是在 init{} 块中启动的,那么如果不小心管理,配置更改可能会导致意外行为或不必要的数据重新获取。

资源管理:

即时数据加载可能会导致资源使用效率低下,尤其是当用户进入应用程序或屏幕后并不需要立即使用数据时。对于需要消耗大量数据或使用高成本操作来获取或处理这些数据的应用程序来说,这可能尤其成问题。

用户界面响应速度:

在 init{} 块中启动数据加载会影响 UI 响应速度,尤其是在数据加载操作时间较长或阻塞主线程的情况下。一般来说,好的做法是保持 init{} 块的轻量级,将繁重或异步操作卸载到后台线程,或使用 LiveData/Flow 来观察数据变化。

为了减少这些问题,通常建议使用更谨慎的方法进行数据加载,例如根据特定的用户操作或 UI 事件触发数据加载,并利用 LiveData 或其他生命周期感知组件以尊重 Android 生命周期的方式管理数据。这有助于确保您的应用程序保持响应速度,更易于测试,并更有效地利用资源。

让我们来看看这种反模式的一些例子:

示例 #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) }
        }

    }
}

在这个 SearchViewModel 中,数据加载是在 init 代码块中立即触发的,这使得数据获取与 ViewModel 实例化紧密耦合,降低了灵活性。在类内部暴露可变状态 _state,而不处理潜在的错误或不同的 UI 状态(加载、成功、错误),会导致实现不够健壮且难以测试。这种方法削弱了 ViewModel 生命周期意识的优势和懒初始化的效率。

怎么样处理更好呢?
改进 #1:

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())

}

这次重构删除了 ViewModel initblock 中的数据获取,转而依赖集合来启动数据加载。这一改动大大提高了管理数据获取的灵活性,减少了 ViewModel 实例化时不必要的操作,直接解决了过早加载数据的问题,提高了 ViewModel 的响应速度和效率。

示例 #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
    }
}

在 SearchViewModel 的 init 块中启动一个 coroutine 来立即处理数据,会将数据获取与 ViewModel 的生命周期联系得过于紧密,从而可能导致效率低下和生命周期管理问题。这种方法有可能导致不必要的网络调用,并使错误处理复杂化,尤其是在用户界面准备好处理或显示此类信息之前。此外,这种方法假定 UI 更新会隐式返回主线程,但这并不总是安全或高效的,而且这种方法会在 ViewModel 实例化后立即启动数据获取,从而使测试更具挑战性。
我们可以将其重构如下:

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
    }
}

改进后的实现避免了在 init 代码块中直接启动一个 coroutine 来观察 searchQuery 的变化,而是选择了一种反应式设置,在 coroutine 上下文之外将 searchQuery 转换为 LiveData。这消除了与生命周期管理和 coroutine 取消相关的潜在问题,确保数据获取本质上是生命周期感知的,而且更节省资源。由于不依赖 init 块来开始观察和处理用户输入,它还将 ViewModel 的初始化与其数据获取逻辑分离开来,从而实现了更简洁的关注点分离和更易于维护的代码结构。

总结
我们深入探讨了在 init{} 块中启动数据加载会阻碍我们前进的原因,并探索了通过 ViewModels 协调应用程序的 UI 和逻辑的更智能、更精简的方法。在整个过程中,我们讨论了直接的解决方案和基本策略,以避免经常出现的陷阱。

此文章为翻译,如有侵权请联系我及时删除,谢谢。
原文地址:[Mastering Android ViewModels: Essential Dos and Don’ts Part 1 🛠️ | by Reza | ProAndroidDev
]
编辑标签

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容