ticktakclockの日記

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

LiveDataをどのレイヤー(Repository層)まで許容するか

こんにちは、tkyです。

先日、とあるcodelabをやっていて『Repository層でLiveDataって使っていいの?』と感じたことがあり。

この疑問からLiveDataを使用するレイヤーとそこから生じるメリット・デメリットなどを考察した内容をまとめておきます。

こういうことを考えながらプログラミングしてるんだなと感じていただけたらと思います。

結論から申し上げると『用法用量を守ればRepository層にLiveDataを使っても良い』になりました。

codelabs.developers.google.com

上記Codelabではkotlin coroutine Flowの使い方を学習するためにあえてそういった書き方をしているとも思いますが、後のYoutubeリンクのDevsummitでも同様の書き方が見られました。

LiveDataとは、といった事などに関することは触れません。

何を疑問視しているのか

私のLiveDataの普段の使い方はこんな感じです。特に言うことはないです。

  • ViewModel <-> Repository でデータの取得
  • 取得したデータをLiveDataにpostValue()する
  • FragmentまたはDataBindingでLiveDataをobserveしてViewに反映する
interface BookRepository {
  fun fetchBooks(): List<Books>
}

class BookViewModel(private val bookRepository: BookRepository): ViewModel() {
    private val _books = MutableLiveData<List<Book>>
    val books: LiveData<Book> = _books
    init {
       runCatching {
         val books = bookRepository.fetchBooks()
         _books.postValue(books)
       }
    }
}

class BookFragment: Fragment() {
    fun onViewCreated() {
        bookViewModel.books.observe(Observer{ books ->
           // リスト更新
        })        
    }    
}

このときのRepositoryでLiveDataを直接返すようにするのってありなの?という疑問です。

interface BookRepository {
  fun fetchBooks(): LiveData<List<Books>>
}
class BookViewModel(private val bookRepository: BookRepository) {
    val books: LiveData<Book> = bookRepository.fetchBooks()
}

LiveDataはViewModelまでかなと思っている派

疑問を持ったということは想定と実態が異なっていたためですが、以下の理由でRepository層にLiveDataを持ってくることにためらいを持っていたために生まれたものでした。

  • RepositoryのテストにAndroidライブラリの依存が入る
  • LiveDataはViewにリアルタイムに通知するものだからViewに近い層で利用するほうが管理しやすそう、という先入観

逆にこの2つくらいしかなくて、普通ViewModelまでしか使われていないから程度のことしか出てこないですし、大きい否定材料にもなりません。

Repository層までLiveDataを持ってきた場合何が良さそう?

  • ViewModelでデータを一次受けしないでよくなる
    • _books.postValue(books) のような記述をしなくて良いということ(特にメリットとも言えない・・・)
  • LiveDataが最新の値を保持するためキャッシュっぽい動きしてくれそう

逆にデメリットも考えてみました

  • LiveDataは複数でObserveするのには向いてなさそう
    • 2箇所でObserveしても1個しか処理しないということ
  • 特定の場合のみpostValueするみたいな仕様が入ってしまう場合Repositoryにロジックが入る。設計が乱れる。
    • RxならViewModel側でFilterすればRepositoryにロジックが入ることなく実装できそう
  • RepositoryのテストにAndroidライブラリの依存が入るためテストしづらい
    • 実際Contextは使われていないのでUnitテストで処理できますが例えばKotlin MultiPlatformProjectなどではLiveData使えない

RoomはLiveDataを返すクエリをサポートしている

冒頭にも書いたとおりFlowを導入するために敢えてRepository層までLiveDataを持ってきて書き換えるという手順をやっているのかなと思いましたが、

RoomでRepository層とDaoまでLiveData使ってるじゃん・・・!ということはダメというわけでもないのかと考えます。

developer.android.com

気になったのはこの一文

『このパターンは、データベースに格納されているデータと同期したデータが常に UI に表示されるようにするうえで役立ちます。』

こういったユースケースを実現する場合は許容しても良いのかもしれません。(前提としてRoomもAndroidライブラリだからLiveDataが入っててもおかしくない)

