月: 2023年6月

debounce処理をViewModelに書いてテスト可能にする

インクリメンタルサーチなどでthrottole処理やdebounce処理が必要になる場合があります。(throttleとdebounceの違いは調べてみてください)

まずはdebounce処理をViewModelで再現します。インクリメンタルサーチで文字列が入力される度にfun search(query: String)が実行されると仮定します。

@HiltViewModel
class RulesViewModel @Inject constructor(
    private val dispatcher: CoroutineDispatcher,
    private val searchUseCase: SearchUseCase
) : ViewModel() {
    private val _results = MutableStateFlow<List<Result>>(listOf())
    val results = _results.asStateFlow()

    fun search(query: String) {
        viewModelScope.launch(context = dispatcher, start = CoroutineStart.LAZY) {
            _results.emit(searchUseCase.search(query))
        }
    }
}

このまま文字列を連続で入力するとfun search()が何度も連続で呼ばれてレスポンスが前後してしまったり、サーバ負荷が高くなったりしてしまうのでdebounce処理に変更します。

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val dispatcher: CoroutineDispatcher,
    private val searchUseCase: SearchUseCase
) : ViewModel() {
    private val _results = MutableStateFlow<List<Result>>(listOf())
    val results = _results.asStateFlow()

    private val debounceJob: Job? = null
    fun search(query: String) {
        debounceJob?.cancel()
        debounceJob = viewModelScope.launch(context = dispatcher, start = CoroutineStart.LAZY) {
            delay(500)
            _results.emit(searchUseCase.search(query))
        }
        debounceJob?.start()
    }
}

このように実装すると文字列入力後500ms以上経過すると検索が実行され、連続で文字列入力した場合は既存のJobがキャンセルされるので実行されなくなります。

またこれをテストコードにする場合は下記のようにします。

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class SearchViewModelSpec {
    val dispatcher = UnconfinedTestDispatcher()

    private val firstResults = listOf(Result())
    private val secondResults = listOf(Result(), Result())
    private val firstQuery = "firstQuery"
    private val secondQuery = "secondQuery"

    @MockK
    lateinit var searchUseCase: SearchUseCase

    private lateinit var viewModel: SearchViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(dispatcher)
        TestTaskExecutor.onBefore()
        MockKAnnotations.init(this, relaxed = true, relaxUnitFun = true)
        viewModel = SearchViewModel(
            Dispatchers.Main,
            searchUseCase
        )
    }

    @Test
    fun search_once() = runTest {
        coEvery {
            searchUseCase.execute(firstQuery)
        } returns flow { emit(firstResults) }
        
        viewModel.search(firstQuery)
        delay(600)
        
        viewModel.results.first().also { response ->
            assertThat(response).isEqualTo(firstResults)
        }

        coVerify(exactly = 1) { searchUseCase.search(firstQuery) }
        confirmVerified(searchUseCase)
    }

    @Test
    fun search_twice() = runTest {
        coEvery {
            searchUseCase.execute(firstQuery)
        } returns flow { emit(firstResults) }
        coEvery {
            searchUseCase.execute(secondQuery)
        } returns flow { emit(secondResults) }
        
        viewModel.search(firstQuery)
        viewModel.search(secondQuery)
        delay(600)
        
        viewModel.results.first().also { response ->
            assertThat(response).isEqualTo(secondResults)
        }

        coVerify(exactly = 0) { searchUseCase.search(firstQuery) }
        coVerify(exactly = 1) { searchUseCase.search(secondQuery) }
        confirmVerified(searchUseCase)
    }

    @After
    open fun tearDown() {
        Dispatchers.resetMain()
        TestTaskExecutor.onAfter()
        unmockkAll()
    }
}

coVerify(exactly = 0) { searchUseCase.search(firstQuery) } で最初の検索が実行されていないことでdebounce処理がちゃんと動いていることが確認できます。


[Android]LinearProgressIndicatorの見た目を自然にする

https://developer.android.com/reference/com/google/android/material/progressindicator/BaseProgressIndicator

