注册
MVP

MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(一)

复杂度


Android 架构演进系列是围绕着复杂度向前推进的。



软件的首要技术使命是“管理复杂度” —— 《代码大全》



因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。



架构的目的在于“将复杂度分层”



复杂度为什么要被分层?


若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。


举一个复杂度不分层的例子:


小李:“你会做什么菜?”


小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”


听了小明的回答,你还会和他做朋友吗?


小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。


小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。


这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。


再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:



  1. 物理层
  2. 数据链路成
  3. 网络层
  4. 传输层
  5. 应用层

其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。


这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。


有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。


引子


该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:



  1. 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。
  2. 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。
  3. 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。
  4. 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。

详细分析过程可以点击下面的链接:




  1. 写业务不用架构会怎么样?(一)




  2. 写业务不用架构会怎么样?(二)




  3. 写业务不用架构会怎么样?(三)




这一篇试着引入 MVP 架构(Model-View-Presenter)进行重构,看能不能解决这些痛点。


在重构之前,先介绍下搜索的业务场景,该功能示意图如下:


1662106805162.gif


业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。


将搜索业务场景的界面做了如下设计:


微信截图_20220902171024.png


搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。


Fragment 之间的切换采用 Jetpack 的Navigation。关于 Navigation 详细的介绍可以点击关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers


高耦合+低内聚


MVP 能否成为高耦合低内聚的终结者?


先来看看高耦合低内聚的代码长什么样。以搜索条为例,它的交互如下:


1664442211986.gif


当输入框键入内容后,显示X按钮并高亮搜索按钮。点击搜索跳转到搜索结果页,同时搜索条拉长并隐藏搜索按钮。点击X时清空输入框并从搜索结果页返回,搜索条还原。


引用上一篇无架构的实现代码:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
// 搜索按钮初始状态
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
// 初始状态下,清空按钮不展示
ivClear.visibility = gone
// 初始状态下,弹出搜索框
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
// 监听输入框,当有内容时更新搜索和X按钮状态
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}
override fun afterTextChanged(s: Editable?) { }
})
// 监听键盘搜索按钮
etSearch.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch.text.toString() ?: ""
if(input.isNotEmpty()) { searchAndHideKeyboard() }
true
} else false
}
// 监听搜索条搜索按钮
tvSearch.setOnClickListener { searchAndHideKeyboard() }
}
// 跳转到搜索页 + 拉长搜索条 + 隐藏搜索按钮 + 隐藏键盘
private fun searchAndHideKeyboard() {
vInputBg.end_toEndOf = parent_id // 拉长搜索框(与父亲右边对齐)
// 跳转到搜索结果页
findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result,
bundleOf("keywords" to etSearch?.text.toString())
)
tvSearch.visibility = gone
KeyboardUtils.hideSoftInput(etSearch)
}
}

这样写的坏处如下:


1. 业务 & 界面耦合



  • “界面长什么样”和“哪些事件会触发界面重绘”是两个不同的关注点,它们可以独立变化,前者由 UI 设计发起变更,后者由产品发起变更。
  • 耦合增加代码量以及复杂度,高复杂度增加理解难度且容易出错。比如当别人接手该模块看着 1000+ 的 Activity 无所适从时。再比如你修改了界面展示,而另一个同学修改了业务逻辑,合代码时,你俩可能发生冲突,冲突解决不好就会产生 Bug。
  • 高耦合还降低了复用性。界面和业务耦合在一起,使得它们都无法单独被复用。即界面无法复用于另一个业务,而业务也无法复用于另一个界面。

