Butter Knife、今までありがとう。 Data Binding、これからよろしく。

  • 356
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

Butter Knife、今までありがとう

あるアプリのmaster branchに,Butter Knifeへの依存をなくすPull Requestをmergeした.

image.png (12.9 kB)

いままでButter Knifeが担っていた仕事はすべてData Bindingが受け持つことになる.Data Bindingは公式はbeta releaseと言っているものの,限りなく1.0に近いRCなんじゃないかという感じがしたため実戦に投入している.

実行時に全力でReflectionするButter Knifeと違い,Data BindingはAnnotation Processingで事前に色々やってくれる方式というのも嬉しい(c.f. Butter KnifeもAnnotation Processingする方式に切り替えるっぽい? => Split the compiler and runtime into separate artifacts. by serj-lotutovici · Pull Request #323 · JakeWharton/butterknife).

「DataBindingでButterKnifeを置き換えることが出来る!」とはよく言われることではあるが,実際の事例をあまり見たことが無い気がしたのでここで紹介しておく.

Yet another Butter KnifeとしてのData Binding

View binding

before

(おそらく)みんながButter Knifeに一番求めている機能.findViewById(int id)が不要になるというアレ.

class ExampleActivity extends Activity {
  @Bind(R.id.title) TextView title;
  @Bind(R.id.subtitle) TextView subtitle;
  @Bind(R.id.footer) TextView footer;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.simple_activity);
    ButterKnife.bind(this);
  }
}

ここはすべてDataBindingで置き換え可能になる.

after

レイアウト全体を<layout></layout>でくくってあげる.

<layout>
  <LinearLayout>
    <TextView android:id="@+id/title">
    <TextView android:id="@+id/subtitle">
    <TextView android:id="@+id/footer">
  </LinearLayout>
</layout>

すると,activity_sample.xmlならActivitySampleBindingというクラスが生成される.DataBindingUtils.setContentView(Activity activity, int id)がBindingインスタンスを返すので,それを保持しておけばいい.

class ExampleActivity extends Activity {
  private ActivitySampleBinding binding;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    binding = DataBindingUtils.setContentView(this, R.layout.simple_activity);
  }
}

このBindingインスタンスがidが設定された各Viewのインスタンスを保持してくれている.

String text = binding.footer.getText();

Non-Activity binding

before

Activity以外,例えばFragmentに対するView binding.

public class FancyFragment extends Fragment {
  @Bind(R.id.button1) Button button1;
  @Bind(R.id.button2) Button button2;

  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = inflater.inflate(R.layout.fancy_fragment, container, false);
    ButterKnife.bind(this, view);
    // TODO Use fields...
    return view;
  }
}

after

生成されたbindingクラスにbind(View view)というstatic methodが生えているので,それを利用すればOK.あとはよしなに….

public class FancyFragment extends Fragment {
  private FragmentFancyBinding binding;

  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    return inflater.inflate(R.layout.fragment_fancy, container, false);
  }

  @Override
  public void onActivityCreated(Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    binding = FragmentFancyBinding.bind(getView());
  }
}

View binding (ViewHolder)

before

Butter Knifeを利用すればListViewにおけるViewHolderの実装をサボることが出来る.

public class MyAdapter extends BaseAdapter {
  @Override
  public View getView(int position, View view, ViewGroup parent) {
    ViewHolder holder;
    if (view != null) {
      holder = (ViewHolder) view.getTag();
    } else {
      view = inflater.inflate(R.layout.list_item_sample, parent, false);
      holder = new ViewHolder(view);
      view.setTag(holder);
    }

    holder.name.setText("John Doe");
    // etc...

    return view;
  }

  static class ViewHolder {
    @Bind(R.id.title) TextView name;
    @Bind(R.id.job_title) TextView jobTitle;

    public ViewHolder(View view) {
      ButterKnife.bind(this, view);
    }
  }
}

after (ListView)

Data Bindingを利用すれば,BindingクラスがViewHolderと同等の働きをしてくれるので,そもそもViewHolderが不要になる(RecyclerViewについてはちょっと違う話になるので後述する).
Data Bindingを利用しているので,「各Viewに値をsetしていく」なんてこともしなくていい(snippet内comment参照).

