随笔博文

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

2022-12-14 13:00:48 michael007js 60

复杂度

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

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

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

复杂度为什么要被分层?

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

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

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

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

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

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

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

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

再举个正面的例子,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.搜索结果页。

Fragment 之间的切换采用 Jetpack 的Navigation

Navigation 封装了切换 Fragment 的细节,让开发者更轻松地实现界面切换。

它包含三个关键概念:

  1. Navigation graph:一个带标签的 xml 文件,用于配置页面及其包含的动作。

  2. NavHost:一个页面容器。

  3. NavController:页面跳转控制器,用于发起动作。

关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门| Android 开发者| Android Developers

界面框架

class TemplateSearchActivity : AppCompatActivity() {
   companion object {
       const val NAV_HOST_ID = "searchFragmentContainer"
 }
   private lateinit var etSearch: EditText
   private lateinit var tvSearch: TextView
   private lateinit var ivClear: ImageView
   private lateinit var ivBack: ImageView
   private lateinit var vInputBg: View
   private val contentView by lazy(LazyThreadSafetyMode.NONE) {
       LinearLayout {
           layout_width = match_parent
           layout_height = match_parent
           orientation = vertical
           background_color = "#0C0D14"
           fitsSystemWindows = true
           
           // 搜索条
           ConstraintLayout {
               layout_width = match_parent
               layout_height = wrap_content
               // 返回按钮
               ivBack = ImageView {
                   layout_id = "ivSearchBack"
                   layout_width = 7
                   layout_height = 14
                   scaleType = scale_fit_xy
                   start_toStartOf = parent_id
                   top_toTopOf = parent_id
                   margin_start = 22
                   margin_top = 11
                   src = R.drawable.search_back
                   onClick = { finish() }
             }
               // 搜索框背景
               vInputBg = View {
                   layout_id = "vSearchBarBg"
                   layout_width = 0
                   layout_height = 36
                   start_toEndOf = "ivSearchBack"
                   align_vertical_to = "ivSearchBack"
                   end_toStartOf = ID_SEARCH
                   margin_start = 19.76
                   margin_end = 16
                   // 轻松定义圆角背景,省去新增一个 xml
                   shape = shape {
                       corner_radius = 54
                       solid_color = "#1AB8BCF1"
                 }
             }
               // 搜索框放大镜icon
               ImageView {
                   layout_id = "ivSearchIcon"
                   layout_width = 16
                   layout_height = 16
                   scaleType = scale_fit_xy
                   start_toStartOf = "vSearchBarBg"
                   align_vertical_to = "vSearchBarBg"
                   margin_start = 16
                   src = R.drawable.template_search_icon
             }
               // 搜索框
               etSearch = EditText {
                   layout_id = "etSearch"
                   layout_width = 0
                   layout_height = wrap_content
                   start_toEndOf = "ivSearchIcon"
                   end_toStartOf = ID_CLEAR_SEARCH
                   align_vertical_to = "vSearchBarBg"
                   margin_start = 7
                   margin_end = 12
                   textSize = 14f
                   textColor = "#F2F4FF"
                   imeOptions = EditorInfo.IME_ACTION_SEARCH
                   hint = "输入您想搜索的模板"
                   hint_color = "#686A72"
                   background = null
                   maxLines = 1
                   inputType = InputType.TYPE_CLASS_TEXT
             }
               // 搜索框尾部清空按钮
               ivClear = ImageView {
                   layout_id = "ivClearSearch"
                   layout_width = 20
                   layout_height = 20
                   scaleType = scale_fit_xy
                   align_vertical_to = "vSearchBarBg"
                   end_toEndOf = "vSearchBarBg"
                   margin_end = 12
                   src = R.drawable.template_search_clear
               // 搜索按钮
               tvSearch = TextView {
                   layout_id = "tvSearch"
                   layout_width = wrap_content
                   layout_height = wrap_content
                   textSize = 14f
                   textColor = "#686A72"
                   text = "搜索"
                   gravity = gravity_center
                   align_vertical_to = "ivSearchBack"
                   end_toEndOf = parent_id
                   margin_end = 16
             }
         }
           // 搜索体
           FragmentContainerView {
               layout_id = NAV_HOST_ID
               layout_width = match_parent
               layout_height = match_parent
               NavHostFragment.create(R.navigation.search_navigation).also {
                   supportFragmentManager.beginTransaction()
                     .replace(NAV_HOST_ID.toLayoutId(), it)
                     .setPrimaryNavigationFragment(it)
                     .commit()
             }
         }
     }
 }

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(contentView)
       initView()
 }

