本文学习官方应用架构指南 | App architecture | Android Developers。
上一篇: android MVC/MVP/MVVM/MVI架构发展历程和编写范式
不是做翻译,而是把重点写出,并用自己的话讲出来。就像我上一篇文章android MVC/MVP/MVVM/MVI架构发展历程和编写范式-CSDN博客学习架构要关注的是思想重点,而非某种模式的教条模板代码。提到的:业务分层,数据驱动,单向数据流, 事件绑定监听, 生命周期感知, 关注内存泄露…
整个官方文档包含概述2章,界面层4章,中间层1章,数据层3章。有的章节写出了提纲挈领的心得体会,有的章节写的不知所云,可能也是谷歌官网大量使用机翻,翻译的不够流畅的原因。本文主要以个人心得体会为主。
经过仔细学习,大概融会贯通,总结了一篇精华文章。相信观众一定能有所感悟。
一、概况
官方从来没有说过android架构到底属于哪种,MVVM或MVI架构。只提到了现代化andriod需要遵循的原则。这也是这些年kotlin协程,Flow大力发展后而形成的一种开发范式。比如协程(作用域,挂起函数,线程模型选择),单向数据流思想,UI状态/StateFlow,ViewModel等等。
UI层:View/Compose
ViewModel(UIState(StateFlow))
Data层:
Repository(Data Sources)
Repository(Data Sources)
Room, data Store, retrofit…
Domain层(可选):
UserCase(Repository, Reposity)
二、原则
首先我们思考下,一个稳健,易于维护的项目,有哪些原则?
关注代码分离
最常见的臃肿就是在Activity/Fragment中一把梭,编写所有代码。界面类,必须保持仅仅处理界面和交互的逻辑。
数据驱动模型
数据它们应该独立于界面组件,它们的生命周期与组件应该没有关联。
数据应该是持久化的,应用杀死后不会丢失数据,没有网络等条件下能继续工作。
增加可测试性和稳定性。
单一数据源
定义新数据类型时,它们应该被放在单一数据源里面,公开出去的是不可变类型;而为了修改,则公开函数或者接收其他类型的事件。这样做的好处:更改数据集中在一起;保护数据不被到处修改;可追溯数据的变更,更容易发现bug。它们可能是数据库,网络请求单例,在其他情况,可能是ViewModel或者界面。
单向数据流
单一数据源,往往就要求单项数据流一起使用。例如,应用数据通常从数据源流向界面。用户事件(例如按钮按下操作)从界面流向ViewModel再传递给单一数据源,在单一数据源中应用数据被修改并以不可变类型公开。
数据/ViewModel远离Context
不要在activity,service等应用的界面层作为数据源,他们的生命周期是短暂的。
除了Activity/Fragment等UI界面与Context,Toast等UI层或者context有关。
而数据类,ViewModel类都不要跟这些有任何关系。
可以减少耦合,提升可测试性,也避免了生命周期的引用问题。
相信有点经验的开发者都会避免这点。
其他注意事项
注意并发,耗时放到子线程,缓存数据来做离线显示提升用户体验,类的单一原则等。
三、分层架构
基于上述原则,一个应用可以分为几层:
- 界面层:把数据显示到屏幕上(包含ViewModel)。 第四章会展开介绍。
- 数据层:包含应用业务逻辑并公开数据。
- 中间层(可选):简化和重复使用界面层与数据层之间的交互。(注意:不是ViewModel,而是多个ViewModel处理的时候,用来简化和重用的。)

