Kotlin & Jetpack Compose Reference
Modern Android development reference with Kotlin Coroutines, Jetpack Compose declarative UI, and Room local database.
Kotlin & Jetpack Compose
A production-grade reference for engineers building real Android applications — from language internals to architecture patterns, performance, and shipping.
This documentation is structured as two parallel tracks: a learning flow (read top-to-bottom to build understanding progressively) and a reference flow (jump directly to any concept via the sidebar). Every section is written with the assumption that you are a working engineer who needs clarity, not explanation of basic programming.
If you're coming from Java or another Android background, start with Kotlin Fundamentals. If you know Kotlin, jump to Compose Core. Architecture section is standalone and critical for team-scale apps.
Mental Models That Matter
Before writing a single line, these three mental models will save you from weeks of confusion and architectural debt.
1. Kotlin is opinionated about safety
Every Kotlin design decision pushes side effects to the edges of your code. Nullability is in the type system — not a runtime concern. Immutability is the default. This is not aesthetics; it directly reduces crash rate.
2. Compose is a function of state
UI = f(State). The entire Compose mental model lives in this equation. A composable is a pure function from state to UI description. It does not manage lifecycle. It does not own state. It describes what the UI looks like for a given input.
3. Architecture is not optional at scale
Putting business logic in a ViewModel "because it works" becomes unmanageable by the second feature. Proper layering — UI → ViewModel → UseCase → Repository → DataSource — pays compound interest. The architecture section of this handbook treats this as non-negotiable.
Syntax & Core Concepts
Kotlin's syntax is dense with meaning. Understanding why each construct exists is more valuable than memorizing its syntax.
Expression-Oriented Language
In Kotlin, most constructs are expressions — they return a value. if, when, and try all produce values. This eliminates the need for ternary operators and makes code more composable.
// Java: statement-based → requires ternary or block assignment // Kotlin: expression-based → cleaner, single assignment val status = when (code) { 200 -> "OK" 404 -> "Not Found" in 500..599 -> "Server Error" else -> "Unknown" } val message = if (user != null) "Hello, ${user.name}" else "Guest"kotlin
String Templates vs Concatenation
Always prefer string templates with ${expression}. For simple variable access, the braces are optional: $variableName. For any expression, braces are required.
Scope Functions
Scope functions (let, run, with, apply, also) are among the most misused constructs in Kotlin. The choice depends on two axes: context object reference (this vs it) and return value (context object vs lambda result).
| Function | Context Object | Returns | Primary Use |
|---|---|---|---|
let | it | Lambda result | Null-safe calls, transformations |
run | this | Lambda result | Object config + compute result |
with | this | Lambda result | Grouping calls on a non-null object |
apply | this | Context object | Object initialization / builder pattern |
also | it | Context object | Side effects without modifying object |
// apply: configure and return same object → ideal for builders val intent = Intent(context, DetailActivity::class.java).apply { putExtra("id", itemId) putExtra("source", analyticsSource) flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } // let: null safety + transform user?.let { renderProfile(it) } ?: showEmptyState()kotlin
⚠ Common Mistakes
- Using
applywhen you need the lambda result (useruninstead) - Nesting scope functions more than 2 levels deep — kills readability
- Using
withon nullable objects — userunon the nullable receiver instead
Types & Null Safety
Kotlin's type system encodes nullability. String and String? are distinct types at compile time. The compiler enforces null safety — not the runtime.
The Nullable Type Contract
fun processName(name: String?): String { // 1. Safe call — returns null if name is null val trimmed = name?.trim() // 2. Elvis operator — provide default val safe = name?.trim() ?: "Anonymous" // 3. Non-null assertion — throws NPE if null (avoid in prod) val forced = name!!.trim() // only when you KNOW it's non-null // 4. Smart cast after null check if (name != null) { return name.trim() // name is now String, not String? } return "Anonymous" }kotlin
Type Inference & Explicit Types
Let the compiler infer types for local variables with obvious types. Always annotate public API signatures, function return types (for non-trivial cases), and collection element types when inference would be ambiguous.
Primitive vs Boxed Types
Kotlin compiles Int to JVM int where possible. Int? compiles to boxed Integer. In hot paths (tight loops, large collections), prefer non-nullable primitives to avoid boxing overhead.
Type Aliases & Inline Value Classes
// Type alias — documentation value only, no runtime cost typealias UserId = String typealias JsonString = String // Inline/value class — zero-cost wrapper with type safety @JvmInline value class UserId(val value: String) fun loadUser(id: UserId) { /* id.value accesses the wrapped String */ } // Now these won't compile — explicit intent required // loadUser("some-string") ← compile error, must use UserId("some-string")kotlin
✓ Key Takeaways
- Avoid
!!in production code. It's admitting you bypassed the type system. - Use
lateinit varonly for DI-injected fields, not for lazy computation (useby lazy). - Value classes are superior to type aliases for domain types — they prevent parameter mix-ups at compile time.
- Nullable return types from APIs should be handled at the boundary layer, not passed deep into the call stack.
Functions, Lambdas & Higher-Order Functions
Functions are first-class citizens in Kotlin. Understanding function types, lambdas, and higher-order functions unlocks the functional programming patterns that Compose is built on.
Function Types
// Function type syntax: (ParamTypes) -> ReturnType val transform: (String) -> Int = { it.length } val predicate: (User) -> Boolean = { it.isActive && it.age >= 18 } val callback: () -> Unit = { updateUI() } // Higher-order function — takes or returns a function fun retry(times: Int, block: () -> Unit) { repeat(times) { try { block() } catch (e: Exception) { /* log */ } } } retry(3) { networkCall() }kotlin
Extension Functions
Extension functions add behavior to types you don't own. They're syntactic sugar — they compile to static utility functions. Critically, they cannot access private members of the extended class.
// Extending Context — common Android pattern fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) { Toast.makeText(this, message, duration).show() } // Extending Flow — powerful for domain logic composition fun Flow<List<User>>.activeOnly(): Flow<List<User>> = map { users -> users.filter { it.isActive } }kotlin
inline Functions & Performance
The inline keyword copies the function body to the call site at compile time. This eliminates lambda object allocation — critical in tight loops and Compose rendering paths.
// Without inline: lambda wraps in an object, heap allocation per call // With inline: lambda code copied to call site, zero allocation inline fun measureTime(block: () -> Unit): Long { val start = System.nanoTime() block() return System.nanoTime() - start } // crossinline: prevents non-local returns from inline lambdas // noinline: opt specific lambda parameter OUT of inlining inline fun runOnMain(crossinline block: () -> Unit) { Handler(Looper.getMainLooper()).post { block() } }kotlin
Trailing Lambda Convention
When the last parameter of a function is a lambda, it can be moved outside the parentheses. This is the convention Compose is built on — every @Composable that takes a content: @Composable () -> Unit parameter exploits this.
The content lambda in Column { ... }, Box { ... }, and every container composable is just a trailing lambda parameter. Understanding this demystifies Compose's DSL-like syntax.
OOP in Kotlin
Classes, Objects & Companions
// Primary constructor — properties declared inline class UserRepository( private val api: UserApi, private val db: UserDao, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { companion object { // Factory method pattern fun create(context: Context): UserRepository { /*...*/ } const val CACHE_TTL = 3600L } } // object declaration — singleton, thread-safe by the JVM object NetworkClient { val retrofit: Retrofit by lazy { buildRetrofit() } }kotlin
Interfaces vs Abstract Classes
Prefer interfaces. They support multiple inheritance and, since Kotlin interfaces can have default implementations, abstract classes serve a narrower role: when you need constructor parameters or non-trivial state in the base type.
Delegation
// Class delegation — compose behavior instead of inheriting it class CachingRepo( private val delegate: UserRepository, private val cache: Cache ) : UserRepository by delegate { // Only override what differs override suspend fun getUser(id: String) = cache.getOrPut(id) { delegate.getUser(id) } } // Property delegation val prefs: SharedPreferences by lazy { context.getSharedPreferences("app", 0) } var theme by mutableStateOf(AppTheme.DARK) // Compose state delegatekotlin
Data Classes & Sealed Classes
Data Classes
Data classes generate equals(), hashCode(), toString(), copy(), and componentN() functions. They are the right choice for immutable value objects — domain entities, DTOs, UI state.
data class User( val id: String, val name: String, val email: String, val isVerified: Boolean = false ) // copy() creates a new instance with selective changes // Never mutate — always produce a new instance val verified = user.copy(isVerified = true) // Destructuring via componentN val (id, name) = userkotlin
Using var in data classes defeats the purpose. If two code paths can mutate the same object, equals() becomes useless for change detection. Always use val. If you need mutation, produce a new copy.
Sealed Classes — Exhaustive State Modeling
Sealed classes restrict the class hierarchy to a known set of subclasses within the same compilation unit. The compiler can verify exhaustiveness in when expressions. This is the foundation of robust state and result modeling.
// Result type — replaces nullable + exception-based error handling sealed class Result<out T> { data class Success<out T>(val data: T) : Result() data class Error(val exception: Throwable, val message: String? = null) : Result<Nothing>() data object Loading : Result<Nothing>() } // Exhaustive when — compiler error if you miss a branch when (val r = fetchUser()) { is Result.Success -> renderUser(r.data) is Result.Error -> showError(r.message) Result.Loading -> showSkeleton() } kotlin
Sealed vs Enum
Use enum for a fixed set of constants without associated data. Use sealed class when each variant carries different data shapes. sealed interface (Kotlin 1.5+) is preferred when subclasses need to extend other classes.
✓ Key Takeaways
- Model all UI state as sealed classes: Loading / Success / Error / Empty.
- Use
data object(Kotlin 1.9+) instead ofobjectfor sealed subclasses — they have properequals/hashCode. - The
outvariance modifier onResult<out T>enables covariance —Result.Loadingcan be returned whereResult<User>is expected.
Collections
Kotlin has two collection hierarchies: read-only (List, Set, Map) and mutable (MutableList, etc.). The distinction is in the interface, not the underlying storage.
Sequence vs Iterable
The key operational difference: Iterable operations are eager (create intermediate collections at each step), while Sequence operations are lazy (process elements one at a time through the entire pipeline).
// Iterable (eager): 3 intermediate lists created val result = users .filter { it.isActive } // → new List .map { it.toDisplayModel() } // → new List .take(10) // → new List // Sequence (lazy): no intermediate collections, stops after 10 val result = users .asSequence() .filter { it.isActive } .map { it.toDisplayModel() } .take(10) .toList()kotlin
✓ Use Sequence when
- Collection has 1000+ elements
- Pipeline has 3+ chained operations
- You use
take/findearly termination - Operations are CPU-intensive
✕ Avoid Sequence when
- Small collections (< 100 elements)
- Single operation chains
- Terminal operation is
groupBy/toMap - Sequence overhead exceeds benefit
Key Collection Operations
// groupBy — produces Map<K, List<V>> val byCategory = products.groupBy { it.category } // associate — produces Map from transformation val idToUser: Map<String, User> = users.associateBy { it.id } // fold — accumulate with initial value val total = cart.fold(0.0) { acc, item -> acc + item.price * item.qty } // flatten vs flatMap val allTags = posts.flatMap { it.tags } // map + flatten in one pass // partition — split into (matching, non-matching) val (active, inactive) = users.partition { it.isActive }kotlin
Coroutines Critical
Coroutines are the single most important Kotlin feature for Android. Understanding their execution model, cancellation contract, and structured concurrency is non-negotiable for production code.
Suspend Functions
A suspend function can be paused and resumed without blocking a thread. The compiler transforms suspend functions into a state machine. The continuation captures the execution point and local variables.
// suspend = "this function may pause and resume" // It does NOT mean "runs on background thread" — that's the dispatcher's job suspend fun fetchUser(id: String): User { // withContext switches dispatcher for this block only return withContext(Dispatchers.IO) { api.getUser(id) // blocking call — safe here because IO dispatcher } }kotlin
Coroutine Scope & Structured Concurrency
Structured concurrency means: a coroutine scope outlives none of its children. When a scope is cancelled, all launched coroutines within it are cancelled. This eliminates coroutine leaks.
class UserViewModel : ViewModel() { // viewModelScope is cancelled when ViewModel is cleared fun loadUser(id: String) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } try { val user = userRepository.getUser(id) // suspend _uiState.update { it.copy(user = user, isLoading = false) } } catch (e: CancellationException) { throw e // ALWAYS rethrow CancellationException } catch (e: Exception) { _uiState.update { it.copy(error = e, isLoading = false) } } } } }kotlin
Dispatchers
| Dispatcher | Thread Pool | Use For |
|---|---|---|
Dispatchers.Main | Main/UI thread | UI updates, observing state |
Dispatchers.IO | 64+ threads (elastic) | Network, file, DB operations |
Dispatchers.Default | CPU core count | CPU-intensive computation |
Dispatchers.Unconfined | Caller's thread | Testing only — avoid in production |
async/await for Parallel Execution
suspend fun loadDashboard(): Dashboard = coroutineScope { // Both requests launch concurrently, not sequentially val userDeferred = async { userRepo.getCurrentUser() } val feedDeferred = async { feedRepo.getLatestFeed() } // await() suspends until result is ready Dashboard( user = userDeferred.await(), feed = feedDeferred.await() ) // coroutineScope cancels both if either throws }kotlin
⚠ Common Coroutine Mistakes
- Catching
CancellationExceptionwithout rethrowing — breaks cancellation propagation - Using
GlobalScope.launch— no structured lifecycle, potential leaks - Calling blocking code on
Dispatchers.Main— causes ANR - Using
runBlockingon the main thread in production - Creating a new
CoroutineScopeinstead of using the provided scope — creates islands outside structured concurrency
Coroutines Internals
Understanding how coroutines work at the bytecode level helps you reason about performance, cancellation, and debugging.
CPS Transformation
The compiler transforms every suspend function into a Continuation-Passing Style (CPS) function. Every suspension point becomes a labeled LABEL in a state machine. When resumed, execution jumps to the correct label with the intermediate result.
Continuation & CoroutineContext
A Continuation<T> is conceptually a callback: resumeWith(Result<T>). The CoroutineContext is an indexed set of elements — Job, Dispatcher, CoroutineName, CoroutineExceptionHandler. Each element is accessed by a typed key.
// Accessing context elements launch(coroutineContext) { val job = coroutineContext[Job] val dispatcher = coroutineContext[ContinuationInterceptor] } // CoroutineExceptionHandler — for launch, not async (async exceptions are deferred) val handler = CoroutineExceptionHandler { _, throwable -> logger.e("Uncaught coroutine exception", throwable) } launch(handler) { riskyOperation() }kotlin
Job Hierarchy & Cancellation
Jobs form a parent-child tree. When a parent job is cancelled, all children are cancelled recursively. When a child fails with an unhandled exception, it propagates up to the parent (unless using SupervisorJob).
// SupervisorJob: child failures don't cancel siblings val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) // supervisorScope in suspend context supervisorScope { val a = async { flakyCall() } // failure here doesn't cancel b val b = async { stableCall() } // handle a's failure separately try { a.await() } catch (e: Exception) { /* fallback */ } b.await() }kotlin
Flow vs LiveData
LiveData is lifecycle-aware but conceptually limited. Flow is the Kotlin-native reactive stream with backpressure, operators, and full coroutine integration. New code should use Flow exclusively.
Cold vs Hot
Flow is cold by default — the producer block runs only when collected, independently for each collector. SharedFlow and StateFlow are hot — they exist independently of collectors and maintain state or replay values.
// Cold Flow — executes per collector fun observeUser(id: String): Flow<User> = flow { while (true) { emit(api.getUser(id)) delay(30_000) } } // StateFlow — hot, single current value, no replay buffer private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() // SharedFlow — hot, configurable replay private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 16) val events = _events.asSharedFlow()kotlin
Collecting in Compose
@Composable fun UserScreen(viewModel: UserViewModel = hiltViewModel()) { // collectAsStateWithLifecycle — Compose + lifecycle-aware (preferred) val uiState by viewModel.uiState.collectAsStateWithLifecycle() // For one-shot events (SharedFlow) LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { is UiEvent.NavigateTo -> navController.navigate(event.route) is UiEvent.ShowSnackbar -> snackbarHost.showSnackbar(event.message) } } } }kotlin
Key Flow Operators
// transform pipeline userFlow .filter { it.isActive } .map { it.toUiModel() } .distinctUntilChanged() // skip if same value .debounce(300) // wait for stable input .flatMapLatest { searchApi(it) } // cancel previous on new emission .catch { e -> emit(UiModel.Error(e)) } .flowOn(Dispatchers.IO) // upstream runs on IOkotlin
| Scenario | Use | Reason |
|---|---|---|
| UI state in ViewModel | StateFlow | Single value, lifecycle-safe |
| One-time events | SharedFlow | No current value, event-based |
| DB / network streams | Cold Flow | Lazy, per-collector execution |
| Combine two streams | combine() | Emits on either source change |
| Search / debounce | flatMapLatest + debounce | Cancel stale requests |
Dependency Injection
DI is not about frameworks — it's about making dependencies explicit and injectable. Hilt is the recommended framework, but the architectural reasoning applies to manual DI, Koin, or Dagger.
Why DI Matters
Hard-coded dependencies (val repo = UserRepository()) inside ViewModels or composables make code impossible to unit-test without real network calls. DI decouples instantiation from usage.
// Hilt setup — Application @HiltAndroidApp class App : Application() // Hilt module — provides dependencies @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideRetrofit(): Retrofit = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() @Provides fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java) } // ViewModel — injected automatically @HiltViewModel class UserViewModel @Inject constructor( private val getUser: GetUserUseCase // use case, not repo directly ) : ViewModel()kotlin
Scopes in Hilt
| Scope | Lifetime | Component |
|---|---|---|
@Singleton | App lifetime | SingletonComponent |
@ActivityRetainedScoped | Activity (survives rotation) | ActivityRetainedComponent |
@ViewModelScoped | ViewModel lifetime | ViewModelComponent |
@ActivityScoped | Activity instance | ActivityComponent |
Threading Model
Android's threading model has one invariant: UI operations must happen on the main thread. Everything else is a performance and correctness concern.
Main Thread Responsibility
The main thread runs at 60-120fps. Any operation taking more than ~8-16ms on the main thread causes a dropped frame. Network calls, disk I/O, large list processing — all must be moved off-thread.
Dispatcher Selection Rules
The decision tree: blocking I/O (Retrofit, Room, file) → Dispatchers.IO. CPU computation (sorting, encryption, image processing) → Dispatchers.Default. UI state updates → Dispatchers.Main. Note: withContext(Dispatchers.Main.immediate) avoids unnecessary dispatch if already on Main.
class ImageProcessor @Inject constructor( @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher ) { suspend fun compressImage(bitmap: Bitmap): ByteArray = withContext(defaultDispatcher) { // CPU-bound — off-load to Default dispatcher ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 85, it) }.toByteArray() } }kotlin
Compose Mental Model
Compose's programming model is fundamentally different from View-based Android development. Mapping old concepts onto Compose produces bad code. Start fresh.
Declarative vs Imperative
Views: mutate the existing UI tree (textView.text = "..."). Compose: describe the entire UI for a given state. The runtime reconciles what changed — you never mutate UI elements directly.
The Composition, Layout, Draw Pipeline
Every frame goes through three phases: Composition (run composable functions, build the slot table), Layout (measure and place), Drawing (render to canvas). Work done only in layout doesn't trigger recomposition. Work done in drawing doesn't trigger layout. Understanding this unlocks performance optimizations.
Composables
A composable function is any function annotated with @Composable. It doesn't return a value — it emits UI nodes into the composition tree.
Rules of Composables
These are enforced by the Compose compiler plugin, not by convention:
- ✓Composables must only be called from other composables or from a composable scope
- ✓Side effects must use effect handlers (
LaunchedEffect,SideEffect,DisposableEffect) - ✓Never read mutable state directly — use
State<T>wrappers - ✓Composables must be fast — no blocking calls, no heavy computation
Stateless vs Stateful Composables
// Stateless — takes data and callbacks, owns nothing @Composable fun UserCard( user: User, onFollowClick: () -> Unit, modifier: Modifier = Modifier ) { Card(modifier = modifier) { Text(text = user.name) Button(onClick = onFollowClick) { Text("Follow") } } } // Stateful — manages its own state @Composable fun ExpandableCard(title: String, content: String) { var expanded by remember { mutableStateOf(false) } Column(modifier = Modifier.clickable { expanded = !expanded }) { Text(title) if (expanded) Text(content) } }kotlin
Effect Handlers
// LaunchedEffect — coroutine tied to key; reruns on key change LaunchedEffect(userId) { viewModel.loadUser(userId) // coroutine cancelled+restarted if userId changes } // DisposableEffect — for cleanup-required effects DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) onResume() } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } // rememberCoroutineScope — for user-triggered coroutines val scope = rememberCoroutineScope() Button(onClick = { scope.launch { performAction() } }) { Text("Go") }kotlin
Recomposition
Recomposition is Compose re-running composable functions when their state inputs change. Understanding when it triggers and how to minimize unnecessary recompositions is central to Compose performance.
Smart Recomposition
Compose's compiler plugin tracks which composables read which State objects. Only composables that directly read changed state are recomposed — not their parents, not their siblings. This is called scoped recomposition.
Stability & @Stable / @Immutable
Compose uses stability to determine if a composable can be skipped when its inputs haven't changed. A type is stable if the compiler can verify it: all fields are immutable and of stable types, or it's annotated with @Stable / @Immutable.
// Unstable — list can change without equality signaling it data class UiState(val users: List<User> = emptyList()) // Compose compiler can't verify List is immutable → not skippable // Stable — use ImmutableList from kotlinx.collections.immutable @Immutable data class UiState(val users: ImmutableList<User> = persistentListOf()) // Or annotate the data class directly @Stable class UserState { var name by mutableStateOf("") // reads/writes tracked by Compose }kotlin
remember & rememberSaveable
remember survives recomposition but not configuration change. rememberSaveable persists through configuration changes via the saved state mechanism. Use the latter for UI state the user would expect to survive rotation.
State Handling in Compose
State Primitives
// mutableStateOf — triggers recomposition on assignment var count by remember { mutableStateOf(0) } // mutableStateListOf — structural changes trigger recomposition val items = remember { mutableStateListOf<String>() } // derivedStateOf — computed state, only recomputes when inputs change val isFormValid by remember { derivedStateOf { email.isNotBlank() && password.length >= 8 } } // derivedStateOf prevents recalculating on every keystroke to unrelated fieldskotlin
ViewModel → Compose Integration
@HiltViewModel class SearchViewModel @Inject constructor( private val search: SearchUseCase ) : ViewModel() { private val _state = MutableStateFlow(SearchState()) val state = _state.asStateFlow() fun onQueryChanged(query: String) { _state.update { it.copy(query = query) } viewModelScope.launch { delay(300) // debounce val results = search(query) _state.update { it.copy(results = results) } } } }kotlin
Layout System
Compose's layout system is fundamentally different from ConstraintLayout/LinearLayout. It measures each child once (as a rule), making deeply nested layouts non-problematic by design.
Single-Pass Measurement
Views can measure children multiple times, causing exponential measurement time in deep hierarchies. Compose enforces single-pass measurement — if you need to measure children multiple times, you use Intrinsics explicitly (expensive — avoid).
Core Layout Composables
// Box — frame layout, z-indexed stacking Box(contentAlignment = Alignment.Center) { Image(...) Text("Overlay", modifier = Modifier.align(Alignment.BottomEnd)) } // Row / Column — with weight for flexible sizing Row(modifier = Modifier.fillMaxWidth()) { Text("Label", modifier = Modifier.weight(1f)) // takes remaining space Icon(..., modifier = Modifier.size(24.dp)) // fixed size } // LazyColumn / LazyRow — only composes visible items LazyColumn( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(items = users, key = { it.id }) { user -> // key = stable identity UserCard(user = user) } item { Spacer(modifier = Modifier.height(80.dp)) } // bottom padding trick }kotlin
Always provide a stable, unique key to items(). Without it, Compose uses index-based identity — inserts and deletions cause unnecessary recomposition of all items below the change point.
Modifiers — Deep Dive
Modifiers are the single most important concept to master in Compose. They are how you apply layout constraints, drawing, interaction, and semantics to composables — in order.
Order Matters
Modifier chains are applied sequentially. clickable before padding means the padding area is not clickable. padding before clickable means it is. This is the inverse of View's padding behavior — internalize it.
// clickable outside padding — touch area excludes padding Modifier .clickable { } .padding(16.dp) // clickable inside padding — touch area includes padding Modifier .padding(16.dp) .clickable { }kotlin
Critical Modifiers Reference
| Modifier | Phase | Notes |
|---|---|---|
fillMaxSize/Width/Height | Layout | Constrains to parent's max |
size(dp) | Layout | Exact size — overrides parent constraints |
wrapContentSize | Layout | Ignore min constraints, align within |
weight(f) | Layout | Proportion of remaining space in Row/Column |
offset(x, y) | Layout | Moves without affecting layout of siblings |
graphicsLayer | Draw | Hardware-accelerated transforms, alpha |
drawBehind | Draw | Custom Canvas drawing behind composable |
pointerInput | Input | Low-level gesture detection |
semantics | A11y | Accessibility tree annotations |
testTag | Testing | Node locator in UI tests |
graphicsLayer vs offset
graphicsLayer transforms happen in the draw phase — they don't affect layout. Animations driven by graphicsLayer never trigger layout or recomposition. Always prefer graphicsLayer for animation transforms over offset() which triggers layout recalculation.
Theming System
Compose's Material3 theming system is built on CompositionLocal — a scoped value that propagates implicitly down the composition tree. Understanding this pattern is key to building custom design systems.
@Composable fun MyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= 31 -> { if (darkTheme) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(LocalContext.current) } darkTheme -> DarkColorScheme else -> LightColorScheme } MaterialTheme( colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content ) } // Accessing theme values Text( text = "Hello", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary )kotlin
Custom CompositionLocals
// Define val LocalAppDimensions = staticCompositionLocalOf { AppDimensions() } // Provide at root CompositionLocalProvider(LocalAppDimensions provides compactDimensions) { MyAppTheme { MyApp() } } // Consume anywhere in the tree val dims = LocalAppDimensions.currentkotlin
Compose Performance
Compose is fast by default. Performance problems are almost always caused by unstable parameters triggering unnecessary recomposition, or heavy work inside the composition phase.
The Recomposition Profiling Workflow
Enable Layout Inspector's recomposition counts. Any composable with counts above 1 per user interaction is suspect. Drill into what state it reads and why it changes.
Stability Fixes
// Problem: Function references create new lambdas on every recomposition LazyColumn { items(users) { user -> // This lambda is new on every recomposition of parent UserCard(user, onClick = { viewModel.onUserClick(user.id) }) } } // Fix 1: remember the lambda val onClick = remember(user.id) {{ viewModel.onUserClick(user.id) }} // Fix 2: pass stable callbacks through ViewModel // Fix 3: Use Compose compiler's lambda stability inference (Kotlin 2.0)kotlin
Defer State Reads
Reading state as late as possible in the call tree minimizes the scope of recomposition. Reading state in the draw phase (via graphicsLayer) skips composition and layout entirely.
// Bad: reads offset state in composition → full recomposition on scroll @Composable fun Header(scrollState: ScrollState) { val alpha = (1f - scrollState.value / 300f).coerceIn(0f, 1f) Box(modifier = Modifier.alpha(alpha)) { ... } } // Good: defers read to draw phase → no recomposition on scroll @Composable fun Header(scrollState: ScrollState) { Box(modifier = Modifier.graphicsLayer { alpha = (1f - scrollState.value / 300f).coerceIn(0f, 1f) }) { ... } }kotlin
✓ Performance Checklist
- All LazyList items have a stable
keyparameter - Data classes passed to composables are
@Stableor@Immutable - Lists use
ImmutableListnotList - Lambdas in LazyList items are wrapped in
remember - Scroll-driven animations use
graphicsLayerto defer state reads - Heavy computation is in
derivedStateOfor moved to ViewModel
State Hoisting
State hoisting is the pattern of moving state upward in the composable tree to make composables stateless. It's not just an architectural preference — it's the mechanism that makes composables testable and reusable.
The Pattern
Instead of a composable owning its state, it receives the current value and a function to update it. The "update function" is typically a lambda that the parent manages.
// Before hoisting — owns its state, not reusable/testable @Composable fun EmailField() { var email by remember { mutableStateOf("") } TextField(value = email, onValueChange = { email = it }) } // After hoisting — stateless, composable, testable @Composable fun EmailField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier ) { TextField(value = value, onValueChange = onValueChange, modifier = modifier) } // Parent manages state @Composable fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() EmailField(value = state.email, onValueChange = viewModel::onEmailChanged) }kotlin
How High to Hoist?
Hoist state to the lowest common ancestor of all composables that need to read or write it. Hoisting too high causes unnecessary recomposition of unrelated subtrees.
Custom Layouts
// Layout composable — full control over measurement and placement @Composable fun StaggeredGrid( columns: Int, modifier: Modifier = Modifier, content: @Composable () -> Unit ) { Layout(content = content, modifier = modifier) { measurables, constraints -> val colWidth = constraints.maxWidth / columns val colHeights = IntArray(columns) { 0 } val placeables = measurables.map { measurable -> val col = colHeights.indexOfMin() val placeable = measurable.measure( constraints.copy(maxWidth = colWidth, minWidth = colWidth) ) colHeights[col] += placeable.height Pair(placeable, col) } val totalHeight = colHeights.max() layout(constraints.maxWidth, totalHeight) { val colY = IntArray(columns) { 0 } placeables.forEach { (placeable, col) -> placeable.placeRelative(col * colWidth, colY[col]) colY[col] += placeable.height } } } }kotlin
Animation System
Compose has a layered animation API. Understanding which layer to use avoids both over-engineering and under-expressive code.
| API | Level | Use When |
|---|---|---|
AnimatedVisibility | High | Show/hide with enter/exit transitions |
AnimatedContent | High | Crossfade between different content states |
animateContentSize | High (modifier) | Animate size changes of a composable |
animate*AsState | Mid | Single-value animation tied to state |
updateTransition | Mid | Coordinated multi-value transitions |
rememberInfiniteTransition | Mid | Looping animations |
Animatable | Low | Imperative, coroutine-driven animation |
// animate*AsState — simplest form val scale by animateFloatAsState( targetValue = if (isPressed) 0.95f else 1f, animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "button_scale" ) // updateTransition — coordinate multiple values val transition = updateTransition(uiState, label = "card") val elevation by transition.animateDp(label = "elevation") { state -> if (state.isExpanded) 8.dp else 2.dp } val contentAlpha by transition.animateFloat(label = "alpha") { state -> if (state.isExpanded) 1f else 0f }kotlin
MVVM in Compose
MVVM in Compose has a tighter, cleaner form than in Views. The View layer is pure functions — no observer setup, no binding, no lifecycle management in the UI layer.
ViewModel Responsibilities
The ViewModel's contract: hold UI state, process user events, call use cases, transform domain models to UI models. It knows nothing about Android UI (no View references, no Context except ApplicationContext via Hilt).
@HiltViewModel class ProfileViewModel @Inject constructor( private val getProfile: GetProfileUseCase, private val updateProfile: UpdateProfileUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileUiState()) val uiState = _uiState.asStateFlow() // Single channel for one-shot events private val _events = Channel<ProfileEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow() fun onSaveClicked() { viewModelScope.launch { _uiState.update { it.copy(isSaving = true) } when (val result = updateProfile(_uiState.value.toRequest())) { is Result.Success -> _events.send(ProfileEvent.SaveSuccess) is Result.Error -> _uiState.update { it.copy(error = result.message) } Result.Loading -> Unit } _uiState.update { it.copy(isSaving = false) } } } }kotlin
Clean Architecture
Clean Architecture enforces a dependency rule: inner layers know nothing about outer layers. Domain knows nothing about Android. This makes the core business logic testable in isolation.
Module Structure
| Module | Contents | Depends On |
|---|---|---|
:app | DI setup, Application class | All modules |
:feature:* | Screen composables, ViewModels | :domain, :ui-common |
:domain | Use cases, domain models, repo interfaces | Nothing (pure Kotlin) |
:data | Repository impls, API, DB | :domain |
:ui-common | Design system, shared composables | Nothing Android-specific |
Domain Model vs DTO vs UI Model
Three distinct model types prevent coupling layers. DTOs match the API/DB schema — they're generated or map directly to JSON/columns. Domain models are the "truth" of your business rules. UI models are trimmed, formatted, and UI-specific transformations of domain models.
// DTO — maps 1:1 to API JSON data class UserDto(@Json(name = "user_id") val id: String, ...) // Domain model — business truth, no framework annotations data class User(val id: UserId, val name: String, val joinDate: Instant, ...) // UI model — display-ready, no business logic data class UserUiModel(val displayName: String, val formattedDate: String, ...)kotlin
Unidirectional Data Flow
UDF is a constraint, not a pattern. State flows down, events flow up. No composable ever modifies state it doesn't own. This makes debugging trivially easy — every state change has a single traceable origin.
UiState Design
Prefer a single UiState data class over multiple StateFlows. Atomic state updates via update { it.copy(...) } prevent the inconsistency window between multiple separate flow updates.
data class FeedUiState( val posts: ImmutableList<PostUiModel> = persistentListOf(), val isLoading: Boolean = false, val isRefreshing: Boolean = false, val error: UiError? = null, val endReached: Boolean = false ) // Every state transition is a copy() call — fully auditablekotlin
Repository Pattern
The repository is the single source of truth for a domain entity. It abstracts the origin of data (network, cache, database) from the rest of the app. It decides where to read from and what to cache — callers don't care.
// Interface defined in :domain — no Android imports interface UserRepository { fun observeUser(id: UserId): Flow<User> suspend fun refreshUser(id: UserId) suspend fun updateUser(user: User): Result<Unit> } // Implementation in :data — Room as truth, network as source class UserRepositoryImpl @Inject constructor( private val api: UserApi, private val dao: UserDao, private val dispatcher: CoroutineDispatcher ) : UserRepository { override fun observeUser(id: UserId): Flow<User> = dao.observeById(id.value) .map { it.toDomain() } .flowOn(dispatcher) override suspend fun refreshUser(id: UserId) = withContext(dispatcher) { val dto = api.getUser(id.value) dao.upsert(dto.toEntity()) } }kotlin
Use Cases
Use cases (interactors) encapsulate a single piece of business logic. They are the primary boundary between the ViewModel and the domain. They make testing trivial — mock the use case, not the entire repository.
// Use case — a single callable class with operator fun invoke() class GetFeedUseCase @Inject constructor( private val feedRepository: FeedRepository, private val userRepository: UserRepository ) { operator fun invoke(userId: UserId): Flow<List<FeedItem>> = combine( feedRepository.observeFeed(userId), userRepository.observeUser(userId) ) { feed, user -> feed.filter { user.blockedUsers.none { b -> b == it.authorId } } .sortedByDescending { it.publishedAt } } }kotlin
API Integration
Production API integration involves more than Retrofit setup. It requires interceptors, error mapping, response handling, and a contract for surfacing errors to the UI layer.
// Retrofit setup with auth interceptor + logging val client = OkHttpClient.Builder() .addInterceptor(AuthInterceptor(tokenProvider)) .addInterceptor(HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE }) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() // Safe API call wrapper — catches network exceptions suspend fun <T> safeApiCall( dispatcher: CoroutineDispatcher = Dispatchers.IO, apiCall: suspend () -> T ): Result= withContext(dispatcher) { try { Result.Success(apiCall()) } catch (e: HttpException) { Result.Error(e, parseHttpError(e.code())) } catch (e: IOException) { Result.Error(e, "No internet connection") } } kotlin
Error Handling Strategy
A consistent error handling strategy prevents scattered try-catch blocks, inconsistent user messaging, and silent failures.
Domain Error Hierarchy
sealed class DomainError : Exception() { data class Network(val code: Int? = null) : DomainError() data object Unauthorized : DomainError() data object NotFound : DomainError() data class Server(val message: String) : DomainError() data object Unknown : DomainError() } // Map HTTP errors to domain errors in the data layer fun HttpException.toDomainError(): DomainError = when (code()) { 401, 403 -> DomainError.Unauthorized 404 -> DomainError.NotFound in 500..599 -> DomainError.Server(parseErrorBody()) else -> DomainError.Network(code()) } // In ViewModel — map to user-visible strings fun DomainError.toUiMessage(): UiText = when (this) { DomainError.Unauthorized -> UiText.Res(R.string.error_unauthorized) DomainError.NotFound -> UiText.Res(R.string.error_not_found) is DomainError.Server -> UiText.Dynamic(message) else -> UiText.Res(R.string.error_generic) }kotlin
Pagination with Paging 3
// PagingSource — defines how to load pages class UserPagingSource( private val api: UserApi ) : PagingSource<Int, User>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> { val page = params.key ?: 1 return try { val response = api.getUsers(page = page, size = params.loadSize) LoadResult.Page( data = response.users.map { it.toDomain() }, prevKey = if (page == 1) null else page - 1, nextKey = if (response.users.isEmpty()) null else page + 1 ) } catch (e: Exception) { LoadResult.Error(e) } } override fun getRefreshKey(state: PagingState<Int, User>) = state.anchorPosition?.let { anchor -> state.closestPageToPosition(anchor)?.prevKey?.plus(1) ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1) } } // In Compose — LazyPagingItems @Composable fun UserList(viewModel: UserViewModel) { val users = viewModel.userPager.collectAsLazyPagingItems() LazyColumn { items(users.itemCount, key = users.itemKey { it.id }) { index -> users[index]?.let { UserCard(it) } } users.apply { when { loadState.append is LoadState.Loading -> item { LoadingIndicator() } loadState.append is LoadState.Error -> item { RetryButton { retry() } } } } } }kotlin
Offline-First Architecture
Offline-first means the app is fully functional without a network connection. Room is the source of truth. Network is a synchronization mechanism, not the data source.
Single Source of Truth Pattern
// Repository: observe DB, trigger network refresh fun getArticles(): Flow<List<Article>> = flow { // Emit from DB immediately emitAll(dao.observeArticles().map { it.toDomain() }) } suspend fun refreshArticles() { val articles = api.getArticles() dao.deleteAll() dao.insertAll(articles.map { it.toEntity() }) // DB update triggers Flow emission automatically }kotlin
WorkManager for Background Sync
Use WorkManager for periodic sync, retry logic, and guaranteed execution. Pair with NetworkType.CONNECTED constraint to avoid syncing on cellular when appropriate.
Caching Strategies
| Strategy | When | TTL |
|---|---|---|
| Cache-first | Static or slow-changing data | Hours to days |
| Network-first | Frequently updated data | Minutes |
| Stale-while-revalidate | Show cached, refresh in background | Configurable |
| Cache-only | Offline mode | Until explicit invalidation |
Implement TTL by storing a lastFetched: Long (epoch ms) in Room alongside the cached data. In the repository, check elapsed time before triggering a network refresh.
Android Studio
Essential Shortcuts
| Action | Mac | Windows/Linux |
|---|---|---|
| Find action | ⌘⇧A | Ctrl+Shift+A |
| Navigate to class | ⌘O | Ctrl+N |
| Navigate to file | ⌘⇧O | Ctrl+Shift+N |
| Recent files | ⌘E | Ctrl+E |
| Refactor rename | ⇧F6 | Shift+F6 |
| Optimize imports | ⌃⌥O | Ctrl+Alt+O |
| Live Compose Preview | ⌘⇧↩ | Ctrl+Shift+Enter |
| Interactive Preview | Click ▶ in gutter | Click ▶ in gutter |
Compose Preview Best Practices
@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "Large Text", fontScale = 1.5f) @Composable fun UserCardPreview() { MyAppTheme { UserCard( user = previewUser(), // dedicated preview data function onFollowClick = {} ) } }kotlin
Debugging
Compose-Specific Debugging
Enable the Compose recomposition count in Layout Inspector. Every composable displays its recomposition count — counts above 1 for a single user action indicates unnecessary recomposition.
Use Modifier.debugRecomposeHighlighter() (custom modifier) to visually flash composables on recomposition during development. Add it to your debug build only.
Coroutine Debugging
Add -Dkotlinx.coroutines.debug JVM flag to see coroutine names in stack traces. Use structured naming with CoroutineName("LoadUserData") in launch calls to identify coroutines in logs.
Flow Debugging
// onEach for non-terminal side-effects (logging, analytics) userFlow .onEach { Timber.d("User updated: ${it.id}") } .onStart { Timber.d("Collection started") } .onCompletion { Timber.d("Collection completed") } .catch { e -> Timber.e(e) } .collect { updateUi(it) }kotlin
Profiling
Android Studio Profiler
CPU Profiler: Use "System Trace" to see frame rendering timelines. Jank shows as red frames. Drill into the main thread to see which methods are consuming time during composition, layout, and drawing phases.
Memory Profiler: Watch for sawtooth allocation patterns (frequent GC). Flat rising lines indicate leaks. Record allocations during a user interaction to find unexpected object creation.
Macrobenchmark
@RunWith(AndroidJUnit4::class) class FeedScrollBenchmark { @get:Rule val benchmarkRule = MacrobenchmarkRule() @Test fun scrollFeedWithCompilation() = benchmarkRule.measureRepeated( packageName = "com.example.app", metrics = listOf(FrameTimingMetric()), compilationMode = CompilationMode.Full(), iterations = 5, setupBlock = { startActivityAndWait() } ) { val feed = device.findObject(By.res("feed_list")) feed.setGestureMargin(device.displayWidth / 5) feed.fling(Direction.DOWN) } }kotlin
Testing Strategy
Test at the right layer. ViewModel tests cover business logic. Use case tests cover domain rules. Repository tests cover data layer contracts. UI tests cover critical user flows.
ViewModel Testing
@OptIn(ExperimentalCoroutinesApi::class) class UserViewModelTest { @get:Rule val coroutineRule = MainDispatcherRule() // replaces Main with TestDispatcher private val fakeRepo = FakeUserRepository() private lateinit var viewModel: UserViewModel @Before fun setUp() { viewModel = UserViewModel(GetUserUseCase(fakeRepo)) } @Test fun `loadUser emits loading then success`() = runTest { fakeRepo.setUser(aUser()) val states = mutableListOf<UserUiState>() val job = launch { viewModel.uiState.toList(states) } viewModel.loadUser("id-1") advanceUntilIdle() assertThat(states[0].isLoading).isTrue() assertThat(states.last().user).isEqualTo(aUser()) job.cancel() } }kotlin
Compose UI Testing
@RunWith(AndroidJUnit4::class) class UserCardTest { @get:Rule val composeRule = createComposeRule() @Test fun followButtonClickCallsCallback() { var clicked = false composeRule.setContent { UserCard(user = aUser(), onFollowClick = { clicked = true }) } composeRule.onNodeWithText("Follow").performClick() assertThat(clicked).isTrue() } }kotlin
Performance Checklist
- ✓Baseline Profile generated and included in release build
- ✓R8 full mode enabled — removes unused code, renames identifiers
- ✓ProGuard rules verified — no over-keeping serializable models
- ✓Image loading via Coil/Glide with size sampling and disk cache
- ✓All Lazy lists have stable keys
- ✓No
!!operators in production code - ✓Strict mode enabled in debug builds
- ✓App startup time measured with
AppStartupBenchmark - ✓Deep links validated with App Links Assistant
- ✓Memory leak detection (LeakCanary) runs in debug builds
Security Basics
Secrets & Storage
Never store secrets in BuildConfig, shared preferences (unencrypted), or local.properties committed to VCS. Use EncryptedSharedPreferences or the Android Keystore system for sensitive values. Tokens should be in Keystore-backed storage, not cleartext.
// EncryptedSharedPreferences — AES256 + keystore-backed key val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() val prefs = EncryptedSharedPreferences.create( context, "secure_prefs", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM )kotlin
Network Security
Use network_security_config.xml to enforce HTTPS and optionally pin certificates. Never disable certificate validation — this includes disabling validation for development builds. Use a Charles Proxy certificate instead.
Root / Tamper Detection
Use Play Integrity API for binding app authenticity to Google's attestation infrastructure. This replaces SafetyNet. Do not implement your own root detection — it's trivially bypassed and adds maintenance burden.
App Optimization
Startup Optimization
Use the App Startup library to consolidate and lazy-initialize third-party SDKs. Profile cold/warm/hot start with Macrobenchmark. Move non-critical initialization off the main thread using Dispatchers.Default.
APK / AAB Size
Serve split APKs via Play's delivery system. Use vector drawables for icons. Compress all PNG assets. Use WebP with quality 85 for photos. Audit dependencies with the dependencyAnalysis Gradle plugin to remove unused libraries.
Battery Optimization
Use WorkManager for deferred work with battery constraints. Avoid waking locks. Use JobScheduler constraints for network-dependent background work. Never use AlarmManager for exact timing in the modern permission model unless user-facing (calendar reminders).
Build & Release Flow
Gradle Setup
// build.gradle.kts — Kotlin DSL recommended android { buildTypes { debug { isDebuggable = true applicationIdSuffix = ".debug" versionNameSuffix = "-debug" } release { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("release") } } // Signing from environment variables — never hardcode in build files signingConfigs { create("release") { storeFile = file(System.getenv("KEYSTORE_PATH") ?: "debug.keystore") storePassword = System.getenv("KEYSTORE_PASSWORD") keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") } } }kotlin
CI/CD Pipeline
- ✓Unit tests on every PR (
./gradlew test) - ✓Lint checks — treat warnings as errors in CI
- ✓Instrumented tests on Firebase Test Lab on main branch
- ✓AAB built with release signing → upload to Play internal track
- ✓Baseline Profile regenerated after major UI changes
- ✓Version code auto-incremented from CI build number
Release Tracks Strategy
Internal → Alpha → Beta → Production. Enable staged rollouts (10% → 50% → 100%) for production. Monitor crash-free users and ANR rate in Play Console before expanding. Set up Firebase Crashlytics alerting on crash rate spikes during rollout.
✓ Pre-Release Checklist
- All API keys pointing to production (not staging) endpoints
- Logging stripped or gated behind BuildConfig.DEBUG
- Test accounts and debug menu removed from release build
- Privacy policy URL valid and accessible
- All required Play Store screenshots and metadata updated
- Target SDK at latest stable version
- Crashlytics initialized and reporting verified
Baseline Profiles pre-compile hot code paths and are included in the AAB. They measurably reduce startup time and scroll jank on first launch. Generate them with Macrobenchmark and include the generated baseline-prof.txt in your release module. This is one of the highest ROI production optimizations available.
Was this guide helpful?
Your feedback helps us continuously improve our developer docs.
