nashcft's blog

時々何か書く。

Klock の今

Kotlin multiplatform 向け datetime ライブラリの Klock は昨年の8月にリリースされた 4.0.10 以降リリースされていないように見える。

参考: https://mvnrepository.com/artifact/com.soywiz.korlibs.klock/klock

実際は現在もメンテが継続されており、 korlibs-time という名前で公開されている。現時点で最新バージョンは 5.4.0

https://mvnrepository.com/artifact/com.soywiz.korge/korlibs-time

これは Klock が含まれる Kotlin 製ゲームエンジン KorGE の 5.0 開発段階で仕切り直し的な状況が発生しライブラリ群の構造が整理されたことによる。そのため 5.0.0 - 5.1.0 の間は korge-foundation という全部入りライブラリとして公開されていた。その後また機能毎に module をばらして公開するかということになり現在再び分離中という状況である。 korlibs-timekorlibs-template と共に 5.2.0 から独立した module としてリリースされた。

github.com

また、 Korlibs 系の module 群は project の構成をもっとシンプルにするために別の repository に移すかという計画も進められており、暫くしたらメンテは以下の repository で行われるようになると思われる。

github.com

github.com

余談

"仕切り直し的な状況が発生し" と書いたが、何があったかというと2023年の7月に KorGE の作者が引退宣言をし暫く有志によるメンテナンスモードになるということが起こっていた。

web.archive.org

https://github.com/korlibs/korge/blob/v4.0.10/README.md

In maintenance mode. Soywiz (the founder/primary lead/developer) has retired from maintaining this project (2023/07/27).
Currently this project is only being maintained by a handful of people who may/may not be as familiar with the architecture.
Do not expect any major updates in the future and expect less support if you do decide to use this project.
We are open for pull requests if there are any issues you'd like to fix yourself.

その後作者に心境の変化があったようで9月中頃から開発に復帰し KorGE 5 のロードマップを発表して KorGE 復活、という流れで現在に至る。

web.archive.org

前掲の記事2つは KorGE の公式ブログから削除されているので wayback machine から引っ張ってきている。

Dependabot: 設定の備忘録

最近 dependabot でライブラリ更新を自動化している repository の dependabot.yml を手入れしていたのでそのメモ。

Gradle: settings.gradle で依存取得先を設定している場合に更新検知できないライブラリがある

2024-02-19 追記

以下の修正によって dependencyResulutionManagement の設定は読まれるようになった:

github.com

追記おわり

Project の依存解決の設定を settings.gradledependencyResolutionManagementpluginManagement で記述している場合、一部のライブラリの更新検知ができないということが起こる。 Log を見てみると maven-metadata.xml の取得先として Maven Central (plugin の場合はこれに加えて Gradle plugin portal) しか参照しておらず、たとえば AndroidJetpack library など Google Maven Repository に配置されているライブラリはバージョン情報の取得に失敗していることがわかる。

# e.g. AndroidX Core
updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> Checking if androidx.core:core-ktx 1.12.0 needs updating
  proxy | YYYY/MM/DD hh:mm:ss [xxxx] GET https://repo.maven.apache.org:443/maven2/androidx/core/core-ktx/maven-metadata.xml
  proxy | YYYY/MM/DD hh:mm:ss [xxxx] 404 https://repo.maven.apache.org:443/maven2/androidx/core/core-ktx/maven-metadata.xml
updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> Latest version is 
updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> Requirements to unlock update_not_possible
updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> Requirements update strategy 
updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> No update possible for androidx.core:core-ktx 1.12.0

build.gradle で依存解決の設定を記述している場合は設定した repository が maven-metadata.xml の取得先に追加されていて期待通りにバージョン情報を取得できているので参照先の取得のために settings.gradle を読んでいないのだと思われる。 Dependabot のコードを何となく眺めた感じでもそうっぽい気がする。根拠にしてるのは以下のファイル:

github.com

これに関する issue はすでに報告されている:

github.com

以下の issue のコメントに workaround が示されており、 private package へアクセスするための設定に使われる registries にアクセスしたい maven repository について追記してそれを参照するようにすれば metadata の取得先に追加される。

github.com

docs.github.com

version: 2
# ↓を追加
registries:
  maven-google:
    type: maven-repository
    url: "https://dl.google.com/dl/android/maven2/" # https://docs.gradle.org/current/javadoc/org/gradle/api/artifacts/dsl/RepositoryHandler.html#google--
