k-shinn’s 雑記

技術メモを書き溜められたら良いな

CleanArchtectureを読んでみたAndroidエンジニアの走り書き

名著と名高いClean Architecture 達人に学ぶソフトウェアの構造と設計をつい最近になって読了しました。

詳細な内容の纏め記事みたいなのは検索すれば先駆者の方々のモノがいくらでも出てきますので、ここではAndroidエンジニアである自分視点でためになった内容の抜粋や感想なんかを纏めてみようと思います。

まだこの本を読んでいない人、特に私と同じAndroidエンジニアの方の参考になればと思います。

(私の理解度が足りていない事や、わかり易さ重視であえてざっくり端折る表現を多用しているため、誤解を生むことも多々あると思います事を、ご了承ください)

CleanArchtectureとはなんなのか?

抜粋とは言いましたが、まず大前提な部分については少し纏めてみたいと思います。

私含め、CleanArchtectureを学んで見ようと思う動機の一つに、「よく見かける同心円状の図(下の図)は何を意味しているのか?」「名前付けや数も含めてアレに沿わなくては行けないのか?」と思うところがあると思います。

f:id:k-shinn:20210809153233j:plain
"Clean Coder Blog" より

端的に言うと、この図は「コンセプト図」であり、名前付けや数はその一例に過ぎません。そしてそのコンセプトこそ、この本で言う「CleanArchtecture」の内容そのものになります。

そのコンセプトとは、主に「関心事の分離」です。

このコンセプトは以下の依存性のルールを守ることにより達成されます。

  • 円の内側は、外側について何も知ってはいけない
  • 円の内側に向かうほど、抽象度と方針のレベルは高まる
  • 円の最も外側は、最下位レベルの具体的な詳細で構成される
  • 円の数はシステムによるが、上記のルールは常に適用される

(「方針≒目的→所謂ビジネスロジック」「詳細≒手段→フレームワークやDB、デバイス等」と考えると分かりやすいと思います)

これらを守り関心事を分離することで、柔軟性に優れたアーキテクチャが構成できると解説しています。

ルールの最後にある通り、円の数≒コンポーネントの数は一定ではありません。構築するシステムによって多くも少なくもなり得ますし、名前もこれに沿わなければいけない訳ではありません。

「CleanArchtecture」とは、具体的な仕組みのことではなく、この依存性のルールそのものであると理解できるわけです。

本書の内容構成について

まだこの本を読んでない人向けに解説すると、そもそもこの「CleanArchtecture」の同心円の図が本の中で出てくるのは、かなり後の方になります。この本の内容の殆どは、「CleanArchtecture」を理解・実践するために必要な要素、歴史的知見、数々のプログラミングの法則及びその実践テクニックになっています。 (これを念頭に置いておかないと、自分が何の本を読んでいるのか途中で見失いそうになります(なりました))

前述した「方針」と「詳細」についでも本の内容に含まれています。

そもそもこの「CleanArchtecture」のコンセプト自体については、著者ご本人が書かれたブログに存在しています。

blog.cleancoder.com

このコンセプトの知識を固めるための本ということになるわけです。

Androidの立ち位置

本書の内容は「アーキテクチャのルールはどれも同じである!」という謳い文句があるように、Android開発においても活かせるものです。

それどころか、「第29章 クリーン組込みアーキテクチャ」において、具体的に触れられている一文があります。

Androidアプリの開発者は、Android APIビジネスロジックを分離できていなければ、ファームウェアを書いていることになる。”

本章では「CleanArchtecture」の依存性のルールを組込みシステム向けに適用し、ハードウェア・ファームウェア・OSをビジネスロジックから切り離すことの重要性を解説しています。依存性のルールの円における外側にOS等が位置するわけです。(「OSは詳細」という分かりやすい小見出しまであります)

つまり、AndroidのシステムAPIも、CleanArchtectureの円の外側に位置するという事です。

Android API の関心分離の実践

