どうして僕たちはRxJavaに完全に別れを告げてCoroutinesを選んだのか?
この記事は、NAVITIME JAPAN Advent Calendar 2020の 15日目の記事です。
はじめに
こんにちは、こねくとです。ナビタイムジャパンで「NAVITIME」のAndroidアプリ開発を担当しています。
NAVITIME JAPAN Advent Calendar 2020の6日目に公開されました、「Androidアプリのリアーキテクチャ」に私も携わっており、今回のnoteではリアーキテクチャの中でRxJavaからKotlinのCoroutinesへ置き換えることを決めるに至った経緯をお話をします。
※具体的な置き換えの実装内容はあまり出てきません。
モチベーション
RxJavaはJavaでリアクティブプログラミングを行うのをサポートしてくれる、非常に便利なライブラリです。例えば、DBアクセスや通信処理の様な時間の掛かる処理を上位層から下位層へ要求するときに、RxJavaのObservable(イベント/データストリームを提供するクラス)を返却して通信のレスポンスを購読させる、というのはAndroid開発者ではお馴染みかと思います。
// イベント・データストリームを提供するクラス
class SQLRepository(private val store: SQLStore) {
fun load(): Observable<Entity> {
return store.load()
}
}
// Observableを購読する側
fun subscribe() {
sqlRepository.load().subscribe(
{
// Entityを受信
},
{}
)
}
上記以外にもSingleやMaybeなど豊富なObservableが用意されていたり、受信したイベント/データを操作するオペレーターも非常に多く用意されていたりします。(RxJavaの詳細についてはReactiveXのサイトをご覧ください。)
NAVITIMEでもRxJavaの恩恵を受けてきましたが、次に挙げる様な課題も感じる様になってきました。
・機能が豊富すぎて、使いこなせない
・受信処理の記述が冗長になりやすい
・新人へのハードルの高さ
これらの課題は「どうしてもRxJavaから置き換えないといけない!」というほどモチベーションを刺激するものではありませんでしたが、よりしっくり来るものがあれば置き換えを前向きに検討したい雰囲気になってきていました。
Coroutinesを選んだ理由
そこで候補に上がったのがKotlin Coroutinesです。CoroutinesはKotlinが言語機能として用意している非同期処理を実現する機能です。開発者はCoroutineScopeというスコープ上でコルーチンを起動して非同期処理を実装します。2020年12月8日現在でver.1.4.2が公開されており、非同期処理の実現だけでなく、Cold Flow(Stream) / Hot Flow(Stream)もサポートしており、これまでにRxJavaを活用していた処理を置き換えるのに十分な機能を備えています。
Coroutinesへの置き換えには新しい技術トレンドへの追従していくというモチベーションももちろんありましたが、後述する4点がポイントになりました。
・ライブラリの運用のしやすさ
・シンプルな記述
・Android Jetpackとの親和性
・新規メンバー参入時のコスト削減
それぞれ説明していきます。
ライブラリの運用のしやすさ
サードパーティライブラリを用いる場合は利用したいライブラリだけでなく、そのライブラリが依存しているライブラリ群もAndroidプロジェクトに取り込む必要があります。そのため、Androidプロジェクトで取り込んでいる他のライブラリとのバージョン競合などに開発を運用していく上で気を配る必要が出てきます。
その点、CoroutinesはKotlinの機能として提供されているため、Kotlinに関連するバージョン管理を気にしてさえいれば他はあまり気にすることがなく、扱いやすいと考えています。
シンプルな記述
RxJavaでは、例えばRESTのGETの様にイベント/データをひとつだけ取得する際、SingleというObservableを用いることが多いかと思いますが、そのレスポンスを取得するためには先述のサンプルコードの様にSingle自体を呼び出し元に公開し、購読する必要があります。
それに対してCoroutinesでは、呼び出し先で中断関数(suspend function)であることを宣言しておけば、直接レスポンスのクラスオブジェクトを取得することが出来、記述がシンプルになります。
// RxJava
// 通信レスポンスを提供するクラス. Singleの形で提供する.
class NetworkRepository(private val api: Api) {
fun callApi(): Single<Response> {
return api.call()
}
}
// Observableを購読する側
fun subscribe() {
netRepository.callApi().subscribe(
{
// Responseを受信
},
{}
)
}
// Coroutines
// 通信レスポンスを提供するクラス. Responseを直接提供する.
class NetworkRepository(private val api: Api) {
// suspend関数であることを宣言する
suspend fun callApi(): Response {
return api.call() // このcall() もsuspend関数
}
}
// 受信する側
fun subscribe() {
coroutineScope.launch {
// Responseを受信
val response = netRepository.callApi()
}
}
...あまり大きな差には感じないでしょうか?もちろんそういったご意見もあるかと思います。
この点については、
・大規模であるほどこういった小さな冗長さの積み重ねが後々可読性の低下へと繋がりがち
・よりシンプルな方が書いていて気持ち良いしコーディングするやる気にも繋がる
という共通認識がメンバー間にあったところが大きいです。
Android Jetpackとの親和性
2019年のGoogle I/OでCoroutine Firstを打ち出して以降、Jetpack (Android向けライブラリスイート)では多くのAPIでCoroutineをサポートしており、この影響はとても大きなものでした。
例えば、これまで複雑になりがちであった非同期処理におけるライフサイクルの考慮はlifecycleScopeの登場によって非常にシンプルに書くことが出来る様になり、開発初心者が忘れがちであったキャンセル処理もライフサイクルを終えるときには自動で行なってくれる様になりました。また最近では、SharedPreferencesの置き換えとしてDataStoreというCoroutineをサポートしたライブラリも登場しています。
新規メンバー参入時に受けられるメリット
弊社では新卒研修のカリキュラムの中にAndroidアプリ開発研修があります。その内容は世の中の技術トレンドを取り込みつつ毎年アップデートしており、現在では非同期処理をCoroutinesを使って学びます。つまり新入社員は配属されるまでにCoroutinesの基礎的な知識を学び、手を動かしてきている訳です。
先述したライフサイクルを意識したキャンセル処理をはじめ、非同期処理は考慮すべき点が多くあり、経験を積んできた開発者でも難しいものです。私もそれなりの開発経験を積んできましたが「人類に非同期処理はまだ早かった」と思うことが今でもあります。そんな非同期処理を折角Coroutinesで学んできていただくので、Coroutinesを採用することで新卒メンバーの心理的ハードルを下げたり、教える側のコストの低減が期待出来る点はプラスアルファのメリットとして小さくないのではないかと考えました。
こうして私たちはRxJavaからCoroutinesへ置き換えることを決めたのです。
やる気は十分、さぁCoroutinesへ置き換えよう
さて、Coroutinesへ置き換えることを決め、さぁ置き換えよう!となった訳ですが、その際に以下の2点を注意しました。
・kotlinx-coroutines-rx2を使って段階的に置き換えること
・kotlinx-coroutines-rx2の処理を残したままにせず、早急に削除する
中途半端な入れ替えはNG
kotlinx-coroutines-rx2はCoroutinesとRxJavaとの相互変換をサポートするライブラリです。これによって、呼び出し先はRxJavaで実装しつつ呼び出し元はCoroutinesで受け取るといったことが出来るため、部分的にCoroutinesへ置き換えていく際に非常に役に立ちます。(下記ではSingleからawait()を使って値を直接返すsuspend関数に変換しています。)
// 通信レスポンスを提供するクラス. Responseを直接提供する.
class NetworkRepository(private val api: Api) {
// suspend関数であることを宣言する
suspend fun callApi(): Response {
// await()を使ってSingleからResponseを直接返すsuspend関数に変換
return api.call().await()
}
}
// 受信する側
fun subscribe() {
coroutineScope.launch {
// Responseを受信
val response = netRepository.callApi()
}
}
いきなり全てを置き換えるのではなく、kotlinx-coroutines-rx2を使い現行の仕様を保証しつつ段階的に置き換えていくアプローチはネット上でも多く見つけることが出来、先人の知恵を参考に私たちも段階的に置き換えていくことにしました。
段階的に進める中で気をつけたのが「早急に削除する」ことです。
先述したSingle.await()の様なRxJava ←→ Coroutinesの相互変換処理は内部で各APIへ橋渡しをしているため、どちらか単体で実装するのに比べるとボトルネックになる可能性が高くなります。
実際にこの相互変換処理起因でANR(Application Not Responding)が発生したケースについてご紹介します。
そのケースではRxJavaをサポートしているサードパーティのライブラリを使用しており、Observable.asFlow()を用いてデータを取得する処理をFlow経由で受信する様に実装していました。また、要件として出来るだけ高頻度でデータを取得したかったこともあり、データストリームに流すデータの量は制限していませんでした。
その結果、秒間100を超えるデータを受信したときにObservableからFlowへと橋渡しをしている途中でデータが詰まってしまい、ANRを引き起こしてしまったのです。
// RxJava → Coroutines変換処理がボトルネックになったケース
fun subscribe() {
observableProvidedLibrary()
// Observable → Flow変換処理. 今回はこの中でデータが詰まってしまった.
.asFlow()
.distinctUntilChanged() // データに変更があるまで後続に流さない
.onEach {
// データを受信
}
.launchIn(coroutineScope)
}
このケースでは変換処理の中で詰まってしまっていたため、これを解消するには変換処理asFlow()を呼び出すよりも前の段階でデータの量を制限する必要があり、「出来るだけ高頻度でデータを取得したい」という要件に対しては物足りないものでした。
// RxJava → Coroutines変換した上でデータが詰まらない様に対処する場合
fun subscribe() {
observableProvidedLibrary()
// 変換処理で詰まらないためには, sampleなどを使ってObservableの段階での制限が必要
.sample(100, TimeUnit.MILLISECONDS)
.asFlow()
.distinctUntilChanged() // データに変更があるまで後続に流さない
.onEach {
// データを受信
}
.launchIn(coroutineScope)
}
この経験から、相互変換処理をそのままにせずに、早くにCoroutinesへ置き換えて削除する様に気をつけました。
まとめ
現在ではRxJavaを完全に取り除き、Coroutinesへの置き換えが完了しています。開発環境レベルでは大きな問題は起こっておらず、Coroutinesへの置き換えを検討していた際に期待した恩恵を感じています。
今回は社内の事情も交えつつ、Coroutinesへ置き換えるまでの動向をお話してきましたが、RxJavaもとても便利な素晴らしいライブラリです。チームの文化や環境によってはRxJavaの方がBetterなことも当然あると思います。そういったライブラリ選定の際の参考となれましたら幸いです。