   private fun initView() {
       // 绘制界面初始状态
       tvSearch?.apply {
           isEnabled = false
           textColor = "#484951"
     }
       ivClear?.visibility = gone
       KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
 }
}

搜索页是一个 Activity,它的根布局是一个纵向的 LinearLayout,其中上部是一个搜索条,下部是搜索体。上述代码使用了 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)

其中 FragmentContainerView 作为 Fragment 的容器,将其和 NavHostFragment 以及一个 navigation 资源文件绑定:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:id="@+id/search_navigation"
   app:startDestination="@id/SearchHistoryFragment">
   <!--联想页-->
   <fragment
       android:id="@+id/SearchHintFragment"
       android:name="com.bilibili.studio.search.template.fragment.SearchHintFragment"
       android:label="search_hint_fragment">
       <!--跳转结果页-->
       <action
           android:id="@+id/action_to_result"
           app:destination="@id/SearchResultFragment" />
       <!--跳转历史页-->
       <action
           android:id="@+id/action_to_history"
           app:popUpTo="@id/SearchHistoryFragment" />
   </fragment>
   <!--历史页-->
   <fragment
       android:id="@+id/SearchHistoryFragment"
       android:name="com.bilibili.studio.search.template.fragment.SearchHistoryFragment"
       android:label="search_history_fragment">
       <!--跳转结果页-->
       <action
           android:id="@+id/action_to_result"
           app:destination="@id/SearchResultFragment" />
       <!--跳转联想页-->
       <action
           android:id="@+id/action_to_hint"
           app:destination="@id/SearchHintFragment" />
   </fragment>
   <!--结果页-->
   <fragment
       android:id="@+id/SearchResultFragment"
       android:name="com.bilibili.studio.search.template.fragment.SearchResultFragment"
       android:label="search_result_fragment">
       <!--跳转历史页-->
       <action
           android:id="@+id/action_to_history"
           app:popUpTo="@id/SearchHistoryFragment" />
       <!--跳转联想页-->
       <action
           android:id="@+id/action_to_hint"
           app:destination="@id/SearchHintFragment" />
   </fragment>
</navigation>

navigation 文件定义了 Fragment 实体以及对应的 跳转行为 action。然后就能用NavController方便地进行 Fragment 的切换:

findNavController(NAV_HOST_ID.toLayoutId()).navigate(
   R.id.action_history_to_result, // 预定义在 xml 中的 action
   bundleOf("keywords" to event.keyword) // 携带跳转参数
)

支离破碎的刷新

了解了整个界面框架和技术选型之后,在不使用任何架构的情况下实现第一个业务界面——搜索条,看看会遇到哪些意想不到的坑。

看上去简单的搜索框,其实包含不少交互逻辑。

交互逻辑:进入搜索页时,搜索按钮置灰,隐藏清空按钮,搜索框获取焦点并自动弹出输入法:

飞书20220903-130310.jpg

用代码表达如下:

class TemplateSearchActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(contentView)
       initView()
 }

   private fun initView() {
       // 绘制界面初始状态
       tvSearch.apply {
           isEnabled = false
           textColor = "#484951"
     }
       ivClear.visibility = gone
       KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
 }
}

交互逻辑:当输入关键词时,显示清空按钮,并高亮搜索按钮:

飞书20220903-130555.gif

通过addTextChangedListener()监听输入框内容变化并做出视图调整:

