ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

EpoxyとDoNotHashとequals()

こんにちは、tkyです。

Androidネタです。epoxyの話です。 ※epoxy is 何の話はしません

epoxyはデータバインディングをサポートしていて、リソースファイルからデータバインディング用のモデルクラスを自動生成してくれたりします。

github.com

上記のドキュメントを読んだり、差分更新について調べているとたまに見かける 「 DoNotHash 」これが何者なのかを少し深堀りして調べてみました。

public @interface EpoxyDataBindingPattern {
  Class<?> rClass();
  String layoutPrefix();
  /**
   * If true, any variable whose type does not implement equals and hashcode will have the
   * {@link EpoxyAttribute.Option#DoNotHash} behavior applied to them automatically.
   * <p>
   * This is generally helpful for listeners - other variables should almost always implement
   * equals and hashcode.
   * <p>
   * For details on the nuances of this, see https://github.com/airbnb/epoxy/wiki/DoNotHash
   */
  boolean enableDoNotHash() default true;     // ★★★DoNotHashこれは何なの?という話です★★★
}
@EpoxyModelClass(layout = R.layout.model_button)
public abstract class ButtonModel extends DataBindingEpoxyModel {
  @EpoxyAttribute @StringRes int textRes;
  @EpoxyAttribute(DoNotHash) OnClickListener clickListener;         // ★★★ DoNotHashこれは何なの?という話です★★★
}

DoNotHash

直訳すると「ハッシュしない/ハッシュなし」と受け取れます。

enableDoNotHash() のコメントも見てみましょう。

Google翻訳より

「trueの場合、型がequalsおよびhashcodeを実装していない変数には、動作が自動的に適用されます。
これは一般的にリスナーに役立ちます-他の変数はほとんど常に等しいとハッシュコードを実装するべきです。」

ちょくで訳すと流石に意味が伝わりづらいですね。equalsやhashcodeという単語はできてました。

equals()とhashCode()

Kotlinにおいては Any型がすべての基底クラスとなりますがここに実装されているメソッドです。

またKotlinにおける等価性は Structural equality と表現され、 ==a?.equals(b) ?: (b === null) が同じであると明記されていることから == 比較を使うと内部的に equals() を使っている、と理解可能です。

kotlinlang.org

話がそれましたが、この2つのメソッドを実装していない変数にはenableDoNotHash()をtrueにする(trueになる)と読めます。

また、「リスナーに役立つ」とありますが、OnClickListener などのInterfaceはActivityとViewModelなど全く別のクラスで実装(implement)できますし

この2つの等価性(==)を見ようとしても確かに異なるよね、となりそうですね。

DoNotHashとしているので hashCode()の値が等しいかどうかを見ているのかもしれません。

以上のコメントの深堀りequals()本来の意味を鑑みると enableDoNotHash()がtrueである ということは 等価性の比較をしない と仮定すると辻褄が合ってきます。

なるほどそうなると差分更新の節で等価性について言及しているということはEpoxy内で描画更新するかどうかは ==もしくはequals() の結果で判断しているのかもしれません。

EpoxyModelクラスに聞いてみた

推測を並べても仕方がないのでカスタムepoxyモデルを作って実際に自動生成されたファイルを実際に見て確認してみようと思います。

ちなみに @EpoxyAttribute にはレイアウトファイルでバインド( )している variableのnameが入ります。

DoNotHashをつける/つけないでequals()の実装に少し変化が見られます。

DoNotHashなし

インスタンスのequalで等価性を確認している。インスタンスが異なればreturn false => EpoxyModelとして異なるもの => 更新の必要がある

    if ((clickListener != null ? !clickListener.equals(that.clickListener) : that.clickListener != null)) {
      return false;
    }

DoNotHashあり

インスタンスの存在を確認している。インスタンスが異なっても”有->無", "無->有" に変化して初めてreturn false => 更新の必要がある

    if (((clickListener == null) != (that.clickListener == null))) {
      return false;
    }

実際のコード

