深入Android ViewModels:Dos和Don’ts
若你正使用ViewModels,务记以下几点以提升代码质量。本系列文章深入探讨了利用Android ViewModels的最佳实践,强调必须遵循和避免的关键点,以提升代码质量。我们将讨论ViewModels在管理UI状态和业务逻辑中的作用,延迟依赖注入的策略以及响应式编程的重要性。此外,我们还会探讨一些需要避免的常见陷阱,如不正确的状态初始化和暴露可变状态,为开发人员提供全面的指导。
理解ViewModels
据Android文档,ViewModel类充当业务逻辑或屏幕级状态的持有者。它将状态暴露给UI,并封装了相关的业务逻辑。其主要优势是缓存状态并通过配置更改进行持久化。这意味着当在活动之间导航或进行配置更改(例如旋转屏幕)时,您的UI不必重新获取数据。
本系列的主要讨论点
- 避免在init {}块中初始化状态。
- 避免暴露可变状态。
- 使用MutableStateFlows时,请使用update{}。
- 在构造函数中延迟注入依赖项。
- 更多地采用响应式而不是命令式编码。
- 避免从外部世界初始化ViewModel。
- 避免从外部世界传递参数。
- 避免硬编码Coroutine Dispatchers。
- 对ViewModel进行单元测试。
- 避免暴露挂起函数。
- 利用ViewModels中的onCleared()回调。
- 处理进程死亡和配置更改。
- 注入UseCases,它们调用Repositories,然后调用DataSources。
- 在ViewModels中只包含领域对象。
- 利用shareIn()和stateIn()操作符,以避免多次触发上游。
1. 避免在init {}块中初始化状态:
在Android ViewModel的init {}
块中启动数据加载可能看起来很方便,因为可以在ViewModel创建时立即初始化数据。然而,这种方法有几个缺点,比如与ViewModel创建的紧密耦合、测试挑战、灵活性有限、处理配置更改、资源管理和UI响应性。为了缓解这些问题,建议使用更谨慎的方法来加载数据,利用LiveData或其他生命周期感知组件以一种符合Android生命周期的方式来管理数据。
Android ViewModel旨在经受住配置变更的考验,比如屏幕旋转。如果在init{}
块中启动数据加载,未经精心处理可能导致意外行为或不必要的数据重取。
资源管理:
即时数据加载可能导致资源使用效率低下,尤其是在用户进入应用程序或屏幕时并不立即需要数据的情况下。对于消耗大量数据或使用昂贵操作获取或处理数据的应用程序,这可能特别棘手。
UI响应性:
在init{}
块中启动数据加载可能会影响UI的响应速度,尤其是如果数据加载操作耗时或阻塞了主线程。通常建议保持init{}块轻量级,并将繁重或异步操作转移到后台线程,或使用LiveData/Flow来观察数据变化。
为了缓解这些问题,通常建议采用更谨慎的方法加载数据,例如在特定用户操作或UI事件的响应中触发加载,并利用LiveData或其他生命周期感知组件以一种尊重Android生命周期的方式管理数据。这可以确保应用程序保持响应性,易于测试,并更有效地利用资源。
让我们看一些反模式的例子:
示例 #1:
//SearchViewModel.kt
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:
//SearchViewModel.kt
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的init块中移除,而是依赖于集合来启动数据加载。这种改变显著提高了在管理数据获取方面的灵活性,并减少了在ViewModel实例化时不必要的操作,直接解决了过早数据加载的问题,增强了ViewModel的响应性和效率。
Example #2:
//SearchViewModel.kt
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块中启动协程进行即时数据处理,过于紧密地将数据获取与ViewModel的生命周期绑定,可能导致效率低下和生命周期管理问题。这种做法存在风险,可能会触发不必要的网络请求,并且在UI准备好处理或显示此类信息之前,会使错误处理变得复杂。此外,它还假设隐含着返回到主线程进行UI更新,这种做法并不总是安全或高效的,并且通过在ViewModel实例化时立即启动数据获取,使得测试变得更加困难。
我们可以对其进行重构,改为:
//SearchViewModel.kt
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块中启动协程来观察searchQuery
的变化,而是选择了一种响应式的方式,在协程的上下文之外将searchQuery
转换为LiveData。这样做消除了与生命周期管理和协程取消相关的潜在问题,确保数据获取是自然地与生命周期相结合,并且更加节省资源。通过不再依赖init块来启动对用户输入的观察和处理,它还将ViewModel的初始化与数据获取逻辑解耦,使代码更加模块化、易于维护。
总之,我们深入探讨了在init{}
块中启动数据加载可能会阻碍进展的原因,并探索了更智能、更精简的方法,通过ViewModels来协调应用程序的UI和逻辑。在整个过程中,我们提出了直接的解决方案和规避常见陷阱的基本策略。
2. 避免暴露可变状态
直接从Android的ViewModels中暴露MutableStateFlow
可能会引入一些与应用架构、数据完整性和代码整体可维护性相关的问题。以下是一些主要关注点:
违反封装原则:
直接暴露MutableStateFlow的主要问题在于它违反了面向对象编程的封装原则。通过暴露可变组件,你允许外部类直接修改状态,这可能导致应用行为不可预测、难以追踪的错误,以及违反ViewModel管理和控制其自身状态的责任。
数据完整性风险:
当外部类可以直接修改状态时,维护数据的完整性变得具有挑战性。ViewModel不能再确保其状态转换是有效的,这可能导致应用中的非法或不一致状态。这会复杂化状态管理,并增加错误的风险。
增加的复杂性:
允许外部ViewModel直接修改状态可能导致代码库更加复杂。跟踪状态变化的发生位置和方式变得更加困难,使得代码库更难理解和维护。这也会使得调试更加困难,因为不太清楚应用程序如何达到特定的状态。
并发问题:
MutableStateFlow是线程安全的,但当应用的多个部分可以同时更新状态时,管理并发变得更加复杂。如果没有仔细协调,可能会出现竞争条件或其他并发问题,导致应用行为不稳定。
测试挑战:
当ViewModel的内部状态可以被外部修改时,测试变得更具挑战性。在测试中更难预测和控制ViewModel的状态,这可能使得测试不够可靠,更加复杂。
架构清晰度:
直接暴露可变状态可能会模糊应用程序架构不同层之间的界限。ViewModel的作用是为UI公开数据并处理逻辑,以供观察和反应,而不是提供可从任何地方更改的可变数据源。这可能会导致关注点分离不够清晰,使得架构更难理解和遵循。
对观察者的控制不足:
当状态可以被外部修改时,如何以及何时通知观察者发生变化变得更加困难。这可能导致不必要的UI更新,或者如果状态在没有正确通知观察者的情况下发生变化,则可能会丢失更新。
下面展示了一个例子,说明了如何将暴露可变状态作为一种不良实践。
//MutableStateExpose.kt
class RatesViewModel constructor(
private val ratesRepository: RatesRepository,
) : ViewModel() {
val state = MutableStateFlow(RatesUiState(isLoading = true))
}
好的,告诉我如何避免暴露可变状态呢?
为了解决这些问题,通常建议将ViewModels中的状态以只读的方式暴露出来,使用StateFlow或LiveData。这种方法保持了封装性,使得ViewModel可以更有效地管理其状态。状态的更改可以通过ViewModel中明确定义的方法进行,这些方法可以根据需要验证和处理更改。这有助于确保数据完整性,简化测试,并保持清晰的架构。
//immutableStateExpose.kt
class RatesViewModel constructor(
private val ratesRepository: RatesRepository,
) : ViewModel() {
private val _state = MutableStateFlow(RatesUiState(isLoading = true))
val state: StateFlow<RatesUiState>
get() = _state.asStateFlow()
}
在上述示例中,我们在ViewModel中有一个内部私有状态,可以在内部更新,然后我们使用asStateFlow()
扩展函数暴露了一个不可变的状态。
3. 使用update{}
来更新MutableStateFlows
-
直接赋值:
mutableStateFlow.value = mutableStateFlow.value.copy()
- 这种方法直接设置MutableStateFlow的值,通过创建当前状态的副本并进行所需的更改。虽然简单直接,适用于简单的状态更新,但不是原子操作🛑🛑🛑,可能导致竞态条件。
-
发射新状态:
mutableStateFlow.emit(newState())
- 使用
.emit()
允许您向MutableStateFlow发送新状态。虽然.emit()
是线程安全的,并且可以用于并发更新,但它是一个挂起函数。这意味着它应该在协程中调用,并且设计用于您可能需要等待状态被消耗的情况。这可能更加灵活,但在同步代码块内或在协程之外使用时会引入复杂性。
- 使用
-
使用
.update{}
:mutableStateFlow.update { it.copy(// 在这里修改状态) }
- 为什么经常使用
.update{}
是首选方法:- 原子性:
.update{}
确保更新操作是原子的,在并发环境中非常重要。这种原子性保证每次更新都是基于最新状态应用的,避免了并发更新之间的冲突。 - 线程安全: 它在内部管理线程安全,因此您无需担心在不同线程之间同步状态更新。
- 简单和安全: 它提供了一种简单而安全的方式来更新状态,而无需显式管理协程,就像用于非同步更新的
.emit()
一样会有额外的开销。
- 原子性:
- 为什么经常使用
综上所述,虽然直接赋值和.emit()
都有其用例,但.update{}
旨在提供一种线程安全、原子的方法来更新MutableStateFlow的值。这使得它成为大多数情况下的选择,其中您需要确保在并发环境中对响应式状态进行一致和安全的更新。
示例用法
假设你有一个MutableStateFlow,它保存了一个名为User的数据类的状态:
data class User(val name: String, val age: Int)
val userStateFlow = MutableStateFlow(User(name = "John", age = 30))
如果你想要更新用户的年龄,你可以这样做:
userStateFlow.update { currentUser ->
currentUser.copy(age = currentUser.age + 1)
}
这段代码会原子性地更新userStateFlow的当前状态,将年龄增加1。lambda表达式中的currentUser代表当前状态。
🛑 确保你使用的是最近版本的协程,才能使用这个扩展函数:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
到目前为止,我们揭示了对于高效的应用程序开发至关重要的高级技术。我们强调了直接从ViewModels中暴露可变状态的缺陷,并讨论了相关的风险。为了解决这些挑战,我们推荐了诸如采用只读状态和利用update{}函数进行更安全的状态更新等解决方案,以确保我们的代码库保持健壮和可维护性。
从我们学到的内容来看,遵循在使用ViewModels时的良好实践是非常重要的。通过避免常见错误和使用正确的技术,我们可以使我们的应用程序更加强大,保持数据安全,并简化测试。所以,让我们记住这些技巧,打造更好的Android应用程序!
4. 在构造函数中延迟注入依赖项
在ViewModel的构造函数中直接注入依赖项而不进行延迟初始化可能会导致以下问题:
- 启动时间增加 ⏱️
- 内存使用增加 📈💾
- 不必要的CPU资源利用 🚫🖥️
延迟初始化推迟了依赖项的创建,直到它们实际被需要,从而优化了应用程序的性能和效率。这种方法特别适用于大型、很少使用或有条件需要的依赖项。根据依赖项的使用情况平衡立即初始化和延迟初始化对于优化应用程序性能至关重要。
在ViewModel构造函数中不使用延迟初始化依赖项可能会影响您的Android应用程序的性能和资源利用率,特别是在启动时或创建这些ViewModel实例时。下面是非延迟注入与延迟注入的比较以及为什么后者在某些情况下通常更受青睐的原因:
何时使用延迟初始化
-
大型或很少使用的依赖项:如果一个ViewModel有大型依赖项或很少访问的依赖项,则延迟初始化是有益的,因为它避免了在实际需要之前初始化这些资源的成本。
-
有条件的依赖项:对于仅在特定条件下需要的依赖项(例如,基于用户操作或特定应用程序状态),延迟初始化可以防止不必要的设置。
案例研究:
@HiltViewModel
class BookViewModel @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val bookmarkUseCase: dagger.Lazy<BookmarkUsecase>,
假设在ViewModel中有一个添加书签的操作,只有当用户点击书签按钮时才会触发,对于通过BookViewModel构造函数注入的这种依赖项,我们可以利用延迟注入,将Usecase的创建推迟到需要时。
延迟注入依赖项可以确保在ViewModel被创建时,依赖项已经就位,从而避免了可能的空指针异常或其他依赖项相关的问题。这可以通过使用依赖注入框架如Dagger Hilt来实现,或者在ViewModel中手动延迟初始化依赖项。
示例代码:
class MyViewModel @ViewModelInject constructor(private val userRepository: UserRepository) : ViewModel() {
// ViewModel代码
}
5. 更多地采用响应式而不是命令式编码
在Android开发中,采用响应式编程可以使代码更加简洁、易于理解和维护。使用LiveData或Flow等响应式组件可以有效地处理数据流,并且更符合Android的生命周期管理。
示例代码:
val userData: LiveData<User> = userRepository.getUserData(userId)
userData.observe(viewLifecycleOwner) { user ->
// 更新UI或执行其他操作
}
6. 避免从外部世界初始化ViewModel
ViewModel应该独立于其外部环境,并且不应该依赖外部环境来初始化。这可以确保ViewModel在不同环境中的可重用性和一致性。
示例代码:
class MyFragment : Fragment() {
private val viewModel: MyViewModel by viewModels()
}
7. 避免从外部世界传递参数
ViewModel应该负责管理自己的数据和状态,而不是依赖外部传入的参数。如果需要,可以使用ViewModel的初始化方法来传递初始参数。
示例代码:
class MyViewModel(private val userId: String) : ViewModel() {
// ViewModel代码
}
8. 避免硬编码Coroutine Dispatchers
在ViewModel中硬编码Coroutine Dispatchers可能会导致代码在不同环境下的可测试性和可维护性降低。建议使用注入的方式来提供Coroutine Dispatchers,以便在测试时能够轻松地替换为不同的调度器。
示例代码:
class MyViewModel(
private val userRepository: UserRepository,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
// ViewModel代码
}
9. 对ViewModel进行单元测试
在编写ViewModel时,确保编写相应的单元测试是非常重要的。这样可以确保ViewModel的逻辑和行为符合预期,并且能够在进行更改时提供保护。使用Mockito、MockK等框架来模拟依赖项,并使用JUnit或Kotlin的测试框架来编写测试用例。
示例代码:
class MyViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var userRepository: UserRepository
private lateinit var viewModel: MyViewModel
@Before
fun setup() {
userRepository = mock()
viewModel = MyViewModel(userRepository)
}
@Test
fun testUserDataLoading() {
// 模拟数据加载行为并验证结果
}
// 其他测试用例
}
10. 避免暴露挂起函数
在ViewModel中尽量避免使用挂起函数,因为它们可能会阻塞主线程,导致应用程序的UI变得不响应。如果必须使用挂起函数,应该在合适的地方进行调度,以确保不会阻塞主线程。
示例代码:
class MyViewModel(private val userRepository: UserRepository) : ViewModel() {
fun loadData() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
userRepository.loadData()
}
// 处理数据加载结果
}
}
}
11. 利用ViewModels中的onCleared()回调
在ViewModel的生命周期结束时,系统会调用onCleared()方法,您可以在这里执行一些清理工作,比如取消任何潜在的长时间运行的任务或释放资源。
示例代码:
class MyViewModel : ViewModel() {
private val job = Job()
override fun onCleared() {
super.onCleared()
job.cancel()
}
}
12. 处理进程死亡和配置更改
当Android应用程序面临进程死亡或配置更改时,ViewModel需要能够正确地处理这些情况,以确保应用程序的状态和数据不会丢失。您可以使用SavedStateHandle来保存和恢复ViewModel的状态。
示例代码:
class MyViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
companion object {
private const val USER_ID_KEY = "user_id"
}
fun setUserId(userId: String) {
savedStateHandle.set(USER_ID_KEY, userId)
}
fun getUserId(): String? {
return savedStateHandle.get(USER_ID_KEY)
}
}
13. 注入UseCases,它们调用Repositories,然后调用DataSources
为了保持代码的可测试性和可维护性,推荐将业务逻辑封装在UseCases中,并将它们注入到ViewModel中。这种方式可以将ViewModel中的业务逻辑与底层数据层解耦,使其更易于测试和修改。
示例代码:
class GetUserUseCase(private val userRepository: UserRepository) {
suspend fun getUser(userId: String): User {
return userRepository.getUser(userId)
}
}
14. 在ViewModels中只包含领域对象
ViewModel应该只包含与UI相关的业务逻辑和数据,并且不应该包含任何与UI无关的逻辑。这样可以确保ViewModel的职责清晰,并且使其更易于理解和维护。
示例代码:
data class User(val name: String, val age: Int)
class MyViewModel : ViewModel() {
private val _user = MutableLiveData<User>()
val user: LiveData<User> = _user
fun updateUser(name: String, age: Int) {
_user.value = User(name, age)
}
}
15. 利用shareIn()和stateIn()操作符,以避免多次触发上游
在处理响应式流时,使用shareIn()和stateIn()操作符可以帮助避免多次触发上游数据源。这些操作符允许您在不同的订阅者之间共享相同的数据流,并确保只有一个实际的数据流被激活。
示例代码:
val userDataFlow: Flow<User> = userRepository.getUserDataFlow(userId)
val sharedFlow = userDataFlow.shareIn(viewModelScope)
// 在一个地方收集数据流,并在多个地方观察
sharedFlow.collect { user ->
// 处理用户数据
}
16. 避免依赖Android资源及Context
尽量避免在ViewModel中引入Android依赖,除了LiveData和其转换器外。这样做有利于关注点分离、提高可测试性和可移植性。LiveData和转换器能够安全地更新UI,同时不与特定的Android UI元素或资源绑定。
结语
以上是Android ViewModels使用的关键点的全部内容。通过遵循这些关键点,您可以更好地利用ViewModels来管理应用程序的UI状态和业务逻辑,从而开发出更加健壮和高效的Android应用程序。