本文为稀土技术社区首发签约文章,14天内制止转载,14天后未获授权制止转载,侵权必究!
杂乱度
Android 架构演进系列是围绕着杂乱度向前推动的。
软件的首要技术任务是“办理杂乱度” —— 《代码大全》
由于低杂乱度才能降低了解成本和沟通难度,进步应对改变的灵活性,减少重复劳动,终究进步代码质量。
架构的意图在于“将杂乱度分层”
杂乱度为什么要被分层?
若不分层,杂乱度会在同一层次打开,这样就太 … 杂乱了。
举一个杂乱度不分层的比方:
小李:“你会做什么菜?”
小明:“我会做用土鸡生的土鸡蛋配上切片的西红柿,放点油盐,开火翻炒的西红柿炒蛋。”
听了小明的回答,你还会和他做朋友吗?
小明把不同层次的杂乱度以不恰当的办法搓弄在一起,让人感觉是一种由“没有必要的详细”导致的“难以了解的杂乱”。
小李其实并不关怀土鸡蛋的来历、西红柿的切法、增加的佐料、以及烹饪办法。
这样的回答除了难以了解之外,局限性也很大。由于它太详细了!只要把土鸡蛋换成洋鸡蛋、或是西红柿片换成块、或是加点糖、或是换成电磁炉,其间任一因素产生变化,小明就不会做西红柿炒蛋了。
再举个正面的比方,TCP/IP 协议分层模型自下到上界说了五层:
- 物理层
- 数据链路成
- 网络层
- 传输层
- 应用层
其间每一层的功用都独立且明确,这样规划的好处是缩小影响面,即单层的变化不会影响其他层。
这样规划的另一个好处是当专注于一层协议时,其他层的技术细节能够不予重视,同一时间只需求重视有限的杂乱度,比方传输层不需求知道自己传输的是 HTTP 仍是 FTP,传输层只需求专注于端到端的传输办法,是树立衔接,仍是无衔接。
有限杂乱度的另一面是“基层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其基层的内容不需求做任何更改。
引子
该系列的前三篇结合“查找”这个事务场景,讲述了不运用架构写事务代码会产生的痛点:
- 低内聚高耦合的制作:控件的制作逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),涣散在现在和将来的逻辑中。这样的规划增加了界面改写的杂乱度,导致代码难以了解、简略改出 Bug、难排查问题、无法复用。
- 耦合的非粘性通讯:Activity 和 Fragment 经过获取对方引证并互调办法的办法完结通讯。这种通讯办法使得 Fragment 和 Activity 耦合,从而降低了界面的复费用。并且没有一种内建的机制来轻松的完成粘性通讯。
- 天主类:全部细节都在界面被铺开。比方数据存取,网络拜访这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、杂乱度高、变化源不单一、改动影响规模大。
- 界面 & 事务:界面展现和事务逻辑耦合在一起。“界面该长什么样?”和“哪些事情会触发界面重绘?”这两个独立的变化源没有做到重视点分离。导致 Activity 代码不单纯、高耦合、代码量大、杂乱度高、变化源不单一、改动影响规模大、易改出 Bug、界面和事务无法独自被复用。
详细剖析过程能够点击下面的链接:
-
写事务不必架构会怎样样?(一)
-
写事务不必架构会怎样样?(二)
-
写事务不必架构会怎样样?(三)
这一篇试着引进 MVP 架构(Model-View-Presenter)进行重构,看能不能处理这些痛点。
在重构之前,先介绍下查找的事务场景,该功用示意图如下:
事务流程如下:在查找条中输入关键词并同步展现联想词,点联想词跳转查找成果页,若无匹配成果则展现引荐流,回来时查找前史以标签方法横向铺开。点击前史可直接建议查找跳转到成果页。
将查找事务场景的界面做了如下规划:
查找页用Activity
来承载,它被分红两个部分,头部是常驻在 Activity 的查找条。下面的“查找体”用Fragment
承载,它可能出现三种状态 1.查找前史页 2.查找联想页 3.查找成果页。
Fragment 之间的切换选用 Jetpack 的Navigation
。关于 Navigation 详细的介绍能够点击关于 Navigation 更详细的介绍能够点击Navigation 组件运用入门 | Android 开发者 | Android Developers
事务和拜访数据分离
上一篇运用 MVP 重构了查找条,引出了 MVP 中的一些基本概念,比方事务接口,View 层接口,双向通讯。
这一篇开始对查找联想进行重构,它的交互如下:
输入关键词的一起恳求网络拉取联想词并展现为列表,点击联想词跳转到查找成果页。再次点击输入框时,对当时词触发联想。
新增了一个事务场景,就在 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 中的 V,它只描绘了界面怎么制作,经过完成 View 层接口表达。它会持有 Presenter 的实例,用以发送事务恳求。
- 事务层:是 MVP 中的 P,它只描绘事务逻辑,经过完成事务接口表达。它会持有 View 层接口的实例,以指导界面怎么制作。它还会持有带有数据存储才能的 Repository。
- 数据存取层:它在 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 应该如下图所示:
每一个从 Presenter 经过 View 层接口传递出去的参数才是 Model,由于它才直接指导界面该怎么制作。
正由于 Presenter 向界面供给了多个 Model,才导致上一节“有限内聚的界面制作”,界面制作无法内聚到一点的根本原因是由于有多个 Model。MVI 在这一点上做了一次晋级,叫“仅有可信数据源”,真实地做到了界面制作内聚于一点。(后续华章会打开剖析)
下面这个比方再一次展现出“多 Model 导致有限内聚的界面改写”的缺陷。
当时输入框的 Flow 如下:
整个流上有两个刷界面的点,一个在流的上游,一个在流的下游。所以不得不把上游切换到主线程履行,不然会报:
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
。
展现联想词的场景是“界面跳转”和“数据传递”一起产生,能够借用界面跳转带着数据。但有些场景下不产生界面跳转也得传递数据。比方下面这个场景:
点击联想词也记为一次查找,也得录入查找前史。
当点击联想词时产生的界面跳转是从联想页 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 就内建了粘性通讯机制。(会在后续文章打开)
全部从头来过
产品需求:增加查找条的过渡动画
查找事务的入口是另一个 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才能挽狂澜?(一)