LoginSignup
2
4

More than 3 years have passed since last update.

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

Posted at

前回のつづきで、今回はAndroidX(Jetpack)のバージョンを上げていきます。
いつもテストの修正までセットにしていましたが、ちょっと長くなりすぎるのでテストの対応は次回に回します。

環境

ツールなど バージョンなど
MacbookPro macOS Catalina 10.15.7
Android Studio 4.1.2
Java(JDK) openjdk version "11.0.10"

修正前のbuild.gradle

前回の記事後のappモジュールのbuild.gradleの状態を載せておきます。

前回までのコード
app/build.gradle
apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-kapt'

apply plugin: 'kotlin-parcelize'

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'
    }
    buildFeatures {
        dataBinding true
        viewBinding 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-jdk8:$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.3.0"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$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"

    // RecyclerView
    implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01'
    // ViewPager2
    implementation 'androidx.viewpager2:viewpager2:1.0.0'

    // Room components
    def room_version = "2.2.6"
    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"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2"

    // 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.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"

    // 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:18.0.2'
    implementation 'com.google.firebase:firebase-crashlytics:17.3.1'
    implementation 'com.firebaseui:firebase-ui-auth:6.2.1'
    implementation 'com.google.firebase:firebase-firestore:22.1.1'

    // 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'
}

dependenciesが長くなりすぎてるのでいつかどうにかしたいですね。

Android公式/Jetpackを更新する

Androidの公式パッケージ、Jetpackを更新していきます。ここは一気に上げないとライブラリ同士がコンフリクト起こすのでまとめて上げていきます。

以下が対象です。

  • androidx.appcompat:appcompat
  • androidx.core:core-ktx
  • androidx.constraintlayout:constraintlayout
  • com.google.android.material:material
  • androidx.fragment:fragment
    • androidx.fragment:fragment-ktxに変更します
  • androidx.recyclerview:recyclerview

以下はテスト関連です。テストの対応は後にしますが、一応今回の対応後もテストはそのままで通せるので、そのためにバージョンアップをしておきます。

  • androidx.test.ext:junit
  • androidx.test:runner
  • androidx.test:rules
  • androidx.test.espresso:espresso-core
  • androidx.test.espresso:espresso-contrib
  • androidx.test.espresso:espresso-intents
  • androidx.test:orchestrator
  • androidx.test:core
  • androidx.fragment:fragment-testing

以下はJetpackとは関係ないですが、テスト関連で連携するので一緒に上げます。
- org.robolectric:robolectric
- 最新の4.5.1にアップします
- org.assertj:assertj-core
- 最新の3.19.0にアップします

1 build.gradleの更新

最終的に以下のようになりました。

app/build.gradle
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.fragment:fragment-ktx:1.3.2'
    implementation 'androidx.activity:activity-ktx:1.2.2'

    testImplementation 'junit:junit:4.13.2'
    testImplementation 'org.assertj:assertj-core:3.19.0'
    testImplementation 'androidx.test.ext:junit:1.1.2'
    testImplementation 'androidx.test:runner:1.3.0'
    testImplementation 'androidx.test:rules:1.3.0'
    testImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    testImplementation 'androidx.test.espresso:espresso-contrib:3.3.0'
    testImplementation 'androidx.test.espresso:espresso-intents:3.3.0'
    testImplementation 'org.robolectric:robolectric:4.5.1'

    androidTestImplementation 'androidx.test:runner:1.3.0'
    androidTestImplementation 'androidx.test:rules:1.3.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.3.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'org.assertj:assertj-core:3.19.0'
    androidTestUtil 'androidx.test:orchestrator:1.3.0'

    // 省略

    // RecyclerView
    implementation 'androidx.recyclerview:recyclerview:1.2.0-rc01'
    // ViewPager2
    implementation 'androidx.viewpager2:viewpager2:1.0.0'

    // 省略

    // FragmentTest
    testImplementation 'androidx.test:core:1.3.0'
    androidTestImplementation 'androidx.test:core:1.3.0'
    // Robolectric用,test向けではなくdebug向けに必要
    debugImplementation 'androidx.fragment:fragment-testing:1.3.2'
    debugImplementation 'androidx.test:core:1.3.0'
    // 無くてもアプリの実行は問題ないがテストがビルド出来ない
    debugImplementation "androidx.legacy:legacy-support-core-ui:1.0.0"
    debugImplementation "androidx.legacy:legacy-support-core-utils:1.0.0"

    // 省略
}

