はじめに
Kotlinを書かれる方に質問です!!
例えばBuilderパターンで生成されるインスタンスをクラス内で使い回したいとき、
反射的にこんな風に書いていませんか?
class Sample {
companion object {
// 使い回したいインスタンス
private val a: A = A.Builder().build()
}
}
class A {
internal class Builder {
fun build(): A = A()
}
}
companion object
の中で初期化処理を書いておけば、
Sampleクラスで何個もインスタンスが作られようと Sample.Companion.a
は使いまわされます。
class Sample {
fun getA() = a
companion object {
private val a: A = A.Builder().build()
}
}
fun main() {
println(Sample().getA().hashCode())
println(Sample().getA().hashCode())
println(Sample().getA().hashCode())
}
// 【実行結果】
// 191382150
// 191382150
// 191382150
ふむふむ。とても便利!
これなら、重たい初期化処理であったとしても1回で済むので安心ですね。
はい。。。これでやらかしました。
ある日の事件
ある日、とあるAndroidアプリの初期描画が遅くなる事象が起こりました。
下記のような呼び出しを行なっているライブラリのバージョンを上げたことによって初期描画が遅くなったとのこと。
class MyApp: Application() {
override fun onCreate() {
super.onCreate()
// WorkManagerで定期更新処理を行うために、
// Applicationクラスでライブラリの呼び出しを行なった。
HogeLib.setup()
}
}
調査してみると
Android Profiler で確認してみると確かに HogeLib.setup()
が遅いような気配が。。。
実装がどうなっていたか
object HogeLib {
// DIするためにリポジトリ層をインスタンス化していた
private val repository = HogeRepository()
fun setup() {
// WorkManagerの登録処理
}
}
internal class HogeRepository {
companion object {
private val PARSER = HogeParser.Builder().build()
}
}
/**
* パース処理を行うためのクラス
* 初期化処理が重め
*/
class HogeParser {
class Builder {
fun build(): HogeParser {
Thread.sleep(100) // 重めの処理
return HogeParser()
}
}
}
※ 説明のために Thread.sleep を使用しています。
状況整理
HogeParser は何らかのパース処理を行うクラスで初期化に少し時間がかかるもののようでした。
ただ、この HogeParser は別のライブラリに切り分けて作ってあったため、
HogeRepository から見れば内部処理自体はブラックボックス化されていました。
そんな中、改修を重ねていた結果、
あるアップデートのタイミングで初期化に時間がかかる考慮が抜けてしまいました。
対応策
遅延プロパティ(lazy) を使って最初の呼び出しのタイミングでインスタンス化する。
class HogeRepository {
companion object {
private val PARSER by lazy { HogeParser.Builder().build() }
}
}
こうすれば、lazyの処理が後回しになるため処理時間も短縮されましたね。
初期化に時間が掛かっていたシングルトンのインスタンスが使用時に生成されるようになり良かった!
・・・と終わりたいのですが、
果たして盲目的にlazyして良いか気になったので追加で調査しました。
気になったポイント
-
companion object
の処理が走るタイミングはいつ? - サブスレッドで呼び出されたらどうなるの?
ここらへんを整理していきます。
companion object
の処理が走るタイミングはいつ?
はじめに、便利関数を用意しておきます。
/**
* 実行しているスレッド名と共にログを表示
* メインスレッドで printLog("sample log"): [main] sample log ->
*/
fun printLog(message: String, vararg args: Any?) {
val threadName = Thread.currentThread().name.removePrefix("DefaultDispatcher-")
print("[$threadName] " + message.format(*args) + " -> ")
}
/**
* 実行に 100ms かかる処理
*/
private fun setup(target: String) {
printLog("setup(\"${target}\")")
Thread.sleep(100)
}
パターン1: 初期化したときに 100ms のスリープが走るパターン
class Sample {
init {
printLog("init()")
}
fun getA() = a
companion object {
private val a = setup("a")
}
}
fun main() {
var sample1: Sample?
var sample2: Sample?
println("time: ${measureTimeMillis { sample1 = Sample() }}ms")
println("time: ${measureTimeMillis { sample2 = Sample() }}ms")
println("time: ${measureTimeMillis { sample1?.getA() }}ms")
println("time: ${measureTimeMillis { sample2?.getA() }}ms")
}
// 【実行結果】
// [main] setup("a") -> [main] init() -> time: 127ms
// [main] init() -> time: 0ms
// time: 0ms
// time: 0ms
パターン2: 初期化したときに lazy するパターン
fun main() {
var sample1: Sample?
var sample2: Sample?
println("time: ${measureTimeMillis { sample1 = Sample() }}ms")
println("time: ${measureTimeMillis { sample2 = Sample() }}ms")
println("time: ${measureTimeMillis { sample1?.getB() }}ms")
println("time: ${measureTimeMillis { sample2?.getB() }}ms")
}
class Sample {
init {
printLog("init()")
}
fun getB() = b
companion object {
private val b by lazy { setup("b") }
}
}
// 【実行結果】
// [main] init() -> time: 49ms
// [main] init() -> time: 0ms
// [main] setup("b") -> time: 105ms
// time: 0ms
はい、期待通りの動きですね。
lazyを使うと初期化に30ms程度かかるようですが、まあ大丈夫でしょう。
サブスレッドで呼び出されたらどうなるの?
パターン3: パターン1をサブスレッドで行うパターン
coroutinesを使っていきます。
dependencies {
// coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
}
suspend fun main() {
var sample1: Sample?
var sample2: Sample?
println("time: ${measureTimeMillis { sample1 = withContext(Dispatchers.Default) { Sample() } }}ms")
println("time: ${measureTimeMillis { sample2 = withContext(Dispatchers.Default) { Sample() } }}ms")
println("time: ${measureTimeMillis { withContext(Dispatchers.Default) { sample1?.getA() } }}ms")
println("time: ${measureTimeMillis { withContext(Dispatchers.Default) { sample2?.getA() } }}ms")
}
// 【実行結果】
// [worker-1] setup("a") -> [worker-1] init() -> time: 170ms
// [worker-1] init() -> time: 1ms
// time: 0ms
// time: 0ms
ワーカースレッドで初期化すると
companion object
の初期化処理もワーカースレッドで行われる。
パターン4: パターン2をサブスレッドで行うパターン
suspend fun main() {
var sample1: Sample?
var sample2: Sample?
println("time: ${measureTimeMillis { sample1 = withContext(Dispatchers.Default) { Sample() } }}ms")
println("time: ${measureTimeMillis { sample2 = withContext(Dispatchers.Default) { Sample() } }}ms")
println("time: ${measureTimeMillis { withContext(Dispatchers.Default) { sample1?.getB() } }}ms")
println("time: ${measureTimeMillis { withContext(Dispatchers.Default) { sample2?.getB() } }}ms")
}
// 【実行結果】
// [worker-1] init() -> time: 82ms
// [worker-1] init() -> time: 1ms
// [worker-1] setup("b") -> time: 105ms
// time: 1ms
こちらも、ワーカースレッドで初期化すると
companion object
の初期化処理もワーカースレッドで行われる。
最初のinit()
の時間が伸びてしまいましたね。。。
まとめ
-
companion object
が走るタイミングはinit()
の前 -
init()
がサブスレッドで実行されれば、companion object
もサブスレッドで実行される - 初めてクラスを初期化するときには、それなりにコストがかかる(数十msくらい)
- 根本として初期化処理はアプリのライフサイクルに影響を与えない場所で行うように心がける
今一度実装を見直すきっかけになりました。
是非、皆さんも気を遣ってみて下さい