【Swift】 @Observable に @MainActor をつけると おまえは 実質 ViewModel だろ

Swift のアーキテクチャパターンまわりの新しい記事を眺めてると、

@Observable には、明示的に @MainActor をつけましょう。

その親 View にも @MainActor をつけるのが推奨されます。

などと書いてるのを多く見かける。

「全部 UI スレッドでは?」

などと思いながら、あれこれやってみました。


import SwiftUI

@MainActor
struct TestWebRequest: View {
  @State private var responseBody = ""

  //private let url = URL(string: "https://wttr.in/?format=3")!

  //private var background = Background()
  private var viewModel = TestViewModel()

  var body: some View {
//    Text("\(responseBody)")
//    Text("\(background.responseBody)")
    Text("\(viewModel.responseBody)")

//      .onAppear { // OK
//        responseBody = try! String(contentsOf: url)
//      }

//      .onAppear {
//        // Passing argument of non-sendable type '(any URLSessionTaskDelegate)?'
//        // outside of main actor-isolated context may introduce data races
//        Task {
//          let (data, _) = try! await URLSession.shared.data(from: url)
//          responseBody = String(data: data, encoding: .utf8)!
//        }
//      }

//      .task {
//        // Passing argument of non-sendable type '(any URLSessionTaskDelegate)?'
//        // outside of main actor-isolated context may introduce data races
//        let (data, _) = try! await URLSession.shared.data(from: url)
//        responseBody = String(data: data, encoding: .utf8)!
//      }

//        .onAppear {
//          Task {
//            responseBody = await Background.request(url: url)
//          }
//        }

//      .task {
//        responseBody = await background.request(url: url)
//      }

//      .task {
//        responseBody = await background.request(url: url)
//      }

      .task {
        //await background.request(url: url)
        await viewModel.request()
      }
  }
}

@MainActor
@Observable
final class TestViewModel {
  var responseBody = ""

  private let background = Background()

  func request() async {
    responseBody = await background.request()
  }
}

//class Background: @unchecked Sendable {
final class Background: Sendable {
  private let url = URL(string: "https://wttr.in/?format=3")!

  func request() async -> String {
    let (data, _) = try! await URLSession.shared.data(from: url)
    return String(data: data, encoding: .utf8)!
  }
}

//actor Background {
//  private let url = URL(string: "https://wttr.in/?format=3")!
//
//  nonisolated func request() async -> String {
//    let (data, _) = try! await URLSession.shared.data(from: url)
//    return String(data: data, encoding: .utf8)!
//  }
//}

#Preview {
  TestWebRequest()
}


こうなりますか。

そろそろ消えてほしかった ViewModel。

いや、それとも、

おすすめ定番パターンを Apple はアナウンスしてもよくない ?

いろいろ混乱します。



【Swift6 Concurrency】 新しい @Observable クラスの書き方を知らないやつwww

私です。

将来に向けて厳しくした Xcode 設定でのどうなるかの検証です。


サンプルコード。

最もよくある非同期処理のパターンの一つだと思います。

ただ WEB リクエストしてそれを表示するやつ。



👉 【SwiftUI】シンプルに HTTPリクエスト でお天気情報取得 hatena-bookmark

どうなるか。

 

🧑🏻‍💻 Passing argument of non-sendable type 'Requester' outside of main actor-isolated context may introduce data races

一応、動きますが。

こんな警告でました。

Passing argument of non-sendable type 'Requester' outside of main actor-isolated context may introduce data races

なんなんですかね。

なんかおかしいですか。

 

🧑🏻‍💻 Non-final class 'Requester' cannot conform to 'Sendable'; use '@unchecked Sendable'

無邪気に Sendable を継承します。

一行のみの変更です。


class Requester: Sendable {

Non-final class 'Requester' cannot conform to 'Sendable'; use '@unchecked Sendable'

と出ました。

よって、結局、以下の2つのどちらかで警告は消えます。


final class Requester: Sendable {


class Requester: @unchecked Sendable {

ここら辺でこれだけつまずいてるのだけども。

👉 Sendable | Apple Developer Documentation hatena-bookmark

 

🤔 気持ち悪さ

よくこの @Observable クラスが、

いわゆる「MVVM でいうところの ModelView」

となってるコードをよく見かけます。

これが「アクター境界」を越えていいの ?

 

🤔 関連記事



【SwiftUI】ScrollView + LazyVStack vs Large amounts of data

どれくらいの数のアイテムがストレスなく処理できるか。

ユーザー側の操作に問題がでるようであれば、

ページングなどそれなりの処理が必要です。

まず、シンプルに試す。

以下、1ファイルコピペして Canvas で試せます。

あれ、Gist 埋め込みのスタイル変わったか。まあいいか。



雰囲気、問題ないのは、1000個ぐらいですかね、少なめで。

なんせ、@Queryfetch() が1万個くらいになると重い。

これを元に改修していきます。

データ数は10万件にしてやってみます。

 

🤔 大量データのインポート

バックグラウンドで @ModelActor を使います。


@ModelActor
actor ItemService {
  func generate(size: Int) async {
    for i in 0 ..< size {
      modelContext.insert(
        Item(i: i, s: String(format: "%04d", i))
      )

      if ((i + 1) % 1_000 == 0) {
        try? modelContext.save()
        try? await Task.sleep(for: .milliseconds(1))
      }
    }
    try? modelContext.save()
  }
}

View の init().onAppear() で。


ItemListView()
  .onAppear {
    Task { 
      await ItemService(modelContainer: modelContext.container)
        .generate(size: 100_000)
    }

20秒くらいかかりました。

環境に依りますが、5000件/秒ぐらいです。


👉 SwiftData でのバックグラウンドでの大量データインサート #SwiftUI - Qiita hatena-bookmark

 

🤔 最終要素の表示を検出する

1000 個ずつ読み込みます。

1000個目を表示したら次の1000個を読み込みます。

この記述がシンプルで良さげです。


ScrollView {
  LazyVStack {
    ForEach(items) { item in
      Text("\(item.i) | \(item.s)")
        .onAppear {
          if item == items.last { // *
            print("load next!")
          }
        }
    }
  }
}


LazyVStack って単純に追加を繰り返すだけでもストレスなく動くんですね !

ちょっと、引っかかる感じもあるけどタイミング次第か。

 

🤔 データの分割読み込みと追加

データが1000個以上が遅い重いの原因は「読み込み」です。

シンプルなデータクラスでも遅い。

@Query の引数がダイナミックでない」ようなので、

@Observable クラスで対応します。

最終アイテムが表示されたときに、1000個ずつ追加します。

検索文字列のセットには Predicate を、

抽出のオフセットや限度数、並び順は FetchDescriptor を使います。


 

🤔 @Query で Observe しながら fetchCount() を使う

話しがそれますが、こんな方法も試す。



private static var fetchDescriptor: FetchDescriptor<Item> {
  var fd = FetchDescriptor<Item>()
  fd.fetchLimit = 1
  return fd
}

@Query(fetchDescriptor)  private var zo: [Item]

private var count: Int {
  zo.isEmpty ? 0 : try! modelContext.fetchCount(FetchDescriptor<Item>())
}

 

🤔 スレッドの切り替えとスレッド間の受け渡し

いまいち馴染めず使いこなせないのが @MainActor

Strict Concurrency Checking を Complete と最も厳しくしておいて進みます。


てか、自在に Swift バージョンを乗り越えての スレッド自在に使えるやつがどれくらいいるのか ?

そもそも、言語側は、開発側が意図的にスレッドの切り替えをさせない方向で進んるように見えるのだが。