既存AndroidアプリでのGraphQL/Apollo Kotlinの部分的導入

こんにちは、カンカクでAndroidエンジニアをやっている @haru067です。最近好きな寿司は、鯖です。

前回の記事では、COFFEE Appでの新規機能の開発にあたり、Jetpack Composeを活用した話をしました。 ここでは特に言及しなかったのですが、Jetpack Composeの他にもう一つ技術的な試みがありました。それがGraphQLの導入です。

そこで今回は既存のAndroidアプリに対して、新規機能の開発でGraphQLを導入した話をしたいと思います。

GraphQLとは

GraphQLは、Web API向けのクエリ言語(及びそのランタイム)です。 よく対比されるのはREST APIですが、GraphQLには、

  • スキーマに基づいてサーバー/クライアント間で一貫した型が利用でき
  • 複数のリクエストを送ることなく一度のリクエストで
  • 必要なデータのみを過不足無く

取得できる、といったメリットがあります。

AndroidにおけるGraphQLのクライアントライブラリで有名なものは、 Apollo Kotlin(以降、Apollo)でしょうか。2021年末にはv3.0.0がリリースされ、Kotlin Nativeのサポートやキャッシュの改善など、様々な機能が追加されました。COFFEE AppでもApolloを使用しており、この記事でもApolloの利用を前提として話を進めます。

導入の経緯

我々の場合では、どちらかと言えばサーバー側の嬉しさが先にあって、クライアント側のエンジニアでも検証した結果、良さそうだね、という流れでした。クライアント側の都合に合わせたAPIを設計する際には、サーバー側のエンジニアもその都合を理解する手間が発生しがちですが、GraphQLでは必要なものを予め用意しておけば、あとはクライアント側のエンジニアで自由に取得してね、という形にできるため、サーバー側のエンジニアの負担が減ると期待していました。

例えば、COFFEE Appでは店舗の情報を表すデータとして、ShopShopSummaryOrderShop、といった似通った型が複数存在し、混乱の元になっていました。(これはアプリの画面毎に必要な情報が微妙に異なり、それぞれで不要な計算を避けるためです。)こういった場合にGraphQLでは必要なものだけを取得するため、その辺りがシンプルに表現できて嬉しいはず、という期待がありました。

タイミングとしてもちょうど良かった

既存アプリにGraphQLを導入するにあたって、REST APIで取得する箇所とGraphQLで取得する箇所が混在するのはなるべく避けたく、そう考えたときに、(機能的に独立性のある)新規開発での導入は、「移行」ではなく「追加」によって実装できるため、タイミングとしては最適でした。

導入方針

ここからはAndroidの話です。 Apolloではクエリを書くと、それに基づくデータクラスが自動生成されます。

例えば次のようなクエリがあったとき、

query LoungeReservation($id: Int!) {
  reservation(id: $id) {
    id
    shop {
      name
      brand {
        name
      }
    }
  }
}

次のようなデータクラスが生成されます。

public data class Reservation(
  public val id: Int,
  public val shop: Shop,
)

public data class Shop(
  public val name: String,
  public val brand: Brand,
)

public data class Brand(
  public val name: String,
)

GraphQLではクライアント側で必要なデータを自由に取得できるため、UI(画面)に合わせてクエリを書き、Apolloが生成したデータクラスをそのままUIステートとして扱うことが多いように思います。これにはレスポンスとUIステートのマッピングを行う手間が削減できるというメリットがあります。

しかし、今回の実装ではUIステートを別途定義し、Apolloが生成するデータクラスを一度変換した上で利用することにしました。

// こうではなく
@Composable
ReservationText(reservation: Reservation) {
  Text("予約店舗: ${reservation.shop.brand.name} ${reservation.shop.name}")
}

// こうする
@Composable
ReservationText(uiState: ReservationUiState) {
  Text("予約店舗: ${uiState.brandName} ${uiState.shopName}")
}

data class ReservationUiState(
  val brandName: String,
  val shopName: String,
) {
  companion object {
    fun from(reservation: Reservation): ReservationUiState 
      return ReservationUiState(
        brandName = reservation.shop.brand.name,
        shopName = reservation.shop.name,
      )
    }
}

こうした理由は、大きく3つありました。