同时,官方提出了一些现代化架构的重点:
响应式分层架构;
单向数据流和单一数据源;
包含状态容器的界面层,用于管理复杂的界面;状态容器:就是ViewModel包裹StateFlow。
协程和数据流;
依赖注入
看到这里,图穷匕见,所有现代化android架构
就是最新的kotlin,协程,ViewModel,StateFlow及所带来的单向数据流思想,Hilt等框架的应用…
接下来就是逐层展开。
四、界面层
常规思想:
分为两部分:
- 使用View(传统的xml方式)或者Compose(目前主推)构建;
- 用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。
你看,官方文档讲述的ViewModel其实属于UI层,与我们平时说的MVVM中的ViewModel有区别吧?让我们接着往下看。
4.1 StateHolders概念
android界面层:
什么是StateHolders状态容器呢?就是把UiState放在这个类里面去管理的,就是状态容器。
很明显,android上ViewModel就是最好的对象。
这张图很明显就是把StateHolders替换成了ViewModel。就是把架构从常规架构的思想,变成了android通用App架构。
4.2 界面生命周期
不要继承修改onResume等生命周期。
使用viewLifecycleOwner.lifecycle.addObserver
和repeatOnLifecycle
, Compose CollectAsStateWiteLifecycle
监听方式。
4.3 定义UiState
因为用户的交互(比如前面的收藏按钮),或者网络请求,其他事件等都会修改数据层的数据。如果到处可以修改数据,或者修改界面,代码变得耦合,难以测试,没有边界。
我们的目的是希望界面的唯一职责是使用和显示UiState。
我们需要需要把界面抽象成UiState, 包括2个原则:
State是把整个UI所有可能放在一个State中,使用一个State类去描述界面的当前状态。
比如登录状态变量Boolean,loading中变量Boolean,数据列表List,消息内容List,全部都放在一个State类里面。要做的是变更StateFlow<State>的value,从而刷新UI。
这与LiveData做显示的时候,有一些区别,往往我们会定义一堆LiveData公开出去让界面注册。这里官方就强调最好是一个类,包含一个页面所有的元素的状态。
但也并不是一定要这样做,如果某些界面类型不相关,是可以考虑分开的。
不可变性
定义状态变量都使用val/final不可变属性。
比如:某个收藏按钮,如果你的UiState里面定义了可变的收藏状态var isFavourite;那么, 当你直接变更UI,并修改此变量。界面层就会跟数据层都会对数据对象进行竞争更改。
破坏了单一数据源原则。正确做法是UI层通知数据层去发生变化,从而影响State的变化。
4.4 单向数据流原则
单向数据流是什么?状态向下流动,事件向上传递。
- ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据。
- 界面会向 ViewModel 发送用户事件通知。
- ViewModel 会处理用户操作并更新状态。
- 更新后的状态将反馈给界面以进行呈现。
- 系统会对导致状态更改的所有事件重复上述操作。
为什么要用单向数据流呢?
代码分离:状态变化的来源的代码位置,转变的实现代码位置,最终使用的代码位置,这些是独立分离的
通过观察状态变化来显示信息,并通过将这些变化传递给 ViewModel 来传递用户事件。
数据一致性:单一数据源,唯一可信;
可测试性:状态来源是独立于界面的,便可测;
可维护性:状态的更改定义十分明确,即状态更改是用户事件及其数据拉取来源共同作用的结果。
4.5 推荐使用ViewModel
有如下原则:
ViewModel不要引用context,resource,toast等;比如列表显示,弹窗消息,控件显示,一定在Activity/Fragment等界面代码上;不能在ViewModel中;
使用协程,viewModelScope和挂起函数;
ViewModel 应该通过名为
uiState
的单个属性向界面公开数据。如果界面显示多块不相关的数据,可以考虑多个UiState;使用StateFlow来包裹UiState;
如果数据作为来自层次结构中的其他层的数据流传入,您应该使用
stateIn
运算符和WhileSubscribed(5000)
(示例)来创建uiState
。(留个坑,学习Flow的combine,和转换,但与架构无关。)您可以选择将
UiState
作为能够包含数据、错误和加载信号的数据类。如果不同状态是互斥的,该类也可以是密封的类。
基本流程如下:
ViewModel中持有StateFlow(数据对象,就是用来包裹uiState的),它们可以供View层去注册监听,刷新UI。
定义了所有的显示UI的State;
定义了所有的View层往ViewModel调用的Events;
View->ViewModel:通过函数传递Events
ViewModel->Model: 解析具体某个Action,执行对应的Model数据层请求,并转变得到State。
ViewModel->View: State更新以后,触发View层的UI更新。
Demo代码:可参考https://blog.csdn.net/jzlhll123/article/details/149835752 的MVI章节的代码。如果没有写过Kotlin的ViewModel+StateFlow建议看一下加深印象。
4.6 界面事件的常规做法
这一小节告诉我们,界面层的操作,比如点击事件,输入内容,刷新按钮等应该如何调用ViewModel。
Demo:
//通过ViewModel函数传递事件。对应架构图中的events
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
//recyclerView的点击事件
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit //点击事件定义在ViewHolder绑定的数据类型Bean中
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
onBookmark = { //点击事件的操作
repository.addBookmark(news.id)
}
)
}
}
其实这一小结很简单,就是想告诉我们界面通过ViewModel的函数来传递events;
对于recyclerView有2种办法:
第一种通过bean类传递lamda函数,如上demo中编写内容;
第二种办法,RecyclerView的item点击事件转达给Activity/Fragment,然后通过ViewModel函数调用。
综合言之,就是通过ViewModel公开函数去调用。而不能把ViewModel传来传去。
官方文档还介绍了:
函数里面,通过scope控制生命周期,通过协程,发起异步。然后就是讲Flow的用法。比如如何combine Flow变成StateFlow。 shareIn等等。后续我们学习一下Flow的具体用法。
五、数据层