では、これを実践するとどうなるだろうと考えてみました。

AndroidAPIとは、つまるところ android androidx 等のandroidパッケージの事です。Activity/Fragment,ViewModelなんかはそれそものですから、否応なしに依存したものになります。

依存性のルールに従うと、これを外側に位置させ、内側はこれを知らないようにしなければいけません。また、この境界線を越える処理は、インターフェイスを作成し、依存性を内側に入り込まないようにします。

…この方針は、Androidでよく見る、ViewModelからUseCaseをインターフェイス越しに呼ぶ形式そのものです。

簡易的に書くと以下のような形です。

class SampleViewModel() : ViewModel() {

    // 実装をクラスをInjectして使用する
    @Inject
    lateinit var useCase: SampleUseCaseInterface

    fun hoge(value: Int) {
        useCase.execute(value)
        ...
    }
    ...
}

呼び出されたUseCase…内側は、外側の事を知ってはいけません。 つまり、UseCaseはAndroidAPIはもとより、FragmentやViewModelの実装に依存しないようにすればよいわけです。

これはUseCaseへの引数として、ViewやViewModel、AndroidContextなどを渡さないようにする事で概ね達成されると思います。(勿論UseCase内で生成もしません)

分かりやすい指標として、UseCaseクラスのimportパッケージにこれら(円の外側の要素)が含まれていない事が挙げられてます。

package com.sample.domain.usecase

// Modelは円の内側である事が殆ど
import com.sample.model.Hogehoge
import com.sample.model.Fugafuga
...

interface SampleUseCaseInterface {
    fun execute(value: Int)
}

class SampleUseCaseImpl : SampleUseCaseInterface {
    override fun execute(value: Int) {
        val hogehoge = Hogehoge(value)
        ...
    }
}

これはCleanArchtectureを理解していなくても実践している方も多いと思います。なぜなら、AndroidパッケージやActivityを含まないクラスは、非常にテストが用意だからです。そしてこの「テスト容易性」も、「CleanArchtecture」 が実現する特性の一つです。

結局の所、「CleanArchtecture」が提唱する仕組みはAndroid開発にも大いに役立ち、また知らずしらずの内に実践している事が多いと思われます。

UseCaseの考え方の参考

前述のUseCaseクラスのimportパッケージで依存を考える方法ですが、数年前のDroidkaigiで正しくこの事を別視点から解説されている方が居りました。

ぼくのかんがえた最強のUsecaseの作り方~あるいはビジネスロジックとはなにかという1つの回答~ - Speaker Deck

CleanArchtectureを理解していなかった当時にこの発表を拝見していたのですが、今読み返してみますとより納得できる内容でした。機械的にではなく、よりドメイン的に納得して実現する参考になると思います。

Androidお馴染みのフレームワークへの適応

先程から述べている通り、AndroidAPIは「詳細(円の外側の要素)」の一つです。 「詳細」はこれ以外にも、DBやフレームワークなどの具体的な実装手段が含まれるとも言及しました。

これをAndroid開発的に考えてみると、非常に馴染み深い一例が出てきます。DeveloperガイドにもあるRepositoryパターンです。

f:id:k-shinn:20210809172145p:plain
"Android Developerガイド" より

用語や矢印のせいで少し混乱するかもしれませんが、CleanArchtectureとして整理するなら、「Room」や「Retrofit」はデータを取得する具体的な実装手段なので「詳細」になります。

RepositoryはRoom/Retrofitの実装を呼び元から隠蔽してくれます。 本来はViewModel~Repository間にUseCaseが存在していると考えると、内側であるUseCaseから、外側であるRoom/Retrofitへの依存を無くして(依存性逆転)くれている事になります。

Repositoryの存在によって、テストがしやすく、フレームワーク依存を他のコンポーネントに伝播させないようにできる事は、このパターンを実装したことがある人には実感できることかと思います。

