nashcft's blog

時々何か書く。

Kotlin Android Extensions の view binding に関する知識の整理

Kotlin Android Extensions の解説記事、Kotlin のリファレンスとか英語ソースだったらよくまとまってるのがあるけど日本語ソースだとあんまりいい感じのないなーという印象なので自分でまとめてみる。ただし view binding の方だけ。英語が読めるなら以下の記事を読めばほぼ十分という感じの内容。

kotlinlang.org

antonioleiva.com

Kotlin Android Extensions の view binding ってなに

  • View の要素に property access をするようにアクセスできるようにする
  • アクセスした view をキャッシュしてくれて2度目以降は findViewById() が呼ばれないようになる (ならない場合もある)

導入

build.gradle

apply plugin: 'kotlin-android-extensions'

使い方

1. Activity, Fragment, カスタム View

欲しい要素のID名で property access 風にアクセスする

// e.g. Activity の場合: activity_main に id が greeting_text な TextView があるとする
class MainActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        greeting_text.text = "Hello, World!"
    }

    // ...
}

Fragment における注意点

Fragment においては onCreateView() でアクセスすると NullPointerException になるため、Kotlin Android Extensions でのアクセスは onViewCreated() 以降で行う必要がある。これは Fragment では内部的に Fragment#getView() を用いて root view にアクセスしており、その返却値は onCreateView() が返す値であり、onCreateView() が呼ばれている時点では null が返ってくるから。

2. 任意の View インスタンスに対する子 View へのアクセス

例えば ViewHolder の中で itemView.title といった感じで property access 風に子 View にアクセスできる。

挙動について

2種類の package

実際の挙動を見てみる前に、 Kotlin Android Extensions の view binding には2種類の package があることを紹介する。

  • kotlinx.android.synthetic.<main or flavor>.<layout_name>.*
    • Activity, Fragment 内で自身の要素にアクセスする場合はこちらが import される
    • また後述する LayoutContainer の実装クラスでもこちらが使われる
    • 以後 .* と呼ぶ
  • kotlinx.android.synthetic.<main or flavor>.<layout_name>.view.*
    • カスタム View 内で自身の要素にアクセスするときと、任意の View インスタンスの子 View にアクセスする場合はこちらが import される
    • 以後 .view.* と呼ぶ

アクセスした View のキャッシュ

Activity と Fragment では.* が使われアクセスした要素をキャッシュするようになる。これはバイトコードデコンパイルして生成された Java コードから確認でき、view binding で View へのアクセスを実装した Activity/Fragment には _$_findCachedViewById() という関数が生えて、.* でアクセスしている部分はそれを使って View へのアクセスするといったコードになっている。

// Activity の場合
   private HashMap _$_findViewCache;

   public View _$_findCachedViewById(int var1) {
      if (this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }

      View var2 = (View)this._$_findViewCache.get(var1);
      if (var2 == null) {
         var2 = this.findViewById(var1);
         this._$_findViewCache.put(var1, var2);
      }

      return var2;
   }

2.の用法の場合、.view.* が使われるが、Javaデコンパイルした結果を見ると単に対象の View から findViewById() でアクセスしているだけだったりする。なのでこちらの使い方をするとアクセスした View がキャッシュされず、アクセスする度に毎回 findViewById() が呼ばれるので注意。

ところで package の紹介でも触れたが 1. の用法でカスタム View の場合 View のアクセスをキャッシュするようになりデコンパイルすると _$_findCachedViewById() が生えているのが確認できるが、 import されるのは .view.* の方である。Package とキャッシュの有無に関連性があるわけではない。

あと、例えば自分で実装した Activity の拡張関数内で view binding で View にアクセスするように実装した時はキャッシュが効くが、ライブラリの ActivityAppCompatActivity などに対する拡張関数で同様の実装をした場合は、たとえレシーバが自分の実装した Activity だったとしてもキャッシュが効かない。大雑把に解釈するとどこから呼ばれるかわからないからという感じだけど、そもそも特定のレイアウトと紐ついている view binding をそんな汎用的な所で使わないよね、複数の Activity でレイアウトを共有するようなことがあってもそのレイヤで共通化するのはちょっと... という所感。

Fragment における注意点再訪

「使い方」で onCreateView() 中で呼ぶと NPE になると書いたが、例えば以下のように書くと NPE にならなかったりする。

override fun onCreateView(inflater: LayoutInflater, 
                          container: ViewGroup?, 
                          savedInstanceState: Bundle?) : View? =
    inflater.inflate(R.layout.fragment_foo, container, false).apply {
        greeting.text = "Hello"
    }

