android-expert

SKILL.md

Android Expert

1. Jetpack Compose

State Management

State in Compose flows downward and events flow upward (unidirectional data flow).

State hoisting pattern:

// 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 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).
// 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: 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.)
SideEffect Run non-suspend side effects after every successful composition
DisposableEffect(key) Side effects with cleanup (register/unregister callbacks)
// 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) }
}

Recomposition Optimization

Recomposition is the main performance concern in Compose. Minimize its scope.

// 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) })
        }
    }
}

Rules for stable types:

  • 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).

// Correct: padding inside clickable area
Modifier
    .size(48.dp)
    .clip(CircleShape)
    .clickable(onClick = onClick)
    .padding(8.dp)

Compose Layouts and Custom Layouts

// 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

Use CompositionLocal to propagate ambient data through the composition tree without threading it explicitly through every composable.

// 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

When to use: User preferences (theme, locale), shared services (analytics, navigation). When to avoid: Data that changes frequently or should be passed explicitly.

Animations

// 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 Coroutines and Flow

Coroutines Fundamentals

// 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 guidelines:

  • Dispatchers.Main: UI interactions, state updates
  • Dispatchers.IO: Network calls, file/database I/O
  • Dispatchers.Default: CPU-intensive computations
// 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

Use Flow for reactive streams. Prefer StateFlow/SharedFlow in ViewModels.

// 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 operators to know:

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 for one-shot events:

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 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
}

Migration example:

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>
}

// 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. Dependency Injection with Hilt

Module Setup

@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
}

Scopes

Scope Component Lifetime
@Singleton SingletonComponent Application lifetime
@ActivityRetainedScoped ActivityRetainedComponent ViewModel lifetime
@ActivityScoped ActivityComponent Activity lifetime
@ViewModelScoped ViewModelComponent ViewModel lifetime
@FragmentScoped FragmentComponent Fragment 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+)

// 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() }
    }
}

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.

// 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 for generation:

@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()
    }
}

Tracing / 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"
    )
}

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.
// Avoid Context leaks: use applicationContext for long-lived objects
class ImageCache @Inject constructor(
    @ApplicationContext private val context: Context // Safe: application scope
) { /* ... */ }

LazyList Performance

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

Theming

// 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,
    )
}

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

// 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. Modern Android Patterns

MVI (Model-View-Intent)

MVI is the recommended pattern for Compose apps. State flows one direction; intents describe user actions.

// 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)
                }
            }
        }
    }
}

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

Use Case example:

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

// 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 }
    }
}

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

  1. ALWAYS collect Flow in Compose with collectAsStateWithLifecycle() — never use collectAsState() which ignores lifecycle; collectAsStateWithLifecycle() pauses collection when the app is backgrounded, preventing resource waste.
  2. NEVER expose mutable state from ViewModel — expose StateFlow/SharedFlow via asStateFlow()/asSharedFlow(); keep MutableStateFlow/MutableSharedFlow private to prevent external mutation.
  3. ALWAYS provide content descriptions for icon-only buttons — screen readers cannot convey icon meaning without contentDescription; never pass null to icons in interactive elements.
  4. NEVER use runBlocking in production coderunBlocking blocks the calling thread; use viewModelScope.launch or lifecycleScope.launch for all coroutine launches.
  5. 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 WhileSubscribed Use 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 state Use mutableStateListOf() or MutableStateFlow<List<T>>
Hardcoded strings in Composables Use stringResource(R.string.key)
runBlocking in production code Use coroutines properly; runBlocking blocks the thread
GlobalScope.launch Use viewModelScope or lifecycleScope
Mutable state exposed from ViewModel Expose StateFlow/SharedFlow; keep mutable state private

Accessibility

// 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")
}

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") }
    )
}

Review:

  • 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"

Root cause and fix:

  • 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

This skill is used by:

  • 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)

Before starting:

cat .claude/context/memory/learnings.md

Check for:

  • Previously discovered Android-specific patterns in this codebase
  • Known issues with Android tooling on this platform
  • Architecture decisions already made (ADRs)

After completing:

  • 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.

Weekly Installs
66
GitHub Stars
16
First Seen
Jan 27, 2026
Installed on
github-copilot62
cursor62
opencode62
gemini-cli61
codex61
kimi-cli60