今回、recyclerviewを除いて基本的にstable版のみ採用しています。build.gradleでショートカットキーとかで最新版にしようとするとβやα版が入ってしまいますが、以下の手順でやるとstable版のみ指定することができます。

Android StudioのProject Structureで、Modules-Dependenciesappを選ぶと、以下のようにライブラリ毎にどんなバージョンがあるかリストで確認しながら設定していくことが出来ます。

dependencies.png

2 deprecated警告に対応

以下の非推奨項目に警告が出ます。(出ていない場合はclean&rebuildしてみてください)

  • 'onActivityCreated(Bundle?): Unit' is deprecated.
  • 'startActivityForResult(Intent!, Int): Unit' is deprecated.
  • 'onActivityResult(Int, Int, Intent?): Unit' is deprecated.
  • 'setTargetFragment(Fragment?, Int): Unit' is deprecated.
  • 'getter for targetFragment: Fragment?' is deprecated.

順番に対応していきましょう。

2.1 Fragment#onActivityCreatedのdeprecatedに対応する

まずはドキュメントの非推奨になった理由などを確認します。

https://developer.android.com/jetpack/androidx/releases/fragment?hl=ja#1.3.0-alpha02
こちらに記載があります。

onActivityCreated() メソッドは非推奨になりました。フラグメントのビューをタッチするコードは onViewCreated()(onActivityCreated() の直前に呼び出される)、他の初期化コードは onCreate() で実行する必要があります。アクティビティの onCreate() が完了した際にコールバックを受け取るには、onAttach() のアクティビティの Lifecycle に LifeCycleObserver を登録し、onCreate() のコールバックを受け取ったら削除する必要があります。 (b/144309266)

つまり onViewCreatedに処理を移すか、onAttachLifeCycleObserverを登録してそのコールバックで実施せよ、とのことです。
今回は、onViewCreatedに初期化処理を移動する方法にします。

元のコード

LogInputFragment.kt
    override fun onActivityCreated(view: View, savedInstanceState: Bundle?) {
        super.onActivityCreated(view, savedInstanceState)

        // 日付の選択を監視
        viewModel.selectDate.observe(viewLifecycleOwner, Observer {
            binding.textDate.text = it.getDateStringYMD()
        })

        // sns投稿設定
        val shareStatus = viewModel.readShareStatus()
        binding.switchShare.isChecked = shareStatus.doPost
        binding.checkBoxTwitter.isChecked = shareStatus.postTwitter
        binding.checkBoxInstagram.isChecked = shareStatus.postInstagram
    }

対応後のコード

LogInputFragment.kt
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        //...(省略)
    }

オーバーライドする関数名とsuperクラスの関数名を変更しただけです。
アプリを実行して新規登録画面を操作してみても問題無さそうでした。
Activityのライフサイクルにもうちょっと厳密に従う必要がある場合を除いて、こちらの方法で良いのではないかなと思います。

2.2 startActivityForResultとonActivityResultのdeprecatedに対応する

こちらのドキュメントによれば、

Activity Result API の統合: Activity 1.2.0 に導入された ActivityResultRegistry API に対するサポートを追加しました。これにより、startActivityForResult() + onActivityResult() と requestPermissions() + onRequestPermissionsResult() のフローを処理する際に Fragment でメソッドをオーバーライドする必要がなくなり、これらのフローをテストするためのフックを提供できるようになります。更新されたアクティビティからの結果の取得をご覧ください。

つまりActivity Result APIを使えとのことのようですね。
ただ、公式ドキュメントを読んでも結局どうしたらよいのか分からなかったので、こちらを参考にしました。

(1)依存ライブラリの追加

まず、androidx.activity:activity-ktxライブラリが必要になるのでbuild.gradleに追加します。

