Developer Documentation ⏱️ 48 min read Verified Production Ready

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.

Kotlin 2.0 Compose BOM 2024.09 Coroutines 1.8 Hilt 2.51 Room 2.6 Retrofit 2.11 Navigation 2.8

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.

◎
How to use this

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.

Compose Runtime Loop
State Change
→
Recomposition
→
Layout
→
Draw

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

FunctionContext ObjectReturnsPrimary Use
letitLambda resultNull-safe calls, transformations
runthisLambda resultObject config + compute result
withthisLambda resultGrouping calls on a non-null object
applythisContext objectObject initialization / builder pattern
alsoitContext objectSide 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 apply when you need the lambda result (use run instead)
  • Nesting scope functions more than 2 levels deep — kills readability
  • Using with on nullable objects — use run on 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 var only for DI-injected fields, not for lazy computation (use by 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.

◈
Compose Convention

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 delegate
kotlin
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) = user
kotlin
△
Data class with mutable properties

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 of object for sealed subclasses — they have proper equals/hashCode.
  • The out variance modifier on Result<out T> enables covariance — Result.Loading can be returned where Result<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 / find early 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

DispatcherThread PoolUse For
Dispatchers.MainMain/UI threadUI updates, observing state
Dispatchers.IO64+ threads (elastic)Network, file, DB operations
Dispatchers.DefaultCPU core countCPU-intensive computation
Dispatchers.UnconfinedCaller's threadTesting 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 CancellationException without rethrowing — breaks cancellation propagation
  • Using GlobalScope.launch — no structured lifecycle, potential leaks
  • Calling blocking code on Dispatchers.Main — causes ANR
  • Using runBlocking on the main thread in production
  • Creating a new CoroutineScope instead 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.

Suspend Function → State Machine
suspend fun loadData() { val a = fetchA() / val b = fetchB(a) }
↓
LABEL_0: Start → call fetchA(cont)
→
LABEL_1: result = fetchA result → call fetchB(a, cont)
→
LABEL_2: return fetchB 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 IO
kotlin
ScenarioUseReason
UI state in ViewModelStateFlowSingle value, lifecycle-safe
One-time eventsSharedFlowNo current value, event-based
DB / network streamsCold FlowLazy, per-collector execution
Combine two streamscombine()Emits on either source change
Search / debounceflatMapLatest + debounceCancel 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

ScopeLifetimeComponent
@SingletonApp lifetimeSingletonComponent
@ActivityRetainedScopedActivity (survives rotation)ActivityRetainedComponent
@ViewModelScopedViewModel lifetimeViewModelComponent
@ActivityScopedActivity instanceActivityComponent
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.

View System vs Compose
IMPERATIVE (View)
Event occurs
Find view by ID
Mutate property
Mutate another property
State inconsistency risk
DECLARATIVE (Compose)
State changes
Recomposition triggered
New UI tree described
Runtime diffs + updates

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 fields
kotlin

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
△
LazyColumn Key Rule

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

ModifierPhaseNotes
fillMaxSize/Width/HeightLayoutConstrains to parent's max
size(dp)LayoutExact size — overrides parent constraints
wrapContentSizeLayoutIgnore min constraints, align within
weight(f)LayoutProportion of remaining space in Row/Column
offset(x, y)LayoutMoves without affecting layout of siblings
graphicsLayerDrawHardware-accelerated transforms, alpha
drawBehindDrawCustom Canvas drawing behind composable
pointerInputInputLow-level gesture detection
semanticsA11yAccessibility tree annotations
testTagTestingNode 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.current
kotlin
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 key parameter
  • Data classes passed to composables are @Stable or @Immutable
  • Lists use ImmutableList not List
  • Lambdas in LazyList items are wrapped in remember
  • Scroll-driven animations use graphicsLayer to defer state reads
  • Heavy computation is in derivedStateOf or 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.

APILevelUse When
AnimatedVisibilityHighShow/hide with enter/exit transitions
AnimatedContentHighCrossfade between different content states
animateContentSizeHigh (modifier)Animate size changes of a composable
animate*AsStateMidSingle-value animation tied to state
updateTransitionMidCoordinated multi-value transitions
rememberInfiniteTransitionMidLooping animations
AnimatableLowImperative, 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.

Compose MVVM Data Flow
UI Layer — @Composable functions
↑ UI State (StateFlow)    ↓ Events/Intents
ViewModel — state holder, event processor
↑ Domain Models    ↓ Use Case calls
Domain Layer — Use Cases
↑ Domain Models    ↓ Data requests
Data Layer — Repository + DataSources

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

ModuleContentsDepends On
:appDI setup, Application classAll modules
:feature:*Screen composables, ViewModels:domain, :ui-common
:domainUse cases, domain models, repo interfacesNothing (pure Kotlin)
:dataRepository impls, API, DB:domain
:ui-commonDesign system, shared composablesNothing 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.

UDF in Compose Architecture
UI renders State
↓
User triggers Event
↓
ViewModel processes Event
↓
New State emitted → UI re-renders

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 auditable
kotlin
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
StrategyWhenTTL
Cache-firstStatic or slow-changing dataHours to days
Network-firstFrequently updated dataMinutes
Stale-while-revalidateShow cached, refresh in backgroundConfigurable
Cache-onlyOffline modeUntil 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

ActionMacWindows/Linux
Find action⌘⇧ACtrl+Shift+A
Navigate to class⌘OCtrl+N
Navigate to file⌘⇧OCtrl+Shift+N
Recent files⌘ECtrl+E
Refactor rename⇧F6Shift+F6
Optimize imports⌃⌥OCtrl+Alt+O
Live Compose Preview⌘⇧↩Ctrl+Shift+Enter
Interactive PreviewClick ▶ in gutterClick ▶ 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

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.

Kotlin & Jetpack Compose Developer Handbook  ·  Built for production engineers  ·  2024

Was this guide helpful?

Your feedback helps us continuously improve our developer docs.

Explore More Docs →