首页 / Swift 编程语言教程 / 主线程与 @MainActor

Swift 编程语言教程

主线程与 @MainActor

本教程共 93 篇 · 第 85 篇 · 更新于 2026-08-08 · 约 7 分钟阅读

SwiftSwift 编程语言教程并发MainActor主线程主actor@MainActorUI更新

本节目标:理解主 actor 怎么保护界面相关数据、为什么 UI 更新必须串行到主线程,并掌握用 @MainActor 标记函数、类型和闭包。

在图形界面程序里,有一条铁律:所有界面相关的操作,都必须在主线程上进行。Swift 把这条铁律升华成了类型层面的保障,叫主 actor(main actor)。它本质上是一个全局的、单例的 actor,专门串行化所有跟 UI 有关的数据和代码。

1-1 为什么 UI 必须串到主线程

界面框架通常要求:更新按钮文字、刷新列表、改背景色这类操作,绝对不能多个线程同时做。否则两个线程各改一半,界面状态会乱套、甚至直接崩溃。

解决思路就是”排队”:把所有 UI 操作塞进一个串行通道,一次只处理一个,绝不同时发生。这个串行通道在 Swift 并发里就体现为主 actor。它在底层确实跑在主线程上,但你要打交道的是”主 actor”这个概念,而不是具体的线程。

Note

主 actor 与主线程关系密切但不同:主 actor 持有私有的可变状态,主线程负责把这些访问串行化。代码跑在主 actor 上,Swift 就会让它在主线程执行。日常口语里两者常混用,但你的代码应该面向”主 actor”思考。

1-2 用 @MainActor 标记函数

要让某个函数保证在主 actor 上运行,给它加 @MainActor 属性:

@MainActor
func show(_ photo: Data) {
    // 更新界面的代码
}

标了之后,show 只能在主 actor 上被调用。如果当前已经在主 actor 上(比如另一个被 @MainActor 标记的函数里),你可以像普通同步函数一样直接调它。但若从不在主 actor 上的代码调,必须写 await,因为跨到主 actor 是一个潜在挂起点:

func downloadAndShow(named name: String) async {
    let photo = await downloadPhoto(named: name)   // 后台下载
    await show(photo)                               // 切回主 actor 更新 UI
}

这是个极常见的模式:耗时的下载/计算在后台做,做完 await show(photo) 切回主 actor 更新界面。只有 show 那步在主 actor 上,长耗时工作并不占用它。

1-3 用 @MainActor 标记整个类型

你可以把 @MainActor 写到结构体、类或枚举上,让它所有方法和属性访问都跑在主 actor:

@MainActor
struct PhotoGallery {
    var photoNames: [String]
    func drawUI() {
        // 画界面的代码
    }
}

photoNames 会影响界面,所以改它必须在主 actor 上串行。PhotoGallery 整体标了 @MainActor,内部所有读写自然都受保护。

1-4 更细粒度的标记

不必总是整类型标记。只把真正碰界面的部分标上即可,其余放后台:

struct PhotoGallery {
    @MainActor var photoNames: [String]
    var hasCachedPhotos = false

    @MainActor func drawUI() { /* 界面代码 */ }
    func cachePhotos() { /* 网络代码,不必在主 actor */ }
}

drawUI() 要画界面、标 @MainActorphotoNames 存着界面要用的状态,也标 @MainActorcachePhotos() 只做网络缓存、不碰界面,就不标,能在后台跑。这样把”必须串行的”和”可以并行的”分得清清楚楚,性能更好。

1-5 标记闭包

想让一段闭包在主 actor 上运行,把 @MainActor 写在捕获列表之前、in 之前:

let photo = await downloadPhoto(named: "日出")
Task { @MainActor in
    show(photo)
}

这里 Task { @MainActor in ... } 开启的任务被限定在主 actor 上,闭包内可以直接调 show,无需再 await。这是”后台取数据、主线程更新”的另一种写法。

