LoginSignup
16
8

More than 1 year has passed since last update.

SwiftUI.Button の action は @MainActor なのか - @Sendable でないクロージャが actor コンテキストを受け継ぐことについて

Posted at

概要

SwiftUI で開発する際、ViewModel を @MainActor にしてそのメソッドを Buttonaction パラメータに渡すということがよくあると思います。このとき、 action に渡すクロージャの中で @MainActor である ViewModel のメソッドが await せず同期的に呼べるので action 引数は @MainActor なのかと思っていたのですが、ある日同じメソッドを引数に直指定すると @MainActor@MainActor でない型に渡すなという旨の warning が出てしまいました。

image.png

一見、もし action@MainActor なら // 2 のメソッドを直指定するケースで warning が出ないはずだし、 @MainActor でなければ // 1 のクロージャの中でメソッドを呼ぶケースで actor の制約のため Button(action: { await viewModel.onTapped() }) のように await で呼ばなければいけないはずなので説明がつかないように思えます。

同じことは Buttonaction だけではなく、例えば onTapGestureperform パラメータなど SwiftUI のクロージャを渡す API 全般で発生します。

image.png

この理由が気になって考えてみたのでこの記事でまとめます。やや自信のない箇所もあるので、もし間違いを見つけた方はコメント等で教えてください。

記事中の動作検証は Xcode 14 Beta 5 で行っています。

TL;DR

前提として、 @Sendable でないクロージャは定義された箇所の actor コンテキストを受け継ぐ。SwiftUI.Buttonaction 引数は @MainActor ではないが、

  • SwiftUI.Buttonaction 引数は @Sendable ではない
  • action にクロージャを渡す場合 SwiftUI.Viewbody の中で定義されることになり、かつ body@MainActor である

という2つの事実により、 action に渡すクロージャをその場で定義した場合は @MainActor を受け継ぐため、クロージャの中で @MainActor のメソッドを同期的に呼ぶことができる。 action 自体が @MainActor なわけないのでクロージャをその場で定義せずにメソッドを直指定すると warning が出る。

@Sendable でないクロージャは actor コンテキストを引き継ぐ

まず前提として、 「actor の中でクロージャが定義されたときにそのクロージャが @Sendable でない場合は actor のコンテキストを受け継ぐ」ということを理解していきます。SE-0306 Actors からクロージャと actor の関係について引用します。

A closure that is not @Sendable cannot escape the concurrency domain in which it was formed. Therefore, such a closure will be actor-isolated if it is formed within an actor-isolated context. This is useful, for example, when applying sequence algorithms like forEach where the provided closure will be called serially ...

A closure formed within an actor-isolated context is actor-isolated if it is non-@Sendable, and non-isolated if it is @Sendable.

簡単にまとめると、クロージャが @Sendable でない場合は actor コンテキストを受け継ぎ、 @Sendable の場合は受け継がないということです 1 。例えば Task.detached に渡すクロージャは @SendableArray#forEach に渡すクロージャは @Sendable ではないので以下のように forEach のクロージャの中でのみ actor の状態に同期的にアクセスできます。

actor MyActor {
    var number: Int = 42
    
    func f(values: [Int]) {
        Task.detached { // @Sendable なクロージャ
            // actor のコンテキスト外なので number プロパティにアクセスするために await が必要
            await print(self.number)
        }
        
        values.forEach { value in // @Sendable でないクロージャ
            // actor のコンテキスト内なので number プロパティに同期的にアクセスできる
            print(value + self.number)
        }
    }
}

以下、 @Sendable でないクロージャは、 Concurrency ドメインの壁を越えることができず、他のタスクや actor では実行できないので安全に actor のコンテキストを受け継ぐことができることを理解していきます。

@escaping でないクロージャ

forEach 渡すクロージャのように @escaping でないクロージャについては、必ず同期的に実行されるので actor コンテキストを受け継ぐのは自然です。以下 // 1// 2 のループは同じように実行されますが、前者の for 文の中で実行されるコードは当然 actor コンテキスト内で実行されるので、後者の forEach に渡すクロージャも actor コンテキスト内で実行されて問題ないということです。

actor MyActor {
    var number: Int = 42
    
    func f(values: [Int]) {
        // 1
        for value in values {
            print(value + self.number)
        }
        
        // 2
        values.forEach { value in
            print(value + self.number)
        }
    }
}

@escaping なクロージャ

@escaping でないクロージャが actor コンテキストを受け継いでも問題ないことは明らかな一方で、 @escaping なクロージャが actor コンテキストを受け継いで問題ないのかは気になるところです。 @escaping なクロージャの使い道としてよくある

  • その場で非同期に実行される
  • プロパティに保存されて後で実行される

