はじめに
みなさんはCoroutines使ってますか。
コードもシンプルになるしライフサイクルあんま考えなくてもうまいことやってくれるしいいことだらけですよね。
ちなみに弊社のAPI関連のコードではRxJava2が使われています。
詳しくは次のセクションで書きますが、パートナーさんへの影響やデグレ懸念など、「やりたいけどなかなかできてない」という状況が続いていました。
今回、新しいAPI機能の追加をきっかけに 「新しく書くところだけ他への影響を最小限にCoroutinesで書いてみよう」 と思い立ちCoroutinesを導入してみました。
プロダクトの構成:コアとテナントの分離
弊社のアプリは core と app(テナント) の2層構造になっています。
core/ ← コア層・共通ライブラリ(全案件で共有)
app/ ← アプリ層・テナント領域(案件ごとにカスタマイズ)
coreには認証・APIクライアント・共通UIコンポーネントなど全案件で使う基盤が入っていて、app(テナント)には案件ごとの設定値ファイルや画面カスタマイズが入っています。
普段私コア側の改修に携わっています。
弊社ではテナント領域の実装は外部パートナーさんにお願いすることがほとんどです。
つまりテナント側のコードを変えると、パートナーさんの学習コストや修正コストが増えてしまいます。
「Coroutinesに移行しよう」と思っても、それがテナント側に影響するなら余計な負担をかけてしまうことが移行に踏み切れなかった理由のひとつでした。
実際の影響を調査してみた
今回、外部トークン取得の汎用テンプレートを新しく作ることになりました。
ずっとCoroutinesで書きたい思いはあったので、実際Coroutinesにしたらテナント側の実装がこれまでのRxJava2を使っていたときからどれだけ変わるか確認してみました。
確認した結果を整理するとこういうことになりました。
今回変わる部分(Coroutines化)
- APIを叩いてトークンを取得するクラス —
suspend fun fetch()を実装する形になる
今まで通り書ける部分(変わらない)
- Retrofitのエンドポイント定義 — Retrofit + RxJava2のいつものスタイルそのまま
- 有効期限チェック — 普通のKotlinクラスなのでCoroutinesは関係なし
- エラーハンドリングのカスタマイズ(
handleErrorのオーバーライド)— これも同様 - リトライポリシー — これも同様
→もしパートナーさんが「Coroutinesって何?」という状態でも、ほとんど普通のKotlinで書ける!
APIを叩いてトークンを取得する処理のsuspend funだけは新しい概念ですが、「インターフェースを実装するだけ」という点では他のファイルと変わらないので、学習コストはかなり低く抑えられています。
・・・じゃあやっちゃおう!
テナント側のコードの使用感はほぼ変わらないことがわかったので、複雑なロジック(リトライ・エラーハンドリング・キャッシュ)はコア側で巻き取り、Coroutinesを使って今回実装することにしました。
RxJava2とCoroutinesの橋渡し
AppExternalTokenFetcher の中で blockingGet() を使っています。
override suspend fun fetch(): List<ExternalToken> = withContext(Dispatchers.IO) {
val response = api.get().blockingGet() // ← RxJava2 → Coroutines の橋渡し
...
}
blockingGet() はスレッドをブロックしますが、withContext(Dispatchers.IO) の中なのでメインスレッドはブロックしません。
今回はこれで問題なしです。
将来的に rxjava3-coroutines-interop ライブラリが使う場合は await() でもっときれいに書けます。
ただ今回はシンプルさ優先でこのスタイルにしました。既存のRxJava2のAPIインターフェースをそのまま活かせるのも地味にうれしいポイント。
コア側の実装:Coroutinesで書いた汎用テンプレート
コア側はいくつかのシンプルなインターフェースで構成しています。
今回のサンプルコードは こちら に公開しました。
ExternalTokenFetcher:APIの呼び出し口
interface ExternalTokenFetcher {
suspend fun fetch(): List<ExternalToken>
}
suspend fun 1行だけのインターフェースです。テナント側はこれを実装するだけでOK。
TokenFetchRetryPolicy:リトライポリシーを持つデータクラス
data class TokenFetchRetryPolicy(
val maxRetryCount: Int = DEFAULT_MAX_RETRY_COUNT,
val shouldRetry: (AppError) -> Boolean = { true } // デフォルト: 全エラーリトライ
) {
companion object {
const val DEFAULT_MAX_RETRY_COUNT = 3
}
}
案件ごとにカスタマイズしたいときはこんな感じで書きます。
// ネットワークエラーだけリトライして、APIエラーはすぐハンドリングする例
override val retryPolicy: TokenFetchRetryPolicy
get() = TokenFetchRetryPolicy(
shouldRetry = { error -> error is NetworkError }
)
TokenValidator:キャッシュの有効期限チェック
毎回APIを叩くのは無駄なので、有効期限チェックを差し込める仕組みも用意しました。
interface TokenValidator {
fun isValid(tokens: List<ExternalToken>?): Boolean
}
実装例(30分のTTL)
class SampleTokenValidator(
private val ttlMillis: Long = 30 * 60 * 1000L
) : TokenValidator {
override fun isValid(tokens: List<ExternalToken>?): Boolean {
if (tokens.isNullOrEmpty()) return false
val elapsed = System.currentTimeMillis() - getFetchedAt()
return elapsed < ttlMillis
}
}
FetchExternalTokenPresenter に渡しておくと、有効期限内なら自動でAPI呼び出しをスキップしてくれます。
FetchExternalTokenPresenter:全体のフロー制御
scope.launch でCoroutineを起動して、成功・失敗の後処理をMainスレッドに戻しています。
interface FetchExternalTokenPresenter : ApiErrorHandler {
val tokenViewInterface: FetchExternalTokenViewInterface
val tokenFetcher: ExternalTokenFetcher
val retryPolicy: TokenFetchRetryPolicy get() = TokenFetchRetryPolicy()
val tokenValidator: TokenValidator? get() = null
fun fetchTokenIfNeeded(scope: CoroutineScope, onComplete: () -> Unit) {
// 1. shouldFetchToken が false ならスキップ
if (!tokenViewInterface.shouldFetchToken) {
onComplete(); return
}
// 2. バリデータが有効と判定したらスキップ
tokenValidator?.let { validator ->
if (validator.isValid(PreferencesManager.instance.tokenSettings)) {
onComplete(); return
}
}
// 3. APIを呼び出す
executeFetch(scope, retryCount = 0, onComplete = onComplete)
}
private fun executeFetch(scope: CoroutineScope, retryCount: Int, onComplete: () -> Unit) {
scope.launch {
try {
val tokens = tokenFetcher.fetch() // suspend fun
PreferencesManager.instance.tokenSettings = tokens
withContext(Dispatchers.Main) { onComplete() }
} catch (e: CancellationException) {
throw e // Coroutineのキャンセルは必ず再スロー
} catch (e: Throwable) {
val error = e as? AppError ?: e.toAppError()
withContext(Dispatchers.Main) {
handleFetchError(scope, error, retryCount, onComplete)
}
}
}
}
}
Fragment側:lifecycleScopeを渡す
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// lifecycleScope を渡すので、Fragment破棄時に自動キャンセルされる
presenter.startTokenFetch(viewLifecycleOwner.lifecycleScope)
}
lifecycleScope を渡すことで、Fragmentのライフサイクルに連動してCoroutineが自動的にキャンセルされます。
RxJava2のときみたいに CompositeDisposable を手動管理しなくてよくなるのは地味にうれしい。これでGithub CopilotにPRで指摘されずに済むぜ!
Coroutinesならではの注意点
導入してみて「RxJava2のときとは違う」と感じた注意点をまとめておきます。
① CancellationException は必ず再スローする
今回のコードでも対応していますが、これが一番ハマりやすいポイントだと思います。
catch (e: Throwable) で全例外を捕まえると、CancellationException まで飲み込んでしまいます。CoroutinesのキャンセルはこのExceptionを伝播させることで実現しているので、再スローしないとキャンセルが機能しません。
// NG: CancellationException を飲み込んでしまう
scope.launch {
try {
tokenFetcher.fetch()
} catch (e: Throwable) {
// CancellationException もここに来てしまう → Fragment破棄後も処理が走り続ける
handleError(e)
}
}
// OK: CancellationException だけ先に再スロー
scope.launch {
try {
tokenFetcher.fetch()
} catch (e: CancellationException) {
throw e // キャンセルを正しく伝播させる
} catch (e: Throwable) {
handleError(e)
}
}
RxJava2の onError コールバックにはこういった概念がなかったので注意です。
② blockingGet() を使う場所に気をつける
今回のように RxJava2 の既存 API を橋渡しする際に blockingGet() を使っていますが、呼び出す場所を間違えるとメインスレッドをブロックしてUIがフリーズします。
// NG: メインスレッド上で blockingGet() を呼ぶ → UIフリーズ
scope.launch {
val response = api.get().blockingGet()
}
// OK: withContext(Dispatchers.IO) の中で呼ぶ
scope.launch {
val response = withContext(Dispatchers.IO) {
api.get().blockingGet()
}
}
blockingGet() を使うなら必ずDispatchers.IOの中で、を意識しておくと安全です。
③ スコープの選び方
Coroutinesはどのスコープで起動するかによって、キャンセルのタイミングが変わります。
// NG: GlobalScope はライフサイクルに紐付かない → Fragment破棄後も動き続ける
GlobalScope.launch {
tokenFetcher.fetch()
}
// OK: viewLifecycleOwner.lifecycleScope → Fragmentのライフサイクルに連動
viewLifecycleOwner.lifecycleScope.launch {
tokenFetcher.fetch()
}
// OK: viewModelScope → ViewModelのライフサイクルに連動
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
tokenFetcher.fetch()
}
}
}
Fragment 内なら viewLifecycleOwner.lifecycleScope、ViewModel 内なら viewModelScope を使うのが基本です。
GlobalScope はほぼ使う場面がないと思っておいて良いと思います。
④ UI更新は Dispatchers.Main に戻す
scope.launch はスコープのDispatcherをそのまま引き継ぎます。lifecycleScope はデフォルトで Dispatchers.Main なので今回は問題ありませんでしたが、Dispatchers.IOで処理した後に UI を触る場合は明示的に切り替える必要があります。
scope.launch {
// IO処理
val tokens = withContext(Dispatchers.IO) {
tokenFetcher.fetch()
}
// そのまま UI を触れる(lifecycleScope はデフォルトが Main)
view.show(tokens)
}
// Dispatchers.IO で起動している場合は明示的に戻す
scope.launch(Dispatchers.IO) {
val tokens = tokenFetcher.fetch()
withContext(Dispatchers.Main) {
view.show(tokens) // Main に戻してから UI 更新
}
}
今回の executeFetch で onComplete() の呼び出しを withContext(Dispatchers.Main) でラップしているのも同じ理由です。
既存コードとの比較
改めて既存のRxJava2スタイルと並べてみます。
RxJava2(既存スタイル):
private val disposable = CompositeDisposable()
fun loadData() {
apiService.getData()
.observeOn(schedulers.mainThread())
.subscribeOn(schedulers.io())
.subscribe(object : ApiObserver<...>(...) {
override fun onRequestSuccess(data: Data) { view.show(data) }
override fun onRequestError(error: AppError) { view.showError(error) }
})
.addTo(disposable)
}
override fun onDestroy() { disposable.dispose() } // 忘れると詰む
Coroutines(新スタイル):
fun loadData() {
viewLifecycleOwner.lifecycleScope.launch {
try {
val data = repository.getData() // suspend fun
show(data)
} catch (e: AppError) {
showError(e)
}
}
// onDestroy不要。lifecycleScopeが自動でキャンセルしてくれます。
}
コード量の削減もありますが、dispose() 忘れをしょっちゅうやらかすので 「後始末を忘れるリスクがなくなる」 のが地味に大きいです。
まとめ
コア・テナント分離の構成があるプロジェクトなのでテナント側への影響は最小化する(パートナーさんへの負担を増やさない)方針で実施しましたが、インターフェースを整理すると思っていたより移行自体は難しくなかったです。
- 複雑なフロー制御はコア層に集約することで、テナント側は
suspend fun fetch()を実装するだけで済む - RxJava2の既存APIは
blockingGet()+withContext(Dispatchers.IO)で橋渡しできる
Coroutinesに移行するメリットはパフォーマンス面でも大きそうなので、次は既存のPresenterのうち比較的シンプルなものから、少しずつ書き換えていきたいと思います。