class TemplateSearchActivity : AppCompatActivity() {
   private fun initView() {
       // 初始化时立刻被执行
       tvSearch.apply {
           isEnabled = false
           textColor = "#484951"
     }
       ivClear.visibility = gone
       // 监听输入框字符变化
       etSearch.addTextChangedListener(object :TextWatcher{
           override fun beforeTextChanged(s: CharSequence?,s: Int,c: Int,after: Int){}
           // 将来的某个时间点被执行的逻辑
           override fun onTextChanged(char: CharSequence?, s: Int, b: Int, c: Int) {
               val input = char?.toString() ?: ""
               // 显示 X,并高亮搜索
               if(input.isNotEmpty()) {
                   ivClear.visibility = visible
                   tvSearch.apply {
                       textColor = "#F2F4FF"
                       isEnabled = true
                 }
             }
               // 隐藏 X,并置灰搜索
               else {
                   ivClear.visibility = gone
                   tvSearch.apply {
                       textColor = "#484951"
                       isEnabled = false
                 }
             }
         }

           override fun afterTextChanged(s: Editable?) {}
     })

       KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
 }
}

为了让语义更加明确,抽象出initView()来表示初始化视图。其中包含了的确在初始化会被立刻执行的逻辑,以及在将来某个时间点会执行的逻辑。

如果按照这个趋势发展下去,界面越来越复杂时,initView() 会越来越长。

项目中,超 1000 行的initView()initConfig()就是这样练成的。如此庞大的初始化方法中,很难找到你想要的东西。

将来的逻辑和现在的逻辑最好不要待在一起,特别是当将来的逻辑很复杂时(嵌套回调)。

上述代码还有一个问题,更新搜索按钮tvSearch的逻辑有两个分身,分别处于现在和将来。这增加了理解界面状态的难度。当刷新同一控件的逻辑分处在各种各样的回调中时,如何轻松地回答“控件在某一时刻应该长什么样?”这个问题。当发生界面状态不一致的 Bug 时,又该从哪个地方下手排查问题?

名不副实的子程序

交互逻辑:点击键盘上的搜索或搜索条右侧的搜索进入结果页时,搜索框拉长并覆盖搜索按钮:

飞书20220903-130619.gif

用代码表达如下:

class TemplateSearchActivity : AppCompatActivity() {
   private fun initView() {
       tvSearch.apply {
           isEnabled = false
           textColor = "#484951"
     }
       ivClear.visibility = gone
       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()
     }
       KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
 }
   // 跳转到搜索页 + 拉长搜索条 + 隐藏搜索按钮 + 隐藏键盘
   private fun searchAndHideKeyboard() {
       vInputBg.end_toEndOf = parent_id // 拉长搜索框(与父亲右边对齐)
       runCatching {
           findNavController(NAV_HOST_ID.toLayoutId()).navigate(
               R.id.action_history_to_result,
               bundleOf("keywords" to etSearch?.text.toString())
         )
     }
       tvSearch.visibility = gone
       KeyboardUtils.hideSoftInput(etSearch)
 }
}

因为跳转到结果页有两个入口,为了复用代码,不得不抽象出一个方法叫searchAndHideKeyboard()

这个命名是糟糕的,因为它没有表达出方法内做的所有事情,或者说这个子程序的抽象是糟糕的,因为它包含了多个目的,不够单纯。

子程序应该有单一且明确的目的。—— 《代码大全》

单纯的子程序最大的好处是能提高复用度。

当需求稍加改动后,searchAndHideKeyboard()就无法被复用,比如另一个搜索场景中,点击搜索时不需要拉长搜索框,也不隐藏搜索按钮,而是将搜索按钮名称改为取消。

之所以该方法难以被复用,因为它的视角错了,它以当前业务为视角,抽象出当前业务下会发生的界面变化,遂该方法也只能被用于当前业务。

若以界面变化为视角,当搜索行为发生时,界面会发生三个维度的变化:1. 搜索框绘制效果变化 2. 输入法的显示状态变化 3. Fragment 的切换。以这样的视角做抽象就能提高代码的复用度,详细的实现细节会在后续篇章展开。

剪不断理还乱的耦合

交互逻辑:当从结果页返回时(系统返回键/搜索条清空键/点击搜索框),搜索条缩回原始长度,并展示搜索按钮:

