ベータコンピューティングの活動や技術、開発のこだわりなどを紹介するブログです。



Jetpack Compose入門 ~ 実践編 ~

この記事について

こんにちは、Beta Computing株式会社で学生アルバイトをしていますAndroidアプリケーション開発担当です。 来年4月から正式に入社予定ですので、現在は研修期間としてAndroid開発の勉強を進めています。 今回はAndroid開発のツールキットであるJetpack Composeを用いてアプリを作成する上で、勉強になった点をまとめたいと思います。

今回作成したアプリについて

今回は買い物メモをリストにして管理するアプリをJetpack Composeを用いて作成しました。

Hilt (Jetpack Composeにおける使用)

Hilt(ヒルト)は依存性(オブジェクト)の注入を簡素化することができるライブラリです。 手動で依存性注入を行う方法に比べて、Hitlを使うことで、 依存関係のスコープをライフサイクルに合せて自動的に管理することが可能になったり、 依存関係の検証によって不足している依存関係や不適切なスコープ設定を早期に発見できたりすることなどのメリットを享受できます。 以前の記事で概要を簡単に紹介しましたが、今回はHiltの使用方法を記事にまとめてみようと思います。 ※ 今後、それぞれのライブラリの導入については、環境によって方法が異なるため、省略いたします。 公式ドキュメントは以下になります。

Hilt を使用した依存関係挿入  |  Android Developers

Hiltを使う準備

まずはアプリケーションクラスを作成して @HiltAndroidAppアノテーションを付けます。

app/…/shoppingmemocompose/ShoppingMemoComposeApplication.kt

import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber

@HiltAndroidApp // 依存関係注入に必要
class ShoppingMemoComposeApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // ログ出力の設定
        if (BuildConfig.DEBUG) {
            Timber.plant(Timber.DebugTree())
        }
    }
}

このクラスをはじめて作る場合はAndroidManifest.xmlに以下の以下の記述が必要です。

app/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application
       ...
        android:name=".ShoppingMemoComposeApplication" <!-- こちらを追加 -->
        tools:targetApi="31">
    </application>
...
</manifest>

また、Hiltを利用するコンポーネントには @AndroidEntryPoint を付与します。 今回の場合は MainActivityに付与します。

app/…/shoppingmemocompose/ui/main/MainActivity.kt

@AndroidEntryPoint // こちらを追加
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ShoppingMemoComposeTheme {
                ShoppingMemoComposeApp()
            }
        }
    }
}

基本的は以上で準備完了です。

実際に依存注入を行う

Hiltを用いて依存性注入を実現する方法を以下にまとめます。 ここでは、具体例としてリポジトリクラスを注入する手順を説明します。

Hiltでは、クラスのインスタンスを生成する際に依存関係を解決するため、対象のクラスに@Injectアノテーションを付けます。 たとえば、リポジトリクラスを作成する場合を見てみます。 以下のように、リポジトリクラスのコンストラクターに@Injectを付与します。

※ DAOなどについてはRoom(データベース)の章で説明します。

class ShoppingMemoRepository @Inject constructor(
    private val converter: ShoppingMemoConverters, // 依存関係
    private val appDatabase: AppDatabase, // 依存関係
) {
    // DAOの取得
    private val shoppingMemoDao = appDatabase.getShoppingMemoDao()

    /**
     * 買い物メモを新規作成する。
     */
    suspend fun createNewShoppingMemo(): ShoppingMemoId {
        val defaultTitle = "無題"
        val shoppingMemoDate = null
        val shoppingMemoId = shoppingMemoDao.insertMemo(
            ShoppingMemoEntity(
                title = defaultTitle,
                date = shoppingMemoDate
            )
        )
        return converter.toShoppingMemoId(shoppingMemoId)
    }


    /**
     * 買い物メモを削除する
     */
    suspend fun deleteShoppingMemo(
        shoppingMemoId: ShoppingMemoId
    ) = shoppingMemoDao.deleteShoppingMemo(shoppingMemoId)

    /**
     * 買い物メモのタイトルを更新する
     */
    suspend fun updateShoppingMemoTitle(
        id: ShoppingMemoId,
        newTitle: String
    ) = shoppingMemoDao.updateTitle(id, newTitle)

    /**
     * 買い物メモの日付を更新する
     */
    suspend fun updateShoppingMemoDate(
        id: ShoppingMemoId,
        newDate: ShoppingMemoDate?
    ) = shoppingMemoDao.updateDate(id, newDate)

    // ... 以下メソッドが続く

}

場合によっては、インターフェイスや外部ライブラリのクラスなど、 直接コンストラクタインジェクションができないものがあります。 その場合は、Hiltモジュールを作成して依存性を提供します。 モジュールを作成するには、@Module@InstallInアノテーションを使用してモジュールを定義します。 また、スコープ設定によって依存性のライフサイクルを管理できます。 たとえば、アプリ全体で同じインスタンスを共有したい場合は、@Singletonスコープを使用します。 以下はその例です。

