复杂度
Android 架构演进系列是围绕着复杂度向前推进的。
软件的首要技术使命是“管理复杂度” —— 《代码大全》
因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。
架构的目的在于“将复杂度分层”
复杂度为什么要被分层?
若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。
举一个复杂度不分层的例子:
小李:“你会做什么菜?”
小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”
听了小明的回答,你还会和他做朋友吗?
小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。
小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。
这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。
再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:
- 物理层
- 数据链路成
- 网络层
- 传输层
- 应用层
其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。
这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。
有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。
引子
该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:
- 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。
- 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。
- 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。
- 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。
详细分析过程可以点击下面的链接:
紧接着又用了三篇讲述了如何使用 MVP 架构对该业务场景的重构过程。MVP 的确解决了一些问题,但也引入了新问题:
- 分层:MVP 最大的贡献在于将界面绘制与业务逻辑分层,前者是 MVP 中的 V(View),后者是 MVP 中的 P(Presenter)。分层实现了业务逻辑和界面绘制的解耦,让各自更加单纯,降低了代码复杂度。
- 面向接口通信:MVP 将业务和界面分层之后,各层之间就需要通信。通信通过接口实现,接口把做什么和怎么做分离,使得关注点分离成为可能:接口的持有者只关心做什么,而怎么做留给接口的实现者关心。界面通过业务接口向 Presenter 发出请求以触发业务逻辑,这使得它不需要关心业务逻辑的实现细节。Presenter 通过 view 层接口返回响应以指导界面刷新,这使得它不需要关心界面绘制的细节。
- 有限的解耦:因为 View 层接口的存在,迫使 Presenter 得了解该把哪个数据塞给哪个 View 层接口。这是一种耦合,Presenter 和这个具体的 View 层接口耦合,较难复用于其他业务。
- 有限内聚的界面绘制:MVP 并未向界面提供唯一 Model,而是将描述一个完整界面的 Model 分散在若干 View 层接口回调中。这使得界面的绘制无法内聚到一点,增加了界面绘制逻辑维护的复杂度。
- 困难重重的复用:理论上,界面和业务分层之后,各自都更加单纯,为复用提供了可能性。但不管是业务接口的复用,还是View层接口的复用都相当别扭。
- Presenter 与界面共存亡:这个特性使得 MVP 无法应对横竖屏切换的场景。
- 无内建跨界面(粘性)通信机制:MVP 无法优雅地实现跨界面通信,也未内建粘性通信机制,得借助第三方库实现。
- 生命周期不友好:MVP 并未内建生命周期管理机制,易造成内存泄漏、crash、资源浪费。
详细分析过程可以点击下面的链接:
从这一篇开始,试着引入 MVVM 架构的思想进行搜索业务场景的重构,看看是否能解决一些痛点。
在重构之前,再介绍下搜索的业务场景,该功能示意图如下:
业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。
将搜索业务场景的界面做了如下设计:
搜索页用Activity
来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment
承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。
Fragment 之间的切换采用 Jetpack 的Navigation
。关于 Navigation 详细的介绍可以点击Navigation 组件使用入门 | Android 开发者 | Android Developers
上一篇引入了 MVVM 中两个重要的概念 ViewModel 和 LiveData。它俩搭配实现了“生命周期更长的数据持有者”并“以数据驱动的方式”刷新界面,使得界面和业务逻辑更加解耦。
让人又爱又恨的数据重放
搜索联想效果如图所示:
输入关键词后会自动拉取接口并展示联想词。
这是一个跨界面通信的场景,因为搜索框在 Activity 而联想页是子 Fragment。拉取接口的动作在 Activity 触发,它得把数据传递给 Fragment 进行展示。
在没有粘性通信加持的情况下,可以有下面两个解决方案:
- 发广播:但这里有一个坑,会导致 Fragment 接收不到广播。(因为发送动作在注册广播之前)
- 通过界面跳转时携带参数:会引入参数的序列化和反序列化,略耗性能。
关于上两个解决方案缺点的详细分析可以点击写业务不用架构会怎么样?(三)
若觉得第二点的性能损耗不是问题的话,它也不是一个通用的方案。当没有发生界面跳转时,就无法使用该方案。比如,当点击联想词时是从联想页跳到结果页,但数据传递却需要从联想页到历史页(点击联想词也视为一次搜索行为,得更新历史),此时界面跳转和数据传递的方向不一致。
爱
粘性通信是这个场景的最优解。因为粘性意味着数据可以重放,即使在数据发送之后才注册观察者,刚发送的数据照样会重新分发给新的观察者。
对于当前场景来说,Activity 只管拉取联想词并递交给一个粘性的数据持有者,然后触发跳转联想页,待联想页构建完毕后才观察已生成的联想词。
输入联想这个场景需要对拉接口做限频,若每次输入变化都拉接口的话,太耗性能及流量了。遂将输入关键词组织成一个流:
etSearch.textChangeFlow { b, input -> input.toString() } // 刷新搜索条 .onEach { searchViewModel.input(it) } .flowOn(Dispatchers.Main) .filter { it.isNotEmpty() } .debounce(300) // 300 ms 限频 .flatMapLatest { flow { // 拉取联想词 emit(searchViewModel.fetchHint(it)) } } .flowOn(Dispatchers.IO) .onEach { hints -> // 跳转到联想页 searchViewModel.setHints(etSearch.text.toString(), hints) } .launchIn(lifecycleScope)
上述代码将 EditText 的输入组织成了一个 Flow,这样就可以使用 debounce() 方便地限制拉取联想词的频次。关于流的详细分析可以点击Kotlin 异步 | Flow 限流的应用场景及原理
同时得为 SearchViewModel 新增和搜索联想相关的两个业务动作及对应的数据:
class SearchViewModel : ViewModel() { private val repository: SearchRepository = SearchRepository() // 联想词数据 val hintsLiveData = MutableLiveData<List<SearchHint>>() // 拉取联想词 suspend fun fetchHint(keyword: String): List<String> { return repository.fetchSearchHint(keyword) } // 跳转联想页 fun setHints(keyword: String, hints: List<String>) { hintsLiveData.value = hints.map { SearchHint(keyword, it) } } }
其中 Repository 是对访问数据的封装,这里用到它访问接口拉取联想词的能力。这个层次结构和 MVP 一模一样:
只不过现在中间的 Presenter 被换成了 ViewModel。
关于数据访问层的设计详解可以点击MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)
最后只需要在联想页中观察粘性数据即可:
class SearchHintFragment : BaseSearchFragment() { val searchViewModel: SearchViewModel by activityViewModels<SearchViewModel>() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 观察联想数据并传递给列表的 Adapter searchViewModel.hintsLiveData.observe(viewLifecycleOwner){ hintsAdapter.dataList = it } }
此处的 onViewCreated() 必然晚于 hintsLiveData 被设值的时刻。粘性的 LiveData 保证了联想词正确地展示。
粘性是 LiveData 自带的属性,它是怎么实现粘性的?
- LiveData 的值被存储在内部的字段中,直到有更新的值覆盖,所以值是持久的。
- 两种场景下 LiveData 会将存储的值分发给观察者。一是值被更新,此时会遍历所有观察者并分发之。二是新增观察者或观察者生命周期发生变化(至少为 STARTED),此时只会给单个观察者分发值。
- LiveData 的观察者会维护一个“值的版本号”,用于判断上次分发的值是否是最新值。该值的初始值是-1,每次更新 LiveData 值都会让版本号自增。
- LiveData 并不会无条件地将值分发给观察者,在分发之前会经历三道坎:1. 数据观察者是否活跃。2. 数据观察者绑定的生命周期组件是否活跃。3. 数据观察者的版本号是否是最新的。
- “新观察者”被“老值”通知的现象叫“粘性”。因为新观察者的版本号总是小于最新版号,且添加观察者时会触发一次老值的分发。
关于 LiveData 粘性更详细的源码分析可以点击LiveData 面试题库、解答、源码分析
恨
但粘性有时候会产生麻烦,比如下面这个场景:
先搜索“1”,然后返回到历史页,弹出 toast “新搜索词排在最前面”,继续输入“2”,此时并未触发搜索行为,只是进行了联想(2不会被记入历史),但当返回历史,上一次的 toast 再次弹出。
代码实现如下:
class SearchViewModel : ViewModel() { val rearrangeLiveData = MutableLiveData<String>() val historyLiveData = MutableLiveData<HistoryModel>() fun search(keyword: String) { ... historyLiveData.value = historyLiveData.value?.addHistory(keyword) ?: HistoryModel(mutableListOf(keyword),false) // 在触发新搜索时提示 rearrangeLiveData.value = "新搜索词汇排在最前面" } }
在 SearchViewModel 中新增了一个数据,它表示 toast 的内容。
然后在历史页观察该数据,并弹出 toast:
class SearchHistoryFragment : BaseSearchFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) searchViewModel.rearrangeLiveData.observe(viewLifecycleOwner){ Toast.makeText(context,it,Toast.LENGTH_SHORT).show() } } }
之所以会弹两次 toast 是因为“历史页被重建+粘性数据”。
当从历史页跳转到联想页时,它的 onDestroyView() 会被调用,所以返回时得重新构建视图,即会触发 onCreateView() -> onViewCreated()。和 toast 相关的 LiveData 正好是在 onViewCreated() 中注册观察者的,重新构建意味着重新注册了一个观察者,又因为 LiveData 是粘性的,所以老数据会分发给新观察者,遂 toast 又弹了一次。
这个 case 中,粘性对历史数据是友好的,因为当历史页重建时,需要重新绘制既有的历史搜索标签。真实让人又爱又恨的粘性。
针对 LiveData 的粘性,网上有各种解决方案,关于它们孰优孰劣的详细分析可以点击LiveData 面试题库、解答、源码分析