Android
Kotlin
Fragment
KotterKnife

FragmentではKotterKnifeをうかつに使えないという話

More than 1 year has passed since last update.

KotlinでViewのバインドをするのに、ButterKnifeの代わりにKotterKnifeを使うというのは、素直な選択だと思うのですが、Fragmentで使う場合、Fragmentの性質とKotterKnifeの実装の関係から、意図したとおりには動作しないことがあります。

KotterKnifeはなにをしてくれる?

KotterKnifeでは、KotlinのDelegationを利用して、変数の初期化を遅延して、必要なときにfindViewById()してくれます。
ただし、一度、findViewById()で見つけたViewはキャッシュ(メモ化)して、それ以後はキャッシュしたViewを返すようになっています。

Fragmentのライフサイクルを思い出してみよう

http://developer.android.com/intl/ja/guide/components/fragments.html#Creating

Fragmentで画面遷移をする場合、遷移先から戻ってくるとき(バックスタックから戻されるとき)、onCreateView()が呼ばれます。
通常、そこではリソースで定義したレイアウトをinflate()して返します。

なにが問題?

KotterKnifeがfindViewById()するのは、最初の一回だけです。
Fragmentでは、Viewを生成するのは一回だけとは限りません。

つまり、Fragmentが抱えるViewとFragment内で変数にバインドされたViewが異なってしまう場合があるわけです。というか、onCreateView()でViewを作り直している限り、遷移先から戻ってきたFragmentでは、必ず異なるものになってしまいます。

結局

KotterKnifeを使うのはやめました。

キャッシュをクリアできるdelegateならいけそうなので、

ViewDelegate.kt
class ViewDelegate(var rootView: View? = null) {

    private val delegates = ArrayList<Bind<*>>()

    fun <V: View> bind(id: Int): Bind<V> {
        val delegate = Bind<V>(id)
        delegates.add(delegate)
        return delegate
    }

    fun clearAll() {
        delegates.forEach { it.clear() }
    }

    inner class Bind<V: View>(val id: Int) : ReadOnlyProperty<Any, V> {
        var root: View? = null
        var view: View? = null

        @Suppress("UNCHECKED_CAST")
        override fun getValue(thisRef: Any, property: KProperty<*>): V {
            if (view == null || root != rootView) {
                root = rootView
                if (root != null) {
                    view = root!!.findViewById(id)
                } else {
                    throw IllegalStateException("root view is null.")
                }
            }
            if (view != null) {
                return view!! as V
            } else {
                throw IllegalStateException("View ID $id for '${property.name}' not found.")
            }
        }

        fun clear() {
            root = null
            view = null
        }
    }
}

こんなクラスを作って、

FooFragment.kt
class FooFragment : Fragment() {
    val views = ViewDelegate()
    val hogeButton: Button by views.bind(R.id.hoge_button)
    val fugaText: TextView by views.bind(R.id.fuga_text)

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view inflater.inflate(R.layout.fragment_foo, container, false)
        views.rootView = view
        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        views.clearAll()
    }

    ...
}

こんな風に使うようにしました。

BindクラスのインスタンスがViewを保持するので、メモリリークしないようにonDestroyView()でclearAll()してますが、定型処理なのでちょっといやんな感じですね。
ViewHolderパターンでも使えるように、rootViewを保持しているのですが、Fragmentを保持するようにすれば、views.rootView = viewとしているところも省けそうです。
誰か、いい感じにまとめてくれる人が現れるとうれしいですね。

2016.5.23 追記

Delegateについて、いろいろ紆余曲折あって、今では、

Views.kt
@Suppress("UNCHECKED_CAST")
object Views {

    fun <V: View> bind(activity: Activity, id: Int): Lazy<V> = lazy { activity.findViewById(id) as V }
    fun <V: View> bind(fragment: android.support.v4.app.Fragment, id: Int): Bind4<V> = Bind4(fragment, id)
    fun <V: View> bind(fragment: android.app.Fragment, id: Int): Bind<V> = Bind(fragment, id)
    fun <V: View> bind(view: View, id: Int): Lazy<V> = lazy { view.findViewById(id) as V }

    class Bind<V: View>(val fragment: android.app.Fragment, val id: Int) : ReadOnlyProperty<Any, V> {

        override fun getValue(thisRef: Any, property: KProperty<*>): V {
            return fragment.view?.findViewById(id) as V
        }
    }

    class Bind4<V: View>(val fragment: android.support.v4.app.Fragment, val id: Int) : ReadOnlyProperty<Any, V> {

        override fun getValue(thisRef: Any, property: KProperty<*>): V {
            return fragment.view?.findViewById(id) as V
        }
    }
}

こんなobjectを作って、

FooFragment.kt
class FooFragment : Fragment() {
    val hogeButton: Button by Views.bind(this, R.id.hoge_button)
    val fugaText: TextView by Views.bind(this, R.id.fuga_text)

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_foo, container, false)
    }

    ...
}

こんな風に使っています。

Fragmentの場合には、毎回、findViewById()していて効率は悪いのですが、clearAll()を忘れずに記述するというプレッシャーが心地よくないので、効率についてはまぁいいやと思うことにしています。