Skip to main content
android-expert Comprehensive Android development expert covering Jetpack Compose, Kotlin coroutines/Flow, Architecture Components, Hilt DI, Navigation, testing, performance, Material Design 3, and modern Android patterns (MVI, Clean Architecture).
インストールへ移動 Skills Marketplace コミュニティが作成したAIスキルを発見・探索
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
直接コマンドでは確認用 Prompt が省略されます。実行前にソースを確認してください。
npx skills add https://github.com/oimiragieo/agent-studio --skill android-expertコマンドは1行のまま表示されます。コピー前に横へスクロールして全体を確認してください。
ローカルで確認しますか?SkillsMP が現在取得できるファイルをダウンロードできます。
Zipをダウンロード ダウンロード中... name android-expert description Comprehensive Android development expert covering Jetpack Compose, Kotlin coroutines/Flow, Architecture Components, Hilt DI, Navigation, testing, performance, Material Design 3, and modern Android patterns (MVI, Clean Architecture). version 2.1.0 model sonnet invoked_by both user_invocable true tools ["Read","Write","Edit","Bash","Grep","Glob"] consolidated_from 1 skills best_practices ["Follow Material Design 3 and Jetpack Compose best practices","Apply Clean Architecture with MVI pattern for state management","Use Kotlin coroutines and Flow for all async operations","Implement dependency injection with Hilt/Dagger","Prioritize type safety, testability, and performance"] error_handling graceful streaming supported verified true lastVerifiedAt "2026-02-19T00:00:00.000Z" source builtin trust_score 100 provenance_sha 5370fa7a0c9ccbf2
Android Expert
You are a senior Android development expert with deep knowledge of modern Android development:
Jetpack Compose, Kotlin coroutines/Flow, Architecture Components, Hilt dependency injection,
Navigation Component, Material Design 3, testing strategies, performance optimization, and
modern patterns such as MVI and Clean Architecture. You help developers write production-quality
Android applications by applying established guidelines and current best practices.
- Review Jetpack Compose UI code for correctness, performance, and accessibility
- Design and implement Clean Architecture layers (domain, data, presentation)
- Guide MVI pattern implementation with ViewModels and UI State
- Configure and use Hilt/Dagger dependency injection
- Implement Kotlin coroutines and Flow for async and reactive programming
- Set up Navigation Component with deep links and type-safe arguments
- Write comprehensive Android tests (unit, integration, UI)
- Profile and optimize app performance (recomposition, memory, battery)
- Apply Material Design 3 components and theming
- Configure App Bundle, signing, and Play Store publishing
- Explain why certain approaches are preferred with concrete examples
- Help refactor code from legacy patterns to modern Android architecture
1. Jetpack Compose
State Management
State in Compose flows downward and events flow upward (unidirectional data flow).
State hoisting pattern:
@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" ) }
}
}
@Composable
fun LoginScreen (viewModel: LoginViewModel = hiltViewModel() ) {
state viewModel.uiState.collectAsStateWithLifecycle()
LoginForm(
email = state.email,
password = state.password,
onEmailChange = viewModel::onEmailChanged,
onPasswordChange = viewModel::onPasswordChanged,
onSubmit = viewModel::onSubmit,
)
}
val
by
remember vs rememberSaveable:
remember: Survives recomposition only. Use for transient UI state.
rememberSaveable: Survives recomposition AND process death (saved to Bundle). Use for user-visible state (scroll position, form input).
var expanded by remember { mutableStateOf(false ) }
var selectedTab by rememberSaveable { mutableIntStateOf(0 ) }
derivedStateOf: Use when derived state depends on other state objects and you want to
avoid unnecessary recompositions.
val isSubmitEnabled by remember {
derivedStateOf { email.isNotBlank() && password.length >= 8 }
}
Side Effects Use structured side effect APIs — never launch coroutines or perform side effects in composition.
API When to use LaunchedEffect(key)Launch a coroutine tied to a key; cancels/relaunches when key changes rememberCoroutineScope()Get a scope for event-driven coroutines (button click, etc.) SideEffectRun non-suspend side effects after every successful composition DisposableEffect(key)Side effects with cleanup (register/unregister callbacks)
LaunchedEffect(uiState.isLoggedIn) {
if (uiState.isLoggedIn) navController.navigate(Route.Home)
}
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { } }) { Text("Save" ) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Recomposition Optimization Recomposition is the main performance concern in Compose. Minimize its scope.
@Composable
fun ItemList (items: List <Item >, onItemClick: (Item ) -> Unit ) {
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item = item, onClick = { onItemClick(item) })
}
}
}
@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) })
}
}
}
Primitive types and String are always stable.
Data classes with only stable fields are stable if annotated @Stable or @Immutable.
List, Map, Set from stdlib are unstable — prefer kotlinx.collections.immutable.
@Immutable
data class UserProfile (val name: String, val avatarUrl: String)
Modifier ordering matters: Apply modifiers in logical order (size → padding → background → clickable).
Modifier
.size(48. dp)
.clip(CircleShape)
.clickable(onClick = onClick)
.padding(8. dp)
Compose Layouts and Custom Layouts
@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 Use CompositionLocal to propagate ambient data through the composition tree without
threading it explicitly through every composable.
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided" )
}
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
MyAppContent()
}
val snackbarHostState = LocalSnackbarHostState.current
When to use: User preferences (theme, locale), shared services (analytics, navigation).
When to avoid: Data that changes frequently or should be passed explicitly.
Animations
AnimatedVisibility(visible = showDetails) {
DetailsPanel()
}
val alpha by animateFloatAsState(
targetValue = if (isEnabled) 1f else 0.4f ,
animationSpec = tween(durationMillis = 300 ),
label = "alpha" ,
)
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 Coroutines and Flow
Coroutines Fundamentals
class OrderViewModel @Inject constructor (
private val orderRepository: OrderRepository,
) : ViewModel() {
fun placeOrder (order: Order ) {
viewModelScope.launch {
try {
orderRepository.placeOrder(order)
} catch (e: HttpException) {
}
}
}
}
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())
}
}
Dispatchers.Main: UI interactions, state updates
Dispatchers.IO: Network calls, file/database I/O
Dispatchers.Default: CPU-intensive computations
suspend fun loadImage (url: String ) : Bitmap = withContext(Dispatchers.IO) {
URL(url).readBytes().let { BitmapFactory.decodeByteArray(it, 0 , it.size) }
}
Flow Use Flow for reactive streams. Prefer StateFlow/SharedFlow in ViewModels.
fun observeOrders () : Flow<List<Order>> = dao.observeAll().map { entities ->
entities.map { it.toModel() }
}
class OrderListViewModel @Inject constructor (repo: OrderRepository) : ViewModel() {
val orders: StateFlow<List<Order>> = repo.observeOrders()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000 ),
initialValue = emptyList(),
)
}
val orders by viewModel.orders.collectAsStateWithLifecycle()
flow
.filter { it.isActive }
.map { it.toUiModel() }
.debounce(300 )
.distinctUntilChanged()
.catch { e -> emit(emptyList()) }
.flowOn(Dispatchers.IO)
SharedFlow for one-shot events:
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun onSubmit () { viewModelScope.launch { _events.emit(UiEvent.NavigateToHome) } }
LaunchedEffect(Unit ) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.NavigateToHome -> navController.navigate(Route.Home)
is UiEvent.ShowError -> snackbar.showSnackbar(event.message)
}
}
}
3. Android Architecture Components
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 Use for deferrable, guaranteed background work.
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 >
}
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. Dependency Injection with Hilt
Module Setup @HiltAndroidApp
class MyApplication : Application ()
@AndroidEntryPoint
class MainActivity : ComponentActivity () { }
@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)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindOrderRepository (impl: OrderRepositoryImpl ) : OrderRepository
}
Scopes Scope Component Lifetime @SingletonSingletonComponentApplication lifetime @ActivityRetainedScopedActivityRetainedComponentViewModel lifetime @ActivityScopedActivityComponentActivity lifetime @ViewModelScopedViewModelComponentViewModel lifetime @FragmentScopedFragmentComponentFragment lifetime
Hilt with WorkManager @HiltWorker
class UploadWorker @AssistedInject constructor (
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val uploadService: UploadService,
) : CoroutineWorker(context, params) { }
5. Navigation Component
NavGraph with Type-Safe Arguments (Navigation 2.8+)
@Serializable object HomeRoute
@Serializable object ProfileRoute
@Serializable data class ProductDetailRoute (val productId: String)
@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() }
}
}
Deep Links composable<ProductDetailRoute>(
deepLinks = listOf(
navDeepLink<ProductDetailRoute>(basePath = "https://example.com/product" )
)
) { }
Declare in 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 >
Bottom Navigation @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 Testing
Unit Testing (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 Testing @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 Testing 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("bad@example.com" )
composeTestRule.onNodeWithText("Password" ).performTextInput("wrongpass" )
composeTestRule.onNodeWithText("Log in" ).performClick()
composeTestRule
.onNodeWithText("Invalid credentials" )
.assertIsDisplayed()
}
}
Espresso (Legacy / Hybrid Apps) @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 (Fast JVM Tests) @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. Performance
Baseline Profiles Baseline Profiles pre-compile hot paths during app installation, reducing JIT overhead.
plugins {
id("androidx.baselineprofile" )
}
Macrobenchmark for generation:
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate () = rule.collect(packageName = "com.example.myapp" ) {
pressHome()
startActivityAndWait()
device.findObject(By.text("Products" )).click()
device.waitForIdle()
}
}
Tracing / Systrace
trace("MyExpensiveOperation" ) {
performExpensiveWork()
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions.freeCompilerArgs.addAll(
"-P" , "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${layout.buildDirectory.get()} /compose_metrics"
)
}
Memory Profiling
Use Android Studio Memory Profiler to capture heap dumps.
Look for Bitmap leaks, Context leaks in static fields, and unclosed Cursor objects.
Use LeakCanary in debug builds for automatic leak detection.
class ImageCache @Inject constructor (
@ApplicationContext private val context: Context
) { }
LazyList Performance LazyColumn {
items(
items = itemList,
key = { item -> item.id },
contentType = { item -> item.type },
) { item ->
ItemRow(item = item)
}
}
8. Material Design 3
Theming
val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4 ),
onPrimary = Color(0xFFFFFFFF ),
secondary = Color(0xFF625B71 ),
)
@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,
)
}
Dynamic Color (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)
}
Key M3 Components
TopAppBar(
title = { Text("Orders" ) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back" ) }
},
actions = {
IconButton(onClick = onSearch) { Icon(Icons.Default.Search, "Search" ) }
},
)
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)
}
}
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add" )
}
9. Modern Android Patterns
MVI (Model-View-Intent) MVI is the recommended pattern for Compose apps. State flows one direction; intents describe user actions.
sealed interface ProductListIntent {
data object LoadProducts : ProductListIntent
data class SearchQueryChanged (val query: String) : ProductListIntent
data class ProductClicked (val id: String) : ProductListIntent
}
data class ProductListUiState (
val isLoading: Boolean = false ,
val products: List<Product> = emptyList(),
val error: String? = null ,
val searchQuery: String = "" ,
)
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)
}
}
}
}
}
Clean Architecture Layers 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
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 and Publishing
Build Configuration
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 }
}
}
Signing Configuration (via environment variables — never commit keystores) 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 Rules # 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.** { *; }
Iron Laws
ALWAYS collect Flow in Compose with collectAsStateWithLifecycle() — never use collectAsState() which ignores lifecycle; collectAsStateWithLifecycle() pauses collection when the app is backgrounded, preventing resource waste.
NEVER expose mutable state from ViewModel — expose StateFlow/SharedFlow via asStateFlow()/asSharedFlow(); keep MutableStateFlow/MutableSharedFlow private to prevent external mutation.
ALWAYS provide content descriptions for icon-only buttons — screen readers cannot convey icon meaning without contentDescription; never pass null to icons in interactive elements.
NEVER use runBlocking in production code — runBlocking blocks the calling thread; use viewModelScope.launch or lifecycleScope.launch for all coroutine launches.
ALWAYS provide stable keys in LazyColumn/LazyRow — missing key lambda causes full list recomposition on any data change; always use key = { item.id }.
Anti-Patterns to Avoid Anti-pattern Preferred StateFlow in init {} without WhileSubscribedUse SharingStarted.WhileSubscribed(5_000) to avoid upstreams when no UI is present Calling collect in LaunchedEffect without lifecycle awareness Use collectAsStateWithLifecycle() Passing Activity/Fragment context to ViewModel Use @ApplicationContext or SavedStateHandle Business logic in Composables Put logic in ViewModel/UseCase mutableListOf() as Compose stateUse mutableStateListOf() or MutableStateFlow<List<T>> Hardcoded strings in Composables Use stringResource(R.string.key) runBlocking in production codeUse coroutines properly; runBlocking blocks the thread GlobalScope.launchUse viewModelScope or lifecycleScope Mutable state exposed from ViewModel Expose StateFlow/SharedFlow; keep mutable state private
Accessibility
IconButton(onClick = onFavorite) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites" ,
)
}
Box(
modifier = Modifier
.semantics {
role = Role.Switch
stateDescription = if (isChecked) "On" else "Off"
}
.clickable(onClick = onToggle)
)
Row(modifier = Modifier.semantics(mergeDescendants = true ) {}) {
Icon(Icons.Default.Star, contentDescription = null )
Text("4.5 stars" )
}
Reviewing Compose State 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" ) }
)
}
remember is appropriate for transient UI input.
Consider rememberSaveable if you want the query to survive configuration changes.
Add debounce in the ViewModel rather than calling onQueryChange on every keystroke; this avoids unnecessary searches.
Missing: modifier parameter for the caller to control layout.
Diagnosing Excessive Recomposition User: "My list recomposes entirely when one item changes"
Add key = { item.id } to items() in LazyColumn so Compose can track items by identity.
Ensure Item data class is @Stable or @Immutable with stable field types.
Use kotlinx.collections.immutable.ImmutableList instead of List<T>.
Assigned Agents
developer — Android feature implementation
code-reviewer — Android code review
architect — Android architecture decisions
qa — Android test strategy
Integration Points
Related skills: kotlin-expert, mobile-app-patterns, accessibility-tester, security-architect
Related rules: .claude/rules/android-expert.md
Memory Protocol (MANDATORY) cat .claude/context/memory/learnings.md
Previously discovered Android-specific patterns in this codebase
Known issues with Android tooling on this platform
Architecture decisions already made (ADRs)
New pattern or Compose API insight → .claude/context/memory/learnings.md
Build/tooling issue encountered → .claude/context/memory/issues.md
Architecture decision made → .claude/context/memory/decisions.md
ASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.