我就算逆境环绕,我面对也要带着笑。
【NowInAndroid架构拆解】系列文章
- 【NowInAndroid架构拆解】(1)分层设计与模块化
- 【NowInAndroid架构拆解】(2)数据层的设计和实现之model与database
- 【NowInAndroid架构拆解】(3)数据层的设计和实现之network
- 【NowInAndroid架构拆解】(4)数据层的设计和实现之data
- 【NowInAndroid架构拆解】(5)VM层的设计和实现之ForYouViewModel
- 【NowInAndroid架构拆解】(6)View层的设计和实现之Navigation路由
- 【NowInAndroid架构拆解】(7)UI层解析——MainActivity构建过程
- 【NowInAndroid架构拆解】(8)UI层解析——ForYou页面展示
- 【NowInAndroid架构拆解】(9)重新审视NowInAndroid架构设计
- 【NowInAndroid架构拆解】番外篇1之Jetpack Compose Navigation
- 【NowInAndroid架构拆解】番外篇2之Bottom Navigation底部导航
- 【NowInAndroid架构拆解】番外篇3之给xml布局者最佳的Jetpack Compose介绍文章
从这一篇文章开始,会开始接触业务逻辑以及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实现,数据流、控制流的划分是十分清晰的,不同层次之间也进行了解耦,便于未来进行替换和扩展。其他页面的实现思路大体上并无差别。下一期我计划分析这个项目的路由跳转实现。