updates:
  - package-ecosystem: "gradle"
    directory: "/"
    # ↓を追加
    registries:
      - maven-google
    # ...

設定を追記した後の実行ログは以下のような感じ。 maven-metadata.xml の取得時のアクセス先に https://dl.google.com:443/dl/android/maven2/ が増えて、そちらで取得成功しておりバージョン情報の評価ができていることがわかる。

 updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> Checking if androidx.core:core-ktx 1.12.0 needs updating
   proxy | YYYY/MM/DD hh:mm:ss [xxxx] GET https://repo.maven.apache.org:443/maven2/androidx/core/core-ktx/maven-metadata.xml
   proxy | YYYY/MM/DD hh:mm:ss [xxxx] 404 https://repo.maven.apache.org:443/maven2/androidx/core/core-ktx/maven-metadata.xml
   proxy | YYYY/MM/DD hh:mm:ss [xxxx] GET https://dl.google.com:443/dl/android/maven2/androidx/core/core-ktx/maven-metadata.xml
   proxy | YYYY/MM/DD hh:mm:ss [xxxx] 200 https://dl.google.com:443/dl/android/maven2/androidx/core/core-ktx/maven-metadata.xml
 updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> Latest version is 1.12.0
 updater | YYYY/MM/DD hh:mm:ss INFO <job_xxxxx> No update needed for androidx.core:core-ktx 1.12.0

GitHub Actions: repository local な composite action 内で使用している action の更新検知をしたい

Repository 内で composite action を定義している場合、その中で使われている action の更新を検知するには / で設定しているのとは別にそれぞれの composite action に対して設定を書く必要がある。

参考:

qiita.com

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/" # これは .github/workflows/ 以下の yaml が対象になる
    # ...
  - package-ecosystem: "github-actions"
    directory: "/.github/actions/my-action1"
    # ...
  - package-ecosystem: "github-actions"
    directory: "/.github/actions/my-action2"
    # ...

directory の指定にワイルドカードを使えるようにする feature request はあるにはあるけど何年も動いてないのであまり期待できなそう:

github.com

おわりに

セットアップまで含めて Renovate 使うのとどっちが楽なんだろ

READ_MEDIA_IMAGES と READ_MEDIA_VIDEO の使用に制限がつくらしい

API level 33 からアプリ外のメディアファイルへのアクセスに必要な permission として READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO が追加された*1が、これらの内 READ_MEDIA_IMAGESREAD_MEDIA_VIDEO の使用に関するポリシーの追加が 2023-10-25 付で発表されていた。

support.google.com

更新されたポリシーの preview は以下のページから確認できる。

support.google.com

これらのページによると、画像や動画のデータは "personal and sensitive user data subject to Google Play's User Data policy" であるためあまり無闇に対象となる permission を使わせないようにしたいので、デバイス上の画像や動画への広範なアクセスがアプリの主たる機能に直接関係するような場合だけこれらの permission を要求していいことにするよ、そのために READ_MEDIA_IMAGESREAD_MEDIA_VIDEO を要求するアプリに関しては使用が妥当なものかを審査するよ、とのこと。また、ファイル選んでアップロードするみたいなユースケースは上記の審査には通らないので system で提供している photo picker を使って permission 無しでのアクセスをするようにしてほしいということだそう。細かい話は以下のヘルプページの FAQ で色々書かれているのでそちらを確認してほしい。

support.google.com

スケジュール的にはだいたい来年中に対応を済ませましょうという感じ。

メジャーな対応ケースとしては、アプリに画像や動画の投稿・アップロード機能があって、そのために自作の picker を使っているとかで API level 33 以上で当該 permission を要求している場合に、 picker を Android が提供している photo picker に置き換えて permission 要求の記述を削除するというものになるだろう。 Photo picker の使い方は activity result contract を作って投げるだけで簡単なので、公式ドキュメントに目を通せばその他の事項含めて割とすぐに置き換えることはできると思う。

developer.android.com

余談だが複数選択をした際に photo picker から返ってくるファイルの順番が選択順ではないので、それだと困るという人は以下の issue に vote しておくと早く対応してくれるかもしれない。

Kover 触ったメモ

DroidKaigi 2023 公式アプリなどで触ったので所感などを書き残しておく。この記事の内容は Kover 0.7.3 時点の機能に基づいている。

