从痛苦中成长:我在移动开发项目中实践 MVVM 的真实经历

小王的技术栈
2025-06-27 16:00
阅读 208

背景介绍

背景介绍

作为一名有五年经验的 Android 开发工程师,这些年我参与过多个中小型项目的开发和维护。其中有一个电商类 App 的重构项目让我印象深刻——那是一次彻头彻尾的技术改造,核心就是围绕 MVVM(Model-View-ViewModel)架构 的全面应用。

这个 App 最初是一个典型的 MVP 模式工程,随着功能不断膨胀,代码逐渐变得臃肿、难以测试,修改一个模块经常需要牵动多个层级。尤其是团队新人在接手时,常常被繁琐的逻辑调用和复杂的生命周期操作搞得晕头转向。

而当时公司对 App 的质量要求也在不断提升:不仅要在多设备上稳定运行,还要满足更频繁的迭代节奏、更高效的测试覆盖率以及后续 iOS 平台适配的可能性。面对这些问题,我们决定在新一轮重构中引入 MVVM 架构作为核心设计思想。

这篇文章就来聊聊我们在实践中遇到的挑战、解决思路、踩过的坑以及最终收获的经验。


初期困境:痛点暴露无遗

初期困境:痛点暴露无遗

我们的项目最初是基于 MVP 设计的,每个页面都对应一个 Presenter,Activity 或 Fragment 承担 View 层的角色。这样的结构在业务简单的时候还能应付,但随着商品详情页、订单流程、用户中心等多个复杂模块的加入,问题开始浮出水面:

1. 好端端的“胖子” Activity/Fragment

很多页面的 View 层(通常是 Fragment 或者 Activity)承载了过多职责。除了处理 UI 外,还有大量的事件监听、数据绑定、本地缓存、甚至网络请求的处理逻辑。例如商品详情页中,光是“立即购买”按钮点击后触发的行为就有七八个不同分支,包括库存判断、价格计算、优惠券检查等。

这导致这些组件越来越难维护,每次改动都需要小心翼翼地阅读几十行甚至上百行的方法体。

2. Presenter 不可控的增长趋势

每一个页面都有对应的 Presenter 类,本意是为了分离视图与业务逻辑。但随着时间推移,Presenter 开始包含越来越多非 UI 相关的逻辑,比如一些基础的数据格式转换、状态管理、跨页面通信。部分 Presenter 类文件竟然达到了上千行代码。

更糟的是,在某些页面跳转或 Tab 切换的过程中,出现了内存泄漏的情况。后来排查发现是因为某些 Presenter 在页面关闭后没有及时释放持有的资源(如 EventBus 订阅、Handler 引用等),而这类问题是传统 MVP 结构下很容易出现的问题。

3. 测试成本高得吓人

虽然理论上 MVP 是支持单元测试的,但在实际项目中因为各种耦合关系太严重,写单测成了非常艰难的事情。例如测试某个订单结算逻辑时,往往要模拟多个接口回调、UI 状态变更、甚至依赖数据库返回值,稍不注意就报空指针或者死循环。

更别提自动化测试了,当时的项目几乎没有做任何自动化 UI 测试的准备,回归测试完全靠人工走查。

4. 跨平台适配困难

当时我们开始规划 iOS 版本,希望两个平台能尽可能复用通用业务逻辑。但 MVP 这种高度依赖 Android 页面结构的设计,让我们很难抽出独立的业务模块供 iOS 使用。很多原本封装在 Presenter 中的逻辑,其实跟平台无关,但因为结构限制无法直接重用。


为什么选择 MVVM?

为什么选择 MVVM?

带着上面这些问题,我们在技术评审会上提出了是否可以考虑使用 MVVM(Model - View - ViewModel)模式 来重构整个架构。

最终我们决定采用 MVVM 的原因主要有以下几点:

1. 更清晰的分层与解耦

  • ViewModel 接管数据驱动的业务逻辑
  • View 只负责 UI 层渲染和交互反馈
  • 数据通过 LiveData 或 StateFlow 实现响应式更新

这种模式让我们能够在不关心当前页面是否存在的前提下执行业务处理,同时又可以通过声明式的观察机制自动同步 UI。

2. ViewModel 生命周期更安全

传统的 MVP 中,Presenter 通常由 View 持有并手动创建销毁,如果 View 关闭后未及时清理引用,容易造成内存泄露。

而在 Android Jetpack 提供的 ViewModel 中,它会根据生命周期自动管理自身实例的存活周期,并且只在关联的 Scope(如 Fragment 或 NavGraph)存在时才保持活跃。这种机制天然避免了很多内存泄露的问题。

3. 更强的可测试性

由于 ViewModel 完全与 View 分离,并且大多数逻辑都是纯函数调用或响应式流的变换,因此非常适合进行单元测试。我们甚至可以在没有 UI 组件的情况下直接测试 ViewModel 的数据流转行为。

另外,得益于 LiveData、StateFlow 等响应式编程 API 的封装,我们可以很方便地模拟各种异步场景,比如成功、失败、错误码等。

4. 更好的跨平台拓展可能

