查看原文
其他

一篇长文,带你通透Compose的重组作用域和性能优化

川峰 郭霖 2023-09-21


/   今日科技快讯   /

近日,山东德州平原县发生的5.5级地震使得手机、电视的“地震预警”功能受到大量关注。不过有网友发现小米手机的地震预警功能默认关闭,需要用户手动开启。

有网友疑惑称这样的救命功能为何不设置默认开启。对此,Redmi手机市场经理张宇回应称,地震预警本身的场景和形态比较特殊,在用户没有充分知晓该功能之前默认开启,地震来临时,很可能会引发用户恐慌,导致额外的人身安全事故,所以功能开启后需要强制性看一遍提醒的演示,以免造成不必要的恐慌。此外,他还表示因为部分城市暂不支持该功能,目前自己的方法是找到就近支持的城市开启,以备不时之需。

/   作者简介   /

本篇文章来自川峰的投稿,文章主要分享了Compose中的重组作用域和性能优化的相关内容,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

川峰的博客地址:
https://blog.csdn.net/lyabc123456?type=blog

/   作用域   /

只有读取可变状态的作用域才会被重组

这句话的意思是只有读取 mutableStateOf() 函数生成的状态值的那些 Composable 函数才会被重新执行。注意,这与 mutableStateOf() 函数在什么位置被定义没有关系。读取操作指的是对状态值的 get 操作。也就是取值的操作。

从一个简单例子开始

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var counter by remember { mutableStateOf(0) }
        Text("Text1", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = { counter++ },
            shape = RoundedCornerShape(5.dp)
        ) {
             Text("Text2: counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor())) 
        }
    }
}
fun getRandomColor() =  Color(
    red = Random.nextInt(256),
    green = Random.nextInt(256),
    blue = Random.nextInt(256),
    alpha = 255
)

在上面的代码中,我们为每个 Composable 组件都设置了一个随机的背景颜色,这样一旦它们发生了重组,我们就可以观察到。


这里点击 Button 修改 counter 的值之后,只有读取 counter 的 Text 组件背景色发生变化,这充分的说明了只有这个 Text 组件才会重组。位于 Button 之上的 Text 组件,虽然它与 counter 定义在同一作用域范围内,但是它不会被触发重组,因为它没有读取该 counter 的值。

假如我们把 Button 内的组件换成一个自定义的 Composable 组件,只要它读取 counter 的值,那么该自定义组件的整个作用域范围都会执行重组:

@Composable
private fun Sample1() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var counter by remember { mutableStateOf(0) }
        Text("Text1", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = { counter++ },
            shape = RoundedCornerShape(5.dp)
        ) { 
            MyText(counter)
        }
    }
}

@Composable
fun MyText(counter: Int) {
    Column {
        Text("MyText: counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Text("Another Text", color = Color.White, modifier = Modifier.background(getRandomColor()))
    }
}


可以看到,点击修改 counter 值的时候,不仅 MyText 组件中的第一个读取 counter 值的 Text 组件会发生重组,而且 MyText 组件中的另一个未读取 counter 值的 Text 组件也发生了重组。也就是说整个 MyText 组件都发生了重组。

内联组件的重组作用域与其调用者相同

在一般情况下,读取某个 state 值的组件和未读取某个 state 值的组件,它们的重组作用域是隔离的,互不影响。但是内联组件除外。可以通过下面的例子来说明这个问题:

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp)
    ) {
        var update1 by remember { mutableStateOf(0) } 

        println("ROOT")
        Text("Text in outer Column", color = Color.White, modifier = Modifier.background(getRandomColor()))

        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = { update1++ },
            shape = RoundedCornerShape(5.dp)
        ) {
            println("🔥 Button 1")
            Text(
                text = "Text in Button1 read update1: $update1",
                textAlign = TextAlign.Center,
                color = Color.White,
                modifier = Modifier.background(getRandomColor())
            )
        }

        Column(
            modifier = Modifier
                .padding(4.dp)
                .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
                .background(getRandomColor())
                .padding(4.dp)
        ) {
            println("🚀 Inner Column")
            var update2 by remember { mutableStateOf(0) }
            Button(
                modifier = Modifier.fillMaxWidth(),
                colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
                onClick = { update2++ },
                shape = RoundedCornerShape(5.dp)
            ) {
                println("✅ Button 2")
                Text(
                    text = "Text in Button2 read update2: $update2",
                    textAlign = TextAlign.Center,
                    color = Color.White,
                    modifier = Modifier.background(getRandomColor())
                )
            }

            Column(
                modifier = Modifier
                    .padding(4.dp)
                    .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
                    .background(getRandomColor())
                    .padding(6.dp)
            ) {
                println("☕ Bottom Column")
                /**
                 * 🔥🔥 Observing update(mutableState) causes entire composable to recompose
                 */
                Text(
                    text = "🔥 Text in Inner Column read update1: $update1",
                    textAlign = TextAlign.Center,
                    color = Color.White,
                    modifier = Modifier.background(getRandomColor())
                )
            }
        } 
    }
}


当我们点击 Button 2 时,只会影响读取 update2 值的范围,这没有问题。但是我们点击 Button 1 时,整个组件都在为我们闪烁!这并没有像我们预想的那样:只影响 Button 1 中的读取 update1 的 Text 以及内部嵌套 Column 中读取 update1 的 Text 组件。而是影响了整个外部的 Column 组件的作用域。

这是因为 Column 组件是被定义为 inline 内联的。我们可以通过它的源码定义中发现:

@Composable
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) {
    val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
    Layout(
        content = { ColumnScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

所以在上面的例子中,最外层的 Column 组件嵌套了第二个 Column 组件,而第二个 Column 组件嵌套了第三个 Column 组件,由于inline函数的特性,最终所有 Column 组件内部的组件都会被直接在编译期插入到最外层中。所以第三个 Column 组件中读取 update1 的 Text 实际上相当于是处在最外层中。因此当 update1 发生变化时,整个最外层都会重组,因为它们属于同一个重组作用域。

同样的,Row 组件也是内联的。因为这两个组件是在开发当中是会高频使用的组件,所以我们要尤其注意这一点。如果我们不想某个状态值导致整个组件都重组,换句话说,如果我们想最大程度的做到状态隔离,缩小重组作用域,那么最好使用非 inline 的组件,例如 Surface 组件等。

隔离重组作用域

上面提到, inline 组件会将重组作用域暴露给调用者,进而导致调用者的重组作用域被放大,子组件发生重组时父组件也受到了牵连。但是,假如我们的业务代码中已经大量的应用了 Column 或 Row 这样的内联组件,或者我们此时想提升一下页面渲染的性能,想要追求极致的用户体验,我们该怎么办呢?换句话说,就是 如何隔离重组作用域?其实很简单,说出来你可能不信:既然 inline 的不行,那么改成 非 inline 的不就可以了嘛。

比如上面的例子,我们可以将 Column 组件换成一个自定义的 Column,我们只需要简单地在外层包装一个 Composable 函数透传 content 即可。例如像下面这样:

@Composable
fun RandomColorColumn(content: @Composable () -> Unit) { 
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp)
    ) { 
        content()
    }
}

然后我们只需将上面例子中的 Column 全部换成这个 RandomColorColumn ,而其他部分的代码基本不动:

@Composable
fun Sample() {
    RandomColorColumn {
        var update1 by remember { mutableStateOf(0) } 
        Text("Text in outer Column", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = { update1++ },
            shape = RoundedCornerShape(5.dp)
        ) { 
            Text(
                text = "Text in Button1 read update1: $update1",
                textAlign = TextAlign.Center,
                color = Color.White,
                modifier = Modifier.background(getRandomColor())
            )
        }

        RandomColorColumn { 
            var update2 by remember { mutableStateOf(0) }
            Button(
                modifier = Modifier.fillMaxWidth(),
                colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
                onClick = { update2++ },
                shape = RoundedCornerShape(5.dp)
            ) { 
                Text(
                    text = "Text in Button2 read update2: $update2",
                    textAlign = TextAlign.Center,
                    color = Color.White,
                    modifier = Modifier.background(getRandomColor())
                )
            }
            RandomColorColumn {   
                Text(
                    text = "🔥 Text in Inner Column read update1: $update1",
                    textAlign = TextAlign.Center,
                    color = Color.White,
                    modifier = Modifier.background(getRandomColor())
                )
            }
        } 
    }
}

效果:


现在,事情就会变得跟我们预期的那样:当疯狂点击 Button 1 时,只有 Button 1 中的读取 update1 的 Text 以及内部嵌套 RandomColorColumn 中读取 update1 的 Text 组件会发生重组,而不是影响所有整个组件。

也许有人会问,这相比原来直接使用 Column,你又多了嵌套一层,不会影响性能吗?答案是并不会。原因主要有两点:

  • 之前在 Jetpack Compose中的绘制流程和自定义布局 中提到过,Compose 中不允许被多次测量,每个子元素只允许被测量一次,因此并不会因为嵌套层级的增加而导致测量次数的指数爆炸问题,正所谓 “一时嵌套一时爽,一直嵌套一直爽”。
  • 实际上 Compose 编译器会对 Composable 函数施加一些 “魔法”,而 Compose runtime 会持有对这些 Composable 函数的引用,它们可能在运行时以任意顺序被重新执行、并行执行(可能多线程)、甚至被跳过执行,所以它们并不像我们传统意义上的标准函数调用堆栈那样,调用顺序也不会跟我们代码书写的那样按照先后顺序一层一层的往下调用再返回。

当然嵌套多了的话,也不能说完全没有影响,至少会增加 Compose 编译器的编译时间成本,还有就是最终生成的DEX包可能会大一些。

另外,Jetpack Compose 这个框架本身已经极为优秀了,正常情况下也不会出现太大的性能问题,一般也不需要这么做。只有在你想要鸡蛋里挑骨头、追求极致性能体验的情况下,才需要十分小心的留意你所使用的 Composable 组件是否是 inline 的。

重组作用域内不读取任何参数的组件不会被重组

这里表达的意思是某个组件不从外部接受参数,当我们定义一个组件时,可以在组件内部维护一些状态值,也有可能通过状态提升,将一些状态作为参数暴露出来,交给其公共父组件来管理。但是在父组件中,一旦发生重组,它只会影响那些会为其传递参数的子组件,如果某个子组件不从父组件接受任何参数,那么它在重组中保持不变(从父组件的视角)。这貌似跟本文列出的第一点有点重复,但是我还是想列出来单独说一下。

我们再利用一下本文最开头的例子进行一下修改:

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var counter by remember { mutableStateOf(0) }
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = { counter++ },
            shape = RoundedCornerShape(5.dp)
        ) { 
            Text("Text2 Don't Read counter", color = Color.White, modifier = Modifier.background(getRandomColor())) 
        }
    }
}