書いてたら思ったより長くなったので先に結論を書いておくと「簡単なので使う場合は各自でいい感じにやっといてください」になる。

Kover とは

JetBrains が開発している code coverage の toolset。要するに JaCoCo みたいなやつ。今回は Gradle plugin について扱うが他に CLI も存在する。

使用メモ

使い方に関しては解説や要約が必要なほど多機能なわけではないので、何ができるかとかのちゃんとした説明は docs を読んでほしい。

Tasks

  • koverHtmlReport: 計測結果を html で出力
    • 手元で見る時はこれ
  • koverXmlReport 計測結果を xml で出力
    • codecov とかに食わせる時はこれ。
  • koverBinaryReport: 計測結果を binary file で出力
    • そういう形式を要求する何かに食わせる時用 (使ったことあるので具体例を知らない)
  • koverLog: 計測結果を task のログとして出力
  • koverVerify: 設定した rule を満たすかの検査
    • 設定については後述
    • rule を満たさない場合は task が失敗する

Android module に対しては上記の task 名の末尾に build variant が付く。

設定まわり

koverReport の中で記述する。 以下は大雑把な設定項目の説明:

  • filter: 計測対象の追加・除外の設定
    • class の指定に関しては fully qualified な class 名が求められるので package 部分から記述する必要がある
    • wildcard の記述で *** が使えるがこれらの違いは無いとのこと (?)
  • verify: verification task に関する設定
    • 評価する単位 -> 全体、 class 毎、 package 毎
    • OK とする coverage の下限・上限
    • 計測値の基準 (lines / bytecode instructions / branches) と単位 (covered or missed に対する count / percentage)
  • defaults: default task に対する設定
    • filterverify はこの中でも設定可能
    • 他に出力形式毎の諸々の設定をすることが可能
  • androidReports
    • Android module のそれぞれの build variant に対する設定
    • 記述できる設定については defaults と同じ

Coverage report の集約

:foo:bar があったとして、 :foo の方の build.gradle で以下のように :bar への依存を追加すると、 :foo の方で kover task を実行した時に :bar のものとまとめられた coverage report が作成される:

// foo/build.gradle
dependencies {
  kover(projects.bar)
}

kover() で依存に追加される module は 同様に kover gradle plugin を適用する必要がある。また、 report の集約を行う際、 koverReport による設定は各 module のものではなく集約を行う (i.e. kover() による依存の設定を記述した) module のもののみが適用される。

所感

どこで report を集約するか

Kotlin/JVM や KMP で Android を考慮しない module のみから構成される project であれば root project で集約して良いと思う。 Android の考慮が入ると割と悩ましい。というのも root project は build variant を持っていないのでここで default report に build variant を merge させるということができないからである。別の場所で集約するとなると、 application project の場合は app module のような代表的な module が候補として検討できるが、 library project の場合は全てを集約した all-in-one module でも存在しない限りそういう候補は project 内で挙げにくく、 report 集約のためにわざわざ専用の module を作るかと言うとそれも微妙では? と感じる。また application project でも複数の app module を持っている、 product flavor の構成によって一つの build variant で全ての実装に対する coverage report を取得することができないといった場合には同様の課題が発生すると思われる。

現時点 (0.7.3) では複数の build variant に対する report を1つに集約する設定方法みたいなものは無さそうだが、複数の build variant をまとめた custom report variant を作成するという機能が以下の issue で検討されているので、将来的にはなんとかなるかもしれない。でも自分の持っていない build variant を解決できるものなのだろうか...?

github.com

設定の記述で楽をしたいが...

触ってみた感じの印象として今のところ Kover の DSL は単純で習得しやすい一方で素朴な書き方しかできず、素直に書くと楽したくなるところで面倒が発生するなあというものがある。具体的には report の集約を行っている場合、新しい module を追加する度に追加された module に kover gradle plugin を適用するのと集約 module の dependencies に kover(projects.newModule) を追加するのを忘れずに行う必要があるという点。これは地味にダルいし人間のやることなので忘れてしまうこともあるだろうしでどうにかしたいが、現時点では Kover DSL 自体はこれを解決する機能を提供していない。

今年の DroidKaigi 公式アプリでは Kover の設定に関する convention plugin が実装されていたので、そこに上記の課題を何とかするための実装を追加して、 report を集約する module (i.e.:app-android module) 以外では Kover に関して気にしなくて良くなるようにした。

