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

杂乱度

Android 架构演进系列是围绕着杂乱度向前推动的。

软件的首要技术任务是“办理杂乱度” —— 《代码大全》

由于低杂乱度才能降低了解成本和沟通难度,进步应对改变的灵活性,减少重复劳动,终究进步代码质量。

架构的意图在于“将杂乱度分层”

杂乱度为什么要被分层?

若不分层,杂乱度会在同一层次打开,这样就太 … 杂乱了。

举一个杂乱度不分层的比方:

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

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

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

小明把不同层次的杂乱度以不恰当的办法搓弄在一起,让人感觉是一种由“没有必要的详细”导致的“难以了解的杂乱”。

小李其实并不关怀土鸡蛋的来历、西红柿的切法、增加的佐料、以及烹饪办法。

这样的回答除了难以了解之外,局限性也很大。由于它太详细了!只要把土鸡蛋换成洋鸡蛋、或是西红柿片换成块、或是加点糖、或是换成电磁炉,其间任一因素产生变化,小明就不会做西红柿炒蛋了。

再举个正面的比方,TCP/IP 协议分层模型自下到上界说了五层:

  1. 物理层
  2. 数据链路成
  3. 网络层
  4. 传输层
  5. 应用层

其间每一层的功用都独立且明确,这样规划的好处是缩小影响面,即单层的变化不会影响其他层。

这样规划的另一个好处是当专注于一层协议时,其他层的技术细节能够不予重视,同一时间只需求重视有限的杂乱度,比方传输层不需求知道自己传输的是 HTTP 仍是 FTP,传输层只需求专注于端到端的传输办法,是树立衔接,仍是无衔接。

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

引子

该系列的前三篇结合“查找”这个事务场景,讲述了不运用架构写事务代码会产生的痛点:

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

详细剖析过程能够点击下面的链接:

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

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

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

这一篇试着引进 MVP 架构(Model-View-Presenter)进行重构,看能不能处理这些痛点。

在重构之前,先介绍下查找的事务场景,该功用示意图如下:

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

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

将查找事务场景的界面做了如下规划:

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

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

Fragment 之间的切换选用 Jetpack 的Navigation。关于 Navigation 详细的介绍能够点击关于 Navigation 更详细的介绍能够点击Navigation 组件运用入门 | Android 开发者 | Android Developers

事务和拜访数据分离

上一篇运用 MVP 重构了查找条,引出了 MVP 中的一些基本概念,比方事务接口,View 层接口,双向通讯。

这一篇开始对查找联想进行重构,它的交互如下:

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

输入关键词的一起恳求网络拉取联想词并展现为列表,点击联想词跳转到查找成果页。再次点击输入框时,对当时词触发联想。

新增了一个事务场景,就在 SearchPresenter 中新增接口:

interface SearchPresenter {
    fun init()
    fun backPress()
    fun touchSearchBar(text: String, isUserInput: Boolean)
    fun clearKeyword()
    fun search(keyword: String, from: SearchFrom)
    fun inputKeyword(input: Input)
    // 拉取联想词
    suspend fun fetchHint(keyword: String): List<String>
    // 展现联想页
    fun showHintPage(hints: List<SearchHint>)
}

若每次输入框内容产生变化都恳求网络则糟蹋流量,所以得做限制。运用呼应式编程使得问题的求解变得简略,详细讲解能够点击写事务不必架构会怎样样?(三)

现套用这个处理计划,并将它和 Presenter 结合运用:

// TemplateSearchActivity.kt
etSearch.textChangeFlow { isUserInput, char -> Input(isUserInput, char.toString()) }
    // 键入内容后高亮查找按钮并展现 X
    .onEach { searchPresenter.inputKeyword(it) }
    .filter { it.keyword.isNotEmpty() }
    .debounce(300)
    // 拉取联想词
    .flatMapLatest { flow { emit(searchPresenter.fetchHint(it.keyword)) } }
    .flowOn(Dispatchers.IO)
    // 跳转到联想页并展现联想词列表
    .onEach { searchPresenter.showHintPage(it.map { SearchHint(etSearch.text.toString(), it) }) }
    .launchIn(lifecycleScope)