// Hiltによる依存性注入の設定
@Module
@InstallIn(SingletonComponent::class)
class AppDatabaseModule {

    // データベースのインスタンス(データベースの章で説明します。)
    @Provides // このアノテーションが必要
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): AppDatabase = AppDatabase.getDataBase(
        context
    )

    @Provides
    @Singleton
    fun provideShoppingMemoConvertors(): ShoppingMemoConverters = ShoppingMemoConverters

    // ... 
}

ViewModelへの依存性注入

ViewModelに対してもHiltで依存性注入が可能です。@HiltViewModelアノテーションとコンストラクタインジェクションを使用することで依存性が注入できます。 以下は例です。

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val repository: ShoppingMemoRepository
) : ViewModel() {
    // データベースの操作などを記述
}

さらに、ComposeでHilt管理下のViewModelを使用するには、hiltViewModel()関数を利用します。

@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
@Composable
fun DetailScreen(
    navigateBack: () -> Unit,
    viewModel: DetailViewModel = hiltViewModel() // ここに依存性注入
    // ...
) {
    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(
                title = { Text("買い物詳細") },
                actions = {
                    TextButton(
                        onClick = viewModel::showDeleteConfirmDialog,
                        enabled = !detailUiState.isLoading
                    ) { Text("削除") }
                },
                navigationIcon = {
                    IconButton(
                        onClick = navigateBack,
                        enabled = !detailUiState.isLoading
                    ) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = "もどる"
                        )
                    }
                },
                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
                )
            )
        },
        floatingActionButton = {
            ExtendedFloatingActionButton(
                onClick = {
                    if (!detailUiState.isLoading) {
                        viewModel.showItemInputDialogOnCreateMode()
                    }
                },
                icon = { Icon(Icons.Filled.Add, "品物追加ボタン") },
                text = { Text(text = "品物追加") }
            )
        }
    ) { paddingValues ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 0.dp) // 水平方向のパディングを0に
                .consumeWindowInsets(paddingValues), // この行を追加
            contentPadding = paddingValues, // innerPaddingをcontentPaddingとして設定,
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top
        ) {
            // LazyColumnの内容が続く...
        }
    }
}

StateFlow

まずFlowとは?

StateFlowを説明する前に、Flowについて説明します。 Flowとは「コルーチンの一種で、返す値が1個のみの「suspend関数」とは異なり、複数の値を順次出力できます。」 と公式ドキュメントで紹介されています。

Android での Kotlin Flow  |  Android Developers

今回の実装では、Flowを用いてデータベースから値をリアルタイムで取得しました。 たとえば以下のコードを見てください。

fun getProcessedData(): Flow<List<String>> = flow {
    val rawDataFlow: Flow<List<Int>> = getRawDataFlow() // 仮のデータソース(Flowで整数のリストを提供)

    rawDataFlow.collect { rawList ->
    
        val filteredList = rawList.filter { it % 2 == 0 }
        val processedList = filteredList.map { "Number: $it" }

        // 加工済みのデータをemitで発行
        emit(processedList)
    }
      // 仮のデータソース(Flowを生成)
    private fun getRawDataFlow(): Flow<List<Int>> = flow {
        emit(listOf(1, 2, 3, 4, 5)) // 最初のリストを発行
        delay(1000) // 次のデータまで1秒待機
        emit(listOf(6, 7, 8, 9, 10)) // 次のリストを発行
    }
}
  • flow {}

    新しいFlowを構築するためのブロックであり、非同期データストリームを生成する役割を持っています。

  • .collect {}

    上流のFlowからデータを収集し、そのデータを順次処理するために使用されています。

  • emit()

    Flow内で新しい値を発行し、下流に向けてデータを送信するための操作です。

また、Flowはコールドストリームと呼ばれるストリームです。 Flowは.collect()を呼び出さないと値を収集することはありません。 それに対して、次に紹介するStateFlowはホットストリームと呼ばれるストリームです。

StateFlowとは?

StateFlowは、Kotlin Coroutinesの一部として提供される「ホットストリーム」の一種です。 通常のFlowはコールドストリームであり、収集が開始されるまで値を生成しませんが、 StateFlowは常に最新の値を保持し、新しいコレクターが収集を開始した瞬間にその値を受け取ることができます。

コールドストリームとホットストリームの違いについては以下のサイトが分かりやすくまとめられていて、大変勉強になりました。 初学者向けKotlin Coroutines Flow

class DataProcessor {

    // MutableStateFlowを定義(初期値は空のリスト)
    private val _processedData = MutableStateFlow<List<String>>(emptyList())