2. 低内聚的界面绘制



  • 同一个控件的绘制逻辑散落在各个地方,分散在不同的方法中,分散在现在和将来的逻辑中(回调)。
  • 低内聚同样也增加了复杂度。就好比玩剧本杀,线索散落在场地的各个角落,你得先搜出线索,然后再将他们拼凑起来,才能形成完整的认知。再比如 y=f(x),唯一x决定唯一y,而低内聚的代码就好比y=f(a,b,c,d),任意一个变化源的改变的都会影响界面状态。当UI变更时极易产生“没改全”的 Bug,对于一个小的 UI 改动,不得不搜索整段代码,找出所有对控件的引用,漏掉一个就是 Bug。

搜索条的业务相对简单,initView()看上去也没那么复杂。如果延续“高业务耦合+低绘制内聚”的写法,当界面越来越复杂之后,1000+ 行的 Activity 不是梦。


用一张图来表达所有的复杂度在 Activity 层铺开:


微信截图_20220903170226.png


业务和界面分离


业务逻辑和界面绘制是两个不同的关注点,它们本可以不在一个层次中被铺开。


MVP 架构引入了 P(Presenter)层用于承载业务逻辑,实现了复杂度分层:


interface SearchPresenter {
// 初始化
fun init()
// 返回
fun backPress()
// 清空关键词
fun clearKeyword()
// 发起搜索
fun search(keyword: String, from: SearchFrom)
// 输入关键词
fun inputKeyword(keyword: String)
}

Presenter 称为业务接口,它将所有界面可以发出的动作都表达成接口中的方法。接口是编程语言中表达“抽象”的手段。这是个了不起的发明,因为它把“做什么”和“怎么做”隔离。


界面会持有一个 Presenter 的实例,把业务逻辑委托给它,这使得界面只需要关注“做什么”,而不需要关注“怎么做”。所以业务接口做到了界面绘制和业务逻辑的解耦。


业务逻辑最终会指导界面如何绘制,在 MVP 中通过View 层界面来表达:


interface SearchView {
fun onInit(keyword: String)
fun onBackPress()
fun onClearKeyword()
fun onSearch()
fun onInputKeyword(keyword:String)
}

Presenter 的实现者会持有一个 View 层接口实例:


class SearchPresenterImpl(private val searchView: SearchView) :SearchPresenter{
override fun init() {
searchView.onInit("")
}
override fun backPress() {
searchView.onBackPress()
}
override fun clearKeyword() {
searchView.onClearKeyword()
}
override fun search(keyword: String, from: SearchFrom) {
searchView.onSearch()
}
override fun inputKeyword(keyword: String) {
searchView.onInputKeyword(keyword)
}
}

Presenter 调用 View 层接口指导界面绘制,界面通过实现 View 层接口实现绘制:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
private val searchPresenter: SearchPresenter = SearchPresenterImpl(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchPresenter.init()
}
override fun onBackPressed() {
super.onBackPressed()
searchPresenter.backPress()
}
// 实现 View 层接口进行界面绘制
override fun onInit(keyword: String) {...}
override fun onBackPress() {...}
override fun onClearKeyword() {...}
override fun onSearch() {...}
override fun onInputKeyword(keyword:String) {...}
}

分离了个寂寞?


这样的实现太脱裤子放屁了。就好比三楼同事想给五楼同事一样东西,非得叫顺丰快递,然后顺丰又托运给了申通快递。


非也!当持有一个“抽象”而不是“具体实现”时,好事就会发生!


Activity 和抽象的 SearchPresenter 接口互动,就能发生多态,即动态地替换业务逻辑的实现。


比如产品希望做一个实验,把用户分成A/B两组,A组在进入搜索页的同时把上一次用户搜索的历史直接展示在输入框中,B组则是展示今天的搜索热词。


同样的初始化动作,同样的在输入框中键入内容,不同的是获取数据的方式,A组从本地磁盘获取搜索历史,而B组从网络获取搜索热词。


初始化动作对应“做什么”,输入框中键入内容对应“展示什么”,获取数据的方式对应“怎么做”。如果这些逻辑没有分层而都写在一起,那只能通过在 Activity 中的 if-else 实现:


