Swift 6 实战:从“定时器轮询”到 AsyncSequence 的优雅实时推送

前言

在 iOS 开发中,「实时刷新」需求随处可见:

  • 天气卡片 3 秒更新一次
  • 座位状态由绿变红
  • 股价、比分、配送进度......

过去我们习惯用 Timer.scheduledTimer 写一个"死循环",或者把 Combine 的 Timer.publish 拼成管道。

Swift 6 以后,官方把 AsyncSequence 推到 C 位,让我们用"流"的思维解决轮询。

核心概念速览

概念 一句话说明 本文对应示例
AsyncSequence 一个可以 for await 遍历的异步序列,天生支持结构化并发与自动取消 AsyncStream<WeatherCondition>
AsyncStream 官方提供的 AsyncSequence 实现,适合"自己写生产端"的场景 pollingStream(api:)
Task.sleep 非阻塞的异步"睡眠",不会卡住线程 try? await Task.sleep(for: .seconds(3))
Task.isCancelled 结构化并发中的"取消标记",用 while 判断即可优雅退出 while !Task.isCancelled
MainActor.run 把闭包安全切回主线程,避免 UI 崩溃 await MainActor.run { ... }

三种实现逐行拆解

基础API

swift 复制代码
enum WeatherCondition: String, CaseIterable {
    case clear, stormy
}

struct WeatherResponse {
    let condition: WeatherCondition
}

actor MockWeatherAPI {
    func fetchWeather() async -> WeatherResponse {
        try? await Task.sleep(for: .seconds(1))
        return WeatherResponse(condition: WeatherCondition.allCases.randomElement()!)
    }
}

定时器派:Timer.scheduledTimer

swift 复制代码
// 传统写法,功能可用,但坑最多
final class TimerViewModel: ObservableObject {
    @Published var weather: WeatherCondition = .clear
    private var timer: Timer?
    
    init(api: MockWeatherAPI) {
        // 每 3 秒在主线程回调一次
        timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
            // 必须在异步上下文调用 async 方法,所以包一层 Task
            Task {
                let response = await api.fetchWeather()
                // 回到主线程改 UI
                await MainActor.run { self?.weather = response.condition }
            }
        }
    }
    
    deinit {
        timer?.invalidate()   // 忘了写就会内存泄漏
    }
}

缺点小结

  1. 忘记 invalidate() 直接泄漏
  2. 后台模式下容易"卡"计时器
  3. 单元测试必须真跑 3 秒,CI 极慢

Combine 派:Timer.publish + Future

swift 复制代码
final class CombineViewModel: ObservableObject {
    @Published var weather: WeatherCondition = .clear
    private var cancellables = Set<AnyCancellable>()
    
    init(api: MockWeatherAPI) {
        // 1. 主线程每 3 秒发一个日期
        Timer.publish(every: 3, on: .main, in: .common)
            .autoconnect()
            // 2. 把日期换成异步请求
            .flatMap { _ in
                Future { promise in
                    Task {
                        let response = await api.fetchWeather()
                        promise(.success(response.condition))
                    }
                }
            }
            .receive(on: RunLoop.main)   // 3. 回到主线程
            .assign(to: &$weather)        // 4. 直接绑到属性
    }
}

缺点小结

  • 异步/await 与 Combine 混写,心智负担高
  • Future 只能完成一次,不能"持续"发值,需要 flatMap 不断新建
  • 测试仍需跑真时间或使用 TestScheduler

AsyncSequence 派:AsyncStream 一统江湖

swift 复制代码
final class StreamViewModel: ObservableObject {
    @Published var weather: WeatherCondition = .clear
    private var task: Task<Void, Never>?
    
    init(api: MockWeatherAPI) {
        // 结构化并发:启动一个子任务
        task = Task {
            // 直接 for await 遍历自定义流
            for await update in Self.pollingStream(api: api) {
                await MainActor.run { self.weather = update }
            }
        }
    }
    
    deinit {
        task?.cancel()   // 取消即停流,无需手动 invalidate
    }
    
    // MARK: - 核心工厂方法
    static func pollingStream(api: MockWeatherAPI) -> AsyncStream<WeatherCondition> {
        AsyncStream { continuation in
            // 真正生产端跑在子任务
            Task {
                // 如果外部调用者取消 Task,这里会优雅退出
                while !Task.isCancelled {
                    let update = await api.fetchWeather()
                    continuation.yield(update.condition)          // 向下游发一个值
                    try? await Task.sleep(for: .seconds(3))       // 等 3 秒再采
                }
                continuation.finish()                             // 告知"我发完了"
            }
        }
    }
}

优点小结

✅ 取消即停:Task 取消后 while 自动结束

