MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(三)
复杂度
Android 架构演进系列是围绕着复杂度向前推进的。
软件的首要技术使命是“管理复杂度” —— 《代码大全》
因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。
架构的目的在于“将复杂度分层”
复杂度为什么要被分层?
若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。
举一个复杂度不分层的例子:
小李:“你会做什么菜?”
小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”
听了小明的回答,你还会和他做朋友吗?
小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。
小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。
这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。
再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:
- 物理层
- 数据链路成
- 网络层
- 传输层
- 应用层
其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。
这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。
有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。
引子
该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:
- 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。
- 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。
- 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。
- 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。
详细分析过程可以点击下面的链接:
这一篇试着引入 MVP 架构(Model-View-Presenter)进行重构,看能不能解决这些痛点。
在重构之前,先介绍下搜索的业务场景,该功能示意图如下:
业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。
将搜索业务场景的界面做了如下设计:
搜索页用Activity
来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment
承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。
Fragment 之间的切换采用 Jetpack 的Navigation
。关于 Navigation 详细的介绍可以点击关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门 | Android 开发者 | Android Developers
生命周期不友好
Presenter 在调 View 层接口的时候是鲁莽的,它并不顾及界面的生命周期,这会发生 crash。
假设用户触发搜索后,正好网络不佳,等了好久搜索结果一直未展示,用户退出了搜索页。但退出没多久后,客户端接收到了网络响应,然后 Presenter 就会调用 View 层接口,通知界面跳转到搜索结果页,此时就会发生如下的 crash:
java.lang.IllegalArgumentException: Navigation action/destination cannot be found from the current destination NavGraph
即在当前的 NavGraph 中无法找到要跳转的目的地。(它的确是不存在了)
解决方案是得让 Presenter 具备生命周期感知能力,当界面的生命周期不可见时,就不再调用 View 层接口。
通常的做法的是为业务接口新增和生命周期相关的方法:
interface SearchPresenter {
fun onDestory() // 新增生命周期方法
}
// 将 View 层接口改为可空类型
class SearchPresenterImpl(private val searchView: SearchView?) : SearchPresenter {
override fun onDestroy() {
searchView = null // 生命周期结束时 View 层接口置空
}
}
class TemplateSearchActivity : AppCompatActivity(), SearchView {
override fun onDestroy() {
super.onDestroy()
// 将生命周期传递给 Presenter
searchPresenter.onDestroy()
}
}
在生命周期结束时将 View 层接口置空。执行业务逻辑时得对 searchView 先判空。
在没有 JetPack 的 Lifecycle 之前上述代码是让 Presenter 感知生命周期的惯用写法。有了 Lifecycle 后,代码可以得到简化:
class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
init {
// 将 View 层接口强转成 LifecycleOwner,并添加生命周期监听者
(searchView as? LifecycleOwner)?.lifecycle?.onStateChanged {
// 在生命周期为 ON_DESTROY 时,调用 onDestroy()
if (it == Lifecycle.Event.ON_DESTROY) onDestroy()
}
}
private fun onDestroy() {
searchView = null
}
}
虽然传进来的是 View 层接口,但它的实现者是 Activity,可以把它强转为 LifecycleOwner,并添加生命周期观察者。这样就可以在 Presenter 内部监听生命周期的变化。
其中的 onStateChanged() 是 Lifecycle 的扩展方法:
// 扩展方法简化了业务层使用的代码量
fun Lifecycle.onStateChanged(action: ((event: Lifecycle.Event) -> Unit)) {
addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
action(event)
if (event == Lifecycle.Event.ON_DESTROY) {
removeObserver(this)
}
}
})
}
生命周期安全还可以更进一步。当界面生命周期完结后,除了不把晚到的数据推送给界面之外,还可以取消异步任务,节约资源并避免内存泄漏。
还是拿刚才联想词的交互来举例,点击联想词记为一次搜索,得录入搜索历史,而搜索历史得做持久化,采用 MMKV,这个细节应该被封装在 SearchRepository 中:
class SearchRepository {
// 获取搜索历史
suspend fun getHistory(): List<String> = suspendCancellableCoroutine { continuation->
val historyBundle = MMKV.mmkvWithID("template-search")?.decodeParcelable("search-history", Bundle::class.java)
val historys = historyBundle?.let { (it.getStringArray("historys") ?: emptyArray()).toList() }.orEmpty()
continuation.resume(historys,null)
}
// 更新搜索历史
suspend fun putHistory(historys:List<String>) = suspendCancellableCoroutine<Unit> { continuation ->
val bundle = Bundle().apply { putStringArray("historys", historys.toTypedArray()) }
MMKV.mmkvWithID("template-search")?.encode("search-history", bundle)
continuation.resume(Unit,null)
}
}
虽然 MMKV 足够快,但 IO 还是充满了不确定性。顺手异步化一下没毛病,使用suspendCancellableCoroutine
将同步方法转成 suspend 方法。
这样的话得为 suspend 提供一个协程运行环境:
class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
// 协程域
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val searchRepository: SearchRepository = SearchRepository()
private val historys = mutableListOf<String>()
// 初始化读历史
override fun init() {
searchView.initView()
// 初始化时,启动协程获取历史
scope.launch {
searchRepository.getHistory().also { historys.addAll(it) }
withContext(Dispatchers.Main) {
searchView.showHistory(historys)
}
}
}
// 搜索时写历史
override fun search(keyword: String, from: SearchFrom) {
searchView.gotoSearchPage(keyword, from)
searchView.stretchSearchBar(true)
searchView.showSearchButton(false)
// 新增历史
if (historys.contains(keyword)) {
historys.remove(keyword)
historys.add(0, keyword)
} else {
historys.add(0, keyword)
if (historys.size > 11) historys.removeLast()
}
searchView.showHistory(historys)
// 启动协程持久化历史
scope.launch { searchRepository.putHistory(historys) }
}
}
新建了一个 CoroutineScope 用于启动协程,CoroutineScope 的用意是控制协程的生命周期。但上述的写法和GlobalScope.launch()
半径八两,因为没有在界面销毁时取消协程释放资源。所以 Presenter.onDestroy() 还得新增一行逻辑:
class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private fun onDestroy() {
searchView = null
scope.cancel()
}
}
阶段性总结:
- 生命周期安全包括两个方面:
- 以生命周期安全的方式刷新界面:当界面生命周期结束时,不再推送数据刷新之。
- 异步任务与界面生命周期绑定:当界面生命周期结束时,取消仍未完成的异步任务,以释放资源,避免内存泄漏
- MVP 架构没有内建的机制来实现上述的生命周期安全,它是手动挡,得自己动手建立一套生命周期安全的机制。而 MVVM 和 MVI 是默认具备生命周期感知能力的。(在后续篇章展开)
困难重重的业务复用
业务接口复用
整个搜索业务中,触发搜索行为的有3个地方,分别是搜索页的搜索按钮(搜索 Activity)、点击搜索历史标签(历史 Fragment)、点击搜索联想词(联想 Fragment)。
这三个触发点分别位于三个不同的界面。而触发搜索的业务逻辑被封装在 SearchPresenter 的业务接口中:
class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
private val historys = mutableListOf<String>() // 历史列表
override fun search(keyword: String, from: SearchFrom) {
searchView.gotoSearchPage(keyword, from) // 跳转到搜索结果页
searchView.stretchSearchBar(true) // 拉升搜索条
searchView.showSearchButton(false) // 隐藏搜索按钮
// 更新历史
if (historys.contains(keyword)) {
historys.remove(keyword)
historys.add(0, keyword)
} else {
historys.add(0, keyword)
if (historys.size > 11) historys.removeLast()
}
// 刷新搜索历史
searchView.showHistory(historys)
// 搜索历史持久化
scope.launch { searchRepository.putHistory(historys) }
}
}
理论上,三个不同的界面应该都调用这个方法触发搜索,这使得搜索这个动作的业务实现内聚于一点。但在 MVP 中情况比想象的要复杂的多。
首先 SearchPresenter 的实例只有一个且被搜索 Activity 持有。其他两个 Fragment 如何获取该实例?
当然可以有一个非常粗暴的方式,即先将 Activity 持有的 Presenter 实例 public 化,然后就能在 Fragment 中先获取 Activity 实例,再获取 Presenter 实例。但这样写使得 Fragment 和 Activity 强耦合。
那从 Fragment 发一个广播到 Activity,Activity 在接收到广播后调用 Presenter.search() 可否?
不行!因为点击联想词有两个效果:1. 触发搜索 2. 更新历史
发广播可以实现第一个效果,但更新历史不能使用广播,因为历史列表historys: List<String>
是保存在 Presenter 层,直接从联想页发广播到历史页拿不到当前的历史列表,就算能拿到,也不该这么做,因为这形成了一条新的更新历史的路径,增加复杂度和排查问题的难度。
所以 MVP 架构在单 Activity + 多 Fragment 场景下,无法优雅地轻松地实现多界面复用业务逻辑。
而在 MVVM 和 MVI 中这是一件轻而易举的事情。(后续篇章会展开)
View 层接口复用
当前 MVP 的现状如下:Activity 是 Presenter 的唯一持有者,也是 View 层接口的唯一实现者。
这样的设计就会产生一些奇怪的代码,比如下面这个场景。为了让搜索历史展示,得在 View 层接口中新增一个方法:
interface SearchView {
fun showHistory(historys: List<String>)// 新增刷新历史的 View 层接口
}
// 搜索 Activity
class TemplateSearchActivity : AppCompatActivity(), SearchView {
override fun showHistory(historys: List<String>) {
// 奇怪的实现:Activity 通知 Fragment 刷新界面
EventBus.getDefault().post(SearchHistorysEvent(historys))
}
}
// 搜索历史页
class SearchHistoryFragment : BaseSearchFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EventBus.getDefault().register(this)
}
override fun onDestroy() {
super.onDestroy()
EventBus.getDefault().unregister(this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onHints(event: SearchHistorysEvent) {
showHistory(event.history) // 接收到 Activity 的消息后,展示搜索历史
}
}
奇怪的事情发生了,为了在发起搜索行为后刷新搜索历史,引入了广播。(此处无法使用 Navigation 的带参跳转,因为搜索行为发生后要跳的界面是结果页而非历史页)
之所以会这样是因为“Activity 是 View 层接口的唯一实现者”,其实 showHistory() 这个 View 层接口应该在历史页 Fragment 实现,因为展示历史的不是 Activity 而是 Fragment。
那把 View 层接口在 Fragment 在实现一遍,然后注册给 Presenter?
这样语义就很变扭了,因为 Fragment 得实现一堆和自己无关的 View 层接口(除了 showHistory()),这些冗余接口得保持空实现。
而且 Presenter 当前只支持持有一个 View 层接口,得重构成支持多 View 层接口。当持有多个 View 层接口,且它们生命周期不完全同步时,如何正确的区别对待?这又是一件复杂的事情。
最后 Fragment 也无法优雅地轻松地获取 Presenter 实例。
用流程图来描述下单 Activity + 多 Fragment 界面框架下的 MVP 的窘境:
即 Activity 同子 Fragment 发起同一个业务请求,该请求会同时触发 Activity 及子 Fragment 的界面刷新。
MVP 无法轻松地实现该效果,它不得不这样蹩脚地应对:
即发起业务请求以及响应界面刷新都途径 Activity。这加重了 Activity 的负担。
成也 View 层接口,败也 View 层接口。下面这个例子又在 View 层接口的伤疤上补了一刀。
产品需求:当搜索为空匹配时,展示推荐流。
推荐流在另一个业务模块中已通过 MVP 方式实现。可否把它拿来一用?
另一个业务模块的 View 层接口中有 6 个方法。当前 Activity 得是现在这些和它无关的冗余方法们并保持空实现。
当前 Activity 还得持有一个新的 Presenter。在搜索匹配结果为空的时,再调新 Presenter 的一个业务接口拉取推荐流。然后在新 View 层接口中绘制推荐流。(搜索结果的展示没有做到内聚,分散在了两个 View 层接口中,增加了维护难度)
虽然不那么优雅,但还是实现了需求。上例中的搜索和推荐接口是串行关系,还比较好处理,若改成更复杂的并行,View 层界面就无力招架了,比如同时拉取两个接口,待它们全返回后才刷新界面。
这是一个如何等待多个异步回调的问题,在面试题 | 等待多个并发结果有哪几种方法?中有详细介绍。普通的异步回调还好弄,但现在异步回调的实现者是 Activity,就有点难办了。(因为无法手动创建 Activity 实例)
再看下面这个产品需求:当展示搜索结果时,上拉加载更多搜索结果。当展示推荐流时,上拉加载更多推荐结果。
界面应该只提供加载更多的时机,至于加载更多是拉取搜索接口还是推荐接口,这是业务逻辑,界面应该无感知,得交给 Presenter 处理。
搜索和推荐分处于两个 Presenter,它们只知道如何加载更多的自己,并不知道对方的存在。关于搜索和推荐业务如何组是一个新的业务逻辑,既不属于推荐 Presenter,也不属于搜索 Presenter。若采用 Activity 持有两个 Presenter 的写法,新业务逻辑势必得在 Activity 中展开,违背了界面和业务隔离的原则。
拦截转发是我能想到的一个解决方案:新建一个 Presenter,持有两个老 Presenter,在内部构建 View 层口的实例并注册给老 Presenter 实现拦截,然后在内部实现等待多个 View 层接口以及加载更多的业务逻辑。
这个方案听上去就很费劲。。。
之所以会这样,是因为 View 层接口是一个 “具体的接口”,而它又和一个 “具体的界面” 搭配在一起。这使得 Presenter 和“这种类型的界面”耦合在一起,较难在其他界面复用。
总结
经过三篇对搜索业务场景的重构,现总结 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、资源浪费。
作者:唐子玄
链接:https://juejin.cn/post/7151809060407345189
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。