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

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

复杂度


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


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


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


复杂度为什么要被分层?


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


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


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


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


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


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


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


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


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


  1. 物理层


  1. 数据链路成


  1. 网络层


  1. 传输层


  1. 应用层


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


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


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


引子


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


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


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


image.png


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


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


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


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


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


image.png


https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/84508632b7bc488f9d5ae0386090e567~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?


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


搜索页面框架设计如下:


image.png


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


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


愈发不单纯的 Activity


搜索联想效果如图所示:


image.png


https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/db6f8c9dd9dc4d03b529e98501e75b77~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?


产品需求:输入关键词后,自动发起请求拉联想词并以列表形式展示。


最直接的实现方法如下:


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 的详细介绍及应用场景可以点击:




然后就可以像这样为搜索框做限流了:


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. 复杂度高:大量细节在同一个层次被铺开,代码显得啰嗦,增加理解成本。


  1. 无扩展性:细节通常容易发生变化,除了 Retrofit + OkHttp 之外也有别的方案可供选择。上述代码无法实现无痛替换,必须得改 Activity 类。


  1. 影响面大:界面绘制、网络请求、数据存取写在同一个 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 重复弹出。关于如何在架构中正确使用粘性会在后续篇章中展开。


不内聚导致合成谬误


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


image.png


https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3aeb51effcdb4524a3b4dd4eae93fa95~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?


//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 就来了:


image.png


https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5b05db1025624ef3956ed59160430945~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp?


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


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


1234
123
12
1
空字串


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


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


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


总结


经过三篇文章的讲述,用最直白的方式实现了搜索业务场景,没有应用任何现有架构。


实现过程中,主要发现了如下痛点:


  1. 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。


  1. 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。


  1. 上帝类:所有细节在界面被铺开。数据存取,网络访问这些和界面无关的细节在 Activity 被铺开。导致 Activity 代码不单纯,高耦合,代码量大,复杂度高,无法实现无痛替换。


  1. 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯,高耦合,代码量大,复杂度高,变化源不单一,易改出 Bug,无法被复用。


本系列后续的篇章会针对这些痛点,给出架构化的解决方案。敬请期待~


推荐阅读


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


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


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


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


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


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


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


目录
相关文章
|
2月前
|
存储 监控 安全
SaaS业务架构:业务能力分析
【9月更文挑战第20天】在数字化时代,软件即服务(SaaS)模式逐渐成为企业软件解决方案的首选。SaaS 业务架构设计对于提供高效、可靠的服务至关重要。其核心业务能力包括:用户管理(注册登录、角色权限)、数据管理(存储备份、安全共享)、业务流程管理(设计定制、工作流自动化)、应用集成(第三方应用、移动应用)及客户服务(支持培训、反馈改进)。通过优化这些能力,可为企业提供更高效、可靠的 SaaS 服务。
59 11
|
4月前
|
测试技术 uml
业务架构问题之在业务架构中,经常说的“看清楚事”指的是什么
业务架构问题之在业务架构中,经常说的“看清楚事”指的是什么
|
5月前
|
人工智能 分布式计算 Cloud Native
PAI的业务架构
PAI的业务架构
50 0
|
架构师 测试技术
【业务架构】业务架构师如何构建业务能力图?
【业务架构】业务架构师如何构建业务能力图?
【业务架构】业务架构师如何构建业务能力图?
|
XML 前端开发 网络协议
写业务不用架构会怎么样?(一)
写业务不用架构会怎么样?(一)
109 0
|
XML 存储 前端开发
写业务不用架构会怎么样?(二)
写业务不用架构会怎么样?(二)
109 0
|
负载均衡 监控 架构师
【业务架构】LEANIX : 业务能力
【业务架构】LEANIX : 业务能力
「业务架构」业务服务:它们到底是什么?
「业务架构」业务服务:它们到底是什么?
|
测试技术
【企业业务架构】什么是第一?架构还是流程?
【企业业务架构】什么是第一?架构还是流程?
|
供应链 架构师
「业务架构」通过设计实现业务模型架构
「业务架构」通过设计实现业务模型架构