ViewModel 可以抽离成纯粹的数据模型+业务逻辑组合,如果我们以后想在 iOS 上使用 Swift ViewModel 替代实现(或者在 Compose Multiplatform 上统一代码),也更容易做到。


实施过程详解

实施过程详解

整个 MVVM 改造并非一蹴而就,我们采取了渐进式替换的策略,逐步将 MVP 中的各个模块迁移到新的架构体系中。

以下是我们在实战中的几个关键步骤和设计决策。

1. 引入 Android Architecture Components

我们在项目中引入了官方推荐的 AAC 组件库:

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"

随后,我们将原来的 Presenter 类重构成基于 ViewModel 的结构:

旧 MVP:

class ProductDetailPresenter {
    fun loadProductDetails(productId: String) { ... }
    fun onAddToCartClicked() { ... }
}

改为 MVVM 后:

class ProductDetailViewModel : ViewModel() {
    private val _product = MutableLiveData<Product>()
    val product: LiveData<Product> = _product

    fun loadProduct(id: String) {
        // 调用 Repository 获取数据
        val result = repository.getProductById(id)
        _product.postValue(result)
    }
}

View 层只需要订阅 LiveData 数据即可自动更新:

viewModel.product.observe(viewLifecycleOwner) { product ->
    binding.tvProductName.text = product.name
}

2. 重构数据流向:Repository + UseCase 模式

为了进一步解耦数据获取与展示逻辑,我们采用了如下结构:

  • ViewModel:负责协调 UI 层和业务逻辑
  • UseCase:定义具体业务逻辑方法,接受参数并返回结果
  • Repository:抽象数据源(本地 DB、API、缓存)

举个例子,下单功能我们拆分为:

class PlaceOrderUseCase(private val orderRepository: OrderRepository) {
    suspend operator fun invoke(cartItems: List<CartItem>) {
        orderRepository.placeOrder(cartItems)
    }
}

然后在 ViewModel 中调用:

fun placeOrder(cartItems: List<CartItem>) {
    viewModelScope.launch {
        try {
            placeOrderUseCase.invoke(cartItems)
            _orderSuccess.postValue(true)
        } catch (e: Exception) {
            _errorMessage.postValue("下单失败,请重试")
        }
    }
}

这样做的好处是非常明显的:

  • ViewModel 不再直接接触网络或数据库
  • UseCase 可以轻松被复用
  • 数据访问逻辑统一由 Repository 管理,方便 Mock 和测试

3. 用 Kotlin Flow / StateFlow 替代 LiveData(后期升级)

一开始我们还在用 LiveData,但它属于阻塞式 API,对于链式变换、背压控制等处理不太灵活。

后来我们逐渐将部分 ViewModel 中的状态流改用 StateFlow,特别是在商品搜索筛选、购物车状态变化等高频更新场景中,效果明显提升。

例如:

val cartItemCount: StateFlow<Int> = repository.cartItemCountStateFlow

// 观察方式
viewModelScope.launch {
    cartItemCount.collect {
        updateCartCount(it)
    }
}

这让我们的状态管理和异步更新更加自然流畅。

4. 将 UI 层彻底解放出来

我们重新规范了 Fragment 和 Activity 的职责边界:

  • 只负责绑定 ViewModel 和接收用户交互
  • 所有数据变化都通过 observe()collect() 自动更新
  • 所有点击事件都直接交给 ViewModel 处理

以一个简单的登录流程为例:

class LoginFragment : Fragment() {

    private val viewModel by viewModels<LoginViewModel>()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.btnLogin.setOnClickListener {
            val username = binding.etUsername.text.toString()
            val password = binding.etPassword.text.toString()
            viewModel.login(username, password)
        }

        viewModel.isLoading.observe(viewLifecycleOwner) {
            if (it) showLoadingDialog else hideLoadingDialog()
        }

        viewModel.loginResult.observe(viewLifecycleOwner) { success ->
            if (success) navigateToHome()
            else showToast("登录失败")
        }
    }
}

这样的结构让 UI 层干净清爽,逻辑集中,团队新人也能快速理解页面的工作机制。


遇到的挑战与应对策略

当然,重构过程中我们也遇到了不少挑战,有些是在预期之内,有些则是完全没预料到的。

挑战 1:ViewModel 如何高效共享

在一个 Tab 页面中有多个 Fragment 共享同一个 ViewModel 的需求,比如商品分类下的四个子页面要共享一个数据源。

这时候我们用了 by activityViewModels()

class ProductTabFragment : Fragment() {
    private val sharedViewModel by activityViewModels<SharedProductViewModel>()
}

通过指定范围为 Activity 级别的 ViewModel,各 Tab Fragment 就能轻松共享数据。这对减少重复请求、优化性能非常有用。

挑战 2:如何避免 “过度 Observable”

刚开始时,我们误以为所有状态都应该封装进 LiveData 中。比如一个简单的弹窗提示,也非要搞个 _showDialog: MutableLiveData<Boolean>,结果导致大量冗余代码。

后来我们意识到:不是所有 UI 状态都需要“可观察”,有些短暂性的 UI 动作更适合用 SingleLiveEvent(一种一次性消费的 Event),或者干脆直接调用 navigate()showDialog() 方法,避免不必要的 observable 管理。