public class MyAdapter extends BaseAdapter {
  @Override
  public View getView(int position, View convertView, ViewGroup parent) {
      ListItemSampleBinding binding;
      if (convertView == null) {
          binding = DataBindingUtil.inflate(inflater, R.layout.list_item_sample, parent, false);
          convertView = binding.getRoot();
          convertView.setTag(binding);
      } else {
          binding = (ListItemSampleBinding) convertView.getTag();
      }

      // たとえばUserのリストなら,項目ごとではなくUserのインスタンスをsetできる
      binding.setUser(getItem(position));
      // binding.name.setText("John Doe");

      return convertView;
  }
}

after (RecyclerView)

RecyclerViewの場合,RecyclerView.ViewHolderが必須になる.このViewHolderにBindingHolderみたいな名前つけて,Bindingクラスのラッパとして働いてもらうといい.

public class SampleRecyclerAdapter extends RecyclerView.Adapter<SampleRecyclerAdapter.BindingHolder> {

    @Override
    public RegisterableDeviceListAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
      final View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_sample, parent, false);
      return new BindingHolder(v);
    }

  @Override
  public void onBindViewHolder(BindingHolder holder, int position) {
    // BindingHolder#getBinding()がViewDataBindingを返すのでsetVariable()を呼んでいる
    // 専用のBinding(この場合だとListItemSampleBinding)を返すことが出来るなら普通にsetUser()でOK
    holder.getBinding().setVariable(BR.user, getItem(position));
  }

  static class BindingHolder extends RecyclerView.ViewHolder {
    private final ViewDataBinding binding;

    public BindingHolder(View itemView) {
      super(itemView);
      binding = DataBindingUtil.bind(itemView)
    }

    public ViewDataBinding getBinding() {
      return binding;
    }
  }
}

Listener binding (onClick)

before

@OnClick@OnItemClickなどのアノテーションを付けてあげることで,勝手にsetOnClickListener()したみたいな動きをしてくれる.

@OnClick(R.id.submit)
public void submit(View view) {
  // TODO submit data to server...
}

after

レイアウトにActivityのインスタンス渡してあげて,Buttonandroid:onClick属性にリスナメソッドを渡してあげる.

<layout>
  <data>
    <variable name="activity" type="info.izumin.android.databindingsample.SampleActivity" />
  </data>
  <LinearLayout>
    <Button android:onClick="@{activity.onSampleButtonClick}">
  </LinearLayout>
</layout>

ActivityのonCreate()ではbindingインスタンスにActivityのインスタンスをセットしておけば,あとはいい感じにやってくれる.

class SampleActivity extends Activity {
  private ActivitySampleBinding binding;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    binding = DataBindingUtils.setContentView(this, R.layout.simple_activity);
    binding.setActivity(this);
  }

  public void onSampleButtonClick(View view) {
    // do something...
  }
}

ただ,先の例だとActivityはLayoutを参照しており,LayoutはActivityを参照している…のように,完全な密結合になってしまう.これが嫌な場合,イベントハンドリングだけを担当するinterfaceを作ってあげればいい.これで程よい感じの疎結合化・責務の分離が実現できる.

interface SampleActivityHandlers {
  void onSampleButtonClick(View view);
}

Clean ArchitectureでいうところのControllerに該当するかな?

その他DataBindingの便利機能

Listener bindings

DataBindingは@BindingAdapter@BindingMethodといったアノテーションを利用した変態annotation processingにより,OnClickListener以外のイベントリスナも設定できる.

<Button android:onClick="@{handlers.onPrevButtonClick}" />
<Button android:onClick="@{handlers.onNextButtonClick}" />
<EditText android:onTextChanged="@{handlers.onTextChanged}" />
<ListView android:onScroll="@{handlers.onScroll}" />
interface SampleActivityHandlers {
  void onPrevButtonClick(View view);
  void onNextButtonClick(View view);
  void onTextChanged(CharSequence s, int start, int before, int count);
  void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount);
}

標準で用意されているAdapterについてはextensions/baseAdapters/.../data-binding/以下のファイルを参照されたい.

Adapter binding

@BindingAdapter及び@BindingMethod悪用事例.

@BindingMethods({
    @BindingMethod(type = SwipeRefreshLayout.class, attribute = "android:onRefresh", method = "setOnRefreshListener"),
    @BindingMethod(type = RecyclerView.class, attribute = "android:adapter", method = "setAdapter")
})
public final class ViewBindingUtils {
}
public interface SampleActivityHandlers {
  void onRefresh();
}
<layout>
  <data>
    <variable name="handlers"
      type="info.izumin.android.databindingsample.SampleActivityHandlers" />
    <variable name="adapter"
      type="android.support.v7.widget.RecyclerView.Adapter" />
  </data>
  <android.support.v4.widget.SwipeRefreshLayout
    android:onRefresh="@{handlers.onRefresh}" >

    <android.support.v7.widget.RecyclerView
      android:adapter="@{adapter}" />

  </android.support.v4.widget.SwipeRefreshLayout>