class TemplateSearchActivity : AppCompatActivity() {
val abtest by lazy { intent.getStringExtra("ab-test") }
fun initView() {
if(abTest == "A"){
// 输入框展示搜索历史
} else {
// 输入框展示搜索热词
}
}
}

若这种分类讨论用上瘾,Activity 代码会以极快的速度膨胀,可读性骤降,最糟糕的是一改就容易出 Bug。因为界面绘制没有内聚在一点,而是散落在各种逻辑分支中,不同分支之间的逻辑可能是互斥,或是协同。。。等等总之极其复杂。


有了抽象的 SearchPresenter 就好办了,抽象意味着可以发生多态。


多态是编程语言支持的一种特性,这种特性使得静态的代码运行时可能产生动态的行为,这样一来编程时不需要为类型所烦恼,可以编写统一的处理逻辑而不是依赖特定的类型。”


可见使用多态可以解耦,通过语言内建的机制实现 if-else 的效果:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
private val abtest by lazy { intent.getStringExtra("ab-test") }
// 根据命中实验组构建 SearchPresenter 实例
private val searchPresenter:SearchPresenter by lazy {
when(type){
"A" -> SearchPresenterImplA(this)
"B" -> SearchPresenterImplB(this)
else -> SearchPresenterImplA(this) // 默认进A实验组
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchPresenter.init() // 不用做任何修改,也没有 if-else
}

override fun onInit(keyword: String){
etSearch.setText(keyword, TextView.BufferType.EDITABLE)// 不用做任何修改,也没有 if-else
}
}

然后只要实现两个不同的 SearchPresenter 即可:


class SearchPresenterImplA(private val searchView: SearchView) :SearchPresenter{
override fun init() {
val keyword = loadFromLocal()// 拉取持久化的搜索历史
searchView.onInit(keyword)
}
}

class SearchPresenterImplB(private val searchView: SearchView) :SearchPresenter{
override fun init() {
val keyword = loadFromRemote()// 从网络拉取搜索热词
searchView.onInit(keyword)
}
}

若使用依赖注入框架,比如 Dagger2 或 Hilt,还能把根据AB测实验组分类讨论构建 Presenter 实例的逻辑简化,真正做到业务代码中无分类讨论。


如果 SearchPresenter 中只有 init() 的逻辑在 AB 测场景下不同,那上述方案中其余相同的逻辑需要实现两份?


不需要,用装饰者模式就可以复用剩余的行为:


class SearchPresenterImplA(
private val searchView: SearchView,
private val presenter: SearchPresenter // 自己持有自己
) :SearchPresenter{
override fun init() {
val keyword = loadFromLocal()// 拉取持久化的搜索历史
searchView.onInit(keyword)
}
override fun backPress() {
presenter.backPress()// 实现委托给presenter
}
override fun touchSearchBar(text: String, isUserInput: Boolean) {
presenter.touchSearchBar(text, isUserInput)// 实现委托给presenter
}
override fun clearKeyword() {
presenter.clearKeyword()// 实现委托给presenter
}
override fun search(keyword: String, from: SearchFrom) {
presenter.search(keyword, from)// 实现委托给presenter
}
override fun inputKeyword(keyword: String) {
presenter.search(keyword)// 实现委托给presenter
}
}

// 像这样构建 SearchPresenterImplA
class TemplateSearchActivity : AppCompatActivity(), SearchView {
val presenter = SearchPresenterImplA(this, SearchPresenterImplB(this))
}

SearchPresenterImplA 持有另一个 SearchPresenter,并且把剩余方法的实现委托给它。


关于装饰者模式更详细的介绍可以点击使用组合的设计模式 | 美颜相机中的装饰者模式


这样一来,就把“界面长什么样”和“AB测试”解耦,它们分处于不同的层次,前者在 Activity 属于 View 层,后者属于 Presenter 层。解耦的同时也发生了内聚,关于界面绘制的知识都内聚在 Activity,关于业务逻辑的知识都内聚在 Presenter。


