🚀

PointFreeのSharingGRDBをベースにSharingFirestoreを作ってみた

に公開
1

Point-FreeのSharingライブラリの機能をFirebase Firestoreに拡張した「SharingFirestore」というライブラリを作ってみました。

https://github.com/bitkey-oss/sharing-firestore

SharingFirestoreとは?

Firestoreって便利なんですけど、SwiftUIと組み合わせようとすると大変じゃないけどボイラープレートが多かったりして意外とめんどう。 特にaddSnapshotListenerの管理とか、それを適切にViewModelで状態管理するとか…ちょっとめんどう。

そんな怠惰なモチベで作ったのがこのライブラリで、SharingFirestoreを使えばSwiftUIやUIKit、そして@Observableとの統合がうまく機能してFirestoreの変更を自動的に検知してUIを更新してくれます。addSnapshotListenerや状態管理のためのコードを書く必要がないのが嬉しいポイント。

例えば、従来のコードだとこんな感じ:

// 従来のFirestoreコード
class TodosViewModel: ObservableObject {
  @Published var facts: [Fact] = []
  private var listener: ListenerRegistration?

  init() {
    startListening()
  }

  private func startListening() {
    let database = Firestore.firestore()
    listener = database
      .collection("facts")
      .order(by: "count", descending: true)
      .addSnapshotListener { [weak self] snapshot, error in
        if let error = error {
          print("Error: \(error)")
          return
        }
        guard let documents = snapshot?.documents else { return }
        self?.facts = documents.compactMap { doc in
          try? doc.data(as: Fact.self)
        }
      }
  }

  deinit {
    listener?.remove()
  }
}

struct FactsView: View {
  @StateObject private var viewModel = TodosViewModel()

  var body: some View {
    List {
      ForEach(viewModel.facts) { fact in
        Text(fact.body)
      }
    }
  }
}

これが、SharingFirestoreを使うとこんなにシンプルに!

// SharingFirestoreの場合
@SharedReader(
  .query(
    configuration: .init(
      path: "facts",
      predicates: [.order(by: "count", descending: true)],
      animation: .default
    )
  )
)
var facts: IdentifiedArrayOf<Fact>

たったこれだけで、Firestoreのコレクションを監視して、データが変更されるたびに自動的にUIを更新してくれます。めっちゃ便利。

基本的な使い方

セットアップ

まずはおなじみのFirebaseの設定から。アプリの起動時に以下のようにFirestoreのインスタンスを設定します:

@main
struct MyApp: App {
  init() {
    prepareDependencies {
      FirebaseApp.configure()
      $0.defaultFirestore = Firestore.firestore()
    }
  }

  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

データの読み込み (クエリ)

読み取り専用でデータを取得するには@SharedReader.queryを使います。こんな感じ:

struct FactsView: View {
  @SharedReader(
    .query(
      configuration: .init(
        path: "facts",
        predicates: [.order(by: "count", descending: true)],
        animation: .default
      )
    )
  )
  var facts: IdentifiedArrayOf<Fact>

  var body: some View {
    List {
      ForEach(facts) { fact in
        Text(fact.body)
      }
    }
  }
}

これでFirestoreからデータを取得し、変更があれば自動的に画面が更新されます。

データの双方向同期

データの読み書き両方をしたい場合は@Shared.syncを使います:

struct TodoListView: View {
  @Shared(
    .sync(
      configuration: .init(
        collectionPath: "todos",
        orderBy: ("createdAt", true),
        animation: .default
      )
    )
  )
  private var todos: IdentifiedArrayOf<Todo>

  @State private var newTodoText = ""

  var body: some View {
    VStack {
      HStack {
        TextField("New Todo", text: $newTodoText)
        Button("追加") {
          $todos.withLock {
            $0.insert(Todo(memo: newTodoText, completed: false), at: 0)
          }
          newTodoText = ""
        }
      }.padding()

      List {
        ForEach(todos) { todo in
          HStack {
            Image(systemName: todo.completed ? "checkmark.square" : "square")
            Text(todo.memo)
          }
          .onTapGesture {
            $todos.withLock {
              if let index = $0.firstIndex(where: { $0.id == todo.id }) {
                $0[index].completed.toggle()
              }
            }
          }
        }
      }
    }
  }
}

ここで最も素晴らしいのは、ローカルでのデータ変更が自動的にFirestoreに反映され、さらに他のデバイスでの変更も自動的に同期されるという点です。

Dynamic Query - あらさんおすすめ、めちゃくちゃ便利機能

さて、ここからが本当の本命です!SharingFirestoreの中でも特に便利な機能、「Dynamic Query」をご紹介します。ユーザーの入力や条件に応じて動的にクエリを変更できるんです!

従来のFirestoreでは、動的な検索機能を実装する際、addSnapshotListenerの購読開始・停止のタイミング管理や、検索条件が変わるたびにリスナーを解除して再設定する必要がありました。

SharingFirestoreのDynamic Queryを使えば、これら全ての面倒な管理をお任せできるのがいい:

struct SearchableView: View {
  @State.SharedReader(value: []) private var items: [Item]
  @State private var searchText = ""

