Share
Embed
Understanding Recomposition Performance Pitfalls
Jossi Wolf
November 15, 2022
Transcript
88 slides
-
Understanding Recomposition Performance Pitfalls Andrei Shikov (he/him) Compose @Google @shikasd_
Jossi Wolf (he/him) Compose @Google @jossiwolf compose -
Related talks We go further into the details of why
deferring reads of Compose state works, learn about stability and how Compose infers it, have a look at a new API for reportFullyDrawn, and more. A holistic guide to app performance with tips that apply to all Android apps. More performance tips for Jetpack Compose Modern App Performance @shikasd_ @jossiwolf -
Always test performance in release mode with R8 enabled —
Ben Trengrove, twice! @shikasd_ @jossiwolf -
@Composable fun Example() { var counter by remember { mutableStateOf(0)
} Text( modifier = Modifier.clickable { counter ++ }, text = "$counter" ) } @shikasd_ @jossiwolf -
State Recomposer Composition records read change observed by recomposes setContent
{ … } @shikasd_ @jossiwolf -
State Recomposer Composition records read change observed by recomposes 1
2 3 @shikasd_ @jossiwolf -
@Composable fun Example() { var counter by remember { mutableStateOf(0)
} Text( modifier = Modifier.clickable { counter ++ }, text = "$counter" ) } state value gets accessed @shikasd_ @jossiwolf -
@Composable fun Example($composer: Composer, ... ) { $composer.startRestartGroup(FunctionKey) // function
body $composer.endRestartGroup() ?. updateScope { $composer -> Example($composer, ... ) } } Recompose scope @shikasd_ @jossiwolf -
@Composable fun Example() { var counter by remember { mutableStateOf(0)
} Text( modifier = Modifier.clickable { counter ++ }, text = "$counter" ) } state value read inside Example function Composition will remember that. ? @shikasd_ @jossiwolf -
@Composable fun Example() { var counter by remember { mutableStateOf(0)
} Text( modifier = Modifier.clickable { counter ++ }, text = "$counter" ) } state value gets updated @shikasd_ @jossiwolf -
State recomposes Composition records read Recomposer modified in Snapshot observed
by @shikasd_ @jossiwolf -
• State is backed by Snapshot 📷 system • State
changes in Snapshot are transactional and atomic (+ observers!) • Recomposer observes state changes through Snapshot system @shikasd_ @jossiwolf -
@Composable fun Example($composer: Composer, ... ) { $composer.startRestartGroup(FunctionKey) // function
body $composer.endRestartGroup() ?. updateScope { $composer -> Example($composer, ... ) } } Recompose scope @shikasd_ @jossiwolf -
@Composable fun Example() { var counter by remember { mutableStateOf(0)
} Text( modifier = Modifier.clickable { counter ++ }, text = "$counter" ) } Composition DID remember that. ? Recompose scope state value gets accessed @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} val scrollableState = rememberScrollableState( onScroll = { delta -> scrollOffset = maxOf(scrollOffset + delta, 0f) delta } ) Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = scrollOffset > 0f) Footer() } } @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} val scrollableState = rememberScrollableState( onScroll = { delta -> scrollOffset = maxOf(scrollOffset + delta, 0f) delta } ) Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = scrollOffset > 0f) Footer() } } @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} val scrollableState = rememberScrollableState( onScroll = { delta -> scrollOffset = maxOf(scrollOffset + delta, 0f) delta } ) Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = scrollOffset > 0f) Footer() } } @shikasd_ @jossiwolf -
Recomposer:recompose App Content Frame #1 Frame #2 measure Recomposer:recompose App
Content measure Other @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} val scrollableState = rememberScrollableState( onScroll = { delta -> scrollOffset = maxOf(scrollOffset + delta, 0f) … } ) Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = scrollOffset > 0f) Footer() } } state read! @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} ... Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = { scrollOffset > 0f }) Footer() } } @Composable fun Content(showScrollToTop: () -> Boolean) { if (showScrollToTop()) { … } } // perf tip #1: defer reads state read! @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset = remember { mutableStateOf(0f)
} ... Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = { scrollOffset > 0f }) Footer() } } @Composable fun Content(showScrollToTop: State<Boolean>) { ... } // perf tip #2: defer reads ❌ -
/ / perf tip #2: extract State read out of
composition @shikasd_ @jossiwolf -
Composition Layout Draw // perf tip #1: extract State read
out of composition @shikasd_ @jossiwolf -
@Composable fun Example() { var state by remember { mutableStateOf(0)
} Text( "$state" // read in composition Modifier .layout { measurable, constraints -> . .. . .. } .drawWithCache { . .. } ) } // perf tip #1: extract State read out of composition @shikasd_ @jossiwolf -
@Composable fun Example() { var state by remember { mutableStateOf(0)
} Text( "$state" // read in composition Modifier .layout { measurable, constraints -> val size = IntSize(state, state) // read in layout . .. } .drawWithCache { . .. } ) } // perf tip #1: extract State read out of composition @shikasd_ @jossiwolf -
@Composable fun Example() { var state by remember { mutableStateOf(0)
} Text( "$state" // read in composition Modifier .layout { measurable, constraints -> val size = IntSize(state, state) // read in layout } .drawWithCache { val color = state // read in draw } ) } // perf tip #1: extract State read out of composition -
// perf tip #3: Use derivedStateOf to reduce update frequency
@shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} ... Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = scrollOffset > 0f) Footer() } } // perf tip #3: derivedStateOf state read! @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} ... val showScrollToTop by remember { derivedStateOf { scrollOffset > 0f } ) Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = showScrollToTop) Footer() } } // perf tip #3: derivedStateOf out of composition in composition @shikasd_ @jossiwolf -
@Composable fun App() { var scrollOffset by remember { mutableStateOf(0f)
} ... val derivedScrollOffset by remember { derivedStateOf { scrollOffset - 10f } ) Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop = derivedScrollOffset > 0f) Footer() } } // perf tip #3: derivedStateOf ❌ out of composition in composition @shikasd_ @jossiwolf -
@Composable fun App() { ... val showScrollToTop by remember {
derivedStateOf { scrollOffset > 0f } ) val buttonHeight by remember { derivedStateOf { showScrollToTop ? 0f : 100f } } } // perf tip #3: derivedStateOf @shikasd_ @jossiwolf -
@Composable fun App() { ... val showScrollToTop by remember {
derivedStateOf(structuralEqualityPolicy()) { scrollOffset > 0f } ) val buttonHeight by remember { derivedStateOf { showScrollToTop ? 0f : 100f } } } // perf tip #3: derivedStateOf @shikasd_ @jossiwolf -
@Composable fun App() { ... Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop =
{ scrollOffset > 0f } ) Footer() } val textOffset by animateValueAsState(scrollOffset) Text(Modifier.offset(textOffset)) } // perf tip #4: reduce scope of state update Recompose scope @shikasd_ @jossiwolf -
@Composable fun App() { ... Column(Modifier.scrollable(scrollableState)) { Header() Content(showScrollToTop =
{ scrollOffset > 0f } ) Footer() } TextWithOffset(offset = scrollOffset) } @Composable fun TextWithOffset(offset: Int) { val textOffset by animateValueAsState(offset) Text(Modifier.offset(textOffset)) } // perf tip #4: reduce scope of state update Recompose scope @shikasd_ @jossiwolf -
@Composable fun Example() { var counter by remember { mutableStateOf(0)
} Button(onClick = { counter ++ }) { Text("$counter") } } Recompose scope @shikasd_ @jossiwolf -
@Composable fun PostColumn(posts: List<Model>) { Column { posts.forEach { model
-> Post(model) } } } @shikasd_ @jossiwolf -
Frame #1 Frame #4 Recomposer:recompose Frame #2 Frame #3 PostColumn
All Post composables get recomposed AppScreen @shikasd_ @jossiwolf -
// What makes a type stable? // 1. Immutability //
2. Observable mutability (e.g. MutableState) @shikasd_ @jossiwolf -
@Composable fun Example($composer: Composer, ... ) { $composer.startRestartGroup(FunctionKey) val parametersChanged
= /* change handling logic */ if (parametersChanged) { // function body } $composer.endRestartGroup() } @shikasd_ @jossiwolf -
Compose Compiler metrics // -classes.txt unstable class Model { stable
val postType: String unstable var isSynchronized: Boolean <runtime stability> = Unstable } // -composables.txt restartable fun Post( unstable model: Model ) @shikasd_ @jossiwolf -
// -classes.txt unstable class Model { stable val postType: String
unstable var isSynchronized: Boolean <runtime stability> = Unstable } // -composables.txt restartable fun Post( unstable model: Model ) class Model { val postType: String var isSynchronized: Boolean } @Composable fun Post( model: Model ) Compose Compiler metrics @shikasd_ @jossiwolf -
// -classes.txt stable class Model { stable val postType: String
stable val isSynchronized: Boolean <runtime stability> = Stable } // -composables.txt restartable skippable fun Post( stable model: Model ) data class Model { val postType: String val isSynchronized: Boolean } @Composable fun Post( model: Model ) Compose Compiler metrics @shikasd_ @jossiwolf -
Frame #1 Frame #2 Frame #3 Only updated Post is
executed Recomposer:recompose AppScreen PostColumn Frame #4 @shikasd_ @jossiwolf -
// stability tip #2: not everything has to be stable
@shikasd_ @jossiwolf -
// -composables.txt restartable fun PostColumn( unstable models: List<Model> ) @Composable
fun PostColumn( models: List<Model> ) @shikasd_ @jossiwolf -
Frame #1 Frame #2 Frame #3 Post Recomposer:recompose AppScreen PostColumn
Frame #4 Post still has to be updated @shikasd_ @jossiwolf -
// the haiku about // implementation details // fleeting in
moment @shikasd_ @jossiwolf -
fun openPost(model: Model) { ... } @Composable fun PostColumn(posts: List<Model>)
{ Column { posts.forEach { model -> Post(model) { // onClick openPost(model) } } } } @shikasd_ @jossiwolf -
Post(model) { // onClick openPost(model) } // is compiled into:
// file level class Post$1(private val model: Model) : Function0<Unit> { override fun invoke() { openPost(model) } } // inside composable Post(model, Post$1(model)) @shikasd_ @jossiwolf -
Post(model) { // onClick openPost(model) } // is compiled into:
// file level class Post$1(private val model: Model) : Function0<Unit> { override fun invoke() { openPost(model) } } // inside composable Post(model, Post$1(model)) no .equals() implementation restart creates new instance @shikasd_ @jossiwolf -
Post(model) { // onClick println("Clicked!") } // is compiled into:
// file level val lambdaParam = object : Function0<Unit> { override fun invoke() { println("Clicked!") } } // inside composable Post(model, lambdaParam) @shikasd_ @jossiwolf -
Post(model) { // onClick println("Clicked!") } // is compiled into:
// file level val lambdaParam = object : Function0<Unit> { override fun invoke() { println("Clicked!") } } // inside composable Post(model, lambdaParam) no captures! @shikasd_ @jossiwolf -
Post(model) { // onClick println(model) } // is compiled into:
// file level class Post$1(val model: Model) : Function0<Unit> { override fun invoke() { println(model) } } // inside composable val onClick = remember(model) { Post$1(model) } Post(model, onClick) @shikasd_ @jossiwolf -
Post(model) { // onClick println(model) } // is compiled into:
// file level class Post$1(val model: Model) : Function0<Unit> { override fun invoke() { println(model) } } // inside composable val onClick = remember(model) { Post$1(model) } Post(model, onClick) stable capture! @shikasd_ @jossiwolf -
class PostActivity : Activity { fun openPost(model: Model) { ...
} @Composable fun PostColumn(posts: List<Model>) { Column { posts.forEach { model -> Post(model) { // onClick openPost(model) } } } } } @shikasd_ @jossiwolf -
Post(model) { // onClick openPost(model) } // is compiled into:
// file level class PostActivity$Post$1($this: PostActivity, model: Model) : Function0<Unit> { override fun invoke() { $this.openPost(model) } } // composable Post(model, PostActivity$Post$1(this, model)) stable! unstable! @shikasd_ @jossiwolf -
Defer reading state to a later phase (layout, drawing) Control
how often state updates causes recomposition with derived state Check hot code paths for unstable types Measure if additional stability brings perf benefits State Reads Stability Compiler tries its best. remember with care. Lambdas @shikasd_ @jossiwolf -
xkcd.com/1691/ Andrei Shikov (he/him) Compose @Google @shikasd_ Jossi Wolf (he/him)
Compose @Google @jossiwolf @shikasd_ @jossiwolf