    // 外部からはStateFlowとして公開
    val processedData: StateFlow<List<String>> get() = _processedData

    // 以下は先ほどのコードとほとんど同じ
    fun startProcessing() {
        val rawDataFlow: Flow<List<Int>> = getRawDataFlow() // 仮のデータソース

        CoroutineScope(Dispatchers.Default).launch {
            rawDataFlow.collect { rawList ->
                val filteredList = rawList.filter { it % 2 == 0 }
                val processedList = filteredList.map { "Number: $it" }
                _processedData.value = processedList
            }
        }
    }

    // 仮のデータソース(Flowを生成)
    private fun getRawDataFlow(): Flow<List<Int>> = flow {
        emit(listOf(1, 2, 3, 4, 5)) // 最初のリストを発行
        delay(1000) // 次のデータまで1秒待機
        emit(listOf(6, 7, 8, 9, 10)) // 次のリストを発行
    }
}

以上の様にStateFlowを用意することで、常に最新の値を取得できます。

FlowとStateFlowについては以下のサイトが大変勉強になりました。 なんとなく使いこなしてた気がしてた、StateFlowを理解する

また、StateFlowと似たものにLiveDataと呼ばれるものがあります。 LiveDataは初期値が必要ないのに対して、StateFlowは初期値が必要でNull安全です。 細かな違いは以下のサイトの中程で説明がされています。

StateFlow と SharedFlow | Android Developers

Room(+DAO、DTO等の話)

Roomとは、Android Jetpackに含まれているライブラリで、SQLiteデータベースの操作を簡潔に行えるようにする抽象レイヤーを提供してくれます。 Roomを導入することで、コード量を削減しつつ、 Flowとの連携も簡単になるため、安全で効率的なデータベース操作が可能になります。 公式ドキュメントは以下です。

Room公式ドキュメント |  Android Developers

また、本章の内容は以下に沿っています。

Room を使用してデータを永続化する |  Android Developers

Entityの用意

Entityとは、Roomにおいてデータベースを表現するクラスです。 Entityクラスを作るには作成したデータクラスに @Entity アノテーションを付けます。 このクラスを用意することで、Room側がテーブル構造を認識して自動的にSQLiteテーブルを作成してくれます。 たとえば以下のようなEntityクラスです。

@Entity(tableName = "shopping_memo_tbl")
data class ShoppingMemoEntity(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "shopping_memo_id")
    val id: Int = 0,
    @ColumnInfo(name = "shopping_memo_title")
    val title: String,
    @ColumnInfo(name = "shopping_memo_date")
    val date: String?
)

このクラスは買い物メモのテーブルです。 テーブル名は @Entity(tableName = "shopping_memo_tbl") で定義されていて、ID、買い物メモのタイトル、買い物メモに紐付いた日付がフィールドとして定義されています。 さらに、IDは主キー(@PrimaryKey アノテーション)かつ自動生成(@PrimaryKey(autoGenerate = true))を指定しています。 このように指定することで、レコードが生成されるたびに新しいIDを生成してくれます(主キーはレコードを区別するためにテーブル内で一意である必要があります)。 さらに今回は、@ColumnInfo(name = "○○") アノテーションを使用して、データベースにおけるフィールド名を指定しています。 その他のアノテーションについては公式ドキュメントをご参照ください。

Entity |  Android Developers

外部キー制約付きEntity

テーブルによっては外部キー制約を定義する必要があります。外部キー制約とは、他のテーブルとの関係による制約です。 たとえば、買い物メモに複数の品物メモを追加する場合を考えます。1つの買い物メモに複数の品物メモを登録する場合、買い物メモと品物メモの関係は1対多になります。

  • キャンプ用品(買い物メモ)
    • ランタン(品物メモ)
    • テーブル(品物メモ)
    • ...

このとき、何の制約も無く買い物メモを削除するとどうなるでしょうか?本来ならば品物メモは買い物メモに属しているはずですが、その買い物メモは削除されているので参照できません。 このような状態は思わぬエラーを引き起こすことになります。これを避けるために、データベースに買い物メモと品物メモは親子関係にあることを明示する必要があります。外部キー制約を設定することで、親テーブルである買い物メモが削除された時に子テーブルである品物メモも削除されるようにします。 では、以上を踏まえて品物メモのテーブルを作成します。

