| name | android-patterns |
| description | Android/Jetpack Compose patterns — state hoisting, UDF with ViewModel/UiState, side effects (LaunchedEffect/rememberUpdatedState), Material Design 3 components, type-safe Navigation, Hilt DI, Room database, and Compose performance (derivedStateOf, key). |
Android Patterns (Jetpack Compose)
When to Activate
- Designing architecture for a new Android feature or app
- Choosing between Compose patterns (state hoisting, side effects)
- Setting up Hilt dependency injection and scoping
- Designing Room database schema with migrations
- Implementing ViewModel with StateFlow and UiState
- Reviewing Android code for best practices and Google guidelines
- Modularizing a growing Android codebase
Jetpack Compose Basics
Composables are pure functions — they receive data and emit UI. Never hold mutable state inside a Composable directly; hoist it up.
@Composable
fun CounterButton(
count: Int,
onIncrement: () -> Unit,
modifier: Modifier = Modifier,
) {
Button(onClick = onIncrement, modifier = modifier) {
Text("Count: $count")
}
}
@Composable
fun CounterScreen() {
var count by rememberSaveable { mutableIntStateOf(0) }
CounterButton(count = count, onIncrement = { count++ })
}
State rules:
remember { } — survives recomposition, lost on configuration change
rememberSaveable { } — survives recomposition AND configuration changes (and process death for primitives)
rememberSaveable(saver = ...) — custom Saver for complex types
State Hoisting
State hoisting = lifting state up so a Composable becomes stateless. Classic pattern:
@Composable
fun SearchBox() {
var query by remember { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
}
@Composable
fun SearchBox(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier,
placeholder = { Text("Search…") },
)
}
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val query by viewModel.query.collectAsStateWithLifecycle()
SearchBox(query = query, onQueryChange = viewModel::onQueryChange)
}
Side Effects
| Effect | When to Use |
|---|
LaunchedEffect(key) | Launch a coroutine tied to Composable lifecycle. Re-launches when key changes. |
DisposableEffect(key) | Register/unregister listeners (e.g. lifecycle observers). Cleanup via onDispose. |
SideEffect | Sync Compose state to non-Compose objects (e.g. analytics). Runs every recomposition. |
rememberCoroutineScope() | Get scope for event-driven coroutines (button clicks). |
produceState | Convert callback-based APIs to Compose State. |
@Composable
fun ProductDetailScreen(productId: String, viewModel: ProductViewModel = hiltViewModel()) {
LaunchedEffect(productId) {
viewModel.loadProduct(productId)
}
}
@Composable
fun LifecycleLogger(tag: String) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
Log.d(tag, "Lifecycle: $event")
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
Material Design 3
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}
@Composable
fun ProductScreen(product: Product, onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(product.name) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { },
icon = { Icon(Icons.Filled.ShoppingCart, "Add to cart") },
text = { Text("Add to Cart") },
)
},
) { padding ->
ProductDetail(product = product, modifier = Modifier.padding(padding))
}
}
Navigation Component (Type-Safe)
@Serializable object HomeRoute
@Serializable data class ProductDetailRoute(val productId: String)
@Serializable data class OrderConfirmationRoute(val orderId: String)
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeScreen(onProductClick = { id ->
navController.navigate(ProductDetailRoute(id))
})
}
composable<ProductDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ProductDetailRoute>()
ProductDetailScreen(
productId = route.productId,
onOrderPlaced = { orderId ->
navController.navigate(OrderConfirmationRoute(orderId)) {
popUpTo<HomeRoute>()
}
},
)
}
composable<OrderConfirmationRoute> { backStackEntry ->
val route = backStackEntry.toRoute<OrderConfirmationRoute>()
OrderConfirmationScreen(orderId = route.orderId)
}
}
}
Key navigation options:
navController.navigate(SomeRoute) {
popUpTo<HomeRoute> { inclusive = false }
launchSingleTop = true
restoreState = true
}
ViewModel + UiState (Unidirectional Data Flow)
sealed interface ProductUiState {
data object Loading : ProductUiState
data class Success(val product: Product, val isInCart: Boolean) : ProductUiState
data class Error(val message: String) : ProductUiState
}
sealed interface ProductUiEvent {
data class NavigateToCart(val cartId: String) : ProductUiEvent
data class ShowSnackbar(val message: String) : ProductUiEvent
}
@HiltViewModel
class ProductViewModel @Inject constructor(
private val getProduct: GetProductUseCase,
private val addToCart: AddToCartUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val productId: String = checkNotNull(savedStateHandle["productId"])
private val _uiState = MutableStateFlow<ProductUiState>(ProductUiState.Loading)
val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
private val _uiEvents = MutableSharedFlow<ProductUiEvent>()
uiEvents: SharedFlow<ProductUiEvent> = _uiEvents.asSharedFlow()
{
loadProduct()
}
{
viewModelScope.launch {
_uiState.value = ProductUiState.Loading
getProduct(productId)
.onSuccess { product ->
_uiState.value = ProductUiState.Success(product, isInCart = )
}
.onFailure { error ->
_uiState.value = ProductUiState.Error(error.message ?: )
}
}
}
{
currentState = _uiState.value ? ProductUiState.Success ?:
viewModelScope.launch {
addToCart(currentState.product.id)
.onSuccess { cartId ->
_uiEvents.emit(ProductUiEvent.NavigateToCart(cartId))
}
.onFailure {
_uiEvents.emit(ProductUiEvent.ShowSnackbar())
}
}
}
}
) {
uiState viewModel.uiState.collectAsStateWithLifecycle()
snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect() {
viewModel.uiEvents.collect { event ->
(event) {
ProductUiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
ProductUiEvent.NavigateToCart -> { }
}
}
}
( state = uiState) {
ProductUiState.Loading -> CircularProgressIndicator()
ProductUiState.Error -> ErrorScreen(message = state.message)
ProductUiState.Success -> ProductContent(
product = state.product,
onAddToCart = viewModel::onAddToCart,
)
}
}
Critical: Use collectAsStateWithLifecycle() (from lifecycle-runtime-compose), NOT collectAsState(). The lifecycle-aware version pauses collection when the app is in the background, saving resources.
Hilt Dependency Injection
@HiltAndroidApp
class MyApp : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MyAppTheme { AppNavHost(rememberNavController()) } }
}
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply { level = BODY })
.build()
@Provides
@Singleton
fun provideRetrofit(okHttp: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttp)
.addConverterFactory(MoshiConverterFactory.create())
.build()
@Provides
@Singleton
fun provideProductApi(retrofit: Retrofit): ProductApi =
retrofit.create(ProductApi::class.java)
}
@Module
@InstallIn(SingletonComponent::class)
{
: ProductRepository
}
Room Persistence
@Entity(
tableName = "products",
indices = [Index(value = ["category_id"]), Index(value = ["sku"], unique = true)],
)
data class ProductEntity(
@PrimaryKey val id: String,
val name: String,
val price: Double,
@ColumnInfo(name = "category_id") val categoryId: String,
val sku: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)
@Dao
interface ProductDao {
@Query("SELECT * FROM products WHERE id = :id")
suspend fun getById(id: String): ProductEntity?
@Query("SELECT * FROM products WHERE category_id = :categoryId ORDER BY name ASC")
fun getByCategory(categoryId: String): Flow<List<ProductEntity>>
@Upsert
suspend fun upsert(product: ProductEntity)
@Delete
suspend
}
(
category: CategoryEntity,
products: List<ProductEntity>,
)
: () {
: ProductDao
: CategoryDao
}
MIGRATION_1_2 = : Migration(, ) {
{
db.execSQL()
db.execSQL()
}
}
DatabaseModule {
: AppDatabase =
Room.databaseBuilder(context, AppDatabase::.java, )
.addMigrations(MIGRATION_1_2)
.build()
: ProductDao = db.productDao()
}
Kotlin Coroutines in Android
class ProductRepositoryImpl @Inject constructor(
private val api: ProductApi,
private val dao: ProductDao,
private val dispatchers: CoroutineDispatchers,
) : ProductRepository {
override fun getProduct(id: String): Flow<Result<Product>> = flow {
dao.getById(id)?.let { emit(Result.success(it.toDomain())) }
val result = runCatching { api.getProduct(id) }
result.onSuccess { dto ->
dao.upsert(dto.toEntity())
emit(Result.success(dto.toDomain()))
}.onFailure { error ->
emit(Result.failure(error))
}
}.flowOn(dispatchers.io)
}
interface CoroutineDispatchers {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
class DefaultDispatchers @Inject constructor() : CoroutineDispatchers {
override val main = Dispatchers.Main
override val io = Dispatchers.IO
override val default = Dispatchers.Default
}
class TestDispatchers(testDispatcher: TestCoroutineDispatcher) : CoroutineDispatchers {
override main = testDispatcher
io = testDispatcher
default = testDispatcher
}
App Modularization
Recommended module structure for apps with 3+ features:
:app — Application entry point, DI graph assembly
:feature:home — Home screen feature module
:feature:product-detail — Product detail feature module
:feature:cart — Cart feature module
:core:network — Retrofit, OkHttp, API definitions
:core:database — Room database, DAOs
:core:ui — Shared Composables, theme, design system
:core:domain — Domain models, use case interfaces
:core:data — Repository implementations (depends on :core:network, :core:database)
:core:testing — Shared test utilities, fakes, test rules
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.myapp.feature.home"
}
dependencies {
implementation(projects.core.ui)
implementation(projects.core.domain)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
testImplementation(projects.core.testing)
}
For Compose performance optimization (derivedStateOf, stable types, lambda captures) and reference rules, see skill android-patterns-advanced.