【SwiftUI】SwiftData でスレッドセーフにバックグラウンドでデータを扱う 🔄 - @ModelActor

SwiftData は便利だけども UI スレッドが重くならないのかな。

という考えからの Todo アプリを作成しながらのスレッドまわりの実装修行。

 

🔄 実装イメージ

API にリクエストを投げると画面が自動的に更新される、という形。

時間のかかる処理は UI スレッドに負荷がかからないように他のスレッドで処理する。

SwiftUI のボタン押してのダウンロードリクエストから、WEB API からデータ取得して、SwiftData ストレージ に保存。

SwiftUI 上では SwiftData マクロ @Query で observe しておいて変化検出即時画面更新。

【SwiftUI】SwiftData をスレッドセーフにバックグラウンドでデータを扱う 🔄 - @ModelActor

そのまま、SwiftData ストレージはローカルキャッシュとなる。

 

🔄 SwiftData はバックグラウンドで使えるのか

WWDC2023 での会話らしいです。

@Duncan: As I understand it, SwiftData model objects are not thread-safe, just like NSManagedObjects. Are there any additional mechanisms to make managing this easier for us than it was in traditional Core Data? e.g., compiler warnings before passing an object out of its context?

@Dave N (Apple): We have provided the ModelActor protocol & the DefaultModelExecutor to make SwiftData work with Swift Concurrency. Use your ModelContainer to initialize a ModelContext in the initializer for your ModelActor conforming actor object and use that context to initialize a DefaultModelExecutor. This will allow you to use that context with async functions on your actor.

@Ben T (Apple): Swift will enforce sendability requirements

@Duncan: SwiftData のモデルオブジェクトは NSManagedObject と同様にスレッドセーフではないと理解しています。従来の Core Data と比べて、オブジェクトを扱う際に管理しやすくするための追加のメカニズムはありますか?例えば、オブジェクトをそのコンテキストから出す前にコンパイラの警告があるでしょうか?

@Dave N(Apple): 私たちは ModelActor プロトコルと DefaultModelExecutor を提供しており、Swift Concurrency と連携するための SwiftData を使用できます。ModelActor に準拠するアクターオブジェクトのイニシャライザで ModelContainer を使用して ModelContext を初期化し、そのコンテキストを使用して DefaultModelExecutor を初期化します。これにより、そのコンテキストをアクターの非同期関数で使用できるようになります。

@Ben T(Apple): Swift は送信可能性の要件を強制します。

👉 Using SwiftData in background? | Apple Developer Forums hatena-bookmark

1年前ぐらいの会話なので、今では使えそうです。

  • ModelContext
  • ModelActor
  • DefaultModelExecutor

が検索のキーワードでしょうか。

 

🔄 @ModelActor

このマクロを expand すると、前述の会話を実装していることがわかります。

class でなく actor に付けるようです。


@ModelActor
actor SampleService {
  nonisolated let modelExecutor: any SwiftData.ModelExecutor
  nonisolated let modelContainer: SwiftData.ModelContainer

  init(modelContainer: SwiftData.ModelContainer) {
    let modelContext = ModelContext(modelContainer)
    self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext)
    self.modelContainer = modelContainer
  }
}

extension SampleService: SwiftData.ModelActor {
}

DefaultSerialModelExecutor() になっているのは、改良して直感的に順番通りに流れるようになったのかな。

ランダムにアドバイスをくれるAPIを利用して実装します。


{
  "slip": { 
    "id": 136, 
    "advice": "Everything matters, but nothing matters that much."
  }
}

👉 Advice Slip JSON API hatena-bookmark

modelContext が非同期関数内で利用できるようになりました。


@ModelActor
actor TodoService {
  private(set) static var shared: TodoService!
  
  static func create(modelContainer: ModelContainer) {
    shared = TodoService(modelContainer: modelContainer)
  }
  
  func download() async {
    do {
      for i in 0 ..< 10 {
        let random = await RandomAdvice.get()
        modelContext.insert(Todo(text: "\(i). \(random.slip.advice)"))
        try modelContext.save()
      }
    } catch {
      if Task.isCancelled {
        print("Task was cancelled.")
      }
    }
  }
}

Task は別ボタンでキャンセルできるように掴んでおく。


Button {
  task = Task {
    await TodoService.shared.download()
  }
} label: {
  Image(systemName: "icloud.and.arrow.down")
}
.onAppear {
  TodoService.create(modelContainer: modelContext.container)
}

 

🔄 まとめ

マクロって GitHub でコードを眺めてるとコードの意味が全くみえない。

しかし、SwiftData のみでここまでできることに驚きました。



恐るべし。

しかし、謎なことが、

👉 【Swift】この ModelActor ってなぜ生きてるの? hatena-bookmark

 

🔄 参考

👉 Context outside of SwiftUI Views | Apple Developer Forums hatena-bookmark
👉 Sendable and @Sendable closures explained with code examples hatena-bookmark
👉 SwiftData background inserts using… | Apple Developer Forums hatena-bookmark
👉 ModelActor Implementation Changes … | Apple Developer Forums hatena-bookmark


Hilt で KSP の依存関係の設定 (build.gradle.kts)

kapt から KSP に移行しようとしてハマる。

 

🚀 Dagger + KSP

今回は使わなったが動く。


plugins {
  id("org.jetbrains.kotlin.android") version "1.9.0"
  id("com.google.devtools.ksp") version "1.9.0-1.0.12"
}

dependencies {
  ksp("com.google.dagger:dagger-compiler:2.48") // Dagger compiler
  ksp("com.google.dagger:hilt-compiler:2.48")   // Hilt compiler
}