「テスト容易性」は前述のとおりですが、「フレームワーク非依存性」も、「CleanArchtecture」が実現する特性です。

「CleanArchtecture」はコンセプトであり、あらゆるアーキテクチャに適用できる/されているルールであると本では述べています。つまり、ここにもそのルールが適用されているという訳です。

色々な「詳細」の分離

これはDBやRepositoryに限ったことではありません。

Android開発では、様々なサードパーティ製のライブラリに頼る事が多々あると思います。そして、この依存性に…例えばライブラリの破壊的なアップデートによる影響修正に…悩まされる事は多いのではないでしょうか。

このライブラリを「詳細」であると捉えると、これを分離し、非依存性を担保する道筋が見えてきます。簡単にはインターフェイスを作成し、内側と捉えたコンポーネントが、この「詳細」のパッケージに依存しなければ良いのです。

…勿論、そんな簡単に何もかも分離できるわけでは無いと思います。特にView周りなんてライブラリまみれになること必至だと思います。それでも、影響範囲を少なくするための道筋にはなると思います。

円の内側について考える

「CleanArchtecture」における円の最も内側は「エンティティ」とされています。これは本の内容的には「最重要なビジネスデータを操作するビジネスルール」とされています。とてもざっくり言うと、「お金を払ってマンガが読める」くらいざっくり感の「システム実装に寄らない価値」と、「顧客とマンガと料金表」くらいざっくり感の「価値を実現するための必要なデータ群」です。

そして、この「エンティティ」の一つ外側にあるのが「ユースケース」…本の内容的には、「エンティティ(ビジネスルール)を実現するためのアプリケーション特有のルール」です。

これらは殆どAndroid実装に転用できる概念だと思います。

どんなシステムにもコアとなるデータクラスは存在します。ユーザデータや料金データ、マンガサービスなら画像データ、動画サービスなら動画データなどです。当たり前ですが、これらのデータはそれ単体は何にも依存しません。(他のデータを内包している等は別として)。これらはそのまま「エンティティ」の概念であると言えます。

では「ユースケース」はどうかと考えると、「お金を計算・消費し、画像を取得して返す」といった処理の実装が、そのまま「エンティティ(ビジネスルール)を実現したユースケース」と言えるでしょう。そしてこれらは、アプリ実装ではUseCase層やDomain層等と呼ばれる、FragmentやViewModelから呼ばれ、Repositoryなどを呼び出している層のことであると言えます。(殆ど場合、そのまま「ユースケース」だと思います)

そしてこの時、依存性のルールに則り更に外側への依存を排することで、柔軟でクリーンなアーキテクチャが実現できるという事になります。

依存性ルールに反する例

逆に、外側への依存を許してしまう瞬間とはどういう場合があるでしょう。

一つに、前述したUseCaseへの引数にFragmentやViewModelなど、Androidフレームワークの要素を含めてしまうことが考えられます。これは、処理の分割を適切に行えば解決できる場合が多いかと思います。

他にパッと思いつく…私自身やらかしていたことがある…例としては、Roomのデータ都合・依存をエンティティ(ここでは円の最も内側に位置するデータの意味)に含めてしまう事が挙げられます。

具体的には、Roomに保存するデータと、APIから取得するデータ形式と、UseCaseやViewとやり取りするデータ形式を一つのデータクラスとして扱っていた事です。

不都合なくこれで動いている間は問題に気付きにくいのですが、サーバ都合でデータの一部形式が変更された時、View都合で一時的なデータを増やした時、何れの場合にもRoomのマイグレーションが必須になってしまい、保守に手間が掛かり、バグの温床にもなってしまった事です。

「エンティティ」のデータはデータベースの都合とは無縁であるべきであり、 データベースの都合は他のコンポーネントは無縁であるべきという良い(悪い)例でした。

境界線の超え方

こういった問題についても、本書では道筋を示してくれています。 曰く、「境界線を越えるデータは、独立した単純なデータ構造にすべき」「常に内側の円にとって便利な形式にすべき」との事です。