LinearProgressIndicatorの見た目や表示非表示を滑らかにします。

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/progress_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:elevation="1dp"
        android:indeterminate="true"
        app:trackCornerRadius="8dp" // Indicatorの角を丸くする
        app:trackThickness="16dp" // Indicatorの太さを設定する(わかりやすいように太くしている)
        app:hideAnimationBehavior="outward" // 隠す時のアニメーション
        app:showAnimationBehavior="inward"/> // 表示する時のアニメーション

変更後。わかりやすいようにtrackThicknessを16dpにしています。

フワッと表示されてフワッっと非表示になります。


AndroidGradlePlugin8.0アップデート時のメモ

AndroidGradlePlugin8.0アップデートで対応した項目のメモです。下記の内容が全てではありません。各プロジェクト毎に異なるのでその都度調べる必要があります。

  • com.android.tools.build:gradleのバージョンを8.0.0に変更する(buildSrcを使っているプロジェクトは適切に変更する)
com.android.tools.build:gradle:8.0.0
  • ルートのbuild.gradle.kts内に下記を追加する(buildSrcを使っているプロジェクトは適切に変更する)
buildscript { dependencies { classpath(“com.android.tools.build:gradle-api:8.0.0” } }
  • AndroidManifest.xmlで<manifest package=“<packagename>”>を指定している場合、同じモジュールの build.gradle.ktsにnamespace = “<packagename>”として移動する
  • 各モジュールのbuild.gradle.ktsでandroid { buildFeatures.buildConfig = true }を追加
  • build.gradle.ktsでandroid { compileOptions.sourceCompatibility(JavaVersion.VERSION_17) }に変更
  • build.gradle.ktsでandriod { compileOptions.targetCompatibility(JavaVersion.VERSION_17) } に変更
  • build.gradle.ktsでandroid { kotlinOptions.jvmTarget = “17” } に変更
  • Java17に対応していないライブラリのバージョンアップ
  • okhttp3を使っているモジュールのproguard.proに下記を追加
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**

https://qiita.com/Nabe1216/items/05c9981fd12eb2fa1df0

  • モジュール外のR(Resources)をコード内から呼び出す場合はパッケージ名が必要になりました。例えばマテリアルデザインライブラリを使っている場合は下記になります
R.string.abc_action_bar_up_description // 変更前
com.google.android.material.R.string.abc_action_bar_up_description // 変更後

import com.google.android.material.R as materialR // 別名を付けて
materialR.string.abc_action_bar_up_description // このようにしても良い
  • CIなどでJavaのバージョンが17未満の場合は17に設定する

BitriseではSet Java versionというレシピがあるので17に設定すれば良い

  • gradle.propertiesでandroid.enableR8.fullMode=trueを追加する
  • 動作確認してみてJsonのシリアライズ/デシリアライズがエラーになる場合は@Json(name=“name”)から@field:Json(name=“name”)に変更する
  • gradle-wrapper.propertiesのdistributionUrlをdistributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zipに変更する

下記は対応必須ではないがdeprecatedになっているので対応

  • Action<ApplicationVariant>がdeprecatedになったので
applicationVariants.all(object : Action<ApplicationVariant> {
    override fun execute(variant: ApplicationVariant) {
        variant.resValue("string", "version_name", variant.versionName)
    }
})

を下記に変更

applicationVariants.forEach { variant ->
    variant.resValue("string", "version_name", variant.versionName)
}

DataStoreへの移行時にSharedPreferencesのキー毎にマイグレーションする


前回の続きで下記のようにDataStoreへの移行をproduceMigrationsで定義します。
internal const val DATA_STORE_NAME = "preferences_data_store"
internal const val PREFERENCES_NAME = “preferences”

internal val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = DATA_STORE_NAME,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = PREFERENCES_NAME
            )
        )
    }
)

このときSharedPreferencesに一つのキーのみで使っている場合は良いですが複数のキーを使っている場合に移行が難しくなります。そのような場合はkeysToMigrateを使います。keysToMigrateについてWeb検索してもあまりヒットしないですね。。

下記のように書きます。

internal const val DATA_STORE_NAME = "preferences_data_store"
internal const val PREFERENCES_NAME = “preferences”
internal const val KEY_NAME = “key”

internal val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = DATA_STORE_NAME,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = PREFERENCES_NAME,
                keysToMigrate = setOf(KEY_NAME)
            )
        )
    }
)

これでSharedPreferencesのキー毎にマイグレーションが可能です。