  var body: some View {
    VStack {
      TextField("検索", text: $searchText)
        .textFieldStyle(RoundedBorderTextFieldStyle())
        .padding()

      List {
        ForEach(items) { item in
          Text(item.name)
        }
      }
    }
    .task(id: searchText) {
      await updateQuery()
    }
  }

  private func updateQuery() async {
    do {
      try await $items.load(.query(ItemsQuery(searchText: searchText)))
    } catch {
      print("検索中にエラーが発生しました: \(error)")
    }
  }
}

private struct ItemsQuery: SharingFirestoreQuery.KeyRequest {
  let searchText: String

  var configuration: SharingFirestoreQuery.Configuration<Item> {
    .init(
      path: "items",
      animation: .default
    )
  }

  func query(_ db: Firestore) throws -> Query {
    var query = db.collection(configuration.path)

    if !searchText.isEmpty {
      // Firestoreの制限内で部分一致検索を実装
      query = query.whereField("name", isGreaterThanOrEqualTo: searchText)
                   .whereField("name", isLessThanOrEqualTo: searchText + "\u{f8ff}")
    }

    return query.order(by: "name").limit(to: 20)
  }
}

この実装の素晴らしい点は、検索条件が変わるたびに自動的にデータを再取得し、UIを更新してくれるところです。簡単なクエリの要件であればこれだけでも十分だったりするかもしれません。

このアプローチでは動的にクエリを組み立てFirestoreから取得できる点できます。全データを取得してクライアント側でフィルタリングするのではなく、Firestoreのフィルタリングを活用できるので、パフォーマンスも向上します。

より高度な使い方

@Observableモデルとの統合

Swift界隈で話題の@Observableマクロとも相性バッチリです!特に、Firestoreへの直接アクセスと@SharedReaderによるクエリを組み合わせることで、より柔軟な実装が可能になります:

@Observable
class TodosModel {
  @ObservationIgnored
  @SharedReader(
    .query(
      configuration: .init(
        path: "todos",
        predicates: [
          .isEqualTo("completed", true),
          .order(by: "createdAt", descending: true)
        ],
        animation: .default
      )
    )
  )
  var completedTodos: [Todo]

  @Dependency(\.defaultFirestore)
  private var firestore

  func addTodo(memo: String) async throws {
    // 直接Firestoreへの書き込みでスロットリングなどの制御が可能
    try await firestore.collection("todos")
      .addDocument(from: Todo(
        memo: memo,
        completed: false,
        createdAt: Date()
      ))
  }

  func toggleCompletion(for todo: Todo) async throws {
    // トランザクションやバッチ書き込みも簡単に
    guard let documentId = todo.documentId else { return }
    try await firestore.collection("todos")
      .document(documentId)
      .setData(["completed": !todo.completed], merge: true)
  }
}

これで、データの読み取りは@SharedReaderによる自動同期を利用しつつ、書き込みは直接Firestoreを使って細かい制御ができちゃいます。書き込みに関するスロットリングやエラーハンドリング、トランザクション処理が必要な場合にも柔軟な対応ができます。

カスタム同期リクエスト

より複雑なユースケース向けに、カスタムリクエストを作成することもできます:

struct UserTodos: SharingFirestoreSync.KeyCollectionRequest {
  typealias Value = Todo
  let userId: String

  var configuration: SharingFirestoreSync.CollectionConfiguration<Todo> {
    .init(
      collectionPath: "users/\(userId)/todos",
      orderBy: .desc("createdAt"),
      animation: .default
    )
  }
}

@Shared(.sync(UserTodos(userId: currentUserId)))
private var userTodos: IdentifiedArrayOf<Todo>

これで、さまざまなユーザーのTodoリストを簡単に管理できます。

結論

FirestoreとSwiftUIをいい感じに統合するのはちょっとめんどくさいというモチベで作ったライブラリです。SharingFirestoreを使えばその面倒さが軽減します。

特にDynamic Queryの機能は、検索機能や動的フィルタリングを実装するときに本当に便利です。

GitHubでSharingFirestoreをチェックしてください。詳細なドキュメントやExample Appも用意してるので見てみて。あとスターください。

https://github.com/bitkey-oss/sharing-firestore

ちなみに

@FirebaseQueryというプロパティラッパーがFirebaseFirestoreに入ってるのでシンプルなクエリがしたい場合にはこれでも良いです、Dynamic Queryみたいなことがしたい場合にはSharingFirestoreを使うと良いです。

1
Bitkey Developers

Discussion