これの単純な実践としては、前述の例では、多少手間でもRoomのデータと「エンティティ」としてのデータは別で定義し、常に変換を挟んで使用する事が考えられます。

マルチモジュールでの実践

Android開発ではここ数年でマルチモジュール構成にすることが特に推進されています。依存性の排除や整理の観点でみても、マルチモジュール構成にすることは非常に有用です。…というより、マルチモジュールにしたい理由の大部分にこれが挙げられると思います。

メインとなるモジュール(デフォルトではappモジュール)の依存を整理するのは非常に難しいですが、Room等の外側のモジュール、データとしてのエンティティ・ユースケースである内側のモジュールから整理していく事で、自ずとクリーンなアーキテクチャになっていくと思います。

(メインモジュールに関しての考え方も本にはありますが、ここでは割愛します)

まとめ

「CleanArchtecture」の本には、Android開発にも非常に役立つ、多くの知見が纏まっています。ここに抜粋したようなアーキテクチャの例や知見は内容のほんの一部であり、アーキテクチャを維持する・崩れそうになるサインを見極めるための知見、守るべき数々の法則等、様々な要素が載っています。

まだ読んだことがなく、「アーキテクチャはどう考えれば良いんだろう?」と悩んでいる方は、是非一読してみることをお勧めします。

参考

GithubActionsへのAppDistributionの導入

GithubActionsにAppDistributionを導入してみたので、その手順を纏めようと思います。

公式ドキュメントは以下です。

firebase.google.com

前提として、FirebaseとAndroidプロジェクトの連携は済んでいる状態とします。

AppDistributionの有効化

単純なことですが、FirebaseConsoleからAppDistributionを有効化します。

f:id:k-shinn:20210711143612p:plain
AppDistribuitonの有効化

あまり気にすることも無いと思いますが、これをやっておかないと最後に実行した時にErrorになります。

FirebaseTokenの取得

CI上からAppDistributionでの配布を実行するために、Tokenを取得します。

取得方法は公式ドキュメント的に3通り紹介されています。

今回はFirebaseへのアクセス権がある開発者でTokenを取得しようと思いますので、サービスアカウントを使用する方法以外の2種類を紹介します。 この場合、開発者が変わる場合にはTokenの見直しが必要になると思いますので、ご注意を。

gradleコマンドで取得する方法

gradleで以下の様に設定を加えます。

  • project-root/build.gradle
buildscript {
    dependencies {
        classpath 'com.google.firebase:firebase-appdistribution-gradle:2.0.1'
    }
}
  • project-root/app/build.gradle
apply plugin: 'com.google.firebase.appdistribution'

続いて以下のコマンドを実行します。

./gradlew appDistributionLogin

実行すると認証のためのURLが出力されるので、ログインすればTokenが発行されます。

f:id:k-shinn:20210711152515p:plain
Firebase Login

f:id:k-shinn:20210711153254p:plain
gradleログイン

FirebaseCLIで取得する方法

何れかの方法でFirebaseCLIをローカル環境にインストールして、以下のコマンドを実行します。

$ firebase login

実行すると即ブラウザでログインを求められるので、gradleの場合同様にログインすればTokenが発行されます。

環境変数へのTokenの登録

CI環境から取得したTokenを使用できるように、環境変数に登録します。 登録すべきTokenは先程取得したFirebaseTokenの他、FirebaseのアプリIDも必要になります。

アプリIDは以下の箇所で参照できます。

f:id:k-shinn:20210711160301p:plain
AppID

GithubActions用に登録するには、Githubリポジトリで、Setting→Secretから行えます。

f:id:k-shinn:20210711154407p:plain
github Secret

配布Actionの設定

Actionがありますので、これを使わせて頂きます。 配布するapkをBuild後、Actionを実行する手順を書けばOKです。

- name: Builed Apk
  run: |
    ./gradlew assembleProductionRelease