我们发现 Button 中的 Text 组件不会发生变化,这是因为它没有读取 counter 的状态值。但是我们发现 Button 本身的颜色却在发生变化,说明它正在发生重组,这是因为 Column 组件是内联的,这一点我们上面已经介绍过,因此 Button 和 Button 之上的那个 Text 组件实际上在调用 Sample() 的组件中,是处于相同的重组作用域。所以这里我们就会看到有趣的事,Button 会重组,但它内部的组件却不一定,这取决于其内部组件是否会从外部接受参数,在这个例子中并没有。

现在,我们来修改一下代码,让 Button 中的 Text 组件接受一些外部的参数:

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally 
    ) {
        var counter by remember { mutableStateOf(0) }
        val myData = remember { MyData() } 
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myData.value = myData.value + 1 
            },
            shape = RoundedCornerShape(5.dp)
        ) { 
            Text("Text2 Read myData.value: ${myData.value}", color = Color.White, modifier = Modifier.background(getRandomColor())) 
        }
    }
}
class MyData(var value: Int = 0)

再次运行查看效果:


这里让 Button 中的 Text 2 读取的是一个普通的类 MyData 中的字段值,并且特意没有设置成 mutableStateOf(),只使用了 remember 函数, remember 充其量只是一个缓存函数(只有第一次执行初始化操作),也就是说 myData并不是一个能被 Composable 观察的 state 值。

但是我们发现了比较神奇的事情,Button 中没有读取任何可变状态的 Text 2 竟然也发生了重组!因为 MyData 这个类会被编译器推断为具有不稳定性,而不确定性的东西是会破坏 Compose runtime 的智能重组的,所以 Compose 就干脆放弃这种情况,每次都会调用它重新执行组合。严格来讲, Text 2 组件发生重组是由于 Text 1 组件读取 counter 组件导致的,如果你把 Text 1 组件注释掉,那么Text 2 组件就不会发生重组(如果还会发生重组,那就是见鬼了)。

或者,我们可以更简单一点,不要用什么 MyData 类了,就直接放一个普通的变量让 Text 2 去读取。

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally 
    ) {
        var counter by remember { mutableStateOf(0) }
        var myVal = 0
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myVal++
            },
            shape = RoundedCornerShape(5.dp)
        ) { 
          Text("Text2 Read myVal: $myVal", color = Color.White, modifier = Modifier.background(getRandomColor()))
        }
    }



你会发现虽然 Text 2 组件读取的 myVal 的值没有变化,但是 Text 2 组件的背景色却在闪,说明它仍然在发生重组。

既然如此,那我们可再简化一点,直接让 Text 2 组件什么也不读取了,但是我们在它上面一行加一个 println 函数,打印一下 myVal 的值:

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally 
    ) {
        var counter by remember { mutableStateOf(0) }
        var myVal = 0
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myVal++
            },
            shape = RoundedCornerShape(5.dp)
        ) { 
            println(myVal)
            Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor())) 
        }
    }



这时发现 Text 2 组件的背景色仍然在闪,说明它仍然在发生重组。奇怪了,不是说 Compose 智能重组只会重组那些读取状态值发生改变的组件吗???

是不是很纳闷,那我们再改一下,直接让 println 函数打印一个固定的字符串:

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally 
    ) {
        var counter by remember { mutableStateOf(0) }
        var myVal = 0
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myVal++
            },
            shape = RoundedCornerShape(5.dp)
        ) { 
            println("Hello World")
            Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor())) 
        }
    }



这下 Text 2 是真的不会变了。

这说明了什么?Button 组件受到重组时,其内部只要读取了外部参数就会发生重组,这里的内部是指 Button { } 大括号中传入的名为 content 的 Composable lambda 参数:


前面的例子中在Text组件的前后去打印外部的参数,Text 组件也会重组是因为 打印外部参数 这件事实际是直接发生在 content 这个 Composable 组件的内部,也就是说 content 这个 Composable 组件读取了外部的参数。

也就是说 Button 的 content lambda 中只要读取了外部参数就会触发 content 的重组,我们修改成下面这样, Text 2 组件也是会发生重组,因为虽然 Text 2 组件没有读取,但是相邻的组件读取了,而它们都最终都会导致 content lambda 从外部读取参数。(注意下面的代码中,即便你将 Button 内部的 Column 套上 Surface 等非 inline 的组件也不能隔离重组)

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally 
    ) {
        var counter by remember { mutableStateOf(0) }
        var myVal = 0
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myVal++
            },
            shape = RoundedCornerShape(5.dp)
        ) {  
            Column {
                Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor()))
                Text("Text3 Read myVal: $myVal", color = Color.White, modifier = Modifier.background(getRandomColor()))
           }
        }
    }


因为 Button 的 content 内容我们是可以自定义的,也就是说我们可以像下面这样定义:

 Button(
     modifier = Modifier.fillMaxWidth(),
     colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
     onClick = { counter++ }, 
 ) { 
     SomeComposable() 
 }

这样可能更能表达上面的意思,因为在实际中,向外暴露 content Composable lambda 的自定义组件是很常见的。这种情况下,在 SomeComposable() 中的任何子组件都不会因为 Button 的重组而发生重组,因为它没有从外部接收任何参数。假如我们希望上面的例子中, Text 3 组件读取外部参数的情况下,Text 2 组件不要受到影响,那么解决方法就是将其包装进一个 不接受任何参数的 SomeComposable() 组件中:

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally 
    ) {
        var counter by remember { mutableStateOf(0) }
        var myVal = 0
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myVal++
            },
            shape = RoundedCornerShape(5.dp)
        ) {  
             Column {
                SomeComposable()
                Text("Text3 Read myVal: $myVal", color = Color.White, modifier = Modifier.background(getRandomColor()))
            }
        }
    }

