ticktakclockの日記

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

minSdkVersionを19->23にする時に考慮したこと

こんにちは、tkyです。

私が業務で担当しているAndroidのアプリサポートバージョンをAPI19->23に引き上げました。

すでにリリースから数ヶ月経過しましたが、その時にやったことなどをまとめたいと思います。

OSの分布を知る

  • 世界的なOS分布 developer.android.com
  • プロダクトのOS分布

    アナリティクスツールなどで確認

切りたいバージョンのユーザーが何%行っていたら切るか、というのは社内でもそこまで定まっておらず、他の会社で採用している5%や3%などといった数値を参考に踏み切りました。

ちなみに5系以下は全体の6%でした。そこそこいました。

社内に持ちかけ

POと話す機会は何回かあり、以前より

  • 古いOSVerでWebViewの表示不正やクラッシュがある
  • 特定の動画フォーマットが再生できない

などの問題が起こっていたことと、たまたまOSサポートバージョンの話になった時に「やりましょう」という流れになりました。

開発しやすくなるなら、という目線で快く進める方向になったのは一番大きいです。

(正直意思決定権を持つ人が納得してくれるかがサポートバージョンを上げる最難関ポイントだと思っています)

クリティカルな不具合は直しておく

目に見えてわかっている不具合は予め対応しておきましょう。後に修正できても古いOSを持つユーザーにはそれは届きません。

minsdkversionを23に引き上げる

単純にapp.gradleの記述を返るだけです。

- minSdkVersion 19
+ minSdkVersion 23

MultiDexサポートライブリは不要に

minSdkVersion が 21未満の場合、64k制限のためにMultidexオプションを付けることがありますが、

minSdkVersion が 21 以上の場合は multidex がデフォルトで有効のため、multidex サポートライブラリは必要ありません。

ApplicationクラスやGradleにmultidex関連の処理を書いている場合削除しておきましょう。

VERSION分岐コードの精査

例えば以下のようなコードがプロジェクト内に存在するとします。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    // lollipop以上でしか使えないAPIを使用するコード
    doSomething()
} else {
   // lollipop未満の場合はこちらを使う
    doSomethingLegacy()
}

minSdkVersionが23になる場合、lollipop未満の条件に当てはまることが無いので

doSomething()

だけで問題ないことになります。条件が if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) であっても同様ですね。

このように VERSION_CODESGrepをかけて削除できそうな箇所を探してリファクタリングしていきました。 Marshmallowでランタイムパーミッションなどが入っているでこういった条件分岐を書いたりしている箇所があるかもしれません。自身のプロジェクト内を探してみてください。

動作確認で何をしたか

特に何もしていませんが、検証端末に5系端末を使っていたので念の為インストール出来ないよね、という確認はしました。

次回からはこの端末もお役御免という形になりました。ありがとう。

ユーザーに事前に通知すること

運用面で考慮したことですが、事前にOSのサポートが終了となる旨をユーザーにお伝えしなければびっくりしてしまいますよね。

そのため直前のバージョンを以てサポートを終了とする旨をお知らせページなどで通知するようにしました。

ユーザーはアプデできないということを意識する

当たり前ですが、5系を切ると5系を使っているユーザーは最新Verにアプデができなくなります。

サーバーレベルでサポートを切るかどうかは別の話で、今回はアプリをアプデできないもののサービスは引き続き利用できる状態としました。

アプリ起動時「新しいバージョンのアプリがあります。アップデートしてください」というようなアプデを促すダイアログを出しているアプリは多いでしょう。

ただし5系ユーザーにこのダイアログを出すとPlay Storeに行っても勿論最新アプリはないので混乱を招いてしまいます。

実際にアプデが入ったときのユーザーの行動を事前に予測しておくことが大事だと思いました。

リリース後に起こった問題について

  • 間違ったAPIを消していた

WebViewのとあるメソッドで、TARGETがLOLLIPOPのメソッドがあったのでそもそも使われることが無いだろうというので消したコードが実はMASHMALLOW以下のメソッドだった。。