✅ 测试友好:用 AsyncStream.makeAsyncIterator() 可以同步拿值,无需真睡 3 秒

✅ 可组装:后续加 timeoutdebouncebuffer 都只要包一层序列

视图层:SeatAvailabilityView

swift 复制代码
struct SeatAvailabilityView: View {
    let condition: WeatherCondition
    
    var body: some View {
        Circle()
            .fill(condition == .clear ? .green : .red)
            .frame(width: 100, height: 100)
            .overlay(Text(condition.rawValue.capitalized))
    }
}

使用:

swift 复制代码
struct ContentView: View {
    @StateObject private var vm = StreamViewModel(api: MockWeatherAPI())
    
    var body: some View {
        SeatAvailabilityView(condition: vm.weather)
            .task {          // 视图消失时自动取消内部的 Task
                // 如果这里还启动额外工作,可一并取消
            }
    }
}

取消的 3 个姿势

场景 实现方式 代码片段
视图消失 .task修饰符 .task { ... }自动在 disappear 时 cancel
手动取消 deinittask?.cancel() 见 StreamViewModel
超时取消 AsyncThrowingStream+ withTimeout 见下方扩展

单元测试:Swift Testing 示例

swift 复制代码
import Testing
@testable import YourModule

struct WeatherPollingTests {
    // 验证流能正常 emit 值
    @Test
    func streamEmitsValues() async throws {
        let api = MockWeatherAPI()
        let stream = StreamViewModel.pollingStream(api: api)
        var iterator = stream.makeAsyncIterator()
        let first = try await iterator.next()
        #expect(first != nil)
    }
    
    // 验证外部取消后,循环会退出
    @Test
    func streamCancellation() async throws {
        let api = MockWeatherAPI()
        let task = Task {
            for await _ in StreamViewModel.pollingStream(api: api) { }
        }
        task.cancel()
        #expect(task.isCancelled)
    }
}

测试提速技巧

  • Task.sleep 抽象成 Clock.sleep,测试注入 ImmediateClock 即可 0 秒跑完
  • AsyncStream.makeAsyncIterator() 可以一条一条拿值,断言更细

扩展场景

带超时的轮询

swift 复制代码
extension AsyncStream {
    func withTimeout<C: Clock>(_ duration: C.Instant.Duration, clock: C) async throws -> Self {
        // 用 withThrowingTaskGroup 同时跑"生产值"和"倒计时"
        // 哪边先到就取消另一边
    }
}

假 WebSocket 一键替换

swift 复制代码
struct MockWebSocket: AsyncSequence {
    typealias Element = WeatherCondition
    
    struct AsyncIterator: AsyncIteratorProtocol {
        mutating func next() async -> WeatherCondition? {
            // 2 秒随机一个值,模拟帧
            try? await Task.sleep(for: .seconds(2))
            return WeatherCondition.allCases.randomElement()
        }
    }
    
    func makeAsyncIterator() -> AsyncIterator { AsyncIterator() }
}

for await update in MockWebSocket() 直接塞进 ViewModel,

将来换真 WebSocket 只要改一行,UI 层零改动。

总结与选型建议

  1. 新代码直接上 AsyncStream
    • 取消简单、测试快、与 Swift Concurrency 原生一致
  2. 老代码如果已用 Combine
    • 可继续用 Timer.publish,但建议包一层 AsyncPublisher 逐步迁移
  3. 纯定时器场景
    • 只要最小依赖,也可以 AsyncSequence 一把梭,别再写 Timer

学习资料

  1. medium.com/@wesleymatl...
相关推荐
Daniel_Coder7 小时前
iOS Widget 开发-9:可配置 Widget:使用 IntentConfiguration 实现参数选择
ios·swiftui·swift·widget·intents
非专业程序员Ping10 小时前
Vibe Coding 实战!花了两天时间,让 AI 写了一个富文本渲染引擎!
ios·ai·swift·claude·vibecoding
m0_4955627812 小时前
Swift的逃逸闭包
服务器·php·swift
m0_4955627813 小时前
Swift-static和class
java·服务器·swift
m0_495562782 天前
Swift-GCD和NSOperation
ios·cocoa·swift
HarderCoder2 天前
Swift 并发:我到底该不该用 Actor?——一张决策图帮你拍板
swift
HarderCoder2 天前
深入理解 DispatchQueue.sync 的死锁陷阱:原理、案例与最佳实践
swift
东坡肘子2 天前
Skip Fuse现在对独立开发者免费! -- 肘子的 Swift 周报 #0110
android·swiftui·swift
Kapaseker3 天前
Swift 构建 Android 应用?它来了
ios·swift