12-21.【SwiftUI】 SwiftUI 的 ViewModel 如何避免反向依赖 UI?

4 阅读3分钟

在 SwiftUI 开发中,ViewModel 反向依赖 UI(即 ViewModel 逻辑中包含了对特定视图实现、层级结构或 UI 组件的假设)会导致代码难以测试、难以复现 Bug。

要实现“ViewModel 不认识 View”的纯净架构,核心在于将命令式的“操控 UI”转变为声明式的“状态描述”


1. 核心陷阱:什么是“反向依赖”?

常见的反向依赖通常通过以下方式悄悄潜入代码:

  • 直接持有视图引用:在 ViewModel 中存储 UIViewNSView
  • 依赖特定的 UI 生命周期:逻辑必须依赖 onAppear 或特定动画的结束回调才能运行。
  • 传递 UI 类型作为参数:ViewModel 的方法接收 ColorFontImage 等 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: CGFloatisCompact: 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 中不出现任何 ColorImageTransition 或布局计算,你就成功切断了反向依赖。