Dev Summit から得た知見

以下の'18セッションで実際にRepositoryにLiveDataを扱うケースが存在しており、許容されていそうということが伺えます。

youtu.be

また同時にアンチパターンについても解説がありました。

  • HTTPリクエストから得た巨大なデータ群にLiveDataを使うとその分がメモリにとどまる
    • APIのRepositoryには使わないほうが良さそう
  • LiveDataインスタンスを複数画面で共有すると意図しないUI更新が起こる可能性がある
  •  データに対してたくさんのオペレータ(filterなど)が必要ならRxを使いましょう
  • ライフサイクルやUIに関係ないところにLiveDataは使わない。そのために設計していない。(もしくはRxを使いましょう)
  • 1回きりのデータ(動画では 1ショットオペレーションと言っている)ならCoroutinesを使いましょう(もしくはRxを使いましょう)

以下の'19セッションでCoroutines FlowでLiveDataの扱い方を解説していました。

www.youtube.com

  • 去年('18)話したRepository層でLiveDataを使えることは話したが、LiveDataはRxのようにリアクティブストリームビルダーとして設計されていない。
  • LiveDataのかわりにCoroutines Flowでできるようになる

確かにFlowの場合Androidライブラリ関係ないのでMultiPlatformProject考慮しても特に問題なさそうだなと思いました。

まとめ

  • LiveDataはUIに紐づくことは忘れずに
  • データが1回きりの表示(1 shot operation)ならCoroutinesで良い、都度変わる(observable)ならLiveDataでも良い。
  • 永続化したデータの内容とUIの表示内容を常に同期したいときはRepositoryでLiveDataを返すのはありかも
  • 巨大なデータクラスをLiveDataで扱うとメモリ圧迫につながるので注意
  • 用法用量を守ればRepository層にLiveDataを使っても良いけどCoroutines Flowで同じことができる
    • ただしFlowの細かい部分はまだExperimentalなので完全に移行できるかと言われると微妙

何を実現したいのかによってどの技術を使うか正しく選択できるようになるのが今の自分には必要だなと思います。日々勉強ですね。

参考文献

developer.android.com

developer.android.com

ViewModelのテストを書く

こんにちは、tkyです。

テスト、書いてますか? 僕はぼちぼち書いてます。

今日はViewModelのテストを書いてみたいと思います。

サンプリリポジトリです。サンプルではGitHubAPIにアクセスする簡単なサンプルにテストを書いてみた感じです。

github.com

本稿ではこの2つを扱いたいと思います。

  • LiveDataのアサート
  • Coroutinesを含むテスト

モックライブラリにはMockKを使っています。mockkの使い方については触れません。

LiveDataのアサート

例えば『View(ActivityやFragment)にデータを渡せているか』をテストしたいときに用います。

        val repository = mockk<ProjectRepository>()
        coEvery { repository.getProjects(any()) } returns emptyList()

        val observer = mockk<Observer<List<Project>>>(relaxed = true)
        val viewModel = ProjectListViewModel(repository, testDispatcherProvider)
        viewModel.projects.observeForever(observer)

        viewModel.onResume()

        verify { observer.onChanged(emptyList()) }

ポイントはここ。mockkで作成したObserverをLiveDataに設定します。

        val observer = mockk<Observer<List<Project>>>(relaxed = true)
        viewModel.projects.observeForever(observer)

LiveDataに値が設定されたときにobserver.onChange()が呼ばれることを利用してこのonChange()をアサートします。

        verify { observer.onChanged(emptyList()) }

Coroutinesを含むテスト

ほぼCoroutinesを使うケースになるかと思いますが、

  • CoroutinesTestRuleを作成
  • TestCoroutineScopeでテストを実行

という流れでやっていきます。

@ExperimentalCoroutinesApi
class CoroutinesTestRule(
    val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }
}
     // このPrivate functionを作っておくと `coroutinesTestRule.testDispatcher.runBlockingTest` を毎回書かなくて良くて便利です
    private fun test(block: suspend TestCoroutineScope.() -> Unit) =
        coroutinesTestRule.testDispatcher.runBlockingTest {
            block()
        }

    @Test
    fun onResume_repositoryのAPIが叩ける() = test {
        val repository = mockk<ProjectRepository>()
        coEvery { repository.getProjects(any()) } returns emptyList()

        val viewModel = ProjectListViewModel(repository, testDispatcherProvider)
        viewModel.onResume()

        coVerify(exactly = 1) { repository.getProjects(any()) }
    }

