【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


【Swift】モデルクラスの ID に UUID() を使ってたらヤバい - PersistentIdentifier

すいません、別にヤバくはないです。

みなさんはどう書いてますか。

私はこんな感じで書いてました。


final class Card {
  var id: UUID
  var front: String
  var back: String

  init(front: String, back: String) {
    self.id = UUID()
    self.front = front
    self.back = back
  }
}

これを、Apple 公式サンプルではこんなかんじで書いてました。


final class Card {
  var front: String
  var back: String

  init(front: String, back: String) {
    self.front = front
    self.back = back
  }
}

extension Card: Identifiable { }

ID (id) がない!

Playground で見てみます。


final class Card {
  var front: String
  var back: String

  init(front: String, back: String) {
    self.front = front
    self.back = back
  }
}

extension Card: Identifiable { }

let card = Card(front: "前", back: "後")

print(card.id)
print(card.front)
print(card.back)

// ObjectIdentifier(0x0000600000c014d0)
// 前
// 後

id が取れます。

Identifiable を継承しても同様です。

Apple のサンプルコードは少し省略して書きましたが、実際は以下のようなコードです。


final class Card: ObservableObject {
  @Published var front: String
  @Published var back: String
  var creationDate: Date

  init(front: String, back: String, creationDate: Date = .now) {
    self.front = front
    self.back = back
    self.creationDate = creationDate
  }
}

extension Card: Identifiable { }


struct CardCarousel: View {
  @State private var selectedCardID: Card.ID?  // *
  @FocusState private var focusCardID: Card.ID?
  private let initialCardID: Card.ID

  let editing: Bool
  var cards: [Card]

  init(editing: Bool, cards: [Card], selectedCard: Card) {
    self.editing = editing
    self.cards = cards
    initialCardID = selectedCard.id
  }

  var body: some View {
    VStack {
      ScrollView(.horizontal) {
        LazyHStack(spacing: 0) {
         ForEach(cards) { card in // *
           Group {
             if editing {
               CardEditorView(card: card)
             } else {
               FlashCardView(card: card)
                 .id(card.id)

ObjectIdentifier(0x0000600000c014d0) の型は、

Card.ID

です。

また、List 内の ForEach() の引数は一つです。

便利ですよね!

 

🆔 SwiftData の場合

同じく Apple サンプルコードです。

SwiftData では更に便利になっています。

こちらにもモデルクラスに ID はありません。

Identifiable もありません。


@Model
final class Card {
  var front: String
  var back: String
  var creationDate: Date

  init(front: String, back: String, creationDate: Date = .now) {
    self.front = front
    self.back = back
    self.creationDate = creationDate
  }
}


struct CardCarousel: View {
  @State private var selectedCardID: PersistentIdentifier? // *
  @FocusState private var focusCardID: PersistentIdentifier?
  private let initialCardID: PersistentIdentifier

  let editing: Bool
  var cards: [Card]

  init(editing: Bool, cards: [Card], selectedCard: Card) {
    self.editing = editing
    self.cards = cards
    initialCardID = selectedCard.id
  }

  var body: some View {
    VStack {
      ScrollView(.horizontal) {
        LazyHStack(spacing: 0) {
          ForEach(cards) { card in // *
            Group {
              if editing {
                CardEditorView(card: card)
              } else {
                FlashCardView(card: card)
                  .id(card.id)

ID の型は、PersistentIdentifier です。

書き方は違いますが、同様にモデルクラスにIDなしで使えます。

 

🆔 まとめ

今回勉強した内容で、以前の Todo アプリを書き換えます。

👉 SwiftUI + SwiftData で ToDo リスト を作ってみる hatena-bookmark

少しだけスッキリしました。


【Swift】Playground で API から JSON データを取得して デコード して画像を表示する 🐶

Xcode Playground 便利です。

ちょっとしたコード記述の確認に使えます。

今回は、通信処理まわりのコードをやってみました。

Dog API というのを利用しました。


https://dog.ceo/api/breeds/image/random


{
    "message": "https://images.dog.ceo/breeds/chow/n02112137_1015.jpg",
    "status": "success"
}

👉 Dog API hatena-bookmark

 

🐶 JSON の取得


let url = "https://dog.ceo/api/breeds/image/random"
let session = URLSession.shared
guard let (data, response) = try? await session.data(from: URL(string: url)!),
      let httpResponse = response as? HTTPURLResponse,
      httpResponse.statusCode == 200
else {
  throw // ...
}

print(String(data: data, encoding: .utf8)!)
// {"message":"https:\/\/images.dog.ceo\/breeds\/lhasa\/n02098413_5594.jpg","status":"success"}

 

🐶 JSON デコード


struct Dog: Decodable {
  let message: String
  let status: String
}


let data: Data = // ...
do {
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .millisecondsSince1970
  print(try decoder.decode(Dog.self, from: data))
} catch {
  throw // ...
}

// Dog(message: "https://images.dog.ceo/breeds/lhasa/n02098413_5594.jpg", status: "success")

 

🐶 WebKit で表示


let url = "https://images.dog.ceo/breeds/lhasa/n02098413_5594.jpg"
let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
let request = URLRequest(url: URL(string: url)!)
view.load(request)
PlaygroundPage.current.liveView = view

 

🐶 まとめ

以上をまとめたコードを貼っておきます。

Xcode Playground にベタ貼りするだけで動きます。

実行時にアレなので強引にメインスレッドに揃えています。


Task { @MainActor in
  // ...
}

前回に調べた内容を参考にしました。

👉 【Swift】URLSession.shared.dataTask() をうまく使いこなせない hatena-bookmark

よろしくおねがいします。

次回は、Concurrency をやってみたいと思います。