[SwiftUI] MainActor の使い所

SwiftUI2021

iOS15, macOS12 で追加された 新しい property wrapper である MainActor の使い所を説明します

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.1 Beta
  • Xcode 13.1
  • iOS 15

MainActor

iOS15/macOS12 で新しく追加された property wrapper です。

ドキュメントでは、”A singleton actor whose executor is equivalent to the main dispatch queue.”と説明されています。

具体的な使用例を見ながら理解してみます。

SwiftUI でのサンプルプロジェクト

以下のような サンプルで見ていきます。

ボタンを押すと イメージを Web から取得して表示するアプリです。

素のコード

イメージデータのサイズが小さいので、Web からの取得も瞬時に終わり 表示されます。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/11/16
//  © 2021  SmallDeskSoftware
//

import SwiftUI

class AppData: ObservableObject {
    @Published var image: NSImage? = nil

    func loadImage() {
        guard let url = URL(string: "https://software.small-desk.com/wp-content/uploads/2021/11/SDSLogo.png") else { return }
        if let image = NSImage(contentsOf: url) {
            self.image = image
        }
    }
}

struct ContentView: View {
    @StateObject private var appData: AppData = AppData()
    var body: some View {
        VStack {
            if let image = appData.image {
                Image(nsImage: image)
                    .resizable().scaledToFit()
                    .frame(width: 800, height: 200)
            } else {
                Text("No image, need to load")
                    .frame(width: 800, height: 200)
            }
            Button(action: {
                appData.loadImage()
            }, label: {
                Text("load image")
            })
                .padding()
        }
        .padding(20)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

現在 対象としているデータは小さいのでうまく動いているように見えますが、イメージデータのサイズが大きくなったり ネットワーク回線が混み始めたりすると、アプリが 固まりはじめます。

Web からのデータ取得を同期実行しているため、イメージデータの取得中は、ユーザーからの操作に対して 反応しなくなるためです。

このような時間が掛かるかもしれない機能実装は非同期で行うのが普通です。

Web からのデータ取得を非同期で実行

iOS15/macOS12 で新しく導入された要素として Task というものがあります。

Apple のドキュメントは、こちら

クロージャで指定したコードを、別スレッドで実行させることができます。

Task を使って、Web からの取得処理を行わせるコードは以下のようになります。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/11/16
//  © 2021  SmallDeskSoftware
//

import SwiftUI

class AppData: ObservableObject {
    @Published var image: NSImage? = nil

    func loadImage() {
        guard let url = URL(string: "https://software.small-desk.com/wp-content/uploads/2021/11/SDSLogo.png") else { return }
        // (1) Web からの取得コードを 別 Task として実行
        Task {
            if let image = NSImage(contentsOf: url) {
                self.image = image
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var appData: AppData = AppData()
    var body: some View {
        VStack {
            if let image = appData.image {
                Image(nsImage: image)
                    .resizable().scaledToFit()
                    .frame(width: 800, height: 200)
            } else {
                Text("No image, need to load")
                    .frame(width: 800, height: 200)
            }
            Button(action: {
                appData.loadImage()
            }, label: {
                Text("load image")
            })
                .padding()
        }
        .padding(20)
    }
}

非同期実行になるのは良いのですが、実行してみると Xcode のコンソールに以下のようなワーニングが表示されます。


[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

意味としては、SwiftUI の画面更新に関わる値が バックグラウンドスレッドから更新することは許されない。メインスレッドから更新されることを確認すること。(例えば、receive(on:) を使って更新する)

動作として、期待通りに画面は更新されていますが、ワーニングで表示されている通り、メインスレッドから画面更新に関わる変更をおこなっていないのは いわゆる bad practice です。

DispatchQueue.main.async

iOS15/macOS12 以前では、以下のように修正するケースが多くありました。どのスレッドで実行させるかを直接指定しています。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/11/16
//  © 2021  SmallDeskSoftware
//

import SwiftUI

class AppData: ObservableObject {
    @Published var image: NSImage? = nil

    func loadImage() {
        guard let url = URL(string: "https://software.small-desk.com/wp-content/uploads/2021/11/SDSLogo.png") else { return }
        // (1)  Task は使う
        Task {
            if let image = NSImage(contentsOf: url) {
                // (2)  DispatchQueue.main.async で実行スレッドを指定する
                DispatchQueue.main.async {
                    self.image = image
                }
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var appData: AppData = AppData()
    var body: some View {
        VStack {
            if let image = appData.image {
                Image(nsImage: image)
                    .resizable().scaledToFit()
                    .frame(width: 800, height: 200)
            } else {
                Text("No image, need to load")
                    .frame(width: 800, height: 200)
            }
            Button(action: {
                appData.loadImage()
            }, label: {
                Text("load image")
            })
                .padding()
        }
        .padding(20)
    }
}

この修正は、iOS15/macOS12 でも有効です。ワーニングは表示されなくなり、期待通りの動作になります。

ですが、completionHandler のネストと似た感じで、closure を複数階層に渡って使用しているので、理解するのに 少し考えることが必要なコードになってしまっています。

MainActor 指定

iOS15/macOS12 で導入された MainActor を使うと以下のようなコードになります。

先ほどまで使用していた DispatchQueue.main がなくなっていることがわかります。

# 別スレッド実行を指定する Task は、必要です。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/11/16
//  © 2021  SmallDeskSoftware
//

import SwiftUI

// (1) MainActor 指定
@MainActor 
class AppData: ObservableObject {
    @Published var image: NSImage? = nil

    func loadImage() {
        guard let url = URL(string: "https://software.small-desk.com/wp-content/uploads/2021/11/SDSLogo.png") else { return }
        // (2) Task は使っているが、内部の DispatchQueue.main は不要になる
        Task {
            if let image = NSImage(contentsOf: url) {
                self.image = image
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var appData: AppData = AppData()
    var body: some View {
        VStack {
            if let image = appData.image {
                Image(nsImage: image)
                    .resizable().scaledToFit()
                    .frame(width: 800, height: 200)
            } else {
                Text("No image, need to load")
                    .frame(width: 800, height: 200)
            }
            Button(action: {
                appData.loadImage()
            }, label: {
                Text("load image")
            })
                .padding()
        }
        .padding(20)
    }
}

class に MainActor を付与することで、その class の属性やメソッドは、メインスレッドで実行されるように自動的になります。

ですので、個別に指定する必要がなくなるというわけです。

このように、UI更新に関わるプロパティを保持するクラスには、MainActor 指定することで、自動的にメインスレッドからの更新を保証できることになります。

と同時に、非同期で実行したい処理は明示的に Task で別スレッドに移動させることができます。

まとめ:MainActor の使い所

MainActor の使い所
  • UI 更新に関わる変更は、メインスレッドから変更することが必要
  • Task では、メインスレッドでの実行をしていできないので NG
  • DispatchQueue.main では、コードのネストが増えて読みづらい
  • (View が依存する class に) @MainActor 指定することで、属性へのアクセスが メインスレッドから行われることが保証される
MEMO
@MainActorh は、SwiftUI 以外にも UIKit や AppKit と合わせても使うことができる property wrapper です。MainActor 自体、Foundation の一部として提供されています。

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI 学習におすすめの本

SwiftUI 徹底入門

SwiftUI は、グラフィカルなライブラリということもあり、文字だけのテキストよりは、画像が多く入れられた書籍を読むと理解が進みやすいです。

自分で購入した中でおすすめできるものとしては、以下のものです。

2019 年発表の SwiftUI 1.0 相当を対象にしているので、2020/2021 に追加された一部の機能は、説明されていません。

ですが、SwiftUI 入門書としては、非常によくできていますし、わかりやすいです。 この本で学習した後に、追加分を学習しても良いと思います。

SwiftUIViewsMastery

英語での説明になってしまいますが、以下の本もおすすめです。

1ページに、コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です