についてそれぞれ見ていきます。

@escaping なクロージャが非同期に実行される場合

クロージャが actor のコンテキストを受け継ぐということは actor のもつ状態を変更できたり、状態を同期的に読み取れるということです。そのクロージャが非同期に実行されてしまっては actor にデータ競合を持ち込んでしまうように思えます。例えば下記のようなコードでは // 1DispatchQueue.global().async でスレッドを切り替えて非同期に実行されるので、 // 1 の操作と // 2 の操作が同時に別のスレッドから実行されてデータ競合が発生する可能性があります。

struct Executor {
    init(operation: @escaping () -> Void) {
        DispatchQueue.global().async {
            operation()
        }
    }
}

actor MyActor {
    var number: Int = 42
    
    func f() {
        let _ = Executor {
            self.number += 1 // 1
        }
        self.number += 1 // 2
    }
}

しかし、実際にはコンパイラが下記のような warning を出してくれてデータ競合の可能性を防げるようになっています。

struct Executor {
    init(operation: @escaping () -> Void) {
        DispatchQueue.global().async {
            // ❗️ Capture of 'operation' with non-sendable type '() -> Void' in a `@Sendable` closure
            operation()
        }
    }
}

DispatchQueue#async だけでなく、 Task.detachedDispatchQueue#asyncAfter のようなメソッドに置き換えても同様の warning が出ます。

現在の Swift では上記の Task.detachedDispatchQueue#async のような非同期に処理を実行する API は @Sendable なクロージャを受け取るようになっています。そのため、 actor 内で定義した @Sendable でないクロージャが最終的に非同期に実行されようとしたところで warning が出るようになっています。コンパイラの warning を無視しない限りはそのクロージャは同期的に実行される必要があり、結果として安全に actor コンテキストを受け継ぐことができるというわけです。

注意点として、上記の warning は Xcode 14 Beta 5 の時点ではこの warning は Strict Concurrency Check を Complete に設定しないと出ないようになっています。おそらく、 Swift 6 の時点ではこの warning はデフォルト設定でも error になるのではないかと思っています。 Strict Concurrency Check について詳しくは以下の記事を参照ください。

@escaping なクロージャがプロパティに保存されて後で実行される場合

@escaping なクロージャはプロパティに保存することができるので、そのクロージャが actor のコンテキストを受け継いでいる場合にプロパティに保存されたクロージャを後から実行しても問題が発生しないか見てみます。例えば以下のコードでは // 1 の操作と // 2 の操作が同時に別のスレッドから実行されることがありうるのでデータ競合が発生する可能性があります。

struct Executor {
    private var operation: () -> Void
    
    init(operation: @escaping () -> Void) {
        self.operation = operation
    }
    
    func executeOperation() {
        operation()
    }
}

actor MyActor {
    var number: Int = 42
    
    func f() {
        let executor = Executor {
            self.number += 1
        }
        
        self.number += 1 // 1
        DispatchQueue.global().async {
            executor.executeOperation() // 2
        }
    }
}

このようなコードに対してもコンパイラが warning を出してくれます。

actor MyActor {
    func f() {
        // ...

        self.number += 1
        DispatchQueue.global().async {
            // ❗️ Capture of 'executor' with non-sendable type 'Executor' in a `@Sendable` closure
            executor.executeOperation()
        }
    }
}

ここでは ExecutorSendable に準拠していないことが warning の原因になっています。先ほども述べたように非同期処理を実行する API は @Sendable なクロージャを受け取るようになっており、 Sendable に準拠していない型である Executor のインスタンスはそのようなクロージャでキャプチャできないようになっているためです。

もちろんこの warning は ExecutorSendable に準拠させれば消えますが、そうするとまた別の warning が発生します。

struct Executor: Sendable {
    // ❗️ Stored property 'operation' of 'Sendable'-conforming struct 'Executor' has non-sendable type '() -> Void'
    private var operation: () -> Void
    
    // ...
}

@Sendable でないクロージャをメンバーに持つ型は Sendable に準拠することができないことがこの warning の原因です。このことから、クロージャが @Sendable でない限りはプロパティに保存されたとしても、クロージャが後で別のスレッドで実行されて actor とデータ競合を起こすということはコンパイラが防いでくれることがわかります。

ここまでの議論で、 @Sendable なクロージャは最終的に Concurrency ドメインの壁を越えられないのでそのクロージャが定義された actor のコンテキストを受け継いでも安全であることが納得できると思います。