@Composable
private fun SomeComposable() { 
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 6.dp))
            .background(getRandomColor())
            .padding(4.dp)
    ) {
        Text("Text2 Read Nothing", color = Color.White, modifier = Modifier.background(getRandomColor()))
    }
}


/   优化Modifier的状态读取   /

一般来说,应该尽量避免在 Modifier 修饰符上读取状态,因为 Modifier 并不是用来显示数据元素的地方,并且从修饰符上频繁读取状态值会导致频繁的重复进而导致过渡重绘。但有时我们可能避免不了,比如想要位置偏移、大小、背景或边距等根据某个状态来不断变化,从而达到某种效果。甚至有可能会经常这么干,一旦你有这样的需求,官方为我们提供了一些更好的原则值得去遵守。

总的来说就是一条原则:尽量使用带 lambda 的修饰符方法来读取 Modifier 需要的状态值。

这是因为使用 lambda 意味着延迟读取(Deffered Reading),而不是在组合期间读取(这样会反复触发重组,而重组意味着触发一些列的测量流程,从布局到绘制)。标准的 Modifier 修饰符方法是一定会在组合期间被执行的,而 lambda 形式的 Modifier 修饰符基本上可以确定是不会在在组合期间被执行的,Compose 的官方团队就这一点也已经给出了明确的说明。

下面是使用 Modifier.offset(offset: Dp) 和 Modifier.offset(offset: Density.() -> IntOffset) 的一个例子,它很好的表达了这两者的不同效果:

@Composable
fun PhasesSample() { 
    var offsetX by remember { mutableStateOf(0f) }
    Row(verticalAlignment = Alignment.CenterVertically) {
        Text(text = "OffsetX")
        Spacer(modifier = Modifier.width(5.dp))
        Slider(value = offsetX,
            valueRange = 0f..50f,
            onValueChange = {
                offsetX = it
            }
        )
    }
    val modifier1 = Modifier 
        .offset(x = offsetX.dp) // 直接读
        .layout { measurable, constraints ->
            val placeable: Placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                println("😃️ modifier1 LAYOUT")
                placeable.placeRelative(0, 0)
            }
        }
        .background(Blue400)
        .drawWithContent {
            println("😜 modifier1 DRAW")
            drawContent()
        }

    val modifier2 = Modifier 
        .offset { // 放在 lambda 中返回
            val newX = offsetX.dp.roundToPx()
            IntOffset(newX, 0)
        }
        .layout { measurable, constraints ->
            val placeable: Placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                println("🍏 modifier2 LAYOUT")
                placeable.placeRelative(0, 0)
            }
        }
        .background(Blue400)
        .drawWithContent {
            println("🍎 modifier2 DRAW")
            drawContent()
        }

    MyBox(modifier = modifier1, "modifier1")
    Spacer(modifier = Modifier.height(8.dp))
    MyBox(modifier = modifier2, "modifier2")
}
@Composable
private fun MyBox(modifier: Modifier, title: String) {
    LogCompositions(msg = "🔥 MyBox() COMPOSITION $title")
    Column(modifier) {
        // This Text changes color in every recomposition
        Text(
            text = title,
            color = Color.White,
            modifier = Modifier
                .background(getRandomColor())
                .fillMaxWidth()
                .padding(2.dp)
        )
        Text(
            text = "modifier hash: ${modifier.hashCode()}\nModifier: $modifier",
            color = Color.White,
            modifier = Modifier.heightIn(max = 200.dp),
            fontSize = 12.sp
        )
    }
}

运行效果:


上面代码中,modifier1 使用标准版本的 offset() 来设置 x 轴的偏移量,而 modifier2 使用 lambda 版本的 offset{} 来设置 x 轴的偏移量。为了观察这两个修饰符在各个布局绘制阶段的执行情况,还为它们设置了layout 和 drawWithContent,它们只用来打印 Log,并没有做额外的事情,代码也只是执行原本的逻辑。同时为了观察 Modifier 的状态,还在 MyBox 组件中将 Modifier 的 hashCode 和其属性值显示到 Text 组件上。

最终我们看到的结果就是,使用标准版本 offset() 的 modifier1 背景一直在闪烁,也就是一直在发生重组,并且其 hashCode 在不停的变化,而使用 lambda 版本 offset{} 的 modifier2 背景不变即没有在发生重组,且其 hashCode 也保持不变。

同时,通过观察 Logcat 控制台的输出,可以发现使用标准版本 offset() 的modifier1 在不断的执行:COMPOSITION -> LAYOUT -> DRAW 三个阶段。


使用 lambda 版本 offset{} 的modifier2 只会输出 LAYOUT 这一个阶段。


结合这些结果,下面再从组合树的角度看一下,为啥标准版不好,而 lambda 大法好:


标准版本 offset() 在每次偏移量发生改变时,会重新创建 Modifier 实例(hashCode不停变化证实了这一点),当 Modifier 实例发生变化时,组合树会先删除旧的,然后再添加一个新的 Modifier 实例。组合树的变化会导致重组的发生,因此如果偏移量频繁变化,重组就会频繁的发生,而每次重组都有可能触发组合->布局->绘制这三个阶段,这非常要命。


而 lambda 版本 offset{} 的优势是, Modifier 实例不会改变(hashCode 保持不变证实了这一点),Compose 只会在需要的时候 调用 lambda 函数,因此组合树不会发生变化,也就是说相比之下可以跳过不必要的重组。(前面的 Log 输出证实了其只会执行 Layout 阶段)

除了跳过重组的好处, lambda 版本还有一点好处就是它可能会跳过布局的一半(在大小没有变化的情况下),因为我们知道 Compose 的布局阶段包含测量和摆放两个阶段,如果只修改了位置,尺寸大小没有变化,那么 Compose 可以完全可以只执行摆放阶段。因此在最好的情况下,它能为我们节省整个流程的一半的时间。😃️


如果我们有机会使用 Modifier.layout() (一般是在自定义组件时), 也可以直接在其 layout(){} 方法中摆放组件的时候进行设置,这会直接发生在布局阶段的摆放阶段。例如将上面例子中的 modifier1 修改代码如下:

val modifier1 = Modifier  
        .layout { measurable, constraints ->
            val placeable: Placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                println("😃️ modifier1 LAYOUT")
                // placeable.placeRelative(0, 0)
                placeable.placeRelative(offsetX.dp.roundToPx(), 0) // 在这里设置
            }
        }
        .background(Blue400)
        .drawWithContent {
            println("😜 modifier1 DRAW")
            drawContent()
        }

这时再运行发现二者都安静了,两个修饰符都不会随 offsetX 变化而发生重组:


对于背景色,也是一样的道理,可以使用 Modifier.drawWithContent{} 这个修饰符中绘制背景框,请参考下面的例子:

@Composable
fun PhasesSample() { 
    var index by remember { mutableStateOf(0f) } 
    Text(text = "bgColor")
    Slider(
        value = index,
        valueRange = 0f..100f,
        onValueChange = { index = it }
    )

    val modifier1 = Modifier
        .layout { measurable, constraints ->
            val placeable: Placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                println("😃️ modifier1 LAYOUT") 
                placeable.placeRelative(0, 0)
            }
        }
        .drawWithContent {
            println("😜 modifier1 DRAW")
            drawContent()
        }
        .background(randomColors[index.roundToInt()%randomColors.size]) // 直接设置

    val modifier2 = Modifier
        .layout { measurable, constraints ->
            val placeable: Placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                println("🍏 modifier2 LAYOUT")
                placeable.placeRelative(0, 0)
            }
        } 
        .drawWithContent { // 在 lambda 中设置
            val color = randomColors[index.roundToInt()%randomColors.size]
            println("🍎 modifier2 DRAW color: $color")
            drawRect(color)
            drawContent()
        }

    MyBox(modifier = modifier1, "modifier1")
    Spacer(modifier = Modifier.height(20.dp))
    MyBox(modifier = modifier2, "modifier2") 
}

