移动应用架构设计实战:MVVM的那些事儿

极客生活家
2025-06-15 21:27
阅读 271

开篇:为什么选择谈这个话题?

开篇:为什么选择谈这个话题?

去年,我参与了一个中型电商类App的重构项目。当时团队已经意识到原有的MVC架构在代码量膨胀之后带来的种种问题——Activity臃肿、逻辑混乱、维护困难。尤其是每次产品需求变更时,我们总是被“改一个功能牵一发动全身”的魔咒折磨。

于是我们决定尝试用MVVM(Model-View-ViewModel)架构模式对项目进行模块化重构。这不是一个简单的技术选型,而是一次彻底的开发流程和协作方式的转变。

这篇文章我想通过亲身经历的项目案例,谈谈我们是如何一步步实践MVVM架构、遇到哪些坑,又是如何解决的。希望能给正在做架构选型或者准备重构的你一些启发。


项目背景:一次痛苦的迭代经历

项目背景:一次痛苦的迭代经历

这个电商App最初是典型的MVP架构,但随着业务复杂度的提升,Presenter层变得越来越重。尤其是在用户中心、订单管理这些核心页面中,一个Presenter文件经常超过2000行,修改起来如同走钢丝。

更糟的是,很多公共逻辑被硬编码进Activity/Fragment中,导致重复代码泛滥。比如商品详情页的“加入购物车”按钮点击事件,在三个不同页面上各有一份几乎一模一样的实现。当产品突然要求所有加购按钮都加上埋点统计的时候,我和同事一边骂娘一边手动修改每个地方……

这时候我们意识到必须做出改变了。在技术选型会议上,有几个方案摆在面前:

  • 继续优化MVP
  • 尝试MVVM + LiveData
  • 接入状态管理框架(如Redux)
  • 使用Jetpack Compose直接构建新界面(但我们还不敢)

考虑到现有项目的可迁徙性、学习成本以及未来可维护性,我们最终选择了基于Jetpack组件的MVVM架构,配合Data Binding和ViewModel+LiveData作为核心方案。


遇到的挑战:不只是技术上的事

遇到的挑战:不只是技术上的事

开始转型之后,最大的挑战其实不是代码结构怎么拆分,而是整个团队协作方式的变化。

挑战1:代码责任划分不清晰

刚开始大家对ViewModel到底应该包含什么内容理解不一致。有的同学认为ViewModel就是把原来的Presenter换了个名字,结果ViewModel里照样写了网络请求、数据处理、甚至UI操作相关的代码。

这完全违背了我们使用MVVM的初衷 —— 要让UI与逻辑解耦。

挑战2:生命周期带来的副作用

我们在ViewModel中封装了一些异步任务,但没有做好异常处理机制。某个页面因为ViewModel未正确释放,导致异步回调中的UI更新崩溃。

这种错误一开始频发,后来我们不得不制定统一的协程Scope管理和异常捕获规范。

挑战3:Data Binding带来的学习曲线

刚引入Data Binding时,大家都觉得“写XML比以前多了好多@{xxx}”,抱怨不断。有些经验丰富的开发者甚至坚持要在代码中绑定监听器而非使用BindingAdapter。

这其实是一个习惯问题,我们花了两三个月才真正体会到双向绑定带来的便利。


解决思路与方案设计

我们围绕Google官方推荐的Android Jetpack组件来搭建整体架构:

ViewModel + LiveData + Repository + Room + Retrofit

整体架构如下:

Repository
   ↑
ViewModel
   ↑
View(Activity / Fragment / Compose)

架构职责分工明确

层级 职责
View 响应用户交互,观察ViewModel变化并更新UI
ViewModel 管理当前页面UI相关的状态和业务逻辑
Repository 数据仓库,负责数据来源(本地/网络)切换,提供统一接口
DataSource 数据源接口,定义网络请求或数据库访问逻辑

这样的分层结构让我们在后续开发中更容易控制代码质量。


实践中的关键代码示例

接下来我分享几个核心模块的代码片段,供参考:

ViewModel的初始化和数据加载

class ProductDetailViewModel(
    private val repository: ProductRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val productId = savedStateHandle.get<String>("product_id") ?: ""

    private val _productLiveData = MutableLiveData<Product>()
    val productLiveData: LiveData<Product> = _productLiveData

    fun loadProductDetail() {
        viewModelScope.launch {
            try {
                val result = repository.getProductDetail(productId)
                _productLiveData.postValue(result)
            } catch (e: Exception) {
                // 异常通知
                eventBus.sendEvent(ShowToastEvent("加载失败"))
            }
        }
    }
}

小提示SavedStateHandle用于保存实例状态,非常适合用在需要恢复的状态场景中。


Repository层整合网络与本地存储

class ProductRepository(private val apiService: ApiService, private val db: AppDatabase) {

    suspend fun getProductDetail(id: String): Product {
        val fromDb = db.productDao().getById(id)
        if (fromDb != null && !isExpired(fromDb)) {
            return fromDb
        }

        val response = apiService.getProductDetail(id)
        val product = response.data
        db.productDao().insert(product)
        return product
    }
}

