Edited at

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

More than 3 years have 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()を忘れずに記述するというプレッシャーが心地よくないので、効率についてはまぁいいやと思うことにしています。