从双11崩溃到架构重构:一个考公程序员的 MVVM 实战血泪史
上周五晚上十一点,我正窝在 MacBook Pro 前刷行测题,手机突然疯狂震动——公司 App 在双11大促期间崩了。作为主力开发之一,我一边心里默念“稳住别慌”,一边火速切回 Xcode,结果看到控制台满屏的 EXC_BAD_ACCESS 和用户投诉截图。那一刻,我真的想砸电脑。
说来惭愧,我在这家公司干了三年多,主要负责移动端业务模块。产品迭代快、需求杂、运营天天催,代码早就成了“意大利面条”。之前为了赶上线,ViewModel 里塞满了网络请求、本地缓存、甚至还有埋点逻辑。这次崩溃,不过是压垮骆驼的最后一根稻草。
更讽刺的是,我最近其实已经在偷偷准备考公——不是对编程没爱了,而是实在受够了这种“救火队员”式的工作节奏。但临走前,总得给团队留下点像样的东西吧?于是,我主动请缨,牵头重构核心模块,采用 MVVM 架构。一来是技术债不能留给下一任,二来……也是给自己简历添点干货,万一考不上还能跳槽呢(狗头保命)。
为什么是 MVVM?不就是换个名字吗?
刚开始提 MVVM,产品经理一脸懵:“这玩意儿能让我明天上线新活动吗?”测试同事翻白眼:“又改架构?上次改完回归测了三天!”连隔壁 Java 后端都调侃:“你是不是 Rust 写多了,以为所有问题都能靠 ownership 解决?”
说实话,我一开始也怀疑:MVVM 真的比 MVP 或 MVC 强?还是只是前端圈的新潮流?
但仔细一想,我们的问题很典型:
- UI 逻辑和业务逻辑高度耦合:一个按钮点击,既要调接口、又要更新本地状态、还要上报行为数据。
- 测试几乎为零:因为所有逻辑都在 ViewController 里,Mock 数据难如登天。
- 跨平台适配痛苦:iOS 和 Android 代码结构不一致,同一个需求要写两套逻辑。
MVVM 的核心思想是 分离关注点(Separation of Concerns),把 UI 层(View)和业务逻辑层(ViewModel)解耦,通过数据绑定自动同步状态。听起来很虚?举个真实例子:
以前,用户登录成功后,我们要手动:
// ViewController 里的地狱代码
if loginResponse.success {
nameLabel.text = response.name
avatarImageView.image = loadImage(response.avatarUrl)
UserDefaults.save(token: response.token)
Analytics.track("login_success")
navigateToHome()
} else {
showErrorAlert()
}
而在 MVVM 中,ViewModel 只负责暴露 userName: String?、avatarURL: URL? 等可观察属性,View 层通过绑定自动更新 UI。ViewModel 不知道 View 的存在,自然也就更容易单元测试。
工具选型:Swift + Combine 还是 RxSwift?
作为 Mac 党,我当然首选 Apple 官方方案。iOS 13 起引入的 Combine 框架,本质上就是响应式编程(Reactive Programming)的官方实现,和 MVVM 天然契合。
虽然 RxSwift 社区更成熟,但考虑到:
- 公司最低支持 iOS 14
- 团队新人多,学习曲线要平缓
- 我自己也在用 Swift 写一些小工具练手
最终拍板:Swift + Combine + MVVM。
不过现实很快打脸——Combine 的文档少得可怜,错误信息还特别抽象。比如这个经典报错:
Publishers.Share<Publishers.ReceiveOn<Publishers.Map<...>>> has no interface
我当时盯着屏幕愣了十分钟,最后发现是因为忘了 .eraseToAnyPublisher()。这种坑,踩一次就长记性。
实战:重构用户中心模块
我们先拿“用户中心”开刀——这是 App 里最复杂的页面之一,包含头像、昵称、会员状态、订单入口、设置项等,且数据来自多个 API。
目录结构设计
我参考了 SwiftUI 的分层思想,但保留 UIKit(毕竟老项目):
/UserProfile/
├── View/
│ ├── UserProfileViewController.swift
│ └── UserProfileView.xib
├── ViewModel/
│ ├── UserProfileViewModel.swift
│ └── UserProfileState.swift
├── Model/
│ ├── User.swift
│ └── Membership.swift
└── Service/
├── UserService.swift
└── ImageService.swift
关键原则:ViewModel 不引用任何 UIKit 组件,只处理数据和状态。
ViewModel 的核心:状态驱动
我定义了一个 UserProfileState 结构体,用枚举表示加载状态:
struct UserProfileState {
enum LoadingStatus {
case idle
case loading
case success(User)
case failure(Error)
}
var userInfo: LoadingStatus = .idle
var avatarImage: UIImage?
}
ViewModel 内部使用 @Published 属性包装器,让状态变化自动通知 View:
class UserProfileViewModel: ObservableObject {
@Published private(set) var state = UserProfileState()
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
}
func loadUserProfile() {
state.userInfo = .loading
userService.fetchUser()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.state.userInfo = .failure(error)
}
},
receiveValue: { [weak self] user in
self?.state.userInfo = .success(user)
// 同时触发头像加载
self?.loadAvatar(for: user.avatarURL)
}
)
.store(in: &cancellables)
}
}
注意这里用了 [weak self] 避免循环引用,cancellables 是一个 Set<AnyCancellable>,用于自动取消订阅。
View 层:只关心“怎么展示”,不关心“数据哪来”
在 ViewController 里,我们通过 assign 或 sink 订阅状态变化:
class UserProfileViewController: UIViewController {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var loadingIndicator: UIActivityIndicatorView!
private let viewModel = UserProfileViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
viewModel.loadUserProfile()
}
private func bindViewModel() {
viewModel.$state
.map(\.userInfo)
.sink { [weak self] status in
switch status {
case .loading:
self?.loadingIndicator.startAnimating()
case .success(let user):
self?.nameLabel.text = user.name
self?.loadingIndicator.stopAnimating()
case .failure(let error):
self?.showError(error)
self?.loadingIndicator.stopAnimating()
default: break
}
}
.store(in: &cancellables)
// 头像绑定
viewModel.$state
.compactMap { $0.avatarImage }
.assign(to: \.image, on: avatarImageView)
.store(in: &cancellables)
}
}
看,ViewController 几乎没有业务逻辑!它只是一个“胶水层”,负责把 ViewModel 的状态“粘”到 UI 上。
跨平台适配与性能优化
Android 同事看了我们的架构,直呼内行。他们用 Kotlin + LiveData 也搞了一套类似的,两边共享 Model 和 Service 层的设计思路。运营同学终于不用再问“iOS 和 Android 为什么表现不一致”了。
性能方面,我们做了几件事:
- 图片懒加载 + 缓存:用
ImageService封装 Kingfisher,ViewModel 只传 URL。 - 避免主线程阻塞:所有网络和解析操作在后台队列,通过
receive(on: .main)切回主线程更新 UI。 - 内存泄漏防护:ViewModel 持有
cancellables,ViewController 销毁时自动取消订阅。
线上监控数据显示,用户中心页面的平均加载时间从 1.8s 降到 0.6s,Crash 率下降 72%。运维大哥难得夸了句:“这次上线挺稳。”
开发心得:MVVM 不是银弹,但值得投入
经过两个月的实战,我对 MVVM 有了更深的理解:
| 优势 | 挑战 |
|---|---|
| 逻辑清晰,新人上手快 | 初期样板代码多 |
| 单元测试覆盖率提升至 65% | 响应式思维需要适应 |
| UI 与逻辑解耦,便于 AB Test | 调试链路变长 |
| 支持 SwiftUI 平滑迁移 | Combine 生态不如 Rx 成熟 |
最大的收获不是技术本身,而是团队协作方式的改变。现在产品经理提需求,会先问:“这个状态怎么表示?”测试同学可以直接 mock ViewModel 返回假数据。就连运营做活动配置,也能通过调整状态枚举快速验证效果。
当然,也有翻车时刻。有一次我忘了在 receive(on:) 里指定队列,导致 JSON 解析卡住主线程,用户滑动列表直接掉帧。被 QA 抓到后,我默默在 Confluence 上加了条规范:“所有异步操作必须显式指定调度队列”。
给同行的建议:别为了模式而模式
MVVM 的本质是 解耦,不是堆砌框架。如果你的页面只有两个按钮,硬套 MVVM 反而是过度设计。
我的经验是:
- 从复杂页面开始试点,比如个人中心、订单详情
- 先定义好 State 结构,再写 ViewModel
- 善用工具:Xcode 的 Memory Graph Debugger 查内存泄漏,Instruments 测性能
- 别怕重构:技术债越拖越贵,趁还能改,赶紧动
写这篇文章的时候,我已经提交了国考报名表。如果上岸,这段 MVVM 实战经历大概率会变成“前程序员的回忆”;如果没上岸,至少简历上能写“主导核心模块架构升级,性能提升 300%”。
不管结果如何,这次重构让我明白:好的架构不是写出来的,是用时间和 bug 喂出来的。就像我最近学的 Rust —— 所有权模型看着反人类,但真能帮你避开很多坑。编程如此,人生亦如此。
共勉。

评论 0