</layout>

ここまでやる必要性がどこにあるかはわからない.

Image source binding

「何らかの値に合わせて表示する画像を切り替える」みたいなのも,BindingAdapterを利用すればサクッと書ける.static methodになるのでテストも楽?

public final class ViewBindingUtils {
  @BindingAdapter("signalStrength")
  public static void setSignalStrengthIcon(ImageView imageView, BluetoothDevice device) {
    int resId = R.mipmap.ic_signal_weak;
    final int rssi = device.getRssi();
    if (rssi >= -40) {
      resId = R.mipmap.ic_signal_strong;
    } else if (rssi < -40 && rssi > -60){
      resId = R.mipmap.ic_signal_medium;
    }
    imageView.setImageResource(resId);
  }
}
<layout>
  <data>
    <variable name="device" type="android.bluetooth.BluetoothDevice" />
  </data>
  <LinearLayout>
    <ImageView app:signalStrength="@{device}" />
    <TextView android:text="@{device.getName()}" />
    <TextView android:text="@{device.getAddress()}" />
  </LinearLayout>
</layout>

BindingAdapterで増やす属性のnamespace,何も付けない(app)にするのとandroidにするのとどちらがいいんでしょうね.

Data Binding on custom view

当然ながら,Data BindingはCustom Viewに対しても利用できる.@BindingAdapter適切に利用すれば,attrs.xml書いてTypedArrayからcustom attrsを取得して…みたいなのを全力でサボることが出来る.それの良し悪しは別として.

public class Pagination extends RelativeLayout {
  private ViewPaginationBinding binding;

  public Pagination(Context context) {
    this(context, null);
  }

  public Pagination(Context context, AttributeSet attrs) {
    super(context, attrs);
    binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.view_pagination, this, true);
  }

  public static void setListener(Pagination paginate, View target, OnPaginationClickListener listener) {
    if (listener != null) {
      target.setOnClickListener(_v -> listener.onClick(paginate));
    }
  }

  @BindingAdapter({"android:onPrevButtonClicked"})
  public static void setPrevClickListener(Pagination view, OnPaginationClickListener listener) {
    setListener(view, view.binding.btnPrevPage, listener);
  }

  @BindingAdapter({"android:onNextButtonClicked"})
  public static void setNextClickListener(Pagination view, OnPaginationClickListener listener) {
    setListener(view, view.binding.btnNextPage, listener);
  }

  public interface OnPaginationClickListener {
    void onClick(Pagination pagination);
  }
}

Butter Knife -> Data Bindingで置き換え不可な機能

Resource binding

これはDataBindingには存在しない.

class ExampleActivity extends Activity {
  @BindString(R.string.title) String title;
  @BindDrawable(R.drawable.graphic) Drawable graphic;
  @BindColor(R.color.red) int red; // int or ColorStateList field
  @BindDimen(R.dimen.spacer) Float spacer; // int (for pixel size) or float (for exact value) field
  // ...
}

View lists

View listsは複数のViewをひとまとめにして扱える機能…らしい.この記事書いてるときにはじめて知った.

// こうして…
@Bind({ R.id.first_name, R.id.middle_name, R.id.last_name })
List<EditText> nameViews;

// こういうのを用意しておいてあげると…
static final ButterKnife.Action<View> DISABLE = new ButterKnife.Action<View>() {
  @Override public void apply(View view, int index) {
    view.setEnabled(false);
  }
};

// こんな感じにまとめて適用できるよ!
ButterKnife.apply(nameViews, DISABLE);

これもData Bindingには存在しない.

まとめ

  • Butter Knifeの機能はだいたいData Bindingで代替可能
    • Resource binding,View listsのみData Bindingでは代替不可
  • ViewHolderパターンはすべて置き換え可能
    • RecyclerView.ViewHolderはBindingのwrapperとして振る舞わせればOK
  • @BindingAdapter@BindingMethod悪用利用すればわりとなんでもできる

みんなもData Bindingで遊ぼう.

References

この投稿は Android Advent Calendar 201515日目の記事です。