Blog

fragmentでもby lazyっぽく書きたい

fragmentが難しい理由の一つとして、viewの生存期間よりfragmentの生存期間のほうが長い、というところがあると思います。

そのため、以下のようなコードは問題があります。

(data binding / view bindingの登場でそもそもfindViewByIdするコードをあまり見かけなくなりましたが)

// 例1: by lazyを使う
class HogeFragment: Fragment() {
    private val button: View by lazy {
        requireView().findViewById(R.id.button)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        ...
        button.setOnClickListeenr {
            ...
        }
    }
}
// 例2: lateinitを使う
class HogeFragment: Fragment() {
    private lateinit var button: View

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        ...
        button = requireView().findViewById(R.id.button)
        button.setOnClickListeenr {
            ...
        }
    }
}

例1では、viewが再生成された際に以前のviewがそのまま使われ、新しいviewに正しくllistenerが設定されません。

(例えば、fragmentをdetach後、再度attachするとfragmentはそのままでviewが再生成される)

例2では、一見正しく動くように見えますが、onDestroyで破棄されたviewへの参照を持ち続けるので、メモリリークが発生することになります。

正しく動くコード

したがって、以下のようなコードが正しく動きます。

// 例3: onDestroyViewで参照を切る
class HogeFragment: Fragment() {
    private var button: View? = null

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        ...
        button = requireView().findViewById(R.id.button)
        button?.setOnClickListeenr {
            ...
        }
    }

    override fun onDestroyView() {
        button = null
    }
}

data binding / view binding / recycler viewのadapter等も本来このような形で書く必要があります。

しかし、このコードを毎回書くのは、以下の理由で大変です。

  • onDestroyViewで忘れずにnullにしなければならない
  • onCreateViewからonDestroyViewまでは必ずnonnullなのに毎回null check(もしくは強制unwrap)しなければならない

AutoClearedValue

これらの解決策としてgoogleのAAC sampleではAutoClearedValueというもの使っています。

以下のように使うことができます。

// 例4: AutoClearedValue
class HogeFragment: Fragment() {
    private var button: View by autoCleared()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        ...
        button = requireView().findViewById(R.id.button)
        button.setOnClickListeenr {
            ...
        }
    }
}

これでbuttonをnonnullで扱え、なおかつonDestroyViewでnullを入れなくても勝手にnullになってくれます。

しかし、この方法でも以下のような不満を感じました。

  • 様々なプロパティーの初期化がonCreateViewに集まり、肥大化する。
  • 各プロパティーの定義が追いにくい
  • できればvarではなくvalで扱いたい

そこで、by lazyのように扱えるReadOnlyAutoClearedValueを作ってみます。

ReadOnlyAutoClearedValue

早速ですが実際のコードです

class ReadOnlyAutoClearedValue<T : Any>(
    private val fragment: Fragment,
    private val initializer: () -> T
) : ReadOnlyProperty<Fragment, T> {
    private var value: T? = null

    private val lifecycleObserver = object : DefaultLifecycleObserver {
        override fun onDestroy(owner: LifecycleOwner) {
            value = null
        }
    }

    init {
        fragment.viewLifecycleOwnerLiveData.observe(fragment) {
            it.lifecycle.addObserver(lifecycleObserver)
        }
    }

    override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
        val isInitialized = getCurrentViewState()?.isAtLeast(Lifecycle.State.INITIALIZED) ?: false
        if (!isInitialized) {
            throw IllegalStateException(
                "Can't access the ReadOnlyAutoClearedValue when getView() is null i.e., before onCreateView() or after onDestroyView()"
            )
        }
        return getOrCreateValue()
    }

    private fun getCurrentViewState(): Lifecycle.State? {
        return try {
            fragment.viewLifecycleOwner.lifecycle.currentState
        } catch (e: IllegalStateException) {
            null
        }
    }

    private fun getOrCreateValue(): T {
        return value ?: initializer().also {
            value = it
        }
    }
}

fun <T : Any> Fragment.autoCleared(initializer: () -> T) =
    ReadOnlyAutoClearedValue(this, initializer)

ちょっと長いですが、以下のようになっています。

  • 予めinitializerをlambdaで渡しておく
  • 初回アクセス時にinitializerが実行され、2回目以降のアクセスはその値が使われる
  • onDestoryViewが呼ばれたタイミングで参照が切られる
  • その後もう一度アクセスしたらinitializerが実行される
  • viewLifecycleOwnerが初期化される前、onDestroyViewが呼ばれた後に値を参照しようとしたらIllegalStateExceptionを吐く(initializerは実行されない)

このように使います。

// 例4: ReadOnlyAutoClearedValue
class HogeFragment: Fragment() {
    private val button: View by autoCleared {
        requireView().findViewById(R.id.button)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        ...
        button.setOnClickListeenr {
            ...
        }
    }
}

かなりシンプルになったのではないかと思います。

まとめ

今回はわかりやすいようにfindViewByIdで表記しましたが、data bindning / view bindingの登場でこのようなケースは減っていると思います。

しかし、recycler viewのadapterやepoxyのcontroller等はこのような形でメンバで保持するのがいいのかもしれません。

fragmentは複雑で色々なハマリポイントがあるので、注意深く扱ってあげる必要がありそうです。

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも