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...
相关推荐
东坡肘子2 天前
Swift、SwiftUI 与 SwiftData:走向成熟的 2025 -- 肘子的 Swift 周报 #116
人工智能·swiftui·swift
大熊猫侯佩2 天前
Swift 6.2 列传(第十三篇):香香公主的“倾城之恋”与优先级飞升
swift·编程语言·apple
1024小神2 天前
Swift配置WKwebview加载网站或静态资源后,开启调试在电脑上debug
swift
kkoral4 天前
基于MS-Swift 为 Qwen3-0.6B-Base 模型搭建可直接调用的 API 服务
python·conda·fastapi·swift
Yorelee.4 天前
ms-swift在训练时遇到的部分问题及解决方案
开发语言·nlp·transformer·swift
崽崽长肉肉5 天前
swift中的知识总结(一)
ios·swift
Yakamoz5 天前
Swift Array的写时复制
swift
汉秋5 天前
SwiftUI 中的 compositingGroup():真正含义与渲染原理
swiftui·swift
汉秋5 天前
SwiftUI 中的 @ViewBuilder 全面解析
swiftui·swift
胖虎16 天前
SwiftUI 页面作为一级页面数据被重置问题分析
ios·swiftui·swift·state·observedobject·stateobject·swiftui页面生命周期