背景
今年度に出るプロダクトに入るAndroidのアプリケーション(UIというより設定を管理するようなシステムサービス)を半年ぐらい設計したりコード書いたりして作ってました。
'22/08ぐらいから本格的に書き始め、MVPのワンパスを通しつつ、提供する機能やAPIを作るなかで、
機能が多いためパフォーマンスが気になり始めました。
特に他アプリとのやり取りがJSONであることもあって、初期化処理は数KBあるJSONを複数処理しないとならず、
処理自体は削減できないので実行時間を短くするしかなく、並列処理するなりしないと時間かかるよねという話になり、KotlinのCoroutineを導入しました。
今回はそんなCoroutineの導入で色々苦労した話をまとめようと思います
(Part1で導入部分について書いてみました)
前提
作ったもの
今回作ったのはAndroidのサービスアプリケーションです。
複数のAPIをAIDLの形式で提供しており、クライアントもそれなりの数がいます。
加えてPFのAPIにもかなり依存しており、PFビルドしないといけないようなものになっています。
提供する機能
- 設定値を管理する
- 設定値を管理するためのDBを作る
- 設定値の初期値をDBに書き込んであげる
- 基本的にRoomにお任せしちゃう
- cacheとかはrepository層で吸収する
- 上記設定値を外部からset/getでき、設定が変わった際に通知を出す
- set APIが呼ばれたらDBの値を変更する
- DBの値が変わったらクライアントアプリに通知を出す
- get APIが呼ばれたらDBの値を読んで返す
- 全面アプリが変わったタイミングなどに設定値をPFに設定する
- 適切なデータをDBから読んでPFに設定する
- その他色々…
Coroutineの導入
上記機能を高パフォーマンスで動かすために並列処理が必要不可欠です。
そのため今回はKotlinのCoroutineを使って並列化しました。
Coroutineについての詳細は割愛しますが、必要な要所要所で補足します。
パフォーマンスのためにやったこと
Level1: DBの初期化
まず最初にCoroutineを使ったのは、単純な並列処理と処理完了の通知の仕組みでした。
DBを初期化する処理で、JSONのparse処理を並列化して効率的に処理するロジックにしてみました。
加えて後続にこのクラスの初期化を待っているクラスがいるので、StateFlowを使って初期化完了を通知してあげます。
/** DBにアクセスするAPIを提供するクラス */
class DataManager(
private val workerScope: CoroutineScope,
context: Context,
) {
// クラスの初期化状況
// isInitializedを外部公開してDBの初期化処理に依存する処理はStateFlow.collectを実装する
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized
// クラスの初期化処理
fun init() = workerScope.launch {
/** json-1~3までparseしてdata classに読み込む */
val json1 = async { parseJson1() }
val json2 = async { parseJson2() }
val json3 = async { parseJson3() }
/** 読み込んだdata classを使ってDBを構築する */
buildInitialDatabase(json1.await(), json2.await(), json3.await())
_isInitialized.emit(true)
}
// ...
}
/** DataManagerの初期化を待ってるクラス
* (設定を監視して何かしらの処理を発火する人)
*/
class Controller(
private val initializationScope: CoroutineScope,
private val dataManager: DataManager,
private val executor: SettingExecutor,
) {
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized
fun init() {
initializationScope.launch {
dataManager.isInitialized.collect { initialized ->
if (initialized) {
val settings = dataManager.readSettings()
executor.setSettings(settings)
cancel() // このScopeをキャンセルしてcloseする
}
}
}
}
}
Level2: DBアクセス
次に改善したのはDBへのアクセス。
頻度が多いと全体のパフォーマンスを下げることになりかねないので、
キャッシュを導入してオンメモリを更新とDBへの書き込みで処理を分ける形にしました。
こうすることで外部公開しているAPIのレスポンスが良くなります。
/** Level1と同じクラスですが、この説明に必要なものだけ抜粋してます */
class DataManager(
private val workerScope: CoroutineScope,
private val ioScope: CoroutineScope,
context: Context,
) {
companion object {
private const val CACHE_SIZE = 5
}
// Room DB
private val database : AppDatabase by lazy { AppDatabase.getInstance(context) }
// 設定値のcache
private val cache = LruCache<Int, SettingPair>(maxSize = CACHE_SIZE)
// LRU cache自体はスレッドセーフだが、
// cacheになければデータを追加するなどの処理でアトミックにしておく必要があるため、
// ReentrantReadWriteLockを導入する
private val rwl = ReentrantReadWriteLock()
suspend fun get(id: Int) = rwl.read {
cache.get(id) // キャッシュから読み込む
} ?: database.dao.getSetting(id).also { // キャッシュになければDBから取ってくる
rwl.write { cache.put(id, SettingPair(id, it)) } // 読み込んだやつをcacheにおいておく
}
suspend fun set(id: Int, value: Int): Result {
rwl.write { cache.put(id, SettingPair(id, value)) } // キャッシュの値を更新する
ioScope.launch {
database.dao.setSetting(id, value) // 別スレッドでDBへの書き込みをする
}
}
data class SettingPair(key: Int, value: Int)
typealias Result = Int
}
ちなみに、、、
Coroutineが提供しているMutex
や、synchronized
でもアトミックに処理できますが、
read/writeに関わらずブロックするのでパフォーマンスが悪いです。
基本的には<write> ✕ <any>をブロックできれば良く、<read> ✕ <read>はマルチスレッドから呼ばれても問題にならないので、ReentrantReadWriteLock
を使うほうが良いです。
細かいですがこれでも数ms変わってきます。
Level3: 設定変更
設定変更は基本的に外部公開しているAIDL経由で実行されます。
外部アプリとの界面にはsuspend
は持ち込めないので、そこだけrunBlocking
でスレッド自体をブロックするしかありません。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html
この場合runBlocking
の中に重い処理を書いているとその処理が終わるまでBinderThread
が開放されないことになり、呼び出し元のスレッドを長時間ブロックします。
そのためなるべく短時間で処理を終わらせてスレッドを開放してあげる必要があります。
もしどうしても長くなってしまう場合は、timeout
とかを入れてfail-safeとするぐらいでしょうか。
/** 外部公開しているAIDLの実体 */
class SettingBinder(private val controller: Controller): IXXXSetting.Stub() {
// ...
override fun setSetting(id: Int, value: Int) = runBlocking {
controller.set(id, value)
}
// ...
}
class Controller(
private val workerScope: CoroutineScope,
private val dataManager: DataManager,
) {
companion object {
private val SUCCESS = 0
}
// ...
suspend fun set(id: Int, value: Int): Result = dataManager.set(id, value).also {
if (it == SUCCESS) {
// workerScopeに通知処理を投げることで、設定値の書き込みと分離できる
// そのためAIDLの呼び出しのレスポンスが高速化する
workerScope.launch { notifyChange(id, value) }
}
}
private fun notifyChange(id: Int, value: Int) {
// 内部通知 + 外部通知
}
}
Part1まとめ
Coroutineの基本的な機能を使って並列処理を簡単に書くことができました(Coroutine素晴らしい)
javaのconcurrency系のAPIだとcallbackを自作したりしないとならないですし、前まで使えていたAsyncTask
もDeprecatedになってしまったので、並列処理は結構もうcoroutineつかわないと無理だなぁ…とか個人的には思ってたりします。
というかCoroutineを書き慣れてしまうと他の並列処理書きたくないわ!!という感じですね。
もしここ実装ミスってるよ!とかご指摘あれば。。(一応プロダクトのコードなのでsnippetですし、かなりぼかして書いてありますのでご容赦ください)