みてね Tech Blog
🏎️

Kotlin 2.0.21から脱却するためにみてねが選んだ選択

に公開

導入

こんにちは。「家族アルバム みてね」(以下 みてね)の開発に携わっている chigichan24 です。最近のリリースでAndroid版のみてねはKotlin 2.1.21にアップデートしました。このアップデートを経て、Kotlin 2.2.21、延いては、まもなくstableになるであろう2.3.0-RCへの更新も小さなコストで十分に対応可能な状態になり、目下対応検討中です。

本記事では、Kotlin 2.1.21にアップデートするまでに私たちが解決した

  • Epoxy 依存を解消して Compose へ移行した際の考え方と進め方
  • Room 2.7 系へのアップデートで遭遇したクラッシュとその原因・回避策

を具体的なコード例とともに紹介します。

背景: 更新できないライブラリが溜まっていく

私たちは、日々Dependabotを用いながらライブラリ更新を行っています。Dependabotが作成したPRの差分と各ライブラリが提供するrelease noteを眺めながら適用可能なバージョン更新なのかを吟味し、検証しながら可能な限り最新のstableを使用するように作業をしています。

ライブラリのアップデートを起点に、PRの自動作成 → ライブラリ差分の確認 → 変更の取り込み、という一連のフローを示した図
ライブラリアップデートを自動PR化し、差分確認を経て変更を取り込むまでのフロー

しかし、あるタイミングからライブラリ更新が滞り始めます。理由はKotlin 2.1.xの登場です。
K2 Compilerの登場によってKotlinはメジャーバージョンが1から2へと更新されました。一方で、その最初のリリースである、Kotlin 2.0.x は以前の Kotlin 1.x系との機能的な互換性の維持に焦点を当ててリリースされたため、このバージョンへの更新は容易でした。
しかし、Kotlin 2.1.xからは本格的にK2 Compilerの恩恵を受けられるようになり、様々なライブラリが対応し始めました。
これにより、Androidアプリ開発者にとってKotlin 2.1への更新が大きな課題となっていったのです。

先に示したように、みてねも例外ではなく、Kotlin 2.1.xに更新するとビルドが通らないので、対応が後手後手に回り、その間に様々なライブラリが Kotlin 2.1.xを要求するようになりました。結果として多くのライブラリの更新が滞るようになりました。

短期的にはこの影響は大きなものではありませんでしたが、次第に「Aというライブラリを最新にした機能開発をしなければならない」、「Bというライブラリの不具合が最新バージョンで修正されているので更新したい」という要求が上がり始め、本腰を入れて対応する必要性が高まりました。

更新ができなかった理由

さて、Kotlin 2.1.21への更新が滞った大きな原因は何だったのでしょうか?それは、一部の画面の実装で使用されていた airbnb/epoxy です。このライブラリは、RecyclerViewを用いたUI実装を簡略化することができるライブラリです。この問題に取り組んでいた当時のリリース状況から、短期的には更新見込みが読みづらい状態でした。またIssue等でも特に急ぎで対応はしないと関係者はコメントを残していました。

そういった状況になるのも無理はなく、ライブラリが作成された当時から時流は変わり、現代では、多くのアプリでComposeを用いたUI実装が行われることが当たり前となっており、このようなライブラリに頼らずとも、誰もが簡単にList構造のViewを作成できるようになっていました。

EpoxyがKotlin 2.1への対応を即座には行わなかったため、ビルドが正常に完了できず、結果的にKotlinのバージョンを上げるブロッカーとなっていました。当時の状況を鑑みて、このライブラリが更新されることは望み薄であることや、今後多くの画面はComposeで実装されていくであろう、という変化を受け入れ、私たちは、Epoxyで実装された画面をComposeですべて実装し直すという選択肢を選びました。

課題1: EpoxyベースのUIをComposeで再実装

Epoxyで実装されていた画面なので、必然的にリスト構造のViewがこの作業の対象となりました。より簡単に言いかえるならば、スクロール可能な画面の一部がこの作業の対象となりました。

Epoxy 依存を解消しつつ AndroidView ベースで再構築する案も検討しました。しかし、

  1. AndroidViewベースで、RecyclerViewをこれからも使い続け、将来においてもメンテし続ける
  2. このタイミングでComposeに書き換える

の2つは対応するコストは大きくは変わらないと判断し、そのような状況であれば、選択肢2.の「このタイミングでComposeに書き換える」を選択することが、未来を見たときにより良い方向へ進めると考えました。

具体的な作業の詳細はケースバイケースであるため避けますが、Epoxyへの依存をとにかくなくすことを主眼において実装が進められました。そのため、一部はAndroidViewのまま残し続けるという選択をした箇所もありますが、この点については許容しながら効率的に進めました。

レイアウトを分割する際に意識したポイントは以下です。

  • スクロール単位ではなく「意味のある UI 部品」単位でボトムアップに Composable関数に切り出す。
  • 状態を持つ箇所と表示専用箇所を分離する。
  • 既存の ViewModel / UseCase の API は維持する。変更がある場合にも最小限に留める。