这样设计可以很容易地扩展缓存策略或引入其他数据源。


Data Binding简单示例

在布局XML中:

<layout>
    <data>
        <variable name="viewModel" type="com.example.ProductDetailViewModel"/>
    </data>

    <TextView
        android:text="@{viewModel.productLiveData.title}"
        ... />
</layout>

在Fragment中绑定:

val binding = FragmentProductDetailBinding.inflate(inflater)
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner

别小看这几行代码,它能有效减少一大半的手动findViewById调用。


踩过的坑和解决办法

说了优点不能不说痛点。下面是我整理的一些真实踩过的坑,希望你不再重蹈覆辙。


坑1:ViewModel内存泄露

我们在初期犯过最严重的错误就是在ViewModel中持有Activity引用。例如:

class MyViewModel(context: Context) : ViewModel() { ... }

这样做会导致ViewModel无法回收,从而引发内存泄露。正确的做法是绝不传入Context对象,如果确实需要上下文资源,可以通过Application提供的上下文。

修复方法:

class MyViewModel(application: Application) : AndroidViewModel(application) {
    ...
    val context = getApplication<Application>().applicationContext
}

坑2:LiveData没有防抖机制

有一个页面会频繁触发筛选条件变动事件,每次改动都会发送搜索请求。但我们发现短时间内多次点击筛选项,会导致多个相同的请求被打到后端。

解决方案很简单 —— 我们用 DistinctUntilChanged() 来去重:

viewModel.searchQuery.observe(viewLifecycleOwner) { query ->
    if (query != lastQuery) {
        // 发送新请求
    }
}

或者在ViewModel内部就做节流处理:

private val _searchInput = MutableLiveData<String>()
val searchResult: LiveData<Result> = _searchInput.switchMap { input ->
    liveData {
        delay(300) // 输入防抖
        emit(repository.search(input))
    }
}

坑3:Data Binding性能问题

Data Binding默认开启的表达式绑定在大型项目中可能会带来一定的性能损耗,特别是在RecyclerView中频繁更新时会出现卡顿现象。

优化建议:

  1. 合理使用<layout>层级,避免过度嵌套。
  2. 对于动态列表,可以在适配器中显式设置Binding变量而不是依赖自动绑定。
  3. 打开DataBinding的增量编译特性:
android {
    buildFeatures {
        dataBinding = true
    }
}

改造后的效果和收益总结

经过为期半年的逐步迁移和重构,我们的项目结构变得更加清晰,主要体现在以下几个方面:

✅ 可维护性提高

  • ViewModel和Repository的职责分明,业务逻辑集中在单一位置,查找、调试更方便。
  • 修改UI不影响业务代码,反之亦然。
  • 多人协作时冲突变少。

✅ 测试更友好

  • ViewModel无依赖,可以直接单元测试。
  • Repository也可mock数据进行快速验证。
  • UI测试也更聚焦于实际交互。

✅ 性能优化空间更大

  • 数据驱动UI的方式使得局部刷新更容易。
  • ViewModel的生命周期与系统生命周期解耦,有利于资源回收。

当然,重构也不是灵丹妙药。我们在初期还是付出了一定的“转型成本”,包括编写文档、组织培训、Code Review标准重建等。


我的经验分享给正在看这篇文章的你

🧱 架构没有银弹,适合自己的才是好架构

MVVM不是一个万能钥匙,它解决了UI和业务分离的问题,但也带来了额外的学习成本。如果你的项目非常小型,可能并不需要一开始就上MVVM。但如果你们的App已经有十几个页面,并且业务逐渐复杂,那么越早做架构规划越好。

⏳ 架构升级需要时间和耐心

不要期待几天就能完成从MVC到MVVM的切换。我们花了三个月时间,边迭代边重构,最终才稳定下来。而且期间还经历了几次架构微调,比如引入协程、合并ViewModel等。

📖 文档和团队共识非常重要

我在项目初期没有及时输出架构说明文档,导致部分新人误用了老架构的方法。后来我们整理了一份《MVVM编码规范》,并在每周的代码评审中严格检查是否符合。

💡 别怕犯错,关键是持续改进

我们最初写的ViewModel也有不少反模式,后来才慢慢修正。重构就是一个不断优化的过程,重要的是保持代码干净、易于理解。


写在最后的一点感悟

做移动开发这些年,我越来越体会到:好的架构不是写出来的,而是在一次次踩坑中打磨出来的。MVVM虽然只是众多架构模式中的一种,但它教会了我如何写出更具伸缩性和可读性的代码。

现在的我们已经在逐步向Jetpack Compose + MVI架构过渡,但那又是另一个故事了。

感谢阅读,愿每一位开发者都能找到属于自己的代码之美。


文章作者简介:一名深耕Android领域八年的老兵,经历过创业公司、大厂以及出海项目实战。目前专注于架构优化与高性能客户端研发。欢迎在评论区交流你的实践经验~

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