其间textChangeFlow() 是一个 EditText 的扩展办法,该办法把监听输入框内容变化的回调转换为一个Flow,而Input是一个 data class:

fun <T> EditText.textChangeFlow(elementCreator: (Boolean, CharSequence?) -> T): Flow<T> = 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?) {
            trySend(elementCreator(isUserInput, p0?.toString().orEmpty()))
        }
    }
    addTextChangedListener(watcher)
    awaitClose { removeTextChangedListener(watcher) }
}
//用于表达用户输入内容
data class Input(val isUserInput: Boolean, val keyword: String)

SearchPresenter.fetchHint()对界面屏蔽了拜访网络的细节:

class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
    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 suspend fun fetchHint(keyword: String): List<String> = suspendCancellableCoroutine { continuation ->
        searchApi.fetchHints(keyword)
            .enqueue(object : Callback<SearchHintsBean>() {
                override fun onResponse(call: Call<SearchHintsBean>, response: Response<SearchHintsBean>) {
                    if (response.body()?.result?.hints?.isNotEmpty() == true) {
                        val hints = if (result.data.hints.contains(keyword)) 
                            result.data.hints 
                        else listOf(keyword, *result.data.hints.toTypedArray())
                        continuation.resume(hints, null)
                    } else {
                        continuation.resume(listOf(keyword), null)
                    }
                }
                override fun onFailure(call: Call<SearchHintsBean>, t: Throwable) {
                    continuation.resume(listOf(keyword), null)
                }
        })
    }
}

拜访网络的细节包含怎么将 url 转换为恳求目标、怎么建议 Http 恳求、怎样改换呼应、怎么将呼应的异步回调转换为 suspend 办法。这些细节都被隐藏在 Presenter 层,界面无感知,它只要关怀怎么制作。

依照这个思路,拜访数据库,拜访文件的细节也都不应该让界面感知。有没有必要把这些拜访数据的细节再抽取出来成为新的一层叫“数据拜访层”?

这取决于数据拜访是否可供其他模块复用,或许数据拜访的细节是否会产生变化。

若另一个 Presenter 也需求做相同的网络恳求(新事务界面恳求老接口仍是挺常见的),像上面这种写,恳求的细节就无法被复用。此刻只能祭出复制粘贴。

并且查找能够产生在许多事务场景,这次是查找模板,下次可能是查找资料。它们肯定不是一个服务端接口。这就是拜访的细节产生变化。若新的查找场景想复用这次的 SearchPresenter,则拜访网络的细节就不应出现在 Presenter 层。

为了增加 Presenter 和网络恳求细节的复用性,通常的做法是新增一层 Repository:

class SearchRepository {
    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 suspend fun fetchHint(keyword: String): List<String> = suspendCancellableCoroutine { continuation ->
        searchApi.fetchHints(keyword)
            .enqueue(object : Callback<SearchHintsBean>() {
                override fun onResponse(call: Call<SearchHintsBean>, response: Response<SearchHintsBean>) {
                    if (response.body()?.result?.hints?.isNotEmpty() == true) {
                        val hints = if (result.data.hints.contains(keyword)) result.data.hints else listOf(keyword, *result.data.hints.toTypedArray())
                        continuation.resume(hints, null)
                    } else {
                        continuation.resume(listOf(keyword), null)
                    }
                }
                override fun onFailure(call: Call<SearchHintsBean>, t: Throwable) {
                    continuation.resume(listOf(keyword), null)
                }
        })
    }
}

然后 Presenter 经过持有 Repository 具有拜访数据的才能:

class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
    private val searchRepository: SearchRepository = SearchRepository()
    // 将拜访数据托付给 repository
    override suspend fun fetchHint(keyword: String): List<String> {
        return searchRepository.fetchSearchHint(keyword)
    }
}

