月: 2021年11月

BroadcastReceiver内でRoomを使ってデータベースにアクセスするとjava.lang.IllegalStateExceptionが発生する

例えばこんなコードがある場合

class MyService : Service() {
    private val broadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            when (intent?.action) {
                Intent.ACTION_SCREEN_OFF -> {
                    // suspend function内でRoomを使ってDBにアクセスしている
                    suspendFunction.execute()
                }
                Intent.ACTION_SCREEN_ON -> {
                    // suspend functionではないがRoomを使ってDBにアクセスしている
                    notSuspendFunctionButUseRoom.execute()
                }
            }
        }
    }
}

このようにしてブロードキャストレシーバー受信時にDBにアクセスする処理を書きます。Intent.ACTION_SCREEN_OFFの場合はsuspend functionで書かれているのでAndroidStudio上でエラーが表示され、コルーチンを使用してsuspend functionを呼び出す必要があります。

Intent.ACTION_SCREEN_ONの場合が少し厄介でsuspend functionで書かれてはいないがRoomを使ってDBにアクセスしている場合、このコードが実行されるとjava.lang.IllegalStateExceptionがスローされてクラッシュします。AndroidStudio上でエラー表示されないので、実行してテストすると実装が間違えている事に気付きます。そもそもですがRoomでDBにアクセスする場合はsuspend functionで書くようにしましょう。

修正例はこのようになります。

class MyService : Service() {
    private val broadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            when (intent?.action) {
                Intent.ACTION_SCREEN_OFF -> {
                    launch(start = CoroutineStart.LAZY) { withContext(Dispatchers.IO) {
                        suspendFunction.execute()
                    }.start()
                }
                Intent.ACTION_SCREEN_ON -> {
                    // notSuspendFunctionButUseRoom.execute()をsuspend functionにした上で
                    launch(start = CoroutineStart.LAZY) { withContext(Dispatchers.IO) {
                        suspendFunctionWithRoom.execute()
                    }.start()
                }
            }
        }
    }
}

オーバーエンジニアリング問題

Gigazineさんで良い記事がありました

引用 : https://gigazine.net/news/20211126-overengineering/

◆オーバーエンジニアリングの影響

「さらに、複雑なコードはテストや修正が困難なので、メンテナンスコストもかさむことになります。」

要するにテストコードやドキュメントが無いプログラムは後世で負債化する可能性が高く、小さい修正のはずが大きな事故になってしまったりします。また修正するときのコストが大きくなってしまい、大した修正でなくても何日もかかったりしてしまいます。

記事内のグラフがとても良くできていて自分が今どのあたりにいるのか自身でわかるのではないでしょうか。

2021年現在のAndroidアプリの開発状況で言うとViewModelが正しく扱えて画面回転に対応できており、最低限ViewModelのテストコードとFragmentのテストコードが無いプロジェクトは負債プロジェクトと言っても差し支えないかなと思います。

別の記事でコードの見通しやシンプルにする方法なども書いていきたいと思います。


suspend functionのユニットテスト

はじめに

前提としてモッキングライブラリはmockk、アサーションライブラリはGoogleのTruthを使うとします。他のライブラリを使っている場合は適宜読み替えてください。

例題

例として下記のようなクラスがあったとします。

internal class LoadDataUseCaseImpl(
        private val dao: DataAccessObject
) : LoadDataUseCase {
    override suspend fun execute(): List<Data> =
            dao.load()
}

// DataAccessObjectはRoomで書かれていて
// load関数自体もsuspend functionで書かれているとします
@Dao
interface DataAccessObject {
    @Query("select * from data")
    suspend fun load(): List<Data>
}

この場合、LoadDataUseCaseImplクラスのインスタンスを作成し、execute()を呼び出したらdao.load()が1回呼び出されるというユニットテストを書けば良さそうです。

suspend functionのテストを実施するには

