随笔博文

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

2022-12-14 13:00:53 michael007js 41

复杂度

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

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

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

复杂度为什么要被分层?

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

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

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

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

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

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

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

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

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

  1. 物理层

  2. 数据链路成

  3. 网络层

  4. 传输层

  5. 应用层

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

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

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

引子

为了降低客户端领域开发的复杂度,架构也在不断地演进。从 MVC 到 MVP,再到 MVVM,目前已经发展到 MVI。

MVVM 仍然是当下最常用的 Android 端架构,曾经的榜一大哥 MVP 已日落西山。

下图是 Google Trends 关于 “android mvvm” 和 “android mvp”的对比图,剪刀差发生在2018年:

微信图片_20220904192016.png

2018 年到底发生了什么使得架构改朝换代?

MVI 在架构设计上又做了哪些新的尝试?它是否能在将来取代 MVVM?

被如此多新名词弄得头晕脑胀的我,不由得倔强反问:“不是用架构又会怎么样?”

该系列以实战项目中的搜索场景为剧本,演绎了如何运用不同架构进行重构的过程,并逐个给出上述问题自己的理解。

搜索是 App 中常见的业务场景,该功能示意图如下:

1662106805162.gif

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

搜索页面框架设计如下: 微信截图_20220902171024.png

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

上一篇用无架构的方式实现了搜索条,这一篇接着用这种方式实现搜索历史界面,看看无架构会产生什么痛点。

搜索历史界面如下图所示:

微信图片_20220912175228.jpg

它以一个 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条历史(先进先出)

1662983642391.gif

新增搜索词有两个入口,分别是搜索页及键盘上的搜索按钮。它们的点击事件都发生在搜索页 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 实例并调用该方法。

微信截图_20220916155745.png

这种通信方式是耦合的,因为双方都持有“具体的对方”。假设另一个搜索业务场景的历史页长得和它不同,则新历史页无法和搜索页 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还少吗?)

这样安排代码也违反了单一职责原则,即类应该尽量单纯,最好只做一件事情。

若使用合适的架构,这耦合是可以避免的。(实现细节会在后续文章展开)

尘不归尘,土不归土

产品需求:当历史超过两行时,会展示折叠开关,点击它可进行展开或折叠:

1663247756904.gif

// 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。

“视图应该长什么样?”和“哪些事件会触发它重绘?”是两个独立的变化源。

比如美术希望更换每个搜索历史标签的背景色,再比如产品希望按搜索总次数降序排列历史。

这是两个可以分离的关注点,它们应该被安排在不同的类中。这样做有诸多好处:

  1. 更安全地变更:其中一个的变化不会破坏另一个原有的功能。

  2. 更低的复杂度:视图构建逻辑和业务逻辑在两个层次被铺开,各自变得更纯粹,理解难度降低。

  3. 更大的复用性:当视图和业务逻辑分开时,各自都增加了被复用的可能性,因为它们更纯粹了。

关于如何利用架构实现关注点分离,会在后续的文章中展开。


首页
关于博主
我的博客
搜索