Jetpack Compose Core Concepts
Declarative vs. Imperative UI
The traditional Android View system is fundamentally imperative: you hold references to View objects and call their methods to mutate their state.
// Imperative (Traditional View System)
textView.text = "Hello"
button.isEnabled = false
progressBar.visibility = View.VISIBLE
Jetpack Compose is entirely declarative: you describe what the UI should look like given a specific state, and the framework takes full responsibility for mapping that description to the actual rendered UI tree.
// Declarative (Jetpack Compose)
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
Column {
Text(text = "Count: $count")
Button(onClick = onIncrement) {
Text("Increment")
}
}
}
// When 'count' changes, Compose automatically recomposes the UI.
// You never manually invoke textView.text = ...
Recomposition
Recomposition is the core execution engine of Compose: When the state a @Composable function depends on changes, Compose will re-invoke that function. It generates a new UI tree, diffs it against the previous one, and precisely updates only the nodes that mutated.
@Composable
fun LoginScreen() {
var username by remember { mutableStateOf("") } // State
var password by remember { mutableStateOf("") }
Column {
// When username changes, ONLY this part recomposes. (Compose is smart)
TextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") }
)
TextField(
value = password,
onValueChange = { password = it },
visualTransformation = PasswordVisualTransformation(),
label = { Text("Password") }
)
Button(
onClick = { /* Handle Login */ },
enabled = username.isNotEmpty() && password.isNotEmpty()
) {
Text("Login")
}
}
}
Critical Properties of Recomposition:
- Idempotency: Identical inputs must yield identical UI. Composable functions must be side-effect free and deterministic.
- Speed & Precision: Compose only recomposes the specific functions reading the mutated state, not the entire screen.
- Skippable: If the parameters haven't changed, Compose can entirely skip executing the
@Composablefunction (leveraging@Stablemarkers to aid the compiler's judgment).
State Management
remember and mutableStateOf
// remember: Retains the state across recompositions (otherwise it resets every time the function is called)
// mutableStateOf: Creates an observable state; mutating it triggers recomposition
var count by remember { mutableStateOf(0) }
// ❌ FATAL FLAW: Without remember, count resets to 0 upon every single recomposition
var count = mutableStateOf(0)
State Hoisting
State hoisting is the pattern of moving state from a child Composable up to its parent, rendering the child stateless (a pure presentation component).
// ❌ State is tightly coupled inside the component (Hard to reuse, impossible to test)
@Composable
fun CounterButton() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
// ✓ State Hoisting: Component is stateless, dictated entirely by the caller
@Composable
fun CounterButton(count: Int, onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("Count: $count")
}
}
// The Parent Component holds the source of truth
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
CounterButton(count = count, onIncrement = { count++ })
}
ViewModel + StateFlow (Industrial Standard)
@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel() {
private val _count = MutableStateFlow(0)
val count = _count.asStateFlow()
fun increment() { _count.value++ }
}
@Composable
fun CounterScreen(viewModel: MainViewModel = hiltViewModel()) {
// Collects the Flow in a lifecycle-aware manner
val count by viewModel.count.collectAsStateWithLifecycle()
CounterButton(count = count, onIncrement = viewModel::increment)
}
Side Effects
By design, Composable functions should be pure. However, real-world development necessitates executing side effects (network requests, logging, event listeners). Compose provides robust API surfaces for these exact scenarios:
@Composable
fun UserScreen(userId: String) {
val viewModel: UserViewModel = hiltViewModel()
// LaunchedEffect: Launches a coroutine when the Composable enters the Composition.
// Automatically cancels and restarts if the 'key' (userId) changes.
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
// DisposableEffect: For side effects that require explicit cleanup (analogous to onDestroy).
DisposableEffect(Unit) {
val listener = SomeEventListener { /* handle */ }
SomeEventBus.register(listener)
onDispose {
SomeEventBus.unregister(listener) // Cleans up when leaving the Composition
}
}
// SideEffect: Executes after *every* successful recomposition.
// Used exclusively for synchronizing Compose state with external non-Compose objects.
SideEffect {
analytics.setUserId(userId)
}
val user by viewModel.user.collectAsStateWithLifecycle()
UserContent(user = user)
}
Common Layouts and Modifiers
// Base Layouts (Analogous to LinearLayout)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Title", style = MaterialTheme.typography.headlineMedium)
Text("Content", color = MaterialTheme.colorScheme.onSurface)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Person, contentDescription = null)
Text("Username")
Text("Online", color = Color.Green)
}
// Box (Stacking layout, analogous to FrameLayout)
Box(modifier = Modifier.size(100.dp)) {
Image(painter = rememberAsyncImagePainter(url), contentDescription = null)
// Badge overlaid on the top right corner of the image
Badge(
modifier = Modifier.align(Alignment.TopEnd)
) { Text("3") }
}
// Lists (Analogous to RecyclerView, leverages virtualized rendering)
LazyColumn {
items(userList) { user ->
UserItem(user = user)
}
}
Compose vs. View System Paradigm
| Feature | View System | Jetpack Compose |
|---|---|---|
| Code Volume | High (XML + Kotlin logic) | Minimal (Pure Kotlin) |
| Learning Curve | Moderate | Steeper (Requires grokking Recomposition & State) |
| Performance | Mature and stable | Continuously optimizing (Complex Lazy lists slightly lag standard RecyclerViews) |
| Interoperability | - | AndroidView embeds XML; ComposeView embeds Compose |
| Animations | Manual Animator orchestrations | Declarative via animate*AsState, AnimatedVisibility |
| Testing | Espresso |
ComposeTestRule |
Architectural Verdict: Greenfield projects should default to Compose. Brownfield projects can incrementally migrate module by module, as Compose and the traditional View system possess seamless bidirectional interoperability.