app/build.gradle
dependencies {
    // 省略

    implementation 'androidx.fragment:fragment-ktx:1.3.2'
    implementation 'androidx.activity:activity-ktx:1.2.2' // 追加

    ...

(2) 内部のActivityを起動して結果を受け取っているパターンの対応

先ほどご紹介したサイトの以下の項からが参考になります。

自身のアプリ内部で別Activityからの結果を受け取る

概要としては

  • startActivityForResultの代わりにactivityResultLauncher.launchを使う
  • onActivityResultの代わりにregisterForActivityResult(StartActivityForResult())でコールバックを登録しておく

という手順に変わります。

例として、SignInActivity.ktを書き変えてみましょう。

元のコード

SignInActivity.kt

    companion object {
        const val REQUEST_CODE_AUTH = 210
        // (省略)
    }

   override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySigninBinding.inflate(layoutInflater)
        setContentView(binding.root)
        setSupportActionBar(binding.toolbar)

        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        binding.buttonSignIn.setOnClickListener {
            // ...(省略)

            // Create and launch sign-in intent
            startActivityForResult(
                authProvider.createSignInIntent(this),
                REQUEST_CODE_AUTH
            )
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        if (requestCode == REQUEST_CODE_AUTH) {
            FirebaseCrashlytics.getInstance()
                .log("FirebaseUI Auth finished. result code = [$resultCode]")

            val response = IdpResponse.fromResultIntent(data)

            if (resultCode == Activity.RESULT_OK) {
                Log.d("AUTH", "Auth Completed.")
                // Successfully signed in
                analyticsUtil.sendSignInEvent()
                // TODO Roomのデータをコンバートしてアップロード
                // or Firestoreからデータをダウンロード

            } else response?.error?.errorCode?.let { errorCode ->
                analyticsUtil.sendSignInErrorEvent(errorCode)
                Log.d("AUTH", "Auth Error.")

                FirebaseCrashlytics.getInstance()
                    .log("FirebaseUI Auth finished. error code = [$errorCode]")
                // Sign in failed. If response is null the user canceled the
                // sign-in flow using the back button. Otherwise check
                // response.getError().getErrorCode() and handle the error.
                // ...

                showError(errorCode)
            }
        }
    }

対応後のコード

SignInActivity.kt
import androidx.activity.result.ActivityResult  // 追加
import androidx.activity.result.contract.ActivityResultContracts // 追加

class SignInActivity : BaseActivity() {

    companion object {
//        const val REQUEST_CODE_AUTH = 210 // 削除
        // (省略)
    }

    // for ActivityResult API
    private val activityResultLauncher =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { 
            onAuthProviderResult(it)
        }

    override fun onCreate(savedInstanceState: Bundle?) {
            // ...

        binding.buttonSignIn.setOnClickListener {
            // ...

            // Create and launch sign-in intent
            activityResultLauncher.launch(authProvider.createSignInIntent(this))
        }
    }

//  onActivityResultは関数ごと削除

    private fun onAuthProviderResult(result: ActivityResult) {
        FirebaseCrashlytics.getInstance()
            .log("FirebaseUI Auth finished. result code = [${result.resultCode}]")

        val response = IdpResponse.fromResultIntent(result.data)

        if (result.resultCode == Activity.RESULT_OK) {
            Log.d("AUTH", "Auth Completed.")
            // Successfully signed in
            analyticsUtil.sendSignInEvent()
            // TODO Roomのデータをコンバートしてアップロード
            // or Firestoreからデータをダウンロード

        } else response?.error?.errorCode?.let { errorCode ->
            analyticsUtil.sendSignInErrorEvent(errorCode)
            Log.d("AUTH", "Auth Error.")

            FirebaseCrashlytics.getInstance()
                .log("FirebaseUI Auth finished. error code = [$errorCode]")
            // Sign in failed. If response is null the user canceled the
            // sign-in flow using the back button. Otherwise check
            // response.getError().getErrorCode() and handle the error.
            // ...

            showError(errorCode)
        }
    }

Activityの起動にrequestCodeが不要になる代わりに、同じ数だけregisterForActivityResultが必要になる感じかな。
onActivityResultswicth分やif文で階層が深くなっていたのを防げる代わりに、private変数の乱立、というところとのトレードオフでしょうかねえ。
個人的には、リクエストコードの値を付けるのに無駄に悩んだりしていたので、それから解放されるのは嬉しいですねw

(3) 暗黙的インテントなどで外部のアプリを起動して結果を受け取っているパターンの対応

本アプリではこのような処理はまだ入れていないものの、将来的に例えばInstagramに投稿する画像はギャラリーから選べるようにするなども考えているため、念のため調べようと思いましたが、こちらも先ほどのサイトが参考になりますのでそのリンクだけにしておきます。

アプリ外部から結果を受け取る

概要としては

  • startActivityForResultの代わりにactivityResultLauncher.launchを使う
  • onActivityResultの代わりにregisterForActivityResult(GetContent())でコールバックを登録しておく

で、先ほどやった自分のアプリ内の別Activityを呼ぶパターンとそれほど違いは無さそうです。
暗黙的インテントの場合は、自分でIntentを作らなくても良くなるのかな。
この辺は、ActivityResultContractsのリファレンスなんかを読んでいくといろいろありそうでした。

前述の参考ページの例では、コンテンツのURIを取得したいのでActivityResultContracts.GetContentを使っており、適切なMIME-Typeを指定するとそれに応じた暗黙的インテントが投げられるのでしょう。

(4) FragmentからstartActivityしている場合

Fragment自身でstartActivityForResultをしてonActivityResultを受け取っている場合は、公式のサンプルにある通り、Fragment内でActivityResultAPIを使えば良いでしょう。
今回問題になったのは、Fragment内ではactivity.startActivityForResultしていて、結果はActivity#onActivityResultで処理している場合です。

サンプルにしているプロジェクトでは、MonthlyPageFragmentで起動し、MainAcvityで結果を受けていました。

MonthlyPageFragment.kt
   override fun onItemClick(data: CalendarCellData) {
        // 省略

        activity?.startActivityForResult(
            intent,
            MainActivity.REQUEST_CODE_LOGITEM
        )
    }
MainActivity.kt
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

        when (requestCode) {
            REQUEST_CODE_LOGITEM -> {
                onStepCountLogChanged(resultCode, data)
                return
            }

        // ...(省略)
    }

こういうのはどうするのが良いんでしょうかね?
今回は安直に、MainActivity側にLogItemActivityを起動する関数を用意して呼ぶようにしました。

MainActivity.kt
    fun launchLogEditActivity(intent: Intent) {
        logItemActivityResultLauncher.launch(intent)
    }
MonthlyPageFragment.kt
    (activity as MainActivity?)?.launchLogEditActivity(intent)

一応、Fragment#activity(getActivity)nullableなため、nullチェックをしています。

(5) DIに対応する

そのままでも良いのですが、Koinを使ってActivityResultRegistoryをDI可能にしておくと、テストで上手いこと使えそうなので、やってしまっておきましょう。

公式にはFragmentの場合ですが例(というかDIする場合のヒント?)があります。
(でも、Fragmentのコンストラクタに引数渡すのはNGじゃなかった??)
https://developer.android.com/training/basics/intents/result?hl=ja#test

Activityの場合はちょっとワザがいるので、自分でも事前にまとめていました。
ActivityResultAPIのテストを書く

これに則ってやってみます。

(a) Koinにscopeモジュールを追加

Koinにモジュールを追加しますが、これまでとちょっと違います。

modules.kt
// scopedモジュール群
val scopeModules = module {
    scope<MainActivity> {
        scoped { get<AppCompatActivity>().activityResultRegistry }
    }
    scope<SignInActivity> {
        scoped { get<AppCompatActivity>().activityResultRegistry }
    }
}

// モジュール群
val appModules = listOf(
    viewModelModule, daoModule, repositoryModule, providerModule, firebaseModule,
     scopeModules // 追加
)

appModulesへの追加もお忘れなく(やらかしたひとw)

scopeというのは、SignInActivityの生存期間(スコープ)の間だけ生存する機能に対して使うことが出来ます。ただし、このモジュールを持つActivityは、ScopeActivityを継承している必要があります。
MainActivitySignInActivityはAnalytics関連をまとめたBaseActivityを継承していますが、このBaseActivityをコピペしてSocpeBaseActivityとしました。

BaseAcvitiy.kt
abstract class ScopeBaseActivity : ScopeActivity() {

    abstract val screenName: String

    // AnalyticsTool inject by Koin
    val analyticsUtil: AnalyticsUtilI by inject()

    override fun onResume() {
        super.onResume()
        analyticsUtil.sendScreenName(screenName)
    }
}

MainActivitySignInActivityはこのScopeBaseActivityを基底クラスとするよう変更します。

MainActivity.kt
class MainActivity : ScopeBaseActivity() {

SignInActivityも同様です。

(b) registerForActivityResultの引数を変更する

registerForActivityResult関数への第2引数get()に変更します。こうすることで、KoinがInjectionしてくれます。

例えばSignInActivityの場合です。

SignInActivity.kt
    // for ActivityResult API
    private val activityResultLauncher =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult(), get()) {
            onAuthProviderResult(it)
        }

MainActivityも同様に3箇所全部、第2引数を追加します。

本体アプリへの対応は以上です。アプリを起動して動作させてみてください。
SignInActivityTestクラスやMainActivityTestIクラス(androidTest)も動くことを確認しておくと良いでしょう。
実際にテストでどうモックしていくかは、次回にやっていきます。

2.3 setTargetFragmentとgetTargetFragmentのdeprecatedに対応する

こちらもActicityResultAPIと同じようにFragmentResultAPIを使うように変更します。
fragment1.3.0から導入されました。

公式ページはこちらです。
https://developer.android.com/guide/fragments/communicate#fragment-result

その他の参考サイトはこちら。
https://qiita.com/TomAndDev/items/a7444d3ac6ef9d2d3ad4

該当しているクラスはConfirmDialogです。
このダイアログを使っているのはSignOutActivityです。
影響はこの2クラスですね。

(1) SignOutActivityのテストを追加する

まず、テストが不足していたので追加しておきます。このクラスのアカウント削除確認に関する部分が、この変更に関係する部分なのです。
動作確認するのにアプリを実際に動かすより楽なので(何しろFirebase Authでのログインが絡んでしまうので)、この部分はテストでの対応をしていきます。

アカウント削除確認のダイアログのテスト
SignOutActivityTest.kt

    @Test
    fun deleteAccount_data_converted() {
        // ResultActivityの起動を監視
        val monitor = Instrumentation.ActivityMonitor(
            SignInActivity::class.java.canonicalName, null, false
        )
        InstrumentationRegistry.getInstrumentation().addMonitor(monitor)

        activity = activityRule.launchActivity(null)
        onView(withId(R.id.signOutScroll)).perform(swipeUp())
        // アカウント削除ボタン
        onView(withId(R.id.buttonAccountDelete))
            .perform(scrollTo(), click())

        onView(withText(R.string.confirm_account_delete_1))
            .check(matches(isDisplayed()))

        onView(withText(R.string.label_yes))
            .check(matches(isDisplayed()))
            .perform(click())

        // コンバートしましたに「はい」と答えたので、アカウント削除をし自分は終了した
        Assertions.assertThat(activity.isFinishing).isEqualTo(true)

        // ResultActivityが起動したか確認
        InstrumentationRegistry.getInstrumentation()
            .waitForMonitorWithTimeout(monitor, 1000L)
        Assertions.assertThat(monitor.hits).isEqualTo(1)
    }

    @Test
    fun deleteAccount_anyway() {
        // ResultActivityの起動を監視
        val monitor = Instrumentation.ActivityMonitor(
            SignInActivity::class.java.canonicalName, null, false
        )
        InstrumentationRegistry.getInstrumentation().addMonitor(monitor)

        activity = activityRule.launchActivity(null)

        onView(withId(R.id.signOutScroll)).perform(swipeUp())
        // アカウント削除ボタン
        onView(withId(R.id.buttonAccountDelete))
            .perform(scrollTo(), click())

        onView(withText(R.string.confirm_account_delete_1))
            .check(matches(isDisplayed()))

        onView(withText(R.string.label_no))
            .check(matches(isDisplayed()))
            .perform(click())

        onView(withText(R.string.confirm_account_delete_2))
            .check(matches(isDisplayed()))

        onView(withText(R.string.label_yes))
            .check(matches(isDisplayed()))
            .perform(click())

        // アカウント削除をし自分は終了した
        Assertions.assertThat(activity.isFinishing).isEqualTo(true)

        // ResultActivityが起動したか確認
        InstrumentationRegistry.getInstrumentation()
            .waitForMonitorWithTimeout(monitor, 1000L)
        Assertions.assertThat(monitor.hits).isEqualTo(1)
    }

(2) ConfirmDialogをFragmentから起動するテストを書く

ConfirmDialogはアプリ内では現在Activityからしか使っていません。Fragmentが使う場合の確認が出来ないので、テストに書いておくことにします。

ConfirmDialogのテスト
ConfirmDialogTest.kt
package jp.les.kasa.sample.mykotlinapp.alert

import android.content.DialogInterface
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.matcher.ViewMatchers
import jp.les.kasa.sample.mykotlinapp.R
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test

// 本体アプリではConfirmDialogがFragmentから参照されていないので、テスト用に作る
class SampleFragment : Fragment(), ConfirmDialog.ConfirmEventListener {
    fun showConfirm() {
        val dialog: ConfirmDialog = ConfirmDialog.Builder()
            .target(this).requestCode(100)
            .message("てすと").create()
        dialog.show(requireFragmentManager(), "tag")
    }

    var confirmResult: Boolean? = null

    override fun onConfirmResult(which: Int, bundle: Bundle?, requestCode: Int) {
        confirmResult = when (which) {
            DialogInterface.BUTTON_POSITIVE -> true
            else -> false
        }
    }
}

class ConfirmDialogTest {
    private lateinit var fragment: SampleFragment
    @Test
    fun showFromFragment() {
        val scenario = launchFragmentInContainer<SampleFragment>(themeResId = R.style.AppTheme)
        scenario.onFragment {
            fragment = it
            it.showConfirm()
        }

        // Dialogが表示されている?
        Espresso.onView(ViewMatchers.withText("てすと"))
            .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))

        Espresso.onView(ViewMatchers.withText(R.string.label_yes))
            .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))

        Espresso.onView(ViewMatchers.withText(R.string.label_no))
            .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))

        assertThat(fragment.confirmResult).isNull()
    }

    @Test
    fun cancelDialog(){
        val scenario = launchFragmentInContainer<SampleFragment>(themeResId = R.style.AppTheme)
        scenario.onFragment {
            fragment = it
            it.showConfirm()
        }

        Espresso.onView(ViewMatchers.withText(R.string.label_no))
            .perform(click())

        Espresso.onView(ViewMatchers.withText("てすと"))
            .check(doesNotExist())

        assertThat(fragment.confirmResult).isEqualTo(false)
    }

    @Test
    fun confirmDialog(){
        val scenario = launchFragmentInContainer<SampleFragment>(themeResId = R.style.AppTheme)
        scenario.onFragment {
            fragment = it
            it.showConfirm()
        }

        Espresso.onView(ViewMatchers.withText(R.string.label_yes))
            .perform(click())

        Espresso.onView(ViewMatchers.withText("てすと"))
            .check(doesNotExist())

        assertThat(fragment.confirmResult).isEqualTo(true)
    }
}

