You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS26 SwiftUI:ViewModel协议+泛型视图做跨平台预览注入是否最佳实践?

背景

我正在开发面向iOS 26 / macOS 26(iPhone、iPad、Mac)的跨平台SwiftUI应用,采用MVVM架构搭配Repository层,全程使用现代@Observable宏。应用具备列表→详情导航功能,并支持单次异步数据获取。
我的目标:在三个平台实现丰富的Xcode预览,无需启动真实依赖(网络、数据库),同时保证生产环境ViewModel性能高效(无存在性装箱开销)。

采用的方案

1. ViewModel协议

protocol RecipeListViewModelProtocol: AnyObject, Observable {
    var recipes: [Recipe] { get }
    var isLoading: Bool { get }
    func fetch() async
    @discardableResult func delete(id: UUID) async -> Bool
}

2. 生产环境ViewModel

@Observable
final class RecipeListViewModel: RecipeListViewModelProtocol {
    private(set) var recipes: [Recipe] = []
    private(set) var isLoading = false

    private let repository: RecipeRepositoryProtocol

    init(repository: RecipeRepositoryProtocol = RecipeRepository()) {
        self.repository = repository
    }

    func fetch() async {
        isLoading = true
        defer { isLoading = false }
        recipes = (try? await repository.fetchAll()) ?? []
    }

    @discardableResult func delete(id: UUID) async -> Bool {
        guard (try? await repository.delete(id: id)) != nil else { return false }
        await fetch()
        return true
    }
}

3. 预览用Mock ViewModel

@Observable
final class RecipeListViewModelMock: RecipeListViewModelProtocol {
    var recipes: [Recipe]
    var isLoading: Bool

    init(recipes: [Recipe] = Recipe.samples, isLoading: Bool = false) {
        self.recipes = recipes
        self.isLoading = isLoading
    }

    func fetch() async {}
    @discardableResult func delete(id: UUID) async -> Bool { true }
}

4. 泛型视图(静态调度,无any)

struct RecipeList<VM: RecipeListViewModelProtocol>: View {
    @State var vm: VM

    var body: some View {
        List(vm.recipes) { recipe in
            Text(recipe.title)
        }
        .overlay { if vm.isLoading { ProgressView() } }
        .task { await vm.fetch() }
    }
}

5. 跨平台PreviewProvider

struct RecipeList_Previews: PreviewProvider {
    static var vm = RecipeListViewModelMock()

    static var previews: some View {
        Group {
            RecipeList(vm: vm)
                .previewDevice(PreviewDevice(rawValue: "iPhone 17 Pro"))
                .previewDisplayName("iPhone")

            RecipeList(vm: vm)
                .previewDevice(PreviewDevice(rawValue: "iPad Pro 11-inch (M5)"))
                .previewDisplayName("iPad")

            RecipeList(vm: vm)
                .previewDevice(PreviewDevice(rawValue: "My Mac"))
                .previewDisplayName("Mac")
        }
    }
}

技术问询

1. 在SwiftUI 6 / iOS 26中,采用protocol: AnyObject, Observable+泛型视图(struct RecipeList<VM: RecipeListViewModelProtocol>)是否为推荐方案?

是,这是当前SwiftUI结合@Observable宏实现类型安全、无装箱开销的最优方案之一:

  • 泛型视图保证静态调度,完全避免any带来的存在性容器开销,符合生产环境性能要求。
  • AnyObject约束适配@Observable仅能用于类的特性,协议定义统一接口,让视图与具体ViewModel实现解耦,方便替换Mock。
  • 该方案完美匹配SwiftUI 6的响应式机制,视图能正确监听@Observable类型的属性变化。

2. 为Xcode Preview编写Mock时,应放在ViewModel层还是Repository层?替代方案是向真实ViewModel注入Mock Repository,该方案可验证真实VM的fetch/delete逻辑,但要求VM可在预览中构造,此方案是否更值得推荐?

两种场景各有用途,优先推荐向真实ViewModel注入Mock Repository的方案:

  • 优势:能复用真实ViewModel的业务逻辑,预览时可验证VM的状态流转(比如加载状态切换、数据更新逻辑),避免Mock ViewModel与真实VM逻辑不一致的问题。
  • 适用场景:需要验证VM业务逻辑正确性的预览,或者VM逻辑复杂时。
  • Mock ViewModel的适用场景:仅需快速搭建静态预览(比如只展示固定数据、无需验证逻辑),或者VM依赖链极复杂难以注入Mock时。
  • 只要真实VM的初始化支持依赖注入(像你当前代码中init(repository:)的设计),就能轻松在预览中构造VM并传入Mock Repository,完全满足预览需求。

3. 泛型如何向子视图传递?RecipeList是基于VM的泛型视图,但子视图如RecipeRow仅需Recipe数据,无需依赖VM,直接传递模型是否足够,还是需要传递泛型约束?

直接传递模型足够,不需要给子视图添加泛型约束:

  • 子视图RecipeRow只关心Recipe数据,与ViewModel完全解耦,这样的设计更简洁、复用性更强——它可以在任何需要展示Recipe的地方使用,不受ViewModel类型限制。
  • 当前代码中的写法(RecipeRow(recipe: recipe))是最优实践,符合单一职责原则:视图层只负责展示数据,子视图专注于单个模型的渲染,无需感知上层的ViewModel逻辑。

4. #Preview似乎不支持同时渲染多设备,在iOS 26中,使用PreviewProvider+.previewDevice()是否仍是实现iPhone/iPad/Mac并排预览的正确方式?

是的,PreviewProvider搭配.previewDevice()仍是iOS 26中实现多设备并排预览的标准方式:

  • 当前#Preview宏主要针对单预览场景,虽然支持在一个文件中定义多个#Preview块,但无法将它们组合成一组并排展示。
  • 使用PreviewProvider的Group容器可以将多个设备的预览组合在一起,在Xcode预览面板中同时查看不同平台的效果,完全满足跨平台预览需求。

5. 上述采用的方案存在哪些架构层面的问题?

当前方案存在几个潜在的架构问题:

  1. ViewModel协议职责边界模糊:协议中包含fetch()、delete(id:)这类业务逻辑方法,导致协议与具体业务绑定过紧,后续业务变更时协议需要频繁修改,违反开闭原则。建议将数据操作逻辑收敛到Repository层,ViewModel协议只暴露状态属性和必要的用户交互入口。
  2. 泛型视图扩展性局限:如果后续需要添加更多ViewModel实现(比如针对不同权限的VM),泛型视图会导致视图层的类型膨胀,而且在导航跳转时(比如从列表到详情),泛型类型的传递会变得繁琐。可以考虑在非预览场景使用具体VM,预览时通过环境变量或依赖注入替换Mock,平衡类型安全和扩展性。
  3. Mock ViewModel维护成本高:如果真实ViewModel的业务逻辑发生变化,需要同步更新Mock ViewModel的实现,否则预览与生产逻辑会出现不一致。优先使用注入Mock Repository的方式可以避免这个问题。
  4. 预览状态覆盖不全:当前预览只展示了默认状态(加载完成有数据),没有覆盖加载中、空数据、错误等状态,建议扩展Mock(比如添加RecipeListViewModelMock(isLoading: true)或空数据的初始化器),让预览覆盖更多场景。

内容的提问来源于stack exchange,提问作者Azimjon Ilkhomov

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.02 03:52:28