SwiftUI.Buttonaction@MainActor なのか

それでは、記事の最初に提示した疑問である SwiftUI.Buttonaction@MainActor なのかについて考えていきます。結論から書くと、SwiftUI.Buttonaction@MainActor ではないと思います。

Xcode で Buttoninit にコードジャンプしてみると @MainActor のアトリビュートがついていないことがわかります。

public struct Button<Label> : View where Label : View {
    public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
}

また、 @MainActor のクロージャを直接渡すと warning が出ることも action@MainActor ではないことを示しています。

@MainActor
final class ViewModel: ObservableObject {
    func onTapped() {
        print("onTapped")
    }
}

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        // ❗️ Converting function value of type '@MainActor () -> ()' to '() -> Void' loses global actor 'MainActor'
        Button(action: viewModel.onTapped) { Text("Tap Me") }
    }
}

ここで、action@MainActor でないとすると、以下のコードが warning を出さないことが不思議に思えます。

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        // ✅ OK
        // MainActor である viewModel.onTapped を同期的に呼び出せている
        Button(action: { viewModel.onTapped() }) { Text("Tap Me") }
    }
}

実際に、例えば上記の Button のケースと同じようなことをしているはずの以下のコードを書いてみると warning が出ます。

@MainActor func mainActor() {}

struct MyButton {
    init(action: () -> Void) {}
}

func f() {
    // ❗️ Call to main actor-isolated global function 'mainActor()' in a synchronous nonisolated context; this is an error in Swift 6
    let _ = MyButton(action: { mainActor() })
}

以上の疑問は、前の節で理解した「@Sendable でないクロージャは定義された actor のコンテキストを引き継ぐ」ということを考えると解消します。

まず、 SwiftUI のコードに Xcode でジャンプしてみると SwiftUI.Viewbody プロパティは @MainActor であることがわかります。

public protocol View {
    associatedtype Body : View

    @ViewBuilder @MainActor var body: Self.Body { get }
}

その上で、 Buttonaction@Sendable ではないクロージャを受け取ります。

public struct Button<Label> : View where Label : View {
    public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
}

@Sendable でないクロージャは定義された actor のコンテキストを引き継ぐという前提のもと、

  • Viewbody@MainActor である
  • Buttonaction に渡すクロージャは @Sendable ではない

という事実を考え合わせると、Buttonaction に渡すクロージャをその場で定義した場合にそのクロージャが @MainActor のコンテキストを受け継ぐことがわかります。これにより、中で @MainActor のメソッドを同期的に呼び出すことが可能になっているというわけです。

struct ContentView: View {
    // @MainActor
    @StateObject private var viewModel = ViewModel()

    // @MainActor
    var body: some View {
        // action に渡したクロージャは @MainActor を受け継ぐので 
        // @MainActor である onTapped メソッドを同期的に呼び出せる
        Button(action: { viewModel.onTapped() }) { Text("Tap Me") }
    }
}

以上の説を確認するため、 action に渡すクロージャをあえて @Sendable にしてみます。 @Senadble なクロージャは actor コンテキストを受け継がないのでこの変更によりクロージャが @MainActor ではなくなり、エラーが発生するようになります。

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
-       Button(action: { viewModel.onTapped() }) { Text("Tap Me") }
        // ❌ Main actor-isolated property 'viewModel' can not be referenced from a Sendable closure
        // ❗️ Call to main actor-isolated instance method 'onTapped()' in a synchronous nonisolated context; this is an error in Swift 6
+       Button(action: { @Sendable in viewModel.onTapped() }) { Text("Tap Me") }
    }
}

別の観点からの確認として、 @MainActor ではない適当な関数で Button を定義して、 action のクロージャ内で @MainActor のメソッドを呼んでみると warning が発生します。これは fbody と異なり @MainActor ではないことから、 f の内部で定義したクロージャも @MainActor ではないためです。

@MainActor func mainActor() {}

func f() {
    // ❗️ Call to main actor-isolated global function 'mainActor()' in a synchronous nonisolated context; this is an error in Swift 6
    let _ = Button(action: { mainActor() }) { Text("Tap Me") }
}

この f 自体を @MainActor にしてあげると body と同じ状況になるため warning は消えます。

@MainActor
func f() {
    // ✅ OK
    let _ = Button(action: { mainActor() }) { Text("Tap Me") }
}

参考

  1. 例外として、 Task.init が受け取るクロージャは @Sendable であるにも関わらず actor コンテキストを受け継ぎます

16
8
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
16
8