1-6 框架替你标好了

用 UI 框架(比如 SwiftUI)时,框架的协议和基类通常已经标了 @MainActor。你写个遵循它的类型,会隐式继承主 actor 隔离,自己常常不用再写:

@MainActor
protocol View { /* ... */ }

// 隐式 @MainActor
struct PhotoGalleryView: View { /* ... */ }

因为 View 协议标了 @MainActorPhotoGalleryView 自动也是。这跟”基类标了、子类隐式继承”一个道理。所以实际项目里你看到的 @MainActor 往往比想象的少——框架已经铺好了。

Tip

经验法则:凡是会读或写界面状态的代码(方法、属性、闭包),都要确保在主 actor 上。最简单的做法是给相关类型整体标 @MainActor;框架类型已标好的,顺着它的隔离即可。

1-7 常见错误

最容易犯的是”在后台线程更新了 UI”。典型症状:程序偶尔崩溃、界面偶发错乱,报错指向界面 API。根因是你在没标 @MainActor 的异步函数里直接改了界面状态。修法是把那段代码移进 @MainActor 函数,或用 await 切回主 actor 再改。

另一个坑是滥用 @MainActor 把长耗时逻辑也圈进去,结果界面又卡了。记住:只有”读写界面”才需要在主 actor,耗时计算该留在后台,最后一步再 await 切回来。

Warning

不要因为”标了 @MainActor 就不崩”就一股脑把所有异步代码都标主 actor。那等于把并发全串行回了主线程,等于没并发。精准标记,才既安全又流畅。

1-8 用 MainActor.run 临时切回

有时你手里是一段没标 @MainActor 的普通异步代码,但其中某一小步需要更新界面。不必把整个函数都标主 actor,可以用 MainActor.run 临时切回去:

func refresh() async {
    let data = await loadFromNetwork()      // 后台
    let model = parse(data)                 // 后台
    await MainActor.run {
        self.label.text = model.title       // 切回主 actor 更新 UI
    }
}

MainActor.run 接受一个闭包,闭包内容保证在主 actor 上运行。它适合”只有一句话要碰界面”的场景,比给整个函数标 @MainActor 更精准,避免把后台逻辑也拖进主 actor。

Tip

判断粒度的小窍门:如果函数里”只有最后几行碰界面”,用 MainActor.run;如果”大半个函数都在读写界面状态”,直接给函数标 @MainActor。两种都是对的,只是粗细不同。

1-9 主 actor 是全局单例 actor

值得单独点一句:主 actor 不是一个普通的 actor 实例,而是 MainActor 这个类型的全局单例——整个程序只有一个。普通 actor 可以有多个实例,各自独立隔离;但主 actor 天生只有一个,所以光用 @MainActor 这个属性就足以标识”主线程那片状态”,不需要你持有它的实例。

这种”用属性而非实例来标记隔离”的灵活性,源于全局 actor 的设计。你自己也能用 @globalActor 定义类似的全局单例 actor(比如一个专门串行化数据库访问的 actor),但那是进阶话题。记住主 actor 的单例本质,有助于你理解为什么 @MainActor 能到处直接用、且指向同一片串行状态。

Tip

当你看到 @MainActor 出现在函数、属性、类型、闭包各处,要知道它们全都指向同一个全局串行通道——主线程。它们彼此不会并发,这正是界面安全的前提。

1-10 小结

主 actor 是保护界面数据的全局单例 actor,对应主线程。用 @MainActor 标记函数、类型、属性或闭包,确保相关代码串行执行;从非主 actor 跨域调用它要写 await。框架类型多已隐式标记,你顺着隔离即可。牢记”耗时在后台、更新回主 actor”的分工,是写出流畅又不崩并发代码的关键。

下一章讲 AsyncSequence:当数据不是一次给完、而是一股流时,怎么用 for await 逐条消费。