移动应用架构设计:MVVM实战——从“能跑就行”到“晚上敢睡”的蜕变

李秀英_数据
2025-12-14 01:01
阅读 611

大家好,我是老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 的核心不是“分层”,而是解耦 + 可测试 + 响应式

我们产品有三大痛点:

  1. UI 和业务逻辑高度耦合:改个按钮颜色可能触发空指针。
  2. 测试覆盖率几乎为零:QA 全靠手工点,回归一次三天起步。
  3. 多平台一致性差: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 可为空)。
  • 要避免内存泄漏,记得用 repeatOnLifecycleLaunchedEffect

我们的折中方案:内部用 StateFlow,对外暴露 StateFlow,仅在需要兼容旧 Fragment 时桥接 LiveData

坑2:Repository 里的缓存策略

产品经理要求“首次加载快,后续刷新要准”。我们用了 双重缓存

  1. 内存缓存(ConcurrentHashMap / NSCache):用于快速返回最近数据。
  2. 磁盘缓存(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 还在联调”。为减少用户困惑,我们做了两件事:

  1. 共享接口契约:用 OpenAPI 3.0 定义所有 API,前后端+移动端共用一份 spec。
  2. 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 必读)
  • 开源项目
  • 工具
    • Android Studio Profiler(查性能瓶颈神器)
    • Xcode Instruments(Leaks & Time Profiler 救命)

写在最后

现在回头看,那次重构虽然痛苦,但值得。不仅产品体验提升,团队开发效率也高了——新来的实习生三天就能上手改页面,因为他只需要看 ViewModel 的状态流转。

成都的生活节奏慢,但代码不能慢。希望这篇带点血泪、带点咖啡渍的实战记录,能帮你少踩几个坑。毕竟,程序员最大的幸福,不是写出多炫的代码,而是改需求时不用通宵,上线后敢关机睡觉

对了,如果你也在搞金融类 App,欢迎交流!(微信私聊暗号:“MVVM 救我” 😏)

— 老K,于成都家中阳台,配一杯冰美式,Mac 风扇安静如猫。

评论 0

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