WebViewに手を入れるときは全OSバージョンチェックしたほうが良いな・・・と改めて反省・・・

まとめ

  • OSサポートバージョンを23に引き上げた
  • これでWebView関連の不具合に当たることも少なくなるといいな
  • 他にもこんなことを考慮した、みたいなのがあればぜひ教えていただきたい

Go言語でgit checkoutを補助するコマンドを作った

こんにちは、tkyです。

Go勉強してコマンドを作ってみました。

github.com

git checkout をサポートする gith というコマンドです。

f:id:ticktakclock:20201123102923p:plain
デモ1

こんな感じにブランチが選択式で表示されるので選べばそのブランチに移動できるようなものです。

リモートブランチをチェックアウトするときはチェックアウトするブランチ名を決めることができます。

f:id:ticktakclock:20201123103223p:plain
デモ2

※例えばorigin/developブランチからfeature/XXXXのようなことをできるように

主要モジュール

これがすべてです。対話式のコマンドを完成させるのに一番ラクな方法かと思います。

サンプルも結構あって実際に見て使い方の理解も深まりました。

github.com

また今回はコマンドに引数とか細かいことは何もやらないでいたために urfave/cli 使用はしませんでした。

作った経緯など

僕はSourceTreeやGitKrakenなどのGUIツールは使っておらず、CUIでブランチの移動とかcommit, pushなどをしています。

gitでブランチを切り替えながら作業している時に

「あれ〜?あのブランチ名なんだったけ〜??」

となることがよくあります。

例えば「ホーム画面のレイアウトを修正」で

  • feature/home_layout
  • feature/home_screen_layout
  • feature/update_home_layout

なんだったかな〜と・・・ブランチ名をノリで付けている弊害であとで困る典型的なパターンに陥っています。

一応運用回避としてGitHubのIssueで管理している場合、

feature/#{IssueMumber}_your_task_name

という感じでIssue番号をプレフィックスにつけておくと判別できますね。

とはいえやっぱりブランチ名調べて git checkout する必要があるのでもう少し楽にしたいな〜というところからコマンド作成に至りました。

Goを選んだ理由は「Goだとコマンドが作りやすい」「Goやったことないからやってみたい」です。

Goの勉強方法

Tour of Goやってました。基本ここで学びます。

tour.golang.org

あとはこちらのスライドで並行で理解していく感じでした。

docs.google.com

ざっくりとこの辺を理解してからコマンド作成をはじめました。

  • 変数の定義方法
  • 関数の定義方法
  • for文, if文の定義方法
  • ポインタ
  • 配列、スライス
  • 構造体

※ポインタについてはもともとC言語やってたので何も困ることはありませんでした

おまけ

.bash_profile とかにこんな感じのことを書いておくと検索文字列に引っかかった一番最初のブランチをチェックアウトする事ができます。

コマンドを作る方法は色々ありますね!

fun gitissue() {
  git branch | grep $1 -m1 | xargs git checkout 
}
$gitissue #123

みなさんも自作コマンドで開発を楽にしていきましょう!

GitHubActionsとCircleCIを使い分ける

こんにちは、tkyです。

今私が携わっているAndroidプロジェクトのCIは基本的にCircleCIを採用しています。

そんな中、同時にGitHubActionsも採用した経緯とどんなことをしているのか、ちょっとハマったことを書き留めておこうと思います。

また、本記事は「こうしたら良い」というわけではなく、「とある課題から解決策を模索した」結果なだけなので

皆さんのプロジェクトに合わせて最適な形を模索するための材料の1つとなれば幸いです。

今までのCIの運用

コードはGitHubで管理しており、PullRequestをトリガーにCIが回るようになってます。

※コミット単位にするとCIの作動回数が多くなるので節約の目的があります。

  • UnitTest
  • Lint
  • DangerでTestとLint結果を報告
  • DeoloyGateのdebug apkアップロード

[課題]developブランチとapkが一致しない

pull requestごとにdebug apkが作られるため最新Developブランチとapkが一致せず、debug用のapkはPRの動作確認用になっていました。

