本文为稀土技能社区首发签约文章,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年:

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

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

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

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

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

查找是 App 中常见的事务场景,该功用示意图如下:

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

事务流程如下:在查找条中输入关键词并同步展现联想词,点联想词跳转查找成果页,若无匹配成果则展现引荐流,回来时查找前史以标签方法横向铺开。点击前史直接建议查找跳转到成果页。

查找页面框架规划如下:

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

查找页用Activity来承载,它被分红两个部分,头部是常驻在 Activity 的查找条。下面的“查找体”用Fragment承载,它或许出现三种状态 1.查找前史页 2.查找联想页 3.查找成果页。

上两篇别离用无架构的方法完结了查找条和查找前史,这一篇接着用这种方法完结查找联想,看看无架构会发生什么痛点。

益发不单纯的 Activity

查找联想效果如图所示:

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

产品需求:输入关键词后,主动建议恳求拉联想词并以列表方法展现。

最直接的完结方法如下:

etSearch.doOnTextChanged { text, _, _, _ -> fetchHint(text.toString()) }
fun fetchHint(keyword: String) {}// 拜访网络进行查找

这样完结有一个缺陷,会进行多次无效的网络拜访。比方查找“kotlin flow”时,onTextChanged()会被回调 10 次,就触发了 10 次网络恳求,而只有终究一次才是有用的。

优化计划是只有在用户中止输入时才进行恳求。但并没有这样的回调告诉事务层用户现已中止输入。那就只能设置一个超时,即用户多久未输入内容后就判定已中止输入。

但完结起来还挺杂乱的:得在每次输入框内容改变后发动超时倒计时,若倒计时归零时输入框内容没有发生新改变,则用输入框当前内容建议恳求,否则将倒计时重置。

若运用流的思想就能极大简化问题:输入框是流数据的出产者,其内容每改变一次,便是在流上出产了一个新数据。但并不是每一个数据都需求被消费,所以得做“限流”,即丢弃一切发射间隔过短的数据,直到出产出某个数据之后一段时刻内不再有新数据。

RxJava 和 kotlin Flow 都可用于表达流,我偏好简练的后者。Kotlin Flow 中的debounce()就十分符合当前场景。

为了用流的思想求解问题,就得先将回调转换成“能发送数据的流”:

fun EditText.textChangeFlow(): Flow<String> = callbackFlow {
    val watcher = object : TextWatcher {
        private var isUserInput = true
        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        }
        override fun onTextChanged(char: CharSequence?, p1: Int, p2: Int, p3: Int) {
            isUserInput = this@textChangeFlow.hasFocus() // 记载是否是用户输入
        }
        override fun afterTextChanged(p0: Editable?) {
            // 当用户输入时,发射数据
            if(isUserInput) trySend(p0?.toString().orEmpty())
        }
    }
    addTextChangedListener(watcher)
    awaitClose { removeTextChangedListener(watcher) }
}

关于 Kotlin Flow 的具体介绍及运用场景可以点击:

  • Kotlin 异步 | Flow 运用场景及原理
  • Kotlin 异步 | Flow 限流的运用场景及原理

然后就可以像这样为查找框做限流了:

class TemplateSearchActivity : BaseActivity() {
    private val mainScope = MainScope()
    private val retrofit = Retrofit.Builder()
        .baseUrl("https://xxx")
        .addConverterFactory(MoshiConverterFactory.create())
        .client(OkHttpClient.Builder().build()) 
        .build()
    private val searchApi = retrofit.create(SearchApi::class.java)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(contentView)
        initView()
    }
    private fun initView() {
        etSearch.textChangeFlow() // 构建输入框文字改变流
            .debounce(300) // 对上游做 300ms 防抖
            .flatMapLatest { fetchHint(it) } // 新查找掩盖旧查找
            .flowOn(Dispatchers.IO) // 异步化
            .onEach {
                goToHintPage() // 跳转到联想页(完结细节鄙人一节展现)
                show(it) // 获取联想列表并展现(完结细节鄙人一节展现)
            } 
            .launchIn(mainScope) // 在主线程搜集
    }
    // 将异步恳求 suspend 化
    private suspend fun fetchHint(keyword: String): List<String> = suspendCancellableCoroutine { continuation ->
        // 拉联想接口
        searchApi.fetchHints(keyword).enqueue(objec: Callback<HintBean> {
            override fun onFailure(call: Call<HintBean>, t: Throwable) { 
                continuation.resume(listOf(keyword), null)
            } 
            override fun onResponse(call: Call<HintBean>, response: Response<HintBean>) { 
                response.body()?.result?.let { 
                    continuation.resume(listOf(keyword, *it.toTypedArray()), null)
                } 
            }
        })
    }
}

