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

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

生命周期安全 & 无内存泄漏


上面弹 toast 的 gif 图中有一个细节,触发搜索行为的瞬间并未弹出 toast,而是等到界面返回了历史页才弹出。


但代码明明是在触发搜索行为的时候就调用了的:


class SearchViewModel : ViewModel() {
    val rearrangeLiveData = MutableLiveData<String>()
    fun search(keyword: String) {
        ...
        // 在触发新搜索时提示
        rearrangeLiveData.value = "新搜索词汇排在最前面"
    }
}


因为观察数据是在历史页进行的。触发搜索联想的时候,历史页的生命周期已经走到了 onDestroyView(),即处于不活跃状态。


此时 LiveData 内部会对处于 destroy 状态的观察者进行清理。以保证数据不会再推送给不活跃的观察者,造成不必要的 crash。及时移除观察者也避免了更长生命周期的观察者持有界面造成内存泄漏的风险。(ViewModel 持有 LiveData,LiveData 持有观察者,观察者是匿名内部类,所以它持有界面引用)


关于这一点源码级别的分析可以点击LiveData 面试题库、解答、源码分析


所以使用 LiveData 时就特别省心,只管赋值就好,不用担心界面生命周期以及内存泄漏问题。


多界面共享业务逻辑


整个搜索业务中,触发搜索行为的有3个地方,分别是搜索页的搜索按钮(搜索 Activity)、点击搜索历史标签(历史 Fragment)、点击搜索联想词(联想 Fragment)。这三个触发点分别位于三个不同的界面。


在 MVP 架构中触发搜索的业务逻辑被封装在 SearchPresenter 的业务接口中:


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
    // 历史列表
    private val historys = mutableListOf<String>() 
    override fun search(keyword: String, from: SearchFrom) {
        // 跳转到搜索结果页
        searchView.gotoSearchPage(keyword, from) 
        // 拉升搜索条
        searchView.stretchSearchBar(true) 
        // 隐藏搜索按钮
        searchView.showSearchButton(false) 
        // 更新历史
        if (historys.contains(keyword)) {
            historys.remove(keyword)
            historys.add(0, keyword)
        } else {
            historys.add(0, keyword)
            if (historys.size > 11) historys.removeLast()
        }
        // 刷新搜索历史
        searchView.showHistory(historys)
        // 搜索历史持久化
        scope.launch { searchRepository.putHistory(historys) }
    }
}


理论上,三个不同的界面应该都调用这个方法触发搜索,这使得搜索这个动作的业务实现内聚于一个方法内。但在 MVP 中要实现这一点不太容易。


最简单的办法是在 Fragment 中获取 Activity 实例,然后再获取其成员变量 SearchPresenter:


// SearchHintFragment.kt
private val presenter by lazy {
    (requireActivity() as? TemplateSearchActivity)?.searchPresenter
}


类型强转的代码都是耦合的,强转为 TemplateSearchActivity 就意味着和这个具体的 Activity 耦合,使得 SearchHintFragment 不能脱离它存在,也就没有单独复用的可能性(比如另一个搜索场景中联想页一模一样,它就无法被复用)


ViewModel 巧妙地解决了这个问题。


虽然 ViewModel 还是在 Activity 中构建,但它并不是直接存储在 Activity 中,而是存在了一个叫ViewModelStore的类中:


public class ViewModelStore {
    // 存放 ViewModel 的 HashMap
    private final HashMap<String, ViewModel> mMap = new HashMap<>();
    // 存 ViewModel
    final void put(String key, ViewModel viewModel) {
        ViewModel oldViewModel = mMap.put(key, viewModel);
        if (oldViewModel != null) {
            oldViewModel.onCleared();
        }
    }
    // 取 ViewModel
    final ViewModel get(String key) {
        return mMap.get(key);
    }
}


Activity 负责提供 ViewModelStore:


// Activity 基类实现了 ViewModelStoreOwner 接口
public class ComponentActivity 
    extends androidx.core.app.ComponentActivity 
    implements LifecycleOwner, ViewModelStoreOwner{
        // Activity 持有 ViewModelStore 实例
        private ViewModelStore mViewModelStore;
        public ViewModelStore getViewModelStore() {
            if (mViewModelStore == null) {
                // 获取配置无关实例
                NonConfigurationInstances nc =
                    (NonConfigurationInstances) getLastNonConfigurationInstance();
                if (nc != null) {
                    // 从配置无关实例中恢复 ViewModel商店
                    mViewModelStore = nc.viewModelStore;
                }
                if (mViewModelStore == null) {
                    mViewModelStore = new ViewModelStore();
                }
            }
            return mViewModelStore;
        }
        // 静态的配置无关实例
        static final class NonConfigurationInstances {
            // 持有 ViewModel 商店实例
            ViewModelStore viewModelStore;
            ...
        }
}