又引进了一个新的杂乱度数据拜访层,它封装了全部拜访数据的细节,比方怎样读写内存缓存、怎样拜访网络、怎样拜访数据库、怎样读写文件。数据拜访层通常向上层供给“原始数据”,即不经过任何事务封装的数据,这样的规划使得它更简略被复用于不同的事务。Presenter 会持有数据拜访层并将全部拜访数据的工作托付给它,并将数据做相应的事务转换,终究传递给界面。

Model 去哪了?

至此事务架构表现为如下状态:

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

事务架构分为三层:

  1. 界面层:是 MVP 中的 V,它只描绘了界面怎么制作,经过完成 View 层接口表达。它会持有 Presenter 的实例,用以发送事务恳求。
  2. 事务层:是 MVP 中的 P,它只描绘事务逻辑,经过完成事务接口表达。它会持有 View 层接口的实例,以指导界面怎么制作。它还会持有带有数据存储才能的 Repository。
  3. 数据存取层:它在 MVP 中找不到自己的方位。它描绘了操作数据的才能,包含读和写。它向上层屏蔽了读写数据的细节,是从网络读,仍是从文件,数据库,上层都不需求关怀。

MVP 中的 M 在哪里?莫非是 Repository 吗?我不觉得!

若 Repository 代表 M,那就意味着 M 不仅代表了数据本身,还包含了获取数据的办法。

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

The View observes the Model for changes

M 是用来被 View 观察的,而 Repository 获取的数据是原始数据,需求经过一次包装或转换才能指导界面制作。

依照这个界说当时架构中的 M 应该如下图所示:

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

每一个从 Presenter 经过 View 层接口传递出去的参数才是 Model,由于它才直接指导界面该怎么制作。

正由于 Presenter 向界面供给了多个 Model,才导致上一节“有限内聚的界面制作”,界面制作无法内聚到一点的根本原因是由于有多个 Model。MVI 在这一点上做了一次晋级,叫“仅有可信数据源”,真实地做到了界面制作内聚于一点。(后续华章会打开剖析)

下面这个比方再一次展现出“多 Model 导致有限内聚的界面改写”的缺陷。

当时输入框的 Flow 如下:

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

整个流上有两个刷界面的点,一个在流的上游,一个在流的下游。所以不得不把上游切换到主线程履行,不然会报:

E CrashReport: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

这也是“有限的内聚”引出的没有必要的线程切换,理想状态下,刷界面应该内聚在一点且处于整个流的结尾。(后续华章会打开)

跨界面通讯?

触发拉取联想词的动作在查找页 Activity 中产生,联想接口的拉取也在 Activity 中进行。这就产生了一个跨界面通讯场景,得把 Activity 中获取的联想词传递给联想页 Fragment。

当拉取联想词完毕后,数据会流到 SearchPresenter.showHintPage():

class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
    override fun showHintPage(hints: List<SearchHint>) {
        searchView.gotoHintPage(hints) // 跳转到联想页
    }
}
interface SearchView {
    fun gotoHintPage(hints: List<SearchHint>) // 跳转到联想页
}

为 View 层接口新增了一个界面跳转的办法,待 Activity 完成之:

class TemplateSearchActivity : AppCompatActivity(), SearchView {
    override fun gotoHintPage(hints: List<SearchHint>) {
        // 跳转到联想页,联想词作为参数传递给联想页
        findNavController(NAV_HOST_ID.toLayoutId())
            .navigate(R.id.action_to_hint, bundleOf("hints" to hints))
    }
}

为了将联想词传递给联想页,得序列化之:

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

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

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

当时传递的数据简略,若杂乱数据选用这种办法传递,可能产生性能上的损耗,首先序列化和反序列化是耗时的。再者当经过 Intent 传递大数据时可能产生TransactionTooLargeException

展现联想词的场景是“界面跳转”和“数据传递”一起产生,能够借用界面跳转带着数据。但有些场景下不产生界面跳转也得传递数据。比方下面这个场景:

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

点击联想词也记为一次查找,也得录入查找前史。

当点击联想词时产生的界面跳转是从联想页 Fragment 跳到查找成果 Fragment,但数据传递却需求从联想页到前史页。在这种场景下无法经过界面跳转来带着参数。