私の場合は、上記のポイントを満たすために以下のようにClaude Codeを活用しながら置き換えていきました。

  • XMLのlayoutファイル・Epoxy使用部分のコードから、適切なComposable関数の分割をAI Agentと議論
  • Composableの最小単位からボトムアップに実装を進める。
  • XMLの内容を、Composable関数の内容に細かくフィードバックし、徐々に理想形に近づけていく。

また、epoxy-compose というものもairbnb/epoxyから提供されており、https://github.com/airbnb/epoxy/tree/master/epoxy-compose これを用いることで段階的移行も可能になります。以下が公式のレポジトリで示されているコードの例です。Composable関数の中にepoxyviewを入れたり、epoxyviewの中にComposable関数を入れたりすることを実現できます。

https://github.com/airbnb/epoxy/blob/42970d3ee10d764fc29b5a36b4c525ffb571e1f4/epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/ComposableInteropActivity.kt#L30-L47

https://github.com/airbnb/epoxy/blob/42970d3ee10d764fc29b5a36b4c525ffb571e1f4/epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/EpoxyInteropActivity.kt#L29-L45

部分的にはこのようなライブラリを使用しながら、みてねでも移行を進めました。

余談ですが、更新が滞っていたairbnb/epoxyが2025/11/22に、新たなバージョン5.2.0をリリースし、課題となっていたKotlin 2.1 への対応をしました。[1] [2]

課題2: Roomのバージョン更新による不安定な動作

上記の作業によってEpoxyへの依存は完全に消すことができました。Kotlinのバージョン更新に合わせて、KSPやRoomのバージョン更新も必要になったため、これらを最新バージョンにし、ついにビルドを成功させることができました!!

しかし、アプリがRuntimeでクラッシュしたり、しなかったりと不安定な状況になりました。Stacktraceを眺めると、Room内部でクラッシュしている様子で状況は振り出しに戻りました。

小課題1: ORDER BY RANDOM()

何が起きたか(現象)

DAOから生成されたコードを、AIを使いながら読み解いていったところ、Roomのバージョンによって生成されるコードに差分があることがわかりました。問題となったのはRelationによって、親子の情報を自動でフェッチするようなクエリに対して、ORDER BY RANDOM()を使用するようなケースです。
例として、以下のような本の内容を表現するようなテーブルを考えます。

これに対して、@Entityは以下のように定義できます。1対多のRelationを保つ構造なので、ここではBookというPOJOを考えます。

@Entity(
    indices = [Index("bookId")],
)
data class Page(
    @PrimaryKey
    val id: Long,
    val content: String,
    val pageNumber: Int,
    val bookId: Long
)

@Entity
data class BookContent(
    @PrimaryKey(autoGenerate = true)
    val id: Long,
    val title: String,
)

data class Book(
    @Embedded
    val bookContent: BookContent,
    @Relation(parentColumn = "id", entityColumn = "bookId")
    var pages: List<Page>,
)

この時、Bookに関する情報をランダムに一件取得するために、ORDER BY RANDOM()を用いて、DAO上でQueryは以下のように書きます。

@Dao
interface BookDao {
    @Transaction
    @Query("SELECT * FROM BookContent ORDER BY RANDOM() LIMIT 1")
    suspend fun getRandomBook(): Book?
}

これは、Room 2.7.0から残念ながらうまく動作しません。多くの場合、Roomの生成コード内でクラッシュします。この現象を理解するためにはRoomによって生成されたコードを理解する必要があります。

なぜ起きるのか(生成コードと2-phaseクエリ)

RoomはRelationを持つようなクエリをどう解釈するでしょうか?Daoでつけるアノテーションや、Entityの定義に依存するのですが、基本的には、Relationをもつ場合は、内部で2-phaseでクエリを実行します。

1回目の実行では、1対多の1に相当するテーブルに対してクエリを実行します。このとき取得されたデータのIDをメモリ上で保持します。

2回目の実行では、保持したIDを使用して、1対多の多に対応するテーブルに対して、データをクエリします。その後1回目のクエリの実行結果とマージして、データを返却します。

つまり、OUTER JOINのようなクエリを生成するのではなく、複数回クエリを実行することでRelationの構造を満たすようにしているのです。これはRoom 2.7.0前後で挙動が明確に変わっています。以前は2回のクエリを実行するのではなく、Cursorの位置を-1にすることで、再実行を実現していました。

一方で、Room 2.7以降ではこの方式に一部更新があり、クエリが再度実行されるような挙動になります。

この1回目の実行と2回目の実行それぞれで ORDER BY RANDOM()が実行されてしまうため、取得しようとするデータが2つのクエリの実行の間で不整合が生じてしまいます。これはTransactionの不足等ではなく、Roomの内部で誤って評価順が変わってしまうことを指します。

この現象についてシーケンスダイアグラムにしたので、詳細はこちらを確認してください。

どう直したか(クエリ分割/JOINの使用)