这样写的结果是 Activity 和四个新类耦合:RetrofitSearchApiMoshiConverterFactoryOkHttpClient

这四个类一起描绘了拜访网络的细节:如何构建恳求?、拜访哪个地址?、如何将呼应转换成数据实体?、如何树立衔接宣布恳求?

继上一篇数据存取细节在 Activity 铺开,现在又一个网络拜访的细节也在此铺开。依照这个节奏发展下去,超 1000+ 行的 Activity 就不奇怪了。

这样写有以下副作用:

  1. 杂乱度高:大量细节在同一个层次被铺开,代码显得啰嗦,增加了解本钱。
  2. 无扩展性:细节一般简单发生改变,除了 Retrofit + OkHttp 之外也有别的计划可供挑选。上述代码无法完结无痛替换,必须得改 Activity 类。
  3. 影响面大:界面制作、网络恳求、数据存取写在同一个 Activity 中,其间恣意一个改变都有或许影响到其他两个。当你修改了界面,另一个同事修改了网络恳求,你们的代码或许发生冲突,形成没有必要的 Bug。

运用合适的架构、做合理的分层、抽象单一职责的类,就能避免这些副作用。(完结细节会在后续文章打开)

跨界面粘性通讯的必要性

查找关键词在查找页 Activity 发生,查找联想词在联想 Fragment 展现。继上篇查找页和前史页的跨界面通讯后,这又是一个跨界面通讯,而这次状况愈加杂乱了:

class TemplateSearchActivity : BaseActivity() {
    // 运用 Navigation 跳转到查找联想页
    private fun goToHintPage() {
        findNavController(NAV_HOST_ID.toLayoutId()).apply {
            if (currentDestination?.id != R.id.SearchHintFragment) {
                navigate(R.id.action_to_hint)
            }
        }
    }
    // 运用播送告诉联想页改写
    private fun show(keyword: String, hints: List<String>) {
        LocalBroadcastManager.getInstance(this).sendBroadcast(
            Intent("Hints").apply { 
                    val extra = hints.map { SearchHint(keyword, it) }.toTypedArray()
                    putExtra("hints", extra) 
            }
        )
    )
}

当 EditText 流中每次发生数据时都需求执行三个操作:1. 拉接口 2. 跳转联想页 3. 将联想列表传递给联想页。

联想页界面监听播送以接纳联想词列表:

class SearchHintFragment : BaseSearchFragment() {
    private val receiver by lazy { HintsReceiver() }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 在 onViewCreated 中监听播送
        context?.let {
            LocalBroadcastManager.getInstance(it)
                .registerReceiver(receiver, IntentFilter("Hints"))
        }
    }
    inner class HintsReceiver : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            val hints = intent?.getParcelableArrayExtra("hints").orEmpty()
            // 把联想列表塞给 RecyclerView.Adapter
            hintsAdapter.dataList = hints.toList()
        }
    }
}

跑一下上面代码,发现输入关键词后确实跳转到了联想页,但联想词并未展现。。。

那是由于 Fragment 的生命周期回调是异步的,导致监听播送慢于发播送。而播送又不是粘性的,即新的观察者不会收到老值的推送。

为了验证这个推论,把代码做如下修改:

// TemplateSearchActivity.kt
private fun show(keyword: String, hints: List<String>) {
    etSearch?.postDelayed({
        LocalBroadcastManager.getInstance(this)
            .sendBroadcast(
                Intent("Hints").apply { 
                    val extra  = hints.map { SearchHint(keyword, it) }.toTypedArray()
                    putExtra("hints", extra)
                }
            )
    }, 500)
}

推迟 500 ms 后再将联想词推送给联想页。跑一下代码,联想词列表展现出来了!

但这不可行,先不说联想词推迟展现的效果产品能否接受,从技能上,“推迟一个固定时刻去做某件事”便是有隐患的。假设主线程中存在耗时操作,导致 Fragment 生命周期回调超过 500 ms后才回调,那便是一个联想词不展现的偶现 Bug(极难排查原因)。

其实 Navigation 供给了携带参数的跳转方法:

findNavController(NAV_HOST_ID.toLayoutId())
    .navigate(R.id.action_to_hint, bundleOf("hints" to searchHints))

