LoginSignup
3
2

More than 1 year has passed since last update.

Kotlinの初期化を雑に書いてはダメ!

Last updated at Posted at 2022-01-03

はじめに

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アプリの初期描画が遅くなる事象が起こりました。
下記のような呼び出しを行なっているライブラリのバージョンを上げたことによって初期描画が遅くなったとのこと。

MyApp.kt
class MyApp: Application() {
    override fun onCreate() {
        super.onCreate()

        // WorkManagerで定期更新処理を行うために、
        // Applicationクラスでライブラリの呼び出しを行なった。
        HogeLib.setup()
    }
}

調査してみると

Android Profiler で確認してみると確かに HogeLib.setup() が遅いような気配が。。。
スクリーンショット_2022-01-02_23_07_30.png

実装がどうなっていたか

HogeLib.kt
object HogeLib {
    // DIするためにリポジトリ層をインスタンス化していた
    private val repository = HogeRepository()

    fun setup() {
        // WorkManagerの登録処理
    }
}
HogeRepository.kt
internal class HogeRepository {
    companion object {
        private val PARSER = HogeParser.Builder().build()
    }
}
HogeParser.kt
/**
 * パース処理を行うためのクラス
 * 初期化処理が重め
 */
class HogeParser {
    class Builder {
        fun build(): HogeParser {
            Thread.sleep(100) // 重めの処理
            return HogeParser()
        }
    }
}

※ 説明のために Thread.sleep を使用しています。

状況整理

HogeParser は何らかのパース処理を行うクラスで初期化に少し時間がかかるもののようでした。

ただ、この HogeParser は別のライブラリに切り分けて作ってあったため、
HogeRepository から見れば内部処理自体はブラックボックス化されていました。

そんな中、改修を重ねていた結果、
あるアップデートのタイミングで初期化に時間がかかる考慮が抜けてしまいました。

対応策

遅延プロパティ(lazy) を使って最初の呼び出しのタイミングでインスタンス化する。

HogeRepository.kt
class HogeRepository {
    companion object {
        private val PARSER by lazy { HogeParser.Builder().build() }
    }
}

こうすれば、lazyの処理が後回しになるため処理時間も短縮されましたね。

スクリーンショット_2022-01-02_23_55_59.png

初期化に時間が掛かっていたシングルトンのインスタンスが使用時に生成されるようになり良かった!
・・・と終わりたいのですが、
果たして盲目的にlazyして良いか気になったので追加で調査しました。

気になったポイント

  • companion object の処理が走るタイミングはいつ?
  • サブスレッドで呼び出されたらどうなるの?

ここらへんを整理していきます。

companion object の処理が走るタイミングはいつ?

はじめに、便利関数を用意しておきます。

Log.kt
/**
 * 実行しているスレッド名と共にログを表示
 * メインスレッドで 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) + " -> ")
}
Sample.kt
/**
 * 実行に 100ms かかる処理
 */
private fun setup(target: String) {
    printLog("setup(\"${target}\")")
    Thread.sleep(100)
}

パターン1: 初期化したときに 100ms のスリープが走るパターン

Sample.kt
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 するパターン

Sample.kt
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を使っていきます。

build.gradle
dependencies {
    // coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
}
Sample.kt
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をサブスレッドで行うパターン

Sample.kt
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くらい)
  • 根本として初期化処理はアプリのライフサイクルに影響を与えない場所で行うように心がける

今一度実装を見直すきっかけになりました。
是非、皆さんも気を遣ってみて下さい:pray:

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2