4
3

More than 3 years have passed since last update.

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(15)ライブラリバージョンアップ2021春(1)

Posted at

概要

前回のつづきです。

前回は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つあるので注意してください。

修正前コード
project/build.gradle
// 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
}
app/build.gradle
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 pluginFirebaes Crashlytics Gradle pluginのバージョンを最新に上げます。

project/build.gradle
  dependencies {
        //...
        classpath 'com.google.gms:google-services:4.3.5'
        classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.0'
   }

2.アプリのbuild.gradleを修正する

firebase-crashlyticsfirebase-analyticsのバージョンを最新にします。

app/build.gradle
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を使うように変更してみます。
パラメータに何を設定したら良いかは、以下のページを見ると分かります。

以下のように書けるかと思います。

AnalyticsUtil.kt
    /**
     * 画面名報告
     */
    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を回してしまうと、無料枠をあっという間に使い切ってしまうところでした(汗)
ローカルで動かしてみて気付いて良かったです。

どういう理屈か分かりませんが、setScreenNamelogEventに変えただけでそうなるので、Analyticsの送信キューが溜まりすぎてしまうか何かかなと推察しています。
ひとまずテスト中は不要なので、モック化して何もしないクラスに差し替えたいと思います。

4.1 抽象クラスを作ってAnalyticsUtilに継承させる

DIで差し替えられるように、

// 基本クラス
abstract class AnalyticsUtilI {
}

// アプリで使う実クラス
class AnalyticsUtil : AnalyticsUtilI {
}

// テストで使うモッククラス
class MockAnalyticsUtil : AnalyticsUtilI {
}

というようにしたいですね。

ということで、以下のようにしてみました。
まずは基本クラスです。

AnalyticsUtil.kt
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はこうなります。

AnalyticsUtil.kt
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のモジュール宣言の箇所を以下のように書き変えます。

modules.kt
// FirebaseService
val firebaseModule = module {
    single { AnalyticsUtil(androidApplication()) as AnalyticsUtilI } // as以降を追記
    single { AuthProvider(androidApplication()) as AuthProviderI }
}

4.3 AnalyticsUtilをAnalyticsUtilIに一括置換する

プロジェクト内検索でAnalyticsUtilAnalyticsUtilIに一括置換してしまいましょう。
なぜなら、DIしている箇所のコードは以下のようだったはずですが、

val analyticsUtil: AnalyticsUtil by inject()

このままだと、DIするときにAnalyticsUtilクラスがないというkoinのエラーでクラッシュしてしまうからです。

Android StudioのメニューEdit - Find - Replace in Pathか、ショートカットキーで一括置換ウィンドウが起動します。一括検索や一括置換は便利なのでショートカットキーを覚えておきたいですね。

replace_in_path.png

検索置換ウィンドウでは、検索文字にAnalyticsUtil、置換文字にAnalyticsUtilIと入力し、単語検索/大文字小文字の一致を設定します。

Android Studio 4.1だと分かりづらくなりましたが、以下の画像赤枠の部分、Aaが大文字小文字の一致設定、Wが単語検索のOn/Offのようです。

replace_all.png

moddules.ktAnalyticsUtil.ktを除外して】、他はReplaceしていきます。
Replace Allしちゃうとこの二つのファイル内も置換しちゃうのでお気を付けて。

いったん、ここまででアプリが通常通り起動し動作するか確認しておきましょう。

4.4 テスト用のモックモジュールを作る

単体テスト用のモックモジュールを作って、mockModuleに追加します。

mockModule.kt
// テスト用にモックするモジュール
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版も同様に作っておいた方が良いでしょう。

MockModules.kt
// テスト用にモックするモジュール
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の修正例を挙げておきます。

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の設定方法が変わったようですね。

app/build.gradle
    dataBinding {
        enabled true
    }

これを、メッセージにあるとおり、以下のように変更すれば良いようです。

app/build.gradle
    buildFeatures {
        dataBinding true
    }

Kotlinのバージョンアップ

もっと先にやれよという気もしないでもないですが、ここでやっておきましょう。

1.プラグインのバージョンをチェック

最新バージョン何かな?と思って調べようとしたのですが、以下の方法が簡単そうです。

