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

·  阅读 3364

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

复杂度

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

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

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

复杂度为什么要被分层?

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

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

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

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

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

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

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

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

再举个正面的例子,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. 更大的复用性:当视图和业务逻辑分开时,各自都增加了被复用的可能性,因为它们更纯粹了。

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

推荐阅读

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

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

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

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

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

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

分类:
Android
收藏成功!
已添加到「」, 点击更改