// 買い物メモの個別アイテムを表すエンティティクラス
@Entity(
    // テーブル名を指定
    tableName = "shopping_memo_item_tbl",

    // 外部キー制約を定義
    foreignKeys = [
        ForeignKey(
            // 参照先のエンティティクラスを指定
            entity = ShoppingMemoEntity::class,
            // 参照先テーブルの主キーカラム
            parentColumns = ["shopping_memo_id"],
            // 参照元テーブルの外部キーカラム
            childColumns = ["shopping_memo_id"],
            // 参照先レコード削除時の動作:親レコードのIDが削除されたら消す。
            onDelete = ForeignKey.CASCADE,
            // 参照先レコード更新時の動作:親レコードのIDを更新するとエラーになる。
            onUpdate = ForeignKey.RESTRICT
        )
    ],

    // インデックスを定義
    indices = [
        // 買い物メモアイテムIDに対するユニークインデックス
        Index(value = ["shopping_memo_item_id"], unique = true),
        // 検索性能向上のための買い物メモIDへのインデックス
        Index(value = ["shopping_memo_id"])
    ]
)
data class ShoppingMemoItemEntity(
    // 主キー(自動採番)
    @PrimaryKey(autoGenerate = true)
    // カラム名を指定
    @ColumnInfo(name = "shopping_memo_item_id")
    val id: ShoppingMemoItemId = ShoppingMemoItemId(0),

    // 買い物メモへの外部キー
    @ColumnInfo(name = "shopping_memo_id")
    val memoId: ShoppingMemoId,

    // 買い物アイテムの名称
    @ColumnInfo(name = "shopping_memo_item_name")
    val name: String,

    // 買い物アイテムのメモ
    @ColumnInfo(name = "shopping_memo_item_memo")
    val memo: String,

    // 買い物アイテムのチェック状態
    // true: チェック済み, false: 未チェック
    @ColumnInfo(name = "shopping_memo_item_is_checked")
    val isChecked: Boolean
)

このクラスでは、@Entity アノテーションの foreignKeys パラメーターを用いて、買い物メモと品物メモ間における外部キー制約を定義しています。具体的には、ShoppingMemoItemEntity(品物メモ)が ShoppingMemoEntity(買い物メモ)の主キーカラム(shopping_memo_id)を外部キーとして持ち、以下のような制約や動作が設定されています。

  • entity = ShoppingMemoEntity::class

    参照先(親テーブル)のEntityを指定しています。

  • parentColumns = ["shopping_memo_id"] / childColumns = ["shopping_memo_id"]

    参照先で定義されている主キー(shopping_memo_id)と、子テーブル側(shopping_memo_item_tbl)の主キー(shopping_memo_id)を紐付けます。

  • onDelete = ForeignKey.CASCADE

    親テーブル(ShoppingMemoEntity)の該当レコードが削除された場合、それに紐付いている子テーブルのレコードもまとめて削除されるように指定しています。つまり買い物メモが削除されたら、そのメモに紐付く品物メモも同時に削除されます。

  • onUpdate = ForeignKey.RESTRICT

    親テーブル(ShoppingMemoEntity)の主キーが更新されたとき、例外を発生させて更新を防ぎます。ほとんどの場合、主キーを変更するような設計は避けるべきです。

さらにこのEntityにはインデックスが設定されています。インデックスとは、不要な全行スキャンを避けつつ目的のレコードのみを素早く特定できる仕組みです。 上記のレコードでは以下のような設定がされています。

  • Index(value = ["shopping_memo_item_id"], unique = true)

    shopping_memo_item_idに対するユニークインデックスを設定し、同じ値を重複して登録できないようにしています。これによりIDの一意性が保証されるだけでなく、検索時も指定したIDに該当する行を素早く特定可能になります。

  • Index(value = ["shopping_memo_id"])

    shopping_memo_idで検索するときの性能を向上するためのインデックスを設定しています。たとえば、特定の買い物メモに紐づくアイテムを一覧表示するときに、全行をスキャンせずインデックスから該当レコードを即座に参照できます。

インデックスはあくまで補助的な構造であり、必須ではありませんが、大規模データを扱う場合や特定の列でもっとも頻繁に検索するとわかっている場合には有効な手段になります。 一方で、不要な列にまでインデックスを付与すると、テーブルの保守やデータ更新時のコストが増大するので、使いどころを考える必要があります。

Entityの用意は以上となります。

DAOの用意

DAO(Data Access Object)とは、データベースの操作を集約したクラスです。このクラスを用意することでデータベースを扱う処理を集約できます。 さらに、データベースの操作には作成・読み取り・更新・削除がありますが、Roomでは特別なアノテーションが用意されており、SQL文を書かずともデータベースを操作するメソッドを簡単に定義できます。

ではまずDAOを作成します。 DAOを用意するには、インターフェイスに@Daoアノテーションを付与します。また、DAOはアプリに対して1つ作成する場合やテーブル毎に作成する場合などがありますが、今回はアプリに1つとします。

@Dao
interface ShoppingMemoDao {
    /**
     * 買い物メモの挿入
     */
    @Insert
    suspend fun insertMemo(memo: ShoppingMemoEntity): Long
}

