KotlinでViewのバインドをするのに、ButterKnifeの代わりにKotterKnifeを使うというのは、素直な選択だと思うのですが、Fragmentで使う場合、Fragmentの性質とKotterKnifeの実装の関係から、意図したとおりには動作しないことがあります。
KotterKnifeはなにをしてくれる?
KotterKnifeでは、KotlinのDelegationを利用して、変数の初期化を遅延して、必要なときにfindViewById()してくれます。
ただし、一度、findViewById()で見つけたViewはキャッシュ(メモ化)して、それ以後はキャッシュしたViewを返すようになっています。
Fragmentのライフサイクルを思い出してみよう
Fragmentで画面遷移をする場合、遷移先から戻ってくるとき(バックスタックから戻されるとき)、onCreateView()が呼ばれます。
通常、そこではリソースで定義したレイアウトをinflate()して返します。
なにが問題?
KotterKnifeがfindViewById()するのは、最初の一回だけです。
Fragmentでは、Viewを生成するのは一回だけとは限りません。
つまり、Fragmentが抱えるViewとFragment内で変数にバインドされたViewが異なってしまう場合があるわけです。というか、onCreateView()でViewを作り直している限り、遷移先から戻ってきたFragmentでは、必ず異なるものになってしまいます。
結局
KotterKnifeを使うのはやめました。
キャッシュをクリアできるdelegateならいけそうなので、
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
}
}
}
こんなクラスを作って、
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について、いろいろ紆余曲折あって、今では、
@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を作って、
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()を忘れずに記述するというプレッシャーが心地よくないので、効率についてはまぁいいやと思うことにしています。