Android kotlin Jetpack Compose + Room + ksp でAndroid Hiltを利用したアプリ作成1

Jetpack Compose、DIの勉強、備忘録用にアプリを作成しました。

# 1.プロジェクト構成

```
拡張子が付いてるもの以外はディレクトリを指してます。

app
│
├── application
│   └── MyApplication.kt
│
├── room
│   ├── AppDatabase.kt
│   ├── dao
│   │   ├── StoreDao.kt
│   │   └── UserDao.kt
│   ├── di
│   │   └── DatabaseModule.kt
│   ├── entity
│   │   ├── Store.kt
│   │   └── User.kt
│   └── repository
│       ├── RoomRepositoryModule.kt
│       ├── StoreRepository.kt
│       ├── StoreRepositoryImpl.kt
│       ├── UserRepository.kt
│       └── UserRepositoryImpl.kt
│
├── ui
│   ├── MainActivity.kt
│   ├── login
│   │   └── LoginScreen.kt
│   ├── store
│   │   └── StoreListScreen.kt
│   ├── navigation
│   │   ├── InventoryApp.kt
│   │   ├── InventoryNavHost.kt
│   │   └── NavigationDestination.kt
│   ├── store
│   │   └── StoreListScreen.kt
│   ├── theme
│   │   ├── Color.kt
│   │   ├── Theme.kt
│   │   └── Type.kt
│   ├── viewmodel
│   │   ├── LoginViewModel.kt
│   │   └── StoreViewModel.kt
│   └── views
│       ├── FloatingActionButton.kt
│       └── IndeterminateCircularIndicator.kt
│
└── utils
    ├── Constants.kt
    ├── StoreListSaver.kt
    └── Resource.kt
    
```


# 2.設定ファイル
2024/02/13時点
ルート直下の「build.gradle」(Project:プロジェクト名)
```build.gradle.kts

plugins {
    id("com.android.application") version "8.1.2" apply false
    id("org.jetbrains.kotlin.android") version "1.8.10" apply false

    id("com.google.dagger.hilt.android") version "2.49" apply false
    id("com.google.devtools.ksp") version "1.9.0-1.0.12" apply false

}
```

appファイル直下の「build.gradle」(Module : app)
```build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")

    id ("com.google.devtools.ksp")
    id ("com.google.dagger.hilt.android")

}

android {
    namespace = "com.jp"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.jp"
        minSdk = 21
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        //TODO This version (1.4.3) of the Compose Compiler requires Kotlin version 1.8.10 but you appear to be using Kotlin version 1.9.0 which is not known to be compatible.  Please consult the Compose-Kotlin compatibility map located at https://developer.android.com/jetpack/androidx/releases/compose-kotlin to choose a compatible version pair (or `suppressKotlinVersionCompatibilityCheck` but don't say I didn't warn you!).
//        kotlinCompilerExtensionVersion = "1.4.3"
        kotlinCompilerExtensionVersion = "1.5.1"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }

    //TODO add suzuki
    buildFeatures {
        viewBinding = true
        //noinspection DataBindingWithoutKapt
        dataBinding = true
    }

}

dependencies {

    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
    implementation("androidx.activity:activity-compose:1.8.2")
    implementation(platform("androidx.compose:compose-bom:2023.03.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")

    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////
    //TODO add
    // FRAGMENT Hilt viewModels()
    implementation ("androidx.fragment:fragment-ktx:1.6.2")

    // Hilt
    val hiltVersion = "2.49"
    implementation ("com.google.dagger:hilt-android:$hiltVersion")
    //TODO 通常のJavaまたはKotlinプロジェクトでHiltコード生成を行うためのコンパイラ依存ライブラリ
//    ksp ("com.google.dagger:hilt-compiler:$hiltVersion")
    //TODO Android向けのHiltコード生成を行うためのコンパイラ依存ライブラリ
    ksp ("com.google.dagger:hilt-android-compiler:$hiltVersion")
//    implementation("androidx.hilt:hilt-navigation-fragment:1.1.0")
    implementation ("androidx.hilt:hilt-navigation-compose:1.1.0")
    implementation ("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
    implementation ("androidx.navigation:navigation-compose:2.7.7")


    // ROOM
    val roomVersion = "2.6.1"
    implementation ("androidx.room:room-ktx:$roomVersion")
    implementation ("androidx.room:room-runtime:$roomVersion")
    ksp ("androidx.room:room-compiler:$roomVersion")

    //Coroutines コルーチン
    implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")


    // RETROFIT
    val retrofit_version ="2.9.0"
    implementation ("com.squareup.retrofit2:retrofit:$retrofit_version")
    implementation ("com.squareup.retrofit2:converter-gson:$retrofit_version")
    implementation ("com.squareup.okhttp3:logging-interceptor:4.12.0")
    implementation ("com.squareup.retrofit2:converter-scalars:$retrofit_version")
    implementation ("com.squareup.retrofit2:converter-moshi:$retrofit_version")


    // MOSHI
    implementation("com.squareup.moshi:moshi-kotlin:1.15.0")

    // PAGING
    val paging_version: String = "3.2.1"
    implementation ("androidx.paging:paging-runtime:$paging_version")
    implementation ("androidx.paging:paging-compose:$paging_version")


    // CARDVIEW
    implementation("androidx.cardview:cardview:1.0.0")

}

```

