¿Qué pasa si escribes un negocio sin arquitectura? (Tres)

Este artículo es el primer artículo firmado de la comunidad tecnológica de pepitas de tierras raras. Está prohibida la reimpresión dentro de los 14 días y la reimpresión sin autorización después de los 14 días. ¡Se debe investigar la infracción!

la complejidad

La misión técnica principal del software es "gestionar la complejidad" - Code Encyclopedia

Debido a la baja complejidad, se puede reducir el costo de comprensión y la dificultad de comunicación, se puede mejorar la flexibilidad para responder a los cambios, se puede reducir la duplicación de esfuerzos y, en última instancia, se puede mejorar la calidad del código.

El propósito de la arquitectura es "complejidad de capas"

¿Por qué la complejidad está en capas?

Sin las capas, la complejidad se extiende al mismo nivel, por lo que es demasiado... complicado.

Aquí hay un ejemplo de una complejidad no jerárquica:

Xiao Li: "¿Qué cocinas?"

Xiao Ming: "Haré huevos revueltos con tomate y tomate, usando huevos de gallina crudos, tomates en rodajas y un poco de aceite y sal".

Después de escuchar la respuesta de Xiao Ming, ¿seguirás siendo su amigo?

Xiao Ming combina diferentes niveles de complejidad de manera inapropiada, haciéndolo sentir como una "complejidad incomprensible" causada por "detalles innecesarios".

De hecho, a Xiao Li no le importa el origen de los huevos, la forma de cortar los tomates, los condimentos agregados y los métodos de cocción.

Además de ser difícil de entender, esa respuesta también es muy limitada. ¡Porque es tan específico! Siempre que los huevos del suelo se reemplacen por huevos extraños, o el tomate en rodajas se reemplace por un bloque, o un poco de azúcar, o una cocina de inducción, si alguno de estos factores cambia, Xiaoming no podrá hacer tomate revuelto huevos.

Como otro ejemplo positivo, el modelo de capas del protocolo TCP/IP define cinco capas de abajo hacia arriba:

  1. capa fisica
  2. enlace de datos
  3. Capa de red
  4. capa de transporte
  5. capa de aplicación

La función de cada capa es independiente y clara. La ventaja de este diseño es reducir la superficie de impacto, es decir, los cambios en una sola capa no afectarán a otras capas.

Otra ventaja de este diseño es que cuando se enfoca en una capa del protocolo, los detalles técnicos de las otras capas pueden ignorarse y solo se debe prestar atención a la complejidad limitada al mismo tiempo. Por ejemplo, la capa de transporte no necesita saber si está transmitiendo HTTP o FTP La capa solo necesita enfocarse en el transporte de extremo a extremo, ya sea que esté conectado o no.

La otra cara de la moneda de la complejidad finita es la "reutilización de las capas subyacentes". Cuando el protocolo de la capa de aplicación se cambia de HTTP a FTP, no es necesario cambiar el contenido de la capa inferior.

Introducción

Para reducir la complejidad del desarrollo en el dominio del lado del cliente, la arquitectura está en constante evolución. De MVC a MVP, a MVVM, se ha desarrollado a MVI.

MVVM sigue siendo la arquitectura del lado de Android más utilizada en este momento, y el que alguna vez fue el hermano mayor MVP se ha ido al ocaso.

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

微信图片_20220904192016.png

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

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

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

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

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

1662106805162.gif

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

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

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

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

愈发不单纯的 Activity

搜索联想效果如图所示:

1663480615505.gif

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

最直接的实现方法如下:

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. 复杂度高:大量细节在同一个层次被铺开,代码显得啰嗦,增加理解成本。
  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 重复弹出。关于如何在架构中正确使用粘性会在后续篇章中展开。

不内聚导致合成谬误

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

1663501961308.gif

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

1663505741535.gif

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

这是因为当输入“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,无法被复用。

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

Supongo que te gusta

Origin juejin.im/post/7144737172816412709
Recomendado
Clasificación