$30 off During Our Annual Pro Sale. View Details »

Understanding Recomposition Performance Pitfalls

Jossi Wolf
November 15, 2022

Understanding Recomposition Performance Pitfalls

Jossi Wolf

November 15, 2022
Tweet

More Decks by Jossi Wolf

Other Decks in Programming

Transcript

  1. Understanding Recomposition
    Performance Pitfalls
    Andrei Shikov (he/him)


    Compose @Google


    @shikasd_
    Jossi Wolf (he/him)


    Compose @Google


    @jossiwolf
    compose

    View Slide

  2. Understanding
    Performance

    View Slide

  3. Improve Monitor
    Inspect
    @shikasd_ @jossiwolf

    View Slide

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

    View Slide

  5. Always test
    performance in release
    mode with R8 enabled
    — Ben Trengrove, twice!
    @shikasd_ @jossiwolf

    View Slide

  6. Understanding
    Recomposition
    @shikasd_ @jossiwolf

    View Slide

  7. @Composable fun Example() {


    var counter by remember { mutableStateOf(0) }


    Text(


    modifier = Modifier.clickable { counter
    ++
    },


    text = "$counter"


    )


    }
    @shikasd_ @jossiwolf

    View Slide

  8. State
    Recomposer
    Composition records read
    change


    observed by
    recomposes
    setContent { … }
    @shikasd_ @jossiwolf

    View Slide

  9. State
    Recomposer
    Composition records read
    change


    observed by
    recomposes
    1
    2
    3
    @shikasd_ @jossiwolf

    View Slide

  10. State
    Recomposer
    Composition records read
    change


    observed by
    recomposes
    @shikasd_ @jossiwolf

    View Slide

  11. @Composable fun Example() {


    var counter by remember { mutableStateOf(0) }


    Text(


    modifier = Modifier.clickable { counter
    ++
    },


    text = "$counter"


    )


    }
    state value gets accessed
    @shikasd_ @jossiwolf

    View Slide

  12. @Composable fun Example($composer: Composer,
    ...
    ) {


    $composer.startRestartGroup(FunctionKey)


    //
    function body


    $composer.endRestartGroup()


    ?.
    updateScope { $composer
    ->

    Example($composer,
    ...
    )


    }


    }
    Recompose scope
    @shikasd_ @jossiwolf

    View Slide

  13. @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

    View Slide

  14. Recomposer
    change


    observed by
    State
    Composition records read
    recomposes
    @shikasd_ @jossiwolf

    View Slide

  15. Recomposer
    change


    observed by
    State
    Composition
    records read
    recomposes
    @shikasd_ @jossiwolf

    View Slide

  16. State
    change


    observed by
    recomposes
    Composition
    records read
    Recomposer
    @shikasd_ @jossiwolf

    View Slide

  17. @Composable fun Example() {


    var counter by remember { mutableStateOf(0) }


    Text(


    modifier = Modifier.clickable { counter
    ++
    },


    text = "$counter"


    )


    }
    state value gets updated
    @shikasd_ @jossiwolf

    View Slide

  18. State
    change


    observed by
    recomposes
    Composition
    records read
    Recomposer
    @shikasd_ @jossiwolf

    View Slide

  19. State
    recomposes
    Composition
    records read
    Recomposer
    modified in Snapshot observed by
    @shikasd_ @jossiwolf

    View Slide

  20. • State is backed by Snapshot 📷 system


    • State changes in Snapshot are transactional and atomic (+ observers!)


    • Recomposer observes state changes through Snapshot system
    @shikasd_ @jossiwolf

    View Slide

  21. State
    change


    observed by
    recomposes
    Composition
    records read
    Recomposer
    @shikasd_ @jossiwolf

    View Slide

  22. Composition
    State
    change


    observed by
    recomposes
    records read
    Recomposer
    @shikasd_ @jossiwolf

    View Slide

  23. Composition
    State
    change


    observed by
    recomposes
    records read
    Recomposer
    @shikasd_ @jossiwolf

    View Slide

  24. @Composable fun Example($composer: Composer,
    ...
    ) {


    $composer.startRestartGroup(FunctionKey)


    //
    function body


    $composer.endRestartGroup()


    ?.
    updateScope { $composer
    ->

    Example($composer,
    ...
    )


    }


    }
    Recompose scope
    @shikasd_ @jossiwolf

    View Slide

  25. @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

    View Slide

  26. State
    Recomposer
    Composition records read
    change


    observed by
    recomposes
    @shikasd_ @jossiwolf

    View Slide

  27. Understanding Recomposition
    Performance
    @shikasd_ @jossiwolf

    View Slide

  28. @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

    View Slide

  29. @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

    View Slide

  30. @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

    View Slide

  31. Recomposer:recompose
    App
    Content
    Frame #1 Frame #2
    measure Recomposer:recompose
    App
    Content
    measure
    Other
    @shikasd_ @jossiwolf

    View Slide

  32. @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

    View Slide

  33. //
    perf tip #1: defer State reads
    @shikasd_ @jossiwolf

    View Slide

  34. @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

    View Slide

  35. @Composable


    fun App() {


    var scrollOffset = remember { mutableStateOf(0f) }


    ...

    Column(Modifier.scrollable(scrollableState)) {


    Header()


    Content(showScrollToTop = { scrollOffset > 0f })


    Footer()


    }


    }


    @Composable


    fun Content(showScrollToTop: State) {


    ...

    }
    //
    perf tip #2: defer reads

    View Slide

  36. / /
    perf tip #2: extract State read out of composition
    @shikasd_ @jossiwolf

    View Slide

  37. Composition
    Layout
    Draw
    //
    perf tip #1: extract State read out of composition
    @shikasd_ @jossiwolf

    View Slide

  38. @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

    View Slide

  39. @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

    View Slide

  40. @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

    View Slide

  41. Frame #1
    measure measure
    Other
    Recomposer:recompose
    Frame #2
    @shikasd_ @jossiwolf

    View Slide

  42. //
    perf tip #3: Use derivedStateOf to reduce update frequency
    @shikasd_ @jossiwolf

    View Slide

  43. @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

    View Slide

  44. @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

    View Slide

  45. @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

    View Slide

  46. @Composable


    fun App() {


    ...

    val showScrollToTop by remember {


    derivedStateOf { scrollOffset > 0f }


    )


    val buttonHeight by remember {


    derivedStateOf { showScrollToTop ? 0f : 100f }


    }


    }
    //
    perf tip #3: derivedStateOf
    @shikasd_ @jossiwolf

    View Slide

  47. @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

    View Slide

  48. //
    perf tip #4: reduce scope of state update
    @shikasd_ @jossiwolf

    View Slide

  49. @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

    View Slide

  50. @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

    View Slide

  51. @Composable fun Example() {


    var counter by remember { mutableStateOf(0) }


    Button(onClick = { counter
    ++
    }) {


    Text("$counter")


    }


    }
    Recompose scope
    @shikasd_ @jossiwolf

    View Slide

  52. Should you optimize all state
    reads?
    No.
    @shikasd_ @jossiwolf

    View Slide

  53. @Composable


    fun PostColumn(posts: List) {


    Column {


    posts.forEach { model
    ->


    Post(model)


    }


    }


    }
    @shikasd_ @jossiwolf

    View Slide

  54. Frame #1 Frame #2 Frame #3
    @shikasd_ @jossiwolf

    View Slide

  55. Frame #1 Frame #4
    Recomposer:recompose
    Frame #2 Frame #3
    PostColumn
    All Post composables get recomposed
    AppScreen
    @shikasd_ @jossiwolf

    View Slide

  56. Understanding Stability
    @shikasd_ @jossiwolf

    View Slide

  57. //
    What makes a type stable?


    //
    1. Immutability


    //
    2. Observable mutability (e.g. MutableState)
    @shikasd_ @jossiwolf

    View Slide

  58. @Composable fun Example($composer: Composer,
    ...
    ) {


    $composer.startRestartGroup(FunctionKey)


    val parametersChanged =
    /*
    change handling logic
    */

    if (parametersChanged) {


    //
    function body


    }


    $composer.endRestartGroup()


    }
    @shikasd_ @jossiwolf

    View Slide

  59. //
    stability tip #1: immutable types or observable mutability
    @shikasd_ @jossiwolf

    View Slide

  60. Compose Compiler metrics
    //
    -classes.txt


    unstable class Model {


    stable val postType: String


    unstable var isSynchronized: Boolean


    = Unstable


    }


    //
    -composables.txt


    restartable fun Post(


    unstable model: Model


    )
    @shikasd_ @jossiwolf

    View Slide

  61. //
    -classes.txt


    unstable class Model {


    stable val postType: String


    unstable var isSynchronized: Boolean


    = 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

    View Slide

  62. //
    -classes.txt


    stable class Model {


    stable val postType: String


    stable val isSynchronized: Boolean


    = 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

    View Slide

  63. Frame #1 Frame #2 Frame #3
    Only updated Post is executed
    Recomposer:recompose
    AppScreen
    PostColumn
    Frame #4
    @shikasd_ @jossiwolf

    View Slide

  64. @Immutable / @Stable


    @shikasd_ @jossiwolf

    View Slide

  65. @Immutable / @Stable


    interface WindowInsetsController
    Children will be considered stable
    @shikasd_ @jossiwolf

    View Slide

  66. //
    stability tip #2: not everything has to be stable
    @shikasd_ @jossiwolf

    View Slide

  67. //
    -composables.txt


    restartable fun PostColumn(


    unstable models: List


    )
    @Composable fun PostColumn(


    models: List


    )
    @shikasd_ @jossiwolf

    View Slide

  68. //
    -composables.txt


    restartable skippable fun PostColumn(


    stable contents: kotlinx.collections.immutable.ImmutableList


    )
    @shikasd_ @jossiwolf

    View Slide

  69. Frame #1 Frame #2 Frame #3
    Post
    Recomposer:recompose
    AppScreen
    PostColumn
    Frame #4
    Post still has to be updated
    @shikasd_ @jossiwolf

    View Slide

  70. //
    stability tip #3: lambdas
    @shikasd_ @jossiwolf

    View Slide

  71. Understanding


    Lambda Stability
    @shikasd_ @jossiwolf

    View Slide

  72. //
    the haiku about


    //
    implementation details


    //
    fleeting in moment
    @shikasd_ @jossiwolf

    View Slide

  73. Lambdas are always
    stable
    @shikasd_ @jossiwolf

    View Slide

  74. fun openPost(model: Model) {
    ...
    }


    @Composable


    fun PostColumn(posts: List) {


    Column {


    posts.forEach { model
    ->

    Post(model) {


    //
    onClick


    openPost(model)


    }


    }


    }


    }
    @shikasd_ @jossiwolf

    View Slide

  75. Post(model) {
    //
    onClick


    openPost(model)


    }


    //
    is compiled into:


    //
    file level


    class Post$1(private val model: Model) : Function0 {


    override fun invoke() {


    openPost(model)


    }


    }


    //
    inside composable


    Post(model, Post$1(model))
    @shikasd_ @jossiwolf

    View Slide

  76. Post(model) {
    //
    onClick


    openPost(model)


    }


    //
    is compiled into:


    //
    file level


    class Post$1(private val model: Model) : Function0 {


    override fun invoke() {


    openPost(model)


    }


    }


    //
    inside composable


    Post(model, Post$1(model))
    no .equals() implementation
    restart creates new instance
    @shikasd_ @jossiwolf

    View Slide

  77. Post(model) {
    //
    onClick


    println("Clicked!")


    }


    //
    is compiled into:


    //
    file level


    val lambdaParam = object : Function0 {


    override fun invoke() {


    println("Clicked!")


    }


    }


    //
    inside composable


    Post(model, lambdaParam)
    @shikasd_ @jossiwolf

    View Slide

  78. Post(model) {
    //
    onClick


    println("Clicked!")


    }


    //
    is compiled into:


    //
    file level


    val lambdaParam = object : Function0 {


    override fun invoke() {


    println("Clicked!")


    }


    }


    //
    inside composable


    Post(model, lambdaParam)
    no captures!
    @shikasd_ @jossiwolf

    View Slide

  79. Post(model) {
    //
    onClick


    println(model)


    }


    //
    is compiled into:


    //
    file level


    class Post$1(val model: Model) : Function0 {


    override fun invoke() {


    println(model)


    }


    }


    //
    inside composable


    val onClick = remember(model) { Post$1(model) }


    Post(model, onClick)
    @shikasd_ @jossiwolf

    View Slide

  80. Post(model) {
    //
    onClick


    println(model)


    }


    //
    is compiled into:


    //
    file level


    class Post$1(val model: Model) : Function0 {


    override fun invoke() {


    println(model)


    }


    }


    //
    inside composable


    val onClick = remember(model) { Post$1(model) }


    Post(model, onClick)
    stable capture!
    @shikasd_ @jossiwolf

    View Slide

  81. class PostActivity : Activity {


    fun openPost(model: Model) {
    ...
    }


    @Composable


    fun PostColumn(posts: List) {


    Column {


    posts.forEach { model
    ->

    Post(model) {


    //
    onClick


    openPost(model)


    }


    }


    }


    }


    }
    @shikasd_ @jossiwolf

    View Slide

  82. Post(model) {
    //
    onClick


    openPost(model)


    }


    //
    is compiled into:


    //
    file level


    class PostActivity$Post$1($this: PostActivity, model: Model) : Function0 {


    override fun invoke() {


    $this.openPost(model)


    }


    }


    //
    composable


    Post(model, PostActivity$Post$1(this, model))
    stable!
    unstable!
    @shikasd_ @jossiwolf

    View Slide

  83. Compiler isn't always
    smart, but it is
    consistent
    @shikasd_ @jossiwolf

    View Slide

  84. //
    stability tip #3: lambdas - remember with care
    @shikasd_ @jossiwolf

    View Slide

  85. That quote about
    premature
    optimization
    — Certainly somebody smart
    @shikasd_ @jossiwolf

    View Slide

  86. Improve Monitor
    Inspect
    @shikasd_ @jossiwolf

    View Slide

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

    View Slide

  88. xkcd.com/1691/
    Andrei Shikov (he/him)


    Compose @Google


    @shikasd_
    Jossi Wolf (he/him)


    Compose @Google


    @jossiwolf
    @shikasd_ @jossiwolf

    View Slide