Shared Elementで画面遷移にアニメーションを加える
こんにちは、tkyです。
Androidで動きのある画面遷移に挑戦してみようと思います。
画面間遷移にアニメーション
今回は画面から画面の遷移にSharedElement(これって機能名になるんですかね?)を指定して
「遷移元のUIの一部が遷移先のUIの一部になるように見せる遷移」
を実現してみたいと思います。
文面だけではかなり想像がつきにくいのでSharedElementしない画面遷移と対応した完成形イメージです。
対応前 | 対応後 |
---|---|
Activitiy -> Activity のSharedElement
今回やったのはActivitiy -> Activityの画面遷移です。 他にはFragmentがあると思いますが、別の記事にしようと思います。
- Fragment -> Fragment
僕自身どうやって画面遷移を実現してるのがあまり理解していなかったのでざっくりメモ含めて解説していきます。
順序としてはこんな感じのことをやればSharedElement実現できます。
この3つをこのコミットで対応していますので、差分だけ見るとより理解しやすいかもしれません。
- startActivity時に遷移元のViewを指定
- 遷移先のViewを指定
- 遷移中のTransitionを指定
引用元:https://developer.android.com/training/transitions/start-activity
上記引用元はAndroidXで書かれていないので、参考にしつつAndroidX対応で書いていきます。
遷移元のViewを指定
アニメーションさせたいViewを指定します。第3引数のStringは後に出てくる遷移先のViewで指定するStringと同じものにする必要があります。
遷移元でやることはこれだけです。
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this@MainActivity, view, "photo") ActivityCompat.startActivity( this, intent, options.toBundle() )
遷移先のViewを指定
最終的に到達したい場所のViewを指定します。この場合ImageView->ImageViewの遷移です。 onCreate() 等でレイアウトをInflateしたあとに遷移させたいViewを指定します。この時setTransitionNameの第2引数は遷移元のViewで指定したStringと同じものにします。
ViewCompat.setTransitionName(binding.imageView, "photo")
ハマったところ
ここで1つハマったところがあって画像は基本的にURLがあってロードしてあとから表示の流れになると思います。
こうなるとImageViewの高さがあとから決まるのでwrap_contentとかにしていると高さ0のImageViewとかになってしまいます。
その高さ0のViewめがけてアニメーションしてしまうのでちょっと変な動きになります。下の動画で「ペスカトーレ」「ジャンバラヤ」をタップした時にViewが上に吸い込まれるような挙動になってしまったりします。
遷移先のViewは高さを指定するなどしてレイアウト時点でどこに配置されるべきかちゃんと定義する、を意識するのが良いのかもしれません。
遷移中のTransitionを指定
res/transition/change_image_transform.xml
(名前は何でも良いです)に遷移中どのようにViewを移動させるかを定義していきます。
それぞれのChangeBounds、ChangeImageTransformについてはあまり調べていません。
(transformで位置(x, y)を変更、boundsで大きさ(w, h)変更ですかね?この2つを組み合わせてアニメーションします)
<?xml version="1.0" encoding="utf-8"?> <transitionSet xmlns:android="http://schemas.android.com/apk/res/android"> <changeBounds /> <changeImageTransform /> <targets> <target android:excludeId="@android:id/statusBarBackground" /> <target android:excludeId="@android:id/navigationBarBackground" /> </targets> </transitionSet>
まとめ
- Activity -> Activity の画面遷移でSharedElementを使ってみた
- 直線的なレイアウトは結構簡単にできた
- Fragmentの画面遷移もトライしてみたい
参考にしたURL
Start an activity using an animation | Android Developers
Shared Element Activity Transition | CodePath Android Cliffnotes
simple pollを使ってslackでアンケートを取る
こんにちは、tkyです。
N番煎じの小ネタなのですが、案外使っていない人が多いのかもしれませんね。
slackでアンケート的な何かがあるときにリアクションを使うパターンはよくありますね。
実は/pollコマンドでアンケート作れます
/poll
というコマンドがあって、これで簡単にアンケートが作れます。
アンケートを取りたいslackのチャンネルに以下のように投稿するだけです。半角スペースで区切ります。必要な文だけ答えを並べます。
フォーマット:/poll 質問 答え1 答え2 答え3 答え4 答え5
例:/poll 旅行どこに行きたいですか!?!!?!?!!?!?!! 北海道 沖縄 大阪 長野 東京
文章中に半角スペースを使いたい場合もあると思うので各要素を""でくくるとより表現しやすいかもしれません。
例:/poll "旅 行 どこに行きたいですか!?!!?!?!!?!?!!" "北 海 道" "沖 縄" "大 阪" "長 野" "東 京"
質問や回答に半角スペースが含まれていても問題なしですね!!
超小ネタで、知っているみなさんにとってはなんともない話かと思いますが案外知らない人も多いかもしれない、そんなネタでした。
KotlinプロパティとカスタムゲッターのBytecodeから見る違い
こんにちは、tkyです。
今日はKotlinのプロパティとゲッターの種類と違いを深堀りしてみようと思います。
はじめにプロパティとフィールドについておさらい程度に単語を整理します。
プロパティとフィールド
Kotlinのプロパティについて復習です。
Kotlinにおいて以下のように記述することはプロパティを宣言することであって、暗黙的にゲッターセッターを持っています。
Javaにするとフィールドとゲッターセッターを記述する必要があるのに対してボイラーコードが減っていいですね。
// kotlin var name: String = "This is a property"
// convert to java private String name = "This is a field."; public String getName() { return name; } public void setName(String var1){ name = var1; }
さらにKotlinのプロパティは明示的にゲッターを記述することもできます。
val isEmpty: Boolean get() = this.size == 0
そして fun getName()
のように宣言しようとするとコンパイルエラーとなります。プロパティ宣言によって暗黙的にgetName()メソッドが宣言されていることが確認できます。
ここまではある程度みなさんも理解していることでしょう。
ゲッター書き方色々
1つのプロパティにアクセスする方法がいくつか存在します。特に暗黙的に作成されたゲッターと明示的に作成されたゲッターは何が違うのでしょうか?
- プロパティ(暗黙的なゲッターを利用)
- カスタムゲッター(明示的に記述する)
- メソッド(暗黙的なゲッターとは異なる命名で作成)
この謎を解明すべくByteCodeという名の秘境の奥地に足を踏み入れてみたのでした。
サンプルコード
適当に作ってみました。全部valで定義しているのでセッターはありません。
class User(val firstName: String, val lastName: String, val age: Int) { // プロパティ宣言 val fullName: String = "$firstName $lastName" // カスタムゲッター val fullNameCustom: String get() = "$firstName $lastName" // メソッドでゲッター fun getFullNameMethod(): String { return "$firstName $lastName" } }
バイトコード
Android Studioの機能で Tools > Kotlin > Show Kotlin Bytecode を選択することでBytecodeが見れるようになります。
長すぎたので要所だけ切り取ります。
// プロパティ宣言 public final getFullName()Ljava/lang/String; @Lorg/jetbrains/annotations/NotNull;() // invisible L0 LINENUMBER 6 L0 ALOAD 0 GETFIELD com/github/ticktakclock/myapplication/User.fullName : Ljava/lang/String; ARETURN L1 // カスタムゲッター public final getFullNameCustom()Ljava/lang/String; @Lorg/jetbrains/annotations/NotNull;() // invisible L0 LINENUMBER 10 L0 NEW java/lang/StringBuilder DUP INVOKESPECIAL java/lang/StringBuilder.<init> ()V ALOAD 0 GETFIELD com/github/ticktakclock/myapplication/User.firstName : Ljava/lang/String; INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; BIPUSH 32 INVOKEVIRTUAL java/lang/StringBuilder.append (C)Ljava/lang/StringBuilder; ALOAD 0 GETFIELD com/github/ticktakclock/myapplication/User.lastName : Ljava/lang/String; INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String; ARETURN L1 // メソッドでゲッター public final getFullNameMethod()Ljava/lang/String; @Lorg/jetbrains/annotations/NotNull;() // invisible L0 LINENUMBER 14 L0 NEW java/lang/StringBuilder DUP INVOKESPECIAL java/lang/StringBuilder.<init> ()V ALOAD 0 GETFIELD com/github/ticktakclock/myapplication/User.firstName : Ljava/lang/String; INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; BIPUSH 32 INVOKEVIRTUAL java/lang/StringBuilder.append (C)Ljava/lang/StringBuilder; ALOAD 0 GETFIELD com/github/ticktakclock/myapplication/User.lastName : Ljava/lang/String; INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String; ARETURN L1
プロパティ宣言
プロパティ宣言のものについてはフィールド fullName
と getFullName()
メソッドが作成されていて、読みだしたフィールドの値を返却していることがわかります。
fullName
もコンストラクタで文字列の連結が行われて、ゲッターでは計算済みの文字列を返却しています。
カスタムゲッターとメソッドのゲッターは特に違いなし
カスタムゲッターとメソッドのゲッターではフィールドは作られずメソッドだけが作成されていました。
バイトコードちゃんと読めないのですが、中に書かれていることも都度文字列の連結をして返却していることが雰囲気でわかります。
カスタムゲッターとメソッドによるゲッターはどちらも同じ振る舞いをするのですが、Kotlinっぽさが出るのはプロパティ宣言にしてカスタムゲッターを使うやり方かなと思います。(多分好みの問題
ゲッター内のコードの計算量に注意
例えば何度も getFullNameCustom()
を呼び出す場合、プロパティ宣言方式のほうが計算済みの値を返すだけなので効率が良いかもしれないなと思いました。これがfor文とかが入ってO(n)などになるとパフォーマンスに影響するかもですね
まとめ
おそらくKotlinのコードの書き方によって生成されるバイトコードは最も良い形に最適化されて出力されると思うので必ずしもこの通りになることはないかもしれません。
しかし自らが書いたプログラムがどう動こうとしているのかを把握しておくことで「なぜこう書いたのか?」の理由づけや意思決定の材料になったり、困ったときにバイトコードを見てみるといった調査の幅も広げられるのではないかなと思います。
BillingLibraryを1.2 -> 2.2.1にアップデートするまで(ついでにRx->Coroutinesも)
こんにちは、tkyです。
BillingLibraryのバージョンを遅ればせながら1.2 -> 2.2.1(20200603時点の最新)にアップデートしましたのでその時の作業内容をまとめておこうと思います。
バージョンアップは必要なのか?
必要です。
Google I/O'19において
- 毎年のGoogle I/Oでメジャーバージョンをリリースする
- メジャーバージョンは2年間サポートする
- AIDL/BL 1.x は非推奨
ことが発表されています。
BL1.0がリリースされたのは 2017/9/19 、I/O'19ですでに非推奨と言われていますが、サポートはBL2.0と同じ期間設けられています。
BL2.0がリリースされたのは 2019/5/7、2年間のサポートを考慮するとXデーは2021/5/7ということになります。
我々は確実に2年に1度はBillingLibraryのアップデートが必要であるということがわかりました。
Google I/O'20は残念ながら新型コロナウィルス(COVID-19)の影響を懸念して完全中止になってしまったため、BillingLibrary3.0のリリースについては不透明な状況にあります。
とはいえいきなり1.0から3.0に上げると変更点が怖いので今のうちに2.0にあげておいて、3.0リリースを受けてもう一度アップデートしようかなと思い作業した次第です。
(どちらかという2.0へアップデートしつつもRxからCoroutinesへの置き換えがメインになりそうです)
versionを1.2 -> 2.2.1にあげる
1.x→2.xへの変更点
ドキュメントのリリースノートを見ると良いです。プログラムもいくつか修正必須項目があります。
Google Play Billing Library リリースノート | Android デベロッパー
私が主に変更したところをピックアップします。
レスポンスコードの定義場所が変更
// 変更前 BillingClient.BillingResponse.OK // 変更後 BillingClient.BillingResponseCode.OK
レスポンスコードはBillingResultオブジェクトに変更
今までレスポンスコードが返っていた場所は全てBillingResult
というクラスで表現されるようになります。
そのためリスナー関連のメソッドの引数の型を変更することと、 responseCode
を billingResult.responseCode
に書き換えることが必要となります。
// 変更前 BillingClientStateListener.onBillingSetupFinished(responseCode: Int) // 変更後 BillingClientStateListener.onBillingSetupFinished(billingResult: BillingResult)
// 変更前 PurchasesUpdatedListener.onPurchasesUpdated(responseCode: Int, purchases: List<Purchase>?) // 変更後 PurchasesUpdatedListener.onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?)
その他のリスナーは別の変更点で一緒に見ていきます。
consumeAsyncの引数の型が変更
こちらについては2.1からCoroutines拡張ができましたので後にRx->Coroutinesに変更しますが、まずはバージョンアップすることだけを考えます。
併せて ConsumeResponseListener.onConsumeResponse(responseCode: Int, purchaseToken: String)
もConsumeResponseListener.onConsumeResponse(billingResult: BillingResult, purchaseToken: String)
へ変更しますが、
SAM変換を利用しており型推論も効いているので変数名を変更していきます。
// 変更前 billingClient.consumeAsync(purchase.purchaseToken) { responseCode, purchaseToken -> // レスポンスの処理 } // 変更後 val consumeParam = ConsumeParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() billingClient.consumeAsync(consumeParam) { billingResult, purchaseToken -> // レスポンスの処理 // responseCodeはbillingResultオブジェクトへ変更 }
queryPurchaseHistoryAsyncで返るオブジェクトが変更
こちらもSAM変換を利用して型推論も効いているので変数名の変更です。List
// 変更前 billingClient.queryPurchaseHistoryAsync(skuType) { responseCode, purchases -> // レスポンスの処理 } // 変更後 billingClient.queryPurchaseHistoryAsync(skuType) { billingResponse, purchaseHistoryRecords -> // レスポンスの処理 }
BillingFlowParams.Builder()に設定するクラスの変更
これは1.2のリリースによる変更で2.xとは関係ありません。
とはいえ2.0においてsetSku(String sku)は削除されており本メソッドは利用できません。
BillingClient.querySkuDetailsAsync()
から取得できるSkuDetail
を BillingFlowParams.Builder.setSkuDetails(skuDetail: SkuDetail)
に設定する必要があります。
Google Play Billing Library リリースノート | Android デベロッパー
BillingClientの諸メソッドをCoroutinesに切り替え
com.android.billingclient:billing-ktx
を利用することでCoroutinesに切り替えられるメソッドが幾つか存在します。必要に応じてCoroutinesに切り替えます。脱Rxです。
- BillingClient.acknowledgePurchase()
- BillingClient.consumePurchase()
- BillingClient.queryPurchaseHistory()
- BillingClient.querySkuDetails()
例えばRxでこのように書いていたら
Single.create<BillingResult> { val consumeParam = ConsumeParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() billingClient.consumeAsync(consumeParam) { billingResult, purchaseToken -> it.onSuccess(billingResult) } }
Coroutines拡張によってスッキリかけるようになります。
val consumeParam = ConsumeParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() return billingClient.consumePurchase(consumeParam)
コミットの分割
おおよそこんな感じでコミットを分けて作業用のブランチにPR出していました。
- バージョンアップで出たビルドエラーの解消
- Coroutinesで書き直せるところを1つずつリファクタ * n回
まとめ
- billing libraryを 1.2 から 2.2.1にしたときの作業ログをここに残した
- そんなに大きく変更があるわけではなかった
com.android.billingclient:billing-ktx
を利用することでCoroutinesに切り替えられるメソッドがある- 近い将来また billing libraryを3.0にアップデートする時が来る
- 来たるべきアプデの時までに覚悟を決めましょう
Android Billing Library 2.0を使って課金フローを確認してみる
こんにちは、tkyです。
PlayConsoleに登録しないでBilling Library2.0を使ってみた所感などを書いていきます。
目的は『課金フローを理解する』ことにあります。
基本ドキュメント見たら書いてあるため公式のドキュメントを信頼するのが一番良いです。本記事ではドキュメントを読みすすめるにあたって理解の助けになれたらと思います。
Google Play Billing Library を使用する | Android デベロッパー
確認したときのリポジトリです。
どこまで試すことができるのか?
下記、『アプリを Google Play のクローズド テスト版トラックまたはオープンテスト版トラックに公開します。』
と書かれている通り任意の購入アイテムを準備して課金のテストを行うためには「おためし」でやってみるには少しハードルが高いです。。。
後述しますが、Googleが特定のテスト用アイテムを用意してくれているためこちらを使用して課金のフローまでは確認することが可能です。
課金実装のおおよその流れ
実際の運用を想定するとクラウド上のどこかにサーバを設置しているかと思いますが、おおよそこのような流れになるかと思います。
よくある課金としては100円=100ポイントのような形でアプリ内通貨(石だったり色々な単位)に換金するようなものをイメージしてみます。
実際は購入するアイテムの一覧はサーバから取得するケースが多いのかなと思います。
このシーケンスにならい実際に組んでみたのがGitHubのリポジトリとなります。 一般的にリリースされているアプリはしっかりと設計されたものだと思うのでそういったアプリに課金実装することを想定して、きっちりとパッケージ分けしてサンプルアプリに見合わない構成になっています。 ロジックはすべてUseCase層にまとめています。
BillingClient生成時にハマったこと
PurchasesUpdatedListenerのリスナーと enablePendingPurchases() をコールしておく必要があるようでした。
これらがないとIllegalStateExceptionが発生してしまうようで、どこかのクラスで PurchasesUpdatedListener を実装する必要があります。
val billingClient = billingBuilder.setListener(this).enablePendingPurchases().build()
coroutines の拡張と使い所
Billing Library 2.1から com.android.billingclient:billing-ktx
という Billing Clientで使えるCoroutines拡張があります。
これを使うことでコールバックでしか受け取れなかった(またはRxを使ってObservableやSingleの形にしていた)ものがsuspend funで記述できるようになります。
BillingClient.startConnection()
については拡張はありません。これは非同期通信というよりはイベントの形状のためだとは思いますが
GooglePlayストアがバックグラウンドで更新されているときは接続が切れる可能性があり onBillingServiceDisconnected()
が発火されるので再接続して処理を維持する必要があります。
なにかするたびに毎回接続してエラーになったら処理を止める、というようにするとわかりやすいのかなと思いました。
Googleが用意しているテスト用のアイテム ID
BillingLibraryの中では sku: String
で表現されています。購入対象のアイテム自体をSkuDetailsというクラスで表現しています。
SKUというのはStock Keeping Unit(ストック・キーピング・ユニット)の略で、受発注・在庫管理を行うときの、最小の管理単位をいいます。商品IDみたいな位置づけで購入対象のアイテムを一意に特定できるものとなります。
幸いにもGoogleがテスト用にアイテム ID を用意してくれており、これを使うと「アイテムが購入された」と見なして処理してくれる為、課金フローを試すことができます。
買い切りタイプのものに対して有効で、月額タイプの定期購入は『静的レスポンスは定期購入のテストには使用できません』となっておりできないようです。
android.test.purchased
このアイテム ID を使用して Google Play 請求サービスをリクエストすると、Google Play は「アイテムが購入された」と見なしてレスポンスを返します。レスポンスには JSON 文字列が含まれており、疑似購入情報(例: 疑似オーダー ID)が格納されます。
android.test.canceled
このアイテム ID を使用して Google Play 請求サービスをリクエストすると、Google Play は「購入がキャンセルされた」と見なしてレスポンスを返します。クレジット カードが無効の場合や、課金前にデベロッパーがユーザーの注文をキャンセルした場合など、注文プロセス内でエラーが発生したケースに相当します。
android.test.item_unavailable
このアイテム ID を使用して Google Play 請求サービスをリクエストすると、Google Play は「購入対象のアイテムがアプリのアイテムリストに含まれていない」と見なしてレスポンスを返します。
考えるべきエラーケース
シーケンスからもわかりますが、全てにレスポンスがありますので、あらゆるところでエラー考慮する必要が出てきます。
- BillingClientの接続
- 接続が切れていたケース
- アイテムIDの問い合わせ
- 存在しなかったケース
- 購入フローを開始
- 無効なアイテムIDだったケース
- すでに購入済みだったケース
- 自サーバに反映
- サーバ反映に失敗したケース
- 購入商品の消費
- 消費に失敗したケース
しかも面倒なのがBillingClientのメソッドのレスポンスコードが BillingResponseCode
に集約されているのもあり網羅的にするにはコスト高だなと思いました。。。
課題になるのが自サーバの反映に成功しても購入商品の消費に失敗した場合、課金履歴とサーバの状態に齟齬が生じるのでどちらかが失敗した場合ロールバックする必要があるのかなと思います。
こちらについては、サーバ側で検証ロジックを持つことである程度解消するのかもしれません。
まとめ
- サンプル作るだけでもかなり理解度が増した
- PurchasesUpdatedListenerが使いにくくてどこで処理していいかわからなかった・・・
- ついでにFlowとかCoroutinesの処理も書いてそっちの理解度も増した
- 定期購入についてはα版までリリースを進める必要があるのでテスト方法が悩ましい
- BillingLibrary3.0の情報がそろそろ出てほしい
gradleのライブラリ依存関係を出す
こんにちは、tkyです。
Android小ネタ集です。色々なブログでも書かれている内容で、ほぼ自分用として書いておきます。
Dependency treeが見たい時、ありますよね?
(ライブラリを特定のバージョンに上げると何故か別の機能がバグるみたいなことがたまにありますそれを解析するためにやったりします。)
先に結論から記載すると以下のようになります。(ファイル出力するかどうかはお好みで)
./gradlew :app:dependencies --configuration developDebugRuntimeClasspath > dependency.txt
configurationについて
./gradlew :app:dependencies
だけでコマンドは動きますが、これだと全ビルドバリアント(buildType x productFlavor)分調べるのでめちゃめちゃ時間かかります。
そのためvariantが developDebug(buildType=debug, productFlavor=develop)の時だけ調べるようにしています
調べたいvariantによってここは変えると良いでしょう。
また、RuntimeClasspathのほかにCompileClasspashもあります。
RuntimeClasspath のほうがgradleの dependencies{}
ブロックに記述したライブラリの依存ツリーとして見れるので関係性を整理する意味でこちらのほうが可視性が高いかなと思いこちらを使っています。
もうすこしRuntimeとCompileの指定方法による違いを調べたかったのですが今日はここまで。。。
以上、コピペで使えるDependency treeでした。
2020/10/13 追記
こんな感じで .bash_profile
とかにalias作っておくといちいちタイプしなくて良いので便利です。
alias dependency='./gradlew :app:dependencies --configuration developDebugRuntimeClasspath > dependency_`date "+%Y%m%d_%H%M%S"`.txt'
EpoxyとDoNotHashとequals()
こんにちは、tkyです。
Androidネタです。epoxyの話です。 ※epoxy is 何の話はしません
epoxyはデータバインディングをサポートしていて、リソースファイルからデータバインディング用のモデルクラスを自動生成してくれたりします。
上記のドキュメントを読んだり、差分更新について調べているとたまに見かける 「 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()
を使っている、と理解可能です。
話がそれましたが、この2つのメソッドを実装していない変数にはenableDoNotHash()をtrueにする(trueになる)と読めます。
また、「リスナーに役立つ」とありますが、OnClickListener などのInterfaceはActivityとViewModelなど全く別のクラスで実装(implement)できますし
この2つの等価性(==)を見ようとしても確かに異なるよね、となりそうですね。
DoNotHashとしているので hashCode()の値が等しいかどうかを見ているのかもしれません。
以上のコメントの深堀りequals()本来の意味を鑑みると enableDoNotHash()がtrueである
ということは 等価性の比較をしない
と仮定すると辻褄が合ってきます。
なるほどそうなると差分更新の節で等価性について言及しているということはEpoxy内で描画更新するかどうかは ==もしくはequals()
の結果で判断しているのかもしれません。
EpoxyModelクラスに聞いてみた
推測を並べても仕方がないのでカスタムepoxyモデルを作って実際に自動生成されたファイルを実際に見て確認してみようと思います。
ちなみに @EpoxyAttribute
にはレイアウトファイルでバインド(
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=差分更新しない』と読み替えることができる
- よくわかっていないまま差分更新してたんだな・・・と理解度の浅さを思い知った