其中 ViewModelStoreOwner 用于描述如何构建 ViewModelStore:


public interface ViewModelStoreOwner {
    ViewModelStore getViewModelStore();
}


Activity 中通过如下代码构建 ViewModel 实例:


// TemplateSearchActivity.kt
val searchViewModel: SearchViewModel = 
    ViewModelProvider(this)[SearchViewModel::class.java]


这行代码构建了 ViewModel 的实例,再把它存放在 TemplateSearchActivity 提供的 ViewModelStore 中。


子 Fragment 通过如下代码获取 父 Activity 的 ViewModel 实例:


val searchViewModel: SearchViewModel by activityViewModels<SearchViewModel>()


其中activityViewModels()是 androidx.fragment:fragment-ktx 提供的一个扩展方法:


public inline fun <reified VM : ViewModel> Fragment.activityViewModels(
    noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> = createViewModelLazy(
    VM::class, { requireActivity().viewModelStore },
    { requireActivity().defaultViewModelCreationExtras },
    // 默认使用 activity 提供的 ViewModelProvider.Factory
    factoryProducer ?: { requireActivity().defaultViewModelProviderFactory }
)
// 惰性构建 ViewModel
public fun <VM : ViewModel> Fragment.createViewModelLazy(
    viewModelClass: KClass<VM>,
    storeProducer: () -> ViewModelStore,
    extrasProducer: () -> CreationExtras = { defaultViewModelCreationExtras },
    factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
    val factoryPromise = factoryProducer ?: {
        defaultViewModelProviderFactory
    }
    return ViewModelLazy(viewModelClass, storeProducer, factoryPromise, extrasProducer)
}
// 惰性的 ViewModel
public class ViewModelLazy<VM : ViewModel> (
    private val viewModelClass: KClass<VM>,
    private val storeProducer: () -> ViewModelStore,
    private val factoryProducer: () -> ViewModelProvider.Factory
) : Lazy<VM> {
    // ViewModel 实例缓存
    private var cached: VM? = null
    override val value: VM
        get() {
            val viewModel = cached
            return if (viewModel == null) {
                val factory = factoryProducer()
                val store = storeProducer()
                // 构建 ViewModel 实例
                ViewModelProvider(store, factory).get(viewModelClass.java).also {
                    cached = it // 缓存 ViewModel 实例
                }
            } else {
                viewModel
            }
        }
    override fun isInitialized(): Boolean = cached != null
}


activityViewModels() 将 ViewModel 的构建封装在一个Lazy里面,表示会惰性计算一次,计算完成后会存入缓存,下次直接获取。所以它必须配合关键词by一起使用。


构建 ViewModel 时传入的 ViewModelProvider.Factory 和 ViewModelStore 都是 activity 的,这样就可以在子 Fragment 中轻松的获取父 Activity 的声明的 ViewModel 了,实现 ViewModel 的共享。


共享 ViewModel 使得 Activity 和 Fragment 可以轻松地获取同一个 ViewModel 实例,所以在它们之间共享业务逻辑或传输数据都变得易如反掌。


新的复杂度


攻击窗口


那些介于同一变量多个引用点之间的代码称为 “攻击窗口”。可能会有新代码加到这种窗口中,不当地修改了这个变量,或者阅读代码的人可能会我忘记该变量应有的值。

一般而言,把对一个变量的引用局部化,即把引用点尽可能集中在一起总是一种很好的做法。这使得代码的阅读者,能每次只关注一部分代码。而如果这些引用点之间的距离非常远,那你就要迫使阅读者的目光在程序里跳来跳去。一个允许任何子程序在任何时间使用任何变量的程序是难于理解的。对于这种程序,你不能只去理解一个子程序,你还必须要理解区域所有使用了相同全局变量的子程序才行,这种程序无论阅读、调试还是修改起来都非常困难。


当代码迁移或重构时,若变量的引用点非常靠近,把相关代码片断重构成独立的子程序就非常容易。


因此,把变量的引用点集中起来除了能降低错误赋值的可能,还能增加代码的可读性,降低代码重构的难度。——《代码大全》


基于上述原因,在定义变量时,应该采用最严格的可见性,然后根据需求扩展变量的作用域:首选将变量局限于某个特定的循环中,然后是局限于某个子程序,其次成为类的私有成员变量,protected 变量,再其次对包可见,最后在迫不得已的情况下再把它作为全局变量。


很不幸 MVVM 架构中,ViewModel 持有的 LiveData 的作用域比想象中的要大,这会带来不可预期的错误。


一开始我是这样定义 LiveData 的:


class SearchViewModel : ViewModel() {
    val liveData1 = MutableLiveData<String>()
    val liveData2 = MutableLiveData<Boolean>()
    val liveData3 = MutableLiveData<Int>()
    val liveData4 = MutableLiveData<Long>()
}


这意味着,在界面可以轻松地拿到 MutableLiveData 的引用,然后改变其值。若这种写法泛滥的话,修改 LiveData 值的代码就会散落在各处,增加阅读代码、修改代码、调试代码的困难。


image.png


图中的红色线条表示界面通过拿到 LiveData 的引用并修改其值。


不可预期的错误是指,当你通过图中的黑线修改 LiveData 值时,它的值可能和你预期不一致,因为还有 N 个别的地方在偷偷的地修改它。


于是乎有了下面这种写法:


class SearchViewModel : ViewModel() {
    private val _liveData1 = MutableLiveData<String>()
    val liveData1: LiveData<String> = _liveData1
    private val liveData2 = MutableLiveData<Boolean>()
    val liveData2: LiveData<Boolean> = _liveData2
    private val liveData3 = MutableLiveData<Int>()
    val liveData3: LiveData<Int> = _liveData3
    private val liveData4 = MutableLiveData<Long>()
    val liveData4: LiveData<Long> = _liveData4
}


只暴露 LiveData 给界面,这样界面就不能擅自修改其值。这样写的好处是所有对 LiveData 的写操作都内聚在 ViewModel 内部,而所有消费 LiveData 的观察者都在 ViewModel 外部。 这降低了维护数据的难度:


image.png


如图所示这就形成了一条“单线数据流”。


为啥单向数据流复杂度就比较低?举个例子,当排查问题时,你想知道到底是哪里修改了 LiveData 的值,在单向数据流的架构中只需要在一个地方打断点就好,因为所有的修改点都收口于此。


上面通过将 MutableLiveda 的作用域收窄简化了数据流动的复杂度。但还有一个复杂度 MVVM 没法化解,因为 LiveData 被定义为 ViewModel 的成员变量,而成员变量的攻击窗口是整个类,因为任何类方法都可以轻松的访问到成员变量。


一个 LiveData 携带着一个 Model,一个 Model 表达这一个界面状态。当新增业务逻辑界面状态发生变化时,首先是在 ViewModel 中新增一个方法以触发业务逻辑,然后在新方法中去修改与该界面状态相关的 LiveData,此时 LiveData 攻击窗口 + 1,处理不好就会变成新增功能导致功能衰退。


纯函数 & 副作用


再从“纯函数 & 副作用”的角度重新审视上述问题:


fun add(a: Int, b: Int){
    return a+b
}


这是一个无副作用的方法,它的结果是可预测的,不会受到除了参数a,b之外其他任何因素的影响。如果输入参数是两个9,返回值必然是18,这个方法执行一万遍结果还是不变,即使方法执行的时候,奥特曼突然出现,结果还是不变。无副作用即可预测,不会发生意料之外的事情。


如果所有的方法都是可以预测的,不会发生意料之外的事情,那该是多美好的一件事情。但往往事与愿违:


var c = 2
fun add(a: Int, b: Int){
    return a+b+c
}


这是一个有副作用的方法,它的结果是不可预测的,因为 c 是一个公共变量,它可能被其他方法访问,其值可能随时发生变化,这导致 add() 的返回值不可预测。


c = 2
add(9,9) // 返回 20
c = 3
add(9,9) // 返回 21


上述代码的执行结果是反直觉的,为啥两次调用 add 的返回值不同?


因为 add() 方法不仅依赖入参还依赖公共变量。你不得不点开 add(),去细究实现细节才能搞懂真相。就好比作者使用晦涩难懂的比喻,为了看明白,不得不逐个百度,再把他们拼凑起来,才能看懂。


除了语义上的难懂,返回值的不可预期也使得程序更容易出错。


更好的写法如下:


fun add(a: Int, b: Int, c: Int){
    return a+b+c
}
var c = 2
add(9, 9, c)


把可能产生副作用的因子作为参数传入,保证方法内部的“无副作用”,这样该方法就可以被安心地调用了。而且该方法也更容易被单元测试了。


但 ViewModel 中所有操纵 LiveData 的函数都不是纯函数,因为 LiveData 是成员变量,这就会发生不可预期的错误。比如测试和你做了同样一段操作,你俩的界面状态就是不一样。因为表面上看执行了相同函数去更新界面状态,但因为它是有副作用的函数,所以不能保证执行两遍就得出相同的结果。这样就为问题的排查设置了重重障碍。


总结


上一篇引入了 MVVM 架构的两个重要概念 ViewModel 以及 LiveData。


通过这一篇的讲述,ViewModel 不仅使得“有免死金牌的业务层”成为可能,也使得跨界面之间的业务逻辑共享以及通信变得轻松。


而 LiveData 不仅使得业务层成为数据持有者以数据驱动刷新界面,还避免了生命周期问题以及内存泄漏风险。


但是 MVVM 引入了新的复杂度,因为更新数据的方法是带有副作用的,由此引起的是不可预期的界面状态。看看下一篇的 MVI 是否能药到病除。


推荐阅读


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


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


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


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


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


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


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

目录
相关文章
|
28天前
|
前端开发 测试技术 数据处理
Kotlin教程笔记 - MVP与MVVM架构设计的对比
Kotlin教程笔记 - MVP与MVVM架构设计的对比
49 4
|
28天前
|
存储 前端开发 Java
Kotlin教程笔记 - MVVM架构怎样避免内存泄漏
Kotlin教程笔记 - MVVM架构怎样避免内存泄漏
26 2
|
29天前
|
敏捷开发 缓存 中间件
.NET技术的高效开发模式,涵盖面向对象编程、良好架构设计及高效代码编写与管理三大关键要素
本文深入探讨了.NET技术的高效开发模式,涵盖面向对象编程、良好架构设计及高效代码编写与管理三大关键要素,并通过企业级应用和Web应用开发的实践案例,展示了如何在实际项目中应用这些模式,旨在为开发者提供有益的参考和指导。
24 3
|
1月前
|
XML 前端开发 Android开发
Kotlin教程笔记(80) - MVVM架构设计
Kotlin教程笔记(80) - MVVM架构设计
|
1月前
|
前端开发 JavaScript 测试技术
android做中大型项目完美的架构模式是什么?是MVVM吗?如果不是,是什么?
在 Android 开发中,选择合适的架构模式对于构建中大型项目至关重要。常见的架构模式有 MVVM、MVP、MVI、Clean Architecture 和 Flux/Redux。每种模式都有其优缺点和适用场景,例如 MVVM 适用于复杂 UI 状态和频繁更新,而 Clean Architecture 适合大型项目和多平台开发。选择合适的架构应考虑项目需求、团队熟悉度和可维护性。
53 6
|
1月前
|
存储 安全 Java
系统安全架构的深度解析与实践:Java代码实现
【11月更文挑战第1天】系统安全架构是保护信息系统免受各种威胁和攻击的关键。作为系统架构师,设计一套完善的系统安全架构不仅需要对各种安全威胁有深入理解,还需要熟练掌握各种安全技术和工具。
128 10
|
22天前
|
XML 前端开发 Android开发
Kotlin教程笔记(80) - MVVM架构设计
Kotlin教程笔记(80) - MVVM架构设计
|
1月前
|
存储 Dart 前端开发
flutter鸿蒙版本mvvm架构思想原理
在Flutter中实现MVVM架构,旨在将UI与业务逻辑分离,提升代码可维护性和可读性。本文介绍了MVVM的整体架构,包括Model、View和ViewModel的职责,以及各文件的详细实现。通过`main.dart`、`CounterViewModel.dart`、`MyHomePage.dart`和`Model.dart`的具体代码,展示了如何使用Provider进行状态管理,实现数据绑定和响应式设计。MVVM架构的分离关注点、数据绑定和可维护性特点,使得开发更加高效和整洁。
163 3
|
1月前
|
前端开发 Java 测试技术
android MVP契约类架构模式与MVVM架构模式,哪种架构模式更好?
android MVP契约类架构模式与MVVM架构模式,哪种架构模式更好?
49 0
|
15天前
|
弹性计算 API 持续交付
后端服务架构的微服务化转型
本文旨在探讨后端服务从单体架构向微服务架构转型的过程,分析微服务架构的优势和面临的挑战。文章首先介绍单体架构的局限性,然后详细阐述微服务架构的核心概念及其在现代软件开发中的应用。通过对比两种架构,指出微服务化转型的必要性和实施策略。最后,讨论了微服务架构实施过程中可能遇到的问题及解决方案。