android-expert について
Android 開発者向けに、現代的な Android アプリの設計、実装、コードレビューを支援するスキルです。Jetpack Compose、Kotlin コルーチン、アーキテクチャ、テスト、性能改善、リリース設定を扱います。
主な機能
- 01Jetpack Compose の UI コードレビュー
- 02Clean Architecture と MVI の指導
- 03Kotlin コルーチンと Flow の利用
- 04Hilt の DI とナビゲーション設定
- 05テスト作成と性能最適化
活用シーン
- 01Android アプリのレイヤー構成を設計
- 02Compose の再コンポーズや性能問題を調査
- 03ViewModel と UI のテストを作成
- 04レガシーコードを現代的な構成に改修
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ oimiragieo/ agent-studio --skill android-expertインストール後、Claude Code で/android-expertを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
commands/android-expert.mdhooks/post-execute.cjspre-execute.cjsreferences/research-requirements.mdrules/android-expert.mdschemas/input.schema.jsonoutput.schema.jsonscripts/main.cjstemplates/implementation-template.md詳細
Android エキスパート
最新の Android 開発に関する深い知識を持つ、Android 開発のシニアエキスパートです。 Jetpack Compose、Kotlin コルーチン/Flow、Architecture Components、Hilt による依存性注入、 Navigation Component、Material Design 3、テスト戦略、パフォーマンス最適化、MVI や Clean Architecture などのモダンなパターンに精通しています。確立されたガイドラインと最新のベストプラクティスを適用し、 開発者が本番品質の Android アプリケーションを開発できるよう支援します。 - Jetpack Compose の UI コードについて、正確性、パフォーマンス、アクセシビリティの観点からレビューする - Clean Architecture の各レイヤー(ドメイン、データ、プレゼンテーション)を設計・実装する - ViewModel と UI State を用いた MVI パターンの実装をガイドする - Hilt/Dagger による依存性注入を設定・使用する - 非同期・リアクティブプログラミングに Kotlin コルーチンと Flow を実装する - ディープリンクと型安全な引数を使って Navigation Component を設定する - 包括的な Android テスト(ユニット、統合、UI)を作成する - アプリのパフォーマンス(再コンポジション、メモリ、バッテリー)をプロファイリングして最適化する - Material Design 3 のコンポーネントとテーマを適用する - App Bundle、署名、Play ストアへの公開を設定する - 具体例を用いて、特定のアプローチが推奨される理由を説明する - レガシーパターンからモダンな Android アーキテクチャへのコードのリファクタリングを支援する1. Jetpack Compose
State 管理
Compose では State は下方向に流れ、イベントは上方向に流れます(単方向データフロー)。
State のホイスティングパターン:
// Stateless composable — accepts state and callbacks
@Composable
fun LoginForm(
email: String,
password: String,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSubmit: () -> Unit,
) {
Column {
TextField(value = email, onValueChange = onEmailChange, label = { Text("Email") })
TextField(value = password, onValueChange = onPasswordChange, label = { Text("Password") })
Button(onClick = onSubmit) { Text("Log in") }
}
}
// Stateful caller — owns state and passes it down
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LoginForm(
email = state.email,
password = state.password,
onEmailChange = viewModel::onEmailChanged,
onPasswordChange = viewModel::onPasswordChanged,
onSubmit = viewModel::onSubmit,
)
}
remember と rememberSaveable の違い:
remember:再コンポジション中のみ状態を保持します。一時的な UI State に使用します。rememberSaveable:再コンポジション後もプロセス終了後も状態を保持します(Bundle に保存されます)。ユーザーに表示される状態(スクロール位置、フォーム入力など)に使用します。
// remember — lost on configuration change / process death
var expanded by remember { mutableStateOf(false) }
// rememberSaveable — survives configuration change and process death
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
derivedStateOf: 派生 State がほかの State オブジェクトに依存し、不要な再コンポジションを
避けたい場合に使用します。
val isSubmitEnabled by remember {
derivedStateOf { email.isNotBlank() && password.length >= 8 }
}
副作用
構造化された副作用 API を使用してください。コンポジション内でコルーチンを起動したり、副作用を実行したりしないでください。
| API | 使用する場面 |
|---|---|
LaunchedEffect(key) | キーに紐づくコルーチンを起動します。キーが変わるとキャンセルして再起動します |
rememberCoroutineScope() | イベント駆動のコルーチン(ボタンクリックなど)用のスコープを取得します |
SideEffect | コンポジションが正常に完了するたびに、サスペンドしない副作用を実行します |
DisposableEffect(key) | クリーンアップを伴う副作用(コールバックの登録/解除など)に使用します |
// Navigate to destination after login success
LaunchedEffect(uiState.isLoggedIn) {
if (uiState.isLoggedIn) navController.navigate(Route.Home)
}
// Scope for click-driven coroutine
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { /* ... */ } }) { Text("Save") }
// Register/unregister a callback
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> /* ... */ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
再コンポジションの最適化
Compose では再コンポジションが主なパフォーマンス上の懸念事項です。その範囲を最小限に抑えてください。
// AVOID: Unstable lambda captures the entire parent scope
@Composable
fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {
LazyColumn {
items(items, key = { it.id }) { item ->
// New lambda instance created on each recomposition of ItemList
ItemRow(item = item, onClick = { onItemClick(item) })
}
}
}
// PREFER: Stable key + remember to avoid unnecessary child recompositions
@Composable
fun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {
val stableOnClick = rememberUpdatedState(onItemClick)
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item = item, onClick = { stableOnClick.value(item) })
}
}
}
安定した型に関するルール:
- プリミティブ型と
Stringは常に安定しています。 - 安定したフィールドのみを持つデータクラスは、
@Stableまたは@Immutableのアノテーションが付いていれば安定しています。 - 標準ライブラリの
List、Map、Setは 不安定 です。kotlinx.collections.immutableを優先してください。
@Immutable
data class UserProfile(val name: String, val avatarUrl: String)
Modifier の順序は重要です: 論理的な順序(サイズ → パディング → 背景 → クリック可能)で Modifier を適用してください。
// Correct: padding inside clickable area
Modifier
.size(48.dp)
.clip(CircleShape)
.clickable(onClick = onClick)
.padding(8.dp)
Compose のレイアウトとカスタムレイアウト
// Custom layout example: badge overlay
@Composable
fun BadgeBox(badgeCount: Int, content: @Composable () -> Unit) {
Layout(content = {
content()
if (badgeCount > 0) {
Box(Modifier.background(Color.Red, CircleShape)) {
Text("$badgeCount", color = Color.White, fontSize = 10.sp)
}
}
}) { measurables, constraints ->
val contentPlaceable = measurables[0].measure(constraints)
val badgePlaceable = measurables.getOrNull(1)?.measure(Constraints())
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.placeRelative(0, 0)
badgePlaceable?.placeRelative(
contentPlaceable.width - badgePlaceable.width / 2,
-badgePlaceable.height / 2
)
}
}
}
CompositionLocal
CompositionLocal を使用すると、コンポジションツリー全体で環境データを伝播できます。すべてのコンポーザブルに明示的に渡す必要はありません。
// Define
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
// Provide at a high level
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
MyAppContent()
}
// Consume anywhere below
val snackbarHostState = LocalSnackbarHostState.current
使用する場面: ユーザー設定(テーマ、ロケール)、共有サービス(分析、ナビゲーション)。 避ける場面: 頻繁に変更されるデータや、明示的に渡すべきデータ。
アニメーション
// Simple animated visibility
AnimatedVisibility(visible = showDetails) {
DetailsPanel()
}
// Animated value
val alpha by animateFloatAsState(
targetValue = if (isEnabled) 1f else 0.4f,
animationSpec = tween(durationMillis = 300),
label = "alpha",
)
// Shared element transition (Compose 1.7+)
SharedTransitionLayout {
AnimatedContent(targetState = selectedItem) { item ->
if (item == null) {
ListScreen(
onItemClick = { selectedItem = it },
animatedVisibilityScope = this,
sharedTransitionScope = this@SharedTransitionLayout,
)
} else {
DetailScreen(
item = item,
animatedVisibilityScope = this,
sharedTransitionScope = this@SharedTransitionLayout,
)
}
}
}
2. Kotlin コルーチンと Flow
コルーチンの基礎
// ViewModel: use viewModelScope (auto-cancelled on VM cleared)
class OrderViewModel @Inject constructor(
private val orderRepository: OrderRepository,
) : ViewModel() {
fun placeOrder(order: Order) {
viewModelScope.launch {
try {
orderRepository.placeOrder(order)
} catch (e: HttpException) {
// handle error
}
}
}
}
// Repository: return suspend fun or Flow, never launch internally
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApi,
private val dao: OrderDao,
) : OrderRepository {
override suspend fun placeOrder(order: Order) {
api.placeOrder(order.toRequest())
dao.insert(order.toEntity())
}
}
Dispatcher のガイドライン:
Dispatchers.Main:UI 操作、State の更新Dispatchers.IO:ネットワーク呼び出し、ファイル/データベース I/ODispatchers.Default:CPU 負荷の高い計算
// withContext switches dispatcher for a block
suspend fun loadImage(url: String): Bitmap = withContext(Dispatchers.IO) {
URL(url).readBytes().let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
Flow
リアクティブストリームにはFlowを使用します。ViewModelではStateFlow/SharedFlowを優先してください。
// Repository: expose cold Flow
fun observeOrders(): Flow<List<Order>> = dao.observeAll().map { entities ->
entities.map { it.toModel() }
}
// ViewModel: convert to StateFlow for UI
class OrderListViewModel @Inject constructor(repo: OrderRepository) : ViewModel() {
val orders: StateFlow<List<Order>> = repo.observeOrders()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
}
// Compose: collect safely with lifecycle awareness
val orders by viewModel.orders.collectAsStateWithLifecycle()
知っておきたいFlowオペレーター:
flow
.filter { it.isActive }
.map { it.toUiModel() }
.debounce(300) // search input debounce
.distinctUntilChanged()
.catch { e -> emit(emptyList()) } // handle errors inline
.flowOn(Dispatchers.IO) // run upstream on IO dispatcher
単発イベントにはSharedFlowを使用:
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
// Emit from ViewModel
fun onSubmit() { viewModelScope.launch { _events.emit(UiEvent.NavigateToHome) } }
// Collect in Composable
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.NavigateToHome -> navController.navigate(Route.Home)
is UiEvent.ShowError -> snackbar.showSnackbar(event.message)
}
}
}
3. Androidアーキテクチャコンポーネント
ViewModel
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getProductUseCase: GetProductUseCase,
) : ViewModel() {
private val productId: String = checkNotNull(savedStateHandle["productId"])
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
init { loadProduct() }
private fun loadProduct() {
viewModelScope.launch {
_uiState.value = try {
val product = getProductUseCase(productId)
ProductDetailUiState.Success(product)
} catch (e: Exception) {
ProductDetailUiState.Error(e.message ?: "Unknown error")
}
}
}
}
sealed interface ProductDetailUiState {
data object Loading : ProductDetailUiState
data class Success(val product: Product) : ProductDetailUiState
data class Error(val message: String) : ProductDetailUiState
}
Room
@Entity(tableName = "orders")
data class OrderEntity(
@PrimaryKey val id: String,
val customerId: String,
val totalCents: Long,
val status: String,
val createdAt: Long,
)
@Dao
interface OrderDao {
@Query("SELECT * FROM orders ORDER BY createdAt DESC")
fun observeAll(): Flow<List<OrderEntity>>
@Query("SELECT * FROM orders WHERE id = :id")
suspend fun getById(id: String): OrderEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(order: OrderEntity)
@Delete
suspend fun delete(order: OrderEntity)
}
@Database(entities = [OrderEntity::class], version = 2)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun orderDao(): OrderDao
}
マイグレーションの例:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE orders ADD COLUMN notes TEXT NOT NULL DEFAULT ''")
}
}
WorkManager
遅延実行が可能で、確実な実行が求められるバックグラウンド処理に使用します。
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val syncRepository: SyncRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
syncRepository.sync()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
@AssistedFactory
interface Factory : ChildWorkerFactory<SyncWorker>
}
// Schedule periodic sync
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest,
)
4. Hiltによる依存性注入
モジュールのセットアップ
@HiltAndroidApp
class MyApplication : Application()
// Activity/Fragment
@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* ... */ }
// Network module
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideOrderApi(retrofit: Retrofit): OrderApi = retrofit.create(OrderApi::class.java)
}
// Repository binding
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindOrderRepository(impl: OrderRepositoryImpl): OrderRepository
}
スコープ
| スコープ | コンポーネント | ライフタイム |
|---|---|---|
@Singleton | SingletonComponent | アプリケーションのライフタイム |
@ActivityRetainedScoped | ActivityRetainedComponent | ViewModelのライフタイム |
@ActivityScoped | ActivityComponent | Activityのライフタイム |
@ViewModelScoped | ViewModelComponent | ViewModelのライフタイム |
@FragmentScoped | FragmentComponent | Fragmentのライフタイム |
WorkManagerでのHilt
@HiltWorker
class UploadWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val uploadService: UploadService,
) : CoroutineWorker(context, params) { /* ... */ }
5. Navigation Component
型安全な引数を使ったNavGraph(Navigation 2.8+)
// Define destinations as serializable objects/classes
@Serializable object HomeRoute
@Serializable object ProfileRoute
@Serializable data class ProductDetailRoute(val productId: String)
// Build NavGraph
@Composable
fun AppNavGraph(navController: NavHostController) {
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeScreen(onProductClick = { id ->
navController.navigate(ProductDetailRoute(id))
})
}
composable<ProductDetailRoute> { backStackEntry ->
val args = backStackEntry.toRoute<ProductDetailRoute>()
ProductDetailScreen(productId = args.productId)
}
composable<ProfileRoute> { ProfileScreen() }
}
}
ディープリンク
composable<ProductDetailRoute>(
deepLinks = listOf(
navDeepLink<ProductDetailRoute>(basePath = "https://example.com/product")
)
) { /* ... */ }
AndroidManifest.xmlに宣言します:
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>
</activity>
ボトムナビゲーション
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStack?.destination
Scaffold(
bottomBar = {
NavigationBar {
TopLevelDestination.entries.forEach { dest ->
NavigationBarItem(
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = { Text(dest.label) },
selected = currentDestination?.hasRoute(dest.route::class) == true,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
) { padding ->
AppNavGraph(navController = navController, modifier = Modifier.padding(padding))
}
}
6. Androidのテスト
単体テスト(JUnit5 + MockK)
@ExtendWith(MockKExtension::class)
class GetProductUseCaseTest {
@MockK lateinit var repository: ProductRepository
private lateinit var useCase: GetProductUseCase
@BeforeEach
fun setUp() {
useCase = GetProductUseCase(repository)
}
@Test
fun `returns product when repository succeeds`() = runTest {
val product = Product(id = "1", name = "Widget", priceCents = 999)
coEvery { repository.getProduct("1") } returns product
val result = useCase("1")
assertThat(result).isEqualTo(product)
}
@Test
fun `throws exception when product not found`() = runTest {
coEvery { repository.getProduct("missing") } throws NotFoundException("missing")
assertThrows<NotFoundException> { useCase("missing") }
}
}
ViewModelのテスト
@OptIn(ExperimentalCoroutinesApi::class)
class ProductDetailViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private val repository = mockk<ProductRepository>()
private lateinit var viewModel: ProductDetailViewModel
@Before
fun setUp() {
viewModel = ProductDetailViewModel(
savedStateHandle = SavedStateHandle(mapOf("productId" to "abc")),
getProductUseCase = GetProductUseCase(repository),
)
}
@Test
fun `uiState is Loading initially then Success`() = runTest {
val product = Product("abc", "Gizmo", 1299)
coEvery { repository.getProduct("abc") } returns product
val states = mutableListOf<ProductDetailUiState>()
val job = launch { viewModel.uiState.toList(states) }
advanceUntilIdle()
job.cancel()
assertThat(states).contains(ProductDetailUiState.Loading)
assertThat(states.last()).isEqualTo(ProductDetailUiState.Success(product))
}
}
class MainDispatcherRule(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}
Compose UIのテスト
class LoginScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun `submit button disabled when fields are empty`() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = {})
}
composeTestRule
.onNodeWithText("Log in")
.assertIsNotEnabled()
}
@Test
fun `displays error message on invalid credentials`() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = {})
}
composeTestRule.onNodeWithText("Email").performTextInput("[email protected]")
composeTestRule.onNodeWithText("Password").performTextInput("wrongpass")
composeTestRule.onNodeWithText("Log in").performClick()
composeTestRule
.onNodeWithText("Invalid credentials")
.assertIsDisplayed()
}
}
Espresso(レガシー/ハイブリッドアプリ)
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun navigatesToDetailScreen() {
onView(withId(R.id.product_list))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))
onView(withId(R.id.product_title)).check(matches(isDisplayed()))
}
}
Robolectric(高速なJVMテスト)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NotificationHelperTest {
@Test
fun `creates notification with correct channel`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val helper = NotificationHelper(context)
helper.showOrderNotification(orderId = "42", message = "Your order shipped!")
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
assertThat(nm.activeNotifications).hasSize(1)
}
}
7. パフォーマンス
Baseline Profiles
Baseline Profilesは、アプリのインストール時に頻繁に使用される処理経路を事前コンパイルし、JITのオーバーヘッドを軽減します。
// app/src/main/baseline-prof.txt (auto-generated by Macrobenchmark)
// Or use the Baseline Profile Gradle Plugin:
// build.gradle.kts (app)
plugins {
id("androidx.baselineprofile")
}
// Generate: ./gradlew :app:generateBaselineProfile
生成用のMacrobenchmark:
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.myapp") {
pressHome()
startActivityAndWait()
// Interact with critical user journeys
device.findObject(By.text("Products")).click()
device.waitForIdle()
}
}
トレース/Systrace
// Add custom trace sections
trace("MyExpensiveOperation") {
performExpensiveWork()
}
// Compose compiler metrics — add to build.gradle.kts
tasks.withType<KotlinCompile>().configureEach {
compilerOptions.freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${layout.buildDirectory.get()}/compose_metrics"
)
}
メモリのプロファイリング
- Android StudioのMemory Profilerを使用して、ヒープダンプを取得します。
Bitmapのリーク、静的フィールドでのContextのリーク、クローズされていないCursorオブジェクトを確認します。- デバッグビルドでは
LeakCanaryを使用し、リークを自動検出します。
// Avoid Context leaks: use applicationContext for long-lived objects
class ImageCache @Inject constructor(
@ApplicationContext private val context: Context // Safe: application scope
) { /* ... */ }
LazyListのパフォーマンス
LazyColumn {
items(
items = itemList,
key = { item -> item.id }, // Stable key prevents unnecessary recompositions
contentType = { item -> item.type }, // Enables item recycling by type
) { item ->
ItemRow(item = item)
}
}
8. Material Design 3
テーマ設定
// Define color scheme
val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
secondary = Color(0xFF625B71),
// ... other tokens
)
// Apply theme
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content,
)
}
ダイナミックカラー(Android 12+)
@Composable
fun MyAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
主なM3コンポーネント
// Top App Bar
TopAppBar(
title = { Text("Orders") },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
},
actions = {
IconButton(onClick = onSearch) { Icon(Icons.Default.Search, "Search") }
},
)
// Card
ElevatedCard(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Column(Modifier.padding(16.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium)
}
}
// FAB
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
9. 最新のAndroidパターン
MVI(Model-View-Intent)
MVIはComposeアプリに推奨されるパターンです。状態は一方向に流れ、インテントはユーザーの操作を表します。
// Intent (user actions)
sealed interface ProductListIntent {
data object LoadProducts : ProductListIntent
data class SearchQueryChanged(val query: String) : ProductListIntent
data class ProductClicked(val id: String) : ProductListIntent
}
// UI State
data class ProductListUiState(
val isLoading: Boolean = false,
val products: List<Product> = emptyList(),
val error: String? = null,
val searchQuery: String = "",
)
// One-shot effects
sealed interface ProductListEffect {
data class NavigateToDetail(val productId: String) : ProductListEffect
}
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val getProductsUseCase: GetProductsUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
private val _effect = MutableSharedFlow<ProductListEffect>()
val effect: SharedFlow<ProductListEffect> = _effect.asSharedFlow()
fun handleIntent(intent: ProductListIntent) {
when (intent) {
is ProductListIntent.LoadProducts -> loadProducts()
is ProductListIntent.SearchQueryChanged -> updateSearch(intent.query)
is ProductListIntent.ProductClicked -> {
viewModelScope.launch { _effect.emit(ProductListEffect.NavigateToDetail(intent.id)) }
}
}
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
_uiState.update {
try {
it.copy(isLoading = false, products = getProductsUseCase())
} catch (e: Exception) {
it.copy(isLoading = false, error = e.message)
}
}
}
}
}
クリーンアーキテクチャのレイヤー
presentation/
ui/ — Composables, screens
viewmodel/ — ViewModels, UI State, Intents
domain/
model/ — Domain entities (pure Kotlin, no Android deps)
repository/ — Repository interfaces
usecase/ — Business logic (one use case per file)
data/
repository/ — Repository implementations
remote/ — API service interfaces, DTOs, mappers
local/ — Room entities, DAOs, mappers
di/ — Hilt modules
Use Caseの例:
class GetFilteredProductsUseCase @Inject constructor(
private val productRepository: ProductRepository,
) {
suspend operator fun invoke(query: String): List<Product> =
productRepository.getProducts()
.filter { it.name.contains(query, ignoreCase = true) }
.sortedBy { it.name }
}
10. App Bundleと公開
ビルド設定
// build.gradle.kts (app)
android {
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 26
targetSdk = 35
versionCode = 10
versionName = "1.2.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
}
}
bundle {
language { enableSplit = true }
density { enableSplit = true }
abi { enableSplit = true }
}
}
署名設定(環境変数を使用 — キーストアは絶対にコミットしないでください)
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
ProGuardルール
# Keep data classes used for serialization
-keep class com.example.myapp.data.remote.dto.** { *; }
# Keep Hilt-generated classes
-keepnames @dagger.hilt.android.lifecycle.HiltViewModel class * extends androidx.lifecycle.ViewModel
# Retrofit
-keepattributes Signature, Exceptions
-keep class retrofit2.** { *; }
絶対遵守事項
- ComposeでFlowを収集する際は、必ず
collectAsStateWithLifecycle()を使用してください — ライフサイクルを考慮しないcollectAsState()は使用しないでください。collectAsStateWithLifecycle()はアプリがバックグラウンドに移行すると収集を一時停止し、リソースの浪費を防ぎます。 - ViewModelから可変状態を絶対に公開しないでください —
asStateFlow()/asSharedFlow()を介してStateFlow/SharedFlowを公開し、外部からの変更を防ぐためにMutableStateFlow/MutableSharedFlowは非公開にしてください。 - アイコンのみのボタンには必ずコンテンツ説明を指定してください —
contentDescriptionがないと、スクリーンリーダーはアイコンの意味を伝えられません。操作可能な要素内のアイコンにnullを渡さないでください。 - 本番コードで
runBlockingを絶対に使用しないでください —runBlockingは呼び出し元のスレッドをブロックします。コルーチンの起動にはすべてviewModelScope.launchまたはlifecycleScope.launchを使用してください。 LazyColumn/LazyRowには必ず安定したキーを指定してください —keyラムダがないと、データが変更されるたびにリスト全体が再コンポジションされます。必ずkey = { item.id }を使用してください。
避けるべきアンチパターン
| アンチパターン | 推奨される方法 |
|---|---|
init {} 内で WhileSubscribed を使わずに StateFlow を使用する | UI がないときに上流の処理が実行されないよう、SharingStarted.WhileSubscribed(5_000) を使用する |
ライフサイクルを考慮せずに LaunchedEffect 内で collect を呼び出す | collectAsStateWithLifecycle() を使用する |
Activity/Fragment のコンテキストを ViewModel に渡す | @ApplicationContext または SavedStateHandle を使用する |
| Composable にビジネスロジックを記述する | ロジックは ViewModel/UseCase に記述する |
Compose の状態に mutableListOf() を使用する | mutableStateListOf() または MutableStateFlow<List<T>> を使用する |
| Composable に文字列をハードコードする | stringResource(R.string.key) を使用する |
本番コードで runBlocking を使用する | コルーチンを適切に使用する。runBlocking はスレッドをブロックします |
GlobalScope.launch | viewModelScope または lifecycleScope を使用する |
| ViewModel からミュータブルな状態を公開する | StateFlow/SharedFlow を公開し、ミュータブルな状態は非公開にする |
アクセシビリティ
// Provide content descriptions for icon-only buttons
IconButton(onClick = onFavorite) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
)
}
// Use semantic roles for custom components
Box(
modifier = Modifier
.semantics {
role = Role.Switch
stateDescription = if (isChecked) "On" else "Off"
}
.clickable(onClick = onToggle)
)
// Merge descendants to reduce TalkBack verbosity
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Icon(Icons.Default.Star, contentDescription = null) // null = decorative
Text("4.5 stars")
}
Compose の状態をレビューする
User: "Is this pattern correct for search?"
@Composable
fun SearchBar(onQueryChange: (String) -> Unit) {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it; onQueryChange(it) },
label = { Text("Search") }
)
}
レビュー:
- 一時的な UI 入力には
rememberが適しています。 - 構成変更後もクエリを維持したい場合は、
rememberSaveableの使用を検討してください。 - キーストロークごとに
onQueryChangeを呼び出すのではなく、ViewModel にdebounceを追加してください。不要な検索を避けられます。 - 不足しているもの:呼び出し元がレイアウトを制御するための
modifierパラメーター。
過剰な再コンポーズを診断する
User: "My list recomposes entirely when one item changes"
根本原因と修正方法:
- Compose がアイテムを ID で追跡できるよう、
LazyColumnのitems()にkey = { item.id }を追加します。 Itemデータクラスのフィールド型を安定したものにし、@Stableまたは@Immutableを指定します。List<T>の代わりにkotlinx.collections.immutable.ImmutableListを使用します。
割り当てられたエージェント
このスキルは次のエージェントによって使用されます。
developer— Android 機能の実装code-reviewer— Android コードレビューarchitect— Android アーキテクチャの意思決定qa— Android テスト戦略
統合ポイント
- 関連スキル:
kotlin-expert、mobile-app-patterns、accessibility-tester、security-architect - 関連ルール:
.claude/rules/android-expert.md
メモリプロトコル(必須)
開始前:
cat .claude/context/memory/learnings.md
次の項目を確認します。
- このコードベースで以前に発見された Android 固有のパターン
- このプラットフォームで Android ツールを使用する際の既知の問題
- すでに決定されたアーキテクチャ上の判断(ADR)
完了後:
- 新しいパターンまたは Compose API に関する知見 →
.claude/context/memory/learnings.md - ビルド/ツール関連で発生した問題 →
.claude/context/memory/issues.md - アーキテクチャ上の決定 →
.claude/context/memory/decisions.md
中断を想定してください。コンテキストがリセットされる可能性があります。メモリに記録されていなければ、起きていないのと同じです。