上の例では、買い物メモの挿入(作成)を定義しています(戻り値は挿入したレコードのID)。本来ならばSQL文を書く必要がありますが、Roomの場合は簡潔に書くことができます。これは更新(@Update)、削除(@Delete)でも同様です。 しかし、場合によっては複雑な操作を定義する必要があります。その場合は@Queryアノテーションを使用して複雑な操作を定義します。たとえば買い物メモのタイトルを更新するメソッドを定義すると以下の様になります。

@Dao
interface ShoppingMemoDao {
    /**
     * 買い物メモの挿入
     */
    @Insert
    suspend fun insertMemo(memo: ShoppingMemoEntity): Long

    /**
     * 買い物メモのタイトル更新
     */
    @Query(
        "UPDATE shopping_memo_tbl SET shopping_memo_title = :newTitle WHERE shopping_memo_id = :id"
    )
    suspend fun updateTitle(id: ShoppingMemoId, newTitle: String)

}

以上のようにSQL文を定義できます(読み取りに関してはこのようにSQL文で定義するしかありません)。

DTOの用意

先ほど紹介したDAOのメソッドに加えて、読み取りのメソッドを追加してみます。レコードを読み取った後はその結果を返す必要があります。その結果にはEntityクラスを使うこともできますが、今回はDTOクラスと呼ばれるものを使用して、DTOクラスで結果を返すようにします。

DTO(Data Transfar Object)とは、異なるレイヤーにデータを転送する際に使用するオブジェクトです。DTOはゲッターおよびセッター以外のメソッドを持たず、メンバー変数のみを保持します。 たとえば、買い物メモのDTOは以下のようになります。

data class ShoppingMemoDto(
    val id: ShoppingMemoId = ShoppingMemoId(0),
    val title: String,
    val date: String?,
    val itemCount: Int,
    val checkedItemCount: Int = 0
)

買い物メモを取得するメソッドは、このクラスで結果を返すようにします。 また、データベースの操作の返り値はFlow(前章で説明しました)を使用します。 Flowを使用することで、データベースの値を監視してデータを最新の状態に保つことができます。

以上を踏まえて、買い物メモを取得するメソッドは以下のようになります。

    @Query(
        """
        SELECT 
            m.shopping_memo_id AS id,
            m.shopping_memo_title AS title,
            m.shopping_memo_date AS date,
             -- 各買い物メモに紐づく品物の総数をカウント
        COUNT(i.shopping_memo_item_id) as itemCount,
        -- チェック済み品物の数を集計(is_checked=1の場合のみカウント)
        SUM(CASE
            WHEN i.shopping_memo_item_is_checked = 1 THEN 1
            ELSE 0
        END) as checkedItemCount
        FROM shopping_memo_tbl m
        -- 買い物メモの品物テーブルを左外部結合
        LEFT JOIN shopping_memo_item_tbl i
        ON m.shopping_memo_id = i.shopping_memo_id
        WHERE m.shopping_memo_id = :id
    """
    )
    fun getShoppingMemoById(id: ShoppingMemoId): Flow<ShoppingMemoDto>

このメソッドを一度呼び出せば、指定の買い物メモの最新の状態を常に取得し続けることができます。

データベースインスタンスの用意

ここまで準備してきたDAOを扱うにはデータベースインスタンスと呼ばれるものを用意します。 データベースインスタンスはアプリのデータベースにアクセスするためのアクセスポイントとなり、DAOの取得・管理を行います。データベースインスタンスを作るにはRoomDatabaseを継承したクラスをつくり、そのクラスに@Databaseアノテーションを付与します。さらに、DAOクラスごとに、引数無しのDAOインスタンスを返り値に持つ抽象メソッドを宣言します。 では実際に作ってみます。

@Database(
    entities = [
        ShoppingMemoEntity::class,
        ShoppingMemoItemEntity::class
    ],
    version = 2,
    exportSchema = false
)
@TypeConverters(ShoppingMemoConverters::class)
abstract class AppDatabase : RoomDatabase() {

    // DAOの登録
    abstract fun getShoppingMemoDao(): ShoppingMemoDao

    companion object {
        private const val DATABASE_NAME = "shopping_memo_database"

        @Volatile
        private var Instance: AppDatabase? = null // null許容型で、nullとして宣言する。

        // データベースビルダーに必要なContextパラメータを持つメソッド
        fun getDataBase(context: Context): AppDatabase {
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    DATABASE_NAME
                )
                    // マイグレーションが失敗したときの対応
                    .fallbackToDestructiveMigration()
                    .build()
                    .also { Instance = it }
            }
        }
    }
}