私たちは、この問題に対して以下のようにクエリを2分割することによって問題を解消しました。はじめに、getRandomBookId を呼び出すことで、ランダムに一件BookIdを抽出します。Idを抽出するのみなので、relationの処理は発生しません。そのため、従来発生していたNPEは抑制されます。
その後、抽出したIDを使用してgetBookByIdを呼び出すことで、RANDOM()が複数回評価されずに、bookに関する情報をクエリすることができました。

@Transaction
@Query("SELECT id FROM BookInfo ORDER BY RANDOM() LIMIT 1")
suspend fun getRandomBookId(): Long

@Transaction
@Query("SELECT * FROM BookInfo WHERE id = :id")
suspend fun getBookById(id: Long): Book

これはRoomが内部で行っていた動作を明示的に2つのクエリに分ける形になります。
他にも明示的にJOINを行うクエリを @Query(...) の中に記載することでも問題を解消させることができます。

より詳しい話はこの問題を再現させたminimal projectである chigichan24/RoomCrashとこれを「コードベースをインデックスして検索できる」サービスである Devin に index させた DeepWiki 版 https://deepwiki.com/chigichan24/RoomCrash をご覧ください。
また、issue trackerにも本件は登録され、2025年12月4日現在ではP2のpriorityが付与されています。[3]

小課題2: 複数スレッドからの読み書き

上で触れたように、Relationのあるテーブルに対してのクエリは、生成されるコード視点で見ると、2段階の実行で、間でstatementをresetします。ORDER BY RANDOM()のように顕著に一つのメソッド定義で問題になるケースもあれば、複数のスレッドから対象のテーブルに対して読み書きが起きうる場合に問題になることもあります。これが小課題2です。

@Transactionアノテーションが付与されていない場合、Phase 1でのクエリと、Phase 2のクエリはsingle transactionの中で実行されることは保証されません。そのため、状況によっては不整合が2つのクエリ間で生じ、クラッシュを引き起こします。

特にこれは、Room 2.6.x時代にRoomが生成していた、コードと異なる場合もあるため、従来は問題なく動作していたコードもRoomバージョンをアップデートすることで生成コードが大きく変化し、意図しない動作になる可能性があります。適切に@Transactionが付与されているかの確認も重要です。

まとめ: わたしたちの選択

これらの修正を行うことで、みてねはKotlin 2.1.21にアップデートできました。その結果、滞っていたライブラリ更新も順調に進んでいます。Dependabotの作るPRのCI結果がPassとなるのが当たり前な世界線が戻ってきました。

今回、Kotlin 2.0.21からKotlin 2.1.21にアップデートしたときに遭遇した課題2点を共有しましたが、細かなものを含めると他にもポツポツと課題は出ています。(e.g., CIの実行時間が意図せず伸びている、KSPの結果が不安定になっている)これらの課題にも正面から向きあって一つ一つ対応しています。

わざわざKotlinのバージョンを最先端まで追わなくても、まだしばらくは通常通りリリースのフローに乗せることはできたでしょう。しかしこの手の課題はじわじわとプロダクト価値を生み出せないという部分に効いてくるので、傷が浅いうちにどんと構えて対応するべきだと個人的には考えています。特にユーザーさんに提供したい価値が、今回のようなKotlinのバージョン依存で提供できないというのはプロとして悔しい気持ちでいっぱいになります。

私は以下の図のように考えています。プロジェクトの実行を保証できるbottomlineとtoplineがあり、その狭間の何処か一点に私たちのプロジェクトは位置しています。bottomlineを追っていると、急な変更に追従することが難しく、またtopline方向へのgapが開いているのでなかなか最新環境まで追いつくことは難しくなります。

時間経過に対する最新環境への追従度の線グラフ。上側のtoplineは常に最新環境で動作保証できるレベル、下側のbottomlineは実際に保証できているレベルを示し、時間とともに両者のギャップと対応コストが増大し、途中でBreaking changeによりbottomlineが急に跳ね上がる様子が描かれている。
最新環境への追従を怠ると、時間とともにギャップと対応コストが膨らむことを示すグラフ

プロダクトの規模や取り巻く環境依存ではあるものの、個人的にはいつでも動作を保証するtoplineにしようと思えばできるという状態を水面下で維持しつつ、最新のstableに追従するというバランスがいいと考えています。みてねのAndroidプロジェクトもそういった水準に日々近づいています。

実際にみてねで遭遇した課題への対応策そのものや、全体の意思決定のプロセス等が、記事を読んでくださった方にも何らかの形で還元されたら嬉しく思います。

脚注
  1. https://github.com/airbnb/epoxy/pull/1393 ↩︎

  2. それでもなお、みてねとしてEpoxyからComposeにするという意思決定をしたのは、当時の状況からはベストだと今でも言えます。遅かれ早かれ、Epoxyへの依存を0にしたいという認識があったため、よききっかけをもらってEpoxyを削除したと考えています ↩︎

  3. https://issuetracker.google.com/issues/413924560 ↩︎

みてね Tech Blog
みてね Tech Blog

Discussion