はじめに
多くのUIフレームワークがそうであるように、UIKit・SwiftUIといったAppleプラットフォームのUIフレームワークでは、UIの更新処理をメインスレッドで行う必要があります。
そのため開発者は、どのスレッドでコードが動作するのかを注意深く確認しながらUIの更新処理を実装しなければなりません。しかし、それはとても難しいことです。
そのソリューションとなるのが MainActor です。この記事では MainActor が解決する課題を明確にし、 MainActor が私たちにもたらしてくれる恩恵についてやさしく理解できるようにします。
この記事は出てくるSwiftコードは Xcode 15.4 (Swift 5.9)で実行しビルドおよび動作させています。また、その結果を掲載しています。
MainActor 登場前のUI更新の課題
MainActor 登場までの課題を再確認するために、次のようなUIKitのコードを例にします。fetchTodo(id:)
関数はバックグランドスレッドでTODOデータを取得し、そのバックグランドスレッドからコールバックを呼び出して結果を返すようになっています。
このコールバック処理の中でUIの更新処理を行ってしまうと、UIの動作にも影響する可能性があり、XcodeのMain Thread Checkerによりランタイムで警告されます。
import UIKit
class TodoView: UIView {
convenience init(_ todo: Todo) { ... }
}
class TodoViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
fetchTodo(id: 1) { [weak self] result in
// ここはバックグラウンドスレッド
let todo = try! result.get()
self?.view.addSubview(TodoView(todo)) // ⚠️ UIViewController.view must be used from main thread only
}
}
}
}
Xcodeでは下図のように表示されます(だれもが1度は見たことがあるでしょう)。
この問題を解消するには、次のコードのようにUI更新処理をメインスレッドで行うようにします。
override func viewDidLoad() {
super.viewDidLoad()
fetchTodo(id: 1) { result in
// ここはバックグラウンドスレッド
DispatchQueue.main.async { [weak self] in
// ここはメインスレッド
let todo = try! result.get()
self?.view.addSubview(TodoView(todo)) // 👌
}
}
}
これを開発者が正確に行うことはとても難しいことです。
実行しようとしているメソッドのコールバックがどのスレッドで実行されるのかはコードからは把握することができず、ドキュメントやコメントなどを参照しなければなりません。そしてドキュメントに明記されていないケースも少なくありません(Appleのドキュメントでさえ)。
おそらく多くの開発者はUI更新処理をすべて DispatchQueue.main.async
で囲うようになったのではないでしょうか。
Swift Concurrencyの登場による非同期処理の変化
Swift 5.5から言語機能としてSwift Concurrencyが導入されました。これまで非同期処理の結果はコールバックにより返ってきていたものが、 async/await
により同じコードフローの中で直接結果が得られるようになりました。
// 非同期処理の関数(メソッド)は結果を返すためのコールバック関数を引数にとる
func fetchTodo(id: Int, completion: @escaping (Result<Todo, Error>) -> Void) {
...
}
fetchTodo(id: 1) { result in
// コールバックで結果を受け取る
let todo = try! result.get()
print(todo)
}
// 非同期関数(メソッド)には `async` キーワードがつく
func fetchTodo(id: Int) async throws -> Todo {
...
}
// 完了を `await` で待機する
let todo = try! await fetchTodo(id: 1)
print(todo)
ただ、非同期関数(メソッド)はどこでも呼び出せるものではありません。非同期処理を担うTaskに指定したオペレーションブロックの中か、別の非同期関数・メソッドの中でのみ呼び出すことができます。
// Taskのオペレーションブロックの中で、非同期関数を呼び出す
Task {
let todo = try! await fetchTodo(id: 1)
}
// 別の非同期関数の中で、非同期関数を呼び出す
func someAsyncFunction() async {
let todo = try! await fetchTodo(id: 1)
}
Swift ConcurrencyとUI更新処理
最初に挙げた例をSwift Concurrencyの非同期関数に置き換えると次のようになります。さて、この中のUI更新処理はどのスレッドで行われるのでしょうか。
override func viewDidLoad() {
super.viewDidLoad()
Task {
let todo = try! await fetchTodo(id: 1)
view.addSubview(TodoView(todo)) // ここはメインスレッド?
}
}
正解は「メインスレッド」です。その理由をコードとコメントで詳しく解説します。
override func viewDidLoad() {
super.viewDidLoad()
// ⭐️ここはメインスレッド
Task {
// このTaskを実行するスレッドはTask初期化の呼び出し元のコンテキストを引き継ぎます。
// つまり、このTask内の同期処理はメインスレッドで実行されます。
// 非同期関数`fetchTodo(id:)`はバックグランドスレッドで処理されます。
// その間このTask内の処理は一時停止し、メインスレッドを明け渡します。
let todo = try! await fetchTodo(id: 1)
// `fetchTodo(id:)`メソッドが完了するとこのTaskが再開します。
// このTask内の同期処理はメインスレッドで実行されるので、ここもメインスレッドです。
view.addSubview(TodoView(todo)) // 👌
}
}
}
ご理解いただけたでしょうか。しかし、ここで1つ疑問として残るのが // ⭐️ここはメインスレッド
の部分です。これはどのように保証されるのでしょうか。
UIKitのフレームワークから viewDidLoad()
メソッドを呼び出される時は必ずメインスレッドで実行されるように実装されていると思いますが、例えばこの TodoViewController
を継承したクラスから、誤ってバックグランドスレッドで super.viewDidLoad()
が呼び出されてしまった場合はどうなるのでしょうか?
安心してください。そのようなことは起こらないようにコンパイラが保証してくれます。その保証の仕組みが MainActor です。
MainActor によるメインスレッド保証
UIViewController
クラスの宣言に @MainActor
属性がついています。
@available(iOS 2.0, *)
@MainActor open class UIViewController : UIResponder, NSCoding, UIAppearanceContainer, UITraitEnvironment, UIContentContainer, UIFocusEnvironment {
open func viewDidLoad()
@MainActor
属性のついたクラスや構造体は、そのすべてのメソッド・プロパティが MainActor として扱われます。つまり、 MainActor である UIViewController
の viewDidLoad()
メソッドも MainActor です。
- クラスや構造体に
@MainActor
がついていたとしても、nonisolated
キーワードのついたメソッドやプロパティは MainActor から除外されます - 特定のメソッドやプロパティのみに
@MainActor
を付与することもできます
MainActor の関数やメソッドはメインスレッドで処理されることがコンパイラにより保証されます。
例に出てきた UIViewController.viewDidLoad()
メソッドや、 UIView.addSubview()
メソッドも MainActor なので、メインスレッドで処理されることがコンパイラレベルで保証されます。
MainActor は actor
の1つで、メインスレッドで実行されることが保証されたシングルトンの特別な actor
です。 actor
についての説明はここで割愛しますが、さきほどのコード例でTask {}
が呼び出し元から引き継ぐのは actor
です。ここでは UIViewController.viewDidLoad()
の actor
である MainActor を引き継いだので、Task内の処理はメインスレッドで実行されることになります。
// ⭐️ここはメインスレッド
Task {
// このTaskを実行するスレッドはTask初期化の呼び出し元のコンテキストを引き継ぎます。
// つまり、このTask内の同期処理はメインスレッドで実行されます。
ではメインスレッド以外でUIを更新しようとするとどなるかを試してみましょう。
先ほどの Task {}
は呼び出し元の actor
である MainActor を引き継いだのでメインスレッドで実行されましたが、 Task.detached {}
を使用すると、呼び出し元の actor
を引き継ぎません。つまり今回の例で言うと、MainActor は引き継がれず何らかのバックグラウンドスレッドで実行されるようになりますので、これを使って試してみます。
override func viewDidLoad() {
super.viewDidLoad()
// ここはメインスレッド(viewDidLoad()は MainActor であるため)
Task.detached {
// このTask内の同期処理はメインスレッドではない
// 何らかのバックグラウンドスレッドで実行されます
let todo = try! await fetchTodo(id: 1)
self.view.addSubview(TodoView(todo)) // ❌ Expression is 'async' but is not marked with 'await'
}
}
Expression is 'async' but is not marked with 'await'
というコンパイルエラーとなりました。 UIView.addSubview()
はメインスレッドで非同期実行( async
)する必要があるため、このTask内の処理を一時停止して、メインスレッドでの処理が終わるまで待機( await
)しなさい、ということです。
つまり UIView.addSubview()
を await
することでエラーが解消します。
override func viewDidLoad() {
super.viewDidLoad()
Task.detached {
let todo = try! await fetchTodo(id: 1)
// バックグラウンドスレッドで実行されているこのTaskを一時停止し、
// メインスレッドで非同期実行されるUI更新が終わるのを待機する
await view.addSubview(TodoView(todo)) // 👌
}
}
async
キーワードがついていない関数(メソッド)なのに、なぜ await
をつけないとビルドが通らないんだ!?といったケースはこのパターンですね。
まとめ
ここまでをまとめると次のとおりです。
- 関数(メソッド)を
@MainActor
属性を付与して定義することで、メインスレッドで実行される関数(メソッド)であることをコンパイラに伝えることができます -
@MainActor
属性が付与された関数(メソッド)を、MainActor ではない場所で呼び出すコードは、コンパイラによりチェックされてエラーになります
MainActor により、UI更新処理がメインスレッドで行われることをコンパイラが保証してくれるようになりました🎉
これはUIKitやSwiftUIに属するUIに関するクラスやメソッドの多くに @MainActor
が付与されているため成り立っています。独自に用意するUIに関するクラスやメソッドも、UI更新を伴うようなものは @MainActor
属性をつけておきましょう。
最初のコード例にあるような DispatchQueue
を直接使用した非同期処理の場合、この MainActor による恩恵を受けることができないため、注意が必要です。
付録(どこまで MainActor になるのか?)
@MainActor
を付与したクラス(class
)・構造体(struct
)・列挙型(enum
)の以下すべてが MainActor となる。クラスの場合、そのサブクラスも同様。 @MainActor
を付与したプロトコル(protocol
)に準拠したクラス・構造体・列挙型も同様。
- initializer
- computed property
- method
- static method
- class method(クラスのみ)
SwiftUI.View
プロトコルは Xcode のバージョンによって MainActor の範囲が異なるようなので要注意です。
- Xcode 16以上 :
View
自体が MainActor - Xcode 15以下 :
View.body
のみが MainActor