@Composable
private fun MyBox(modifier: Modifier, title: String) {
    LogCompositions(msg = "🔥 MyBox() COMPOSITION $title")
    Column(Modifier.background(purple500)) { 
        Text(
            text = title,
            color = Color.White,
            modifier = modifier
                .fillMaxWidth()
                .padding(4.dp)
        )
        Text(
            text = "modifier hash: ${modifier.hashCode()}\nModifier: $modifier",
            color = Color.White,
            fontSize = 12.sp
        )
    }
}

运行效果:

同样的,这里使用标准版 background() 直接设置背景色,会导致 Modifier 实例不断变化,而使用 Modifier.drawWithContent{} 方法绘制背景色, Modifier 实例就会保持不变。如果观察log输出,会发现前者不断输出 COMPOSITION 和 DRAW (因为位置大小没有变,所以跳过了布局 LAYOUT 阶段),而后者只会输出 DRAW ,也就是说在 lambda 中进行绘制只会影响绘制阶段,它可以为我们跳过组合和布局两个阶段。不得不说,这样大大滴好呀。😃️

看完背景色的设置,再来看一下 padding,奇怪的是在 Compose 的API中我并没有找到关于 padding修饰符的 lambda 版本,这让我很是不解,我就搞不懂了,既然要搞,官方为啥不搞一套呢?难道又需要在 Modifier.layout(){} 中设置吗?那岂不是很麻烦?其实我们可以自己搞一个 lambda,因为 lambda 就是一个函数类型的函数参数而已,所以我们可以这样做。

@Composable
fun DeferredPaddingComposablesSample() {
    var padding by remember { mutableStateOf(0f) }
    //...
    PaddingDeferred {
        padding.dp
    } 
}
@Composable
private fun PaddingDeferred(padding: () -> Dp) { 
    Text(
        text = "PaddingDeferred",
        modifier = Modifier
            .padding(start = padding())
            .fillMaxWidth()
            .background(getRandomColor()) 
    )
}

这样虽然不能避免读取 padding 值本身的组件的重组,但是却能够尽量的避免父组件的重组,将重组范围缩小到最小。请看下面的示例:

@Composable
fun DeferredPaddingComposablesSample() {
    var padding by remember { mutableStateOf(0f) }

    Row(verticalAlignment = Alignment.CenterVertically) {
        Text(text = "Padding")
        Spacer(modifier = Modifier.width(5.dp))
        Slider(value = padding,
            valueRange = 0f..50f,
            onValueChange = {
                padding = it
            }
        ) 
    } 
    PaddingOuterDeferred { padding.dp } // 使用 lambda 传
    PaddingOuterDirectly(padding.dp) // 直接传
}
@Composable
private fun PaddingOuterDeferred(padding: () -> Dp) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(getRandomColor())
            .padding(10.dp)
    ) {

        LogCompositions(msg = "😍 PaddingOuterDeferred")
        Text("PaddingOuterDeferred")
        PaddingMiddleDeferred(padding)
    }
}
@Composable
private fun PaddingMiddleDeferred(padding: () -> Dp) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(getRandomColor())
            .padding(10.dp)
    ) {
        LogCompositions(msg = "😃 PaddingMiddleDeferred")
        Text("PaddingMiddleDeferred")
        PaddingInnerDeferred(padding)
    }
}
@Composable
private fun PaddingInnerDeferred(padding: () -> Dp) {
    LogCompositions(msg = "😜 PaddingInnerDeferred")
    Text(
        text = "PaddingInnerDeferred",
        modifier = Modifier
            .padding(start = padding())
            .fillMaxWidth()
            .background(getRandomColor())

    )
}

@Composable
private fun PaddingOuterDirectly(padding: Dp) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(getRandomColor())
            .padding(10.dp)
    ) {

        LogCompositions(msg = "PaddingOuterDirectly")
        Text("PaddingOuterDirectly")
        PaddingMiddleDirectly(padding)
    }
}
@Composable
private fun PaddingMiddleDirectly(padding: Dp) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(getRandomColor())
            .padding(10.dp)
    ) {
        LogCompositions(msg = "PaddingMiddleDirectly")
        Text("PaddingMiddleDirectly")
        PaddingInnerDirectly(padding)
    }
}
@Composable
private fun PaddingInnerDirectly(padding: Dp) {
    LogCompositions(msg = "PaddingInnerDirectly")
    Text(
        text = "PaddingInnerDirectly",
        modifier = Modifier
            .padding(start = padding)
            .fillMaxWidth()
            .background(getRandomColor())
    )
}

同样的,为了对比效果,这里我们搞了两套代码,一套直接传参数 dp 值不使用函数类型,另一套传递函数类型。运行效果:


对比效果很明显,不使用 lambda 直接传 dp 值的,所有父组件都在发生重组,而使用 lambda 传 dp 值的只有最里面的子组件才会重组。

但是感觉还不是很完美,假如我们想做到像 Modifier.offset{ } lambda 版本那样的效果,也就是读取 padding 状态值的时候完全不发生重组,难道就没有办法了吗?当然不是,首先 我们可以使用 Modifier.layout(){} 来自己处理,这样比较麻烦,但是可以实现。还有更简单一点的办法,自定义 Modifier 修饰符。我们可以仿照 Modifier.offset{ } 的方式来自定义,但我们不需要自己写 padding 的逻辑,因为我们可以直接查看系统内置 padding 源码的实现,找到下面的代码:

// Padding.kt
@Stable
fun Modifier.padding(paddingValues: PaddingValues) =
    this.then(
        PaddingValuesModifier(
            paddingValues = paddingValues,
            inspectorInfo = debugInspectorInfo {
                name = "padding"
                properties["paddingValues"] = paddingValues
            }
        )
    )
private class PaddingValuesModifier(
    val paddingValues: PaddingValues,
    inspectorInfo: InspectorInfo.() -> Unit
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        require(
            paddingValues.calculateLeftPadding(layoutDirection) >= 0.dp &&
                paddingValues.calculateTopPadding() >= 0.dp &&
                paddingValues.calculateRightPadding(layoutDirection) >= 0.dp &&
                paddingValues.calculateBottomPadding() >= 0.dp
        ) {
            "Padding must be non-negative"
        }
        val horizontal = paddingValues.calculateLeftPadding(layoutDirection).roundToPx() +
            paddingValues.calculateRightPadding(layoutDirection).roundToPx()
        val vertical = paddingValues.calculateTopPadding().roundToPx() +
            paddingValues.calculateBottomPadding().roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            placeable.place(
                paddingValues.calculateLeftPadding(layoutDirection).roundToPx(),
                paddingValues.calculateTopPadding().roundToPx()
            )
        }
    }

    override fun hashCode() = paddingValues.hashCode()

    override fun equals(other: Any?): Boolean {
        val otherModifier = other as? PaddingValuesModifier ?: return false
        return paddingValues == otherModifier.paddingValues
    }
}    

我们直接把上面的源码复制一份,然后把不必要的调试信息去掉,稍微改一下,我们自己的 lambda 版本的 padding{ } 修饰符就诞生了:

@Stable
private fun Modifier.padding(paddings: Density.() -> PaddingValues) = then(
    PaddingValuesModifier(paddings, rtlAware = true)
)

private class PaddingValuesModifier(
    val paddings: Density.() -> PaddingValues,
    val rtlAware: Boolean,
) : LayoutModifier {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val paddingValues = paddings()
        require(
            paddingValues.calculateLeftPadding(layoutDirection) >= 0.dp &&
                    paddingValues.calculateTopPadding() >= 0.dp &&
                    paddingValues.calculateRightPadding(layoutDirection) >= 0.dp &&
                    paddingValues.calculateBottomPadding() >= 0.dp
        ) {
            "Padding must be non-negative"
        }
        val horizontal = paddingValues.calculateLeftPadding(layoutDirection).roundToPx() +
                paddingValues.calculateRightPadding(layoutDirection).roundToPx()
        val vertical = paddingValues.calculateTopPadding().roundToPx() +
                paddingValues.calculateBottomPadding().roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(
                    paddingValues.calculateLeftPadding(layoutDirection).roundToPx(),
                    paddingValues.calculateTopPadding().roundToPx()
                )
            } else {
                placeable.place(
                    paddingValues.calculateLeftPadding(layoutDirection).roundToPx(),
                    paddingValues.calculateTopPadding().roundToPx()
                )
            }
        }
    }

    override fun hashCode() : Int {
        var result = paddings.hashCode()
        result = 31 * result + rtlAware.hashCode()
        return result
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        val otherModifier = other as? PaddingValuesModifier ?: return false

        return paddings == otherModifier.paddings &&
                rtlAware == otherModifier.rtlAware
    }
}