しかも実際はブランチをチェックアウトして自分でビルドして動作確認しており現状のapkは事実上形骸化していました。

チームメンバーと会話して「develop最新と紐付いているapkを常に確認できる状況作っておきたいよね」ということで

DeoloyGateのdebug apkアップロード のワークフローのトリガーを developブランチにマージされた時(developにコミットされた時)に変更します。

Circle CIの設定見直し

現在CircleCIの管理画面ではこの2がONになっています。

developブランチにマージされた時にしたい場合 Only build pull requests をOFFにしてfilterでdevelopのみ動作するワークフローを作ればよいのですが

そうするとPR以外のリモートへのコミットでもCIが回るのでそれもなぁ・・・という感じです。

上記をを許容できるならGitHubActions使う必要ないのでこの話は終わりですw

もう少し頑張りたいよね、というところでdevelopブランチにコミットされた時のトリガーだけをGitHubActionで切り出すことにしました。

実際に検討で使った図です

f:id:ticktakclock:20201121141635p:plain:w400
検討図

そんなこんなで以下のように話をまとめて現在のプロジェクトで2つのCIツールを運用している、という話でした。

  • CircleCI: PRした時に処理したい場合
  • GitHubActions: (特定ブランチに)コミットされた時に処理したい場合

なぜGitHubActionsにすべて移動しないのか?

GitHubの各トリガーに柔軟に対応できるGitHubActionsを使えばやりたいことは全部できるでしょう。

しかしながら後述するskip-buildの機能やSSHデバッグなどCircleCIがデフォでできる機能は各々でアクションを定義する必要があります。

ローカルCLIもないため実際にPR上げてtry&errorで動作確認になるのでコストも高いです。(一応CLI実行できるnodeプロジェクトがあったような気がする)

仮に私がaction全部作れたとして、ほかメンバーがそれを管理運用できるか、という問題も浮上してきます。

そういう部分もあり、いきなり全てをGitHubActionsにして逆にコストがかかることを考慮して一部だけ切り出しただけなので、今後の状況によってはありえるかなと思っています。(当分先になりそうですが・・・)

GitHubActions導入してみた

こんな感じのymlを作成します。これがDeploygateにアップロードする(gradleタスクを実行する)ワークフローとなります。

色んな人がAndroidのビルドしていたりするのでコピペで色々貼り付けてます。

.github/workflows/upload_apk.yml

name: upload_apk
# CIツールの使い分け
# ・CircleCI: PRした時に処理したい場合
# ・GitHubActions: (特定ブランチに)コミットされた時に処理したい場合
on:
  push:
    branches:
      - develop

jobs:
  build_canceller:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: stop old workflow
        uses: yellowmegaman/gh-build-canceller@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          workflows_filter: "upload_apk"
  deploygate-develop:
    name: Upload apk to deploygate for developDebug
    runs-on: ubuntu-latest
    needs: build_canceller
    steps:
      - name: Check out
        uses: actions/checkout@v2
      - name: set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: NDK cache
        id: cache-primes
        uses: actions/cache@v2
        with:
          path: ${ANDROID_HOME}
          # GithubActionsコンテナに入っているNDKバージョンとプロジェクトの必要NDKバージョンが異なるため
          # コンテナに必要NDKバージョンをインストールする
          # ビルドエラーが出た場合cacheステップとInstallステップに記述のバージョンを更新してください
          key: ${ANDROID_HOME}/ndk/21.0.6113669
          restore-keys: |
            ${{ runner.os }}-ndk-
      - name: Install NDK
        run: echo "y" | sudo $ANDROID_HOME/tools/bin/sdkmanager --install "ndk;21.0.6113669" --sdk_root=${ANDROID_SDK_ROOT}
      - name: Gradle cache
        uses: actions/cache@v2
        with:
          path: ~/.gradle
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
      - name: Upload DeployGateDevelopDebug
        run: |
          ./gradlew uploadDeployGateDevelopDebug

[ハマったこと]古いビルドはキャンセルされない

