【NowInAndroid架构拆解】(5)VM层的设计和实现之ForYouViewModel

168 阅读7分钟

我就算逆境环绕,我面对也要带着笑。


【NowInAndroid架构拆解】系列文章


从这一篇文章开始,会开始接触业务逻辑以及UI层的实现。在前面分析数据层时,由于 单一可信数据源 原则的存在,数据层必然设计得聚焦统一,所有数据都只有唯一的一个来源。然而在UI层,由于业务逻辑交错复杂的原因,需要考虑的场景数不胜数,这也是十分考验架构设计能力和业务理解水平的地方。

在这篇文章里,先以首页的ForYou模块来起手。

ForYou模块功能的第一眼拆解

整个ForYou模块可以拆分为两块区域:

  • Topic列表区: 用户没有关注任何Topic时,进入APP会显示该区域;当用户在列表中选中任一Topic,下方出现该Topic的News列表,同时Done按钮变为可点击状态;点击Done按钮,Topic列表区收起隐藏
  • News信息流区: 显示用户所选Topic相关联的News列表,当用户未选中任何Topic时,这一块显示空白

状态流部分——StateFlow的划分

状态是指界面上某一块区域的 显示状态(显示、隐藏)加载状态(加载成功、失败、加载中) 以及 数据状态(显示哪些数据) ,通常可以将后两者组装成一个状态,即 加载数据状态

在最新的技术栈里,推荐用StateFlow来表达状态流,作为热流,它的好处是可以 保存最新一次的状态,无论何时进行监听,总能获取到当前最新的状态和数据。而无需重放历史存在过的状态。

Topic列表区:显示状态、加载状态、数据状态

以下代码来自于ForYouViewModel.kt

private val shouldShowOnboarding: Flow<Boolean> =
    userDataRepository.userData.map { !it.shouldHideOnboarding }
    
val onboardingUiState: StateFlow<OnboardingUiState> = // 对外使用StateFlow的热流,便于保存即时状态
    combine( // 组合两个Flow对象
        shouldShowOnboarding,
        getFollowableTopics(),
    ) { shouldShowOnboarding, topics ->
        if (shouldShowOnboarding) {
            OnboardingUiState.Shown(topics = topics)
        } else {
            OnboardingUiState.NotShown
        }
    }
        .stateIn( // stateIn操作转换Flow->StateFlow
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = OnboardingUiState.Loading,
        )

首先,列表区会根据用户是否有关注的Topic,决定是否进行显示该区域。因此需要管理显示状态shouldShowOnBoarding表示显示状态来自于用户数据userData,通过map操作对Flow进行转换。

其次,列表区的数据是要经过网络/数据库io加载的,必须要表示加载中以及成功、失败的状态。对于成功的场景,需要返回Topic列表,其中的每个Topic还要包含关注状态信息。因此需要管理加载数据状态

以上状态通过combile进行组装,并借助stateIn转换为可供观测的热流onboardingUiState,在ViewModel层对此进行监听。

News信息流区:加载状态、数据状态

val feedState: StateFlow<NewsFeedUiState> =
    userNewsResourceRepository.observeAllForFollowedTopics() // 返回UserNewsResource列表
        .map(NewsFeedUiState::Success) // 包装成Success对象
        .stateIn( // Flow->StateFlow
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = NewsFeedUiState.Loading,
        )

信息流区同样需要根据Topic的关注情况,实时发生变化。同时它也存在加载过程,因此StateFlow应当包含加载状态的信息,以及具体的News列表数据。此外,News中的每一条会存在“是否收藏”的状态。

isSyncing以及stateIn函数的参数含义

val isSyncing = syncManager.isSyncing
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = false,
    )

ForYouViewModel.kt文件中,还有另外一个StateFLow对象,它是表示是否在通过WorkManager进行数据同步的isSyncing

stateIn函数有三个参数,其含义如下:

  • scope: 该Flow对应协程所处的Scope,当Scope终结时,自动停止发送,这里使用的是viewModelScope
  • started: Flow的发送策略,常见的有以下三种:
    • Eagerly: 一经创建就立刻发送,且协程永不停止
    • Lazily: 只有当首个订阅者出现时才开始发送,且协程永不停止
    • WhileSubscribed: 只有当首个订阅者出现时才开始发送,可自定义协程停止时间
  • initialValue: 发送初始值

