全体構成
Jetpack Compose を利用したモダン Android アプリ開発において、
新規プロジェクト作成直後にまとめて導入しておきたい主要ライブラリと、その設定手順を整理します。
毎回の環境構築で迷わないための、テンプレートとして利用できる構成です。
対象技術スタック:
- Jetpack Compose
- Hilt(依存性注入)
- Room(ローカルDB)
- Retrofit(API通信)
- Moshi(JSON変換)
- KSP(アノテーション処理)
- libs.versions.toml(バージョンカタログ)
- MockK / MockWebServer(テスト)
ディレクトリ構成例:
data/
local/
AppDatabase.kt
Migrations.kt
UserDao.kt
UserEntity.kt
UserEntityMapper.kt
remote/
UserApi.kt
UserResponse.kt
UserResponseMapper.kt
repository/
UserRepository.kt
domain/
model/
User.kt
repository/
Repository.kt
di/
DatabaseModule.kt
NetworkModule.kt
ui/
user/
ResetDialog.kt
UserContent.kt
UserList.kt
UserScreen.kt
UserUiState.kt
UserViewModel.kt
1. 新規プロジェクト作成
Android Studio で新規プロジェクトを作成します。
- Android Studio で New Project → Empty Activity
- Language: Kotlin
- Minimum SDK: 24以上
- Use Kotlin DSL(
build.gradle.kts)を使用 - Composeを有効化
2. libs.versions.toml 設定
gradle/libs.versions.toml を編集し、使用するライブラリとバージョンを定義します。
versions
[versions]
ksp = "2.3.6"
hilt = "2.59.1"
hiltNavigation = "1.3.0"
room = "2.8.4"
moshi = "1.15.2"
retrofit = "3.0.0"
loggingInterceptor = "5.3.2"
mockk = "1.14.9"
mockwebserver = "5.3.2"
kotlinxCoroutinesTest = "1.10.2"
robolectric = "4.16.1"
libraries
[libraries]
# Hilt
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" }
hilt-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
# Room
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
# Moshi
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
# Retrofit
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
# OkHttp Logging
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
# Test
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
group版
[libraries]
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" }
hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
# Moshi
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
# Retrofit
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
# OkHttp Logging
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" }
# Test
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
plugins
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room", version.ref = "room" }
3. Project/build.gradle.kts 設定
Gradle プラグインを追加します。
plugins
plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.room) apply false
}
4. app/build.gradle.kts 設定
plugins
plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.room)
}
dependencies
dependencies {
// Hilt
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
ksp(libs.hilt.compiler)
androidTestImplementation(libs.hilt.testing)
kspAndroidTest(libs.hilt.compiler)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
androidTestImplementation(libs.room.testing)
// Moshi
implementation(libs.moshi)
implementation(libs.moshi.kotlin)
ksp(libs.moshi.codegen)
// Retrofit
implementation(libs.retrofit)
implementation(libs.retrofit.moshi)
// OkHttp Logging
implementation(libs.logging.interceptor)
// Test
testImplementation(libs.mockk)
testImplementation(libs.mockwebserver)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
}
(AGB8以降) BuildConfig 生成設定
HTTP通信ログ出力を切り替えるために BuildConfig を参照していますが、
AGP8から BuildConfig がデフォルトでは生成されなくなりました。
そのため、buildFeatures 内に buildConfig = true を追加します。
android {
buildFeatures {
+ buildConfig = true
compose = true
}
}
Room スキーマ出力設定
Build時に app/schemas/ へデータベースの JSON スキーマが生成されるようにします。
これはマイグレーションテストに必須です。
room {
schemaDirectory("$projectDir/schemas")
}
kotlinx-serialization 適用バージョンの強制
Migrationテスト実行時に java.lang.AbstractMethodError が発生する場合に必要な対応です。
Room の JSON スキーマ読み込み時に使用される kotlinx-serialization において、不具合で古いバージョン(1.7.3)が選択される場合があります。
この状態でシリアライザが実行されると、ランタイムとの不整合により AbstractMethodError が発生します。
その場合、app/kotlin:build.gradle.ktsにてバージョンを強制することで回避できます。
発生するエラー:
java.lang.AbstractMethodError: abstract method "kotlinx.serialization.KSerializer[] kotlinx.serialization.internal.GeneratedSerializer.typeParametersSerializers()"
追加するコード:
configurations.all {
resolutionStrategy.eachDependency {
val serdeVer = "1.8.0"
when(requested.module.toString()) {
"org.jetbrains.kotlinx:kotlinx-serialization-json" -> useVersion(serdeVer)
"org.jetbrains.kotlinx:kotlinx-serialization-json-jvm" -> useVersion(serdeVer)
"org.jetbrains.kotlinx:kotlinx-serialization-core" -> useVersion(serdeVer)
"org.jetbrains.kotlinx:kotlinx-serialization-core-jvm" -> useVersion(serdeVer)
"org.jetbrains.kotlinx:kotlinx-serialization-bom" -> useVersion(serdeVer)
}
}
}
元ネタ:
https://github.com/Kotlin/kotlinx.serialization/issues/2968#issuecomment-3356075918
5. Hilt 初期設定
自動インポート機能を適切に動作させるため、ここに入る前に
Sync Project with Gradle Files を実行してください。
Application の作成
@HiltAndroidApp を付与することで、アプリケーションレベルの依存関係コンテナ(SingletonComponent)が生成されます。
これにより、@Inject や @Module で定義した依存関係をアプリ全体で利用できるようになります。
@HiltAndroidApp
class MyApplication: Application()
AndroidManifest.xml に追加:
<application
android:name=".MyApplication"
Activity の変更
+ @AndroidEntryPoint
class MainActivity : ComponentActivity()
ViewModel での利用
ViewModel で Hilt を利用する場合は、
@HiltViewModel を付与し、コンストラクタインジェクションを使用します。
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel()
6. Room 初期設定
Entity、DAO、Database を定義します。
また、必要に応じて Maigration を作成します。
Entity
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: Int,
val name: String,
val email: String
)
DAO
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun observeAll(): Flow<List<UserEntity>>
@Query("SELECT * FROM users WHERE id = :id")
suspend fun get(id: Int): List<UserEntity>
@Query("SELECT * FROM users")
suspend fun getAll(): List<UserEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: UserEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(users: List<UserEntity>)
@Delete
suspend fun delete(user: UserEntity)
@Query("DELETE FROM users")
suspend fun deleteAll()
@Transaction
suspend fun replace(items: List<UserEntity>) {
deleteAll()
insertAll(items)
}
}
Database
@Database(
entities = [
UserEntity::class,
// 追加のEntityを記述
],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Migrations
val ALL_MIGRATIONS: Array<Migration> = arrayOf(
// 必要に応じてマイグレーションを追加
)
7. Moshi + Retrofit 設定
DTOに@JsonClass(generateAdapter = true)を付与することで、
KSP により JSON Adapter が自動生成されます。
DTO (Response Model)
@JsonClass(generateAdapter = true)
data class UserResponse(
@property:Json(name = "id")
val id: Int,
@property:Json(name = "username")
val name: String,
@property:Json(name = "email")
val email: String
)
- JSON ↔ Kotlin 変換は Moshi
- Adapter 生成は KSP
- DTO は Domain や DB に依存しない
API定義 (Retrofit)
interface UserApi {
@GET("users")
suspend fun getUsers(): List<UserResponse>
@GET("users/{id}")
suspend fun getUser(
@Path("id") id: Int
): UserResponse
}
8. Domain Model とマッピング
data class User (
val id: Int,
val name: String,
val email: String,
)
Local ↔ Domain
fun UserEntity.toDomain(): User = User(id, name, email)
fun List<UserEntity>.toDomain() = map { it.toDomain() }
fun User.toEntity(): UserEntity = UserEntity(id, name, email)
fun List<User>.toEntity() = map { it.toEntity() }
Remote ↔ Domain
fun UserResponse.toDomain(): User = User(id, name, email)
fun List<UserResponse>.toDomain() = map { it.toDomain() }
リポジトリ
アプリケーションでの データ取得や保存の責務 を集約するのが Repository です。
リモートAPI と ローカルDB の橋渡し役となり、データアクセスの詳細を内部に隠蔽します。
UI や UseCase は Repository を介して Domain Model を扱うため、
アプリケーション層が具体的な DB や API の実装に依存せず済みます。
インタフェース (Domain層)
interface Repository<T> {
fun observeAll(): Flow<List<T>>
suspend fun getAll(): List<T>
suspend fun get(id: Int): T
suspend fun save(item: T)
suspend fun saveAll(items: List<T>)
suspend fun deleteAll()
}
実装例 (DB + API 橋渡し)
@Singleton
class UserRepository @Inject constructor(
private val userApi: UserApi,
private val userDao: UserDao
): Repository<User> {
/** ローカルDBの全ユーザーを監視するFlowを返す */
override fun observeAll(): Flow<List<User>> =
userDao.observeAll().map { it.toDomain() }
/** 指定IDのユーザーをAPIから取得し、ローカルDBへ保存したうえで返却する */
override suspend fun get(id: Int): User =
userApi.getUser(id).toDomain()
.also { save(it) }
/** APIから全ユーザーを取得し、ローカルDBを完全に置き換えて同期する */
override suspend fun getAll(): List<User> =
userApi.getUsers().toDomain().also {
userDao.replace(it.toEntity())
}
override suspend fun save(item: User) = userDao.insert(item.toEntity())
override suspend fun saveAll(items: List<User>) = userDao.insertAll(items.toEntity())
override suspend fun delete(item: User) = userDao.delete(item.toEntity())
override suspend fun deleteAll() = userDao.deleteAll()
}
10. Hilt Modules
DatabaseModule
リポジトリが利用する DAO を提供します。
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase =
Room.databaseBuilder(
context,
AppDatabase::class.java,
"app.db"
)
.addMigrations(*ALL_MIGRATIONS)
.build()
@Provides
fun provideUserDao(
database: AppDatabase
): UserDao = database.userDao()
}
NetworkModule
リポジトリが利用する API を提供します。
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideMoshi(): Moshi =
Moshi.Builder().build()
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
@Provides
@Singleton
fun provideOkHttpClient(
logging: HttpLoggingInterceptor
): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(logging)
.build()
@Provides
@Singleton
fun provideRetrofit(
moshi: Moshi,
client: OkHttpClient
): Retrofit =
Retrofit.Builder()
.baseUrl("https://example.com/")
.client(client)
.addConverterFactory(
MoshiConverterFactory.create(moshi)
)
.build()
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi =
retrofit.create(UserApi::class.java)
}
BaseUrl が複数ある場合は Qualifier を作ります。
BaseUrl が複数ある場合のコード例
BaseUrl が複数ある場合のコード例
例:
Main API → https://api.example.com/
Auth API → https://auth.example.com/
① Qualifier を作る
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class MainApi
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthApi
② Hilt Module で分ける
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder().build()
@MainApi
@Provides
@Singleton
fun provideMainRetrofit(
client: OkHttpClient
): Retrofit =
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
@AuthApi
@Provides
@Singleton
fun provideAuthRetrofit(
client: OkHttpClient
): Retrofit =
Retrofit.Builder()
.baseUrl("https://auth.example.com/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
}
③ API を分ける
@Provides
@Singleton
fun provideUserApi(
@MainApi retrofit: Retrofit
): UserApi =
retrofit.create(UserApi::class.java)
@Provides
@Singleton
fun provideAuthApi(
@AuthApi retrofit: Retrofit
): AuthApi =
retrofit.create(AuthApi::class.java)
11. テスト環境の構築
- インメモリ DB + モック API で安全にテスト
- Hilt
@TestInstallInで本番 Module を置き換え
テスト用の Module を作成
TestDatabaseModule (インメモリ DB)
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DatabaseModule::class]
)
object TestDatabaseModule {
@Provides
@Singleton
fun provideInMemoryDatabase(
@ApplicationContext context: Context
): AppDatabase =
Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
)
.allowMainThreadQueries() // テスト用にメインスレッド許可
.build()
@Provides
fun provideUserDao(
database: AppDatabase
): UserDao = database.userDao()
}
TestNetworkModule (モック API)
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [NetworkModule::class]
)
object TestNetworkModule {
@Provides
@Singleton
fun provideMockApi(): UserApi = object : UserApi {
override suspend fun getUsers(): List<UserResponse> =
listOf(
UserResponse(1, "Alice", "alice@example.com"),
UserResponse(2, "Bob", "bob@example.com"),
UserResponse(3, "Charlie", "charlie@example.com")
)
override suspend fun getUser(id: Int): UserResponse =
UserResponse(id, "User $id", "user$id@example.com")
}
}
Hilt DI テストランナーの設定
androidTest に Runner を作成
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
androidTest の Runner を変更
パッケージ名は実装に合わせます。
android {
defaultConfig {
testInstrumentationRunner = "com.example.CustomTestRunner"
}
}
12. テスト
Hilt DI テスト
@HiltAndroidTest
class HiltInjectionTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var userRepository: UserRepository
@Inject
lateinit var userApi: UserApi
@Inject
lateinit var userDao: UserDao
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun diInjectionWorks() {
assertNotNull(userRepository)
assertNotNull(userApi)
assertNotNull(userDao)
}
@Test
fun apiReturnsMockData() = runTest {
val users = userRepository.getAll()
assert(users.isNotEmpty())
assertEquals("Alice", users.first().name)
}
@Test
fun daoCanInsertAndRead() = runTest {
val user = UserEntity(1, "Alice", "alice@example.com")
userDao.insert(user)
val flow = userDao.observeAll().first()
assert(flow.isNotEmpty())
assertEquals("Alice", flow.first().name)
}
}
Room DAO テスト
@OptIn(ExperimentalCoroutinesApi::class)
class UserDaoTest {
private lateinit var database: AppDatabase
private lateinit var userDao: UserDao
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
)
.allowMainThreadQueries()
.build()
userDao = database.userDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun insertUser_andGetAll_returnsInsertedUser() = runTest {
val user = UserEntity(
id = 1,
name = "Alice",
email = "alice@example.com"
)
userDao.insert(user)
val users = userDao.getAll()
TestCase.assertEquals(1, users.size)
TestCase.assertEquals("Alice", users.first().name)
TestCase.assertEquals("alice@example.com", users.first().email)
}
}
Room Migration テスト
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val testDatabase = "migration-test"
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migrateAll() {
// version 1 のDBを作成
val db = helper.createDatabase(testDatabase, 1)
db.execSQL("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com')")
db.close()
// マイグレーション
val migratedDb = helper.runMigrationsAndValidate(
testDatabase,
2, // 最新version
true,
*ALL_MIGRATIONS
)
// データ検証
migratedDb.query("SELECT * FROM users WHERE id = 1").also {
assertEquals(1, it.count)
it.moveToFirst()
assertEquals("Alice", it.getString(it.getColumnIndexOrThrow("name")))
it.close()
}
migratedDb.close()
}
}
適切なバージョンの Migration が存在しない場合:java.lang.IllegalStateException
スキーマ JSON が存在しない場合:java.io.FileNotFoundException
Retrofit API 単体テスト (MockK を利用)
class UserApiUnitTest {
private lateinit var api: UserApi
@Before
fun setup() {
api = mockk()
}
@Test
fun getUsers_givenSuccess_returnsExpectedUsers() = runTest {
coEvery { api.getUsers() } returns listOf(
UserResponse(1, "Alice", "alice@example.com"),
UserResponse(2, "Bob", "bob@example.com")
)
val result = api.getUsers()
assertEquals(2, result.size)
assertEquals(1, result[0].id)
assertEquals("Alice", result[0].name)
assertEquals(2, result[1].id)
assertEquals("Bob", result[1].name)
coVerify { api.getUsers() }
}
@Test
fun getUser_givenValidId_returnsExpectedUserResponse() = runTest {
coEvery { api.getUser(1) } returns UserResponse(1, "Alice","alice@example.com")
val result = api.getUser(1)
assertEquals(1, result.id)
assertEquals("Alice", result.name)
assertEquals("alice@example.com", result.email)
coVerify { api.getUser(1) }
}
}
Retrofit API 結合テスト (MockWebServer を利用)
class UserApiIntegrationTest {
private lateinit var server: MockWebServer
private lateinit var api: UserApi
@Before
fun setup() {
server = MockWebServer()
server.start()
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(MoshiConverterFactory.create())
.build()
api = retrofit.create(UserApi::class.java)
}
@After
fun teardown() {
server.close()
}
@Test
fun getUser_whenServerReturns200_parsesCorrectly() = runTest {
server.enqueue(
MockResponse(
code = 200,
body = """{"id":1,"username":"Alice","email":"alice@example.com"}"""
)
)
val result = api.getUser(1)
assertEquals(1, result.id)
assertEquals("Alice", result.name)
assertEquals("alice@example.com", result.email)
}
@Test
fun getUsers_whenServerReturns200_parsesCorrectly() = runTest {
server.enqueue(
MockResponse(
code = 200,
body = """[
{"id":1,"username":"Alice","email":"alice@example.com"},
{"id":2,"username":"Bob","email":"bob@example.com"}
]"""
)
)
val result = api.getUsers()
assertEquals(2, result.size)
assertEquals("Alice", result[0].name)
assertEquals("Bob", result[1].name)
}
@Test
fun getUser_whenServerReturns404_throwsException() = runTest {
server.enqueue(MockResponse(code = 404))
try {
api.getUser(1)
fail("Expected an exception")
} catch (e: Exception) {
assertEquals("HTTP 404 Client Error", e.message)
}
}
@Test
fun getUser_whenInvalidJson_throwsException() = runTest {
server.enqueue(
MockResponse(
code = 200,
body = """{"id":"abc","username":123}""" // 型が不正
)
)
try {
api.getUser(1)
fail("Expected an exception")
} catch (e: Exception) {
// MoshiやRetrofitが変換エラーを投げる
assertTrue(e is retrofit2.HttpException || e is com.squareup.moshi.JsonDataException)
}
}
}
13. UI (おまけ)
- Compose + ViewModel 連携
- UIState とイベントハンドラのみを受け取る構造でテスト容易
-
UserScreen/UserContent/ResetDialogで分離
本筋から外れる、かつ長いので見たい方だけ開いてください。
Jetpack Compose を利用したモダン Android アプリ開発では、UI を ViewModel に依存させず、UI State とイベントハンドラのみを受け取る構造にすると、テストやプレビューがしやすくなります。
ここでは UserContent の構築例と UIState 連携のベストプラクティスを紹介します。
UI State の定義
UIState は ViewModel が UI に渡す「画面の状態」をまとめたデータクラスです。
UI はこの State を受け取るだけで描画できるようにします。
data class UserUIState(
val isLoading: Boolean = false,
val users: List<User> = emptyList(),
val errorMessage: String? = null
)
Content Composable
UserContent は ViewModel から渡される UserUIState と、ユーザー操作用のイベントリスナを受け取ります。
@Composable
fun UserContent(
uiState: UserUIState,
onRefreshUsers: () -> Unit,
showResetUsersDialog: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(64.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 6.dp
)
}
!uiState.errorMessage.isNullOrEmpty() -> {
// エラー表示
Text(
text = uiState.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
UserList(
users = uiState.users,
modifier = Modifier
.fillMaxSize()
.padding(bottom = 72.dp) // 更新ボタン分のスペース
)
}
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
) {
// 更新ボタン
Button(
enabled = !uiState.isLoading,
onClick = onRefreshUsers,
modifier = Modifier
.weight(2f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("更新", color = MaterialTheme.colorScheme.onPrimary)
}
Spacer(modifier = Modifier.width(6.dp))
// 削除ボタン
Button(
enabled = !uiState.isLoading,
onClick = showResetUsersDialog,
modifier = Modifier
.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("削除", color = MaterialTheme.colorScheme.onError)
}
}
}
}
Preview
@Preview で動作を確認可能です。
@Preview(
showBackground = true,
heightDp = 800,
widthDp = 400
)
@Composable
fun UserContentPreview() {
val uiState = UserUIState(
isLoading = false,
errorMessage = "エラーです",
users = listOf(
User(1, "Alice", "alice@example.com"),
User(2, "Bob", "bob@example.com"),
User(3, "Charlie", "charlie@example.com")
)
)
MaterialTheme {
UserContent(
uiState,
{},
{}
)
}
}
List Composable
ユーザーをリスト表示するコンポーネント。
リスト部分は別 Composable に分離し、UIState とは疎結合にします。
@Composable
fun UserList(
users: List<User>,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(users) { user ->
Card(
modifier = Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
) {
// 名前
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
// アドレス
Text(
text = user.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
Screen
UserScreen は UserViewModel と UserContent の橋渡しをします。
また、ダイアログ表示の管理も行うことができます。
@Composable
fun UserScreen(
modifier: Modifier = Modifier,
viewModel: UserViewModel = hiltViewModel()
) {
// ViewModel から UIState を取得
val uiState by viewModel.uiState.collectAsState()
// ダイアログ表示フラグを Compose 側で保持
var showResetUsersDialog by remember { mutableStateOf(false) }
UserContent(
uiState = uiState,
onRefreshUsers = viewModel::refreshUsers,
showResetUsersDialog = { showResetUsersDialog = true },
modifier = modifier
)
// リセット確認ダイアログ
ResetDialog(
isShown = showResetUsersDialog,
onDismiss = { showResetUsersDialog = false },
onReset = viewModel::resetUsers
)
}
Dialog Composable
ダイアログを独立した Composable にすることで、Content と UIロジックの分離 が可能です。
再利用も簡単になり、テストやプレビューも容易です。
@Composable
fun ResetDialog(
isShown: Boolean,
onDismiss: () -> Unit,
onReset: () -> Unit
) {
if (!isShown) return
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = "Reset Users")
},
text = {
Text(text = "ユーザーデータをリセットしますか?")
},
confirmButton = {
TextButton(onClick = {
onReset()
onDismiss()
}) {
Text(text = "はい")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = "いいえ")
}
}
)
}
ViewModel
ViewModel は UIState を StateFlow で公開します。
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository
): ViewModel() {
private var _uiState = MutableStateFlow(UserUIState())
val uiState: StateFlow<UserUIState> = _uiState.asStateFlow()
init {
// DB を監視する
viewModelScope.launch {
repository.observeAll()
.onStart { _uiState.update { it.copy(isLoading = true) } }
.catch { e ->
// Flow 例外時の処理
_uiState.update {
it.copy(errorMessage = e.message ?: "不明なエラー")
}
}
.collect { users ->
_uiState.update { UserUIState(users = users) }
}
}
}
/** ユーザー取得をリトライ/更新 */
fun refreshUsers() {
if (_uiState.value.isLoading) return
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
runCatching { repository.getAll() }
.onFailure { e ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = e.message ?: "不明なエラー"
)
}
}
}
}
/** ユーザーをすべて削除 */
fun resetUsers() {
if (_uiState.value.isLoading) return
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
runCatching { repository.deleteAll() }
.onFailure { e ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = e.message ?: "削除エラー"
)
}
}
}
}
}
MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// UserScreen
UserScreen(modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
}
}
}
}
}
以上で終了ですが、ここまでで十分に実行できるはずです。
そのままだとURLがダミーのため HTTP 404 エラーとなりますが、
NetworkModule.provideRetrofit の baseUrl を下記サイトにすると動作を試せます。
📚 公式リンク集
🎨 Jetpack Compose
Jetpack Compose 公式
https://developer.android.com/jetpack/compose
Compose Pathway(公式チュートリアル)
https://developer.android.com/courses/pathways/compose
Material3 for Compose
https://developer.android.com/jetpack/compose/designsystems/material3
🧩 Hilt
Hilt 公式ドキュメント
https://developer.android.com/training/dependency-injection/hilt-android
Dagger / Hilt GitHub
https://github.com/google/dagger
Hilt を使用した依存関係挿入 | App architecture | Android Developers
https://developer.android.com/training/dependency-injection/hilt-android?hl=ja
🗄 Room
Room 公式
https://developer.android.com/training/data-storage/room
Room Migration 公式
https://developer.android.com/training/data-storage/room/migrating-db-versions
🌐 Retrofit
Retrofit GitHub(公式)
https://github.com/square/retrofit
Retrofit 公式サイト
https://square.github.io/retrofit/
🔎 Moshi
Moshi GitHub
https://github.com/square/moshi
Moshi Codegen 公式
https://github.com/square/moshi#codegen
⚙ KSP(Kotlin Symbol Processing)
KSP GitHub
https://github.com/google/ksp
KSP Overview
https://kotlinlang.org/docs/ksp-overview.html
🧪 MockK
MockK GitHub
https://github.com/mockk/mockk
MockK Guidebook
https://mockk.io/
🛰 MockWebServer
MockWebServer GitHub
https://github.com/square/okhttp/tree/master/mockwebserver