プログラミング

【Swift】@Observableと@MainActorで作るモダンなSwiftUI状態管理

記事内に商品プロモーションを含む場合があります

iOS 17から導入された@Observableマクロ。これを使うと、従来のObservableObject@Publishedの組み合わせよりもシンプルに状態管理ができるようになりました。

でも、実際にプロジェクトで使おうとすると「@MainActorとどう組み合わせればいいの?」「Swift 6の厳密なConcurrencyチェックで怒られる…」といった疑問が出てきます。

この記事では、@Observable@MainActorを組み合わせた実践的なViewModelパターンを解説します。

Observableパターンのイメージ

@Observableマクロとは

@Observableは、iOS 17で導入されたObservationフレームワークの中核となるマクロです。クラスに付与することで、プロパティの変更を自動的に追跡し、SwiftUIのビューを更新してくれます。

import Observation

@Observable
class CounterModel {
    var count = 0
    
    func increment() {
        count += 1
    }
}

これだけで完了です。ObservableObjectプロトコルへの準拠も、@Publishedも不要になりました。

ObservableObjectからの主な変更点

従来のObservableObjectパターンとの違いを見てみましょう。

Beforeの書き方(ObservableObject)

class CounterViewModel: ObservableObject {
    @Published var count = 0
    
    func increment() {
        count += 1
    }
}

struct CounterView: View {
    @StateObject private var viewModel = CounterViewModel()
    
    var body: some View {
        VStack {
            Text("\(viewModel.count)")
            Button("増やす") {
                viewModel.increment()
            }
        }
    }
}

Afterの書き方(@Observable)

@Observable
class CounterViewModel {
    var count = 0
    
    func increment() {
        count += 1
    }
}

struct CounterView: View {
    @State private var viewModel = CounterViewModel()
    
    var body: some View {
        VStack {
            Text("\(viewModel.count)")
            Button("増やす") {
                viewModel.increment()
            }
        }
    }
}

ポイントは、@StateObjectではなく@Stateを使う点です。@Observableクラスは参照型ですが、SwiftUIが適切にライフサイクルを管理してくれます。

@MainActorとの組み合わせ

実際のアプリでは、ViewModelで非同期処理を行うケースがほとんどです。そこで重要になるのが@MainActorです。

MainActorとスレッド安全性

なぜ@MainActorが必要なのか

SwiftUIのビュー更新はメインスレッドで行われる必要があります。@Observableのプロパティを変更する処理がバックグラウンドスレッドで実行されると、予期しない動作やクラッシュの原因になります。

@MainActorをクラス全体に付与することで、すべてのプロパティアクセスとメソッド呼び出しがメインスレッドで実行されることを保証できます。

@MainActor
@Observable
class UserViewModel {
    var user: User?
    var isLoading = false
    var errorMessage: String?
    
    private let apiClient: APIClient
    
    init(apiClient: APIClient = .shared) {
        self.apiClient = apiClient
    }
    
    func fetchUser(id: String) async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            user = try await apiClient.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

ViewでのViewModel利用

struct UserView: View {
    @State private var viewModel = UserViewModel()
    let userId: String
    
    var body: some View {
        Group {
            if viewModel.isLoading {
                ProgressView()
            } else if let user = viewModel.user {
                UserDetailView(user: user)
            } else if let error = viewModel.errorMessage {
                Text(error)
            }
        }
        .task {
            await viewModel.fetchUser(id: userId)
        }
    }
}

.taskモディファイアはビューのライフサイクルに紐づいた非同期処理を実行できます。ビューが消えると自動的にタスクがキャンセルされるので便利です。

Swift 6の厳密なConcurrency対応

Swift 6では、Concurrencyに関するチェックがより厳密になりました。特に注意が必要なのは、Sendableへの対応です。

問題になりやすいパターン

@MainActor
@Observable
class SettingsViewModel {
    var settings: AppSettings  // AppSettingsがSendableでないと警告
}

AppSettingsSendableでない場合、Swift 6では警告やエラーになることがあります。

解決策1: Sendableに準拠させる

struct AppSettings: Sendable {
    var theme: Theme
    var notificationsEnabled: Bool
}

構造体で、すべてのプロパティがSendableであれば、自動的にSendableに準拠します。

解決策2: nonisolatedを活用する

外部から受け取るデータでSendableにできない場合は、nonisolatedを使って分離することも検討できます。

@MainActor
@Observable
class DataViewModel {
    var displayData: [String] = []
    
    nonisolated func processRawData(_ data: NonSendableData) -> [String] {
        // データ処理(UIに依存しない純粋な変換)
        return data.items.map { $0.name }
    }
}

@Bindableを使ったBinding生成

@ObservableクラスのプロパティをTextFieldなどにバインドする場合は、@Bindableを使います。

@MainActor
@Observable
class ProfileViewModel {
    var name = ""
    var email = ""
}

struct ProfileEditView: View {
    @Bindable var viewModel: ProfileViewModel
    
    var body: some View {
        Form {
            TextField("名前", text: $viewModel.name)
            TextField("メール", text: $viewModel.email)
        }
    }
}

@Bindableは、@ObservableクラスのプロパティへのBindingを生成するためのプロパティラッパーです。親ビューから渡されたViewModelに対して使用します。

@Stateで保持している場合

自身で@Stateを使ってViewModelを保持している場合は、$viewModelで直接アクセスできます。

struct ProfileView: View {
    @State private var viewModel = ProfileViewModel()
    
    var body: some View {
        Form {
            TextField("名前", text: $viewModel.name)
            TextField("メール", text: $viewModel.email)
        }
    }
}

移行時のベストプラクティス

既存プロジェクトを@Observableに移行する際のポイントをまとめます。

段階的に移行する

一度にすべてを移行しようとせず、新規のViewModelから@Observableを使い始めるのがおすすめです。既存のObservableObjectと共存できるので、リスクを抑えながら移行できます。

イニシャライザの扱い

@Observableクラスでは、イニシャライザでプロパティを初期化する際に特別な対応は不要です。

@MainActor
@Observable
class ItemViewModel {
    var items: [Item]
    var selectedItem: Item?
    
    init(items: [Item] = []) {
        self.items = items
    }
}

計算プロパティの追跡

@Observableは計算プロパティも追跡してくれます。依存するプロパティが変更されると、計算プロパティを参照しているビューも更新されます。

@MainActor
@Observable
class CartViewModel {
    var items: [CartItem] = []
    
    var totalPrice: Int {
        items.reduce(0) { $0 + $1.price * $1.quantity }
    }
    
    var isEmpty: Bool {
        items.isEmpty
    }
}

まとめ

@Observable@MainActorの組み合わせは、SwiftUIの状態管理をシンプルかつ安全にしてくれます。

主なポイントをおさらいすると、

  • @Observableマクロでボイラープレートを削減
  • @MainActorでスレッド安全性を確保
  • @StateでViewModelを保持(@StateObjectは使わない)
  • @BindableでBindingを生成
  • Swift 6ではSendableへの対応も意識する

新規プロジェクトでは積極的に@Observableを採用し、既存プロジェクトも段階的に移行していくことをおすすめします。コードがすっきりして、メンテナンスしやすくなりますよ。

出典一覧