然后在联想页通过 getArguement() 就能获取联想词:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    // 获取联想词
    val hints = arguments?.getParcelableArrayList<SearchHint>("hints").orEmpty()
}

但这样传参得先把 SearchHint 序列化:

@Parcelize // 序列化注解
data class SearchHint( val keyword: String, val hint: String ):Parcelable

若结构体简单,则是最好的通讯方法。若实体类大,一来序列化耗时,二来占用 transaction buffer,或许发生TransactionTooLargeException

对于杂乱结构体,引入跨界面的粘性通讯便是一个更好的挑选,这样即使观察数据在发数据之后进行,也照样能收到之前的数据。

在 MVVM 和 MVI 架构中,就内建了这种通讯方法。但有时候粘性又会引入麻烦,比方运用粘性消息完结 toast 的展现,就会导致 toast 重复弹出。关于如安在架构中正确运用粘性会在后续华章中打开。

不内聚导致组成谬误

产品需求:清空联想词后,回来前史页。

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

//TemplateSearchActivity.kt
etSearch.textChangeFlow()
    .onEach { if (it.isEmpty()) gotoHistoryPage() }// 若输入框被清空则回来前史页
    .filter { it.isNotEmpty() } // 流过滤,当输入不空时才让它往下流
    .debounce(300)
    .flatMapLatest { flow { emit(searchRepository.fetchSearchHint(it)) } }
    .flowOn(Dispatchers.IO)
    .onEach {
        goToHintPage()
        show(etSearch.text.toString(), it)
    }
    .launchIn(mainScope)

运用退格键删除输入框内容,当清空时不该等待 300 ms 才回来前史页,所以该操作只能放在 debounce() 上游的 onEach() 中,然后再通过 filter 过滤出输入非空的值往下流。

跑一下代码,bug 就来了:

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

当清空输入框时,界面确实回来了前史页,但又立马回到了联想页,并且总是会对首字母触发联想。

这是由于当输入“1234”,然后按住退格键后,TextWatcher.afterTextChanged() 会按如下次序触发回调:

1234
123
12
1
空字串

终究一个空字串会被filter { it.isNotEmpty() }过滤掉,而“1”是仅有一个满足debounce(300)条件的值(在它之后就再也没有新的值了),所以它会触发恳求联想接口,当接口回来时跳转到联想页。

流上每一段子逻辑都没毛病,但用流把它们串联起来之后就出毛病了

之所以会这样是由于“界面跳转逻辑没有内聚在一起”,它们别离处于不同的子逻辑中,每个子逻辑都有不同的影响源。当这些影响源排列组合到一起的时候,就会发酵出意想不到的坏滋味。

总结

通过三篇文章的叙述,用最直白的方法完结了查找事务场景,没有运用任何现有架构。

完结过程中,首要发现了如下痛点:

  1. 低内聚高耦合的制作:控件的制作逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),涣散在现在和将来的逻辑中。这样的规划增加了界面改写的杂乱度,导致代码难以了解、简单改出 Bug、难排查问题、无法复用。
  2. 耦合的非粘性通讯:Activity 和 Fragment 通过获取对方引证并互调方法的方法完结通讯。这种通讯方法使得 Fragment 和 Activity 耦合,然后下降了界面的复费用。并且没有一种内建的机制来轻松的完结粘性通讯。
  3. 上帝类:所有细节在界面被铺开。数据存取,网络拜访这些和界面无关的细节在 Activity 被铺开。导致 Activity 代码不单纯,高耦合,代码量大,杂乱度高,无法完结无痛替换。
  4. 界面 & 事务:界面展现和事务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的改变源没有做到重视点别离。导致 Activity 代码不单纯,高耦合,代码量大,杂乱度高,改变源不单一,易改出 Bug,无法被复用。

本系列后续的华章会针对这些痛点,给出架构化的处理计划。敬请期待~

引荐阅读

写事务不必架构会怎么样?(一)

写事务不必架构会怎么样?(二)

写事务不必架构会怎么样?(三)

MVP 架构终究审判 —— MVP 处理了哪些痛点,又引入了哪些坑?(一)

MVP 架构终究审判 —— MVP 处理了哪些痛点,又引入了哪些坑?(二)

MVP 架构终究审判 —— MVP 处理了哪些痛点,又引入了哪些坑?(三)

“无架构”和“MVP”都救不了事务代码,MVVM能力挽狂澜?(一)