你这个感觉非常对:
View 里出现 await presenter.refresh(),哪怕只是一行,也确实像在“指挥业务”。
但这里要分清两层:
View 触发一个用户意图(Intent):这是 UI 职责(不算业务)
View 组织业务流程(Workflow):这才是业务
presenter.refresh() 如果只是“响应点击的意图”,其实还在 UI 职责边界内;
但你想更纯粹:View 连“refresh”这个业务词都不出现,只说“用户点了按钮”,这就进入 MVI/Intent 风格 了。
我给你三种越来越“干净”的做法,你可以按你想要的纯度选。
方案 A(你现在的):View 调用 Presenter 的用例方法
.anyAsyncButton { await presenter.refresh() }✅ 简单、清晰
✅ SwiftUI 常见
⚠️ View 仍然知道 “refresh” 这个业务名
方案 B(更纯):View 只发送“意图”,Presenter 自己决定做什么
把 refresh() 换成 send(_:):
enum SettingsIntent {
case refreshTapped
case signOutTapped
case deleteAccountTapped
}View:
.anyButton {
presenter.send(.signOutTapped)
}Presenter:
@MainActor
func send(_ intent: SettingsIntent) {
switch intent {
case .signOutTapped:
task = Task { await signOut() }
case .deleteAccountTapped:
task = Task { await deleteAccount() }
case .refreshTapped:
task = Task { await refresh() }
}
}✅ View 完全不碰 await
✅ View 只描述“用户做了什么”
✅ 业务流程集中在 Presenter
⚠️ Presenter 内部会用 Task(但这是“业务编排层”,合理)
⚠️ 需要管理 task 去重/取消(我下面给你更优雅的)
你担心 “func 内部 Task 不推荐”——这里属于 UI 意图入口层,它本来就是 async 的边界之一,允许用 Task。
方案 C(最纯 + 最稳):View 只改状态,Presenter 通过状态变化触发异步(Optional 驱动)
这是你之前提到的“Presenter 一定要 Optional 驱动”的落地版。
View 不调用任何方法,甚至不发 intent,只改一个状态:
Button("Sign Out") {
presenter.intent = .signOut
}Presenter:
enum Intent: Equatable { case signOut, deleteAccount, refresh }
@Observable
final class SettingsPresenter {
var intent: Intent? = nil
var alert: AnyAppAlert? = nil
func bind() {
// 伪代码:监听 intent 变化触发任务
}
}SwiftUI 里更常见做法是:View 调用 presenter.handle(.signOut)(方案 B),而不是做复杂监听;但如果你在做更“声明式”的架构,这条路可以走到极致。
✅ View 极度纯
⚠️ 实现复杂,收益要看项目规模
你现在最适合哪一种?(按 AIChats 规模)
我建议你用 方案 B:Intent + Presenter 内部 Task 管理。
原因:
你已经有 AnyAppAlert、AppState、FirebaseAuthService
你希望 View 不写业务
你同时又希望并发结构清晰、可取消、可去重
方案 B 的“最佳实践版本”:Presenter 自己管理任务(防连点 + 可取消)
@MainActor
final class SettingsPresenter: ObservableObject {
@Published var alert: AnyAppAlert?
@Published var showCreateAccountView = false
private var runningTask: Task<Void, Never>?
func send(_ intent: Intent) {
runningTask?.cancel()
runningTask = Task { [weak self] in
guard let self else { return }
await self.handle(intent)
}
}
enum Intent {
case signOutTapped
case deleteAccountTapped
case createAccountTapped
}
private func handle(_ intent: Intent) async {
switch intent {
case .createAccountTapped:
showCreateAccountView = true
case .signOutTapped:
// do/try/catch -> update appState/alert
case .deleteAccountTapped:
// do/try/catch -> update appState/alert
}
}
}View 变成:
Text("Sign Out")
.anyButton { presenter.send(.signOutTapped) }
Text("Delete Account")
.anyButton { presenter.send(.deleteAccountTapped) }✅ View 完全不 await
✅ View 不提业务函数名
✅ 业务/并发/错误集中在 Presenter
✅ 防重复点击、支持取消
最关键的一点:你担心“在 func 里用 Task”会破坏 structured concurrency
这个担心是对的,但要注意:
structured concurrency 的边界一定存在
(比如按钮点击、通知回调、delegate 回调、timer)
这些地方天生是同步入口,你必须用 Task 桥接。
所以“Task 只能在边界用一次”——
边界不一定是 View,也可以是 Presenter 的 intent 入口。
只要你把 Task 统一放在 Presenter 的 send(intent:) 入口里,任务管理集中,反而更干净、更可控。
如果你愿意,我可以直接帮你把 SettingsView 按方案 B 重构成:
View 0 业务词
Presenter 统一处理 async
错误统一写到 AppState.alert
并且自带防抖/去重/取消
你贴一下 AppState 结构(尤其 updateAppState 和 alert 存在哪里),我就能给你一份可以直接落地的版本。