```AndroidManifest.xml
これは一部、抜粋です。
    <application
        //ここにapplicationクラスを定義する
        android:name=".application.MyApplication"
        >
        //割愛
    </application>

```


# 3.実装

entityクラス（テーブル定義）です。
ログイン画面を想定した、ユーザー名、パスワードのカラムを持つentityクラスです。
店舗一覧に表示するデータのentityクラスです。（店舗名のみですが表示したい情報を適宜追加して下さい）
```User.kt
@Entity(tableName = "user")
data class User(

    @PrimaryKey(autoGenerate = false)
    val id: Int,

    @ColumnInfo(name = "account")
    val account: String,

    @ColumnInfo(name = "password")
    val password: String

)
```

```Store.kt
@Entity(tableName = "store")
data class Store(
    @PrimaryKey(autoGenerate = true)
    var id: Int = 0,

    @ColumnInfo(name = "store_name")
    var storeName: String = "",


)
```

Daoクラス（クエリーを実行するメソッド）です。
Android Hiltを利用し、Dao、Repository、RepositoryImplの3ファイルで構成しています。
```StoreDao.kt
@Dao
interface StoreDao {

    /**
     * 追加（1件）
     */
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertStore(
        store: Store
    ): Long


    /**
     * 全件取得
     */
    @Query("SELECT * FROM store ")
    suspend fun getStore(): List<Store>

}
```


RepositoryとDaoをどのように実装すれば良いのかChatGPTに質問した結果、以下の回答が返ってきました。
```
【メリット】
StoreRepositoryがStoreDaoを直接継承することによるメリットは、主に以下の点が挙げられます：

1.シンプルなコード構造:
StoreRepositoryがStoreDaoを継承することで、StoreRepositoryには直接StoreDaoのメソッドが組み込まれます。
これにより、StoreRepository内で追加のラッパーメソッドを作成する必要がなく、コードがシンプルになります。

2.直感的な使用:
StoreRepositoryを利用する開発者が、StoreDaoのメソッドを直接呼び出せるため、使用方法が直感的です。
クライアントコードは、StoreRepositoryを通してデータベース操作を行うことが期待されるため、コードが理解しやすくなります。

3.一貫性と適合性:
StoreRepositoryがStoreDaoを継承することで、StoreRepositoryにおいても同じメソッドが提供され、
データベースへのアクセスが一貫しています。これにより、コードベース全体でのメソッドの適合性が向上します。

4.拡張性:
将来的に新しいメソッドがStoreDaoに追加された場合、StoreRepositoryも自動的にそれらの変更を受け入れることができます。
クライアントコードは変更を意識せずに、既存のStoreRepositoryをそのまま利用できます。

5.テスト容易性:
ユニットテストや統合テストが行いやすくなります。StoreRepositoryのテストでは、
StoreDaoのメソッドの呼び出しをモックやスタブとして簡単に置き換えることができます。
これらのメリットは、コードのメンテナンス性、可読性、拡張性に寄与し、開発者がシームレスにデータベース操作を行えるようになります。

【デメリット】
StoreRepositoryがStoreDaoを継承することで、StoreRepositoryはStoreDaoの全てのメソッドを持ってしまいます。
これは、StoreRepositoryにとって不要なメソッドが増える可能性があります。
```