现在,我们把前面例子中的使用函数类型传递 dp 值的最内层的 padding 应用改一下,改成使用我们自己的 lambda 版本:

// ...省略其余代码
@Composable
private fun PaddingInnerDeferred(padding: () -> Dp) { 
    Text(
        text = "PaddingInnerDeferred",
        modifier = Modifier
            //.padding(start = padding())
            .padding {
                PaddingValues(start = padding())
            }
            .fillMaxWidth()
            .background(getRandomColor())
    )
}

再次运行代码,查看效果:


可以看到,上面的 Inner Text 上读取变化的 padding 值时,也不会闪了,腰也不疼了,腿也不酸了,再也不会发生重组了。真香😃

/   避免重组循环(循环阶段依赖项)   /

系统始终按照相同的顺序来调用 Compose 的各个阶段,并且无法在同一帧中后退。不过,这并未禁止应用跨不同的帧进入组合循环。请思考以下示例:

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

在本例中,我们实现了一个垂直列(并不理想),其顶部是图片,而图片下方则是文本。我们要使用 Modifier.onSizeChanged() 来获取图片的解析大小,然后对文本使用 Modifier.padding() 以将其下移。从 Px 转换回 Dp 的过程很不自然,这就说明代码存在一些问题。该示例的问题在于,我们没有在单个帧中达到“最终”布局。该代码依赖发生多个帧,它会执行不必要的工作,并导致界面在用户屏幕上跳动。

接下来,我们要逐一检查每个帧,看看发生了什么。在第一帧的组合阶段,imageHeightPx 的值为 0,并且文本是按照 Modifier.padding(top = 0) 提供的。接着,布局阶段紧随其后,并且系统调用 onSizeChanged 修饰符的回调。此时,imageHeightPx 更新为了图片的实际高度。Compose 为下一帧安排重组。在绘制阶段,由于值发生的更改尚未得到反映,因此,系统渲染文本时将内边距设为了 0。

然后,Compose 启动根据 imageHeightPx 的值发生的更改安排的第二帧。系统在 Box 内容块中读取状态,并在组合阶段调用该状态。此时,系统提供文本时会采用与图片高度相匹配的内边距。在布局阶段,代码确实会再次设置 imageHeightPx 的值,但不会安排重组,因为该值会保持不变。

最终,我们会在文本上实现所需的内边距,但这并不是最理想的情况,因为我们还要使用一个额外的帧将内边距值传递回其他阶段,而这会导致产生一个内容重叠的帧。


该示例可能显得有些刻意,但请注意以下通用模式:

  • Modifier.onSizeChanged()、onGloballyPositioned() 或一些其他布局操作
  • 更新某种状态
  • 使用该状态作为对布局修饰符(padding()、height() 或类似元素)的输入
  • 可能会重复

若要修复以上示例,您可以使用适当的布局,以上示例可以使用一个简单的 Column() 来实现,但您可能会遇到需要进行自定义的更复杂的示例,这时需要编写自定义布局。

/   在列表中使用key指定唯一id   /

Compose 编译器会在每个 Composable 函数的函数体中插入一个 group,每个 group 都会使用一个 key 作为唯一标识,这个 key 是使用 Composable 在源码中的位置信息来生成的(由 Compose runtime 生成)。也就是说这个 key 就是 Composable 的标识。一般情况下 Composable 在源码中的位置是不会变的,但是使用列表的情况除外。当代码中会生成一个 Composable 列表时,对 Compose runtime 来说,分配唯一标识是很困难的。

例如下面的例子:


在这种情况下,每次都从相同的位置调用 Talk(talk),但是每个 Talk 表示列表上的不同项,因此是树上的不同节点。在这种情况下,Compose runtime 依赖于调用的顺序来生成唯一的 id,并且仍然能够区分它们。

当将一个新元素添加到列表的末尾时,这段代码仍然可以正常工作,因为其余的调用保持在与以前相同的位置。但如果我们在顶部或中间的某个位置添加元素呢?Compose runtime 将重新组合该位置以下的所有Talk,因为它们改变了位置,即使它们的输入没有变化。这是非常低效的(尤其是对于长列表而言),因为这些调用本应该被跳过。

为了解决这个问题,Compose 提供了一个用来设置 key 的 Composable,因此我们可以手动为 Composable 调用分配一个显式的 key :


在这个例子中,我们使用 talk.id(可能是唯一的)作为每个 Talk 的 key,这将允许 runtime 保存列表中所有项的标识,而不管它们的位置如何。

记住,位置记忆是 Composable 函数的属性之一,这一部分在 Jetpack Compose 深入探索系列一:Composable 函数中有介绍过,感兴趣的话可以去看看。

/   使用derivedStateOf降低重组次数   /

derivedStateOf 主要作用是将其他高频变化的 state 转换成另一个低频变化的 state,从而减少多余的没必要的重组,其在性能优化方面的应用场景和示例,之前在 Jetpack Compose 中的副作用中的 derivedStateOf { } VS remember(key) { } 部分有提到过,感兴趣的可以点击过去查看。这里就不再重复列举了。

总而言之,当你需要依赖其他状态生成另一个状态来更新 UI,并且其他状态的更新频率远远高于 UI 的刷新频率时,这时就是可以考虑使用 derivedStateOf 的时候了。


/   类型的稳定性与不稳定性   /

关于稳定性的话题,在前面的例子中有提到过,但是没有详细展开。下面再来看一下:

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var counter by remember { mutableStateOf(0) }
        val myData = remember { MyData(0) }
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myData.value = myData.value + 1
            },
            shape = RoundedCornerShape(5.dp)
        ) {
            MyText(myData.value)
        }
    }
}

class MyData(var value: Int = 0)