- name: upload artifact to Firebase App Distribution
  uses: wzieba/Firebase-Distribution-Github-Action@v1
  with:
    appId: ${{secrets.FIREBASE_APP_ID}}  # Githubに保存した環境変数
    token: ${{secrets.FIREBASE_TOKEN}}   # Githubに保存した環境変数
    groups: android-developer  # テスターグループ名
    file: app/build/outputs/apk/production/Release/app-production-Release.apk

後は上記のAction中にもあるように、テスターグループをFirebaseConsole上で設定して実行すれば完了です。

テスターグループについて

配布先はグループ毎に指定出来ます。このグループの作成はFirebaseConsole上で可能です。

f:id:k-shinn:20210711160407p:plain
テスターグループ設定

Action内で指定するテスターグループ名は、グループ名横のaliasになります。

テスターの登録

テスターはConsole上で直接アドレスを登録するか、招待リンクを作成してテスター側に登録してもらう事が出来ます

f:id:k-shinn:20210711155105p:plain
招待リンクの作成

後は登録時もしくは配布時に送られてくるメールからDistributionのページを開くか、メールのリンクにあるテスターアプリをインストールすれば使用できます。

その他の配布方法

gradleコマンドでの実行

gradleに予め設定を書いておけば、gradleコマンドでAppDistributionでの配布を行うことも出来ます。

前述のgradleでのTokenの取得はこの設定のついでに出来ますので、gradleコマンドで配布する場合はそちらも合わせて行うと良いと思います。 (あまり利点は無いと思いますが、GithubActionsからgradleコマンドを叩いてもできるはずです。)

CircleCIでの配布

これは自分で試していないので調べた限りの話になりますが、CircleCIの場合、その内部だけで設定を完結させようとすると、FirebaseCLIやfastlane等を環境上に導入するなど、ひと手間必要になりそうです。

セキュリティ上問題がなければ、上記のgradleコマンドを直接叩く手順をCIに加えてしまうことも選択肢に入れて良いと思いました。

GithubActionsでVersionに沿ったTAG付けを自動化する

開発バージョンを固める時にcommitにTAGを打つと思いますが、あれって結構面倒ですし、 開発内容に直結しない作業なので手動管理だと忘れがちです。 でも打っておかないと後々問題があって遡る時に辛かったりします…つまりは、自動化したい作業です。

今回はこの作業をGithubActionsで自動化したのと、その際のActionやTipsの紹介です。

自動化したいこと

今回はPRで特定のブランチ(Releaseブランチとか)に変更点をマージした際、以下のことを自動的にやってほしいと思いました。

  • Release用のapkをbuildする
  • versionName(versionCode)でTAGを打つ
    • この際、ReleaseNotes(事前に作成してGithubで管理している)の内容をコメントに転記する
  • TAGの位置(GithubのRelease)にbuildしたapkをアップロードする

使用するAction

上記の内容を行うために、公式及びGitHubMarketplaceからの以下のActionを使用します。 なお、build環境の構築などは割愛します。

(余談)バージョン名管理で悩んだ事

今回上記の get-apk-version が大いに役立ちました。 これがあったからTAGの自動化に踏み切れたと言っても過言ではありません。

というのも、アプリのバージョン管理はあくまでもgradleファイルの中で管理していることであり、 なおかつ変数管理してnameとcodeを作る等の処理が入っているので、GithubActionsの環境下からどうやってそれを取得すれば良いか迷ったからです。

調べてみると、GithubActionsの中でバージョンを管理するためのActionなんかも有りましたが、 元々gradleで管理しているものをGithubActionsに移管するのは違うだろうと思い、これは辞めました。 (CircleCIもまだ併用中で、CI環境に依存する事はしたくなかった等の理由もあります)

では別途読み込ませるためのファイルを管理するか…とも思いましたが、それも面倒ですし、逆に手間が増えてしまうので、 あくまでも現状の開発フロー内でどうにか出来ないかと考えていた所、上記Actionを見つけたという経緯です。