github.com

この追加実装では以下の3つの操作をしている*1:

  1. この convention plugin を適用した module に kover gradle plugin を適用する
  2. この convention plugin を適用した module 以外の module に kover gradle plugin を適用する
  3. この convention plugin を適用した module に対して、 2. の操作を行った module を report の集約対象として依存に追加する

ただし、レビューコメントでも指摘されているようにこのアプローチは Gradle の configuration cache 関連で将来導入されるであろう project isolation と思い切り衝突することをしている。現状これでも configuration cache を効かせられるし、今後このようなユースケースに対して project isolation を守りつつ同様の設定ができるようにになる API が追加される可能性もあるが、あくまで今の内だけの手段として捉えておいた方が良いのではないかと思う。

Configuration cache と project isolation について参考:

ところで multi-module project での設定を簡単にするための機能を追加しようという issue が作成されているので、この設定記述の問題も Kover 側で何とかしてくれそうな雰囲気がある。 Custom report variant で root project から各 subproject の build variant をうまく扱えるようになったら Android project でも root project に設定を全部書いて済ませられるようになるのではないかと期待している。

github.com

とは言え今の時点ではどうすりゃいいのさとなるが、個人的には多少の手間を飲み込んで report 集約の依存の記述は集約場所の build.gradle に直接記述し、 plugin の適用と共通で使える koverReport の設定を convention plugin 化して、 DroidKaigi 2023 公式アプリの AndroidFeaturePlugin.kt みたいな各 module 共通で適用する plugin をまとめて適用する convention plugin に追加して使うくらいがいいんじゃないかなと思う。こうするとおまけとして kover gradle plugin を適用した module 全てに koverReport の設定が入るので module 単位で kover reoprt task を実行した時も集約した report を作る時と同じ設定で動くようになる。 Report ファイルの生成は module 個別に行うことはまずないだろうが、 verification は DSL に module 単位での評価という設定は無いので、 module 単位で基準を満たす or not を見られるようになるのは便利かもしれない... のだが、実は 0.7.3 の時点では全ての report variant 向けとなるはずの koverReport 直下の verify による verification rule の設定が Android build variant に対応する report task には適用されないというバグがあり、 Android project ではこのように設定を共通化しても verification rule が共有できない場合がある*2:

github.com

おわりに

だらだらと Kover を触った所感を書いた。 API がわかりやすく簡単に設定を書けるので、 JaCoCo で自作 merge task を作って頑張ってるようなところではこちらに移行した方が幸せになれるんじゃないかと思う。ただし Android project で flavor まわりの構成が複雑だと現時点ではうまくやるにはまだ機能不足に感じるので、そういう場合は頑張って gradle script を書いてどうにかなることを祈るか、もう暫く様子を見るのがいいかもしれない。

*1:2. と 3. に関して、実際は今回行った実装では考慮不足で module 以外の階層部分 (:core とか) にも操作が適用されてしまい色々無駄が発生しているのだが、結果に影響は出ないからいいやということでサボっている

*2:Default report task には問題の verification rule が適用されるので、 kover gradle plugin を適用する全ての module が同一の build variant を持っているならば、それを default task に merge した上で default report task を使用することで問題を回避し verification rule を共有できる

Kotlin: runCatching と coroutine

内容的には以下の issue で議論されていることの抜粋のようなものだが、つまるところ現状 Kotlin Coroutines と runCatching (より詳細には runCatching の block 内で suspend function を呼んだ場合) の食い合わせが悪い問題に対してどういう対処ができるのかについて、備忘としてまとめておく。

github.com

runCatching は全ての例外を catch する

現時点のコード:

github.com