由于 Activity 和 Fragment 都能轻松地拿到对方的引证,所以经过直接调对方的办法完成参数传递也不是不能够。仅仅这让 Activity 和 Fragment 耦合在一起,使得它们无法独自被复用。

正如写事务不必架构会怎样样?(三)中描绘的那样,界面之间需求一种解耦的、高性能的、最好还带粘性才能的通讯办法。

MVP 并未内建这种通讯机制,只能借助于第三方库 EventBus:

class TemplateSearchActivity : AppCompatActivity(), SearchView {
    override fun sendHints(searchHints: List<SearchHint>) {
        findNavController(NAV_HOST_ID.toLayoutId()).navigate(R.id.action_to_hint, bundleOf("hints" to hints))
        EventBus.getDefault().postSticky(SearchHintsEvent(searchHints))// 发送粘性广播
    }
}
// 将联想词封装成实体类便于广播发送
data class SearchHintsEvent(val hints: List<SearchHint>)
class SearchHintFragment : BaseSearchFragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        EventBus.getDefault().register(this) // 注册
    }
    override fun onDestroy() {
        super.onDestroy()
        EventBus.getDefault().unregister(this)// 刊出
    }
    @Subscribe(threadMode = ThreadMode.MAIN,sticky = true)
    fun onHints(event: SearchHintsEvent) {
        hintsAdapter.dataList = event.hints // 接纳粘性音讯并改写列表
    }
}

MVVM 和 MVI 就内建了粘性通讯机制。(会在后续文章打开)

全部从头来过

产品需求:增加查找条的过渡动画

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

查找事务的入口是另一个 Activity,其间也有一个长得一模一样的查找条,点击它会跳转到查找页 Activity。在跳转过程中,两个 Activity 的查找条有一个水平+透明度的过渡动画。

这个动画的参加引进了一个 Bug:进入查找页键盘不再主动弹起,查找前史页没加载出来。

那是由于原先初始化是在 onCreate() 中触发的:

// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    searchPresenter.init()
}

参加过渡动画后,onCreate() 履行的时分,动画还未完结,即初始化时机就太早了。处理计划是监听过渡动画完毕后才初始化:

// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    window?.sharedElementEnterTransition?.doOnEnd {
        searchPresenter.init()
    }
}

做了这个调整之后,又引进了一个新 Bug:当在前史页反正屏切换后,前史不见了。

那是由于反正屏切换会重新构建 Activity,即重新履行 onCreate() 办法,但这次并没有产生过渡动画,所以初始化办法没有调用。处理办法如下:

// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    window?.sharedElementEnterTransition?.doOnEnd {
        searchPresenter.init()
    }
    // 反正屏切换时也得再次初始化
    if(savedInstanceState != null) searchPresenter.init()
}

即当产生反正屏切换时,也手动触发一下初始化。

尽管这样写代码就有点奇怪,由于有两个不同的初始化时机(增加了初始化的杂乱度),不过问题仍是是处理了。

但每一次反正屏切换都会触发一次读查找前史的 IO 操作。当时场景数据量较小,也无大碍。若数据量大,或许初始化操作是一个网络恳求,这个计划就不适宜了。

究其原因是由于没有一个生命周期比 Activity 更长的数据持有者在反正屏切换时暂存数据,待切换完结后恢复之。

很可惜 Presenter 无法成为这样的数据持有者,由于它在 Activity 中被构建并被其持有,所以它的生命周期和 Activity 同步,即反正屏切换时,Presenter 也重新构建了一次。

而 MVVM 和 MVI 就没有这样的烦恼。(后续华章打开剖析)

总结

  • 在 MVP 中引进数据拜访层是有必要的,这一层封装了存取数据的细节,使得拜访数据的才能能够独自被复用。
  • MVP 中没有内建一种解耦的、高性能的、带粘性才能的通讯办法。
  • MVP 无法应对反正屏切换的场景。当反正屏切换时,全部从头来过。
  • MVP 中的 Model 表现为若干 View 层接口中传递的数据。这样的完成导致了“有限内聚的界面制作”,增加了界面制作的杂乱度。

引荐阅读

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

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

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

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

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

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

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