CircleCIのskip-buildと同じような機能はGitHubActionsにはないので自力でなんとかする必要があります。

そういうActionがあるのでありがたく使わせていただくことにします。

GitHubActionsを無料稼働枠内で使うための節約テクニックみたいな感じです・・・

※2020/11/21現在最新は

  build_canceller:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: stop old workflow
        uses: yellowmegaman/gh-build-canceller@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          workflows_filter: "upload_apk"

以上です。

Kotlin applyから理解するレシーバー付きラムダ

こんにちは、tkyです。

『レシーバー付きラムダ』という用語をご存知でしょうか。Kotlinインアクションとかで使われている表現です。

英語では Function literals with receiver と言われています。

今回は apply{} がどのように動作するのか確認しながら『レシーバー付きラムダ』何なのかというものを理解して行きたいと思います。

ラムダ式とは、スコープ関数、拡張関数といった用語の説明はしません。

apply

まず apply をおさらいします。こんな感じでインスタンスを生成したあとにプロパティに値を設定したりとかそんな使い方をするのが一般的ですね。

class User(val name: String) {
    var age: Int = 0
    var email: String = ""
}

val user = User("ticktakclock").apply {
    age = 10
    email = "example@example.com"
}

apply の実装を見てみるとこの様になっています。

public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}

注目していただきたいのは関数の引数である block: T.() -> Unit です。 引数にラムダを渡しているわけなのですが、 T.() というように Tの拡張関数のような表現になっていますね。

ここで拡張関数のドキュメントを見に行きます。

kotlinlang.org

以下抜粋

拡張関数を宣言するには レシーバータイプ (receiver type) を関数名の前に付ける必要があります。 次の例では、 swap 関数を MutableList<Int> に追加しています:

fun MutableList<Int>.swap(index1: Int, index2: Int) {
  val tmp = this[index1] // 'this' がリストに対応する
  this[index1] = this[index2]
  this[index2] = tmp
}
拡張関数内での this キーワードは、レシーバオブジェクト(ドットの前に渡されたもの)に対応しています。これで、この関数を任意の MutableList<Int> からでも呼べるようになりました:

はいでました!レシーバー!

この関数を実行(受ける)ときのthisの型またはオブジェクトを レシーバー というのですね。

swap関数をレシーバーはMutableListと理解できます。

block: T.() -> Unit は拡張関数で定義しようとすると次のようになりますが、このときのthisはTですね。別の視点からみてもこの理解でおおよそあってそうです。

fun T.block() {
    // thisはT
}

ラムダ式におけるthisはあくまでそのラムダ式を実行するオブジェクトとなりますが、 レシーバータイプを指定するとことでthisの型を指定することができるということです。

いったん apply のドキュメントを見に行ってみましょう。

kotlinlang.org

コンテキストオブジェクトは、レシーバー(this)として使用できます。

ここでもちゃんとレシーバーが出てきました。

新しい単語(コンテキストオブジェクト)も出てきました。関数のスコープ内で使用する特定のオブジェクトのことを指しているのであっていると思いますが、その特定のオブジェクトにthisでアクセスできるということですね。

続いてラムダ式のリファレンスも見に行ってみましょう。きっと何かが書いてありそうな予感。

kotlinlang.org

ありました。関数型はレシーバーを使用することで関数型のインスタンスを呼び出す事ができる、というようなことが書いてあります。

applyに戻ります。

class User(val name: String) {
    var age: Int = 0
    var email: String = ""
}

val user = User("ticktakclock").apply {
    // applyを呼んだUserインスタンスをレシーバーとしてラムダ式を実行する
    age = 10
    // thisはUserインスタンスなのでthis.age =10 と同じ
    email = "example@example.com"
}

おお〜理解できるかも〜!

まとめ

  • レシーバー付きラムダというのは拡張関数をラムダ式で記述したもののこと
  • レシーバーというのはこの関数を実行(受ける)ときのthisの型またはオブジェクトのこと
  • ドキュメントって何でも書いてあるんだなぁ

理解の仕方や調べ方の助けになったら幸いです。

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と比較してみて

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