順に確認します。

  • @Database(...)

    • entities = [ShoppingMemoEntity::class, ShoppingMemoItemEntity::class]

      データベースが扱うEntityを指定します。

    • version = 2

      スキーマのバージョンです。スキーマを変更するたびにバージョンを増やす必要があります。

    • exportSchema = false

      スキーマのバージョン履歴をバックアップしないように設定しています。

  • @TypeConverters(ShoppingMemoConverters::class)

    Roomがそのままでは扱えない型を、SQLiteで扱える型へ変換するためのメソッド群を提供するクラスを登録するためのアノテーションです。これにより、データベースやDAOでShoppingMemoConverters内の変換処理が自動的に使われ、たとえばカスタムクラスやDateなどを問題なく保存・読み出しできるようになります。

    ShoppingMemoConverterの内容はこちら

      object ShoppingMemoConverters {
          @TypeConverter
          @JvmStatic
          fun toShoppingMemoId(value: Long): ShoppingMemoId {
              return ShoppingMemoId(value)
          }
    
          @TypeConverter
          @JvmStatic
          fun fromShoppingMemoId(id: ShoppingMemoId): Long {
              return id.value
          }
    
          @TypeConverter
          @JvmStatic
          fun toShoppingMemoDate(value: String?): ShoppingMemoDate? {
              return ShoppingMemoDate.parse(value)
          }
    
          @TypeConverter
          @JvmStatic
          fun fromShoppingMemoDate(date: ShoppingMemoDate?): String? {
              return ShoppingMemoDate.toDbValue(date)
          }
    
          @TypeConverter
          @JvmStatic
          fun toShoppingMemoItemId(value: Long): ShoppingMemoItemId {
              return ShoppingMemoItemId(value)
          }
    
          @TypeConverter
          @JvmStatic
          fun formShoppingMemoItemId(id: ShoppingMemoItemId): Long {
              return id.value
          }
      }
    

 

  • abstract fun shoppingMemoDao(): ShoppingMemoDao

    Roomでは、抽象メソッドとしてDAOを定義すると、ビルド時に生成されるコードがこのメソッドと結びついて、DAOインスタンスを取得できるようになります。 このメソッドを通じて、ShoppingMemoDaoの各メソッドを呼び出せるようになる仕組みを提供しています。

  • @Volatile private var Instance: AppDatabase? = null

    AppDatabaseのシングルトンインスタンスを保持します。これにより、データベースのインスタンスが一度だけ初期化され、安全に複数のスレッドで共有されます。

  • fun getDataBase(context: Context): AppDatabase

    データベースのシングルトンインスタンスを取得するためのメソッドで、このメソッドは複数のスレッドが同時に呼び出してもデータベースが一度だけ初期化されるようにし、Instancenullの場合、データベースを初期化し、データベースインスタンスを返します。

    • .fallbackToDestructiveMigration()

      マイグレーションが失敗した場合に、既存のデータを破棄して新しいバージョンのデータベースを再作成します。これにより、データベースのスキーマが変更された場合でもアプリがクラッシュせずに動作し続けるようにします。

    • .build()

      Roomデータベースビルダーにより、指定された設定に基づいてデータベースインスタンスを構築します。

    • .also { Instance = it }

      構築されたデータベースインスタンスを変数Instanceに代入し、その後でそのインスタンスを返します。

以上のコードはAndroid Developer公式のコードを参考にして書いたのですが、今回の実装ではHilt(最初の章で説明しました)を使用してDIコンテナを用意しているので、ここでの排他制御 ( @Volatilesynchronized ) は不要であるとのご指摘を先輩からいただきました。DIコンテナがアプリケーションのライフサイクルを通じて同じAppDatabaseインスタンスを提供するからです。 その点を踏まえて今回は以下のような実装にしました。

AppDatabaseModule.kt

// Hiltによる依存性注入の設定
@Module
@InstallIn(SingletonComponent::class)
class AppDatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): AppDatabase = AppDatabase.getDataBase(
        context
    )
}

AppDatabase.kt

@Database(
    entities = [
        ShoppingMemoEntity::class,
        ShoppingMemoItemEntity::class
    ],
    version = 2,
    exportSchema = false
)
@TypeConverters(ShoppingMemoConverters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun shoppingMemoDao(): ShoppingMemoDao
    // インスタンスの管理はDIコンテナに任せているため不要になります

    companion object {
        private const val DATABASE_NAME = "shopping_memo_database"

        fun getDataBase(context: Context): AppDatabase {
            return Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                DATABASE_NAME
            ).fallbackToDestructiveMigration()
            .build()

        }
    }
}

以上でデータベースインスタンスの用意ができました。

Repositoryの用意

ここまでで、データベースの操作を呼び出すことはできるようになりましたが、さらにRepositoryと呼ばれるものを用意します。 Repositoryとは、データの取得や保存を一括して管理するクラスであり、アプリのメイン機能(ビジネスロジック)とデータアクセス部分が分離され、コードの保守がしやすくなります。 アプリケーションにおけるRepositoryクラスの立ち位置は、以下の図のようになります。