@Composable
fun MyText(counter: Int = 0) {
    Column {
        Text("MyText: Read myData.value: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Text("Another Text", color = Color.White, modifier = Modifier.background(getRandomColor()))
    }
}


这里的 MyData 类的 myData 对象并没有被 mutableStateOf() 包装,按理说它应该是一个不能被 Composable 观察到的状态,但是这里当 Button 组件重组时,读取 myData 对象值的 MyText 组件也发生重组了。这说明 Compose 还是为它执行了 Composable 函数。

这是因为 myData 对象虽然是被定义为 val 不可被重新赋值,但是 MyData 类中包含了可变成员 var value,这就意味着 myData 对象并不是真正意义上的不可变对象,因为随时都可以通过其可变成员修改它的数据,实际上面代码中也是这么干的,在点击事件中,不断的修改它的 value 成员值+1。因此它被编译器推断为是一个不稳定的类,而不稳定的类会破坏智能重组,因此意味着它不能跳过重组(Not skippable)。


智能重组指的是当 Composable 函数的输入没有改变且这些输入被认为是稳定时,跳过重新组合。稳定性在这个意义上是一个非常重要的概念,因为它意味着Compose runtime 可以安全地读取和比较这些输入,以在需要时跳过重新组合。稳定性的最终目标是帮助 runtime。

那么什么样的类会被编译器确认为稳定的类型呢?总的来说,一个稳定的类型需要满足如下三点:

对相同的两个实例调用 equals 的结果始终相同。这意味着比较是一致的,因此运行时可以依赖它。

当类型的公开属性更改时,总是会通知Composition组合。否则,我们可能会遇到输入与最新状态不同步的情况。为了确保不会发生这种情况,总是针对此类情况触发重新组合。智能重组合无法依赖此输入。

所有公开属性也必须是稳定的。

默认情况下,所有基本类型都是稳定的,String类型和所有函数类型也是稳定的。

有些类型不是不可变的,但可以被Compose假设为稳定的,它们可以用@Stable 进行注解。对于我们在代码中创建的自定义类型,我们可以判断它们是否符合上面列出的属性,并使用@Immutable或@Stable注解手动将它们标记为稳定类型。@Immutable和@Stable 都是通过 @StableMarker 元注解注解的注解,如果要使用这三个注解来标注一个类为稳定的,则该类必须也满足上面三点要求。

因此对于上面的示例代码,即便将 MyData 类上手动添加 @Stable 注解也是不能避免重组的,因为其公开属性就是不稳定的,这违背了上面的要求。另外这三个注解都是开发者对编译器的承诺,但编译器在编译时不会验证它们。因此依靠开发者履行契约是相当危险的,而且很难长期维持,所以编译器会自动推断类的稳定性。

为了推断一个类的稳定性,Compose考虑了不同的东西。当类的所有字段都是只读且稳定时,类型被推断为稳定。像 class Foo 或 class Foo(val value: Int) 这样的类将被推断为稳定的,因为它们没有字段或只有稳定字段。然后像 class Foo(var value: Int) 这种将被推断为不稳定的。对于由其他类组成的类,如 class Foo(val bar: Bar, val bazz: Bazz),稳定性被推断为所有参数稳定性的组合。这种情况会通过递归来解决。

像内部可变状态这样的东西也会使类不稳定。其中一个例子如下:


这种状态会随着时间的推移发生变化,即使它是由类本身在内部发生变化。这意味着运行时并不能真正的相信它会总是保持一致不变的。

总的来说,Compose编译器只有在能够证明一个类型是稳定的时候才会考虑它。例如:一个接口被认为是不稳定的,因为Compose不知道它将如何实现。

再来看一个示例代码:


在这个例子中,我们得到一个List作为参数,它能以可变的方式实现(它可以是List也可以是MutableList实现)。对编译器来说,这种情况下推断会变得及其困难,所以它会直接假设它是不稳定的。

对于开头的例子,我们只需将 MyData 类中的公开属性value改为 val 类型即可使编译器推断它为稳定类型(因为它只有一个公开可变类型而且是基本类型):

@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(4.dp)
            .shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
            .background(getRandomColor())
            .padding(4.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var counter by remember { mutableStateOf(0) }
        var myData = remember { MyData1(0) }
        Text("Text1 Read counter: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Button(
            modifier = Modifier.fillMaxWidth(),
            colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
            onClick = {
                counter++
                myData = MyData1(myData.value + 1)
            },
            shape = RoundedCornerShape(5.dp)
        ) {
            MyText(myData.value)
        }
    }
}

class MyData1(val value: Int = 0)

@Composable
fun MyText(counter: Int = 0) {
    Column {
        Text("MyText: Read myData.value: $counter", color = Color.White, modifier = Modifier.background(getRandomColor()))
        Text("Another Text", color = Color.White, modifier = Modifier.background(getRandomColor()))
    }
}

这样 MyText 组件便不会发生重组了,但同时它也不会读取到 myData 中的值。关于 @Satble 如何使用。官方给出了一个简单的示例:

// 告诉编译器,其类型是稳定的可以跳过不必要的重组
@Stable
interface UiState<T> {
    val value T?
    val exception: Throwable?
    val hasError: Boolean
        get() = exception != null
}

在目前来讲,Compose 中使用 @Immutable 和 @Satble 的效果是一样的实际效果是一样的。使用它们都会开启和优化智能重组。但实际中应该谨慎使用它们,而是让编译器自动推断类的稳定性。

建议:Composable中使用的所有class中的var修饰的公开属性都使用 mutableStateOf() 来修饰。(即满足 “所有公开属性的更改时,总是会通知Composition组合” 的要求)

除了使用注解,保证稳定性的另一种方法是使用不可变的集合类,例如使用 kotlinx 提供的 immutable 集合工具类,不过这需要单独添加依赖:

implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:$version")


如果不满足使用前面注解的条件,也不能使用可变的集合类,那么还有一种笨方法,就是将接受不稳定的数据类型手动改为稳定类型。例如下面的代码中,组件接受了一个不稳定的类型:

@Composable
fun WelcomeView(user: UnStableUser) {
    Text(text = "Welcome ${user.username}!")
}
data class UnStableUser(
    var id: String,
    var email: String,
    var username: String
)

那么可以自己手工写一个工具方法将此类转换成另一个 Stable 的类:

data class User(
    val id: String,
    val email: String,
    val username: String
)

fun UnStableUser.toUser(): User {
    return User(
        id = id,
        email = email,
        username = username
    )
}

fun User.toUnStableUser(): ExternalUser {
    return UnStableUser(
        id = id,
        email = email,
        username = username
    )
}

在转换的时候,可以尽量只选择那些被 UI 组件使用到的字段,避免引入多余的可变因素。例如上面的使用user的组件其实只使用到了一个 username,在这种情况下其实将输入参数改成 String 也是可以的。但如果参数较多,还是通过转换类比较方便。

/   优化lambda回调   /

前面提到 lambda 版本的修饰符可以在读取可变状态时提升性能,但是某些情况下,使用 lambda 也会带来一些性能问题。比如我们通常使用的 lambda 回调:

@Composable
fun SomeLayout(onClick: () -> Unit) {
     Button(onClick = onClick) {
       Text(text = "text")
     }
}

假如我们在另一个 Composable 中调用它。

@Composable
fun SomeComposable() {
     SomeLayout {
       // ...
     }
}

在 Composable 中,这种 lambda 回调经常被用于控制反转,即将事件处理的主动权交于外部调用者来实现,但是在 kotlin 中,这种方式的 lambda 调用会生成一个匿名内部类对象(对应JVM平台):

object: Function0<Unit> {
    override fun invoke() {
        // ...
    }
}

由于 Java 中没有所谓的函数类型,当函数类型被翻译到 JVM 平台时,就会以对应的函数接口类型来替代不同参数个数的kotlin函数类型(Function0...Function22,最多有22个)。而这也是问题所在,调用 lambda 的 Composable 实际上无意间向子组件中传递了一个匿名内部类对象。前面提到过,Compose 编译器会对 Composable 中使用的数据类型进行自动类型推断,如果被推断为不稳定的类型,那么就不会执行智能重组(不能跳过重组)。

因此,当调用者无意间在子组件暴露的 lambda 中引入了可变因素,那么该匿名内部类对象很可能就会被 Compose 编译器推断为不稳定,从而导致每次都会触发不必要的重组。来看下面的例子。

@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) { 
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CoffeeSelector(
            type = viewModel.type,
            onTypeChange = {
                viewModel.changeType(it)
            } 
        )
    }
}

@Composable
fun CoffeeSelector(
    type: Type,
    onTypeChange: (Type) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.padding(10.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(getRandomColor()),
            contentAlignment = Alignment.Center
        ) {
            val text = when(type) {
                Type.BIG -> "大杯"
                Type.MIDDLE -> "中杯"
                Type.SMALL -> "小杯"
            }
            Text(text = text, fontSize = 16.sp, color = Color.White)
        }
        Spacer(modifier = Modifier.height(16.dp))
        Column {
            MyButton("大杯") {
                onTypeChange(Type.BIG)
            }
            MyButton("中杯") {
                onTypeChange(Type.MIDDLE)
            }
            MyButton("小杯") {
                onTypeChange(Type.SMALL)
            }
        }
    }
}

@Composable
fun MyButton(text: String, onClick: () -> Unit) {
    Button(
        onClick = onClick,
        colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
        modifier = Modifier.fillMaxWidth()
    ) {
        Text(
            text = text,
            fontSize = 16.sp,
            color = Color.White,
            modifier = Modifier.fillMaxWidth()
                .wrapContentWidth(Alignment.CenterHorizontally)
        )
    }
}

enum class Type { BIG, MIDDLE, SMALL }

class MainViewModel: ViewModel() {

    var type by mutableStateOf(Type.MIDDLE)
        private set

    fun changeType(type: Type) {
        this.type = type
    }
}

class MainActivity : ComponentActivity() { 
    private val viewModel: MainViewModel by viewModels() 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState) 
        setContent {
            MainScreen(viewModel)
        }
    }
}