假设界面和业务耦合在一起,后果不堪设想。因为业务的变化是飞快的,今天是 AB 测,明天可能是从不同入口进入搜索页,上报不同的埋点。类似这种情况 Activity 的逻辑会被成堆的 if-else 玩坏。


阶段性总结:



界面和业务分层之后(复杂度被分层),它们就能独立变化(高扩展性),独立复用(高复用性),再配合上“面向抽象编程”,使得业务的逻辑分支被巧妙的隐藏起来(复杂度被隐藏)。



有限的内聚


这样的 View 层接口定义会产生一个问题:


class TemplateSearchActivity : AppCompatActivity() {
override fun onBackPress() {
vInputBg.end_toStartOf = ID_SEARCH // 搜索框右侧对齐搜索按钮
ivClear.visibility = visible
}

override fun onClearKeyword() {
vInputBg.end_toStartOf = ID_SEARCH // 搜索框右侧对齐搜索按钮
ivClear.visibility = gone
}

override fun onSearch() {
vInputBg.end_toStartOf = parent_id // 搜索框右侧对齐父容器
}

override fun onInputKeyword(keyword: String) {
ivClear.visibility = if(keyword.isNotEmpty()) visible else gone
}
}
复制代码

一个控件应该长成什么样的代码依然散落在不同方法中,就像上一篇描述的一样。


这样容易发生“改不全”或“功能衰退”的 Bug,比如搜索页新增了一个业务逻辑,一个新的 View 层接口被实现,该接口的实现需要非常小心,因为它修改的控件也会在其他 View 层接口被修改,你得确保它们不会发生冲突。


之所以会这样,是因为“View 层接口面向业务进行抽象”,其实从接口的命名就可以看出。


更好的做法是“在 View 层接口屏蔽业务动作,只关心做怎么样的绘制”:


interface SearchView {
fun initView() // 初始化
fun showClearButton(show: Boolean)// 展示X
fun highlightSearchButton(show: Boolean) // 高亮搜索按钮
fun gotoSearchPage(keyword: String, from: SearchFrom) // 跳转到搜索结果页
fun stretchSearchBar(stretch: Boolean) // 拉伸搜索框
fun showSearchButton(highlight: Boolean, show: Boolean) // 展示搜索按钮
fun clearKeyword(clear:Boolean) // 清空关键词
fun gotoHistoryPage()// 返回历史页
}

这下 View 层接口描述的都是展示怎么样的界面,Presenter 和 Activity 的代码得做相应的修改:


class SearchPresenterImpl(private val searchView: SearchView) :SearchPresenter{
override fun init() {
searchView.initView()
}
override fun backPress() {
searchView.stretchSearchBar(false)
searchView.showSearchButton(true)
searchView.clearKeyword(true)
}
override fun clearKeyword() {
searchView.highlightSearchButton(false)
searchView.showClearButton(false)
searchView.showSearchButton(true)
searchView.stretchSearchBar(false)
searchView.clearKeyword(true)
searchView.gotoHistoryPage()
}
override fun search(keyword: String, from: SearchFrom) {
searchView.gotoSearchPage(keyword, from)
searchView.stretchSearchBar(true)
searchView.showSearchButton(false)
}
override fun inputKeyword(keyword: String) {
if (keyword.isNotEmpty()) {
searchView.showClearButton(true)
searchView.highlightSearchButton(true)
} else {
searchView.showClearButton(false)
searchView.highlightSearchButton(false)
}
}
}

这样的 Presenter 看上去就没那么“脱裤子放屁”了,它不仅仅是一个界面动作的转发者,它包含了一点业务逻辑。


对应的 Activity 修改如下:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
private val searchPresenter: SearchPresenter = SearchPresenterImpl(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchPresenter.init()
}

override fun onBackPressed() {
super.onBackPressed()
searchPresenter.backPress()
}