これなら現状の開発フローに更に手作業を増やすことはなく、GithubActionsにバージョンを持ってくることが出来ました。

コマンド結果を変数に入れて活用する

get-apk-version を使用する際に問題が発生しました。 build時にapkファイル名にバージョンを入れて出力する設定をしていたため(「v202010-release.apk」の様な感じ)、「バージョンを取得するためのActionにファイル名を入れるためにバージョンが必要」という状況が発生したのです。

これは同僚氏から「findすれば行けるのでは」とコマンド例をもらったことで解決出来ました。 最終的には以下のようにパスを変数に格納することで、他のActionに取り回せるようになりました。

- name: get file path
  id: find_path
  run: |
    APK_PATH=`find app/build/outputs/apk/production/release/ -name \*.apk | head -n 1`
    echo "::set-output name=PATH::${APK_PATH}"
- name: Get Apk Version
  id: apk
  uses: JantHsueh/get-apk-info-action@1.0
  with:
    apkPath: ${{ steps.find_path.outputs.PATH }}

変数の活用については以下のドキュメント辺りを参照すると良いと思います。 - GitHub Actionsのワークフローコマンド - GitHub Docs

Actionを使いつつ、痒いところにはコマンドを併用することで、大抵の事は出来るようになる気がします。

完成したAction

これまでの事を活用することで、build〜Slack通知までを、以下のように書くことができました。

# build
- name: build apk
  run: |
    ./gradlew assembleProductionRelease
# APKからバージョンを取得
- name: get file path
  id: find_path
  run: |
    APK_PATH=`find app/build/outputs/apk/production/release/ -name \*.apk | head -n 1`
    echo "::set-output name=PATH::${APK_PATH}"
- name: Get Apk Version
  id: apk
  uses: JantHsueh/get-apk-info-action@1.0
  with:
    apkPath: ${{ steps.find_path.outputs.PATH }}
# Tagの作成
- name: Create Release
  id: create_release
  uses: actions/create-release@v1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    tag_name: v${{ steps.apk.outputs.versionNum }}(${{ steps.apk.outputs.versionCode }})
    release_name: Release v${{ steps.apk.outputs.versionNum }}(${{ steps.apk.outputs.versionCode }})
    body_path: LastReleaseNotes.md
# apkのUpload
- name: Upload Release Asset
  id: upload-release-asset
  uses: actions/upload-release-asset@v1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    upload_url: ${{ steps.create_release.outputs.upload_url }}
    asset_path: ${{ steps.find_path.outputs.PATH }}
    asset_name: ${{ steps.apk.outputs.versionCode }}-production-release.apk
    asset_content_type: application/vnd.android.package-archive
# Slack通知、成功時
- name: Slack Notification on SUCCESS
  if: success()
  uses: tokorom/action-slack-incoming-webhook@main
  env:
    INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
  with:
    text: "Release Actions Succeeded!"
    attachments: |
      [
        {
          "color": "good",
          "fields": [
            {
              "title": "GitHub Actions URL",
              "value": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            },
            {
              "title": "TAG URL",
              "value": "${{ steps.create_release.outputs.html_url }}"
            }
          ]
        }
      ]
# Slack通知、失敗時
- name: Slack Notification on FAILURE
  if: failure()
  uses: tokorom/action-slack-incoming-webhook@main
  env:
    INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
  with:
    text: "Release Actions Failed..."
    attachments: |
      [
        {
          "color": "danger",
          "fields": [
            {
              "title": "GitHub Actions URL",
              "value": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }
          ]
        }
      ]

これによって、Assetを含んだReleaseTAGが自動で作成できました。

f:id:k-shinn:20210614104133p:plain
GithubActionsから作成されたReleaseTAG

また、リンク付きのSlack通知も出来ました。(アイコンは失敗時と分けたほうが良いかも…)