Android StudioのメニューToolsから、Kotlin-Configure Kotlin Plugin Updatesとやると、

tools-kotlin.png

設定画面が出てきて、最新バージョンが表示されています。

kotlin_preference.png

ベータ版とか使いたい人は、Update channelを変更してCheck againしましょう。

新しいバージョンは1.4.31みたいなので、Installをクリックして待ちます。

Plugin will be activated after restartと出るので、OKをクリックして、Android Studioを再起動させます。

2.使用するKotlinバージョンを変更する

プロジェクトで利用するKotlinバージョンを変更します。

project/build.gradle
buildscript {
    ext.kotlin_version = '1.4.31'

これでGradle Syncしてみます。
一応成功しますが、右下にこんなポップアップが出てくるかと思います。

kotlin_migration.png

移行が必要そうですね。
自動でやってくれそうなのでRun migrationsをクリックしてみます。
次の画面はデフォルトのままで問題ないかと思います。

kotlin_migration_2.png

しばらく待つと・・・

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プラグインを削除する

app/build.gradle
// apply plugin: 'kotlin-android-extensions' // この行を削除

2.ViewBindingを有効にする

先ほどdataBindingの設定方法を変更しましたが、そこにviewBindingも追加します。

app/build.gradle
    buildFeatures {
        dataBinding true
        viewBinding true
    }

Gradle Syncしておきましょう。
もしかしたら、一度ビルドもした方が良いかも知れません(ViewBindingのコードが生成されるようにするため)。もちろん、ビルドは失敗しますが。

3.Viewオブジェクトへのアクセスの変更

3.1.DataBindingを使っているクラスの修正

対象クラスは、DataBindingUtilなどでgrep検索などすると分かりやすいかなと思います。
修正箇所を全部書いているとキリがないので、LogEditFragmentクラスを例に記載します。他は同じようにやれば大丈夫でしょう。

まずimport文を削除します。

LogEditFragment.kt
// import kotlinx.android.synthetic.main.fragment_log_input.* // ←これを削除

修正前はこのように書いてあったのを、

LogEditFragment.kt
            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 // 変更
                )

こんな風に書き変えます。

LogEditFragment.kt
            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オブジェクトが他の関数からも必要になるケースが増えるので、これはもうメンバー変数で持つようにした方が良いでしょうね。

LogEditFragment.kt
    private lateinit var binding: FragmentLogEditBinding

代入する箇所も、ローカル変数からメンバー変数に入れるように変えるのを忘れないように。

LogEditFragment.kt
        // Inflate the layout for this fragment
        binding = DataBindingUtil.inflate(
            layoutInflater, R.layout.fragment_log_edit, container, false
        )

これで、他の関数でもアクセスできるようになりました。

LogEditFragment.kt

    private fun validation(): Int? {
        return logEditValidation(binding.editCount.text.toString())
    }

置き換えていくときの便利なショートカットとして、例えばradio_groupを置き換えたい場合、radio_groupの前にカーソルを合わせてbinding.rまで(リソースidの先頭の数文字)を打って、候補が表示されたところで矢印キーで該当の変数に合わせ、そこでキーボードのTabキーを叩くと、単語ごと綺麗に置き換えることが出来ます。

後は他のクラスも同じようにやっていきましょう。

EditメニューのFind in pathDataBindingUtilを入力し、右下のOpen in Find Windowしておくと、検索結果ウィンドウを残したまま作業が出来るので、多少楽かと思います。

DataBindingを既に使っているクラスは、ActivityでもFragmentでもやることに違いはないです。

3.2.ViewBindingを使うActivityクラスの対応

DataBindingを使っていないその他のActivityとFragmentはViewBindingを使うように変更しなければなりません。

DataBindingを使う場合は、レイアウトファイルを<layout>タグで全体を囲う必要がありましたが、ViewBindingの場合はこれは必要ありません。

LogItemActivity.ktを例にやっていきます。

まず、import文を削除するのは同じです。

LogItemActivity.kt
// import kotlinx.android.synthetic.main.activity_log_item.*  // 削除

bindingメンバー変数を宣言しておきます。