飞书20220903-135702.gif

class TemplateSearchActivity : AppCompatActivity() {
   private fun initView() {
       tvSearch?.apply {
           isEnabled = false
           textColor = "#484951"
     }
       ivClear?.visibility = gone
       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()
     }
       // 监听清空按钮
       ivClear?.setOnClickListener {
           etSearch?.text = null
           etSearch?.requestFocus()
           // 弹键盘
           KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
           // 回到历史页
           backToHistory()
     }
       // 监听搜索框触摸事件并回到历史页
       etSearch?.setOnTouchListener { v, event ->
           if (event.action == MotionEvent.ACTION_DOWN) {
               backToHistory()
         }
           false
     }

       KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
 }
   // 监听系统返回并回退到历史页
   override fun onBackPressed() {
       super.onBackPressed()
       backToHistory()
 }
   
   private fun backToHistory() {
       runCatching {
           findNavController(NAV_HOST_ID.toLayoutId()).currentDestination?.takeIf { it.id == R.id.SearchResultFragment }?.let {
               // 还原搜索框和搜索按钮
               tvSearch?.visibility = visible
               vInputBg?.end_toStartOf = "tvSearch"
         }
           // 弹出结果页回到历史页
           findNavController(NAV_HOST_ID.toLayoutId()).navigate(R.id.action_to_history)
     }
 }
}

这段代码和上一小节有着同样的问题,即在 Activity 中面向业务抽象出各种名不副实且难以复用的子程序。

backToHistory()还加重了这个问题,因为它和searchAndHideKeyboard()是耦合在一起的,分别表示进入搜索结果页和从结果页返回时搜索条的界面交互逻辑。

交互逻辑是易变的,当它发生变化时,就得修改两个地方,漏掉一处,就会产生 Bug。

修改一个子程序后,另一个子程序出 Bug 了。你说恐怖不恐怖?

总结

整个过程没有 ViewModel 的影子,也没有 Model 的影子。

Model,模型(名词)。Trygve Reenskaug,MVC 概念的发明者,在 1979 年就对 MVC 中的 M 下过这样的结论:

The View observes the Model for changes. —— Trygve Reenskaug

上面的代码没有抽象出一个模型用来表达界面的状态变化,界面也没有发送任何指令给 ViewModel,而是在 Activity 中独自消化了所有的业务逻辑。

这是很多 MVVM 在项目中的现状:只要不牵涉到网络请求,则业务逻辑都写在 View 层。这样的代码好写,不好懂,也不好改。

显而易见的坏处是,Activity 变得复杂(复杂度在一个类中被铺开),代码量增多,随之而来的是理解成本增加。

除了业务逻辑和界面展示混合在一起的复杂度之外,上述代码还有另一个复杂度:

代码中对搜索按钮tvSearch的引用有将近10处,且散落在代码的各个角落,有些在初始化initView()中、有些在输入框回调onTextChange()中、有些在业务方法searchAndHideKeyboard()中、还有些在系统返回回调onBackPress()中。在不同的地方对同一个控件做出“是否显示”、“字体颜色”、“是否可点击”等状态的修改。

如果有人在阅读你的代码时不得不搜索整个应用程序以便找到所需的信息,那么就应该重新组织你的代码了。——《代码大全》

这样的写法无法简明扼要地回答“搜索按钮应该长什么样?”这个问题。你不得不搜索整段代码中所有对它的引用,才能拼凑出问题答案。这样是吃力的!

关于“界面该长什么样”这个问题的答案应该内聚在一个点,Flutter 以及 Compose 就是这样降低复杂度的,即 View 和 Model 强绑定,且内建了 View 随 Model 变化而变化的刷新机制。(也可以使用 MVI 实现类似的效果)

除此之外,这样写还有一个坏处是“容易改出 bug”。当需求变更,假设换了一种搜索按钮高亮颜色,可能会发生“没改全”的 bug,因为决定按钮颜色的代码不止一处。

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

微信截图_20220903170226.png

本篇用无架构的方式完成了搜索条的实现。搜索历史及结果的无架构实现会在后续篇章中展开。


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