SwiftUI中多子视图与子ViewModel的单一数据源实现方案
我正在开发一个SwiftUI项目,其中包含多个各自关联ViewModel的子视图,希望实现「单一数据源(Single Source of Truth)」的数据管理方案,目标是避免手动同步子视图与父视图间的数据,确保一处修改能自动同步到所有地方。
我曾考虑使用EnvironmentObject来实现,但担心它会导致视图间的紧耦合,以及给测试和UI预览带来困难。
请问在这类多子视图、多子ViewModel的场景下,实现单一数据源有什么推荐方案或最佳实践?我希望保持代码库的整洁可维护性,同时确保数据变更能在应用中无缝传播。
我欢迎任何相关见解、代码示例或设计模式,帮助我维持高效整洁的代码库并实现数据的无缝同步。
感谢您的指导!
示例模拟项目代码
FormView
@MainActor class FormViewModel: ObservableObject { var firstStepData = FirstStepDataModel() var secondStepData = SecondStepDataModel() } struct FormView: View { @StateObject var vm = FormViewModel() var body: some View { VStack { // switch between steps and load data back from model. FirstStepView(vm: .init(data: vm.firstStepData)) { data in vm.firstStepData = data } } } }
FirstStepView
struct FirstStepDataModel: Codable { var personalData = PersonalInformationsDataModel() var contactData = ContactInformationsDataModel() } @MainActor class FirstStepViewModel: ObservableObject { @Published var data: FirstStepDataModel let personalVM: PersonalInformationsViewModel let contactVM: ContactInformationsViewModel private var subscriptions: Set<AnyCancellable> = [] init(data: FirstStepDataModel) { self._data = Published(initialValue: data) self.personalVM = PersonalInformationsViewModel(data: data.personalData) self.contactVM = ContactInformationsViewModel(data: data.contactData) syncSubVMs() } func syncSubVMs() { personalVM.$data .receive(on: DispatchQueue.main) .sink { [weak self] data in self?.data.personalData = data } .store(in: &subscriptions) contactVM.$data .receive(on: DispatchQueue.main) .sink { [weak self] data in self?.data.contactData = data } .store(in: &subscriptions) } } struct FirstStepView: View { @ObservedObject var vm: FirstStepViewModel let nextStep: (FirstStepDataModel) -> Void var body: some View { VStack { PersonalInformationsView(vm: vm.personalVM) ContactInformationsView(vm: vm.contactVM) Button("Next Step") { // go next step // send data model to service // put step data to FormViewModel's step data. nextStep(vm.data) } } } }
通用视图(可复用)
struct PersonalInformationsDataModel: Codable { var firstName: String = "" var lastName: String = "" } @MainActor final class PersonalInformationsViewModel: ObservableObject { @Published var data: PersonalInformationsDataModel init(data: PersonalInformationsDataModel) { self._data = Published(initialValue: data) } // Validations... // Network requests... } struct PersonalInformationsView: View { @ObservedObject var vm: PersonalInformationsViewModel var body: some View { VStack { TextField("First Name", text: $vm.data.firstName) TextField("Last Name", text: $vm.data.lastName) } } } struct ContactInformationsDataModel: Codable { var phoneNumber: String = "" var faxNumber: String = "" } @MainActor final class ContactInformationsViewModel: ObservableObject { @Published var data: ContactInformationsDataModel init(data: ContactInformationsDataModel) { self._data = Published(initialValue: data) } // Validations... // Network requests... } struct ContactInformationsView: View { @ObservedObject var vm: ContactInformationsViewModel var body: some View { VStack { TextField("Phone Number", text: $vm.data.phoneNumber) TextField("Fax Number", text: $vm.data.faxNumber) } } }
我尝试过从父视图通过绑定传递所有数据,但遇到了UI更新问题,同时将ViewModel中的变量绑定回视图也颇具挑战。
推荐方案与最佳实践
方案1:使用引用类型数据模型(Class替代Struct)
当前代码中数据模型都是值类型struct,传递时会产生副本,导致各ViewModel持有独立数据,需要手动同步。将数据模型改为引用类型class,所有ViewModel共享同一实例,自然实现单一数据源:
修改数据模型为Class
class PersonalInformationsDataModel: Codable, ObservableObject { @Published var firstName: String = "" @Published var lastName: String = "" // 实现Codable协议 enum CodingKeys: String, CodingKey { case firstName, lastName } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(firstName, forKey: .firstName) try container.encode(lastName, forKey: .lastName) } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) firstName = try container.decode(String.self, forKey: .firstName) lastName = try container.decode(String.self, forKey: .lastName) } // 默认初始化器 init() {} } // 同理修改其他数据模型 class FirstStepDataModel: Codable, ObservableObject { @Published var personalData = PersonalInformationsDataModel() @Published var contactData = ContactInformationsDataModel() // 实现Codable协议... init() {} }
简化ViewModel同步逻辑
因为数据是引用类型,子ViewModel直接持有父模型的引用,无需订阅同步:
@MainActor class FirstStepViewModel: ObservableObject { let data: FirstStepDataModel let personalVM: PersonalInformationsViewModel let contactVM: ContactInformationsViewModel init(data: FirstStepDataModel) { self.data = data // 直接传入引用而非副本 self.personalVM = PersonalInformationsViewModel(data: data.personalData) self.contactVM = ContactInformationsViewModel(data: data.contactData) // 无需syncSubVMs方法,数据自动同步 } } @MainActor final class PersonalInformationsViewModel: ObservableObject { let data: PersonalInformationsDataModel init(data: PersonalInformationsDataModel) { self.data = data } // 验证、网络请求逻辑保持不变 }
视图层修改
视图直接绑定到数据模型的@Published属性,UI自动更新:
struct PersonalInformationsView: View { @ObservedObject var data: PersonalInformationsDataModel var body: some View { VStack { TextField("First Name", text: $data.firstName) TextField("Last Name", text: $data.lastName) } } } // 修改FirstStepView调用逻辑 struct FirstStepView: View { let data: FirstStepDataModel let nextStep: () -> Void var body: some View { VStack { PersonalInformationsView(data: data.personalData) ContactInformationsView(data: data.contactData) Button("Next Step") { nextStep() } } } } // FormView中直接传递引用 struct FormView: View { @StateObject var vm = FormViewModel() var body: some View { VStack { FirstStepView(data: vm.firstStepData) { // 无需手动赋值,数据已自动同步到vm.firstStepData } } } }
方案2:使用Combine实现双向绑定(保留值类型)
如果坚持使用值类型数据模型,可通过Combine将子ViewModel的@Published数据与父模型双向绑定,避免手动订阅的繁琐:
双向绑定扩展工具
extension Publisher where Output: Equatable, Failure == Never { func bind<T>(to subject: Published<T>.Publisher, on object: ObservableObject) -> AnyCancellable where T == Output { self.combineLatest(subject) .filter { $0 != $1 } .map(\.0) .assign(to: subject.projectedValue, on: object) } }
修改FirstStepViewModel同步逻辑
@MainActor class FirstStepViewModel: ObservableObject { @Published var data: FirstStepDataModel let personalVM: PersonalInformationsViewModel let contactVM: ContactInformationsViewModel private var subscriptions: Set<AnyCancellable> = [] init(data: FirstStepDataModel) { self._data = Published(initialValue: data) self.personalVM = PersonalInformationsViewModel(data: data.personalData) self.contactVM = ContactInformationsViewModel(data: data.contactData) // 双向绑定:子VM数据变更同步到父模型,父模型变更同步到子VM personalVM.$data.bind(to: $data.personalData, on: self) contactVM.$data.bind(to: $data.contactData, on: self) $data.personalData .filter { $0 != self.personalVM.data } .assign(to: &self.personalVM.$data) $data.contactData .filter { $0 != self.contactVM.data } .assign(to: &self.contactVM.$data) } }
方案3:状态容器(State Container)模式
创建全局或局部的状态容器,统一管理所有表单数据,各ViewModel通过依赖注入获取容器实例,实现单一数据源:
定义状态容器
@MainActor class FormStateContainer: ObservableObject { @Published var firstStep = FirstStepDataModel() @Published var secondStep = SecondStepDataModel() // 全局表单操作逻辑 func submitForm() async throws { // 提交所有步骤数据到服务器 } }
依赖注入到ViewModel
@MainActor class FirstStepViewModel: ObservableObject { private let state: FormStateContainer var firstStepData: FirstStepDataModel { get { state.firstStep } set { state.firstStep = newValue } } let personalVM: PersonalInformationsViewModel let contactVM: ContactInformationsViewModel private var subscriptions: Set<AnyCancellable> = [] init(state: FormStateContainer) { self.state = state self.personalVM = PersonalInformationsViewModel(data: state.firstStep.personalData) self.contactVM = ContactInformationsViewModel(data: state.firstStep.contactData) // 同步子VM数据到状态容器 personalVM.$data .sink { [weak self] in self?.state.firstStep.personalData = $0 } .store(in: &subscriptions) contactVM.$data .sink { [weak self] in self?.state.firstStep.contactData = $0 } .store(in: &subscriptions) } }
视图中注入状态容器
struct FormView: View { @StateObject private var stateContainer = FormStateContainer() var body: some View { VStack { FirstStepView(vm: FirstStepViewModel(state: stateContainer)) // 其他步骤视图同样注入stateContainer } } }
方案对比与选型建议
- 引用类型模型:实现最简单,适合中小规模表单,无需额外同步逻辑,但需要处理Codable协议,且模型变更会直接影响所有引用处。
- Combine双向绑定:保留值类型的语义优势,适合需要严格值不可变性的场景,但需要编写绑定逻辑,代码量略多。
- 状态容器模式:适合大型复杂表单,统一管理所有状态,便于测试和扩展,通过手动注入避免
EnvironmentObject的紧耦合问题。
内容的提问来源于stack exchange,提问作者eghnacr