f:id:k-shinn:20210614104205p:plain
GithubActionsからのSlack通知

この様な既存Actionが簡単に使用できる他、Slack通知に使用しているようなGithub上のリンクも取得しやすいのがとても助かる点です。失敗時のリンクを貼っておくとすぐに確認しにいけて便利で特にオススメです。

今回の事例では、Acitonとコマンドの併用について学べたのが、個人的に特にプラスになった点です。 更になんとかしたい点としては、ReleaseNotesの更新漏れをどうにか検知できないかといった点があります。これについてはおいおい考えていきたい所です。

皆さんも、GithubActionsで細かなCI課題を解決していきましょう。

RoomのMigrationテストで詰まった話

最近、AndroidにてRoomのMigrationテストを今更ながら実運用として書き始めました。 Room自体もMigrationテストも公式ドキュメントに沿えば十分書けるのですが、個人的に詰まった箇所が数カ所あるので、覚書として纏めたいと思います。

Roomの導入については以下の公式ドキュメントから参照すると良いと思います。

developer.android.com

Migrationとテストの手順については以下の箇所を参照すれば大丈夫です。

developer.android.com

exportSchemaの設定見直し

Migrationテストにはデータベースのバージョンごとのスキーマ履歴が必要になります。 これの出力設定は公式ドキュメントでは以下の箇所です。

私はRoom導入時にはMigrationテストの準備まで行っていなかったので、以下の様に exportSchemafalse にしてしばらく動かしていました。

@Database(
    entities = [UserEntity::class],
    version = 1,
    exportSchema = false
)
abstract class MyDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    ...

逆に言うと、この設定だとgradleの以下の記述は必要ありませんでした。

android {
    ...

    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += [
                        "room.schemaLocation"  : "$projectDir/schemas".toString(),
                        "room.incremental"     : "true",
                        "room.expandProjection": "true"]
            }
        }
    }

    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
    ...

とはいえ大した行数ではないので、導入処理としてひとまず一通り書いておいた方が良いだろうとは思います。

なお、exportSchema をそもそも書かないと、build時に以下の警告が出ることがあります。 記述時に警告が出ないので、忘れる事もあり得るかと思います。

警告: Schema export directory is not provided to the annotation processor so we cannot export the schema. You can either provide `room.schemaLocation` annotation processor argument OR set exportSchema to false.

Schemaファイルの活用

Migration設定を書く際に、以下の様に差分のSQLを書かなくてはいけません。

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("CREATE TABLE IF NOT EXISTS `messages` (`id` INTEGER NOT NULL, `message` TEXT NOT NULL, PRIMARY KEY(`id`))")
    }
}

SQLに慣れている人には苦も無いことかも思いますが、私含め自信が無い人は、前述の手順で出力設定したスキーマ履歴のファイルを活用できます。