挑战 3:数据恢复和异常处理

由于 ViewModel 会在配置改变时不销毁,所以当用户旋转屏幕或切换语言时,我们需要确保数据不会丢失。

我们通过以下方式解决了这个问题:

  • 对于临时状态(如加载进度),在 ViewModel 内部保存为 MutableLiveData
  • 对于耗时任务,用 viewModelScope.launch() 启动协程,配合取消机制
  • 对于异常情况,统一用 sealed class 包装状态,便于 UI 做统一处理

例如:

sealed class UiState {
    object Loading : UiState()
    data class Success(val data: Data) : UiState()
    data class Error(val message: String) : UiState()
}

挑战 4:初期学习曲线陡峭

新成员加入项目组时,很多人一开始不太适应 MVVM 的写法。特别是 LiveData + 协程 + StateFlow 这套组合拳,如果没有一定经验的人很容易写反了顺序,或者搞不清什么时候该用 observe,什么时候用 collect。

为此我们做了三件事:

  1. 编写了简洁的编码规范文档,图文结合示例说明常见用法
  2. 搭建了一个 Demo 项目,演示典型业务场景的 MVVM 实现
  3. 在 Code Review 中重点关注新手写的 ViewModel 是否符合设计规范

一段时间后,大家普遍反映 MVVM 的结构比 MVP 更清晰,逻辑更易懂。


效果总结与收益

经过约两个月的时间完成架构迁移,整体项目质量得到了显著提升,主要体现在以下几个方面:

✅ 1. 代码可读性和可维护性大幅提高

现在打开任意一个页面的 ViewModel,都能快速看到它的输入输出、状态变更逻辑。View 层几乎没有任何业务代码,全部交由 ViewModel 负责。

✅ 2. 团队协作效率提升

统一了架构规范之后,不同开发人员之间的协同更加顺畅。即使一个人离职,其他人也能迅速接手他的工作内容。

✅ 3. 单元测试覆盖率大幅提升

我们使用 JUnit 和 MockK 对 ViewModel 的业务逻辑进行了全面覆盖,单测数量从原来的 20% 提升到了 75%,大大提高了回归验证的效率。

✅ 4. App 性能和稳定性更好了

由于 ViewModel 的生命周期管理更科学,内存泄漏大幅减少,页面加载速度也有一定提升。尤其在低端机型上表现更为稳定。

✅ 5. iOS 适配更轻松

虽然目前还没启动 iOS 版本的开发,但我们已经开始尝试用 Kotlin Multiplatform 抽取部分领域模型和公共 UseCase。MVVM 的分层结构让我们更容易识别哪些部分适合复用。


我的一些建议与心得

如果你正在考虑要不要在自己的项目中尝试 MVVM,我可以分享以下几条建议:

📌 1. 不要盲目追求“最先进”的架构,要看团队是否适合

MVVM 虽好,但也不是万能灵药。如果项目规模小,业务逻辑简单,MVC 或者 MVP 反而是更快捷的选择。只有当你真正遇到业务复杂、多人协作、测试困难等问题时,MVVM 的优势才会显现出来。

📌 2. 建立清晰的项目规范和命名规则

比如:

  • ViewModel 类名统一为 XxxViewModel
  • LiveData 名以 _xxxxxx 成对出现
  • UseCase 以动词开头,如 LoadUserUseCase
  • Repository 抽象接口放在 domain 层

这些规范看似细枝末节,但对于后期维护至关重要。

📌 3. 善用工具提升效率

Jetpack 提供了很多强大的辅助工具,比如 Safe Args、DataStore、Navigation 等,合理搭配使用能让开发事半功倍。

同时,Kotlin 协程 + Flow 也是现代 Android 开发中不可或缺的一环,值得花时间深入掌握。

📌 4. 不要一开始就追求完美架构

我们一开始也没有把所有的 ViewModel 都写得很“规范”。有些模块可能是边写边改,甚至允许暂时混合 MVP 和 MVVM 的写法。

重要的是先迈出第一步,再在迭代中不断优化和完善。

📌 5. 注意版本兼容性问题

Android 架构组件(AAC)本身也在不断演进,有时候升级到新版可能会有 Breaking Change。记得关注官方更新日志,做好兼容性测试。


结语:架构是为了解决问题,而不是制造麻烦

写到这里,我想说的是,MVVM 真正的价值并不是它有多么“高级”,而是它帮你把复杂的问题结构化、模块化、可视化。

回顾整个重构过程,我们并不是一帆风顺的。有过争执、妥协、加班,也曾踩过不少坑,但最终我们收获了一个更清晰、更易维护、更可持续发展的架构体系。

作为一个开发者,我也更加明白:好的架构,从来都不是照搬模板的结果,而是在实际工作中反复打磨、不断迭代后的智慧结晶。

希望这篇文章能帮你在自己的项目中少走弯路,早日找到最适合你们团队的架构之道。

感谢你读到这里,如果有任何问题或交流想法,欢迎随时联系我。

评论 0

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