概要としては、テスト用のSampleFragmentを作って、そいつがConfirmDialogを表示して結果を受け取る処理を単純なコードでいいので作っておきます。テストしやすいように全部publicです。

テストでは、FragmentScenarioを使ってFragmentを仮の環境の中で起動させます。このとき、IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activityというエラーが出る場合、launchFragmentInContainerの引数でテーマを指定しておくと良いようです。

val scenario = launchFragmentInContainer<SampleFragment>(themeResId = R.style.AppTheme)

参考サイト:
https://stackoverflow.com/questions/32346748/robolectric-illegalstateexception-you-need-to-use-a-theme-appcompat-theme-or

(3) ConfirmDialogクラスの変更

setTargeFragmentしたりgetTargetFragmentしているのをFragmentResultAPIに置き換えていきます。

(a) リスナーを削除

不要になるのでConfirmEventListenerを削除してしまいます。

(b) ConfirmDialog#Builderクラスを変更

target, requestCode, それからdata関数も不要になるので削除します。
これでsetTargetFragmentが削除出来ます。

(c) show関数をオーバーロードする

参考サイトの通りに、show関数のオーバーロードを用意してコールバックをラムダで渡せるようにします。
(※関数のオーバーロードとは、引数が違う同名の関数を定義すること)
Activityから呼ばれる場合と、Fragmentから呼ばれる場合とで微妙に違うので関数も分けました。

