ticktakclockの日記

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

simple pollを使ってslackでアンケートを取る

こんにちは、tkyです。

N番煎じの小ネタなのですが、案外使っていない人が多いのかもしれませんね。

slackでアンケート的な何かがあるときにリアクションを使うパターンはよくありますね。

f:id:ticktakclock:20200620010543p:plain
slack一般的なアンケート

実は/pollコマンドでアンケート作れます

/poll というコマンドがあって、これで簡単にアンケートが作れます。

f:id:ticktakclock:20200620010908p:plain
pollコマンドでアンケート

アンケートを取りたいslackのチャンネルに以下のように投稿するだけです。半角スペースで区切ります。必要な文だけ答えを並べます。

フォーマット:/poll 質問 答え1 答え2 答え3 答え4 答え5

例:/poll 旅行どこに行きたいですか!?!!?!?!!?!?!! 北海道 沖縄 大阪 長野 東京

文章中に半角スペースを使いたい場合もあると思うので各要素を""でくくるとより表現しやすいかもしれません。

例:/poll "旅 行 どこに行きたいですか!?!!?!?!!?!?!!" "北 海 道" "沖 縄" "大 阪" "長 野" "東 京"

f:id:ticktakclock:20200620011536p:plain
半角スペースを使いたいとき””でくくると良い

質問や回答に半角スペースが含まれていても問題なしですね!!

超小ネタで、知っているみなさんにとってはなんともない話かと思いますが案外知らない人も多いかもしれない、そんなネタでした。

KotlinプロパティとカスタムゲッターのBytecodeから見る違い

こんにちは、tkyです。

今日はKotlinのプロパティとゲッターの種類と違いを深堀りしてみようと思います。

はじめにプロパティとフィールドについておさらい程度に単語を整理します。

プロパティとフィールド

Kotlinのプロパティについて復習です。

dogwood008.github.io

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()メソッドが宣言されていることが確認できます。

f:id:ticktakclock:20200614151229p:plain
エラー

ここまではある程度みなさんも理解していることでしょう。

ゲッター書き方色々

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が見れるようになります。

f:id:ticktakclock:20200614163317p:plain
Tools > Kotlin > Show Kotlin 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

プロパティ宣言

プロパティ宣言のものについてはフィールド fullNamegetFullName() メソッドが作成されていて、読みだしたフィールドの値を返却していることがわかります。 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 は非推奨

ことが発表されています。

youtu.be

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 というクラスで表現されるようになります。

そのためリスナー関連のメソッドの引数の型を変更することと、 responseCodebillingResult.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から List< PurchaseHistoryRecord>になっていますが、中身は同じ(autoRenewing, orderId, packageNameは含まない)らしいです。

// 変更前
billingClient.queryPurchaseHistoryAsync(skuType) { responseCode, purchases ->
    // レスポンスの処理
}
// 変更後
billingClient.queryPurchaseHistoryAsync(skuType) { billingResponse, purchaseHistoryRecords ->
    // レスポンスの処理
}

BillingFlowParams.Builder()に設定するクラスの変更

これは1.2のリリースによる変更で2.xとは関係ありません。 とはいえ2.0においてsetSku(String sku)は削除されており本メソッドは利用できません。 BillingClient.querySkuDetailsAsync() から取得できるSkuDetailBillingFlowParams.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 デベロッパー

確認したときのリポジトリです。

github.com

どこまで試すことができるのか?

下記、『アプリを Google Play のクローズド テスト版トラックまたはオープンテスト版トラックに公開します。』

と書かれている通り任意の購入アイテムを準備して課金のテストを行うためには「おためし」でやってみるには少しハードルが高いです。。。

developer.android.com

後述しますが、Googleが特定のテスト用アイテムを用意してくれているためこちらを使用して課金のフローまでは確認することが可能です。

課金実装のおおよその流れ

実際の運用を想定するとクラウド上のどこかにサーバを設置しているかと思いますが、おおよそこのような流れになるかと思います。

よくある課金としては100円=100ポイントのような形でアプリ内通貨(石だったり色々な単位)に換金するようなものをイメージしてみます。

f:id:ticktakclock:20200604164340p:plain
シーケンス図

実際は購入するアイテムの一覧はサーバから取得するケースが多いのかなと思います。

