Google I/O : Jetpack Compose 中常见的性能问题

前言

Jetpack Compose的应用也逐渐广泛起来,由于声明式UI的特点,Compose在开发的易用性方面有较大优势,但相信很多人对于Compose的性能问题有一些疑问。
这些问题有些是因为Compose还是个新生事物,不够成熟导致的,有些则是因为开发者的使用不当导致的。
本文主要介绍如何编写和配置应用程序以获得最佳性能,并指出了一些要避免的问题。

正确配置应用

如果您的Compose应用性能不佳,则可能意味着存在配置问题。 首先应该检查以下配置项

使用Release模式构建并且使用R8

如果您发现性能问题,请确保尝试在Release模式下运行您的应用。Debug模式对于发现许多问题很有用,但它会带来显着的性能成本,并且很难发现可能影响性能的其他代码的问题。
同时您还应该使用 R8 编译器从您的应用程序中删除不必要的代码。 默认情况下,在Release模式下构建会自动使用 R8 编译器。

使用baseline profile

Compose 作为一个单独的库分发,而不是作为 Android 平台的一部分。这种方法让我们可以经常更新 Compose 并支持较旧的 Android 版本。但是,将 Compose 作为库分发也会产生一定的成本。 Android 平台代码已编译并安装在设备上。另一方面,库需要在应用程序启动时加载,并在需要功能时及时解释(即JIT)。这可能会在启动时减慢应用程序的速度,并且每当它首次使用库功能时。

您可以通过定义baseline profile来提高性能。这些配置文件定义了用户主流程所需的类和方法,并与您应用的 APK 一起分发。在应用程序安装期间,ART 会提前编译该关键代码(即AOT),以便在应用程序启动时准备好使用。

定义一个好的baseline profile并不总是那么容易,因此 Compose 默认附带一个。因此默认情况下你不需要做任何额外工作。
同时,如果您选择定义自己的配置文件,您可能会生成一个实际上不会提高应用程序性能的配置文件。您应该测试配置文件以验证它是否有帮助。一个很好的方法是为您的应用程序编写 Macrobenchmark测试,并在您编写和修改baseline profile时检查测试结果。有关如何为 Compose UI 编写 Macrobenchmark 测试的示例,请参阅 Macrobenchmark Compose示例。

总得来说,使用baseline profile即通过AOT取代JIT,加快Compose首次运行的速度。
在默认情况下Compose已经自带了一个默认的baseline profile,你不需要做什么额外工作就可以支持。
但如果你要自定义baseline profile的话,需要做好测试用例,验证自定义的配置是否有效。

自定义baseline profile比较麻烦,不过根据Google I/O上给出的数据,可以达到20%到30%的启动性能提升,大家可以根据情况决定是否使用

关于Compose的一些最佳实践

在编写Compose代码时你可能会碰到一些常见的错误。这些错误不影响运行,但会损害您的 UI 性能。本节列出了一些最佳实践来帮助您避免它们。

使用remember减少计算

Compose函数可以非常频繁地运行,就像动画的每一帧一样频繁。 出于这个原因,你应该尽可能少地在Compose中做计算。
最常见的就是使用remember, 这样,计算只运行一次,并且可以在需要时获取结果。
例如,这里有一些代码显示排序的名称列表,其中排序操作比较耗时

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

问题在于,每次重组 ContactsList 时,整个联系人列表都会重新排序,即使列表没有更改。 如果用户滚动列表,只要出现新行,Composable 就会重新组合。
为了解决这个问题,在LazyColumn 之外对列表进行排序,并使用 remember 存储排序后的列表:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
          // ...
        }
    }
}

如上,当第一次组成 ContactList 时,列表被排序一次。 如果联系人或比较器更改,则重新生成排序列表。 否则,可组合项可以继续使用缓存的排序列表。
当然:如果可能,最好将计算完全移到Compose之外,比如ViewModel

Lazy Layout使用Key

Lazy Layout使用智能重组,仅在必要时才会发生重组。 同时,我们可以帮助它做出最佳决策。
假设用户操作导致item在列表中移动。 例如,假设您显示按修改时间排序的笔记列表,最近修改的笔记在最上面。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

不过,这段代码存在一定的问题。 假设底部的note发生了变化。 它现在是最近修改的note,所以它应该排在列表的顶部,而其他每个note都向下移动一个位置。

这里的问题是,没有您的帮助,Compose 不会意识到未更改的项目只是在列表中移动。 相反,Compose 认为旧的“第 2 项”被删除并创建了一个新的,依此类推,第 3 项、第 4 项一直如此。 结果是,Compose 会重新组合列表中的每一项,即使其中只有一项实际发生了变化。

解决方案是提供item key。 为每个item提供一个稳定的key可以让 Compose 避免不必要的重组。 在这种情况下,Compose 可以看到现在位于第 3 项和item过去位于第 2 的item相同。由于该项目的数据都没有更改,因此 Compose 不必重新组合它。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

使用 derivedStateOf 限制重组