ConfirmDialog.kt
    companion object {
       // ...

        const val TAG = "ConfirmDialog"
        const val REQUEST_KEY = "confirmDialog"
        const val RESULT_KEY_NEGATIVE = "confirmDialogNegative"
        const val RESULT_KEY_POSITIVE = "confirmDialogPositive"
    }

    fun show(
        activity: AppCompatActivity,
        onPositive: (() -> Unit)? = null,
        onNegative: (() -> Unit)? = null
    ) {
        activity.supportFragmentManager.setFragmentResultListener(REQUEST_KEY, activity) { requestKey, bundle ->
            if (requestKey != REQUEST_KEY) return@setFragmentResultListener

            when {
                bundle.containsKey(RESULT_KEY_NEGATIVE) -> onNegative?.invoke()
                bundle.containsKey(RESULT_KEY_POSITIVE) -> onPositive?.invoke()
            }
        }
        show(activity.supportFragmentManager, TAG)
    }

    fun show(
        target: Fragment,
        onPositive: (() -> Unit)? = null,
        onNegative: (() -> Unit)? = null
    ) {
        target.childFragmentManager.setFragmentResultListener(
            REQUEST_KEY,
            target.viewLifecycleOwner
        ) { requestKey, bundle ->
            if (requestKey != REQUEST_KEY) return@setFragmentResultListener
            when {
                bundle.containsKey(RESULT_KEY_NEGATIVE) -> onNegative?.invoke()
                bundle.containsKey(RESULT_KEY_POSITIVE) -> onPositive?.invoke()
            }
        }
        show(target.childFragmentManager, TAG)
    }

