移动应用架构设计:MVVM实战——从“能跑就行”到“晚上敢睡”的蜕变
大家好,我是老K,坐标成都,某金融科技公司后端开发,工龄5年。平时用 Mac 写代码,Windows 只用来测兼容性(谁让测试同事说“用户真机是 Windows 模拟器呢”)。虽然主业是搞后端,但因为团队人少+产品膨胀快,我从去年开始被“借调”参与移动端架构改造——没错,就是那个传说中“前后端通吃、全栈救火”的岗位。
说真的,一开始我以为自己只是来搭个 API 接口的,结果产品经理小李一句“用户体验要丝滑,数据要实时同步”,直接把我推进了 MVVM 的深水区。今天这篇文章,不灌鸡汤,不讲理论,就复盘我们团队在重构一款理财类 App 时,如何把一个“屎山混合体”改造成可维护、可测试、晚上敢睡觉的 MVVM 架构。
起因:那个让我们连续加班三周的“双11需求”
时间回到去年10月。离双11还有两周,产品突然甩过来一份 PRD:“新增‘资产全景’页面,支持实时持仓更新、动态收益曲线、多账户切换,且首次加载必须小于800ms”。
我一看 UI 稿:三个 Tab、五个图表、N 个异步接口、还要支持离线缓存和 WebSocket 实时推送……而当前代码?Activity 里塞了 2000 行逻辑,网络请求、UI 更新、状态判断全混在一起,连 ViewModel 都没听过(别笑,真有)。
更扎心的是,前端同事当时主力在搞 H5 版本,原生 Android/iOS 团队各就一人,还是外包。运维大哥半夜打电话:“线上 Crash 率飙升,用户说点‘总资产’就闪退”。我盯着日志里那句 NullPointerException: Attempt to invoke virtual method on a null object reference,默默点了杯美式,心想:这届 App 是真不打算让我过年了。
于是,技术债到期,重构提上日程。领导拍板:“这次必须上 MVVM,不然下次双11你还得通宵。”
为什么选 MVVM?不是 MVP 不好吗?
说实话,我最初对 MVVM 是抗拒的。毕竟后端出身,觉得“不就是把逻辑挪个地方?” 但翻了几本书(比如《Android 架构指南》《iOS 应用架构之道》),又扒了几个开源项目(如 Google 的 Sunflower),才意识到 MVVM 的核心不是“分层”,而是解耦 + 可测试 + 响应式。
我们产品有三大痛点:
- UI 和业务逻辑高度耦合:改个按钮颜色可能触发空指针。
- 测试覆盖率几乎为零:QA 全靠手工点,回归一次三天起步。
- 多平台一致性差:iOS 和 Android 功能对不上,用户投诉“为什么 iOS 有这个功能 Android 没有”。
MVVM 通过 ViewModel 抽象出 UI 状态,LiveData/StateFlow 实现数据驱动 UI,Repository 统一数据源,正好对症下药。
注:我们 Android 用 Kotlin + Jetpack Compose(部分旧页面仍用 XML),iOS 用 SwiftUI + Combine。虽平台不同,但 MVVM 思想一致,这也方便我们做跨端对齐。
实战:从“写死数据”到“响应式数据流”
第一步:定义清晰的职责边界
我们画了一张极简架构图,贴在会议室白板上(后来成了团队 meme 图):
[UI Layer] ←→ [ViewModel] ←→ [Repository] ←→ [Remote / Local]
- UI Layer(Activity/Fragment/SwiftUI View):只负责展示和用户交互,不包含任何业务逻辑。
- ViewModel:持有 UI 状态(如 loading、error、data),处理用户事件(如点击、下拉刷新),调用 Repository。
- Repository:单一数据源入口,决定从网络 or 本地 DB 获取数据,处理缓存策略。
- Data Sources:Retrofit/OkHttp、Room/Core Data、WebSocket Client 等。
举个具体例子:资产总览页面。
旧代码大概是这样的(伪代码):
// Activity.kt (恐怖故事开始)
override fun onCreate() {
fetchUserAssets()
}
fun fetchUserAssets() {
api.getAssets().enqueue { response ->
if (response.isSuccessful) {
val data = response.body()
textView.text = data.totalAmount
chartView.setData(data.history)
// …… 还有20行 UI 操作
} else {
showErrorDialog()
}
}
}
问题很明显:网络回调里直接操作 UI,无法单元测试,错误处理散落各处。
新架构下:
// AssetViewModel.kt
class AssetViewModel(
private val repository: AssetRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<AssetUiState>(AssetUiState.Loading)
val uiState: StateFlow<AssetUiState> = _uiState.asStateFlow()
init {
loadAssets()
}
private fun loadAssets() {
viewModelScope.launch {
try {
val assets = repository.getAssetsWithCache()
_uiState.value = AssetUiState.Success(assets)
} catch (e: Exception) {
_uiState.value = AssetUiState.Error(e.message ?: "加载失败")
}
}
}
fun refresh() {
loadAssets()
}
}
sealed class AssetUiState {
object Loading : AssetUiState()
data class Success(val assets: UserAssets) : AssetUiState()
data class Error(val message: String) : AssetUiState()
}
UI 层只需监听 uiState:
// Compose
LaunchedEffect(viewModel.uiState) {
viewModel.uiState.collect { state ->
when (state) {
is Loading -> showLoading()
is Success -> renderChart(state.assets)
is Error -> showToast(state.message)
}
}
}
iOS 侧用 SwiftUI + Combine 几乎同理:
@Published var uiState: AssetUiState = .loading
func loadAssets() {
Task {
do {
let assets = try await repository.getAssetsWithCache()
await MainActor.run {
self.uiState = .success(assets)
}
} catch {
await MainActor.run {
self.uiState = .error(error.localizedDescription)
}
}
}
}
效果?
- ViewModel 可单独写 Unit Test,不用启动模拟器。
- UI 只关心状态,不关心数据怎么来的。
- 错误统一处理,再也不用担心“某个接口失败导致整个页面空白”。
踩坑实录:那些文档没告诉你的事
坑1:LiveData vs StateFlow,到底用哪个?
Android 团队一开始用 LiveData,结果在 Compose 里发现 collectAsState() 对 LiveData 支持不够优雅。后来迁移到 StateFlow,但要注意:
- StateFlow 必须有初始值(不像 LiveData 可为空)。
- 要避免内存泄漏,记得用
repeatOnLifecycle或LaunchedEffect。
我们的折中方案:内部用 StateFlow,对外暴露 StateFlow,仅在需要兼容旧 Fragment 时桥接 LiveData。
坑2:Repository 里的缓存策略
产品经理要求“首次加载快,后续刷新要准”。我们用了 双重缓存:
- 内存缓存(ConcurrentHashMap / NSCache):用于快速返回最近数据。
- 磁盘缓存(Room / Core Data):用于离线场景。
但问题来了:如何保证缓存一致性?
我们引入了一个简单的版本号机制:
data class CacheWrapper<T>(
val data: T,
val version: Long,
val timestamp: Long
)
每次从网络拉取新数据,版本号+1。本地读取时,若版本过旧(比如超过5分钟),则触发后台刷新。
吐槽:这个方案其实来自一本叫《Designing Data-Intensive Applications》的书(中文名《数据密集型应用系统设计》),没想到在移动端也用上了。
坑3:多平台“伪同步”
由于 iOS 和 Android 开发节奏不同,经常出现“Android 已上线,iOS 还在联调”。为减少用户困惑,我们做了两件事:
- 共享接口契约:用 OpenAPI 3.0 定义所有 API,前后端+移动端共用一份 spec。
- Feature Flag 控制:通过远程配置(Firebase Remote Config / 自研配置中心)动态开关功能。
这样即使 iOS 迟一周上线,也能通过配置隐藏入口,避免用户看到“灰色不可点”按钮。
效果对比:数字不会骗人
重构上线三个月后,我们拉了份数据(真实数据脱敏):
| 指标 | 重构前 | 重构后 | 变化 |
|---|---|---|---|
| 页面平均加载时间 | 1250ms | 680ms | ↓45.6% |
| Crash 率 | 1.8% | 0.3% | ↓83% |
| UI 相关 Bug 数/月 | 27 | 6 | ↓78% |
| 单元测试覆盖率 | 12% | 68% | ↑466% |
最爽的是,今年双11当天,我居然晚上9点就下班了!运维群里一片祥和,产品经理发了个红包:“这次体验真的丝滑”。
一点心得:架构不是银弹,但能让你睡好觉
有人说:“小项目没必要搞 MVVM,过度设计。” 我以前也这么想。但经历了这次重构,我意识到:不是项目大小决定架构,而是团队规模、迭代速度、产品质量要求。
我们公司虽不大,但金融产品对稳定性、安全性要求极高。一个崩溃可能导致用户资金显示异常——这种锅,我背不起。
另外,别迷信“新技术”。我们虽然喜欢折腾(比如试过 Compose Multiplatform),但生产环境依然选择成熟组合:Jetpack + Room + Retrofit + Hilt(Android),SwiftUI + Combine + CoreData + Alamofire(iOS)。稳定压倒一切,尤其在涉及“钱”的场景。
最后,分享一句我们团队的口头禅:“写代码不是为了跑起来,是为了以后还能改。”
资源推荐(亲测有用)
如果你也在考虑 MVVM,这些资源帮了大忙:
- 书籍:
- 《Android App Development with Kotlin》(官方推荐,MVVM 章节很实)
- 《Combine: Asynchronous Programming with Swift》(RayWenderlich 出品,iOS 必读)
- 开源项目:
- Google’s Sunflower(Android MVVM 范本)
- Kickstarter’s iOS app(SwiftUI + MVVM 实践)
- 工具:
- Android Studio Profiler(查性能瓶颈神器)
- Xcode Instruments(Leaks & Time Profiler 救命)
写在最后
现在回头看,那次重构虽然痛苦,但值得。不仅产品体验提升,团队开发效率也高了——新来的实习生三天就能上手改页面,因为他只需要看 ViewModel 的状态流转。
成都的生活节奏慢,但代码不能慢。希望这篇带点血泪、带点咖啡渍的实战记录,能帮你少踩几个坑。毕竟,程序员最大的幸福,不是写出多炫的代码,而是改需求时不用通宵,上线后敢关机睡觉。
对了,如果你也在搞金融类 App,欢迎交流!(微信私聊暗号:“MVVM 救我” 😏)
— 老K,于成都家中阳台,配一杯冰美式,Mac 风扇安静如猫。

评论 0