柔軟性:GraphQLとREST APIをうまく共存させたい

今回は新規機能の開発であり、データ取得の大半はGraphQLで完結すると想定していました。しかし、GraphQLとREST API、それぞれのデータソースから取得した値を結合したい、といったユースケースが出たらどうだろうか、今は無くても今後出てくる可能性があるかもしれない、と考えたときに、全体方針として抽象化を1段挟んでおいたほうが安全だろう、としました。

拡張性:複雑なUIステートも書けるようにしておきたい

(新)Guide to app architectureでも言及されていますが、UIステートの値から算出される値をUIステート内に定義する、というパターンがあります。

例えば、COFFEE Appではラウンジの予約画面で、入力項目を埋めた場合に確認のUIを表示したい、というケースがありました。

コードで表すと次のような感じです。

 data class ReservationUiState(
  val selectedCheckinId: String?,
  val selectedSeatId: String?,
   ...
) {
  val isConfirmSheetVisible: Boolean
    get() = selectedCheckinId != null && selectedSeatId != null

  ...
}

こうした処理は、Apolloが生成するデータクラスを拡張することでも実現できますが、そこまでするなら別途UIステートを定義してしまったほうが良いよね、という結論に至りました。

依存性:UI層がData層に依存してほしくない

前に述べたように、実装量の面ではApolloが生成するクラスをそのまま利用したほうが良いですし、GraphQLらしい作りと言えるでしょう。しかしそれはApolloへの依存をUIが持つことに他なりません。1からアプリを作る場合には、依存を許容することが多いと思いますが、今回は既存アプリへの導入であり、REST APIと共存する部分的な活用でした。それゆえGraphQLはあくまでREST APIと並んだ存在であり、UIから見たときには、それらが隠蔽されている、という方針を重視しました。実装の手間と依存、どちらを取るかという話で、これは最後まで迷ったところでした。

実際、使ってみて

想定と現実

まず、「必要なものを予め用意しておけば、あとはクライアント側のエンジニアで自由に取得してね、という形にできる」という想定には、完璧なスキーマが定義されている、という前提があるように思いました。 例えば、「リストの0件はnullで表現されるのか、空のリストで返ってくるのか」「ここは何故nullableになっているのか。実装上仕方なくなのか、手違いなのか」といった確認の往復は、いざクライアント側のエンジニアが実装に入ったタイミングで実際にありました。スキーマが成熟していけば徐々には減るとは思うのですが、コストとしては確実に存在していて、APIをレビューするのか、スキーマをレビューするのか、という差の話だと思いました。

あとはよくある話として、「クライアント側のエンジニアで値を自由に取得できる」は負荷観点でヤバいクエリが叩けるのでは?というのがあります。これについてはPersisted Queryという仕組みがあり、登録したクエリを叩くことで、無秩序なクエリが実行されるのを防ぐことができます。今回の実装ではPersisted Queryを使用しなかったのですが、代わりにある程度実装が固まったところで、関係者を集めてクエリをレビューする時間をとりました。これはバックエンド-フロントエンド間の認識を合わせる意味もありましたが、iOS-Android間で妙に異なるクエリがないか確認することで、実際片方のOSで実装漏れが見つかる、といった副次的な効果もありました。

Apollo Kotlinについて

わりとKotlin/Androidフレンドリーというか、Androidで利用することを想定してしっかり作られている印象を受けました。まずコルーチンに対応しています。大事ですね。 次に(一部限定的ではありますが)マルチプラットフォームにも対応しています。これは人によってはありがたいかもしれません。 あとはキャッシュ周りが良い感じです。SQLiteのキャッシュをSingle Source of Truthとする ことができて、キャッシュ→ネットワークとFlowで複数回データをemitするようなAPI も最近追加されました。これはイマドキなアーキテクチャを作っていく上でありがたいと思います。また、(効率のよい)キャッシュの保存もID指定を少し足してあげる程度 で実装できます。気になる点としては、SQLiteのキャッシュが蓄積される問題があって、この辺りが解消されるといいな、という期待があります。

以上、レポっす

というわけで、GraphQLのお話でした。アプリが実際に動いている様子を見たい方は、COFFEE Appをダウンロードしてみて下さい!

あとは、最後にお決まりのやつを貼っておきます。よろしくお願いします。