Androidのユニットテストでsuspend functionのテストをする場合はrunBlockingTestを使用します。org.jetbrains.kotlinx:kotlinx-coroutines-test:VERSIONで入れられます。またdao.load()もLoadDataUseCaseImplインスタンスのexecute()どちらもsuspend functionで書かれているので呼び出す場合もrunBlockingTestが必要になります。

ユニットテストを書く

実際のテストコードは下記のようになります。

@RunWith(AndroidJUnit4::class)
class LoadDataUseCaseImplTest {
    @MockK
    private lateinit var dao: DataAccessObject // 実際に動作しなくて良いので@MockKアノテーションを付けてモックにします。
    private lateinit var useCase: LoadDataUseCaseImpl

    @Before
    fun setUp() {
        MockKAnnotations.init(this, relaxed = true, relaxUnitFun = true) // モックを注入します
        useCase = LoadDataUseCaseImpl(dao) // LoadDataUseCaseImplインスタンスを作成します。
    }

    @Test
    fun execute_callDaoLoadOnce() {
                // dao.load()が呼ばれたら何もしないようにします
        // coEvery { runBlockingTest { } }をすると戻り値を書かなくてもよくなります
                // suspend functionなのでrunBlockingTestが必要です
        coEvery { runBlockingTest { dao.load() } } just Runs

                // suspend functionなのでrunBlockingTestが必要です
        runBlockingTest { useCase.execute() }

                 // dao.load()が1回呼ばれることを確認する
                 // suspend functionなのでrunBlockingTestが必要です
        coVerify(exactly = 1) { runBlockingTest { dao.load() } }
        confirmVerified(dao)
    }

    @After
    fun tearDown() {
        unmockkAll()
    }
}

runBlockingTestが多すぎると思いますがsuspend functionを呼び出す度に必要になるのでこのようになります。


MaterialButtonToggleGroupを角丸にする

引用:https://developer.android.com/reference/com/google/android/material/button/MaterialButtonToggleGroup

角丸にするShapeColumnToggleButtonIconOnlyとそれ以外の見た目を指定するStyleColumnToggleButtonIconOnlyを両方宣言します。

<style name="ShapeColumnToggleButtonIconOnly" parent="ShapeAppearance.MaterialComponents.SmallComponent">
    <item name="cornerFamily">rounded</item>
    <item name="cornerSize">16dp</item>
</style>

<style name="StyleColumnToggleButtonIconOnly" parent="Widget.MaterialComponents.Button.OutlinedButton">
    <item name="iconPadding">0dp</item>
    <item name="android:insetTop">0dp</item>
    <item name="android:insetBottom">0dp</item>
    <item name="android:paddingLeft">24dp</item>
    <item name="android:paddingRight">24dp</item>
    <item name="android:minWidth">24dp</item>
    <item name="android:minHeight">24dp</item>
</style>

その後ボタンの見た目にそれぞれのstyleを設定します。

<com.google.android.material.button.MaterialButtonToggleGroup
    android:id="@+id/button_toggle_group"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:selectionRequired="true"
    app:singleSelection="true">

    <Button
        android:id="@+id/first_button"
        style="@style/StyleColumnToggleButtonIconOnly"
        app:shapeAppearance="@style/ShapeColumnToggleButtonIconOnly"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:icon="@drawable/ic_change" /> <!-- 適当なアイコンを指定 -->

    <Button
        android:id="@+id/second_button"
        style="@style/StyleColumnToggleButtonIconOnly"
        app:shapeAppearance="@style/ShapeColumnToggleButtonIconOnly"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:icon="@drawable/ic_check" /> <!-- 適当なアイコンを指定 -->

</com.google.android.material.button.MaterialButtonToggleGroup>

ポイントはstyle=”@style/xxxx”とapp:shapeAppearance=”@style/yyyy”で指定している箇所です。styleを一つにまとめてしまったりすると意図した見た目にならないので、必ず別々のstyleを指定するようにしましょう。