数据层包含了业务逻辑,如何创建,存储,修改数据,给其他代码提供公开的数据。
2个分类:
本地存储:数据库,DataStore,SharedPreference,Firebase API, MMKV;
远程数据:网络请求okhttp/retrofit,蓝牙,GPS等等。
组织代码:
- 为每一类不同类型定义一个仓库(Repository):
- 数据层可以包含多个仓库(repository),每一个仓库可以有0~N个数据源(DataSource);
- 解决多个数据源(DataSource)的整合冲突;
- 公开数据和集中处理数据变化;
- 业务逻辑;
- 一个DataSource仅处理一种数据(文件,网络,或数据库):
比如你有个应用数据字典,放在asset里面,app出厂有一份,网络更新/Cache各有一份。优先使用新的。怎么做呢?
AppDictRepository {
AssetAppDictDataSource()
NetworkCacheAppDictDataSource()
NetworkAppDictDataSource()
}
官方并告诉大家,应该如何组织多层代码:
应该使用room,okhttp,retrofit,协程等框架;
应该使用DataStore,room,sqlite,sharedPref等缓存技术;
使用WorkManger实现定期更新;
对于线程,生命周期的重点把控。
六、中间层 (可选)
负责封装复杂的业务逻辑,或者由多个 ViewModel 重复使用的简单业务逻辑。此层是可选的,因为并非所有应用都有这类需求。
重点:
- 避免代码重复。
- 改善使用类的可读性。
- 改善应用的可测试性。
- 让您能够划分好职责,从而避免出现大型类。
其实官方文档讲的不太好,云里雾里。总结一句话:
如何改造ViewModel里面的使用的那些可能到处使用的数据提供类。
有大量代码开发经验的工程师相信都会自行分离代码。
类的单一原则。 就是告诉我们一个类干一件事情。通过组合模式,降低代码的复杂度,便于重用。
- 组合模式
ViewModelA {
AReposity
Breposity
CReposity
FormatHelperA
HelperB
}
ViewModelB {
AReposity
Breposity
HelperB
}
//转变为
UserCase {
AReposity
BRepsity
HelperB
}
ViewModelA {
UserCase
}
...
其中提到2个点,我觉得是有用的:
生命周期的管控
不应该由这些数据类或者中间层类来管理。我们可以通过suspend函数往外公开,那么它就可以被生命周期管理了,在ViewModel里面自行scope.launch来执行。
线程处理
参考如下,入参
defaultDispatcher + withContext(defaultDispatcher)
:我认为觉得也没什么必要,直接在调用的地方,scope.launch(Dispatcher.IO/Default)里面传递就好了,更为直观,控制逻辑也交给ViewModel。
class GetLatestNewsWithAuthorsUseCase(
private val newsRepository: NewsRepository,
private val authorsRepository: AuthorsRepository,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default //线程逻辑
) {
suspend operator fun invoke(): List<ArticleWithAuthor> =
withContext(defaultDispatcher) {
val news = newsRepository.fetchLatestNews()
val result: MutableList<ArticleWithAuthor> = mutableListOf()
// This is not parallelized, the use case is linearly slow.
for (article in news) {
// The repository exposes suspend functions
val author = authorsRepository.getAuthor(article.authorId)
result.add(ArticleWithAuthor(article, author))
}
result
}
}
七、管理类之间的依赖关系
类与类之间的关系,可以通过两种设计模式来解决:
- 依赖注入(DI)
- 服务定位器(service locator)
当然啦,就引出了Hilt的使用。主要解决构造函数注入。
如果类型包含多项需要共享的可变数据,可以通过工厂模式创建共同类和使用单例管理这些工厂代码的创建。具体参考https://developer.android.com/training/dependency-injection/manual?hl=zh-cn#dependencies-container
八、测试
- ViewModel包括Flow进行单元测试;
- 数据层实体单元测试;
- 进行界面导航测试;
- 测试替身:请参阅 Android 文档中的“使用测试替身”;
- 测试StateFlow。测试
StateFlow
时:- 尽可能对
value
属性进行断言 - 如果使用
WhileSubscribed
,您应该创建一个collectJob
- 尽可能对
最后
官方原话:
提供的建议和最佳实践可应用于各种应用。遵循这些建议和最佳实践可以提升应用的可扩展性、质量和稳健性,并可使应用更易于测试。不过,您应该将这些提示视为指南,并视需要进行调整来满足您的要求。
这是在告诉我们,不要教条盲从,非要使用一个复杂开发框架,把代码写的十分复杂,才是好的吗?
应该找到合适的开发方式就是最好的。
下一篇我将结合自己开发实例给出一套基于MVI, Redux思想,和官方StateFlow/SharedFlow的简易框架。