MockkでCoroutines用のモック(coEvery)とアサーション(coVerify)を使うだけです。 上記の場合『ライフサイクルのonResume()が呼ばれたとき、repository.getProjects()が呼ばれることをアサート』することになります。

ポイントはViewModelのコンストラクタにCoroutinesのDispatcherをDIしていることです。

ViewModelではこの様になっています。

    fun onResume() {
        viewModelScope.launch(dispatcherProvider.io()) {  //  普通はviewModelScope.launch(Dispatchers.IO)  で良いのだが・・・
            try {
                val response = repository.getProjects("ticktakclock")
                _projects.value = response
            } catch (e: Exception) {
                e.printStackTrace()
                _projects.value = emptyList()
            }
        }
    }

なぜこのようなことをしているのかというと、 テスト用のコルーチンスコープで動くIOスレッドとViewModelで動くDispatchers.IOのスレッドが異なるため、 テストのcoVerify{}が正しく動作しないことがあるためです。LiveDataも同様に正しく動作しないことがあります。

理由については上記の通りで、図解するとこのようなイメージです。

f:id:ticktakclock:20200818042912p:plain:w500

この図解はAndroid Dev Summit '19にて説明されたもので、詳しくはこちらのアーカイブを見ていただいたらよいかと思います。 www.youtube.com

まとめ

  • livedataとCoroutinesのテストがかけるようになった
  • Coroutinesのテストのテストを見越してDispatcherもDIしておくと後々楽になるかも

JetpackComposeのチュートリアルをやってみた

こんにちは、tkyです。

かねてから触ってみたかったJetpackComposeのチュートリアルをやってみました。

本稿は2020/08/09時点のAndroid Jetpack compose チュートリアルを実施するに当たり ハマらないため+これからやることをイメージできるようにすることを目的とした共有的な位置づけで記載します。

すでにチュートリアルをやったことがあったり、必要な環境を

事前情報

事前、というか最初にまとめ書いときます。

  1. jetpack composeを試すにはAndroidStudio canary版が必要
  2. build gradleバージョンはほぼ最新じゃないとコンパイルエラーになるかも
  3. jetpack composeバージョンを1つ上げるとコンパイルエラーになることはよくある

Android Studio Canary版をインストールする

ここからCanary版をインストールします。筆者環境はMacなので既存のASと競合することはなかったですが、Win環境の人はインストール先を工夫したりすると良いかもですね。

https://developer.android.com/studio/preview

DL当時は AS 4.2 CANARY7でした。

ちなみにStableのASでjetpack composeを動かそうとすると画像のような通知が表示されます。

f:id:ticktakclock:20200809135830p:plain
StableのASで動かそうとすると通知

app.gradleに必要なもの

Jetpack Composeに必要なものは以下の様です、現時点ではDev13まで進んでいる模様

android {
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

    composeOptions {
        kotlinCompilerExtensionVersion "0.1.0-dev13"
    }
    buildFeatures {
        // Enables Jetpack Compose for this module
        compose true
    }
}
dependencies {
    implementation 'androidx.ui:ui-core:0.1.0-dev13'
    implementation 'androidx.ui:ui-tooling:0.1.0-dev13'
    implementation 'androidx.ui:ui-layout:0.1.0-dev13'
    implementation 'androidx.ui:ui-material:0.1.0-dev13'
}

build.gradleの方も更新が必要です。 最新じゃないと「java.lang.AbstractMethodError」というメッセージのコンパイルエラーが発生してちょっとだけハマります。