上記内容から、今回はRepositoryにDaoを継承した実装方法にしました。
```StoreRepository.kt
interface StoreRepository : StoreDao {

}
```


```StoreRepositoryImpl.kt
@Singleton
class StoreRepositoryImpl @Inject constructor(
    private val storeDao: StoreDao,

) : StoreRepository {

    /**
     * 追加（1件）
     */
    override suspend fun insertStore(store: Store): Long {
        return storeDao.insertStore(store = store)
    }


    /**
     * 全件取得
     */
    override suspend fun getStore(): List<Store> {
        return storeDao.getStore()
    }

}
```


```UserDao.kt
@Dao
interface UserDao {

    /**
     * ログインアカウント検索
     */
    @Query("SELECT * FROM user WHERE account = :account AND password = :password")
    suspend fun getUser(
        account: String,
        password: String
    ): List<User>

}
```


```UserRepository.kt
interface UserRepository : UserDao {

}
```


```UserRepositoryImpl.kt
@Singleton
class UserRepositoryImpl @Inject constructor(
    private val userDao: UserDao,

) : UserRepository {


    /**
     * ログインアカウント検索
     */
    override suspend fun getUser(account: String, password: String): List<User> {
        return userDao.getUser(account = account, password = password)
    }

}
```

UserSecondRepositoryクラスをバインドするクラスになります。
アノテーションがBindsのクラスです。
```RoomRepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RoomRepositoryModule {

    @Binds
    abstract fun provideUserRepository(
        userRepositoryImpl: UserRepositoryImpl

    ): UserRepository

    @Binds
    abstract fun provideStoreRepository(
        storeRepositoryImpl: StoreRepositoryImpl

    ): StoreRepository

}
```


RoomDatabaseのクラスです。
```AppDatabase.kt
/**
 * Databaseクラス
 */
@Database(
    entities = [
        User::class,
        Store::class,
    ],
    version = AppDatabase.DATABASE_VERSION

)
abstract class AppDatabase() : RoomDatabase() {

    /**
     * TODO テーブル追加、カラム追加した場合は、DATABASE_VERSIONを上げる
     */
    companion object {
        // java.lang.IllegalStateException: Room cannot verify the data integrity.
        // Looks like you've changed schema but forgot to update the version number.
        // You can simply fix this by increasing the version number
        //TODO このエラーが出た場合、DBバージョンを上げる
        const val DATABASE_VERSION: Int = 1

    }

    //////////////////////////////////////////////////////////////
    //DAO
    //////////////////////////////////////////////////////////////
    abstract fun userDao(): UserDao
    abstract fun storeDao(): StoreDao

}
```