override fun initView() {
etSearch.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
if (etSearch.text.toString().isNotEmpty())
searchPresenter.onSearchBarTouch(etSearch.text.toString(), true)
}
false
}

tvSearch.onClick = {
searchPresenter.search(etSearch.text.toString(), SearchFrom.BUTTON)
}

etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
searchPresenter.inputKeyword(input)
}

override fun afterTextChanged(s: Editable?) {
}
})
etSearch.requestFocus()
KeyboardUtils.showSoftInput(etSearch)
}

override fun showClearButton(show: Boolean) {
ivClear.visibility = if (show) visible else gone
}

override fun gotoSearchPage(keyword: String, from: SearchFrom) {
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_to_result,
bundleOf("keywords" to keyword)
)
}
KeyboardUtils.hideSoftInput(etSearch)
StudioReport.reportSearchButtonClick(keyword, from.typeInt)
}

override fun stretchSearchBar(stretch: Boolean) {
vInputBg.apply {
if (stretch) end_toEndOf = parent_id
else end_toStartOf = ID_SEARCH
}
}

override fun showSearchButton(highlight: Boolean, show: Boolean) {
tvSearch.apply {
visibility = if(show) visible else gone
textColor = if(highlight) "#F2F4FF" else "#484951"
isEnable = highlight
}
}

override fun clearKeyword(clear: Boolean) {
etSearch.apply {
text = null
requestFocus()
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

override fun gotoHistoryPage(clear: Boolean) {
findNavController(NAV_HOST_ID.toLayoutId()).popBackStack()
}
}

同一控件的绘制逻辑总算内聚到一个方法中了,但不同控件的绘制逻辑还是散落在不同的方法。


不同控件的显示是有协同或互斥关系的,比如搜索条拉长时,搜索按钮得隐藏。但拉长搜索条和搜索按钮的绘制分处于不同的 View 层接口,这里就有一个潜规则:“在调拉长搜索条方法的同时,必须同时调用隐藏搜索按钮方法”。当 Presenter 中充斥着这种潜规则时,就会发生界面状态不一致的问题。(最常见的比如,列表加载成功后,loading 还在转圈圈)


之所以会这样是因为 MVP 只是在“低内聚的界面绘制”基础上往前进了一小步,做到了单个控件绘制逻辑的内聚。而 MVI 又进了一步,做到了整个界面绘制逻辑的内聚。(实现细节在后面的篇章展开)


经过 MVP 的重构,现在架构如下图所示:


微信截图_20221004170848.png


为啥看上去,比无架构方案还要复杂一点?


没错,MVP 架构引入了新的复杂度。首先是新增一个 Presenter 类,接着还引入了两个接口:业务接口+ View 层接口。这是实现解耦的必要代价。


引入 Presenter 层也有收益,与“复杂度在 View 层被铺开”相比,现在的 View 层要精简得多,也单纯的多。但复杂度被不是凭空消失了,而是被分层,被转移。从图中可以看出现在的复杂度聚集在 Presenter 中业务接口和 View 层接口的交互。MVI 用了一种新的思想方法来化解这个复杂度。(后续篇章会展开分析)


总结



  • MVP 引入了业务逻辑层 P(Presenter),使得界面绘制和业务逻辑分开,降低了它们的耦合,形成相互独立的界面层 V 和业务逻辑层 P。界面代码的复杂度得以降低也变得更加单纯。
  • MVP 通过接口实现界面层和业务逻辑层的双向通信,界面层通过业务接口向业务逻辑层发起请求。业务逻辑层通过 View 层接口指导界面绘制。接口是一种抽象手段,它把做什么和怎么做分离,为发生多态提供了便利。
  • MVP 中 View 层接口的抽象应该面向“界面绘制”而不是“面向业务”。这样做不仅可以让界面绘制逻辑变得内聚,也让增加了代码的复用性。

作者:唐子玄
链接:https://juejin.cn/post/7151809622586687524
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册