👉 Dagger KSP hatena-bookmark

 

🚀 Hilt + kapt


// build.gradle.kts (Project)

plugins {
  id("com.google.dagger.hilt.android") version "2.44" apply false
}


// build.gradle.kts (Module)

plugins {
  kotlin("kapt")
  id("com.google.dagger.hilt.android")
}

dependencies {
  implementation("com.google.dagger:hilt-android:2.44")
  kapt("com.google.dagger:hilt-android-compiler:2.44")
}

kapt {
  correctErrorTypes = true
}

👉 Hilt を使用した依存関係の注入  |  Android デベロッパー  |  Android Developers hatena-bookmark

 

🚀 Hilt + KSP


// build.gradle.kts (Project)

plugins {
  id("com.google.devtools.ksp") version "1.8.10-1.0.9" apply false
  id("com.google.dagger.hilt.android") version "2.44" apply false
}


// build.gradle.kts (Module)

plugins {
  id("com.google.devtools.ksp")
  id("com.google.dagger.hilt.android")
}

dependencies {
  implementation("com.google.dagger:hilt-android:2.44")
  ksp("com.google.dagger:hilt-android-compiler:2.44")
}

👉 kapt から KSP に移行する  |  Android デベロッパー  |  Android Developers hatena-bookmark

👉 Revisions · Hilt + kapt → KSP hatena-bookmark

 

🚀 まとめ

Hilt で公式リファレンスを見ながら、kapt → KSP と順番に変化させていけばスムーズに対応できたのに、Dagger KSP ページを見ながら進んだのがハマった原因。

Version Catalog を使っていくことになりそうなので、抜粋しておく。




【JetpackCompose】 Composable 関数の Modifier について知らなかった3つのルール

【JetpackCompose】 Composable 関数の Modifier について知らなかった流儀

detekt に指摘されながら、ドキュメントを読む。


こんなコードがあったとして。


Column {
    InnerContent()
}

@Composable
private fun InnerContent() {
    Text(...)
    Image(...)
    Button(...)
}

以下、知らなかった流儀。

 

🧩 Composable 関数はレイアウトを一つだけ出力する

A composable function should emit either 0 or 1 pieces of layout, but no more. A composable function should be cohesive, and not rely on what function it is called from.

コンポーザブル関数は、レイアウトのピースを0個または1個だけ発行するべきで、それ以上は発行してはいけません。コンポーザブル関数は結束性を持ち、呼び出される関数に依存すべきではありません。

レイアウトのネストのコストはあまり気にしなくて良い、とのこと。

Nesting of layouts has a drastically lower cost vs the view system, so developers should not try to minimize UI layers at the cost of correctness.

レイアウトのネストには、ビューシステムに比べてはるかに低いコストがかかるため、UIの階層を正確性の犠牲にして最小化しようとしないべきです。

👉 Do not emit multiple pieces of content - Twitter Jetpack Compose Rules hatena-bookmark

ということで、一見不要に見える Column を追加する。


@Composable
private fun InnerContent() {
    Column {
        Text(...)
        Image(...)
        Button(...)
    }
}

 

🧩 デフォルトを持つパラメータ modifier: Nodifier = Modifier は必須

They are especially important for your public components, as they allow callers to customize the component to their wishes.

特に、公開コンポーネントにとって Modifier は非常に重要であり、呼び出し元がコンポーネントを希望に合わせてカスタマイズできるようにします。

👉 When should I expose modifier parameters? - Jetpack Compose Rules hatena-bookmark

Composables that accept a Modifier as a parameter to be applied to the whole component represented by the composable function should name the parameter modifier and assign the parameter a default value of Modifier.

Composable 関数内でコンポーザブル関数を表すコンポーネント全体に適用するための修飾子をパラメータとして受け入れる場合、そのパラメータは "modifier" という名前を付け、パラメータに Modifier のデフォルト値を割り当てるべきです。

👉 Modifiers should have default parameters - Jetpack Compose Rules hatena-bookmark

ということで、親からの Modifier を受け入れるようにします。デフォルト値もつけておきます。


@Composable
private fun InnerContent(modifier: Modifier = Modifier) {
    Column {
        Text(...)
        Image(...)
        Button(...)
    }
}

 

🧩 受け取った Modifier パラメータは最上位のレイアウトにのみ適用する

Modifiers should be applied once as a first modifier in the chain to the root-most layout in the component implementation. Since modifiers aim to modify the external behaviors and appearance of the component, they must be applied to the top-most layout and be the first modifiers in the hierarchy. It is allowed to chain other modifiers to the modifier passed as a param if needed.

Modifier は、コンポーネントの実装内でルートのレイアウトに最初の Modifier として一度適用すべきです。Modifier はコンポーネントの外部の動作や外観を変更することを目的としているため、最上位のレイアウトに適用し、階層内で最初の Modifier である必要があります。必要に応じて、パラメータとして渡された Modifier に他の Modifier を連鎖させることは許可されています。

👉 Modifiers should be used at the top-most layout of the component - Jetpack Compose Rules hatena-bookmark

ということで、最上位ルートの Column で適用します。


@Composable
private fun InnerContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text(...)
        Image(...)
        Button(...)
    }
}

 

🧩 まとめ

@Composable 内のレイアウトに関する Modifier は、親 (呼び出し元) から持ってきて、最上位のレイアウトで一度だけ適用する。

detekt を使うことで、なんとなく記述していた部分がスッキリしてきます。

👉 【AndroidStudio】detekt で JetpackCompose 記述をチェックする hatena-bookmark