graph LR;
  classDef class1 fill:#ffccdd
    View-->ViewModel;
    ViewModel-->UseCase;
    subgraph 必要に応じて作成
    UseCase
    end
    UseCase-->Repository:::class1;
    Repository-->DAO;

この図に示されているように、RepositoryクラスはUseCaseとDAOの間に位置し、UseCaseがビジネスロジックを処理し、Repositoryクラスを介してデータアクセスを管理します。 DAOは、前述したように具体的なデータベース操作を担当し、Repositoryクラスがデータの取得や保存のロジックを統一的に扱います。 このアプローチにより、データソースが変更された場合でも、Repositoryクラスを通じて一貫したインターフェイスを提供するため、アプリケーションの他の部分への影響を最小限に抑えることができます。(今回の実装ではUseCaseクラスは作成しませんでした。)

では実際にRepositoryクラスを作成してみます。

ShoppingMemoRepository.kt

class ShoppingMemoRepository @Inject constructor(
    // クラスの詰め替えをするコンバーターやDAOなどの依存性注入
    private val appDatabase: AppDatabase,
    // ...
) {
    private val shoppingMemoDao = appDatabase.getShoppingMemoDao()
    /**
     * 買い物メモを新規作成する。
     */
    suspend fun createNewShoppingMemo(): ShoppingMemoId {
        val defaultTitle = "無題"
        val shoppingMemoDate = null
        val shoppingMemoId = shoppingMemoDao.insertMemo(
            ShoppingMemoEntity(
                title = defaultTitle,
                date = shoppingMemoDate
            )
        )
        return converter.toShoppingMemoId(shoppingMemoId)
    }

    /**
     * 買い物メモのタイトルを更新する
     */
    suspend fun updateShoppingMemoTitle(
        id: ShoppingMemoId,
        newTitle: String
    ) = shoppingMemoDao.updateTitle(id, newTitle)

    // その他の操作が以下に続く
}

あとはViewModel等で呼び出して操作を行うことができます。

DetailViewModel.kt

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val repository: ShoppingMemoRepository
) : ViewModel() {
      /**
     * 買い物メモのタイトルを更新する。
     */
    fun updateShoppingMemoTitle(newTitle: String) =
        viewModelScope.launch(Dispatchers.IO) {
            startLoading()
            runCatching {
                _detailUiState.value.memo?.let {
                    repository.updateShoppingMemoTitle(it.id, newTitle)
                }
            }.onSuccess {
                Timber.tag(LOG_TAG).d("買い物メモのタイトルを無事更新できました。")
            }.onFailure {
                Timber.tag(LOG_TAG)
                    .d("何らかの問題が発生して買い物メモのタイトルを更新できませんでした。")
                Timber.e(it)
            }
            endLoading()
        }.invokeOnCompletion {
            Timber.tag(LOG_TAG).d("買い物メモのタイトルを更新するコルーチンは終了しました。")
        }

        // 以下省略
}

ダークテーマ対応

最近のスマホにはダークテーマ(ダークモード)が標準で搭載されるようになりました。 ダークモードは、画面の背景色を白色から黒色または暗い色に変更する機能です。これにより、とくに夜間や暗い場所での視認性の向上や、バッテリー消耗を抑える効果があります。OLEDディスプレイを搭載したデバイスでは、黒いピクセルが表示されていないため、さらに電力が節約されます。

ダークテーマについては、何もしなくともある程度は対応できます。 Android StudioでJetpac Composeプロジェクトを作成すると、以下のファイルがサンプルとして作成されています。

Theme.kt

// サンプル(一部省略)
private val DarkColorScheme = darkColorScheme(
    primary = Purple80,
    secondary = PurpleGrey80,
    tertiary = Pink80
)

private val LightColorScheme = lightColorScheme(
    primary = Purple40,
    secondary = PurpleGrey40,
    tertiary = Pink40
)

@Composable
fun MyApplicationTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }

        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

