前言
从MVC,MVP,MVVM再到MVI的演变,我们一直着重的是业务层次的分明,归根结底,就是在合适的地方做合适的事情。
其实归纳演进,我们可以发现,所有的架构做的都是同一件事,就是让数据流的流向更加规范。
Android官方架构设计给出了view层,视图层,数据层,网络层,还有repo层。数据从上往下传递。确保数据的单项流动。这里有几点特别重要。
- 控制层级内业务的边界线,确保功能的单一性
永远不要设计一个万能的类,尽管在一个类里写代码非常得爽,但是不相信科学会付出代价。也不能让同一个功能层级的类互相引用,不然设计业务类的边界线非常痛苦,并且这和业务变化强相关。明明加上一行代码就可以解决的事情,最后却创建了一个新的类来,而且还要说服leader加上这么多代码的必要性。但是,请相信,有些准则是无数人踩坑踩出来了,业务的界限一旦被打破,代码就会迅速恶化。
- 控制类的大小
我们从MVC走过来,本质就是不想要一个巨大Controller。所有拆分抽象和32种设计模式,就是希望实现业务功能的单一性。你不会对一个1000行代码的功能类有阅读的欲望,如果看到,建议立刻拆分。
- 尽量少用继承
继承是用来定义约束的,而不是提供能力的。我们应当使用组合类的方式,在类与类之间提供业务能力。 在前文中以及最近官方github中给出的项目代码,通过hilt实现了的项目,可以看到类与类之间大量的组合设计。在实际的开发中,尽量避免层层嵌套的继承,这个会大大减少理解和业务开发的成本。
接下来,本文将介绍如何设计业务的整体结构,以及通过flow为其提供单项数据流。
结构
古早的开发者经常在需要时,去进行数据获取,然后根据这些数据来构建页面。同时同样的逻辑也经常反着来。因此,合理的演变就是有一套完整的基建来达成数据的流动。即是响应式编程。响应式编程基于数据流(Data Stream)和变化传播(Change Propagation),允许代码对数据的变化自动做出响应。我们就可以不再请求数据而改为观察数据。同样数据就像水流一样,可以从数据源流动到业务层。
显而易见,确保数据只在一个方向上流动,可以保证代码简单并且更不容易出错。
ViewModel
我们通常将ViewModel作为业务的具体承载点。尤其是在加入了MVI和flow的设计思想之后,ViewModel更加体现出高内聚,低耦合的特点。
@HiltViewModel
class MainViewModel @Inject constructor(
val userDataRepository: UserDataRepository,
) : ViewModel() {
val userId: Flow<String> = userDataRepository.userData,
val uiState: StateFlow<MainUiState> = combine(
userId,
).map {
Content(it)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MainUiState.Empty,
)
fun refreshId(userId: String) {
viewModelScope.launch {
userDataRepository.refresh(userId)
}
}
}
sealed interface MainUiState {
data object Loading : MainUiState
data class Content(
val userId: String?,
) : MainUiState
data object Empty : MainUiState
}
这是一个简单的ViewModel的使用示例。我们通过Repo中获取到数据流,对数据进行流转处理,处理为State的热流对外进行提供。MainUiState是一个封闭接口,它的使用,确保所有的类都必须在同一文件中声明,从而限制了继承结构。在后续开发中,开发者可以根据State进行开发,确保所有的业务场景都被覆盖,避免了未处理的状态分支的情况。
refreshId方法,封装了repo的方法调用,隔离了数据处理和业务处理之间的操作。
通过flow和封闭类,可以比较直观的凸显出MVI的特点。在MVI中,ViewModel更偏向于保存状态(State),而非保存数据。在View和Model的交互中,MVI也更偏向于使用event进行交互。
MVI中的intent指的是意图,即应用程序状态改变的未来操作。View获取到用户的意图,并将它传输到ViewModel。
MVI有两个关键点:
- 确保了数据流动的单向性。
从DataStore到View,从下至上的业务结构中,我们可以看到,State从下往上流动,Intent从上往下流动,这确保了流动的单向性,避免出现了多个数据源的可能性
- 数据的不变性
将数据映射State和Intent,可以避免数据的直接变动,增强了业务的健壮性。 当然,正如水无常势。我们对于MVI的理解也应当更加的宽泛。将View和Model的隔离做的更加彻底,而不是仅仅限制于State和Intent的固定模式中。
Repo
Repo是Android官方架构的一层,它的意义在于对于数据的抽象。数据源可以来自DataStore,Room和网络请求,因此我们需要在RoomDataSource或者NetDataSource之上,插入一层Repo,将数据源的优先级和缓存逻辑放在Repo中,屏蔽具体的数据来源。
interface UserDataRepository {
val userData: Flow<UserData>
suspend fun setUSerIds(id: Set<String>)
suspend fun data(UserData: data)
...........
}
Repo通过对外暴露flow,提供数据展示。
class LocalUserDataRepository @Inject constructor(
private val dataSource: DataStoreDataStore,
) : UserDataRepository {
override val userData: Flow<UserData> =
dataSource.userData
}
具体的数据流由dataSource提供。Repo这一层本身作为数据源和ViewModel的桥梁,用于封装数据的获取和处理的业务操作,但是大部分情况下,我们的业务数据都是只来源于网络请求,因此省略repo这一层,也可以减少业务的架构层级,在实际开发中也很有价值。
DataSource
我比较推荐将数据处理的操作都放在这一层。例如如果是Room的数据存储,则将数据库的CRUD,数据库的封装都在这里处理。如果是网络请求,则对网络缓存,网络请求启动和取消,数据解析,请求串行还是并行,也都可以在这里处理,并且可以拆分CommonDataSource,方便其他业务使用。我们更加希望,DataSource向外提供的是data的flow,以及对数据的封装能力。换而言之,DataSource不应该有具体的业务功能,也不应该提供实际业务数据的实体。
数据流
业务的生命周期,UI和数据处理一直是开发时的老大难问题。在早期开发中,我们通常将一个View关联若干个ViewModel,然后这若干个ViewModel分别暴露若干个LiveData。视图会通过数据绑定或者手动订阅的方式来观察这些 LiveData。
但是DataSource是数据加载的最小单元,那么LiveData是数据缓存的最小单元。它的作用是数据缓存,是面向UI的状态容器,而不是数据流。基于这个思想,LiveData面向数据处理的扩展能力会差一些,并且只在UI线程进行操作。
对于数据流的开发方案,我们在实际开发中更加偏向于使用flow,这并不仅仅是因为它的操作符更多,更重要的是它完美支持结构性并发的能力。
需要说明的一点是,flow并不是liveData的进化版,他们只是使用场景不同,在实际原生开发,以及Compose开发中,两者也可进行asLiveData的方式互相转换。
SateFlow和ShardFlow
下面来看个例子
val resultUiState: StateFlow<ResultUiState> =
contentsRepository.getContent()
.flatMapLatest { totalCount ->
query.flatMapLatest { query ->
getQuery(query)
.map<UserResult, ResultUiState> { data ->
ResultUiState.Success(
data = data.data,
)
}
.catch { emit(SearchResultUiState.LoadFailed) }
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ResultUiState.Loading,
)
上面是一段StateFlow处理复杂数据流模式。Repo提供Data的冷流,将repo提供的count flow和query提供的query flow进行结合处理生成state flow,并通过stateIn将冷流转化为热流,使得多个业务方共享一个数据流,资源更加的高效。
StateFlow是状态流,它始终保持的最新的状态,即你可以通过state.value获取到当前flow最新的数据值。StateFlow会对内部的数据进行判重,因此如果期望通过reset value来重新通知监听者的情况下,必须对data的equal进行重写。
SharedFlow和StateFlow不同,它本身没有状态,因此它的初始化并不需要initValue。很多同事使用SharedFlow的目的仅仅是为了避免重发数据。其实相比之下,StateFlow的核心在于状态,ShareFlow在于数据广播和共享。因此StateFlow更加偏向于业务和UI处理,而ShareFlow则更加适用于事件传递。在实际开发中,应当基于两者的侧重点选择使用。
生命周期
正如我们上文所说的,flow的出现一个非常重要的目的就是管理数据和业务操作的生命周期和业务范围,从而界定业务之间的边界线。往简单的说,从当前页面离开时,当前页面所有的网络请求和业务处理都应当取消,从而避免不必要的资源浪费。往复杂的说,从一个业务周期到另一个业务周期时,在上一个业务周期内所有操作和数据都将过期,数据应该更新,即使从页面上看,没有任何变化。这种操作,对于大型项目和团队合作具有非常重要的意义。
协程
flow的使用无法离开协程上下文,它直接确定了数据流的起始。下面我们简单介绍几个常用的协程上下文。 最简单的GlobalScope创建协程。
GlobalScope.launch{
......
}
GlobalScope属于顶级scope,它的生命周期覆盖全局。在实际开发中,没有任何业务场景一定要用到GlobalScope的情况,这极容易出现内存泄漏的问题,使用GlobalScope不如使用单例。
如果需要临时使用一次协程,我们可以使用suspendCancellableCoroutine。
suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) {
if (response.body != null) {
response.closeQuietly()
}
}
}
override fun onFailure(call: Call, e: IOException) {
continuation.resumeWithException(e)
}
},
)
continuation.invokeOnCancellation {
try {
cancel()
} catch (t: Throwable) {
}
}
}
suspendCancellableCoroutine提供invokeOnCancellation方法,方便我们在cancel时释放资源。 此外,协程也为我们提供了将异步回调转化为flow的操作:callbackFlow
currentTimeZone: SharedFlow<TimeZone> = callbackFlow {
trySend(TimeZone.currentSystemDefault())
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(TimeZone.currentSystemDefault())
}
}
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
trySend(TimeZone.currentSystemDefault())
awaitClose {
context.unregisterReceiver(receiver)
}
}.distinctUntilChanged()
.conflate()
.flowOn(ioDispatcher)
.shareIn(appScope, SharingStarted.WhileSubscribed(5_000), 1)
这个在实际业务中非常有用,对于解决异步回调callback的情况,简直是量身定做。
在实际开发中,我们往往更多地将协程和activity,fragment或者ViewModel绑定。
lifecycleScope.launch {
val data = fetchData()
updateUI(data)
}
我们不用额外关注协程的生命周期,这个系统自动会为我们处理。
自定义生命周期
在复杂的业务场景中,只有一次性scope和已有的页面scope是远远不够的。更重要的是,在一个团队中,我们并不想开发者花费过多的时间在考虑scope上,而不是将精力注重于业务的具体实现上。因此,我们更应该按照业务节点来定义业务scope。
Job
job = viewModelScope.launch {
// 长时间运行的任务
}
///某个节点
job.cancel()
通过创建协程返回的job,我们可以精细化控制当前任务结束。这在实际开发时作用很大,非常适用于那种用完就扔的场景。举个例子,当我们在弹框中,需要展示各种提示气泡,而当弹框过了5秒后,这些气泡将完全消失。
我们单独为这个特定业务场景定义一个scope是不值得的,因此我们更偏向于保存协程创建返回的Job,在5秒后,直接调用cancel方法,从而实现目的。
业务Scope
大多数情况下,团队应当为各个重要的业务节点设置Scope。
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ReviewScope
@Module
@InstallIn(SingletonComponent::class)
internal object CoroutineScopesModule {
@Provides
@Singleton
@ReviewScope
fun providesCoroutineScope(
@Dispatcher(NiaDispatchers.Default) dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
}
ReviewScope可以是作为一篇文章的查看周期,从文章阅读起始创建,在切到下一片文章时canel。业务开发同学无需理解这个ReviewScope的实际创建原理,而是只要知道ReviewScope的实际生效周期就可以将当前的业务操作限定在单篇文章的查看生命周期内。
LifecycleRegistry
而更好的一个设计方案是,我们应当为一个业务单独设计一个lifeCycle,并将其与Scope连接在一起。 依旧以上文中的文章阅读业务为例。
class ArticleLifecycle() : LifecycleOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
fun getLifecycle(): Lifecycle = lifecycleRegistry
val scope = lifecycleRegistry.coroutineScope
// 初始化方法:启动生命周期并创建协程 Scope
fun init() {
lifecycleRegistry.currentState = Lifecycle.State.INITIALIZED
}
fun onCreate(){
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
fun onStart(){
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
}
fun onStop(){
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
}
fun destroy() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
}
通常查看文章的业务,是通过RecycleView或者flip实现的,因此start和stop,destroy的时机也是在对应的方法中,比如RecycleView的bind方法中。我们通过调用ArticleLifecycle的onStart等生命周期方法,就显式的确定了ArticleLifecycle自身的scope的生命周期,并且它和UI scope的生命周期阶段定义一致,更加符合开发的习惯。
总结
写到这里,我忽然之间感到,一个业务设计数据流最重要的一点设置数据的生命周期界限,简而言之就是在正确时间,正确的地方做正确的事情。无论是flow,ViewModel,协程还是channel,都是为了这一点服务的。我以前在读书时,曾在课本上看到,程序语言就是数据结构+控制结构,现在生以为然。做好数据流和生命周期的规划,对于业务具有很大的意义。