复杂度
软件的首要技术使命是“管理复杂度” —— 《代码大全》
因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。
架构的目的在于“将复杂度分层”
复杂度为什么要被分层?
若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。
举一个复杂度不分层的例子:
小李:“你会做什么菜?”
小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”
听了小明的回答,你还会和他做朋友吗?
小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。
小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。
这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。
再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:
- 物理层
- 数据链路成
- 网络层
- 传输层
- 应用层
其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。
这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。
有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。
引子
为了降低客户端领域开发的复杂度,架构也在不断地演进。从 MVC 到 MVP,再到 MVVM,目前已经发展到 MVI。
MVVM 仍然是当下最常用的 Android 端架构,曾经的榜一大哥 MVP 已日落西山。
下图是 Google Trends 关于 “android mvvm” 和 “android mvp”的对比图,剪刀差发生在2018年:
2018 年到底发生了什么使得架构改朝换代?
MVI 在架构设计上又做了哪些新的尝试?它是否能在将来取代 MVVM?
被如此多新名词弄得头晕脑胀的我,不由得倔强反问:“不是用架构又会怎么样?”
该系列以实战项目中的搜索场景为剧本,演绎了如何运用不同架构进行重构的过程,并逐个给出上述问题自己的理解。
搜索是 App 中常见的业务场景,该功能示意图如下:
业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史直接发起搜索跳转到结果页。
搜索页面框架设计如下:
搜索页用Activity
来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment
承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。
上一篇用无架构的方式实现了搜索条,这一篇接着用这种方式实现搜索历史界面,看看无架构会产生什么痛点。
搜索历史界面如下图所示:
它以一个 Fragment 的形式嵌入到搜索页 Activity 中:
class SearchHistoryFragment : Fragment() { private lateinit var tvHistory: TextView private lateinit var ivDelete: ImageView private lateinit var flowSearchHistory: LineFeedLayout private lateinit var ivSwitch: ImageView private val contentView by lazy(LazyThreadSafetyMode.NONE) { ConstraintLayout { layout_width = match_parent layout_height = match_parent // 搜索历史 tvHistory = TextView { layout_id = "tvHistory" layout_width = wrap_content layout_height = wrap_content textSize = 16f textColor = "#F0F2FB" text = "搜索历史" gravity = gravity_center start_toStartOf = parent_id top_toTopOf = parent_id margin_start = 16 margin_top = 18 } // 删除按钮 ivDelete = ImageView { layout_id = "ivDelete" layout_width = 20 layout_height = 20 scaleType = scale_fit_xy end_toEndOf = parent_id align_vertical_to = "tvHistory" margin_end = 16 src = R.drawable.search_delete_history onClick = { showDeleteConfirmDialog() } } // 搜索历史标签 flowSearchHistory = LineFeedLayout { layout_id = "fSearchHistory" layout_width = match_parent layout_height = 70 top_toBottomOf = "tvHistory" margin_horizontal = 16 margin_top = 14 verticalGap = 10.dp horizontalGap = 8.dp } // 折叠开关 ivSwitch = ImageView { layout_id = "ivSwitch" layout_width = 30 layout_height = 30 scaleType = scale_fit_xy imageDrawable = StateListDrawable().apply { addState(intArrayOf(state_selected), ContextCompat.getDrawable(context, R.drawable.template_history_off)) addState(intArrayOf(state_unselected), ContextCompat.getDrawable(context, R.drawable.template_history_on)) } top_toBottomOf = "fSearchHistory" center_horizontal = true margin_top = 20 isSelected = false } } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return contentView } }
上述代码使用了 Kotlin 的 DSL 使得可以用声明式的语法动态的构建视图,避免了 XML 的解析并加载到内存,以及 findViewById() 遍历查找时间复杂度,性能略好,但缺点是无法预览。
关于 运用 Kotlin DSL 动态构建布局的详细讲解可以点击Android性能优化 | 把构建布局用时缩短 20 倍(下)
这套构建布局的 DSL 源码可以在这里找到wisdomtl/Layout_DSL: Build Android layout dynamically with kotlin, get rid of xml file, which has poor performance (github.com)
其中的 LineFeedLayout 是历史标签的容器,一个横向铺开自动换行的自定义控件:
class LineFeedLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr) { var horizontalGap: Int = 0 var verticalGap: Int = 0 var onNewLine: ((Int) -> Unit)? = null private var lines = 0 // 用挂起的方式获取行数 suspend fun getLines() = suspendCancellableCoroutine<Int> { continuation -> post { continuation.resume(lines) } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { measureChildren(widthMeasureSpec, heightMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) val width = MeasureSpec.getSize(widthMeasureSpec) var height = 0 var remainWidth = width lines = if (childCount > 0) 1 else 0 onNewLine?.invoke(lines) // 遍历孩子逐个测量 (0 until childCount).map { getChildAt(it) }.forEach { child -> val lp = child.layoutParams as? MarginLayoutParams val appendWidth = child.measuredWidth + lp?.marginStart.orZero + lp?.marginEnd.orZero if (isNewLine(appendWidth, remainWidth)) { remainWidth = width - child.measuredWidth height += (lp?.topMargin.orZero + lp?.bottomMargin.orZero + child.measuredHeight + verticalGap) ++lines onNewLine?.invoke(lines) } else { remainWidth -= child.measuredWidth if (height == 0) height = (lp?.topMargin.orZero + lp?.bottomMargin.orZero + child.measuredHeight + verticalGap) } remainWidth -= (lp?.leftMargin.orZero + lp?.rightMargin.orZero + horizontalGap) } if (heightMode == MeasureSpec.EXACTLY) { height = MeasureSpec.getSize(heightMeasureSpec) } // 待孩子测量完后,决定自己的宽高 setMeasuredDimension(width, height) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { var left = 0 var top = 0 var lastBottom = 0 // 遍历孩子逐个布局 (0 until childCount).map { getChildAt(it) }.forEach { child -> val lp = child.layoutParams as? MarginLayoutParams val appendWidth = child.measuredWidth + lp?.marginStart.orZero + lp?.marginEnd.orZero if (isNewLine(appendWidth, r - l - left)) { left = -lp?.leftMargin.orZero top = lastBottom lastBottom = 0 } val childLeft = left + lp?.leftMargin.orZero val childTop = top + lp?.topMargin.orZero child.layout( childLeft, childTop, childLeft + child.measuredWidth, childTop + child.measuredHeight ) if (lastBottom == 0) lastBottom = child.bottom + lp?.bottomMargin.orZero + verticalGap left += child.measuredWidth + lp?.leftMargin.orZero + lp?.rightMargin.orZero + horizontalGap } } private fun isNewLine(usedWidth: Int, remainWidth: Int): Boolean = usedWidth > remainWidth } val Int?.orZero: Int get() = this ?: 0
其实借助于 ConstraintLayout + Flow 的组合也能实现自动换行效果。但若想获取换行控件的行数则不是件容易的事。产品要求默认只展示两行历史,若超过了两行则显示展开按钮,该按钮的展示与否依赖行数。
遂自定义了一个容器控件,这样控件的换行对我们来说就不再是黑盒了。
关于该自定义控件源码的详细解析可以点击 Android自定义控件 | 源码里有宝藏之自动换行控件。上述代码,在这篇文章的基础上,新增了一个属性 lines 和获取它的 suspend 方法:
private var lines = 0 suspend fun getLines() = suspendCancellableCoroutine<Int> { continuation -> post { continuation.resume(lines) } }
lines 表示子标签横向铺开后的行数,之所以要用 suspend 方法获取它,是因为行数的计算是发生在未来的,即得等到 View 树遍历完成后,lines 才被赋值。
界面间耦合的通信
产品要求:新的搜索词会展示在最前面,且最多展示11条历史(先进先出)
新增搜索词有两个入口,分别是搜索页及键盘上的搜索按钮。它们的点击事件都发生在搜索页 Activity 中,而搜索历史展示在历史页 Frgment 中,这是一个跨界面通信的场景:Activity 中的一个动作将改变 Fragment 中的展示。
Activity 和 Fragment 之间有诸多通信方式。最直接的方式莫过于“直接方法调用”,因为 Fragment 和 Activity 都能方便地拿到对方的引用,这样就能直接调用对方的方法。
为历史页 Fragment 新增公共方法addHistory()
:
// SearchHistoryFragment.kt private val historys = mutableListOf<String>() // 所有历史列表 private var showAllHistory = false // 是否显示所有历史开关 fun addHistory(keyword: String) { if(historys.contains(keyword)) { // 若已包含关键词,则置顶 historys.remove(keyword) historys.add(0, keyword) } else { // 若不包含关键词,则头插入 historys.add(0, keyword) if (historys.size >= 12) historys.removeLast() // 历史尾删除,控制历史数量 } // 根据历史列表重新构建历史标签 flowSearchHistory.apply { removeAllViews() historys.forEach { addView(getHistoryTagView(it)) } } // 显示搜索历史以及删除图标 tvHistory.visibility = visible ivDelete.visibility = visible lifecycleScope.launch { // 以挂起方式获取历史标签行数 val lines = flowSearchHistory.getLines() // 若历史标签超过2行,根据开关调整历史控件高度 if (lines > 2) { flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> { height = if (showAllHistory) wrap_content else 70.dp } } // 若超过2行,则显示开关 ivSwitch.apply { visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible isSelected = showAllHistory } } } // 动态构建历史标签 private fun getHistoryTagView(tag: String) = BTextView { layout_id = tag layout_width = wrap_content layout_height = wrap_content textSize = 12f textColor = "#ffffff" text = tag gravity = gravity_center padding_horizontal = 12 padding_vertical = 7 maxLines = 1 ellipsize = ellipsize_end shape = shape { corner_radius = 25 solid_color = "#2C2D3E" } }
然后在搜索页 Activity 的两个入口增加通信逻辑:
class TemplateSearchActivity : AppCompatActivity() { private lateinit var etSearch: EditText private lateinit var tvSearch: TextView private fun initView() { // 通知历史页新增关键词 tvSearch.onClick = { addHistory(etSearch.text.toString()) } // 通知历史页新增关键词 etSearch.setOnEditorActionListener { v, actionId, event -> if (actionId == EditorInfo.IME_ACTION_SEARCH) { val input = etSearch.text?.toString() ?: "" if (input.isNotEmpty()) addHistory(input) true } else false } } // 在 Activity 中获取 Fragment 对象并调用其方法 private fun addHistory(keyword: String) { supportFragmentManager .fragments[0] .childFragmentManager .findFragmentById(R.id.SearchHistoryFragment).addHistory(keyword) } }
产品需求:在点击清空历史时收起键盘
清空历史的按钮在 Fragment 中,而和键盘关联的 EditText 在 Activity 中,这是一个从 Fragment 发起的反向通信。依葫芦画瓢,得在 Activity 中新增一个方法以实现收起键盘,然后在 Fragment 中获取 Activity 实例并调用该方法。
这种通信方式是耦合的,因为双方都持有“具体的对方”。假设另一个搜索业务场景的历史页长得和它不同,则新历史页无法和搜索页 Activity 合作。
更解耦的方式是广播,即 Activity 发送广播,Fragment 监听广播:
class TemplateSearchActivity : AppCompatActivity() { private lateinit var etSearch: EditText private lateinit var tvSearch: TextView private fun initView() { tvSearch.onClick = { addHistory(etSearch.text.toString()) } etSearch.setOnEditorActionListener { v, actionId, event -> if (actionId == EditorInfo.IME_ACTION_SEARCH) { val input = etSearch.text?.toString() ?: "" if (input.isNotEmpty()) addHistory(input) true } else false } } private fun addHistory(keyword: String) { // 发广播 LocalBroadcastManager .getInstance(context) .sendBroadcast( Intent("add-history").apply { putExtra("keyword", keyword) } ) } }
Fragment 监听广播:
class SearchHistoryFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) context?.let { LocalBroadcastManager .getInstance(it) .registerReceiver(HistoryReceiver(), IntentFilter("add-history")) } } inner class HistoryReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == "add-history") { addHistory(intent.getStringExtra("keyword").orEmpty()) } } } }
若更换历史页 Fragment,只需要监听广播,就能和搜索页 Activity 的业务逻辑衔接上。
当前业务场景中,必须在 Fragment.onCreate() 注册广播,且在 Fragment.onDestroy() 注销。因为点击联想词也会产生搜索历史,而此时历史页被联想页覆盖,若在 Fragment.onPause() 注销的话,则无法收到联想页发来的广播(广播不是粘性的,即老值不会分发给新观察者)。
但 androidx.localbroadcastmanager 已经在 1.1.0-alpha01 版本被废弃了。若还想用广播的方式完成界面间通信,只能使用 EventBus 了,对于当前场景来说,大可不必。
若能有一个媒介,Activity 和 Fragment 都能轻松地获取它,它就能承载跨界面通信的功能。
这个媒介在 MVP 架构中是 P,即 Presenter。它会在 Activity 中被构建,Fragment 通过获取 Activity 的实例就能访问到它。(该系列后续会展开实现细节)
若采用 MVVM 或 MVI 架构,Activity 和其子 Fragment 之间的通信就可以通过 ViewModel 以更轻松的方式实现。(该系列后续会展开实现细节)
在 Activity 中存取数据
产品需求:进入搜索页时,展示历史搜索,默认展示两行历史,超过两行的内容可进行折叠/展开
得把搜索历史持久化,它是一个字符串列表,使用 MMKV 就能满足要求。关于 MMKV 的详细介绍可以点击Tencent/MMKV: An efficient, small mobile key-value storage framework developed by WeChat. Works on Android, iOS, macOS, Windows, and POSIX. (github.com)
SearchHistoryFragment.addHistory() 是历史发生变更的点,遂在其中增加持久化逻辑:
// SearchHistoryFragment.kt private var historys = mutableListOf<String>() fun addHistory(keyword: String) { if (historys.contains(keyword)) { historys.remove(keyword) historys.add(0, keyword) } else { historys.add(0, keyword) if (historys.size >= 12) historys.removeLast() } // 当历史发生变更时,持久化它 val bundle = Bundle().apply { putStringArray("historys", historys.toTypedArray()) } MMKV.mmkvWithID("template-search")?.encode("search-history", bundle) flowSearchHistory.apply { removeAllViews() historys.forEach { addView(getHistoryTagView(it)) } } tvHistory.visibility = visible ivDelete.visibility = visible lifecycleScope.launch { val lines = flowSearchHistory.getLines() if (lines > HISTORY_FOLDED_MAX_LINES) { flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> { height = if (showAllHistory) wrap_content else 70.dp } } ivSwitch.apply { visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible isSelected = showAllHistory } } }
持久化的方式是将历史列表存储在 Bundle 中,然后再将 Bundle 存储在 MMKV 中。之所以增加了一层 Bundle 是为了保持历史搜索的顺序。
还得在页面启动时,从 MMKV 读取内容并以此重绘界面:
// SearchHistoryFragment.kt private var historys = mutableListOf<String>() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 从 MMKV 读搜索历史 val historyBundle = MMKV.mmkvWithID("template-search")?.decodeParcelable("search-history", Bundle::class.java) historyBundle?.let { val historys = it.getStringArray("historys") ?: emptyArray() if (historys.isNotEmpty()) { // 将搜索历史存储在 Fragment 的成员 historys 中 this.historys = historys.toMutableList() // 重绘界面 flowSearchHistory.apply { removeAllViews() historys.forEach { addView(getHistoryTagView(it)) } } tvHistory.visibility = visible ivDelete.visibility = visible lifecycleScope.launch { val lines = flowSearchHistory.getLines() if (lines > HISTORY_FOLDED_MAX_LINES) { flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> { height = if (showAllHistory) wrap_content else 70.dp } } ivSwitch.apply { visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible isSelected = showAllHistory } } } } }
这段代码写完,Activity 和一个新的类发生了耦合:MMKV
。
调用 MMKV 的 api 实现数据存取,这属于数据存取的细节。
如果大量的细节在同一层次被铺开,代码就显得啰嗦,增加的理解成本。项目中超过 1000 行的 Activity 就是这样被堆砌出来的。在编辑器打开这些上帝类得卡半天。
细节通常容易发生变化。拿持久化数据举例,从刚开始的 SharedPreference,到性能更好的 MMKV,再到更符合 MAD 的 DataStore。(MAD = Modern Android Development)。
发生细节的变更时,应该将影响面控制到最小,以尽可能地实现“更安全地变更”。当所有的细节都在 Activity 中被铺开时,一个小细节变更的影响面就被放大。比如你修改了 Activity 中持久化数据的细节,而同事修改了 Activity 界面展示的细节,很不巧合代码时,发生冲突了,然后。。。。。(你一定知道我省略了什么,因代码冲突引入的bug还少吗?)
这样安排代码也违反了单一职责原则,即类应该尽量单纯,最好只做一件事情。
若使用合适的架构,这耦合是可以避免的。(实现细节会在后续文章展开)
尘不归尘,土不归土
产品需求:当历史超过两行时,会展示折叠开关,点击它可进行展开或折叠:
// SearchHistoryFragment.kt private var showAllHistory = false ivSwitch.onClick = { isSelected = isSelected.not() // 变换开关状态 showAllHistory = isSelected // 将开关状态记录在 Fragment 的成员变量中 // 以挂起方式获取控件行数 lifecycleScope.launch { val lines = flowSearchHistory.getLines() // 根据行数重绘控件高度(70.dp 表示两行高度,wrap_content 表示完全铺开) if (lines > HISTORY_FOLDED_MAX_LINES) { flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> { height = if (showAllHistory) wrap_content else 70.dp } } // 根据行数判断是否展示开关 ivSwitch.apply { visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible isSelected = showAllHistory } } }
产品需求:删除历史记录弹窗确认。
// SearchHistoryFragment.kt ivDelete.onClick = { showDeleteConfirmDialog() } private fun showDeleteConfirmDialog() { DialogHelper.createBDialog(requireContext()) .setMessage("确认删除?") .setPositiveButton("确认") { historys.clear() tvHistory.visibility = gone ivDelete.visibility = gone ivSwitch.visibility = gone flowSearchHistory.removeAllViews() } .setNegativeButton("取消") { } .show() }
写完这段代码之后,控制折叠控件展示的逻辑已经分散在 4 个地方了:1. 新增关键词时 2. 启动历史页读取持久化历史时 3. 点击历史开关时 4. 清空历史时
当产品希望默认展示 1 行历史搜索时,需要改 3 个地方。
一个简单改善方法是将历史控件的重绘逻辑抽象为一个方法,然后分别在 3 个地方调用它。但其实这个抽象没这么好做,因为每一处的刷新逻辑不完全一样:
// SearchHistoryFragment.kt private fun updateFlowHeightAndSwitch() { lifecycleScope.launch { val lines = flowSearchHistory.getLines() if (lines > HISTORY_FOLDED_MAX_LINES) { flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> { height = if (showAllHistory) wrap_content else 70.dp } } ivSwitch.apply { visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible isSelected = showAllHistory } } }
这是根据行数刷新历史控件高度以及展示折叠开关的逻辑。这段逻辑会在折叠历史时调用,并且还会在新增历史,以及启动历史页时调用,不过后面两处调用还得带上新的逻辑:
// SearchHistoryFragment.kt private fun showFlow() { flowSearchHistory.apply { removeAllViews() this@SearchHistoryFragment.historys.forEach { addView(getHistoryTagView(it))} } tvHistory.visibility = visible ivDelete.visibility = visible updateFlowHeightAndSwitch() }
即使已经抽象出了两个方法,尽可能地让刷新历史控件的逻辑内聚,但依然有一段零散的逻辑在清空历史的地方:
flowSearchHistory.removeAllViews()
这段代码犯了和上一篇同样的错误,即支离破碎的刷新逻辑。但因为这次业务逻辑更复杂,所以即使尽了最大努力,依然无法做到将历史控件的刷新逻辑内聚到一个方法内部。
这是因为“从业务视角做视图构建的抽象”,代码分别定义了新增历史、启动历史页、历史折叠、清空历史时的视图构建逻辑。为了代码的复用,抽象了一些“奇怪”的构建方法,从它们名字就可以看出这点。
那些介于同一变量多个引用点之间的代码称为“攻击窗口”(window of vulnerability)。可能有新的代码加到这些窗口中,不当地修改了这个变量。所以应该尽量缩小变量的作用域,把它的引用点尽可能集中在一起是一个很好的做法。——《代码大全》
我看到上面描述后的感想是:“除非万不得已,不然就不要声明成员变量”。成员变量的作用域相较于局部变量要扩大了很多,因为所有的成员方法都可以无障碍的访问它,若类还公开了对成员变量的修改方法,就等于又给变量打开了一个攻击窗口。
Android 中将布局的构建写在 xml 中,然后在 Activity 中获取 View 的引用,天然地造就了 Activity 持有了很多 View 的引用。若对 View 的更新又不内聚在一个方法中,则 View 天然就拥有了很多攻击窗口,一不小心就会出现意料之外的界面状态。
构建视图及对视图刷新逻辑本不该分开,理想状态下 View 应该以观察者的身份观察一个 Model,它的构建和刷新都依赖于 Model 的变更。这样可以避免界面状态的不一致。先进的 UI 框架都遵循了这个思想,比如 Flutter,Compose。
“视图应该长什么样?”和“哪些事件会触发它重绘?”是两个独立的变化源。
比如美术希望更换每个搜索历史标签的背景色,再比如产品希望按搜索总次数降序排列历史。
这是两个可以分离的关注点,它们应该被安排在不同的类中。这样做有诸多好处:
- 更安全地变更:其中一个的变化不会破坏另一个原有的功能。
- 更低的复杂度:视图构建逻辑和业务逻辑在两个层次被铺开,各自变得更纯粹,理解难度降低。
- 更大的复用性:当视图和业务逻辑分开时,各自都增加了被复用的可能性,因为它们更纯粹了。
关于如何利用架构实现关注点分离,会在后续的文章中展开。
推荐阅读
MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(一)
MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)
MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(三)
“无架构”和“MVP”都救不了业务代码,MVVM能力挽狂澜?(一)