出力されたjsonファイルの中身を見ると、以下の様にテーブル作成のクエリがそのまま存在します。

    ...
    "entities": [
      {
        "tableName": "users",
        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
        "fields": [
        ...

もちろん変数部分を書き換える必要はありますが、ほぼそのままMigration用のSQL文として流用できます。(この点を考えてもSchemaの出力設定は一先ずしておくべきだなと思いました)

androidTestとtestBuildType設定

RoomのMigrationテストはandroidTest側に書かなければなりません。…これ自体も、癖でローカルユニットテストとして書いてしまって、最初は動かずに首を傾げる自体になりました。

これはRoomのテストに限らない話なのですが、androidTestはbuildVariantを変更していると動かない場合があります。 以下のドキュメントに書いてある通りなのですが、デフォルトの debug 以外のbuildVariantに設定しているとErrorになってしまうので、注意が必要です。

この設定は、androidTestを開発フロー中のどの時点で、どの程度の頻度で行うかなど、プロジェクトの方針やCI設計に依存するものかと思います。 これは私も現在進行形で悩んでいることなので、良い方針ができれば別途CIの話として纏めたいと思います。

versionを前後しているとErrorする問題

これは開発上どうしようもない話かと思うのですが、開発中にデータベースのバージョンが違うブランチを行ったり来たりしていると、実行時にアプリが落ちることがあります。

例として、version2->1と移行してしまうと以下のExceptionが発生します。

 java.lang.IllegalStateException: A migration from 2 to 1 was required but not found. Please provide the necessary Migration path via RoomDatabase.Builder.addMigration(Migration ...) or allow for destructive migrations via one of the RoomDatabase.Builder.fallbackToDestructiveMigration* methods.

2->1 のMigrationなんて早々書かないとは思いますので、可能なら以下のドキュメントに沿って、 fallbackToDestructiveMigration を指定しておくと良いかと思います。

ただしこれは、ドキュメントにもある通り 既存のデータが失われることを許容できるのであれば という条件が付きます。 実運用でもMigrationに失敗してユーザのデータベースが失われても許容可能であるなら、開発効率にも影響してしまうのでやるべきかとは思います(そうならないためのMigrationテストではあるのですが…)。

なんにせよ、データベースはテストをちゃんと書いて、慎重に扱いたいものです。

Android12beta触ろうとして早速詰まった所

Android12beta版が発表されまして、早速試そうとしたところで色々詰まったのでその備忘録です。

前提として、既存のプロジェクトを12に移行して試す場合です。 公式ドキュメント的には以下の辺りからの内容です。

developer.android.com

developer.android.com

intent-filter付きのコンポーネントへのexported設定

ドキュメント的には以下の内容です。

intent-filter を含むActivityやServiceには、 android:exported="true/false" を設定する事が必須になりました。 ざっくりとした理解としては、アプリの外に公開する必要がある―外部から開かれるためのfilterには "true"、そうでないものは "false" になるようです。

つまるところ、起動時のActivityのへの指定は "true" になるようです。

    <application
        ...
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

これを指定しないとそもそもインストールで失敗するのはドキュメントの通りなのですが、起動時のActivityを間違って "false" にしてしまい、以下のgifのようになりました…。

f:id:k-shinn:20210524234008g:plain:w200

intentの受け取り手が見つからなくて失敗しているような挙動でしょうか。

例としてドキュメントに載っているものがBackgroundServiceの "false" 設定だけだったので、適当にコピペしてたら間違うかもしれないので注意です。

PendingIntentの可変性宣言

こちらも必須になった系のものです。 ドキュメントは以下です。

タイトル通り、PendingIntentを使う際に PendingIntent.FLAG_MUTABLE または PendingIntent.FLAG_IMMUTABLE のフラグを付けるのが必須になっています。

可能な限り FLAG_IMMUTABLE を推奨とのことですが、通知内のダイレクト返信アクションバブルを使用する際にはこの限りでは無いそうです。 あくまでも必要に迫られた場合のみということですね。

なお困ったことに、依存するLibraryもこの影響を受けてしまうようです。

私の環境ではデバッグ用途のLibraryが使用していたらしく、起動時に以下のErrorを吐いてしまいました。

    java.lang.IllegalArgumentException: /* PACKAGE_NAME */: Targeting S+ (version 10000 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.
    Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality depends on the PendingIntent being mutable, e.g. if it needs to be used with inline replies or bubbles.

PendingIntentの作成時にスローされるので、意図せず内包している可能性があり注意が必要です。

sdkVersionの構文

最初に弄る箇所の話なのですが、お恥ずかしい話なので最後に書きます...。

compileSdkVersiontargetSdkVersion は "31" ではない…というだけの話です。 最初に何も考えず"31"って書いてBuild失敗してしばらく首を傾げてました…。

正しくはドキュメントの通り以下です…。

android {
    compileSdkVersion("android-S")

    defaultConfig {
        targetSdkVersion("S")
    }
}

起動時に躓くと気分的にとても消耗してしまうので、気を付けたいところです。