どういうことかと言えば、inflate した View に対する apply の中で実行しているため、Fragment に対してではなくここで生成した View の子 View に対するアクセスと認識され、 .view.* の方が使われているという感じ。この場合ここでのアクセスは先ほどの説明の通りキャッシュされないので意図的でないならやめておいた方が良い。Fragment 内で初めて書いた View へのアクセスがこれだった場合に起こることが多く、他ですでにアクセス処理を書いた上でこのように書くと .* の方を使われて NPE になるなんてこともある。

LayoutContainer

View を property として持つ任意のクラスで View のアクセスをキャッシュしてくれるようにする。ViewHolder でよく使う。

1.3.0 RC 現在 experimental なので使う場合は experimental フラグを true にする必要がある。

build.gradle

androidExtensions {
    experimental = true
}

使い方

対象のクラスに LayoutContainer を実装する。

LayoutContainer は↓のような感じ。View binding の対象となる root view を property として持つだけのシンプルなインターフェイス

public interface LayoutContainer {

    public val containerView: View?
}

実装する側は例えば RecyclerView.ViewHolder なら以下のような感じ:

class ItemViewHolder (
    override val containerView: View
) : RecyclerView.ViewHolder(containerView), LayoutContainer {

    fun bind(item: Item) {
        item_title.text = item.title
        item_description.text = item.description 
    }
} 

こんな感じで実装してると synthetic.main.viewholder_item.* みたいに .view.* じゃない方が import されてるし、デコンパイルして見てみるときちんと _$_findCachedViewById が生えててそれが使われているのがわかる。

// デコンパイルの結果
public final class ItemViewHolder extends ViewHolder implements LayoutContainer {
   @NotNull
   private final View containerView;
   private HashMap _$_findViewCache;

   public final void bind(@NotNull Item item) {
      Intrinsics.checkParameterIsNotNull(item, "item");
      TextView var10000 = (TextView)this._$_findCachedViewById(id.item_title);
      Intrinsics.checkExpressionValueIsNotNull(var10000, "item_title");
      var10000.setText((CharSequence)item.getTitle());
      var10000 = (TextView)this._$_findCachedViewById(id.item_description);
      Intrinsics.checkExpressionValueIsNotNull(var10000, "item_description");
      var10000.setText((CharSequence)item.getDescription());
   }

   @NotNull
   public View getContainerView() {
      return this.containerView;
   }

   private ItemViewHolder(View containerView) {
      super(containerView);
      this.containerView = containerView;
   }

   public View _$_findCachedViewById(int var1) {
      if (this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }

      View var2 = (View)this._$_findViewCache.get(var1);
      if (var2 == null) {
         View var10000 = this.getContainerView();
         if (var10000 == null) {
            return null;
         }

         var2 = var10000.findViewById(var1);
         this._$_findViewCache.put(var1, var2);
      }

      return var2;
   }

   public void _$_clearFindViewByIdCache() {
      if (this._$_findViewCache != null) {
         this._$_findViewCache.clear();
      }

   }
}

キャッシュ方法の変更

_$_findCachedViewById() によるキャッシュはデフォルトでは HashMap を使っているが、これを変更することができる。これも 1.3.0 RC 現在 experimental となっている。

選択肢は HashMap / SparseArray / キャッシュ無し。build.gradle に書くことで project 全体に、@ContainerOptions アノテーションを使うことでクラス単位で設定できる。

build.gradle に書く場合

androidExtensions {
    defaultCacheImplementation = "SPARSE_ARRAY"
}

アノテーションを使う場合

@ContainerOptions(cache = CacheImplementation.NO_CACHE)
class FooActivity : Activity()

切り替えたことないので特にどれがどうという知見はない。

おわりに

Kotlin Android Extensions の view binding に関して書けるだけ書いてみた。表面的には View へのアクセスが簡単にできるもの程度だけど、その裏側でどのように View を取得しているかとか、キャッシュの有無とかまで知っておけば変な挙動に悩まされるみたいなことはないと思われる。あとはバイトコードから Javaデコンパイルしたものを読めばどういう挙動になっているか大体把握できるのであれ? と思ったらとりあえずデコンパイルするとよい。

Kotlin Android Extensions にはもう一つ Parcelable 関連の機能があるけど、これは今回の関心事から外れるし記事にするほど情報量ないので公式の解説読んでください。

参考