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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 18:20:26