それぞれ、対象のFragmentManagerに対してsetFragmentResultListenerをしています。
リクエストキーREQUEST_KEYに対して、結果のBundleに含まれるキーに応じて、コールバックを呼んでいます。

return@setFragmentResultListenerの部分についてですが、これは

  • setFragmentResultListenerというラベルが付いたラムダを抜ける

というのを意味しています。

ラベルというのは、一部の言語にあるgoto文のラベルみたいなもので、こんな風にラベルを付けることが出来るのがKotlinの仕様になっています。

fun foo(){
  bar @label {
    return@label
  }
}

ただ、暗黙のラベルというのがあって、それは「ラムダが渡される関数の名前」ということになっています。今回setFragmentResultListenerに対して渡しているラムダなので、そのラムダを抜けるのには暗黙のラベルを使ってreturn@setFragmentResultListenerとすることが出来る、というわけです。することが出来ると言うより、しなければならない、ですかね。ラベルがないとAndroid Studioが警告を出します。

単なるreturnだと、普通は「関数」を抜けることを意味します。ある関数の中でラムダを呼んでいたら、ラムダを呼んでいる関数自体を抜けてしまいます。

リターン、ラベルについては、以下のページの説明がわかりやすかったので参考にしてみて下さい。
https://qiita.com/k5n/items/acfaff8b56faf57971f7#%E3%83%AA%E3%82%BF%E3%83%BC%E3%83%B3%E3%81%A8%E3%82%B8%E3%83%A3%E3%83%B3%E3%83%97

