在 SwiftUI 开发中,ViewModel 反向依赖 UI(即 ViewModel 逻辑中包含了对特定视图实现、层级结构或 UI 组件的假设)会导致代码难以测试、难以复现 Bug。
要实现“ViewModel 不认识 View”的纯净架构,核心在于将命令式的“操控 UI”转变为声明式的“状态描述” 。
1. 核心陷阱:什么是“反向依赖”?
常见的反向依赖通常通过以下方式悄悄潜入代码:
- 直接持有视图引用:在 ViewModel 中存储
UIView或NSView。 - 依赖特定的 UI 生命周期:逻辑必须依赖
onAppear或特定动画的结束回调才能运行。 - 传递 UI 类型作为参数:ViewModel 的方法接收
Color、Font或Image等 UI 定义类型。 - 逻辑耦合布局:ViewModel 中存在逻辑判断,如
if screenWidth > 375 { ... }。
2. 策略一:使用逻辑类型替代 UI 类型
ViewModel 应该只输出数据(Data),而 View 负责将数据映射为视觉。
- 错误:ViewModel 返回
Color.red。 - 正确:ViewModel 返回状态枚举
.error,View 根据枚举决定显示什么颜色。
Swift
// ✅ 好的设计:ViewModel 只输出逻辑含义
enum AccountStatus {
case active, suspended, pending
}
class ProfileViewModel: ObservableObject {
@Published var status: AccountStatus = .active
}
// View 负责“翻译”逻辑
struct ProfileView: View {
@ObservedObject var viewModel: ProfileViewModel
var themeColor: Color {
switch viewModel.status {
case .active: return .green
case .suspended: return .red
case .pending: return .orange
}
}
}
3. 策略二:利用“意图(Action)”解耦交互
不要让 ViewModel 猜用户点击了哪个按钮。通过声明式的 Action 让 View 告诉 ViewModel 发生了什么。
- 反模式:
viewModel.showLoginSheet()(ViewModel 控制 UI 的跳转)。 - 解耦方案:View 设置
isLoginPresented = true,ViewModel 仅处理登录后的逻辑更新。
4. 策略三:处理副作用的“接口化”
如果 ViewModel 需要执行导航、弹窗或震动反馈,不要直接调用 UI API。
-
防御式对策:定义协议(Delegate 或 Coordinator) 。
ViewModel 持有一个满足协议的对象,它只负责调用
router.navigate(to: .details),而不关心 Details 是如何推入栈的(是 Sheet 还是 NavigationStack)。
5. 策略四:逻辑与几何(Geometry)分离
如果业务逻辑依赖于屏幕尺寸或滚动位置,不要把 GeometryProxy 传给 ViewModel。
- 解决办法:在 View 层计算出逻辑所需的纯数值(如
scrollOffset: CGFloat或isCompact: Bool),然后通过 ViewModel 的方法传进去。这样 ViewModel 就可以在不启动模拟器的情况下进行单元测试。
6. 防御式检查清单:你的 ViewModel 越界了吗?
| 检查项 | 越界信号 | 修复方案 |
|---|---|---|
| Import 语句 | 出现了 import SwiftUI | 检查是否有 UI 类型,尽量只 import Foundation。 |
| 弹窗控制 | 方法名为 presentAlert() | 改为修改状态 errorMessage = "...",由 View 绑定 .alert。 |
| 异步反馈 | 使用 DispatchQueue.main.async 强行刷 UI | 使用 @MainActor 标注 ViewModel,让调度透明化。 |
| 测试便利性 | 无法在 XCTest 中初始化 ViewModel | 检查是否依赖了单例(如 UIApplication.shared),改为依赖注入。 |
总结
ViewModel 的本质是一个**“状态机”**。它接收事件(Input),转换状态(Output)。只要你保证 ViewModel 中不出现任何 Color、Image、Transition 或布局计算,你就成功切断了反向依赖。