“无架构”和“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能力挽狂澜?(一)

目录
相关文章
|
3月前
|
SQL 前端开发 关系型数据库
如何开发一套研发项目管理系统?(附架构图+流程图+代码参考)
研发项目管理系统助力企业实现需求、缺陷与变更的全流程管理,支持看板可视化、数据化决策与成本优化。系统以MVP模式快速上线,核心功能包括需求看板、缺陷闭环、自动日报及关键指标分析,助力中小企业提升交付效率与协作质量。
|
3月前
|
JSON 文字识别 BI
如何开发车辆管理系统中的加油管理板块(附架构图+流程图+代码参考)
本文针对中小企业在车辆加油管理中常见的单据混乱、油卡管理困难、对账困难等问题,提出了一套完整的系统化解决方案。内容涵盖车辆管理系统(VMS)的核心功能、加油管理模块的设计要点、数据库模型、系统架构、关键业务流程、API设计与实现示例、前端展示参考(React + Antd)、开发技巧与工程化建议等。通过构建加油管理系统,企业可实现燃油费用的透明化、自动化对账、异常检测与数据分析,从而降低运营成本、提升管理效率。适合希望通过技术手段优化车辆管理的企业技术人员与管理者参考。
|
3月前
|
消息中间件 缓存 JavaScript
如何开发ERP(离散制造-MTO)系统中的生产管理板块(附架构图+流程图+代码参考)
本文详解离散制造MTO模式下的ERP生产管理模块,涵盖核心问题、系统架构、关键流程、开发技巧及数据库设计,助力企业打通计划与执行“最后一公里”,提升交付率、降低库存与浪费。
|
2月前
|
前端开发 JavaScript BI
如何开发车辆管理系统中的车务管理板块(附架构图+流程图+代码参考)
本文介绍了中小企业如何通过车务管理模块提升车辆管理效率。许多企业在管理车辆时仍依赖人工流程,导致违章处理延误、年检过期、维修费用虚高等问题频发。将这些流程数字化,可显著降低合规风险、提升维修追溯性、优化调度与资产利用率。文章详细介绍了车务管理模块的功能清单、数据模型、系统架构、API与前端设计、开发技巧与落地建议,以及实现效果与验收标准。同时提供了数据库建表SQL、后端Node.js/TypeScript代码示例与前端React表单设计参考,帮助企业快速搭建并上线系统,实现合规与成本控制的双重优化。
|
3月前
|
机器学习/深度学习 人工智能 搜索推荐
从零构建短视频推荐系统:双塔算法架构解析与代码实现
短视频推荐看似“读心”,实则依赖双塔推荐系统:用户塔与物品塔分别将行为与内容编码为向量,通过相似度匹配实现精准推送。本文解析其架构原理、技术实现与工程挑战,揭秘抖音等平台如何用AI抓住你的注意力。
921 7
从零构建短视频推荐系统:双塔算法架构解析与代码实现
|
3月前
|
监控 供应链 前端开发
如何开发ERP(离散制造-MTO)系统中的财务管理板块(附架构图+流程图+代码参考)
本文详解离散制造MTO企业ERP系统中财务管理模块的搭建,聚焦应收账款与应付账款管理,涵盖核心功能、业务流程、开发技巧及Python代码示例,助力企业实现财务数据准确、实时可控,提升现金流管理能力。
|
3月前
|
供应链 监控 JavaScript
如何开发ERP(离散制造-MTO)系统中的库存管理板块(附架构图+流程图+代码参考)
本文详解MTO模式下ERP库存管理的关键作用,涵盖核心模块、业务流程、开发技巧与代码示例,助力制造企业提升库存周转率、降低缺货风险,实现高效精准的库存管控。
|
3月前
|
前端开发 API 定位技术
如何开发车辆管理系统中的用车申请板块(附架构图+流程图+代码参考)
本文详细解析了如何将传统纸质车辆管理流程数字化,涵盖业务规则、审批流、调度决策及数据留痕等核心环节。内容包括用车申请模块的价值定位、系统架构设计、数据模型构建、前端表单实现及后端开发技巧,助力企业打造可落地、易扩展的车辆管理系统。
|
3月前
|
设计模式 人工智能 API
AI智能体开发实战:17种核心架构模式详解与Python代码实现
本文系统解析17种智能体架构设计模式,涵盖多智能体协作、思维树、反思优化与工具调用等核心范式,结合LangChain与LangGraph实现代码工作流,并通过真实案例验证效果,助力构建高效AI系统。
532 7
|
3月前
|
消息中间件 JavaScript BI
如何开发ERP(离散制造-MTO)系统中的客户管理板块(附架构图+流程图+代码参考)
本文详解离散制造-MTO模式下ERP系统客户管理模块的设计与实现,涵盖架构图、流程图、功能拆解、开发技巧及TypeScript参考代码,助力企业打通客户信息与报价、生产、交付全链路,提升响应效率与订单准交率。