(d) 結果のセット

FragmentResultListenerに結果をセットする場所は、onClickになります。ここでsetFragmentResultをすると、登録したリスナーにコールバックされていきます。

ConfirmDialog.kt
    override fun onClick(dialog: DialogInterface?, which: Int) {
        FirebaseCrashlytics.getInstance().log("ConfirmDialog selected:$which")
        when (which) {
            DialogInterface.BUTTON_POSITIVE ->
                setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_POSITIVE to true))
            else ->
                setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_NEGATIVE to true))
        }
    }

POSITIVEボタンだったらリザルトキーRESULT_KEY_POSITIVEにtrueを、それ以外だったらRESULT_KEY_NEGATIVEにtrue、というようにBundleを作成して結果にセットしています。

bundleOfというのは、

Bundle().apply {
    putXXXX(key, value)
}

とやっているのと同義です。私も参考サイトを見ていて知りました。
androidx.core.os.BundleKt.classで定義されている関数です。
実装を見てみるとかなりの力技な気もしますが:sweat:

ConfirmDialogの変更は以上です。

(4) SignOutActivityの変更

SignOutActivityを変更していきます。まず、ConfirmEventListenerのimplementsが不要になったので削除します。
onConfirmResult関数が不要になり、それぞれ以下のような関数にしてConfirmDialogの呼び出しとコールバックの処理を書いていくと、スッキリするのではないでしょうか。