classpath "com.android.tools.build:gradle:4.2.0-alpha07"

チュートリアル自体は簡単

こういう関数を作成して、

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name")
}

ActivityのonCreateでsetContentする。 layoutの場合はsetContentView()だったのでまぁまぁ覚えやすいのではないでしょうか。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        Greeting("Android")
    }
}

一応これでビルドするとこちらのようなレイアウトが完成します。

f:id:ticktakclock:20200809140752p:plain
チュートリアル完成

これだけじゃ面白くないし、そもそもTextViewが左によりすぎてますね。

ご安心ください、チュートリアルでちゃんとレイアウト組みます。

いろいろ端折りますが、こんな感じにColumnというレイアウト用のcomposeUIを使ってLinearLayoutのような事ができます。

        setContent {
            Column(modifier = Modifier.padding(16.dp)) {
                Greeting("Android")
                Greeting("Android")
                Greeting("Android")
            }
        }

f:id:ticktakclock:20200809141141p:plain
レイアウトチュートリアル

ここで私が抱いた感想はこちらです

「Flutterと同じじゃん、てことは横はRowかな」

はい、Rowでした。ちゃんと横方向レイアウトになりました。

f:id:ticktakclock:20200809142041p:plain
Rowの場合のレイアウト

チュートリアルとしてはこの先 ・画像 ・マテリアルデザインの実現(角丸つけたりする)

を行って終了です。

Jetnews という存在

公式のサンプル。チュートリアルの後はこのアプリで実際にどうやってjetpack composeアプリを作るのか学んで行くことになりそうです。

github.com

Canary版で新規プロジェクト

を作ることもできます。

f:id:ticktakclock:20200810132505p:plain
EmptyComposeActivity

ここから作ることで最新のJetPackCompose環境が作れるので自身の環境でビルドエラーになってしまったときなどは このプロジェクトとの差分を見ることでトラブルシューティングできるかもしれません。

これから

jetpack compose  チュートリアルをやってみました。 ReactやFlutterなどの宣言的UIでレイアウト組んだことはあったので自然に馴染むことはできました。

今後は ・どのようにUIを作るか、どの単位でUIを作るか ・命名規則 ・パッケージ構成の考察 ・コピペで使える逆引きCompose ・FlutterとかReactとか宣言型UIと比較してみて

このあたりを書いてみるのも面白いかなと思いました。

Shared Elementで画面遷移にアニメーションを加える

こんにちは、tkyです。

Androidで動きのある画面遷移に挑戦してみようと思います。

画面間遷移にアニメーション

今回は画面から画面の遷移にSharedElement(これって機能名になるんですかね?)を指定して

「遷移元のUIの一部が遷移先のUIの一部になるように見せる遷移」

を実現してみたいと思います。

文面だけではかなり想像がつきにくいのでSharedElementしない画面遷移と対応した完成形イメージです。

対応前 対応後
f:id:ticktakclock:20200628165626g:plain
対応前
f:id:ticktakclock:20200628164953g:plain
完成形

github.com

Activitiy -> Activity のSharedElement

今回やったのはActivitiy -> Activityの画面遷移です。 他にはFragmentがあると思いますが、別の記事にしようと思います。

  • Fragment -> Fragment

僕自身どうやって画面遷移を実現してるのがあまり理解していなかったのでざっくりメモ含めて解説していきます。

順序としてはこんな感じのことをやればSharedElement実現できます。

この3つをこのコミットで対応していますので、差分だけ見るとより理解しやすいかもしれません。

github.com

  • startActivity時に遷移元のViewを指定
  • 遷移先のViewを指定
  • 遷移中のTransitionを指定

https://developer.android.com/training/material/images/SceneTransition.png

引用元: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が上に吸い込まれるような挙動になってしまったりします。

f:id:ticktakclock:20200628182530g:plain
微妙にうまく動いていない

遷移先の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でアンケート的な何かがあるときにリアクションを使うパターンはよくありますね。

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にアップデートする時が来る
  • 来たるべきアプデの時までに覚悟を決めましょう