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