SignOutActivity.kt
   override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        // アカウント削除ボタン
        binding.buttonAccountDelete.setOnClickListener {
            analyticsUtil.sendButtonEvent("delete_account")
            // 確認フローを開始する
            showDeleteAccountConfirm()
        }
    }

    private fun showDeleteAccountConfirm() {
        val dialog = ConfirmDialog.Builder()
            .message(R.string.confirm_account_delete_1)
            .create()
        dialog.show(this,
            onPositive = {
                // データ削除した、なので削除決行
                doDeleteAccount()
            },
            onNegative = {
                // データ削除しなくてよいかもう一度確認
                showDeleteAccountConfirmLast()
            })
    }

    private fun showDeleteAccountConfirmLast() {
        // データ削除しなくてよいかもう一度確認
        val dialog = ConfirmDialog.Builder()
            .message(R.string.confirm_account_delete_2)
            .create()
        dialog.show(this,
            onPositive = {
                // アカウント削除してよい、なので削除決行
                doDeleteAccount()
            },
            onNegative = {
                // いいえなので何もしない
            })
    }

(5) SampleFragmentの変更

こちらはテストコードの変更になります。
SampleFragmentは以下のようになりました。

ConfirmDialogTest.kt
class SampleFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val view = TextView(context)
        view.text = "HELLO"
        return view
    }

    fun showConfirm() {
        val dialog: ConfirmDialog = ConfirmDialog.Builder()
            .message("てすと").create()
        dialog.show(
            this,
            onPositive = { confirmResult = true },
            onNegative = { confirmResult = false })
    }

    var confirmResult: Boolean? = null
}

onCreateViewViewを何かしら返さないとviewLifecycleOwnerが作成されないらしく、テスト実行時にエラーになったのでとりあえずTextViewを作って返すようにしました。
(ViewがないならlifecycleOwnerは不要でしょってことでしょうかね)

あとはSignOutActivityで対応したのとほぼ同じことをするだけです。

(6) テストを実行して確認する

先ほど(1)と(2)で追加したテストを流してみて、パスすれば完了です。

2.4 Koinモジュール定義に出る警告no cast neededに対応する

Koinのアップデートを前回行い、以下のような警告が出るようになっていました。

> 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

が、

なお、以下のように「キャストが不要だというエラーが出ますが、この部分のキャストを消すとKoinのInjectに不具合が生じるので、消してはいけません。

といって特に対応していませんでした。

ところが、以下によると、警告を消すことが出来るようなので対応しておきます。

対応前はこうなっていたのを、

modules.kt
val providerModule = module {
    factory { CalendarProvider() as CalendarProviderI }
    factory { EnvironmentProvider() as EnvironmentProviderI }
}

// FirebaseService
val firebaseModule = module {
    single { AnalyticsUtil(androidApplication()) as AnalyticsUtilI }
    single { AuthProvider(androidApplication()) as AuthProviderI }
}

以下のようにするだけです。

modules.kt
val providerModule = module {
    factory<CalendarProviderI> { CalendarProvider() }
    factory<EnvironmentProviderI> { EnvironmentProvider() }
}

// FirebaseService
val firebaseModule = module {
    single<AnalyticsUtilI> { AnalyticsUtil(androidApplication()) }
    single<AuthProviderI> { AuthProvider(androidApplication())}
}

as XXXXによるキャストをやめて、ジェネリックの型パラメータに変更です。

テストの方のモックモジュールも対応しておきましょう。

ここまでで、アプリの実行、テストの実行がそれぞれ問題ないことを確認出来れば今回の作業は完了です。

まとめ

  • Android Jetpack関連のバージョンをstable版の最新(2021/03/01現在)に更新しました。
  • 非推奨となった処理の代替として、Activity Result APIFragment Result APIに対応しました。

ここまでのコードは以下にアップしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_16

次回予告

今回対応したJetpackのバージョンアップに伴う、テストの修正を行っていきます。

2
4
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
2
4