🍫

Apolloキャッシュでステート管理は難しかった

2023/12/05に公開

これはGLOBIS Advent Calendar 2023 5日目の記事です。

「GLOBIS 学び放題」はビジネスナレッジを、基礎から実践まで、いつでもどこでも学べる動画サービスです。iOS版も提供しております。

使用技術としてGraphQLとそのクライアントライブラリであるApolloを使用しています。今回はApolloのキャッシュ機能の内容とそのままステート管理できないか挑戦したところ難しかったという話を今回お伝えしたいです。

Apolloのバージョンは1.2.0を使用しています。

Apolloキャッシュを利用する経緯

「GLOBIS 学び放題」はオフライン対応もしています。例えば旅行中の飛行機の中でも事前にダウンロードした動画を視聴したいニーズがあります。そのためオフライン状態でも視聴できるようなキャッシュ構造にしています。そのため今まではGraphQLから取得したデータをRealmのModelに変換して保存していました。ただGraphQLで定義されたスキーマ定義とModelの構成はほぼ一緒なのに変換するためのボイラーテンプレートの記述が多く、カラム追加によるmigrationを忘れてクラッシュするケアレスミスが発生しました。

そこでApolloキャッシュを利用することで取得したデータをそのままキャッシュできれば一つステップが減るのでApolloキャッシュの本格的な導入をしました。ただしオフライン対応もあるのでキャッシュの保存先はSQLiteでプラグインを使用しています。

結果的にこの目的は成功してRealmに保存していたコード量が多く削減されてApolloのキャッシュに寄せることが出来ました。

さらにステート管理に活かせるのでは期待したが

元々の目的は上記だったのですがApolloのキャッシュをそのままステート管理に使えるのではという意見がチーム内から発生しました。Web側ではApolloでステート管理をする記事がいくつかありましたので。

その一歩としてApolloキャッシュではスキーマごとに分解して保存できる機能を試しました。

query GetFavoriteBook {
  favoriteBook { # Book object
    id
    title
    author {     # Author object
      id
      name
    }
  }
}

説明するとfavoriteBook のオブジェクトでidが同じであれば別のQueryで取得したfavoriteBook のオブジェクトがmergeされるという機能です。そうすることでキャッシュの最新に反映できるという狙いです。実はこの機能はデフォルトでオンになっているわけではなく、以下のような設定することで反映がされます。

enum SchemaConfiguration: ApolloAPI.SchemaConfiguration {
  static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? {
    guard let id = object["id"] as? String else {
        return nil
    }

    return CacheKeyInfo(id: id) # idが一意ではない場合はここにTypeも指定することもできます。
  }
}

SchemaConfiguration.swift はApolloの機能を使って生成されたコードで、その内容を上書きすれば使えます。上記の設定をすることで今まではQueryとレスポンスが1対1の関係で保存されていたのが、レスポンスを分解してidごとにレコードを作成して保存されるようになります。

分かりやすく説明すると以下のようなデータ構造で保存されます。

id: 123 <- favoriteBook
value: {id: 123, title: "Book name", author: 124}

id: 124 <- Author
value: {id: 124, name: "田中"}

このようにレスポンスを分解することでデータを効率的に更新できるので、ApolloではこのデータをNormalizing responsesと読んでいます。

しかし、最終的にはこの仕組みを取り入れませんでした。

なぜなら今までGraphQLのQueryとして取得してましたが、以下のような書き方に変える必要があったからです。

client.store.withinReadTransaction({ transaction -> FavoriteBook in
  let data = try transaction.readObject(
    ofType: FavoriteBook.self,
    withKey: 123
  )

  return data
})

ここで重要なのはwithKeyで指定された123です。データの正規化をした場合はこのKeyを管理をしないといけないからです。つまりこのKeyをUserDefaultsやCoreDataなどで別途永続化して管理する必要があるので管理コストが増えるからです(私はGraphQLのQueryで取得もKeyを指定できるやり方も両方出来ると途中まで勘違いしていました)

個人的にはかなり微妙なデータアクセスです。今まではGraphQLのQueryによって宣言的にキャッシュを取得出来ていたのに、急に手続き的な作法に変わってしまったので。
そのためレスポンスデータの正規化は諦めてQueryとレスポンスデータを1:1で紐付ける既存のやり方に戻しました。

iOSでApolloを使ったステート管理は難しい

上で書いたようにレスポンスデータの正規化は今まで取得方法と比べて変更点が多く、Keyの管理の手間も増えるので対応を見送りました。

また後から気づいたのですがiOSの方にはReact版にあるステート管理のセクションが無いのでもともと難しかったと思います。
そのためiOS版のApolloではキャッシュ機能はあくまでレスポンスキャッシュなのであくまでそこにフォーカスするべきと再認識しました。

そもそもですがレスポンスキャッシュとステート管理はレイヤーが違う話でした。例えが微妙かもしれませんがOSI参照モデルというとステート管理はアプリケーション層の話なのにレスポンスキャッシュはもっと下の層の話です。
Single Source of Truthにしたいよね、だから一緒に管理したいという話があり目的がそれていきました。

最後に

実はApolloでステート管理をしてみるというのはReact側では進んでいるみたいです。例えば以下の記事などです。

https://zenn.dev/hakushun/articles/ad022d56b205e0

しかし、今後もiOSの文脈では難しいのでというのが個人の意見です。

理由としては以下になります

  • Webと違ってキャッシュのライフサイクルが長い。アプリは立ち上げてユーザーがアプリをキルしない限り残る。またブラウザのようにReloadボタンが提供されている訳では無いのでユーザーが気軽にデータの最新化が出来ない。グロービスのアプリのようにディスクキャッシュさせるケースもある。
  • ステートの範囲が大きい。Webでは画面遷移によってステートも分割しやすいがアプリでは画面分割によってステートの分割がしにくい。
  • GraphQLの機能とステート管理が密結合しているので、仮に今後gRPCやRESTにしたい場合にステート管理まで影響が出る

Webでは進んでいることを単純にiOSでも当てはめようとすると難しいという実感しました。

https://recruiting-tech-globis.wraptas.site/

Discussion