上面代码中,三个按钮将选择结果通过点击事件回调,回传给调用者,调用者获得选择结果后,更新状态值,这会再次将状态值反馈给组件显示结果。显示选择结果的文本组件会发生重组,这是正常的,但是底部的三个按钮它们没有读取任何状态,每次点击的时候却也发生了重组。这就是问题所在。正常情况下,应该只有展示选择结果的文本组件会发生重组,底部的三个按钮不应该发生任何重组。

这是由于调用 CoffeeSelector 的地方通过 lambda 的方式创建了匿名内部类对象(onTypeChange的调用),而在这个 lambda 中引入了 viewModel 这个局部变量,因此匿名内部类对象会持有对该局部变量的引用,而我们观察到 MainViewModel 其实是一个不稳定的类型。(因为MainViewModel中的type成员有可能会随着时间的推移而发生变化,Compose runtime 并不相信它会总是保持一致不变)

所以 CoffeeSelector 实际上是读取了一个包含不稳定的 viewModel 对象的匿名内部类对象,该对象被编译器推断为不稳定,CoffeeSelector 中的三个按钮实际上都读取了该匿名内部类对象,因此每次它们都会发生重组。

那么该如何避免这种多余的重组发生呢?首先我们可以直接在调用处避免直接使用 lambda 的形式调用回调,而是改用函数引用进行传递:

@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) { 
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CoffeeSelector(
            type = viewModel.type,
            onTypeChange = viewModel::changeType
        )
    }
}

现在运行之后就会发现,点击按钮时,三个按钮不会再发生重组了,而只要上面展示结果的文本组件才会重组:


假如 lambda 回调的参数和要调用的 viewModel 的方法参数不匹配,比如个数不一样,那么就不能直接引用 viewModel 的方法,此时可以采用另一种办法,通过 remember 来创建一个 函数类型的 lambda 变量,同样可以达到目的,虽然它的可读性不是很好。

@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) { 
    val changeTypeLambda = remember<(Type) -> Unit> {
        {
            viewModel.changeType(it)
        }
    }
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CoffeeSelector(
            type = viewModel.type,
            onTypeChange = changeTypeLambda
        )
    }
}

最后还有一种迂回战术,即不在 lambda 中引入不稳定的类型,或者说引入被Compose认可的类型,例如 mutableStateOf() :

@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) { 
    var type by remember { mutableStateOf(Type.MIDDLE) }
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CoffeeSelector(
            type = type, 
            onTypeChange = {
                  type = it
            }
        )
    }
}

这里 type 的类型是枚举类型,它是稳定的类型(因为它不可修改),虽然 type 本身是 var 可变的,但是同时 type 是通过 mutableStateOf() 创建,这意味着,一旦它发生变化,它就会通知组合。因此这种方式是官方的标准用法,也是我们日常开发最常见用法。这种方式也能避免上面例子中的三个按钮的无必要的重组。

/   警惕log日志带来的重组问题   /

如果你在平时开发当中比较喜欢在代码中添加大量的 log 日志,那么可能需要注意了,这里也许有人会好奇:难到我只是打印个 log 也会影响重组吗??是的,答案是有可能会,请看下面代码:

@Composable
fun ScrollingList() {
    var listState = rememberLazyListState();
    LazyRow {...}
    // Here!
    Log.d(TAG, "List recompose ${listState.firstVisibleItemIndex}")
    MyComposable(offset = listState.firstVisibleItemIndex)
}
@Composable
fun MyComposable(offset : Int) {...}

通常我们为了定位问题,往往会有意无意的添加那么一两句 log,其实这里有一个隐藏的陷阱就是 Log 方法中的 String 字符串本身会读取 Compose 的 State,因此当 State 改变时,这句 log 自身就会导致重组发生。

如果你是从本文开头一直读到这里,那么你可能会记得前面我在尝试解释“重组作用域内不读取任何参数的组件不会被重组”这部分中提到过的例子,Button{}中只是添加了一个 println(myVal) 读取外部的变量,然而与这个 println 同级的不读取任何状态的Text组件也会在myVal变化时发生重组。(忘记的话可以往上翻一下)其实跟这里道理是一样的。

那么,这个该如何解决呢?难到你还不让我加 log 打日志了吗,这么变态?别慌,使用 Compose 为我们提供的副作用 Api 即可。

@Composable
fun ScrollingList() {
    var listState = rememberLazyListState();
    LazyRow {...}
    // 使用SideEffect处理副作用
    SideEffect {
        Log.d(TAG, "List recompose ${listState.firstVisibleItemIndex}")
    }
    MyComposable(offset = listState.firstVisibleItemIndex)
}
@Composable
fun MyComposable(offset : Int) {...}

如果觉得每次这样写有点麻烦,可以搞个工具类(如果是旧项目改造,那么旧项目中一定有类似的日志工具类拿来改造一下即可),例如写一个扩展函数:

@Composable
fun String.Log(block: () -> String) {
    if (BuildConfig.DEBUG) return
    SideEffect {
        Log.d(this, block())
    }
}

这样使用就简单多了:

@Composable
fun ScrollingList() {
    var listState = rememberLazyListState();
    LazyRow {...} 
    TAG.Log { "List recompose ${listState.firstVisibleItemIndex}" }
    MyComposable(offset = listState.firstVisibleItemIndex)


最后,并不是所有的日志都会带来重组问题,比如只是打印一些字符串常量并不会影响重组,我们只需要留心那些可能会发生状态频繁变化的地方是否有日志读取了状态即可。

/   开启Compose Compiler编译报告   /

前面在 @Stable 和 @Immutable 部分探讨了一些关于类的稳定性的话题,前面提到 Compose Compiler会自动推断类的稳定性,作为开发者,如果我们想针对这一方面做一些优化,但是我们的项目中可能存在着成千上万的 Composable 函数,我们又不能够针对每一个 Composable 函数都去揣测 Compose 编译器会如何推断其稳定性,这样我们岂不是成了一个 “人形编译器”,要是有个程序能自动帮我们把每一个 Composable 函数的稳定性都分析好了然后告诉我们结果就好了。

幸好,这些顾虑 Compose 官方已经为我们考虑好了。我们只需要在使用 Compose Compiler 的项目中添加一些配置,就可以获得 Compose 编译器的关于项目中所有稳定性推断的结果报告,然后就可以根据这些报告进行结果分析和调整优化。

具体而言,有两种方式可以开启 Compose 编译报告,一种是通过命令行运行指定命令,你可以在 Android Studio 的 Terminal 面板中输入以下命令:

// 为构建目标启用编译器度量
.gradlew -Pandroidx.enableComposeCompilerMetrics=true :compose:runtime:runtime:compileKotlin

// 为构建目标启用编译器报告
.gradlew -Pandroidx.enableComposeCompilerReports=true :compose:runtime:runtime:compileKotlin

另一种方式是在使用 Compose 的模块的 build.gradle 中添加配置,例如:

android {
    kotlinOptions {
       jvmTarget = '1.8'
       // Enable Compose Compiler Report
       freeCompilerArgs += ["-P","plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
               project.buildDir.absolutePath + "/compose_metrics"]
       // Enable Compose Compiler Metrics
       freeCompilerArgs += ["-P","plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
               project.buildDir.absolutePath + "/compose_metrics"]
   }
}

其中 metricsDestination=<directory> 和 reportsDestination=<directory> 后面跟的<directory>是报告输出的目录,你可以配置任意项目中的路径,这里是直接配置到构建输出的 build 目录下面。

然后我们构建项目之后,就会在 build 目录下面看到 Compose 编译器为我们输出的一些报告:


这里每个模块会有几个文件输出:

  • xxx-module-module.json:其中包含了一些整体的统计数据。
  • xxx-module-composables.txt:其中包含了每个Composable函数声明的详细输出。
  • xxx-module-composables.csv:这是对应上面txt文件的表格版本。
  • xxx-module-composables.log:其中会包含一些日志信息。
  • xxx-module-classes.txt:其中包含了每个Composable函数引用的类的稳定性的相关信息。

注意:实际项目中最好是使用release版本的构建输出来分析报告结果,以更加接近实际项目的发布版本的性能状况。

我们看一下 -module-module.json 中输出的内容:


就是一些统计数字,例如前两项 skippableComposables 和 restartableComposables 分别表示可跳过和可重启的 Composable 函数的数量为1655和1753,而总的 Composable 函数的数量 totalComposables 为1805。

再来看看 -module-composables.txt 中的内容:


这里列出了每个 Composable 函数并且在其前面加上了 restartable 和 skippable 的关键字,分别表示该 Composable 函数是否是可重启和可跳过的。

还可以通过Office或WPS查看 -module-composables.csv 文件,Excel 版本更加直观:


那根据这些报告我们应该重点关心哪些内容呢?关注那些标记为 restartable 但没有标记为 skippable 的函数。

restartable

作为一个 Composable 函数,标记为 restartable 是理所应当的,因为这是 Compose 重组的基石,也就是每个 Composable 函数都应该是可重启(或者说可重复执行)的。

当 Compose 检测到函数输入发生变化时, 它便使用新输入重新启动(重新invoke)此函数。可重启的函数标志着 Composition 的 scope范围的边界。Snapshot (比如 MutableState)被读取到的“范围”很重要,因为它定义了在 快照(snapshot) 更改时被重新运行的代码块。理想情况下,快照更改将尽可能仅触发最近的 函数/lambda 重启,使得被重新运行的代码最少化。如果宿主代码块无法重启,则 Compose 需要遍历树以找到最近的祖先可重启的“范围”。这可能意味着很多函数需要重新运行。

skippable

skippable (可跳过性) 意味着:在重新组合期间,如果 Compose 发现自上次调用以来可组合函数的参数没有更改(所有参数都相等),则 Compose 可以完全跳过此函数的调用,即所谓的 “智能重组”。可跳过性对于公开 Api 来说通常是非常重要的,如果使用相同的输入调用可组合对象的几率很高,则可跳过性会对性能产生很大的影响。一个函数不可跳过的典型原因是它的一个或多个参数类型被认为是不稳定的。

所以我们应该重点查找那些被标注为 restartable 但却没有标记为 skippable 的函数,例如,我们看到一个下面这样的函数就满足这种情况:


它的前面缺少了 skippable 的标记,仔细看它的参数中 list 参数被标记为 unstable 而其它参数都标记为 stable,这也是为什么它是不可跳过的原因,前面也提到过,因为 List 是一个抽象的接口, Compose 编译器无法判断最终用户会如何实现它(你可能传一个List但也可以是一个MutableList),因此它是 unstable 的。

一旦发现了类似这种情况的 Not skippable 的函数,有两种办法可以解决:

  1. 手动修改该函数的参数类型,确保所有参数都是 stable 的类型
  2. 使用注解 @NonRestartableComposable 将函数标注为不可重启

第一个解决方法还好理解,但是第二个解决办法是啥意思呢?这种情况意味着可组合函数不太可能是重新组合树的“根”节点,换句话说,如果可组合函数不直接读取任何状态变量,则不太可能使用这个重启作用域。但是,编译器很难确定这一点,所以编译器无论如何都会为其生成一个重启作用域,除非你直接使用 @NonRestartableComposable 注解来显示的指定。(注意,默认情况下不是所有的组合都是可重新启动的,因为内联组合或具有非Unit返回类型的组合都是不可重新启动的)

关注那些标记为 unstatble 的类

当我们找到一个未标记为 skippable 的函数时,如果其参数是 unstatble 类型的对象,但是我们查看代码时,又不能明显的找出其具体的 unstable 的原因,该怎么办呢?别忘了,我们还有一个结果报告的文件没看呢,即 -module-classes.txt ,该文件就是用来做这件事的,打开它会发现其列出了所有Composable引用的类,并且每个类的前面都会有 statble 或 unstatble 标记,同样的类中的每个属性前面也有这两个标记。

 unstable class WatchedViewState {
   unstable val user: TraktUser?
   stable val authState: TraktAuthState
   stable val isLoading: Boolean
   stable val isEmpty: Boolean
   stable val selectionOpen: Boolean
   unstable val selectedShowIds: Set<Long>
   stable val filterActive: Boolean
   stable val filter: String?
   unstable val availableSorts: List<SortOption>
   stable val sort: SortOption
   unstable val message: UiMessage?
   <runtime stability> = Unstable
 }

我们需要重点关注的就是标记为 unstatble 的类的 unstatble 的属性成员,例如下面标注的就是符合这个情况:


编译器可能使用一些简单的规则来判断一个类的成员字段是否稳定,例如:

  1. 公开的字段是可变的 (例如使用var修饰的属性)
  2. 公开的字段是另一个不稳定的类型

如果类中的任何一个字段满足这个条件,它将被推断为不稳定的,但这并不意味着它不能被标记为稳定的。有时可变字段的使用方式在稳定性保证的上下文中仍然是安全的。例如,一个非常常见的情况是使用一个字段来“缓存”一些计算的结果。如果缓存只是出于性能原因,并且该类的公共API使得不可能知道值是否被“缓存”,那么该类仍然可以标记为稳定(你可以使用前面提到的@Stable 和 @Immutable 注解)。

因此,为了找到导致类不稳定的字段,您只需要在 -module-classes.txt 文件中寻找那些 var 修饰的或者 unstable val 修饰的任何字段即可。

/   测试和开启性能报告   /

关于 Jetpack Compose 的性能测试,可以参考官方的使用 Macrobenchmark 检查应用性能的Codelab,其中列出了使用 Macrobenchmark 库对应用进行性能测试的详细步骤。这里有一点需要注意的是 benchmark 测试应当是基于真实设备且运行 release 版本的测试(但是你可以使用debug签名),以达到更加接近实际的测试结果。

运行 benchmark 测试后,可以通过 logcat 观察两个重要的指标:TTID (timeToInitialDisplay) 和 TTFD (timeToFullDisplay ),分别表示第一帧准备好和内容完全绘制完所需的时间。


这里表示启动时间的最小值为 294.8 毫秒,中位数为 301.5 毫秒,最大值为 314.8 毫秒。

TTID 指标跟应用的启动类型密切相关,在Android中,应用启动类型主要分为三种:热启动、温启动和冷启动。它们分别对应的生命周期方法如下图所示:


在冷启动开始时,系统有三个任务,分别是:

  1. 加载并启动应用。
  2. 在启动后立即显示应用的空白启动窗口。
  3. 创建应用进程。

系统一旦创建了应用进程,应用进程就负责后续阶段:

  1. 创建 Application 对象。
  2. 启动主线程消息循环。
  3. 创建主 Activity。
  4. 填充视图内容。
  5. 进行View布局。
  6. 执行初始绘制。

一旦应用进程完成第一次绘制,系统进程就会换掉当前显示的后台窗口,替换为主 activity。此时,用户可以开始使用应用。对于启动性能而言,最容易出现问题的阶段是在创建 Application 和创建主 Activity的过程中。


TTID 发生在 Activity#onStart 阶段,在应用首次冷启动时,它就是从 Application#onCreate 到第一帧开始绘制时的耗时,而在热启动的情况下,它的耗时比冷启动要少很多。注意 TTFD 是内容完全绘制完毕的时间,此时用户真正的可以与应用进行交互操作,注意它跟 TTID 的区别。


通过 Macrobenchmark 可以自动衡量应用呈现第一帧所需的时间TTID 。不过,直到第一帧呈现之后,应用内容仍未完成加载的情况也很常见,而您可能想要了解用户需要等待多长时间才能使用应用。此等待时间称为完全显示所用时间 TTFD,即应用内容已全部加载,且用户可以与应用互动所需的时间。你可以使用 Activity.reportFullyDrawn() 函数来主动报告应用真正完全显示的时间,以便向 Macrobenchmark 库传达更精准的时间。

如果是在 Composable 中,可以调用 ReportDrawnWhen 和 ReportDrawnAfter来报告,但这两个 Api 是从 activity-compose 1.7 开始支持的,需要添加对应的依赖。




推荐阅读:
我的新书,《第一行代码 第3版》已出版!
原创:写给初学者的Jetpack Compose教程,基础控件和布局
使用 AndroidX 增强 WebView 的能力

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存