Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Jetpack Compose Mechanisms

Avatar for Jaewoong Jaewoong
October 07, 2026

Jetpack Compose Mechanisms

Avatar for Jaewoong

Jaewoong

October 07, 2026

More Decks by Jaewoong

Other Decks in Programming

Transcript

  1. The Composable function Calling Context ✅ ❌ @Composable fun Place(name:

    String) { Person(name) } fun place(name: String) { Person(name) } @Composable fun Person(name: String) { // composable code } @Composable fun Person(name: String) { // composable code }
  2. The Composable function Composable function Kotlin Coroutines @Composable fun Place(name:

    String) { Person(name) } suspend fun fetchPlace(name: String): Place { getPlaceFromDB(name = name) } @Composable fun Person(name: String) { // composable code } setContent { Place(name = "skydoves") } suspend fun getPlaceFromDB(name: String): Place { // work.. } coroutineScope.launch { fetchPlace(name = "skydoves") }
  3. The Composable function Coroutines suspend fun fetchPlace(name: String): Place {

    // work.. } Compile fun fetchPlace( name: String, callback: Continuation<Place> ) { // work.. } • Kotlin supports non-blocking suspension through suspending functions and coroutines. • Suspend functions can only be called from another suspending context. • The compiler transforms suspend functions into Continuation-Passing Style CPS) with an implicit Continuation<T.
  4. The Composable function fun Greeting(name: String, $composer: Composer?, $changed: Int)

    { $composer = $composer.startRestartGroup(<key>) @Composable fun Greeting(name: String) { Text("Hello, $name") } Compose Compiler .. real function body (skipped) .. $composer.endRestartGroup()?.updateScope { nc, _ -> Geeting(name, nc, updateChangedFlags($changed) or 0b1) } }
  5. The Composable function • Compose Compiler transforms the intermediate representation

    IR) of composable functions. • Compose adds a new parameter, $composer, to all composable functions at the end. • The $composer is the runtime context passed through composable calls, allowing compiler-generated code to interact with the Compose Runtime.
  6. The Composable function fun NamePlate( name: String, lastname: String, $composer:

    Composer<*> ) { ... Column(modifier = Modifier.padding(16.dp), $composer) { Text( text = name, $composer Compose Compiler ) Text( text = lastname, $composer ) } ... } @Composable fun NamePlate(name: String, lastname: String) { Column(modifier = Modifier.padding(16.dp)) { Text(text = name) Text(text = lastname) } }
  7. The Composable function • Composable functions can be re-executed, called

    recomposition, unlike standard functions. • The Compose Compiler transforms composable functions, so they can be restarted and skipped efficiently during recomposition.
  8. The Composable function fun Greeting(name: String, $composer: Composer?, $changed: Int)

    { $composer = $composer.startRestartGroup(<key>) ← Start @Composable fun Greeting(name: String) { Text("Hello, $name") } Compose Compiler .. skipped .. $composer.endRestartGroup()?.updateScope { nc, _ -> ← End Geeting(name, nc, updateChangedFlags($changed) or 0b1) } }
  9. The Composable function Always Same • Re-executing a Composable function

    multiple times with the same input parameters should consistently produce the same UI tree. • Compose Runtime relies on this assumption Idempotent) for things like recomposition. • Compose Runtime doesn't re-execute for the same input by assuming Composable functions are idempotent.
  10. Compose Compiler @Composable fun Greeting(name: String) { Text("Hello, $name") }

    ≠ fun Greeting(name: String) { Text("Hello, $name") }
  11. Compose Compiler Hilt & Dagger class ExampleActivity extends Activity {

    @BindView(R.id.title) TextView title; @BindView(R.id.subtitle) TextView subtitle; @BindView(R.id.footer) TextView footer; @Override public void onCreate(Bundle savedIns) { super.onCreate(savedIns); setContentView(R.layout.simple_activity); ButterKnife.bind(this); // TODO Use fields... } } @AndroidEntryPoint class MyActivity : MyBaseActivity() { // Bindings in SingletonComponent or ActivityComponent @Inject lateinit var bar: Bar override fun onCreate(savedInstanceState: Bundle?) { // Injection happens in super.onCreate(). super.onCreate() // Do something with bar ... } }
  12. Compose Compiler The Compose Compiler is important in Jetpack Compose

    as a Kotlin compiler plugin. Unlike KAPT and KSP, the Compose Compiler works inside the Kotlin compiler itself, allowing it to analyze and transform composable functions during compilation.
  13. Compose Compiler @Composable fun Greeting(name: String) { Text("Hello, $name") }

    Compose Compiler fun Greeting(name: String, $composer: Composer?, $changed: Int) { $composer = $composer.startRestartGroup(<key>) var $dirty = $changed if ($changed and 0b0110 == 0) { $dirty = $dirty or (if ($composer.changed(name)) 0b0100 else 0b0010) } if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) { Text("Hello, $name", $composer, 0) } else { $composer.skipToGroupEnd() } $composer.endRestartGroup()?.updateScope { nc, _ -> Geeting(name, nc, updateChangedFlags($changed) or 0b1) } }
  14. Compose Compiler @Composable fun Greeting(name: String) { Text("Hello, $name") }

    Compose Compiler fun Greeting(name: String, $composer: Composer?, $changed: Int) { $composer = $composer.startRestartGroup(<key>) var $dirty = $changed if ($changed and 0b0110 == 0) { $dirty = $dirty or (if ($composer.changed(name)) 0b0100 else 0b0010) } if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) { Text("Hello, $name", $composer, 0) } else { $composer.skipToGroupEnd() } $composer.endRestartGroup()?.updateScope { nc, _ -> Geeting(name, nc, updateChangedFlags($changed) or 0b1) } }
  15. Compose Compiler @Composable fun Greeting(name: String) { Text("Hello, $name") }

    Compose Compiler fun Greeting(name: String, $composer: Composer?, $changed: Int) { $composer = $composer.startRestartGroup(<key>) var $dirty = $changed if ($changed and 0b0110 == 0) { $dirty = $dirty or (if ($composer.changed(name)) 0b0100 else 0b0010) } if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) { Text("Hello, $name", $composer, 0) } else { $composer.skipToGroupEnd() } $composer.endRestartGroup()?.updateScope { nc, _ -> Geeting(name, nc, updateChangedFlags($changed) or 0b1) } }
  16. Compose Compiler fun Greeting(name: String, $composer: Composer?, $changed: Int) {

    $composer = $composer.startRestartGroup(<key>) var $dirty = $changed if ($changed and 0b0110 == 0) { $dirty = $dirty or (if ($composer.changed(name)) 0b0100 else 0b0010) @Composable } fun Greeting(name: String) { if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) { Text("Hello, $name") Text("Hello, $name", $composer, 0) } Compose Compiler } else { $composer.skipToGroupEnd() Skip! } $composer.endRestartGroup()?.updateScope { nc, _ -> Geeting(name, nc, updateChangedFlags($changed) or 0b1) } }
  17. Compose Compiler @Composable fun Greeting(name: String) { Text("Hello, $name") }

    Compose Compiler fun Greeting(name: String, $composer: Composer?, $changed: Int) { $composer = $composer.startRestartGroup(<key>) var $dirty = $changed if ($changed and 0b0110 == 0) { $dirty = $dirty or (if ($composer.changed(name)) 0b0100 else 0b0010) } if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) { Text("Hello, $name", $composer, 0) } else { $composer.skipToGroupEnd() } $composer.endRestartGroup()?.updateScope { nc, _ -> Geeting(name, nc, updateChangedFlags($changed) or 0b1) } }
  18. Compose Runtime Input Changes On each recomposition, the runtime compares

    every argument against the value it kept from the previous composition. If any argument differs, the composable runs again. Either one on its own is enough. fun Greeting(name: String, $composer: Composer?, $changed: Int) { .. var $dirty = $changed Check with equals() if ($changed and 0b0110 == 0) { $dirty = $dirty or (if ($composer.changed(name)) 0b0100 else 0b0010) } if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) { Text("Hello, $name", $composer, 0) } Recomposition! .. }
  19. Compose Runtime Observing State Changes Jetpack Compose offers an effective

    mechanism, State, to trigger recomposition by monitoring state changes using the State API provided by the Compose runtime library. A composable that reads a State is recorded as depending on it. When that state changes, the runtime marks this composable to run again. var bird by remember { mutableStateOf(..) }
  20. Compose Runtime @Composable fun BirdProfile() { var bird = Dove()

    } vs. @Composable fun BirdProfile() { var bird = remember { Dove() } } vs. @Composable fun BirdProfile() { var bird = mutableStateOf(Dove()) } vs. @Composable fun BirdProfile() { var bird = remember { mutableStateOf(Dove()) } }
  21. Compose Runtime Memory @Composable fun BirdProfile() { var bird =

    Dove() } #Run 1 Dove@2a139a55 Recompose @Composable fun BirdProfile() { var bird = Dove() } #Run 1 Dove@2a139a55 #Run 2 Dove@15db9742 Recompose @Composable fun BirdProfile() { var bird = Dove() } #Run 1 Dove@2a139a55 #Run 2 Dove@15db9742 #Run 3 Dove@6d06d69c
  22. Compose Runtime Memory @Composable fun BirdProfile() { var bird =

    remember { Dove() } } #Run 1 Dove@2a139a55 Recompose @Composable fun BirdProfile() { var bird = remember { Dove() } } #Run 1 Dove@2a139a55 Recompose @Composable fun BirdProfile() { var bird = remember { Dove() } } #Run 1 Dove@2a139a55
  23. Compose Runtime Remember API SlotTable System • Stores a value

    in the compositionʼs SlotTable. • • Reuses the stored value across recompositions until its keys change. • Stores the structure and runtime state of the composition. Lets the Composer locate and reuse values across recompositions.
  24. Compose Runtime @Composable fun Counter() { var count by remember

    { mutableStateOf(0) } if (count > 0) { Icon(Icons.Default.Star, null) } Text("Count: $count") }
  25. Compose Runtime @Composable fun Counter() { var count by remember

    { mutableStateOf(0) } if (count > 0) { Icon(Icons.Default.Star, null) } Text("Count: $count") }
  26. Compose Runtime @Composable fun Counter() { var count by remember

    { mutableStateOf(0) } if (count > 0) { Skip! Icon(Icons.Default.Star, null) } Text("Count: $count") }
  27. Compose Runtime @Composable fun Counter() { var count by remember

    { mutableStateOf(1) } if (count > 0) { Icon(Icons.Default.Star, null) } Text("Count: $count") }
  28. Compose Runtime Gap Buffer • SlotTable uses a Gap Buffer

    to store groups and slots efficiently. • The gap moves near the edit position, making insertions and removals cheaper during composition. Similar to the text editor. @Composable fun Counter() { var count by remember { mutableStateOf(1) } if (count > 0) { Icon(Icons.Default.Star, null) } Text("Count: $count") } insert spaces!
  29. Compose Runtime @Composable fun Counter() { var count by remember

    { mutableStateOf(1) } if (count > 0) { Icon(Icons.Default.Star, null) } Text("Count: $count") }
  30. Compose Runtime @Composable fun Counter() { var count by remember

    { mutableStateOf(1) } if (count > 0) { Icon(Icons.Default.Star, null) } Text("Count: $count") }
  31. Compose Runtime Positional Memoization • The call position becomes part

    of the identity. • Values are reused when the same call appears at the same position. • Each call position can maintain its own independent state. SlotTable caches the runtime state and structure of the composition, allowing the composer to reuse them during recomposition.
  32. Compose Runtime @Composable fun Counter() { var count = 0

    Text("Count: $count") Button(onClick= { counter++ }) {..} } vs. @Composable fun Counter() { var count = remember { 0 } Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = 3 → recomposition → var count = 0
  33. Compose Runtime @Composable fun Counter() { var count = 0

    Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = 3 → recomposition → var count = 0 vs. @Composable fun Counter() { var count = remember { 0 } Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = 3 → recomposition → var count = 3 Stores values in the SlotTable at runtime.
  34. Compose Runtime The UI has never been updated @Composable fun

    Counter() { var count = 0 Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = 3 → recomposition → var count = 0 vs. @Composable fun Counter() { var count = remember { 0 } Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = 3 → recomposition → var count = 3
  35. Compose Runtime What is State? • An observable value holder

    designed for Compose. • Unlike LiveData or StateFlow, you don't explicitly subscribe/unsubscribe to a Compose State. LiveData<T> observe(owner) { ... } • There is no observe() or collect() call. When a composable reads state.value, compose system tracks that read automatically. If the value changes, Compose schedules recomposition for the composables that read it. StateFlow<T> collect { ... } State<T> // nothing. you just read it. In that sense, reading the value is effectively the subscription.
  36. Compose Runtime State & Snapshot system • mutableStateOf() does not

    hand you a plain box. It gives you state backed by Compose's Snapshot system. • The Snapshot system gives Compose a consistent view of state and lets it observe reads and manage changes over time. • When that state changes, Compose knows which work may need to run again. You can think of Snapshot as the consistency layer underneath Compose state.
  37. Compose Runtime • getValue() looks like a plain getter. There

    is no explicit subscription, but the read leaves a trace. • Compose remembers which state was read by which scope. When that state changes, the corresponding scope is invalidated and can be recomposed. // you wrote var count by mutableStateOf(0) Text("Count: $count") // conceptually val count = mutableStateOf(0) Text("Count: " + count.getValue(), $composer, 0) // and getValue() is the whole getter get() = next.readable(this).value
  38. Compose Runtime • A write does not recompose immediately. It

    invalidates dependent scopes and schedules recomposition. • Multiple writes before recomposition can be coalesced, three writes don't necessarily mean three recompositions. Where you read state decides what gets recomposed when that state changes. So state reads define recomposition scope depends on it.
  39. Compose Runtime @Composable fun BirdProfile() { var bird by mutableStateOf(Dove())

    } vs. @Composable fun BirdProfile() { var bird by remember { mutableStateOf(Dove()) } }
  40. Compose Runtime @Composable fun Counter() { var count by mutableStateOf(0)

    Text("Count: $count") Button(onClick= { counter++ }) {..} } vs. @Composable fun Counter() { var count by remember { mutableStateOf(0) } Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = State(3 → recomposition → var count = State(0
  41. Compose Runtime @Composable fun Counter() { var count by mutableStateOf(0)

    Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = State(3 → recomposition → var count = State(0 The UI has not been updated! vs. @Composable fun Counter() { var count by remember { mutableStateOf(0) } Text("Count: $count") Button(onClick= { counter++ }) {..} }
  42. Compose Runtime @Composable fun Counter() { var count by mutableStateOf(0)

    Text("Count: $count") Button(onClick= { counter++ }) {..} } vs. 3 clicks → var count = State(3 → recomposition → var count = State(0 The UI is updated! @Composable fun Counter() { var count by remember { mutableStateOf(0) } Text("Count: $count") Button(onClick= { counter++ }) {..} } 3 clicks → var count = State(3 → recomposition → var count = State(3
  43. Compose UI • Column, Row, Text, and Image all contribute

    to the same underlying structure: a LayoutNode hierarchy. • Layout, drawing, input, focus, and accessibility are built on top of this structure. • Your Button is not backed by an android.view.View. Underneath, there is a Compose-owned node hierarchy instead.
  44. Compose UI • The SlotTable is not the UI. It

    is a flat recording of what was called, and in what order. • The tree is the thing that actually gets measured and drawn. Parent and child links live in the nodes.
  45. Compose UI • The Applier. Insert a node, remove a

    node, move a node. That is very nearly the whole interface. • It is the only part of Compose that knows the tree is made of LayoutNode. • The runtime never touches the tree itself, which is why the same runtime drives Android, desktop and web.
  46. Compose UI // compose-runtime interface Applier<N> { fun insertBottomUp(index: Int,

    instance: N) fun remove(index: Int, count: Int) fun move(from: Int, to: Int, count: Int) ... } // compose-ui internal class UiApplier(root: LayoutNode): AbstractApplier<LayoutNode>(root) // compose-ui, androidMain internal class AndroidComposeView(...) : ViewGroup(context), Owner, ...
  47. Compose UI // compose-runtime interface Applier<N> { fun insertBottomUp(index: Int,

    instance: N) fun remove(index: Int, count: Int) fun move(from: Int, to: Int, count: Int) ... } // compose-ui internal class UiApplier(root: LayoutNode): AbstractApplier<LayoutNode>(root) // compose-ui, androidMain internal class AndroidComposeView(...) : ViewGroup(context), Owner, ...
  48. Compose UI • Every UI component like Text, Image, Icon,

    Row, Column, Box ends up calling `Layout` composable function. • Layout is the only thing that creates a LayoutNode. Nothing else does. • What makes them different is two arguments: how to measure, and which modifiers to wear. Layout(finalModifier, EmptyMeasurePolicy)
  49. Compose UI • Text is Layout with a policy that

    measures nothing. • BasicText ends on a single line, and the measure policy it hands over measures nothing. • Text is compose-material3, BasicText is compose-foundation, Layout is compose-ui. // foundation, the last line of BasicText Layout(modifier then TextStringSimpleElement(text, style, ...), EmptyMeasurePolicy) // and EmptyMeasurePolicy measures nothing at all layout(constraints.maxWidth, constraints.maxHeight, placementBlock)
  50. Compose UI // AndroidComposeView: ViewGroup, Owner internal class AndroidComposeView(..): ViewGroup(context),

    Owner { override val root = LayoutNode() override fun dispatchDraw(canvas: android.graphics.Canvas) { measureAndLayout() canvasHolder.drawInto(canvas) { root.draw(canvas = this, graphicsLayer = null) } } .. } // the Composition is bound to it Composition(UiApplier(composeView.root), parent)
  51. Compose Performance • Composition: What to show. Compose executes composable

    functions and builds a representation of the UI. • Layout: Where to place it. Each node is measured and positioned within the layout tree. • Drawing: How to render it. UI elements draw their content onto a Canvas, typically the device screen.
  52. Compose Performance Kotlin 2.0.10 (before strong skipping mode) • If

    any parameter type is unstable, the composable cannot be skipped and will be recomposed without comparing the values. • If all parameter types are stable, Compose compares them using equals(). If any parameter has changed, the composable is recomposed.
  53. Compose Performance Kotlin 2.0.10 (before strong skipping mode) • If

    any parameter type is unstable, the composable cannot be skipped and will be recomposed without comparing the values. • If all parameter types are stable, Compose compares them using equals(). If any parameter has changed, the composable is recomposed. Kotlin 2.0.20 (after strong skipping mode) • Unstable parameters are compared using referential equality (===) • Stable parameters are compared using structural equality Object.equals())
  54. Compose Performance In most cases, Strong Skipping Mode is enough.

    But, Strong Skipping Mode does not change the stability of any type. Unstable types remain unstable. What changes is how the runtime handles unstable parameters during the skip check. Stability still matters when unstable values are frequently recreated as new instances. restartable fun Avatar( stable modifier: Modifier? = @static Companion stable imageUrl: String? = @static null stable initials: String? = @static null stable shape: Shape? = @dynamic ... unstable badges: List<String> ) Strong Skipping skippable restartable fun Avatar( stable modifier: Modifier? = @static Companion stable imageUrl: String? = @static null stable initials: String? = @static null stable shape: Shape? = @dynamic ... unstable badges: List<String> )
  55. Compose Performance @Composable fun UserScreen(users: List<User>) { UserList( users =

    users.map { user -> UserProfile( name = user.name, avatarUrl = user.avatarUrl ) } ) } Parent recomposes → map() creates a new List every time → List is unstable → === fails → UserList cannot be skipped
  56. Compose Performance @Composable fun UserScreen(users: List<User>) { val profiles =

    remember(users) { users.map { user -> UserProfile( name = user.name, avatarUrl = user.avatarUrl ) } } UserList(users = profiles) } Parent recomposes → users is unchanged → remember() reuses the mapped List → Same List instance → === succeeds → UserList can be skipped
  57. Compose Performance data class UiState( val items: List<ItemUi> = emptyList()

    ) class DemoViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() fun updateItems() { _uiState.update { state -> state.copy( // create a new instance items = state.items.map { it.copy() } ) } } }
  58. Compose Performance data class UiState( val items: List<ItemUi> = emptyList()

    ) @Composable fun DemoScreen(viewModel: DemoViewModel) { val uiState by viewModel.uiState.collectAsState() class DemoViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() fun updateItems() { _uiState.update { state -> state.copy( // create a new instance items = state.items.map { it.copy() } ) } } } ItemList( items = uiState.items ) }
  59. Compose Performance data class UiState( val items: List<ItemUi> = emptyList()

    ) @Composable fun DemoScreen(viewModel: DemoViewModel) { val uiState by viewModel.uiState.collectAsState() class DemoViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() } ItemList( items = uiState.items ) } fun updateItems() { _uiState.update { state -> state.copy( // create a new instance items = state.items.map { it.copy() } ) distinctuntilchanged() → equals(==) check } } val oldState = _state.value if (expectedState != null && oldState != expectedState) return false if (oldState == newState) return true // same as the equals _state.value = newState
  60. Compose Performance class MessageRepository( private val dao: MessageDao unstable )

    { fun observeMessages(): Flow<List<Message>> = dao.observeMessages() .map { entities -> // new instances entities.map { it.toMessage() } } }
  61. Compose Performance class MessageRepository( private val dao: MessageDao unstable )

    { fun observeMessages(): Flow<List<Message>> = dao.observeMessages() .map { entities -> // new instances entities.map { it.toMessage() } } } class MessageViewModel( repository: MessageRepository ) : ViewModel() { val messages = repository.observeMessages() .stateIn( distinctuntilchanged() → equals(==) check viewModelScope, SharingStarted.WhileSubscribed(), emptyList() ) }
  62. Compose Performance class MessageRepository( private val dao: MessageDao unstable )

    { fun observeMessages(): Flow<List<Message>> = dao.observeMessages() .map { entities -> // new instances entities.map { it.toMessage() } } } class MessageViewModel( repository: MessageRepository ) : ViewModel() { @Composable fun MessageScreen( viewModel: MessageViewModel ) { val messages by viewModel.messages.collectAsState() LazyColumn { items( items = messages, key = { it.id } ) { message -> // this is unstable // new unstable instance → === fails MessageBubble(message = message) } } New instance → Skip check fails → val messages = repository.observeMessages() } .stateIn( distinctuntilchanged() → equals(==) check viewModelScope, SharingStarted.WhileSubscribed(), emptyList() ) } Recomposition
  63. Compose Performance data class Message( val id: String, var isRead:

    Boolean, // mutable val reactions: List<String> // unstable collection }
  64. Compose Performance data class Message( val id: String, var isRead:

    Boolean, // mutable val reactions: List<String> // unstable collection } data class Message( val id: String, val isRead: Boolean, // read-only val reactions: ImmutableList<String> // stable collection }
  65. Compose Performance data class Message( val id: String, var isRead:

    Boolean, // mutable val reactions: List<String> // unstable collection } @Immutable data class Message( val id: String, val isRead: Boolean, // read-only val reactions: List<String> // unstable collection }
  66. Compose Performance NowinAndroid ForYou Feed Screen UserNewsResource model (with Strong

    Skipping Mode) • • unstable: 140 recompositions stable: 30 recompositions Stability configuration file // always use immutable classes for our data model com.google.samples.apps.nowinandroid.core.model.data.* → You can also make those models by using Immutable or Stable.
  67. Compose Performance Jetpack Compose Mechanisms 1. Compose Compiler 2. Compose

    Runtime 3. Compose UI 4. Compose Performance 🔗: howcomposeworks.com