在重组中使用状态的一个风险是,如果状态快速变化,你的 UI 可能会比你预期的发生更多的重组。
例如,假设您正在显示一个可滚动的列表。 您检查列表的状态以查看哪个item是列表中的第一个可见item

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

这里的问题在于,如果用户滚动列表,listState 会随着用户拖动手指而不断变化。 这意味着该列表不断被重新组合,而showButton的结果也会被不断计算。
但是,您只有在firstVisibleItemIndex发生变化时才需要计算showButton。 所以,这里多了很多额外的计算,会影响您的UI性能

解决方案是使用derivedStateOfderivedStateOf告诉Compose只有当我们关心的状态发生变化时,才需要重组。
在这种情况下,当firstVisibleItemIndex发生变化时才需要重组。但如果用户还没有滚动到足以将新item带到顶部的程度,则不需要重新组合。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

注意,如果把状态都放在ViewModel里,也就不用考虑这个了

尽可能延迟读取State

您应该尽可能推迟读取State。 延迟读取State有助于确保 Compose 在重组时重新运行尽可能少的代码。 例如,如果您的 UI 具有在composable树中高高提升的状态,并且您在子composable中读取状态,则可以将读取的状态包装在 lambda 函数中。 这样做会使读取仅在实际需要时发生。

我们来看一段Jetsnack在列表滚动时实现Title折叠展开的代码,为了达到这个效果,Title composable需要知道滚动偏移量,以便使用修饰符来偏移自己。 在进行优化之前,这是Jetsnack代码的简化版本:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

当滚动状态发生变化时,Compose 会寻找最近的父重组作用域并使其无效。 在这种情况下,最近的父级是可组合的是Box。 因此 Compose 重组了 Box,并且还重组了 Box 内的任何可组合项。 如果您将代码重构为仅读取您需要的State,那么您可以减少需要重组的元素数量。

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    // ...
    }
}

滚动参数现在是一个 lambda。 这意味着 Title 仍然可以引用被提升的状态,但该值只能在 Title 内部读取,也就是实际需要的地方。 这样一来,当滚动值发生变化时,最近的重组范围现在是 Title composable,因此Compose 不再需要重组整个 Box

这是一个很好的改进,但我们还可以做得更好。 因为我们所做的只是更改可组合标题的偏移量,这可以在布局阶段完成,而不必经过组合阶段。

Compose的阶段

与大多数其他界面工具包一样,Compose 会通过几个不同的“阶段”来渲染帧。如果我们观察一下 Android View 系统,就会发现它有 3 个主要阶段:测量、布局和绘制。Compose 和它非常相似,但开头多了一个叫做“组合”的重要阶段。

Compose 有 3 个主要阶段:

  • 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
  • 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
  • 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。

优化状态读取

知道了Compose的3个阶段,Compose 会执行局部状态读取跟踪,因此我们可以在适当阶段读取每个状态,从而尽可能降低需要执行的工作量
如果我们在布局阶段读取状态,就可以跳过组合阶段,如果我们在绘制阶段读取状态,就可以跳过组合和布局

因此我们可以将offset的读取推迟到布局阶段,这样可以避免组合阶段重新执行

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(y = scrollProvider()) }
    ) {
      // ...
    }
}

之前代码使用了Modifier.offset(x: Dp, y: Dp),它以偏移量为参数。 通过切换到修饰符的 lambda 版本,您可以确保函数在布局阶段读取滚动状态。 因此,当滚动状态发生变化时,Compose 可以完全跳过组合阶段,直接进入布局阶段。 当您将频繁更改的状态变量传递给modifier时,应尽可能使用modifierlambda 版本。

绘制阶段读取状态的一个例子

上面我们看了一个在布局阶段读取状态的例子,下面来看一下绘制阶段读取状态的一个例子

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

这里盒子的背景颜色在两种颜色之间快速切换。 因此,这种状态变化非常频繁。 然后可组合项在背景modifier中读取此状态。 结果,盒子必须在每一帧上重新组合,因为每一帧的颜色都在变化。

为了改善这一点,我们可以使用基于 lambdamodifier:在本例中为 drawBehind。这意味着仅在绘制阶段读取颜色状态。
因此,Compose 可以完全跳过组合和布局阶段,当颜色发生变化时,Compose 会直接进入绘制阶段。

避免反向写入

Compose 有一个核心假设,即您永远不会写入已读取的状态。 当你这样做时,它被称为反向写入,它会导致重组在每一帧上发生,无休止。
以下代码显示了此类错误的示例。

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read
}

这段代码在读取了状态之后在可组合项末尾的更新了状态。 如果您运行此代码,您会看到在单击导致重组的按钮后,随着 Compose 重组此 Composable,计数器会在无限循环中迅速增加,看到读取的状态已过期,因此会安排另一个重组 .

您可以通过从不在 Composition 中写入状态来完全避免向后写入。 如果可能,请始终响应事件来更新状态,并使用 lambda 表达式,就像前面的 onClick 示例一样。

总结

本文主要介绍如何编写和配置Compose以获得最佳性能,并指出了一些要避免的问题。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容