概要
前回のつづきです。
前回はtargetSDKのみあげて、他のライブラリのバージョンを上げなかったので、上げてみようと思います。
今回は以下を対象にします。
- Crashlytics
- Kotlin
- Room
- Koin
Crashlyticsは去年からずっと「更新せよ」とFirebaseから通知メールが来ていましてね。リリースしていないアプリだからと無視していましたが、前回の記事でプログラム動作中にクラッシュし続けていたのでようやく対応します(汗)
また、前回Android Gradle Pluginのバージョンも上げましたが、それで出るようになった警告などにも未対応なので、一緒にやっていきます。
なお、AndroidX(Jetpack)のアップデートは、やっていたら記事が長くなったので次回に分けます。
ということで、今回はライブラリバージョンアップ2021春(1)となっています。
環境
| ツールなど | バージョンなど |
|---|---|
| MacbookPro | macOS Catalina 10.15.7 |
| Android Studio | 4.1.2 |
| Java(JDK) | openjdk version "11.0.10" |
修正前のbuild.gradle
修正前後を見比べた方が分かりやすそうだったので、まず作業前のを貼っておきます。
build.gradleは、プロジェクト直下にあるファイルと、appフォルダ下にあるファイルの2つあるので注意してください。
修正前コード
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.72'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
classpath 'com.google.gms:google-services:4.3.3'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.1.0'
}
}
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
def keystorePropertiesFile = rootProject.file("keystore.properties")
android {
compileSdkVersion 29
defaultConfig {
applicationId "jp.les.kasa.sample.mykotlinapp"
minSdkVersion 19
targetSdkVersion 29
multiDexEnabled true
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
testApplicationId "jp.les.kasa.sample.mykotlinapp.test"
resConfigs "ja"
}
signingConfigs {
debug {
storeFile file('debug.jks')
storePassword 'android'
keyAlias = 'androiddebugkey'
keyPassword 'android'
}
release {
if (keystorePropertiesFile.exists()) {
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
}
buildTypes {
debug {
resValue "string", "app_name", "(d)歩数計記録アプリ"
applicationIdSuffix ".debug"
minifyEnabled false
signingConfig signingConfigs.debug
}
release {
resValue "string", "app_name", "歩数計記録アプリ"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
}
testOptions {
unitTests {
includeAndroidResources = true
returnDefaultValues = true
}
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
dataBinding {
enabled true
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.3.0-alpha01'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'com.google.android.material:material:1.2.0-alpha04'
implementation 'androidx.fragment:fragment:1.2.1'
testImplementation 'junit:junit:4.12'
testImplementation 'org.assertj:assertj-core:3.2.0'
testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation 'androidx.test:runner:1.2.0'
testImplementation 'androidx.test:rules:1.2.0'
testImplementation 'androidx.test.espresso:espresso-core:3.2.0'
testImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
testImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
testImplementation 'org.robolectric:robolectric:4.3.1'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'org.assertj:assertj-core:3.2.0'
androidTestUtil 'androidx.test:orchestrator:1.2.0'
// multidex
implementation 'androidx.multidex:multidex:2.0.1'
def lifecycle_version = "2.1.0"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
androidTestImplementation("androidx.arch.core:core-testing:$lifecycle_version") {
exclude group: 'org.mockito:mockito-core'
}
testImplementation "androidx.arch.core:core-testing:$lifecycle_version"
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01'
// ViewPager2
implementation 'androidx.viewpager2:viewpager2:1.0.0'
// Room components
def room_version = "2.2.3"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
androidTestImplementation ("androidx.room:room-testing:$room_version"){
exclude group: 'com.google.code.gson'
}
testImplementation "androidx.room:room-testing:$room_version"
// Coroutines
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0"
// perission dispather
implementation "org.permissionsdispatcher:permissionsdispatcher:4.6.0"
kapt "org.permissionsdispatcher:permissionsdispatcher-processor:4.6.0"
// Koin for Kotlin apps
def koin_version = "2.0.1"
implementation "org.koin:koin-core:$koin_version"
// Testing
testImplementation "org.koin:koin-test:$koin_version"
androidTestImplementation "org.koin:koin-test:$koin_version"
// Koin Extended & experimental features
implementation "org.koin:koin-core-ext:$koin_version"
// Koin for Android
implementation "org.koin:koin-android:$koin_version"
// Koin AndroidX Scope feature
implementation "org.koin:koin-androidx-scope:$koin_version"
// Koin AndroidX ViewModel feature
implementation "org.koin:koin-androidx-viewmodel:$koin_version"
// Koin AndroidX Fragment Factory (unstable version)
// implementation "org.koin:koin-androidx-fragment:$koin_version"
// FragmentTest
testImplementation 'androidx.test:core:1.2.0'
androidTestImplementation 'androidx.test:core:1.2.0'
// Robolectric用,test向けではなくdebug向けに必要
debugImplementation "androidx.fragment:fragment-testing:1.2.1"
debugImplementation 'androidx.test:core:1.2.0'
// 無くてもアプリの実行は問題ないがテストがビルド出来ない
debugImplementation "androidx.legacy:legacy-support-core-ui:1.0.0"
debugImplementation "androidx.legacy:legacy-support-core-utils:1.0.0"
// Firebase
implementation 'com.google.firebase:firebase-analytics:17.4.1'
implementation 'com.google.firebase:firebase-crashlytics:17.0.0'
implementation 'com.firebaseui:firebase-ui-auth:6.2.1'
implementation 'com.google.firebase:firebase-firestore:21.4.3'
// Required only if Facebook login support is required
// Find the latest Facebook SDK releases here: https://goo.gl/Ce5L94
implementation 'com.facebook.android:facebook-android-sdk:7.0.0'
// Hashids
implementation 'org.hashids:hashids:1.0.3'
// Mockito
testImplementation 'org.mockito:mockito-core:3.7.7'
testImplementation 'org.mockito:mockito-inline:3.7.7'
}
Crashlyticsのバージョンアップ
こちらの公式ドキュメントを参考にやっていきます。
Firebase Crashlytics SDK にアップグレードする
https://firebase.google.com/docs/crashlytics/upgrade-sdk?platform=android
が、そもそもFabric時代のCrashlyticsではなく、すでにFirebase Crashlyticsを導入していますから、上記ページ内にある内容はあまりやることはなく、バージョンを最新に上げるくらいです。
1.プロジェクトルートのbuild.gradleを修正する
Google Services Gradle pluginとFirebaes Crashlytics Gradle pluginのバージョンを最新に上げます。
dependencies {
//...
classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.0'
}
2.アプリのbuild.gradleを修正する
firebase-crashlyticsとfirebase-analyticsのバージョンを最新にします。
dependencies {
// Firebase
implementation 'com.google.firebase:firebase-analytics:18.0.2'
implementation 'com.google.firebase:firebase-crashlytics:17.3.1'
}
これだけで大丈夫なはずです。
クラッシュレポートまで確認したい場合は、任意の場所で強制的に例外を起こしましょう。
throw RuntimeException("Test Crash")
以前書いたコードだと、アプリ起動時にペットを飼っているかのダイアログを表示させた上で、犬を飼ってないと答えると例外起こすようにしていましたね😁
それを復活させても良いでしょうw
場所はコメントで探せば分かるはずです。MainActivity.kt内です。
例外が起きたかどうかは、一度クラッシュさせてからアプリを再起動し、少ししてからFirebase のコンソールで確認できるかと思います。
確認できたらコードは元に戻しておきましょう。
3.Analyticsのバージョンアップに伴う非推奨関数の対応
先ほどFirebase Analyticsも一緒にバージョンアップしましたが、以下のようなビルド警告が出ていました。
w: /qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/utils/AnalyticsUtil.kt: (24, 27): 'setCurrentScreen(Activity!, String?, String?): Unit' is deprecated. Deprecated in Java
確認できない場合は、Clean&Rebuildしてみてください。
setCurrentScreenが非推奨になったようなので、まずはリファレンスを確認してみます。
This method is deprecated.
To log screen view events, call mFirebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, params) instead.
とあるので、logEventを使うように変更してみます。
パラメータに何を設定したら良いかは、以下のページを見ると分かります。
以下のように書けるかと思います。
/**
* 画面名報告
*/
fun sendScreenName(screenName: String, classOverrideName: String? = null) {
val bundle = Bundle().apply {
putString(FirebaseAnalytics.Param.SCREEN_NAME, screenName)
classOverrideName?.also {
putString(
FirebaseAnalytics.Param.SCREEN_CLASS,
classOverrideName
)
}
}
firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, bundle)
}
引数が変わったので、ビルドすると関数の利用箇所で軒並みコンパイルエラーになりますので、直していきましょう。第1引数を消すだけですが、
activity?.let { analyticsUtil.sendScreenName(it, SCREEN_NAME) }
こんな風になっていてactivityのnullチェックをしていたかと思います。
でも引数が不要になったので、analyticsUtil.sendScreenName(SCREEN_NAME)だけで良くなりますね。
4. AnalyticsUtilをテストではモックをDIする
実はこの時点で単体テストが通らなくなります。テストが失敗するのではなくて、テストの実行自体が途中でハングし延々と返ってこなくなります。
ハングする箇所は複数有りますが、その箇所はほぼ固定で、当然ながらハングしてしまうテスト関数はそれぞれ単独で実行すればテストは動くしパスします。
これに気付かずCIを回してしまうと、無料枠をあっという間に使い切ってしまうところでした(汗)
ローカルで動かしてみて気付いて良かったです。
どういう理屈か分かりませんが、setScreenNameをlogEventに変えただけでそうなるので、Analyticsの送信キューが溜まりすぎてしまうか何かかなと推察しています。
ひとまずテスト中は不要なので、モック化して何もしないクラスに差し替えたいと思います。
4.1 抽象クラスを作ってAnalyticsUtilに継承させる
DIで差し替えられるように、
// 基本クラス
abstract class AnalyticsUtilI {
}
// アプリで使う実クラス
class AnalyticsUtil : AnalyticsUtilI {
}
// テストで使うモッククラス
class MockAnalyticsUtil : AnalyticsUtilI {
}
というようにしたいですね。
ということで、以下のようにしてみました。
まずは基本クラスです。
abstract class AnalyticsUtilI {
/**
* スクリーン名報告
*/
abstract fun sendScreenName(
screenName: String,
classOverrideName: String? = null
)
/**
* イベント報告
*/
abstract fun logEvent(eventName: String, bundle: Bundle?)
/**
* ユーザープロパティ設定
*/
abstract fun setUserProperty(propertyName: String, value: String)
/**
* ユーザーIDのセット
*/
abstract fun setUserId(userId: String?)
/**
* ユーザープロパティ設定の例
*/
fun setPetDogProperty(hasDog: Boolean) {
setUserProperty("pet_dog", hasDog.toString())
}
/**
* ボタンタップイベント送信
*/
fun sendButtonEvent(buttonName: String) {
val bundle = Bundle().apply { putString("buttonName", buttonName) }
logEvent(FirebaseAnalytics.Event.SELECT_ITEM, bundle)
}
/**
* シェアイベント送信
*/
fun sendShareEvent(type: String) {
val bundle = Bundle().apply { putString("share_type", type) }
logEvent(FirebaseAnalytics.Event.SHARE, bundle)
}
/**
* カレンダーセルタップイベント送信
*/
fun sendCalendarCellEvent(date: String) {
val bundle = Bundle().apply { putString("date", date) }
logEvent("CalendarCell", bundle)
}
/**
* サインイン開始ボタンイベント送信
*/
fun sendSignInStartEvent() {
logEvent("StartSignIn", null)
}
/**
* サインイン開始ボタンイベント送信
*/
fun sendSignInErrorEvent(errorCode: Int) {
val bundle = Bundle().apply { putInt("errorCode", errorCode) }
logEvent("ErrorSignIn", bundle)
}
/**
* サインイン完了イベント送信
*/
fun sendSignInEvent() {
logEvent(FirebaseAnalytics.Event.LOGIN, null)
}
/**
* サインアウト開始ボタンイベント送信
*/
fun sendSignOutStartEvent() {
logEvent("StartSignOut", null)
}
/**
* サインアウト完了イベント送信
*/
fun sendSignOutEvent() {
logEvent("logout", null)
}
/**
* アカウント削除イベント送信
*/
fun sendDeleteAccountEvent() {
logEvent("delete_account", null)
}
}
継承先では必要最低限の関数だけ実装すれば良いように、抽象関数としました。
AnalyticsUtilはこうなります。
class AnalyticsUtil(app: Application) : AnalyticsUtilI() {
private val firebaseAnalytics by lazy { FirebaseAnalytics.getInstance(app) }
init {
FirebaseApp.initializeApp(app)
}
override fun sendScreenName(screenName: String, classOverrideName: String?) {
val bundle = Bundle().apply {
putString(FirebaseAnalytics.Param.SCREEN_NAME, screenName)
classOverrideName?.also {
putString(
FirebaseAnalytics.Param.SCREEN_CLASS,
classOverrideName
)
}
}
firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, bundle)
}
override fun logEvent(eventName: String, bundle: Bundle?) {
firebaseAnalytics.logEvent(eventName, bundle)
}
override fun setUserProperty(propertyName: String, value: String) {
firebaseAnalytics.setUserProperty(propertyName, value)
}
override fun setUserId(userId: String?) {
firebaseAnalytics.setUserId(userId)
}
}
4.2 koinのモジュール設定を変更する
koinのモジュール宣言の箇所を以下のように書き変えます。
// FirebaseService
val firebaseModule = module {
single { AnalyticsUtil(androidApplication()) as AnalyticsUtilI } // as以降を追記
single { AuthProvider(androidApplication()) as AuthProviderI }
}
4.3 AnalyticsUtilをAnalyticsUtilIに一括置換する
プロジェクト内検索でAnalyticsUtilをAnalyticsUtilIに一括置換してしまいましょう。
なぜなら、DIしている箇所のコードは以下のようだったはずですが、
val analyticsUtil: AnalyticsUtil by inject()
このままだと、DIするときにAnalyticsUtilクラスがないというkoinのエラーでクラッシュしてしまうからです。
Android StudioのメニューEdit - Find - Replace in Pathか、ショートカットキーで一括置換ウィンドウが起動します。一括検索や一括置換は便利なのでショートカットキーを覚えておきたいですね。
検索置換ウィンドウでは、検索文字にAnalyticsUtil、置換文字にAnalyticsUtilIと入力し、単語検索/大文字小文字の一致を設定します。
Android Studio 4.1だと分かりづらくなりましたが、以下の画像赤枠の部分、Aaが大文字小文字の一致設定、Wが単語検索のOn/Offのようです。
【moddules.ktとAnalyticsUtil.ktを除外して】、他はReplaceしていきます。
Replace Allしちゃうとこの二つのファイル内も置換しちゃうのでお気を付けて。
いったん、ここまででアプリが通常通り起動し動作するか確認しておきましょう。
4.4 テスト用のモックモジュールを作る
単体テスト用のモックモジュールを作って、mockModuleに追加します。
// テスト用にモックするモジュール
val mockModule = module {
// ...
single(override = true) {
MockAnalyticsUtil() as AnalyticsUtilI
}
}
// AnalyticsUtilのモッククラス
class MockAnalyticsUtil : AnalyticsUtilI() {
override fun sendScreenName(
screenName: String,
classOverrideName: String?
) {
}
override fun logEvent(eventName: String, bundle: Bundle?) {
}
override fun setUserProperty(propertyName: String, value: String) {
}
override fun setUserId(userId: String?) {
}
}
これで、単体テスト(Robolectric版)が実行出来るはずです。
androidTest版も同様に作っておいた方が良いでしょう。
// テスト用にモックするモジュール
val testMockModule = module {
// ...
single(override = true) {
TestAnalyticsUtil() as AnalyticsUtilI
}
}
// AnalyticsUtilのモッククラス
class TestAnalyticsUtil : AnalyticsUtilI() {
override fun sendScreenName(
screenName: String,
classOverrideName: String?
) {
}
override fun logEvent(eventName: String, bundle: Bundle?) {
}
override fun setUserProperty(propertyName: String, value: String) {
}
override fun setUserId(userId: String?) {
}
}
こちらもテストを実行して確認しておきましょう。
通るのを確認したら、ここまででコミットしておいた方が良いでしょうね!
(実はこのテストが通らないのはだいぶ後になって気付き、細かくコミットしていなかったため、最初から全部やり直してどこでテストが動かなくなるか原因突き止めるのに1人日くらい費やしました・・・泣
まさかlogEventが元凶だったなんて!!
皆さん、コミットは計画的に😭)
その他のビルド警告
以下のような警告が出ていました。
もし確認できない場合は、一度cleanしてリビルドするとまた出てくるかと思います。
w: /qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/activity/main/MainViewModel.kt: (55, 21): Type mismatch: inferred type is Date? but Date was expected
w: /qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/activity/main/MonthlyPageViewModel.kt: (60, 21): Type mismatch: inferred type is Date? but Date was expected
w: /qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/utils/AnalyticsUtil.kt: (24, 27): 'setCurrentScreen(Activity!, String?, String?): Unit' is deprecated. Deprecated in Java
w: /qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/utils/Util.kt: (77, 16): Type mismatch: inferred type is Date? but Date was expected
どうやら、SimpleDateFormat#parseの戻り値が@Nullableにアノテーションされているようで、Date(non-null)を期待しているコードと整合性がとれてないと言われているようです。
でも、パースできないときは例外が発生するはずなので、nullが返ることってありますかね?
と思ったのですが、リファレンスと読むと、
See the parse(java.lang.String, java.text.ParsePosition) method for more information on date parsing.
と書いてあり、元になる関数のリファレンスを見ると「パースに失敗したらnullを返す」と書いてありました。
Returns
Date A Date, or null if the input could not be parsed
ということで、パースに失敗するのはコーディング上のミスしかこの場合は考えられないため、全部!!を付けることにします。これを付けると、該当の変数が万一nullになったときは、NullPointerExceptionがスローされます。
例として、Util.ktの修正例を挙げておきます。
fun Calendar.equalsYMD(dateStr: String): Boolean {
val fmt = SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN)
val cal = Calendar.getInstance()
val time = fmt.parse(dateStr)!! // not-null assersion
cal.time = time
return this.equalsYMD(cal)
}
なお、!!は、演算子名としてはNot-Null Assertion Operatorというそうです(長いw)
https://kotlinlang.org/docs/null-safety.html#the-operator
この演算子はあまり多用はよろしくなく避けるべきと思っていますが、今回はコーディングミスしか有り得ないということで許容しました。
ここまでで、ビルド時の警告はほぼ消えたかと思います。
以下の警告は、Firebase Authの内部のマニフェストファイルに問題があるようなので、こちらでは対処が出来ないため同じく無視をします。Firebaseの中の人が気付いて直してくれるのを待ちましょう。
/Users/sachie/workspace/github/qiita_pedometer/app/src/debug/AndroidManifest.xml:24:9-31:50 Warning:
activity#com.google.firebase.auth.internal.FederatedSignInActivity@android:launchMode was tagged at AndroidManifest.xml:24 to replace other declarations but no other declaration present
赤字のANTLR Tool version 4.5.3 used for code generation does not match the current runtime version 4.7.1〜とかってやつは、Roomのバージョンアップをすれば消えるはずとの情報があったのですが、後段でアップデートしても出続けていました。解消方法は不明です。
Gradle Syncで表示される警告に対応する
Gradle Sync実行時に、以下のようなメッセージが出ていないでしょうか?
DSL element 'android.dataBinding.enabled' is obsolete and has been replaced with 'android.buildFeatures.dataBinding'.
It will be removed in version 5.0 of the Android Gradle plugin.
Affected Modules: app
警告というか「情報」レベルですが、対応しておきましょう。
Databindingの設定方法が変わったようですね。
dataBinding {
enabled true
}
これを、メッセージにあるとおり、以下のように変更すれば良いようです。
buildFeatures {
dataBinding true
}
Kotlinのバージョンアップ
もっと先にやれよという気もしないでもないですが、ここでやっておきましょう。
1.プラグインのバージョンをチェック
最新バージョン何かな?と思って調べようとしたのですが、以下の方法が簡単そうです。
Android StudioのメニューToolsから、Kotlin-Configure Kotlin Plugin Updatesとやると、
設定画面が出てきて、最新バージョンが表示されています。
ベータ版とか使いたい人は、Update channelを変更してCheck againしましょう。
新しいバージョンは1.4.31みたいなので、Installをクリックして待ちます。
Plugin will be activated after restartと出るので、OKをクリックして、Android Studioを再起動させます。
2.使用するKotlinバージョンを変更する
プロジェクトで利用するKotlinバージョンを変更します。
buildscript {
ext.kotlin_version = '1.4.31'
これでGradle Syncしてみます。
一応成功しますが、右下にこんなポップアップが出てくるかと思います。
移行が必要そうですね。
自動でやってくれそうなのでRun migrationsをクリックしてみます。
次の画面はデフォルトのままで問題ないかと思います。
しばらく待つと・・・
No suspicious code found. 143 files processed in 'Project 'qiita_pedometer''.
このアプリのコードには影響ないようです^^
Kotlinの1.3系から1.4系への変更点については、以下などを参考にしてください。
3. Kotlin stdlibをJDK8に上げる
app/build.gradleを開くと、org.jetbrains.kotlin:kotlin-stdlib-jdk8のラインで警告が出ます。
Plugin version (1.4.31) is not the same as library version (1.4.10)
いや、kotlinのバージョンは1.4.31だし、pluginも1.4.31に上げたし・・・1.4.10ってどこから出てきた??
と悩んで、そういやソースコードオプションはJava8にしているな、ということで、org.jetbrains.kotlin:kotlin-stdlib-jdk8に変更しGradle Syncしたところ警告は消えました。
確か以前は問題があってjdk7をわざわざ指定していたような気がするのですが、テストや動作確認してみても問題無さそうなので、こちらで行くことにします。
一応、./gradlew app:dependenciesしてみた結果、結構いろんなライブラリがまだ1.4.10を参照しているようですが、kotlin-stdlib-jdk8にすると警告が消えるのは謎です・・・
kotlin-extensionsの非推奨に対応する
先ほど「コードには影響なかった」と書きましたが、実は警告メッセージが出ています。
Warning: The 'kotlin-android-extensions' Gradle plugin is deprecated.
Please use this migration guide (https://goo.gle/kotlin-android-extensions-deprecation) to
start working with View Binding (https://developer.android.com/topic/libraries/view-binding)
and the 'kotlin-parcelize' plugin.
なんと、とても便利だったkotlin-extensionsが非推奨になりました。
初めてこの情報を聞いたときには「なんで?!」と思いましたが、以下の記事などで経緯が大変分かり易く解説されていましたので、是非一読してください。
Kotlin 1.4.20-M2でDeprecatedとなったKotlin Android Extensionsを弔う
https://qiita.com/iwsksky/items/27e48c244df120508fe8
じゃあ何を使うの!?というところですが、
- DataBindingを使っているところは、bindingオブジェクトからアクセスするようにすればOK
- それ以外の所は、ViewBindingというものを使う
とすれば良いようです。
早速やっていきましょう。
1.kotlin-extensionsプラグインを削除する
// apply plugin: 'kotlin-android-extensions' // この行を削除
2.ViewBindingを有効にする
先ほどdataBindingの設定方法を変更しましたが、そこにviewBindingも追加します。
buildFeatures {
dataBinding true
viewBinding true
}
Gradle Syncしておきましょう。
もしかしたら、一度ビルドもした方が良いかも知れません(ViewBindingのコードが生成されるようにするため)。もちろん、ビルドは失敗しますが。
3.Viewオブジェクトへのアクセスの変更
3.1.DataBindingを使っているクラスの修正
対象クラスは、DataBindingUtilなどでgrep検索などすると分かりやすいかなと思います。
修正箇所を全部書いているとキリがないので、LogEditFragmentクラスを例に記載します。他は同じようにやれば大丈夫でしょう。
まずimport文を削除します。
// import kotlinx.android.synthetic.main.fragment_log_input.* // ←これを削除
修正前はこのように書いてあったのを、
val dateText = text_date.text.toString() // 変更
val stepCount = edit_count.text.toString().toInt() // 変更
val level =
levelFromRadioId(radio_group.checkedRadioButtonId) // 変更
val weather =
weatherFromSpinner(
spinner_weather.selectedItemPosition // 変更
)
こんな風に書き変えます。
val dateText = binding.textDate.text.toString()
val stepCount = binding.editCount.text.toString().toInt()
val level =
levelFromRadioId(binding.radioGroup.checkedRadioButtonId)
val weather =
weatherFromSpinner(
binding.spinnerWeather.selectedItemPosition
)
binding.<resourceId>と書き変える感じです。ただ、resourceIdはキャメルケースに置き換えられているので注意が必要です。
xxxxBindingオブジェクトが他の関数からも必要になるケースが増えるので、これはもうメンバー変数で持つようにした方が良いでしょうね。
private lateinit var binding: FragmentLogEditBinding
代入する箇所も、ローカル変数からメンバー変数に入れるように変えるのを忘れないように。
// Inflate the layout for this fragment
binding = DataBindingUtil.inflate(
layoutInflater, R.layout.fragment_log_edit, container, false
)
これで、他の関数でもアクセスできるようになりました。
private fun validation(): Int? {
return logEditValidation(binding.editCount.text.toString())
}
置き換えていくときの便利なショートカットとして、例えばradio_groupを置き換えたい場合、radio_groupの前にカーソルを合わせてbinding.rまで(リソースidの先頭の数文字)を打って、候補が表示されたところで矢印キーで該当の変数に合わせ、そこでキーボードのTabキーを叩くと、単語ごと綺麗に置き換えることが出来ます。
後は他のクラスも同じようにやっていきましょう。
EditメニューのFind in pathでDataBindingUtilを入力し、右下のOpen in Find Windowしておくと、検索結果ウィンドウを残したまま作業が出来るので、多少楽かと思います。
DataBindingを既に使っているクラスは、ActivityでもFragmentでもやることに違いはないです。
3.2.ViewBindingを使うActivityクラスの対応
DataBindingを使っていないその他のActivityとFragmentはViewBindingを使うように変更しなければなりません。
DataBindingを使う場合は、レイアウトファイルを<layout>タグで全体を囲う必要がありましたが、ViewBindingの場合はこれは必要ありません。
LogItemActivity.ktを例にやっていきます。
まず、import文を削除するのは同じです。
// import kotlinx.android.synthetic.main.activity_log_item.* // 削除
bindingメンバー変数を宣言しておきます。
import jp.les.kasa.sample.mykotlinapp.databinding.ActivityLogItemBinding
class LogItemActivity : AppCompatActivity() {
...
private lateinit var binding: ActivityLogItemBinding
xxxxBindingクラスの命名規則は、レイアウトファイル名をそのままキャメルケースにしたものです。
今回は、activity_log_item.xmlですから、ActivityLogItemBindingとなります。
次に、レイアウトファイルをsetContentViewしている部分を変更します。
binding = ActivityLogItemBinding.inflate(layoutInflater)
setContentView(binding.root)
あとは、Databindingのときと同じように、リソースidをbinding.<リソースidのキャメルケース>に置き換えていくだけです。
3.3.ViewBindingを使うFragmentクラスの対応
import文を削除するのは同じ。
// import kotlinx.android.synthetic.main.fragment_log_input.* // 削除
// import kotlinx.android.synthetic.main.fragment_log_input.view.* // 削除
bindingメンバ変数を宣言しておきます。
import jp.les.kasa.sample.mykotlinapp.databinding.FragmentLogInputBinding
class LogInputFragment : BaseFragment() {
private var _binding: FragmentLogInputBinding? = null
private val binding get() = _binding!!
Activityとは違い、FragmentとViewの生存期間の違いから、bindingオブジェクトはnullの期間があり得るようです。従って、このような書き方が公式のサンプルでされていました。
参考:
https://developer.android.com/topic/libraries/view-binding?hl=ja
よってこの記事でも同じ書き方を踏襲しています。
次に、レイアウトをinflateしている部分を変更します。
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// Inflate the layout for this fragment
_binding = FragmentLogInputBinding.inflate(inflater, container, false)
ついでに、onCreateViewの戻り値がView?となってるのをViewと、non-nullに修正しました。
onDestroyViewで_binding変数をnullクリアします。
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
LogInputFragmentでは、contentViewから子Viewにアクセスしていましたが、それは不要になり、代わりにbindingオブジェクトを介すことになります。
override fun onCreateView(
// ....
binding.radioGroup.check(R.id.radio_normal)
today.clearTime()
binding.textDate.text = today.getDateStringYMD()
// ...
return binding.root
}
最後に返すViewオブジェクトは、binding.rootを返します。
同じ作業を、他のクラスにも行っていき、ビルドエラーが消えれば完了です。
3.4.includeを使っているレイアウトの場合
レイアウトxmlで、<include>を使って他のレイアウトファイルを参照している場合があります。
例えば、InstagramShareActivityです。
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="stepLog"
type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activity.share.InstagramShareActivity">
<!-- 省略 -->
<include
layout="@layout/content_instagram_share"
app:stepLog="@{stepLog}" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
こんな風になっているときには、まず<include>にidを付けます。
<include
android:id="@+id/content"
layout="@layout/content_instagram_share"
app:stepLog="@{stepLog}" />
コードからは以下のように、binding.<includeレイアウトに付けたid>という感じでアクセスできるようになります。
binding.content.buttonShareInstagram.setOnClickListener{...}
参考サイト:
https://stackoverflow.com/questions/58730127/viewbinding-how-to-get-binding-for-included-layouts
3.5 テストコードの修正
Espressoを使っているテストのandroidTest版の以下のテストで、直接viewオブジェクトにアクセスしているコードがありましたので、ここも修正しました。ViewPagerを取るためにやむを得なかったようですね。
@Test
fun swipe() {
// ...
val idleWatcher = ViewPagerIdleWatcher(mainActivity.binding.viewPager) // 変更
idleWatcher.waitForIdle()
// ...
MainActivity#bingingは、面倒なので変数宣言でpublicにしておきました。
気になる方は@VisibleForTesting使うとかしましょう。
lateinit var binding: ActivityMainBinding
4. Parcelプラグインへの変更
Kotlin extensionsのもう一つの機能、Parcelizeも利用していましたが、それも移行する必要があります。
4.1.プラグインを追加
アプリモジュールのbuild.gradleに以下を追加します。
apply plugin: 'kotlin-parcelize' // 追加
4.2.インポートを変更
以下2つのimportを変更します。
import kotlinx.android.parcel.Parcelize
import kotlinx.android.parcel.RawValue
変更後
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
後は変更の必要なく、ビルドは通るはずです。
なお、以下のように「キャストが不要だというエラーが出ますが、この部分のキャストを消すとKoinのInjectに不具合が生じるので、消してはいけません。
> Task :app:compileDebugKotlin
w: /Users/sachie/workspace/github/qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/di/modules.kt: (47, 34): No cast needed
w: /Users/sachie/workspace/github/qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/di/modules.kt: (48, 37): No cast needed
w: /Users/sachie/workspace/github/qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/di/modules.kt: (53, 50): No cast needed
w: /Users/sachie/workspace/github/qiita_pedometer/app/src/main/java/jp/les/kasa/sample/mykotlinapp/di/modules.kt: (54, 49): No cast needed
Roomのバージョンアップ
Roomは2021/03/01時点で最新バージョンは2.2.6のようですので、それに上げていきますが、付随して一緒に上げなければならない物が多いのでちょっと大変でした。
1. build.gradleの変更
Room本体以外のライブラリの変更点は次の通りです。
-
room-kotlin拡張を追加
以下によるとkotiln拡張が使えるようなので追加してみます。
https://developer.android.com/jetpack/androidx/releases/room?hl=ja -
Liefecycle関係やcoroutine関連
- Lifecycle-livedata-ktxが必要になるようなので追加
- core-testingはバージョン依存が別なようなので、定義を分けました。
-
lifecycle-extensionsは2.2.0を最後にdeprecatedとなったので削除
- 結局koinが最新バージョンでも参照してしまっているようですが、取り敢えず。
-
lifecycle-compilerの代わりにlifecycle-common-java8を使う
-
その方がインクリメントビルドが早いそうなので、そちらに変更します。
まとめると以下のようになります。
// Lifecycles
def lifecycle_version = "2.3.0"
// implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" // 削除
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" // 追加
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" // 削除
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
def arch_version = "2.1.0" // バージョン定義を別出し
androidTestImplementation("androidx.arch.core:core-testing:$arch_version") {
exclude group: 'org.mockito:mockito-core'
}
testImplementation "androidx.arch.core:core-testing:$arch_version"
// ...
// Room components
def room_version = "2.2.6" // version up
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version" // ←追加
androidTestImplementation ("androidx.room:room-testing:$room_version"){
exclude group: 'com.google.code.gson'
}
testImplementation "androidx.room:room-testing:$room_version"
// Coroutines
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2" // version up
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2" // version up
2.LiveDataからFlowへの変更
DAOでは、LiveDataを返すよりコルーチンのFlowを返す方が良さそうなので、そのように修正してみます。
参考記事:
https://qiita.com/tfandkusu/items/672b2a043d27c0fefc89
https://developer.android.com/codelabs/advanced-kotlin-coroutines
https://android.benigumo.com/20200129/flow-vs-livedata/
2.1 DAOクラス、Repositoryクラスの修正
import kotlinx.coroutines.flow.Flow // 追加
// ...
@Dao
interface LogDao {
// ...
@Query("SELECT * from log_table WHERE date>= :from AND date < :to ORDER BY date")
fun getRangeLog(from: String, to: String): Flow<List<StepCountLog>> // LiveData→Flowに変更
@Query("SELECT date from log_table ORDER BY date limit 1")
fun getOldestDate(): Flow<String> // LiveData→Flowに変更
}
LogRepositoryもDAOに合わせて変更します。
import kotlinx.coroutines.flow.Flow // 追加
// ...
class LogRepository(private val logDao: LogDao) {
// ...
@WorkerThread
fun searchRange(from: String, to: String): Flow<List<StepCountLog>> { // LiveData→Flowに変更
return logDao.getRangeLog(from, to)
}
@WorkerThread
fun allLogs(): List<StepCountLog> {
return logDao.getAllLogs()
}
@WorkerThread
fun getOldestDate(): Flow<String> { // LiveData→Flowに変更
return logDao.getOldestDate()
}
}
2.2 ViewModelクラスの修正
ViewModelでは、asLiveDataを使ってLiveDataとして監視できるようにします。
// 一番古いデータの年月
private val oldestDate = repository.getOldestDate().asLiveData() // LiveDataに変換
// データリスト
val stepCountList: LiveData<List<StepCountLog>> =
Transformations.switchMap(_dataYearMonth) {
val ymd = getFromToYMD(it)
firstDayInPage = ymd.first
repository.searchRange(ymd.first.getDateStringYMD(), ymd.second.getDateStringYMD()).asLiveData() // LiveDataに変換
}
3. ViewModelProvidersの非推奨に対応する
lifecycle-extensionsにあったViewModelProvidersが使えなくなりましたので、別の方法に変えなければなりません。
参考記事:
https://qiita.com/sudo5in5k/items/1d70ec65fd264eed5f7c
基本的にはKoinでInjectionしているので不要だったはずなのですが、SnsChooseDialogクラスでだけ、使っていました。
// import androidx.lifecycle.ViewModelProviders // 削除
import androidx.fragment.app.activityViewModels // 追加
class SnsChooseDialog : DialogFragment() {
private val viewModel :LogItemViewModel by activityViewModels() // 追加
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
// val viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java) // 削除
// AlertDialogで作成する
val builder = AlertDialog.Builder(requireContext())
builder.setItems(arrayOf("Twitter", "Instagram")) { _, which ->
viewModel.selectShareSns(which)
}
return builder.create()
}
}
val viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)としていたのを削除し、メンバー変数に持たせてandroidx.fragment.app.activityViewModels()から作成しています。
これはandroidx.fragment:fragment-ktxパッケージに入っている関数で、Fragmentの親ActivityのViewModelを探してきてくれるようです。
https://developer.android.com/reference/kotlin/androidx/fragment/app/package-summary#activityviewmodels
気をつけないと行けないのは、Fragment#onAttachedの後でないと、アクセスできないという点ですね。
今回はDialogFragmentが表示されておりリストを選択した後なので、DialogFragmentはAcvitityにアタッチ済みなはずということで、問題無さそうです。
(※Lazyなので最初にviewModel変数にアクセスされたときに初めてactivityViewModels()が実行されます)
4. Testの修正
戻り値がLiveDataからFlowに変わったので、それに合わせてテストも変更します。
4.1 LogRepositoryTestの修正
LogRepositoryTestでは以下の2つの関数が変更が必要です。
@Test
fun searchRange() {
runBlocking {
repository.insert(StepCountLog("2019/07/31", 12345))
repository.insert(StepCountLog("2019/08/01", 12345))
repository.insert(StepCountLog("2019/08/30", 12345))
repository.insert(StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD))
repository.insert(StepCountLog("2019/09/01", 123, LEVEL.BAD, WEATHER.RAIN))
repository.insert(StepCountLog("2019/12/31", 1111, LEVEL.BAD, WEATHER.RAIN))
repository.insert(StepCountLog("2019/01/01", 1111)) // 古いデータ
repository.insert(StepCountLog("2020/01/01", 11115))
repository.insert(StepCountLog("2020/02/29", 29))
repository.insert(StepCountLog("2020/02/28", 28))
repository.insert(StepCountLog("2020/03/01", 31))
}
val data6 = repository.searchRange("2019/06/01", "2019/07/01")
data6.observeForever {
assertThat(it).isEmpty()
}
val data8 = repository.searchRange("2019/08/01", "2019/09/01")
data8.observeForever {
assertThat(it).isNotEmpty()
assertThat(it!!.size).isEqualTo(3)
assertThat(it[0]).isEqualToComparingFieldByField(
StepCountLog("2019/08/01", 12345)
)
assertThat(it[1]).isEqualToComparingFieldByField(
StepCountLog("2019/08/30", 12345)
)
assertThat(it[2]).isEqualToComparingFieldByField(
StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD)
)
}
// 月またぎ、年またぎ
val data12 = repository.searchRange("2019/12/01", "2020/02/01")
data12.observeForever {
assertThat(it).isNotEmpty()
assertThat(it!!.size).isEqualTo(2)
assertThat(it[0]).isEqualToComparingFieldByField(
StepCountLog("2019/12/31", 1111, LEVEL.BAD, WEATHER.RAIN)
)
assertThat(it[1]).isEqualToComparingFieldByField(
StepCountLog("2020/01/01", 11115)
)
}
// 閏月
val data2 = repository.searchRange("2020/02/01", "2020/03/01")
data2.observeForever {
assertThat(it).isNotEmpty()
assertThat(it!!.size).isEqualTo(2)
assertThat(it[0]).isEqualToComparingFieldByField(
StepCountLog("2020/02/28", 28)
)
assertThat(it[1]).isEqualToComparingFieldByField(
StepCountLog("2020/02/29", 29)
)
}
}
@Test
fun getOldestDate() {
runBlocking {
repository.insert(StepCountLog("2019/08/30", 12345))
repository.insert(StepCountLog("2019/09/01", 12345))
repository.insert(StepCountLog("2019/09/22", 12345))
repository.insert(StepCountLog("2019/10/10", 12345))
repository.insert(StepCountLog("2019/10/13", 12345))
repository.insert(StepCountLog("2019/01/13", 12345))
repository.insert(StepCountLog("2020/02/03", 12345))
repository.insert(StepCountLog("2019/02/03", 12345))
repository.insert(StepCountLog("2020/02/04", 12345))
}
val date = repository.getOldestDate()
date.observeForever {
assertThat(it).isNotEmpty()
assertThat(it).isEqualTo("2019/01/13")
}
}
まずはgetOldestDate関数から見ていきます。
getOldestDateの戻り値はFlowです。Flowにセットされた値を取り出すにはどうすれば良いでしょうか?
そもそも、Flowというのは何だったのかというのを今更ながら考えてみます(汗)
こういうときは、F1キーでJavadocを表示してみましょう。
An asynchronous data stream that sequentially emits values and completes normally or with an exception.
非同期にデータをストリームに流すような感じのことが書いてあります。
つまりデータがドンドン流れてくる(更新される)ことが想定されているんですね。
で、その流れを監視する。という考えなわけです。まあこれはLiveDataでも同じですが、LiveDataはライフサイクルと絡んでしまうのに対し(もっともそれがウリなわけですが)、Flowはcoroutineの機能ですから、どこでもデータが取り出せるという所でしょうか。
この辺はRxJavaとか使ったことがある人だと理解しやすいのかな。
本来リポジトリクラスやDAOはライフサイクル関係ないですから、そこでLiveData返すのはどうだろう?と考えると、やはりFlowを使うのが自然なのかなと思います。
じゃあ、そのFlowから、やっぱりデータはどうやって取り出すべき?
安直な方法としては、asLiveDataを使ってしまい、Observeしているコードはそのままにするのもありますね。
でもせっかくなのでFlowのやり方でやりたいものです。
こういうときはいろいろググっても良いですがやっぱり公式を見ましょう。
といっても公式内も検索を上手くやらないと見つけられなかったりしますが^^;
Android での Kotlin Flow のテスト
https://developer.android.com/kotlin/flow/test?hl=ja
こちらに、いろんなFlowからの値の取り出し方が書いてあるので、ブクマしておくと良いかも知れません。
今回はfirstを使いました。
@Test
fun getOldestDate() = runBlocking<Unit> {
// ...
val date = repository.getOldestDate().first()
assertThat(date).isNotEmpty()
assertThat(date).isEqualTo("2019/01/13")
}
repository.getOldestDate().first()でFlowに流れてきた値を取り出して、取得した日付文字列が期待通りかのチェックになっています。
元の関数とは、runBlockingの指定の仕方も違っています。
以下でも良いのですが、
@Test
fun getOldestDate() {
runBlocking {
// ...
val date = repository.getOldestDate().first()
assertThat(date).isNotEmpty()
assertThat(date).isEqualTo("2019/01/13")
}
}
テスト関数の中身全体をrunBlockingで囲うくらいなら、fun テスト関数() = runBlocking{}とした方が良いかなと思ったのと、Coroutineの公式サイトでもsuspend functionのテストの書き方として挙げていたのでそちらに合わせました。
searchRangeテスト関数の方も同じです。
@Test
fun searchRange() = runBlocking<Unit> {
repository.insert(StepCountLog("2019/07/31", 12345))
repository.insert(StepCountLog("2019/08/01", 12345))
repository.insert(StepCountLog("2019/08/30", 12345))
repository.insert(StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD))
repository.insert(StepCountLog("2019/09/01", 123, LEVEL.BAD, WEATHER.RAIN))
repository.insert(StepCountLog("2019/12/31", 1111, LEVEL.BAD, WEATHER.RAIN))
repository.insert(StepCountLog("2019/01/01", 1111)) // 古いデータ
repository.insert(StepCountLog("2020/01/01", 11115))
repository.insert(StepCountLog("2020/02/29", 29))
repository.insert(StepCountLog("2020/02/28", 28))
repository.insert(StepCountLog("2020/03/01", 31))
val data6: List<StepCountLog> = repository.searchRange("2019/06/01", "2019/07/01").first()
assertThat(data6).isEmpty()
val data8 = repository.searchRange("2019/08/01", "2019/09/01").first()
assertThat(data8).isNotEmpty()
assertThat(data8.size).isEqualTo(3)
assertThat(data8[0]).isEqualToComparingFieldByField(
StepCountLog("2019/08/01", 12345)
)
assertThat(data8[1]).isEqualToComparingFieldByField(
StepCountLog("2019/08/30", 12345)
)
assertThat(data8[2]).isEqualToComparingFieldByField(
StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD)
)
// 月またぎ、年またぎ
val data12 = repository.searchRange("2019/12/01", "2020/02/01").first()
assertThat(data12).isNotEmpty()
assertThat(data12.size).isEqualTo(2)
assertThat(data12[0]).isEqualToComparingFieldByField(
StepCountLog("2019/12/31", 1111, LEVEL.BAD, WEATHER.RAIN)
)
assertThat(data12[1]).isEqualToComparingFieldByField(
StepCountLog("2020/01/01", 11115)
)
// 閏月
val data2 = repository.searchRange("2020/02/01", "2020/03/01").first()
assertThat(data2).isNotEmpty()
assertThat(data2.size).isEqualTo(2)
assertThat(data2[0]).isEqualToComparingFieldByField(
StepCountLog("2020/02/28", 28)
)
assertThat(data2[1]).isEqualToComparingFieldByField(
StepCountLog("2020/02/29", 29)
)
}
Flowからの値の取り出しにfirst()を使い、関数をrunBlocking<Unit>で起動するように変更しています。
4.2 MainViewModelTestの修正
こちらも、以下のテストのrunBlockingで関数ブロックそのものを囲います。
@Test
fun addStepCount() = runBlocking<Unit> {
viewModel.addStepCount(StepCountLog("2019/06/21", 123))
viewModel.addStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
val list = viewModel.repository.allLogs()
assertThat(list.size).isEqualTo(2)
assertThat(list[0]).isEqualToComparingFieldByField(
StepCountLog(
"2019/06/22",
456,
LEVEL.BAD,
WEATHER.HOT
)
)
assertThat(list[1]).isEqualToComparingFieldByField(StepCountLog("2019/06/21", 123))
}
@Test
fun deleteStepCount() = runBlocking<Unit> {
viewModel.addStepCount(StepCountLog("2019/06/21", 123))
viewModel.addStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
Thread.sleep(500)
viewModel.deleteStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
val list = viewModel.repository.allLogs()
assertThat(list.size).isEqualTo(1)
assertThat(list[0]).isEqualToComparingFieldByField(StepCountLog("2019/06/21", 123))
}
これで、UnitTest, RobolectricTest, 及びandroidTestが全て問題なく実行出来るはずです。
ここまででまた一度コミットしておきましょう。
Koinのバージョンアップ
1 build.gradleの変更
2.2.2が現時点では最新のようです。
koin-coreはkoin-androidを入れると自動で入るようなので削除しました。
また、koin-core-extはexperimentだったものが正式にkoin-androidに統合されたそうなので、これも削除しました。
koin-androidx-fragmentがstable版になっているようなので、使ってみたい方は使ってみると良いのではないでしょうか。
以前使おうとしていた場所がどこだったかもう忘れましたが(汗)
// Koin for Kotlin apps
def koin_version = "2.2.2"
// Testing
testImplementation "org.koin:koin-test:$koin_version"
androidTestImplementation "org.koin:koin-test:$koin_version"
// Koin for Android
implementation "org.koin:koin-android:$koin_version"
// Koin AndroidX Scope feature
implementation "org.koin:koin-androidx-scope:$koin_version"
// Koin AndroidX ViewModel feature
implementation "org.koin:koin-androidx-viewmodel:$koin_version"
// Koin AndroidX Fragment Factory (unstable version)
// implementation "org.koin:koin-androidx-fragment:$koin_version"
2 パッケージを修正する
以下のパッケージが移動になっているので修正します。
package jp.les.kasa.sample.mykotlinapp.di
//import org.koin.core.KoinComponent // 削除
//import org.koin.core.inject // 削除
import org.koin.core.component.KoinComponent // 追加
import org.koin.core.component.inject // 追加
@Suppress("EXPERIMENTAL_API_USAGE")
inline fun <reified T> byKoinInject(): T {
return object : KoinComponent {
val value: T by inject()
}.value
}
ただし、KoinComponentを間違って使っているのでは無いかという警告が出るようになってしまうため、@Suppressで警告を消しています。
もっとも、これは本当に使って良いか考えてから対処した方が良いです。
こちらなどが詳しいので参考にしてください。
https://qiita.com/que9/items/74fa813199e19dd19889
今回は、この関数が使われているのがBindingAdapter内であり、BindingAdapterのインスタンスの生成をこちらはハンドリング出来ないため、使っても構わない箇所、と判断できます。
そのため、警告を抑制することにしました。
3 テスト用inject関数のimportパッケージを変更
org.koin.core.injectではなく、org.koin.test.injectを使うように変更します。
// import org.koin.core.inject
import org.koin.test.inject
LogItemActivityTestなども同様に変更が必要です。修正対象ファイルは、プロジェクト内検索を掛けるか、ビルドしたらエラーになるのでそれで探しても良いでしょう。
Robolectric版、androidTest版ともに修正が必要です。
全部修正できたら、ビルドして、アプリの実行、テストを通しておきましょう。
Lintチェックエラーに対応
Lintチェックを掛けていないならば飛ばして良いのですが、私の場合Gitub Actionsで
- name: Check
if: success()
run: ./gradlew lint testDebugUnitTest
としていてチェックを掛けており、ここがコケていてテストが出来ていなかったので直すことにしました。
エラーは3種類です。警告なだけのものについては今回は対応しません。
1. LiveData value assignment nullability mismatch
Lintエラーレポートにはこんな感じで表示されます。
どうやらNonnullであるべき変数にNullableな変数を渡してしまっているようです。
該当箇所の実装を見ると、
val resultUri = saveBitmap(bitmap, displayName)
_savedBitmapUri.postValue(resultUri)
saveBitmapの宣言はsaveBitmap(...) : Uri?なのでNullableですね。
LiveData_savedBitmapUriを監視している箇所は、以下の通りで、
viewModel.savedBitmapUri.observe(this, Observer { imageFileUri ->
// シェア用画像が出来た
// シェアインテント
val share = Intent(Intent.ACTION_SEND)
share.type = "image/*"
share.putExtra(Intent.EXTRA_STREAM, imageFileUri)
startActivity(Intent.createChooser(share, "Share to"))
analyticsUtil.sendShareEvent("Instagram")
})
シェア画像が作れなかったときは特に何もしてないので(本当はあまり良くないようにも思いますね。せめてエラーメッセージ表示しなよって感じですが^^;)、ここはnullチェックしてnullでなければpostValueするのが良さそうです。
とはいえKotlinですから、if(uri!=null)なんてせずに、letを使うことにしましょう。
val resultUri = saveBitmap(bitmap, displayName)
resultUri?.let { _savedBitmapUri.postValue(it) }
2. Use the 'require_____()' API rather than 'get____()' API for more descriptive error messages when it's null.
どうやらFragment#getArgumentsを使うなということになったようです。
ということで、指摘されている箇所を全部、requireArguments()に置き換えます。
非nullアサーション!!が不要になるのでまあ見やすくはなりますね。
一括置換で良さそうです。
3.app:tint attribute should be used on ImageView
うーん、android:tint属性の代わりにapp:tintを使えと言っているようです。
よく分からなかったのでググりましたが、よく分からないけどandroid:tintにはAPiレベル21未満でバグがあるので使うなってことらしいです。
参考:https://stackoverflow.com/questions/64256397/android-imageview-tint
まあ言われるとおり対応しておきましょう。Android Sturioで該当のレイアウトxmlファイルを開くと、確かに赤いエラーが表示されていました。
Fixボタンがあるのでポチッとしていけばいいでしょう。
4. lintチェックをして確認
最後に./gradlew lintしてエラーは無いのを確認して、pushしましょう。
なお、Github ActionsではandroidTest((Instrumentation Test)も回していますが、相変わらず安定性が悪くだいたい1つか2つ、テストが失敗してしまいますね・・・
そういえばCircleCIもエミュレーターテストが出来るようになったのではなかったかな。
今度時間見て試してみようかな・・・
まとめ
RoomやKotlin、Koinといったライブラリのバージョンアップを行いました。
ここまでのコードは以下にアップしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_15
次回予告
ライブラリバージョンアップ(2)をやっていきます。
AndroidX(Jetpack)関連のアップデートが結構ボリュームあって、特にテストへの対応が大変そうなので次回に・・・







