Android Expert
- 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, ) } } }
- 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) } } }
- 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, )
- 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) { /* ... */ }
- 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))
}
}
- 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)
}
}
- 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) } }
- 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") }
- 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 } }
- 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
-
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 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.