在本项目中,大多使用了started = SharingStarted.WhileSubscribed(5_000)作为流发射策略,通过设置5s延时,能够减少因短暂取消订阅导致的重复数据加载,例如当 UI 层(如 Activity/Fragment)因配置变更(如屏幕旋转)或短暂跳转其他界面时,不会因终止订阅而释放资源,而是会继续维持5s,等待用户返回该界面。5 秒‌是一个经验值,在多数场景下能平衡用户体验(避免数据重载)和资源消耗。

控制流部分——交互操作的划分

看完了数据流,接下来看控制流部分。控制流通常以函数的方式呈现,每一个用户操作都表示一次控制流的传递过程,例如选中感兴趣的Topic、点击收藏News的按钮、下拉刷新页面等等。

回到ForYou这个页面,根据其交互,可以推测控制流有以下几种:

  • 选中/反选Topic
  • 点击Done按钮
  • 收藏News
  • 点击News跳转详情页

结合代码查看,确实是对应这几种操作提供了对应的函数。

// 选中/反选Topic
fun updateTopicSelection(topicId: String, isChecked: Boolean) {
    viewModelScope.launch {
        userDataRepository.setTopicIdFollowed(topicId, isChecked)
    }
}

// 收藏News
fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) {
    viewModelScope.launch {
        userDataRepository.setNewsResourceBookmarked(newsResourceId, isChecked)
    }
}

// 标记已阅读News
fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
    viewModelScope.launch {
        userDataRepository.setNewsResourceViewed(newsResourceId, viewed)
    }
}

// 点击News跳转详情页
fun onDeepLinkOpened(newsResourceId: String) {
    if (newsResourceId == deepLinkedNewsResource.value?.id) {
        savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = null
    }
    analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId)
    viewModelScope.launch {
        userDataRepository.setNewsResourceViewed(
            newsResourceId = newsResourceId,
            viewed = true,
        )
    }
}

// 隐藏Topic区域(点击Done按钮)
fun dismissOnboarding() {
    viewModelScope.launch {
        userDataRepository.setShouldHideOnboarding(true)
    }
}

这些函数的实现都很薄,除了埋点统计以外,并不会包含过多的业务逻辑,大部分是直接调用repository对相应数据进行更新。

先更新UI后更新Data V.S. 先更新Data后更新UI

以收藏文章为例,在ForYou页面的News信息流里,用户可以点击“书签”按钮,对文章进行收藏,收藏的文章id以持久化的方式保存在数据库/云端。

由于持久化属于耗时操作,会依据设备IO状态而出现耗时乃至失败的情况,因此需要在保证持久化操作完成后再更新UI。

在传统的MVC、MVP架构里,通常会把UI变化的逻辑写在网络请求的onSuccess回调中,这样做的优点是一目了然简单清晰,而缺点也不容忽视——将UI与Data耦合在了一起,一旦界面或者数据结构发生变化,都要面临大量的修改。

对此,ForYou页面在底层通过DataStore实现双向自动通信,当用户点击“收藏”后,会逐级调用到userPreferences.updateData(),对数据进行持久化更新。

// OfflineFirstUserDataRepository.kt
override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {
    niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked)
    analyticsHelper.logNewsResourceBookmarkToggled(
        newsResourceId = newsResourceId,
        isBookmarked = bookmarked,
    )
}

// NiaPreferenceDataSource.kt
suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {
    try {
        userPreferences.updateData {
            it.copy {
                if (bookmarked) {
                    bookmarkedNewsResourceIds.put(newsResourceId, true)
                } else {
                    bookmarkedNewsResourceIds.remove(newsResourceId)
                }
            }
        }
    } catch (ioException: IOException) {
        Log.e("NiaPreferences", "Failed to update user preferences", ioException)
    }
}

// InMemoryDataStore.kt
class InMemoryDataStore<T>(initialValue: T) : DataStore<T> {
    override val data = MutableStateFlow(initialValue) // 注意这个data,是一个可观察的Flow,可以订阅它的变化。
    override suspend fun updateData(
        transform: suspend (it: T) -> T,
    ) = data.updateAndGet { transform(it) }
}

总结

以上就是ForYou页面的ViewModel实现,数据流、控制流的划分是十分清晰的,不同层次之间也进行了解耦,便于未来进行替换和扩展。其他页面的实现思路大体上并无差别。下一期我计划分析这个项目的路由跳转实现。