Roomのモジュール（データベース生成、DAO生成）です。
アノテーションがModuleのクラスです。（このアプリでは、objectにしています。）
```DatabaseModule.kt
@InstallIn(SingletonComponent::class)
@Module
object DatabaseModule {

    /**
     * DB 生成
     */
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "app_database"
        )
            // Wipes and rebuilds instead of migrating if no Migration object.
            // Migration is not part of this codelab.
            .fallbackToDestructiveMigration()
            .build()
    }


    ////////////////////////////////////////////////////////////////////////
    // DAO生成
    ////////////////////////////////////////////////////////////////////////
    /**
     * UserDao 生成
     */
    @Provides
    @Singleton
    fun provideUserDao(database: AppDatabase): UserDao {
        return database.userDao()
    }

    /**
     * StoreDao 生成
     */
    @Provides
    @Singleton
    fun provideStoreDao(database: AppDatabase): StoreDao {
        return database.storeDao()
    }

}
```

ViewModelクラスです。DIを利用したRepositoryの実装方法です。
```LoginViewModel.kt
@HiltViewModel
class LoginViewModel @Inject constructor(
    private val userRepository: UserRepository,

) : ViewModel() {



    companion object {
        private val TAG: String = LoginViewModel::class.simpleName.toString()
    }


    //一般的に、クラスのカプセル化を意識して、MutableLiveDataプロパティを非公開にし、LiveDataプロパティのみを公開します。
    //StateFlow
    private val _myUiState: MutableStateFlow<Resource<List<User>>>
            = MutableStateFlow(Resource.Loading(isLoading = false))
    val myUiState: StateFlow<Resource<List<User>>>
        get() = _myUiState.asStateFlow()


    /**
     * アカウント検索処理
     */
    fun searchUser(account: String, password: String){
        viewModelScope.launch(Dispatchers.IO) {
            Log.d(TAG, "###   searchUser start   ###")
            try {
                _myUiState.value = Resource.Loading(isLoading = true)
                Log.d(TAG, "###   account : $account, password : $password   ###")

                //TODO 遅延処理（インジケーター表示を確認する為）
                delay(1500L)

                val user = userRepository.getUser(account = account, password = password)
                _myUiState.value = Resource.Success(user)

            }catch (error: Exception){
                Log.d(TAG, "###   error.toString : $error   ###")
                _myUiState.value = Resource.Error(message = error.toString())

            }
        }
    }

}
```


```StoreViewModel.kt
@HiltViewModel
class StoreViewModel @Inject constructor(
    private val storeRepository: StoreRepository,

) : ViewModel() {

    companion object {
        private val TAG: String = StoreViewModel::class.simpleName.toString()
    }


    //一般的に、クラスのカプセル化を意識して、MutableLiveDataプロパティを非公開にし、LiveDataプロパティのみを公開します。
    //StateFlow
    private val _myUiState: MutableStateFlow<Resource<List<Store>>>
            = MutableStateFlow(Resource.Loading(isLoading = false))
    val myUiState: StateFlow<Resource<List<Store>>>
        get() = _myUiState.asStateFlow()

    /**
     * 店舗追加
     */
    fun addStore(storeName: String){
        viewModelScope.launch {
            try {
                _myUiState.value = Resource.Loading(isLoading = true)

                val store = Store().also {
                    it.id = 0
                    it.storeName = storeName
                }

                storeRepository.insertStore(store)

            } catch (error: Exception) {
                _myUiState.value = Resource.Error(message = error.toString())

            }

        }
    }

    /**
     * 店舗一覧取得処理
     */
    fun selectStoreList(){
        viewModelScope.launch {
            try {
                _myUiState.value = Resource.Loading(isLoading = true)

                val storeList = storeRepository.getStore()
                _myUiState.value = Resource.Success(storeList)

            } catch (error: Exception) {
                _myUiState.value = Resource.Error(message = error.toString())

            }

        }
    }

}
```

長くなりそうなので、今回はここまでとなります。
次回は、画面とViewModelクラスを実装に関して別のページで説明します。
続きのページはこちらです。

https://qiita.com/android_aim_for_the_top/items/d10e4a3ed0edcb05f8e0

以上で説明は終わりになります。
ご意見ご要望があれば、お気軽にお知らせください。