MVVM中异步状态更新与withAnimation的正确使用问题
我正在学习Combine与SwiftUI结合MVVM及Clean Architecture的用法,但遇到了一个问题:当ViewModel包含更新Published值的异步方法时,如何正确使用withAnimation。我已找到一个解决方案,但认为其并不合规,遗漏了某些基础要点。以下是我的实现代码:
数据管理器:
protocol NameManaging { var publisher: AnyPublisher<[Name], Never> { get } func fetchNames() async } class MockNameManager: NameManaging { var publisher: AnyPublisher<[Name], Never> { names.eraseToAnyPublisher() } func fetchNames() async { var values = await heavyAsyncTask() names.value.append(contentsOf: values) } private func heavyAsyncTask() async -> [Name] { // do some heavy async task } private var names = CurrentValueSubject<[Name], Never>([]) }
ViewModel:
class NameListViewModel: ObservableObject { @Published var names = [Name]() private var anyCancellable: AnyCancellable? private var nameManager: NameManaging init(nameManager: NameManaging = MockNameManager()) { self.nameManager = nameManager self.anyCancellable = nameManager.publisher .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] values in withAnimation { self?.names = values } }) } func fetchNames() async { await nameManager.fetchNames() } }
视图:
struct NameList: View { @StateObject private var nameListViewModel = NameListViewModel() var body: some View { VStack { VStack { HStack { Button(action: updateNames) { Text("Fetch some more names") } } } .padding() List { ForEach(nameListViewModel.names) { NameRow(name: $0) } } } .navigationTitle("Names list") .onAppear(perform: updateNames) } func updateNames() { Task { await nameListViewModel.fetchNames() } } }
我目前在ViewModel的data manager publisher的.sink()方法中使用了withAnimation,虽然能正常工作,但这导致视图相关逻辑混入了ViewModel中。请问如何在视图的updateNames方法中使用withAnimation?或者是否存在更优的实现方式?
核心原则是把动画逻辑完全留在视图层,ViewModel只负责数据处理和业务逻辑,符合MVVM和Clean Architecture的职责分离要求。以下是几种可行的实现方式:
方式一:移除ViewModel中的动画,视图通过withAnimation包裹异步触发
这种方式保留你原有的Combine订阅逻辑,只需要把动画代码从ViewModel移到视图:
步骤1:修改ViewModel
去掉sink中的withAnimation,直接将数据赋值给@Published属性:
class NameListViewModel: ObservableObject { @Published var names = [Name]() private var anyCancellable: AnyCancellable? private var nameManager: NameManaging init(nameManager: NameManaging = MockNameManager()) { self.nameManager = nameManager self.anyCancellable = nameManager.publisher .receive(on: DispatchQueue.main) .assign(to: \.names, on: self) // 直接绑定,无需动画 } func fetchNames() async { await nameManager.fetchNames() } }
步骤2:修改视图的updateNames方法
用withAnimation包裹异步任务的触发,SwiftUI会自动将后续的状态更新关联到动画上下文:
func updateNames() { withAnimation { Task { await nameListViewModel.fetchNames() } } }
方式二:重构数据层,让异步方法直接返回数据(更推荐)
这种方案更贴合Clean Architecture的分层思想,数据层只负责获取数据,ViewModel处理数据整合,视图完全控制展示和动画:
步骤1:重构数据管理器
去掉Combine publisher,让fetchNames直接返回异步获取的数据:
protocol NameManaging { func fetchNames() async -> [Name] } class MockNameManager: NameManaging { func fetchNames() async -> [Name] { return await heavyAsyncTask() } private func heavyAsyncTask() async -> [Name] { // 模拟耗时异步任务 try? await Task.sleep(nanoseconds: 1_000_000_000) return (0..<5).map { Name(id: UUID(), value: "New Name \($0)") } } }
步骤2:简化ViewModel
用@MainActor确保所有@Published属性的更新都在主线程,避免线程问题:
@MainActor class NameListViewModel: ObservableObject { @Published var names = [Name]() private var nameManager: NameManaging init(nameManager: NameManaging = MockNameManager()) { self.nameManager = nameManager } func fetchNames() async { let newNames = await nameManager.fetchNames() names.append(contentsOf: newNames) } }
步骤3:视图中控制动画
在updateNames中用withAnimation包裹异步调用,让SwiftUI应用动画效果:
struct NameList: View { @StateObject private var nameListViewModel = NameListViewModel() var body: some View { VStack { Button(action: updateNames) { Text("Fetch some more names") } .padding() List { ForEach(nameListViewModel.names) { name in Text(name.value) // 假设NameRow是展示name的文本视图 } } } .navigationTitle("Names list") .onAppear(perform: updateNames) } func updateNames() { withAnimation { Task { @MainActor in await nameListViewModel.fetchNames() } } } } // 补充Name结构体定义(假设你之前有这个) struct Name: Identifiable { let id: UUID let value: String }
方式三:视图通过onReceive监听数据变化并应用动画
如果需要更精细地控制动画触发时机,可以在视图中监听ViewModel的names变化,然后应用动画:
struct NameList: View { @StateObject private var nameListViewModel = NameListViewModel() @State private var animateNames = false var body: some View { VStack { Button(action: updateNames) { Text("Fetch some more names") } .padding() List { ForEach(nameListViewModel.names) { name in Text(name.value) .animation(.default, value: animateNames) } } } .navigationTitle("Names list") .onAppear(perform: updateNames) .onReceive(nameListViewModel.$names) { _ in withAnimation { animateNames.toggle() } } } func updateNames() { Task { await nameListViewModel.fetchNames() } } }
这种方式适合需要对特定视图元素单独设置动画的场景。
内容的提问来源于stack exchange,提问作者ostojan