public inline fun <R> runCatching(block: () -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

runCatchingblock の実行中に発生した例外を全て捕捉し、 Result として返すという振る舞いになっている。 Result.failure(e) は catch した ThrowableResult.Failure で wrap して Result に詰めて返すというもの。

Kotlin Coroutines の cancellation 復習

Kotlin Coroutines では coroutine が cancel された際にシグナルとして現在中断されている箇所で CancellationException を throw し、これが起動中の coroutine 内を通り抜けていく。 runCatching はこの CancellationException も捕捉してしまうので、都度自分で rethrow 処理を実装しないと cancel シグナルの伝搬がそこで止まってしまって上層に cancel されたことが伝わらなくなったり、 cancel 時の振舞いが意図しないものになる可能性があったりといった不都合が出る。

CancellationException の伝搬が上手くいかないとどうなるかについてはそこまで真面目に探していないこともあって今のところ良い感じにまとめられた内容の資料を見つけられていないが、冒頭の issue のコメントや以下のページから雰囲気は掴めると思う。

kotlinlang.org

medium.com

現時点の workaround

じゃあ毎回 CancellationException だけ rethrow するのを書くかというとダルく、特別 cancel された時に実行したい処理を書くなんてことがある場合以外は CancellationException の存在をロジック中で意識させたくないというのが正直なところだと思う。なので現時点では冒頭の issue で提案されているような runCatchingCancellationException の catch/rethrow を追加した関数を定義して使うのが落とし所としては無難だろう:

suspend fun <R> suspendRunCatching(block: suspend () -> R): Result<R> {
  return try {
    Result.success(block())
  } catch (ce: CancellationException) {
    throw ce
  } catch (e: Throwable) {
    Result.failure(e)
  }
}

このアプローチは実際に android/nowinandroid で採用されている。

github.com

ただ冒頭の issue に CancellationException の中でも timeout 由来のもの (TimeoutCancellationException) は別で考慮した方がいいんじゃないか? という意見があり、何をどう catch するかは検討の余地がありそう。

また runCatchingThrowable を catch した所で ensureActive() を呼ぶのはどうかという案も出されているが、 ensureActive() を呼ぶには CoroutineScope, CoroutineContext, または Job のいずれかが見えている必要があり使用に制限が出てくるので、個人的にはあまり筋が良いとは感じない。

メモ

Apollo Kotlin: エラーハンドリングのプラクティスと union type とコード生成方式

以前 Android で GraphQL あまり嬉しくないのでは的な tweet をいくつかしていて、その中でエラーの扱いに困るなというものがあった。

https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format

大雑把にいうと top level の errors field は schema に載らない (= コード生成の対象にならない) ため結局エラー用の API document みたいなものを用意する必要が出てくる上に、アプリケーション由来の情報を入れるとなると errors.extensions field を使うことになるが、これは実質任意の構造の object なので Kotlin 上では Map<String, Any?>? の中を探るみたいな扱いになり面倒というものだった。

アプリケーション由来のエラーは schema で表現する

その後チームでエラーハンドリングどうしましょうを考える機会があって調べたり教えてもらったりで以下の記事を読んだ。

productionreadygraphql.com

sachee.medium.com

techblog.gaudiy.com

これらの記事を要約すると top level の errors field はシステム由来のエラーのみを扱うようにし、アプリケーション由来のエラー*1は schema 上で型として表現すべし、一箇所で複数種類のエラー状態の発生が起こりうる場合は union type を使おう、みたいなことが書いてある。この考え方ならそれぞれの operation に対してどのようなエラーが起こりうるのかやそれをクライアントでどのように扱うかも schema で管理共有できるようになるしクライアントサイドでもビジネスロジックに関わるエラーを安全に扱えるようになるので便利、じゃあこれでいきましょうとなり、この方針に沿って schema の構築が進められることになった。

Apollo Kotlin で union type を union type らしく使うにはコード生成方式を変える

デフォルトの生成モデルの問題点

ところで Apollo Kotlin で何も考えずに union type の値を取得しようとするとあまり嬉しくなくて、これがどういうことかはコードを見た方がわかりやすいと思うので以下で示していく。コード例としては上でリンクした GraphQL の union type のページに例として載っている query を使う (若干変更を加えている):

# schema
type Query {
  search(text: String!): [SearchResult!]!
}

union SearchResult = Human | Droid | Starship # それぞれの具体型の定義は省略

# query
query Search($text: String!){
  search(text: $text) {
    ... on Human {
      name
      height
    }
    ... on Droid {
      name
      primaryFunction
    }
    ... on Starship {
      name
      length
    }
  }
}

この query に対して生成されるコードは、モデル部分だけ取り出すと以下のような構造になる:

data class Data(
  val search: List<Search>,
) : Query.Data

data class Search(
  val __typename: String,
  val onHuman: OnHuman?,
  val onDroid: OnDroid?,
  val onStarship: OnStarship?
)

data class OnHuman(
  // ...
)

data class OnDroid(
  // ...
)

data class OnStarship(
  // ...
)

見てわかるように、 response として与えられるリストの要素を表すモデル Search は inline fragment で指定したそれぞれの型が個別の nullable な property として定義され、また型同士も独立した関係となっている。 Schema の定義上 SearchResult は non-null なので Human, Droid, Starship (Kotlin 上では OnHuman, OnDroid, OnStarship) のいずれかは存在するのだが、モデルとしては実際にインスタンスが作られるまでどれが入ってくるかわからないのでそれぞれの property は nullable で表さざるを得ないという感じ。これを扱うとなるとそれぞれの property の null check を行って non-null だったら取り出して云々、みたいになるが、 GraphQL の schema 上は大丈夫と言っても Kotlin 上ではそうとはわからないコードになるので見た目上安心度が低くなんか泥臭い雰囲気のものになってしまう。

val search = // query 実行して response.data.search 取ってくる

return search.map {
  when {
    it.onHuman != null -> // ...
    it.onDroid != null -> // ...
    it.onStarship != null -> // ...
  }
}

Response-based なコード生成方式

こういうのは Kotlin なら sealed class/interface として扱いたくなるものだし何かやり方あるでしょと調べてみるとあった。 Apollo Kotlin v3 から追加された機能で、コード生成方式を responseBased に指定することで上記のような inline fragment による field 指定を一つの property にまとめる形でモデル生成をしてくれるようになるというものだ。

www.apollographql.com

コード生成方式の指定は build.gradleapollo block 内で codegenModels に値をセットすることでできる。デフォルトの生成方式にも operationBased という名前がついていて明示的に指定することもできるが、実際には responseBased を指定する時しか codegenModels は使わないだろう。

apollo {
  service("service") {
    // ...
    codegenModels.set("responseBased")
  }
}

そして responseBased で生成した query のモデル部分は以下のような構造になる:

data class Data(
  val search: List<Search>,
) : Query.Data {
  sealed interface Search {
    val __typename: String

    companion object {
      fun Search.asHuman() = this as? HumanSearch
      fun Search.asDroid() = this as? DroidSearch
      fun Search.asStarship() = this as? StarshipSearch
    }
  }

  data class HumanSearch(
    // ...
  ) : Search

  data class DroidSearch(
    // ...
  ) : Search

  data class StarshipSearch(
    // ...
  ) : Search

  data class OtherSearch(
    // ...
  ) : Search
}

リストの要素 Search が data class から sealed interface に変わり、 inline fragment で指定した型はその具体型として表現されている。これで無駄な nullable に煩わされることなく when を使って具体型を特定して分岐させるという書き方ができるようになった。やったね。ところで OtherSearch という型が生成されているが、これは例えば interface で返す field になっていて、 interface で定義されてる field を取りつつ特定の具体型だったらこの field も、みたいな書き方をしている時に inline fragment などで指定した型にマッチしないものが取得された場合の受け皿として用意されているものと思われる。

responseBased なコード生成に関しては inline fragment だけでなく自分で定義した fragment に対しても同じように機能したり field を merge してくれたりと他にも色々トピックはあるが、詳しくは上で紹介した公式ドキュメントを読んでほしい。 Apollo Kotlin のコード生成まわりは公式ドキュメント以外にも以下の記事や design document を読むと理解が深まるかもしれない。

www.apollographql.com

github.com

おわりに

GraphQL とやりとりする際のエラーハンドリングに関するアプリケーション由来のエラーを schema で表現するプラクティスと、それで union type を使う際に Apollo Kotlin ではコード生成方式を responseBased を指定すると Kotlin 側でも sealed interface として扱えるようになるので便利という話をした。ハンドリングが必要なエラー状態を schema で表現するという schema 設計方針を採用できるなら GraphQL を Android/Kotlin で扱う上での嬉しくないポイントが一つ解消されるなあという所感なのだが、実際のところどのくらい普及しているプラクティスなのだろうか。

*1:エラーというか失敗シナリオと呼ぶべきか

Jetpack DataStore で保存する値はキャッシュされている

タイトルの通りで、実用性を考えたらそれはそうでしょというものなんだけど、確認のために実装を読んだのでそのメモ。というのをしばらく放置していたら実装がガッと書き換えられてしまったので書き換え以前 (= 1.1.0-alpha01 時点) と書き換え後両方の実装を見ていくことにする。

1.1.0-alpha01 時点の実装

1.1.0-alpha01 時点では DataStore の実装 class は single-process 向けと multi-process 向けの2種類ある:

このそれぞれが downstreamFlow という StateFlow を持っていて、ここに保存しているデータを保持している。

@Suppress("UNCHECKED_CAST")
private val downstreamFlow = MutableStateFlow(UnInitialized as State<T>)

Read 側に当たるDataStore.data はこの downstreamFlow の状態がデータ保持済みでなかったり整合性が取れていなかったりする場合にストレージアクセスの依頼を行い、あとは downstreamFlow の中身を見続けるというようになっている。

e.g.) SingleProcessDataStore:

override val data: Flow<T> = flow {
    /**
     ...
     */

    val currentDownStreamFlowState = downstreamFlow.value

    if (currentDownStreamFlowState !is Data) {
        // We need to send a read request because we don't have data yet.
        actor.offer(Message.Read(currentDownStreamFlowState))
    }

    emitAll(
        downstreamFlow.dropWhile {
            // ...
        }.map {
            when (it) {
                is ReadException<T> -> throw it.readException
                is Final<T> -> throw it.finalException
                is Data<T> -> it.value
                is UnInitialized -> error(
                    "..."
                )
            }
        }
    )
}

Write 側の DataStore#updateData を追っていくと最終的にはストレージに新しい値を書き込んだ後にその値で downstreamFlow を更新している。 ストレージアクセスは actor を介して行われるので追うのが少し手間だが、 SingleProcessDataStore では transformAndWrite, MultiProcessDataStore では writeData が実際に downstreamFlow を更新している関数となる。

SingleProcessDataStore:

// downstreamFlow.value must be successfully set to data before calling this
private suspend fun transformAndWrite(
    transform: suspend (t: T) -> T,
    callerContext: CoroutineContext
): T {
    // ...
    val curDataAndHash = downstreamFlow.value as Data<T>
    curDataAndHash.checkHashCode()

    val curData = curDataAndHash.value
    val newData = withContext(callerContext) { transform(curData) }

    // Check that curData has not changed...
    curDataAndHash.checkHashCode()

    return if (curData == newData) {
        curData
    } else {
        connection.writeData(newData)
        downstreamFlow.value = Data(newData, newData.hashCode())
        newData
    }
}