アプリの配色に関しては、このTheme.ktファイルで管理されているようです。上記のコードを見てみると、上2つのprivate変数(DarkColorSchemeおよびLightColorScheme)でそれぞれライトテーマ、ダークテーマの設定が書かれており、 下のval colorScheme = when {..の部分でテーマの切替えが実装されています。

しかしながら、場合によっては文字が見にくくなったり、配色が原因でUIが判別しにくいものになることがあります。 こうしたことを避けるためには、このテーマ設定を独自に実装する必要があります。 本章ではその対応について記述します。また、内容は以下の公式ドキュメントに沿っています。

Jetpack Compose でのマテリアル テーマ設定 |  Android Developers

アプリのテーマを全て自分で指定することもできますが、以下のGoogleが提供しているMaterial Theme Builderを使用することで、簡単に独自の配色でテーマを作成できます。

Material Theme Builder

※公式サイトを開くと以下のようなページが開くと思います。(閲覧日:2025/01/29)

Material Theme Builder 公式サイト

配色を行うには画面左にある「Core colors」にある色を変更します。 色を変更するには、丸い色のアイコンをクリックします。色の指定には表示されるツールを使用する、もしくはカラーコードを入力する方法があります。 配色には上のPrimaryカラーから順に設定します。 それぞれの配色の役割は以下の公式サイトで説明されています。

Material 3 ガイドライン

配色が完了したら、設定をエクスポートします。 画面右上のプラスボタン(の様なもの)をクリックして、展開されたサイドバーの下にある「Export」をクリックします。 選択肢が出てくるので今回は「Jetpack Compose(Theme.kt)」を選択します。すると、Color.kt、Theme.kt、Tpye.ktの3つのファイルがダウンロードされるはずです。 後はそのファイルの内容をプロジェクトに適応させれば作成したアプリのテーマを適応させることができます。 このとき、ダークモードも準備されています。

たとえば、Color.ktファイルを開くと以下のようになっているはずです。

package com.example.compose
import androidx.compose.ui.graphics.Color

val primaryLight = Color(0xFF6D5E0F)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFF8E287)
val onPrimaryContainerLight = Color(0xFF534600)
val secondaryLight = Color(0xFF665E40)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFEEE2BC)
val onSecondaryContainerLight = Color(0xFF4E472A)
// 以下、大量の配色設定が続く

これをプロジェクトにあるColor.ktファイルに追記します。 元からある色は削除します。

import androidx.compose.ui.graphics.Color

- val Purple80 = Color(0xFFD0BCFF)
- val PurpleGrey80 = Color(0xFFCCC2DC)
- val Pink80 = Color(0xFFEFB8C8)

// 以下を追加
+ val primaryLight = Color(0xFF6D5E0F)
+ val onPrimaryLight = Color(0xFFFFFFFF)
+ val primaryContainerLight = Color(0xFFF8E287)
+ val onPrimaryContainerLight = Color(0xFF534600)
+ val secondaryLight = Color(0xFF665E40)
+ val onSecondaryLight = Color(0xFFFFFFFF)
+ val secondaryContainerLight = Color(0xFFEEE2BC)
+ val onSecondaryContainerLight = Color(0xFF4E472A)
// 以下、大量の配色設定が続く

Theme.ktファイルなども同様に置き換えます。(ただしクラス名などが変わらないように注意します。)

Edge to Edge対応

Edge to Edgeとは、画面内に表示されているコンテンツを、システムのUI(たとえば、画面上のステータスバーなど)の背景にも表示させることを指します。 Android 15(SDK35)以降のバージョンではEdge to Edgeが強制されるため、対応が必要です。 以下のサイトにその対応について書かれています。

アプリでコンテンツをエッジ ツー エッジで表示し、Compose でウィンドウ インセットを処理する |  Android Developers

注意しなければならないのは、コンテンツの表示が広がったことで、そのコンテンツがその他のUIと干渉しないようにしなければならないということです。 たとえば今回のアプリでは、リストを使用しており以下のように実装しました。

LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(vertical = 12.dp)
                .consumeWindowInsets(paddingValues), // この行を追加
            contentPadding = paddingValues, 
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top
        ) {

            // 省略

            items(
                items = listUiState.shoppingMemoList,
                key = { memo -> memo.id.value }
            ) { memo ->
                ShoppingMemoListCell(
                    shoppingMemoSummary = memo,
                    onMemoCellClick = { onMemoCellClick(memo.id) }
                )
            }
            item {
                // 最後の要素がボタンに被らないように
                Spacer(
                    modifier = Modifier
                        .navigationBarsPadding()
                        .height(88.dp) // FABの高さ + パディング
                )
            }
        }

上記のコードでは、Modifier.consumeWindowInsets(paddingValues)を使用してウィンドウインセットを適切に管理することで、システムUIに重ならないようにコンテンツを配置しています。 また、リストの最後にはスペーサーを追加し、リストを一番下までスクロールした際に、下に配置しているボタンと被らないようにしています。

最後に

Jetpack Composeを用いたアプリ開発に挑戦し、その過程で得た学びを本記事にまとめました。 今回の実装を通じて、アプリケーションの開発には幅広い知識が求められることを改めて実感しました。 とくに、データベース設計の難しさを痛感し、この分野についてさらなる学習が必要であると感じています。 今後も継続して知識を深め、より良いアプリケーションを開発できるよう努力していきたいと思います。