LoginSignup
14
4

やさしい @MainActor

Last updated at Posted at 2024-06-26

はじめに

多くの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によりランタイムで警告されます。

TodoViewController.swift
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度は見たことがあるでしょう)。

Main Thread Checkerによる警告

この問題を解消するには、次のコードのようにUI更新処理をメインスレッドで行うようにします。

TodoViewController.swift
  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 により同じコードフローの中で直接結果が得られるようになりました。

Swift Concurrency以前
// 非同期処理の関数(メソッド)は結果を返すためのコールバック関数を引数にとる
func fetchTodo(id: Int, completion: @escaping (Result<Todo, Error>) -> Void) {
  ...
}

fetchTodo(id: 1) { result in
  // コールバックで結果を受け取る
  let todo = try! result.get()
  print(todo)
}
Swift Concurrency以後
// 非同期関数(メソッド)には `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更新処理はどのスレッドで行われるのでしょうか。

TodoViewController.swift
  override func viewDidLoad() {
    super.viewDidLoad()
    
    Task {
      let todo = try! await fetchTodo(id: 1)
      view.addSubview(TodoView(todo)) // ここはメインスレッド?
    }
  }

正解は「メインスレッド」です。その理由をコードとコメントで詳しく解説します。

TodoViewController.swift
  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 属性がついています。

UIKit.UIViewController
@available(iOS 2.0, *)
@MainActor open class UIViewController : UIResponder, NSCoding, UIAppearanceContainer, UITraitEnvironment, UIContentContainer, UIFocusEnvironment {
    open func viewDidLoad()

@MainActor 属性のついたクラスや構造体は、そのすべてのメソッド・プロパティが MainActor として扱われます。つまり、 MainActor である UIViewControllerviewDidLoad() メソッドも MainActor です。

  • クラスや構造体に @MainActor がついていたとしても、nonisolated キーワードのついたメソッドやプロパティは MainActor から除外されます
  • 特定のメソッドやプロパティのみに @MainActor を付与することもできます

MainActor の関数やメソッドはメインスレッドで処理されることがコンパイラにより保証されます。

例に出てきた UIViewController.viewDidLoad() メソッドや、 UIView.addSubview() メソッドも MainActor なので、メインスレッドで処理されることがコンパイラレベルで保証されます。

MainActoractor の1つで、メインスレッドで実行されることが保証されたシングルトンの特別な actor です。 actor についての説明はここで割愛しますが、さきほどのコード例でTask {} が呼び出し元から引き継ぐのは actor です。ここでは UIViewController.viewDidLoad()actor である MainActor を引き継いだので、Task内の処理はメインスレッドで実行されることになります。

    // ⭐️ここはメインスレッド
    Task {
      // このTaskを実行するスレッドはTask初期化の呼び出し元のコンテキストを引き継ぎます。
      // つまり、このTask内の同期処理はメインスレッドで実行されます。

ではメインスレッド以外でUIを更新しようとするとどなるかを試してみましょう。

先ほどの Task {} は呼び出し元の actor である MainActor を引き継いだのでメインスレッドで実行されましたが、 Task.detached {} を使用すると、呼び出し元の actor を引き継ぎません。つまり今回の例で言うと、MainActor は引き継がれず何らかのバックグラウンドスレッドで実行されるようになりますので、これを使って試してみます。

TodoViewController.swift
  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 することでエラーが解消します。

TodoViewController.swift
  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

14
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
4