MultiProcessDataStore:

// Write data to disk and return the corresponding version if succeed.
internal suspend fun writeData(newData: T, updateCache: Boolean = true): Int {
    var newVersion: Int = 0

    // ...
    storageConnection.writeScope {
        // ...
        newVersion = sharedCounter.incrementAndGetValue()
        writeData(newData)
        if (updateCache) {
            downstreamFlow.value = Data(newData, newData.hashCode(), newVersion)
        }
    }

    return newVersion
}

書き換え以降の実装

注意: 本節の内容はリリースされていない開発中のコードに関するものなので、今後の変更によってなかったことになる可能性がある

2月の下旬ごろに DataStore の内部実装の大きな変更が行われた。それによって DataStore の実装 class は1つ (DataStoreImpl)に統一され、 single-process / multi-process の切り替えは新しく作られた interface InterProcessCoordinator の実装で行われるようになった。また、キャッシュ部分は DataStore の実装 class が持つ StateFlow だったのが DataStoreInMemoryCache として分離された。

変更に関する主な CL:

ということで、この変更後の状態でキャッシュにの仕組みについて追いかける時に見るのは以下の class になる:

DataStoreImplMultiProcessDataStore の実装をベースに細かい変更が加えられたものというのが現在の状態で、キャッシュ関連は保持している値の取得や更新が downstreamFlow から inMemoryCache に変わった他は流れを追う分には変わりなく読めると思う。

DataStoreInMemoryCache は、以前の downstreamFlow に相当する StateFlow の保持と値の更新に関するちょっとした処理を持った helper class で、 multi-process のためのケアを追加することでコードが大きくなったので切り出したみたいな雰囲気。

おわりに

というわけで DataStore の内部実装で値のキャッシュを持っており DataStore.data を 動かす度にストレージまで読みに行くわけではないので、アプリ側でキャッシュする仕組みを持つ必要はないことがわかった。冒頭にも書いたけどそれはそうという感想しか出てこなくて締まらないので関係ない話をすると DataStore のリリースは随分されていないのでそろそろ何か出してほしいですねという気持ちがある。あと保存データを暗号化する機能を組み込んでほしい。