このシーケンスにならい実際に組んでみたのがGitHubリポジトリとなります。 一般的にリリースされているアプリはしっかりと設計されたものだと思うのでそういったアプリに課金実装することを想定して、きっちりとパッケージ分けしてサンプルアプリに見合わない構成になっています。 ロジックはすべてUseCase層にまとめています。

github.com

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 に集約されているのもあり網羅的にするにはコスト高だなと思いました。。。

課題になるのが自サーバの反映に成功しても購入商品の消費に失敗した場合、課金履歴とサーバの状態に齟齬が生じるのでどちらかが失敗した場合ロールバックする必要があるのかなと思います。

こちらについては、サーバ側で検証ロジックを持つことである程度解消するのかもしれません。

developer.android.com

まとめ

  • サンプル作るだけでもかなり理解度が増した
  • 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はデータバインディングをサポートしていて、リソースファイルからデータバインディング用のモデルクラスを自動生成してくれたりします。

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=差分更新しない』と読み替えることができる
  • よくわかっていないまま差分更新してたんだな・・・と理解度の浅さを思い知った

プルリクのレビュー件数をグラフにしてみた

こんにちは、tkyです。

社内でプルリクをいろいろなメンバーにレビューしてもらっているのですが、

「最近特定の人ばかりにレビュー依頼してる気がする・・・負荷溜まってないかな・・・」

という一抹の不安をいだき誰にレビュー依頼するか判断する材料にしようと考え(完全に個人用で)作った感じです。

何作ったの

社内のメンバーのプルリク溜まってる件数をグラフにしてみました。

token, organization, teamを入力することでこんな感じのグラフが出るようなものです。

こうすることでレビュー溜まってない人にレビューアサインしたほうが早いかな?など検討の余地が生まれて良いですね。

f:id:ticktakclock:20200414221150p:plain:w500
screenshot

github.com

技術的なところ

せっかくだしTypescriptとVueも一緒に触ってみながら成果物残してみるかー!というモチベーションです。 私はAndroidエンジニアですが、Reactとかのフロントの経験はあるのでそんなに抵抗はないです。

chart.jsのおかげでグラフ化はかんたんでした。感謝・・・

使ったAPI

大まかな流れは /orgs/:org_id/team/:team_id/members で特定のチームに属するメンバーを抽出して

そのメンバーが持っているPRレビューの数を /search/issues で取得します。

Search | GitHub Developer Guide

Team Members | GitHub Developer Guide

/search/issues の課題

search/issues?q=is:open+is:pr+review-requested:ticktakclock こんな感じで私がレビューすべきプルリクIssueが取得できるのですが、

review-requestedが1人しか指定できないのか、例えば

Aさん・・・2件、 Bさん・・・1件、 Cさん・・・4件

あったとして

  • search/issues?q=is:open+is:pr+review-requested:a-san+review-requested:b-san ・・・3件取得
  • search/issues?q=is:open+is:pr+review-requested:a-san+review-requested:c-san ・・・0件取得

みたいなことがあってなぜか複数指定して検索できるパターンとできないパターンがあり

指定したメンバー全員を一気に検索することができなかったことです。

そのため本リポジトリでは1人1人API叩いて検索してます。

配列を一気にasync - await  するのにはPromise.all()でくくってあげる必要がある、というのは本件に関係ありませんがそこそこ重要なポイントかなと思います。

      const res = await api(
        "/orgs/" + this.organization + "/teams/" + this.team + "/members"
      );
      const results: Array<RequestedReviewer> = await Promise.all(
        res.data.flatMap(async (user: User) => {
          const res = await api(
            "search/issues?q=is:open+is:pr+org:" +
              this.organization +
              "+archived:false" +
              "+review-requested:" +
              user.login +
              "&sort=created&order=asc&page=1&per_page=10"
          );
          return { user: user, issues: res.data.items };
        })
      );

これで何が問題かというとGitHubAPIのレート制限にすぐに引っかかるということです。

普通にtoken使ってやる場合は1時間あたり最大60件なので、比較的すぐに制限に引っかかります。

GitHub API v3 | GitHub Developer Guide

まとめ

  • レビュー件数がわかって少し幸せ
  • search apiもう少し拡張してほしい
  • typescriptとVueの勉強しようと思ったのにGitHubAPIの勉強になってしまった・・・

こうすれば複数人のIssue検索できるよ、のような知見があるかたはぜひ教えていただきたいです!!