ドキュメントにも記載があるサンプルカスタムモデルに少しだけアレンジ

@EpoxyModelClass(layout = R.layout.model_button)
abstract class ButtonModel : DataBindingEpoxyModel() {
    @EpoxyAttribute
    var textRes: Int = 0

    @EpoxyAttribute
    lateinit var clickListener: View.OnClickListener                           // DoNotHash無し版

    @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
    lateinit var clickListenerDoNotHash: View.OnClickListener      // DoNotHash有り版
}

自動生成ファイルButtonModel_.javaのequals()とhashCode()抜粋

  @Override
  public boolean equals(Object o) {
    if (o == this) {
      return true;
    }
    if (!(o instanceof ButtonModel_)) {
      return false;
    }
    if (!super.equals(o)) {
      return false;
    }
    ButtonModel_ that = (ButtonModel_) o;
    if (((onModelBoundListener_epoxyGeneratedModel == null) != (that.onModelBoundListener_epoxyGeneratedModel == null))) {
      return false;
    }
    if (((onModelUnboundListener_epoxyGeneratedModel == null) != (that.onModelUnboundListener_epoxyGeneratedModel == null))) {
      return false;
    }
    if (((onModelVisibilityStateChangedListener_epoxyGeneratedModel == null) != (that.onModelVisibilityStateChangedListener_epoxyGeneratedModel == null))) {
      return false;
    }
    if (((onModelVisibilityChangedListener_epoxyGeneratedModel == null) != (that.onModelVisibilityChangedListener_epoxyGeneratedModel == null))) {
      return false;
    }
    // testRes: Int
    if ((getTextRes() != that.getTextRes())) {
      return false;
    }
    // DoNotHash無し版  ★★★ equals()が増えてる!!! ★★★
    if ((clickListener != null ? !clickListener.equals(that.clickListener) : that.clickListener != null)) {
      return false;
    }
    // DoNotHash有り版
    if (((clickListener == null) != (that.clickListener == null))) {
      return false;
    }
    return true;
  }
  @Override
  public int hashCode() {
    int result = super.hashCode();
    result = 31 * result + (onModelBoundListener_epoxyGeneratedModel != null ? 1 : 0);
    result = 31 * result + (onModelUnboundListener_epoxyGeneratedModel != null ? 1 : 0);
    result = 31 * result + (onModelVisibilityStateChangedListener_epoxyGeneratedModel != null ? 1 : 0);
    result = 31 * result + (onModelVisibilityChangedListener_epoxyGeneratedModel != null ? 1 : 0);
    result = 31 * result + getTextRes();
    result = 31 * result + (clickListener != null ? clickListener.hashCode() : 0);   // ★★★ hashCodeが増えてる!!! ★★★
    result = 31 * result + (clickListenerDoNotHash != null ? 1 : 0);
    return result;
  }

わかったこと

  • DoNotHashが付与されている(=フラグがtrueになっている)プロパティはインスタンスの存在が変化しない限り、

    • EpoxyModelとしては等価の扱い(equals()がtrue)となる
  • DoNotHashが付与されていない(=フラグがfalseになっている)プロパティはインスタンスの等価性が変化しない限り、

    • EpoxyModelとしては等価の扱い(equals()がtrue)となる
  • 見方を変えると、DoNotHashが付与されていない場合において、以下の考え方もできる

    • equals()をoverrideして比較ロジックを独自で組んでいる場合、 インスタンスが異なってもEpoxyModelとしては等価の扱い(equals()がtrue)となる
  • 安易にequals()をoverrideしたクラスをEpoxyModelとDataBindingに与えると意図しない動作(描画更新されない)を引き起こす可能性がある

まとめ

  • EpoxyModelの等価性について調べた
  • DoNotHashの有無でEpoxyModelの等価条件が変わることがわかった
  • 最終的には『DoNotHash=差分更新しない』と読み替えることができる
  • よくわかっていないまま差分更新してたんだな・・・と理解度の浅さを思い知った