前回のつづきで、今回は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の状態を載せておきます。
前回までのコード
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の更新
最終的に以下のようになりました。
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-Dependenciesのappを選ぶと、以下のようにライブラリ毎にどんなバージョンがあるかリストで確認しながら設定していくことが出来ます。
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
に処理を移すか、onAttach
でLifeCycleObserver
を登録してそのコールバックで実施せよ、とのことです。
今回は、onViewCreated
に初期化処理を移動する方法にします。
元のコード
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
}
対応後のコード
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
に追加します。
dependencies {
// 省略
implementation 'androidx.fragment:fragment-ktx:1.3.2'
implementation 'androidx.activity:activity-ktx:1.2.2' // 追加
...
(2) 内部のActivityを起動して結果を受け取っているパターンの対応
先ほどご紹介したサイトの以下の項からが参考になります。
概要としては
-
startActivityForResult
の代わりにactivityResultLauncher.launch
を使う -
onActivityResult
の代わりにregisterForActivityResult(StartActivityForResult())
でコールバックを登録しておく
という手順に変わります。
例として、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)
}
}
}
対応後のコード
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
が必要になる感じかな。
onActivityResult
がswicth
分や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
で結果を受けていました。
override fun onItemClick(data: CalendarCellData) {
// 省略
activity?.startActivityForResult(
intent,
MainActivity.REQUEST_CODE_LOGITEM
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_CODE_LOGITEM -> {
onStepCountLogChanged(resultCode, data)
return
}
// ...(省略)
}
こういうのはどうするのが良いんでしょうかね?
今回は安直に、MainActivity
側にLogItemActivityを起動する関数を用意して呼ぶようにしました。
fun launchLogEditActivity(intent: Intent) {
logItemActivityResultLauncher.launch(intent)
}
(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にモジュールを追加しますが、これまでとちょっと違います。
// 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
を継承している必要があります。
MainActivity
やSignInActivity
はAnalytics関連をまとめたBaseActivity
を継承していますが、このBaseActivity
をコピペしてSocpeBaseActivity
としました。
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)
}
}
MainActivity
とSignInActivity
はこのScopeBaseActivity
を基底クラスとするよう変更します。
class MainActivity : ScopeBaseActivity() {
SignInActivity
も同様です。
(b) registerForActivityResultの引数を変更する
registerForActivityResult
関数への第2引数get()
に変更します。こうすることで、KoinがInjectionしてくれます。
例えばSignInActivity
の場合です。
// 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でのログインが絡んでしまうので)、この部分はテストでの対応をしていきます。
アカウント削除確認のダイアログのテスト
@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のテスト
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)
(3) ConfirmDialogクラスの変更
setTargeFragment
したりgetTargetFragment
しているのをFragmentResultAPIに置き換えていきます。
(a) リスナーを削除
不要になるのでConfirmEventListener
を削除してしまいます。
(b) ConfirmDialog#Builderクラスを変更
target
, requestCode
, それからdata
関数も不要になるので削除します。
これでsetTargetFragment
が削除出来ます。
(c) show関数をオーバーロードする
参考サイトの通りに、show
関数のオーバーロードを用意してコールバックをラムダで渡せるようにします。
(※関数のオーバーロードとは、引数が違う同名の関数を定義すること)
Activity
から呼ばれる場合と、Fragment
から呼ばれる場合とで微妙に違うので関数も分けました。
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
をすると、登録したリスナーにコールバックされていきます。
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
で定義されている関数です。
実装を見てみるとかなりの力技な気もしますが
ConfirmDialog
の変更は以上です。
(4) SignOutActivityの変更
SignOutActivity
を変更していきます。まず、ConfirmEventListener
のimplementsが不要になったので削除します。
onConfirmResult
関数が不要になり、それぞれ以下のような関数にしてConfirmDialog
の呼び出しとコールバックの処理を書いていくと、スッキリするのではないでしょうか。
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
は以下のようになりました。
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
}
onCreateView
でView
を何かしら返さないと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に不具合が生じるので、消してはいけません。
といって特に対応していませんでした。
ところが、以下によると、警告を消すことが出来るようなので対応しておきます。
対応前はこうなっていたのを、
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 }
}
以下のようにするだけです。
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 APIとFragment Result APIに対応しました。
ここまでのコードは以下にアップしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_16
次回予告
今回対応したJetpackのバージョンアップに伴う、テストの修正を行っていきます。