LogItemActivity.kt
 import jp.les.kasa.sample.mykotlinapp.databinding.ActivityLogItemBinding

 class LogItemActivity : AppCompatActivity() {
    ...
    private lateinit var binding: ActivityLogItemBinding

xxxxBindingクラスの命名規則は、レイアウトファイル名をそのままキャメルケースにしたものです。
今回は、activity_log_item.xmlですから、ActivityLogItemBindingとなります。

次に、レイアウトファイルをsetContentViewしている部分を変更します。

LogItemActivity.kt
        binding = ActivityLogItemBinding.inflate(layoutInflater)
        setContentView(binding.root)

あとは、Databindingのときと同じように、リソースidをbinding.<リソースidのキャメルケース>に置き換えていくだけです。

3.3.ViewBindingを使うFragmentクラスの対応

import文を削除するのは同じ。

LogInputFragment.kt
// import kotlinx.android.synthetic.main.fragment_log_input.* // 削除
// import kotlinx.android.synthetic.main.fragment_log_input.view.* // 削除

bindingメンバ変数を宣言しておきます。

LogInputFragment.kt
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している部分を変更します。

LogInputFragment.kt
    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クリアします。

LogInputFragment.kt
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

LogInputFragmentでは、contentViewから子Viewにアクセスしていましたが、それは不要になり、代わりにbindingオブジェクトを介すことになります。

LogInputFragment.kt
    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です。

activity_instagram_share.xml
<?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を付けます。

activity_instagram_share.xml
        <include
                android:id="@+id/content"
                layout="@layout/content_instagram_share"
                app:stepLog="@{stepLog}" />

コードからは以下のように、binding.<includeレイアウトに付けたid>という感じでアクセスできるようになります。

InstagramShareActivity.kt
    binding.content.buttonShareInstagram.setOnClickListener{...}

参考サイト:
https://stackoverflow.com/questions/58730127/viewbinding-how-to-get-binding-for-included-layouts

3.5 テストコードの修正

Espressoを使っているテストのandroidTest版の以下のテストで、直接viewオブジェクトにアクセスしているコードがありましたので、ここも修正しました。ViewPagerを取るためにやむを得なかったようですね。

MainActivityTestI.kt
    @Test
    fun swipe() {
       // ...
        val idleWatcher = ViewPagerIdleWatcher(mainActivity.binding.viewPager) // 変更
        idleWatcher.waitForIdle()

       // ...

MainActivity#bingingは、面倒なので変数宣言でpublicにしておきました。
気になる方は@VisibleForTesting使うとかしましょう。

MainActivity.kt
lateinit var binding: ActivityMainBinding

4. Parcelプラグインへの変更

Kotlin extensionsのもう一つの機能、Parcelizeも利用していましたが、それも移行する必要があります。

4.1.プラグインを追加

アプリモジュールのbuild.gradleに以下を追加します。

app/build.gradle
apply plugin: 'kotlin-parcelize' // 追加

4.2.インポートを変更

以下2つのimportを変更します。

HasPet.kt
import kotlinx.android.parcel.Parcelize
import kotlinx.android.parcel.RawValue

変更後

HasPet.kt
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-extensions2.2.0を最後にdeprecatedとなったので削除

    • 結局koinが最新バージョンでも参照してしまっているようですが、取り敢えず。
    • lifecycle-compilerの代わりにlifecycle-common-java8を使う
    • その方がインクリメントビルドが早いそうなので、そちらに変更します。

まとめると以下のようになります。

app/build.gradle
    // 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クラスの修正

LogDatabase.kt
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に合わせて変更します。

LogRepository.kt
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として監視できるようにします。

MainViewModel.kt
   // 一番古いデータの年月
    private val oldestDate = repository.getOldestDate().asLiveData() // LiveDataに変換
MonthlyPageViewModel.kt
   // データリスト
    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クラスでだけ、使っていました。

LogEditFragment.kt
// 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が表示されておりリストを選択した後なので、DialogFragmentAcvitityにアタッチ済みなはずということで、問題無さそうです。
(※Lazyなので最初にviewModel変数にアクセスされたときに初めてactivityViewModels()が実行されます)

4. Testの修正

戻り値がLiveDataからFlowに変わったので、それに合わせてテストも変更します。

4.1 LogRepositoryTestの修正

LogRepositoryTestでは以下の2つの関数が変更が必要です。

LogRepositoryTest.kt
    @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はライフサイクルと絡んでしまうのに対し(もっともそれがウリなわけですが)、Flowcoroutineの機能ですから、どこでもデータが取り出せるという所でしょうか。
この辺はRxJavaとか使ったことがある人だと理解しやすいのかな。
本来リポジトリクラスやDAOはライフサイクル関係ないですから、そこでLiveData返すのはどうだろう?と考えると、やはりFlowを使うのが自然なのかなと思います。

じゃあ、そのFlowから、やっぱりデータはどうやって取り出すべき?

安直な方法としては、asLiveDataを使ってしまい、Observeしているコードはそのままにするのもありますね。

でもせっかくなのでFlowのやり方でやりたいものです。

こういうときはいろいろググっても良いですがやっぱり公式を見ましょう。
といっても公式内も検索を上手くやらないと見つけられなかったりしますが^^;

Android での Kotlin Flow のテスト
https://developer.android.com/kotlin/flow/test?hl=ja

こちらに、いろんなFlowからの値の取り出し方が書いてあるので、ブクマしておくと良いかも知れません。

今回はfirstを使いました。

LogRepositoryTest.kt
    @Test
    fun getOldestDate() = runBlocking<Unit> {
        // ...

        val date = repository.getOldestDate().first()
        assertThat(date).isNotEmpty()
        assertThat(date).isEqualTo("2019/01/13")
    }

repository.getOldestDate().first()Flowに流れてきた値を取り出して、取得した日付文字列が期待通りかのチェックになっています。

元の関数とは、runBlockingの指定の仕方も違っています。

以下でも良いのですが、

LogRepositoryTest.kt
    @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テスト関数の方も同じです。

LogRepositoryTest.kt
    @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で関数ブロックそのものを囲います。

MainViewModelTest.kt
    @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-corekoin-androidを入れると自動で入るようなので削除しました。
また、koin-core-extはexperimentだったものが正式にkoin-androidに統合されたそうなので、これも削除しました。

koin-androidx-fragmentがstable版になっているようなので、使ってみたい方は使ってみると良いのではないでしょうか。
以前使おうとしていた場所がどこだったかもう忘れましたが(汗)

app/build.gradle
   // 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 パッケージを修正する

以下のパッケージが移動になっているので修正します。

KoinUtils.kt
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を使うように変更します。

SettingRepositoryTest.kt
// import org.koin.core.inject
import org.koin.test.inject

LogItemActivityTestなども同様に変更が必要です。修正対象ファイルは、プロジェクト内検索を掛けるか、ビルドしたらエラーになるのでそれで探しても良いでしょう。

Robolectric版、androidTest版ともに修正が必要です。

全部修正できたら、ビルドして、アプリの実行、テストを通しておきましょう。

Lintチェックエラーに対応

Lintチェックを掛けていないならば飛ばして良いのですが、私の場合Gitub Actionsで

/github/workflows/android.yml
    - name: Check
      if: success()
      run: ./gradlew lint testDebugUnitTest

としていてチェックを掛けており、ここがコケていてテストが出来ていなかったので直すことにしました。

エラーは3種類です。警告なだけのものについては今回は対応しません。

1. LiveData value assignment nullability mismatch

Lintエラーレポートにはこんな感じで表示されます。

Lint_error1.png

どうやらNonnullであるべき変数にNullableな変数を渡してしまっているようです。

該当箇所の実装を見ると、

InstagramShareViewModel.kt
    val resultUri = saveBitmap(bitmap, displayName)
    _savedBitmapUri.postValue(resultUri) 

saveBitmapの宣言はsaveBitmap(...) : Uri?なのでNullableですね。
LiveData_savedBitmapUriを監視している箇所は、以下の通りで、

InstagramShareActivity.kt
        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を使うことにしましょう。

InstagramShareViewModel.kt
    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ファイルを開くと、確かに赤いエラーが表示されていました。

Lint-error2.png

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)関連のアップデートが結構ボリュームあって、特にテストへの対応が大変そうなので次回に・・・

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3