昨天,在 KUG 群看到了江佬分享 Compose 的新版本,这次的亮点在于性能上的升级。Compose 的大版本更新我都有发文章,那么这次自然也不落下。一起来看看新版本有些啥吧
前几篇:
1.3.0:Jetpack Compose 上新:瀑布流布局、下拉加载、DrawScope.drawText
1.4.0:Jetpack Compose 上新:Pager、跑马灯、FlowLayout
官方文档
今天(2023-08-09),作为 Compose 2023 年 8 月版本材料清单(BOM) 的一部分,我们发布了 Jetpack Compose 版本 1.5,这是安卓现代的本地 UI 工具包,被许多应用程序(例如 Play 商店、Dropbox 和 Airbnb)所使用。此版本主要专注于性能改进,因为我们在 2022 年 10 月版本开始的 Modifer 重构 的主要部分现在已合并。
性能
在我们首次发布 2021 年的 Compose 1.0 时,我们专注于确保 API 接口设计正确,为构建应用提供牢固的基础。我们希望有一个功能强大且表达能力强的 API,易于使用且稳定,以便开发人员可以自信地在生产中使用它。随着我们不断改进 API,性能成为了我们的首要任务,在 2023 年 8 月版本中,我们已经实现了许多性能改进。
Modifer 的性能
在此版本中, Modifer 在 Composition 时间上看到了大幅的性能改进,Composition 时间提升高达 80%。最棒的是,由于我们在第一个版本中确保了正确的 API 接口设计,大多数应用只需升级到 BOM 2023.08.00 版本,即可从中受益。
我们有一套用于监控性能回归并指导我们改进性能的基准测试。在 Compose 初始的 1.0 版本发布后,我们开始关注可以进行改进的地方。基准测试显示,我们花费了比预期更多的时间用于实例化 Modifer 。 Modifer 占据了 Composition Tree 的绝大部分,因此占据了 Compose 首次组合时间的最大一块儿。在 2022 年 10 月发布的版本中,我们对 Modifer 进行了更高效的设计重构,该版本包含了新的 API 和性能改进,它位于我们的最底层模块 Compose UI 中。
高级的 Modifer 依赖于更低级的 Modifier,所以我们开始在 Compose Foundation 将低级 Modifer 迁移到下一个版本,即 2023 年 3 月版本。这包括 graphicsLayer、低级焦点 Modifer 、Padding 和 Offset。这些低级 Modifer 被其他广泛使用的 Modifer (例如 Clickable)调用,并且还被许多基础 Composable(例如 Text)使用。在 2023 年 3 月版本中迁移 Modifer 为这些组件带来了性能改进,但真正的收益将在将更高级别的 Modifer 和 Composable 迁移到新的 Modifer 系统时产生。
在 2023 年 8 月版本中,我们已经开始 迁移 Clickable Modifer 到新的 Modifer 系统中,这在某些情况下使 Composition 显著加快,高达 80%。这在包含可点击元素(如按钮)的 LazyColumn 中尤其重要。被 Clickable 使用的 Modifier.indication 仍在迁移过程中,因此我们预计在未来的版本中会有进一步的收益。
作为这项工作的一部分,我们发现了在最初的重构中未涵盖的组合 Modifer 用例,并添加了一个新的 API,用于创建消耗 CompositionLocal 实例的 Modifier.Node 元素。
我们正在撰写文档,指导您如何将您自己的 Modifer 迁移到新的 Modifier.Node API。要立即开始,请参考我们仓库中的示例。
您可以在 Android Dev Summit '22 的 Compose Modifer 深入探讨 中了解更多关于这些变化背后的原因。
内存占用
此版本包含了许多在内存使用方面的改进。我们仔细检查了在不同的 Compose API 中发生的分配,并在许多方面,特别是在图形堆栈和矢量资源加载方面,减少了总的分配。这不仅减少了 Compose 的内存占用,还直接提高了性能,因为我们花费更少的时间分配内存并减少了垃圾回收。
此外,我们修复了在使用 ComposeView 时的 内存泄漏,这将使所有应用受益,特别是那些使用多 Activity 架构或大量的 View/Compose 互操作的应用。
文本
BasicText 已经迁移到了一个由 Modifer 支持的新渲染系统,这给初始组合时间带来了平均 22% 的收益,而在涉及文本的复杂布局的一个基准测试中,收益高达 70%。
一些文本 API 也已经稳定下来,包括:
- TextMeasurer and 相关 APIs
- LineHeightStyle.Alignment(topRatio)
- Brush
- DrawStyle
- TextMotion
- DrawScope.drawText
- Paragraph.paint (brush, drawStyle, blendMode)
- MultiParagraph.paint (brush, drawStyle, blendMode)
- PlatformTextInput
核心功能的改进和修复
我们还在核心 API 中添加了新功能和改进,同时稳定了一些 API:
- LazyStaggeredGrid 现在已经稳定。
- 添加了 asComposePaint API,用于替换 toComposePaint,返回的对象包装了原始的 android.graphics.Paint。
- 添加了 IntermediateMeasurePolicy,以支持 SubcomposeLayout 中的Lookahead 测量。
- 添加了 onInterceptKeyBeforeSoftKeyboard Modifer ,以在软键盘出现之前拦截键盘事件。
开始吧!
我们对所有提交到我们的 问题追踪器 的错误报告和功能请求表示感谢 — 它们帮助我们改进 Compose 并构建您所需的 API。请继续提供您的反馈,帮助我们使 Compose 变得更好!
想知道接下来会发生什么?请查看我们的路线图,了解我们目前正在思考和努力开发的功能。我们迫不及待地想看到您接下来会构建什么!
Happy composing!
看看代码
我们可以挑一些变化,看看代码层面到底干了什么
Clickable 迁移到新的 Modifier API
fun Modifer.clickable(
// ...
onClick: () -> Unit
) = composed(
factory = {
- val onClickState = rememberUpdatedState(onClick)
- val onLongClickState = rememberUpdatedState(onLongClick)
- val onDoubleClickState = rememberUpdatedState(onDoubleClick)
val hasLongClick = onLongClick != null
- val hasDoubleClick = onDoubleClick != null
val pressInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) }
val currentKeyPressInteractions = remember { mutableMapOf<Key, PressInteraction.Press>() }
if (enabled) {
@@ -314,48 +304,27 @@
}
}
}
- val delayPressInteraction = remember { mutableStateOf({ true }) }
+ val centreOffset = remember { mutableStateOf(Offset.Zero) }
val interactionModifier = if (enabled) {
ClickableInteractionElement(
interactionSource,
pressInteraction,
- currentKeyPressInteractions,
- delayPressInteraction
+ currentKeyPressInteractions
)
} else Modifier
- val centreOffset = remember { mutableStateOf(Offset.Zero) }
+ val pointerInputModifier = CombinedClickablePointerInputElement(
+ enabled,
+ interactionSource,
+ onClick,
+ centreOffset,
+ pressInteraction,
+ onLongClick,
+ onDoubleClick
+ )
- val gesture =
- Modifier.pointerInput(interactionSource, hasLongClick, hasDoubleClick, enabled) {
- centreOffset.value = size.center.toOffset()
- detectTapGestures(
- onDoubleTap = /**/,
- onLongPress = /**/,
- onPress = /**/,
- onTap = /**/
- )
- }
Modifier
.genericClickableWithoutGesture(
- gestureModifiers = gesture,
interactionSource = interactionSource,
indication = indication,
indicationScope = rememberCoroutineScope(),
@@ -368,6 +337,7 @@
onLongClick = onLongClick,
onClick = onClick
)
相较而言,一些 State 被移除,pointerInput 从原有的 Modifier.pointerInput 改为了 CombinedClickablePointerInputElement,而这个类的实现如下:
+private class ClickablePointerInputElement(
+ private val enabled: Boolean,
+ private val interactionSource: MutableInteractionSource,
+ private val onClick: () -> Unit,
+ private val centreOffset: MutableState<Offset>,
+ private val pressInteraction: MutableState<PressInteraction.Press?>
+) : ModifierNodeElement<ClickablePointerInputNode>() {
+ override fun create(): ClickablePointerInputNode = ClickablePointerInputNode(
+ enabled,
+ interactionSource,
+ onClick,
+ centreOffset,
+ pressInteraction
+ )
+
+ override fun update(node: ClickablePointerInputNode) = node.also {
+ it.updateParameters(enabled, interactionSource, onClick)
+ }
+
+ // omit codes like equals, hashCode, toString
+}
+
+private class CombinedClickablePointerInputElement(
+ private val enabled: Boolean,
+ private val interactionSource: MutableInteractionSource,
+ private val onClick: () -> Unit,
+ private val centreOffset: MutableState<Offset>,
+ private val pressInteraction: MutableState<PressInteraction.Press?>,
+ private val onLongClick: (() -> Unit)?,
+ private val onDoubleClick: (() -> Unit)?
+) : ModifierNodeElement<CombinedClickablePointerInputNode>() {
+ override fun create(): CombinedClickablePointerInputNode = CombinedClickablePointerInputNode(
+ enabled,
+ interactionSource,
+ onClick,
+ centreOffset,
+ pressInteraction,
+ onLongClick,
+ onDoubleClick
+ )
+
+ override fun update(node: CombinedClickablePointerInputNode) = node.also {
+ it.updateParameters(enabled, interactionSource, onClick, onLongClick, onDoubleClick)
+ }
+
+ // omit codes like equals, hashCode, toString
+}
+
可以看到,原先的几个 State 被合并到了一个 CombinedClickablePointerInputElement 中,而原先的 Modifier.pointerInput 则被拆分成了两个 Modifier,一个是 CombinedClickablePointerInputElement,另一个是 ClickablePointerInputElement,这两个 Modifier 都实现了 ModifierNodeElement 接口,这个接口的作用是用来创建和更新 Modifier.Node 部分源码如下:
/**
* 一个 [Modifier.Element],用于管理特定 [Modifier.Node] 实现的实例。只有在将创建和更新该实现的 [ModifierNodeElement] 应用于布局时,才能使用给定的 [Modifier.Node] 实现。
*
* [ModifierNodeElement] 应该非常轻量级,除了保存创建和维护关联的 [Modifier.Node] 类型实例所需的信息外,几乎不做其他工作。
*
*/
abstract class ModifierNodeElement<N : Modifier.Node> : Modifier.Element, InspectableValue {
/** 省略一些 Inspect 相关的代码 */
/**
* 在第一次将 Modifier 应用于布局时将调用此函数,应构造并返回相应的 [Modifier.Node] 实例。
*/
abstract fun create(): N
/**
* 当将 Modifier 应用于输入与上次应用不同的布局时调用。此函数将以当前节点实例作为参数传入,预期该节点将被更新到最新状态。
*/
abstract fun update(node: N)
// 省略一些检查器相关的代码、hashCode、equals 等
}
如果我们观察一下它的几个实现,会发现 create
方法用于新建一个 Modifier.Node
实例,而 update
方法则用于更新这个实例。
// ClickableElement
private class ClickableElement(
private val interactionSource: MutableInteractionSource,
private val enabled: Boolean,
private val onClickLabel: String?,
private val role: Role? = null,
private val onClick: () -> Unit
) : ModifierNodeElement<ClickableNode>() {
override fun create() = ClickableNode(
interactionSource,
enabled,
onClickLabel,
role,
onClick
)
override fun update(node: ClickableNode) {
node.update(interactionSource, enabled, onClickLabel, role, onClick)
}
}
// LayoutElement
fun Modifier.layout(
measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) = this then LayoutElement(measure)
private data class LayoutElement(
val measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) : ModifierNodeElement<LayoutModifierImpl>() {
override fun create() = LayoutModifierImpl(measure)
override fun update(node: LayoutModifierImpl) {
node.measureBlock = measure
}
}
internal class LayoutModifierImpl(
var measureBlock: MeasureScope.(Measurable, Constraints) -> MeasureResult
) : LayoutModifierNode, Modifier.Node() {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
) = measureBlock(measurable, constraints)
override fun toString(): String {
return "LayoutModifierImpl(measureBlock=$measureBlock)"
}
}
而作为对比,早期的 Modifier.layout 是这样的
fun Modifier.layout(
measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) = this.then(
// 这里直接创建了一个 LayoutModifierImpl,而新版是通过 LayoutElement 来管理的
LayoutModifierImpl(
measureBlock = measure,
inspectorInfo = debugInspectorInfo {
name = "layout"
properties["measure"] = measure
}
)
)
private class LayoutModifierImpl(
val measureBlock: MeasureScope.(Measurable, Constraints) -> MeasureResult,
inspectorInfo: InspectorInfo.() -> Unit,
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
) = measureBlock(measurable, constraints)
// 省略 hashCode、equals、toString 等
}
看起来,二者的区别就是早期的 Modifier.layout 直接创建了一个 LayoutModifierImpl,而新版则是通过 LayoutElement (ModifierNodeElement) 来管理的。这个 API 自 Compose 1.3.0-beta01 引入,具体来说是 这个 Commit。而实际上,二者在工作细节上已经有了很大变化。相较于原始的版本,新的 LayoutModifierImpl 改为继承自 Modifer.Node,并且实现了 LayoutModifierNode 接口。
你可能很好奇,这样的变更到底有什么用呢?要了解这个问题,我十分推荐你去观看负责这部分更改的团队成员所做的解释:Compose Modifiers deep dive(如果你感兴趣,可以留个言,我也会把它翻译成文章)。直观点来说,对于下面这个简单的 Composable
由于高级别 Modifier 实际依赖于单个或多个低级别 Modifier,而且有些 Modifier 还会持有状态,在旧的实现中,通过 Modifier.materialize
方法展开后,上面的 Composable 会被展开成下面这样的结构
这还不是全部,只是再展开屏幕放不下了 😂
而在新的实现中,通过 Modifier.Node
结构,每一个 Modifier 会被对应成一个 Node (也就是通过 ModifierNodeElement::create
创建,ModifierNodeElement::update
更新)。从结构上,它就能被缩减为
新版下 Compose Tree 的大致模型
更多细节,可以自行参阅源码
内存占用
关于内存的优化,我们截取 compose.animation
的一些变化来看看
Removed allocations in recomposition, color animations, and AndroidComposeView (Ib2bfa)
替换局部函数
下面是 commit 的注释
- 在组合中删除了最大的分配源(365个实例,也是其他测试中最大的源)。addPendingInvalidationsLocked() 使用了一个方法局部函数,导致每次调用时都会创建一个 Ref$ObjectRef。此更改将该函数升级为一个方法,接收必要的参数,并返回以前直接分配给 addPendingInvalidationsLocked() 中的被无效变量的值。
旧的
private fun addPendingInvalidationsLocked(values: Set<Any>, forgetConditionalScopes: Boolean) {
var invalidated: HashSet<RecomposeScopeImpl>? = null
fun invalidate(value: Any) {
// 省略具体实现
}
values.fastForEach { value ->
if (value is RecomposeScopeImpl) {
value.invalidateForResult(null)
} else {
// 这里调用了局部函数
invalidate(value)
derivedStates.forEachScopeOf(value) {
invalidate(it)
}
}
}
}
新的
// 原本的局部函数被分离为了一个 private 的扩展函数
private fun HashSet<RecomposeScopeImpl>?.addPendingInvalidationsLocked(
value: Any,
forgetConditionalScopes: Boolean
): HashSet<RecomposeScopeImpl>? {
var set = this
// 省略具体实现
return set
}
private fun addPendingInvalidationsLocked(values: Set<Any>, forgetConditionalScopes: Boolean) {
var invalidated: HashSet<RecomposeScopeImpl>? = null
values.fastForEach { value ->
if (value is RecomposeScopeImpl) {
value.invalidateForResult(null)
} else {
// 这里调用了上面的函数
invalidated =
invalidated.addPendingInvalidationsLocked(value, forgetConditionalScopes)
derivedStates.forEachScopeOf(value) {
invalidated =
invalidated.addPendingInvalidationsLocked(it, forgetConditionalScopes)
}
}
}
}
从代码上无法直观看出,其实秘密藏在编译后。我们举个栗子:
// 旧的
fun foo() {
var invalidated: HashSet<Any>? = null
fun bar() {
invalidated = HashSet()
}
bar()
invalidated?.add("1")
}
// 新的
fun foo2(value: Any) {
var invalidated: HashSet<Any>? = null
invalidated = invalidated?.bar2(value)
}
private fun HashSet<Any>.bar2(value: Any): HashSet<Any> {
val set = this
set.add(value)
return set
}
看起来差不多,但是反编译后却大相径庭
public final void foo() {
final Ref.ObjectRef invalidated = new Ref.ObjectRef();
invalidated.element = null;
<undefinedtype> $fun$bar$1 = new Function0() {
// $FF: synthetic method
// $FF: bridge method
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}
public final void invoke() {
invalidated.element = new HashSet();
}
};
$fun$bar$1.invoke();
HashSet var10000 = (HashSet)invalidated.element;
if (var10000 != null) {
var10000.add("1");
}
}
public final void foo2(@NotNull Object value) {
Intrinsics.checkNotNullParameter(value, "value");
HashSet invalidated = null;
invalidated = null;
}
private final HashSet bar2(HashSet $this$bar2, Object value) {
$this$bar2.add(value);
return $this$bar2;
}
旧的实现中怎么莫名其妙多出了一个 Ref.ObjectRef
和 Function0
?
Ref.ObjectRef
是局部函数bar
的闭包,因为bar
里面用到了invalidated
,所以invalidated
会被编译成一个Ref.ObjectRef
,而Ref.ObjectRef
会被传入bar
中,这样bar
就能修改foo
中的invalidated
了。- Function0:这是一个函数类型的匿名内部类,用于封装嵌套函数 bar() 的代码。在 Java 字节码中,函数类型被表示为接口和匿名类的组合。在这里,编译器生成了一个实现了 Function0 接口的匿名内部类,该接口代表一个没有参数和返回值的函数。这个匿名内部类的 invoke() 方法中放置了 bar() 函数的代码。
这就是局部函数(可能会产生)的代价。而新的实现中,我们只需要一个 bar2
函数,就能完成同样的功能。这个小变化确实带来了内存开销的优化,如果各位老铁们有对内存开销非常敏感的场景,也可以考虑使用这种方式来替换局部函数。
"MutableList +=" -> "for { add }"
旧的
// toComplete 是一个 MutableSet<> (实际为 LinkedHashSet),而 toApply 是一个 MutableList<> (实际为 ArrayList)
// val toApply = mutableListOf<ControlledComposition>()
// val toComplete = mutableSetOf<ControlledComposition>()
toComplete += toApply
toApply.fastForEach { composition ->
composition.applyChanges()
}
新的
// We could do toComplete += toApply but doing it like below
// avoids unncessary allocations since toApply is a mutable list
// toComplete += toApply
toApply.fastForEach { composition ->
toComplete.add(composition)
}
toApply.fastForEach { composition ->
composition.applyChanges()
}
其中的 fastForEach
是一个内联函数,它的实现如下
/**
* 通过 index 来遍历 [List],并且对每一个 item 调用 [action]。
* 这不会像 [Iterable.forEach] 那样分配一个 iterator。
*/
internal inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
contract { callsInPlace(action) }
for (index in indices) {
val item = get(index)
action(item)
}
}
如上,区别就是把 += 操作改成了 for 循环。那么这个 += 操作到底做了什么呢?我们来看一下它的实现
@kotlin.internal.InlineOnly
public inline operator fun <T> MutableCollection<in T>.plusAssign(elements: Iterable<T>) {
this.addAll(elements)
}
可以看到,它实际上是调用了 MutableCollection.addAll
方法。通过 debug 发现,这个 MutableCollection 实际上是一个 LinkedHashSet
,而 addAll
方法的实现如下
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
内部实际上是通过 for 循环来遍历,然后调用 add
方法来添加元素。而查看它们反编译后的字节码,确实也是类似的情况:
// += 操作
CollectionsKt.addAll(var23, var24);
// for 循环
for(var52 = ((Collection)toApplyNew).size(); index$iv < var52; ++index$iv) {
item$iv = $this$fastForEach$iv.get(index$iv);
composition = (String)item$iv;
var33 = false;
toCompleteNew.add(composition);
}
所以我就很好奇,这样的变化实际运行起来又是怎样的呢?
借助 ChatGPT 的帮助,我写了一个简单的测试,来对比一下这两种方式的性能差异
import org.junit.Test
import kotlin.system.measureNanoTime
class MemoryAllocationTest {
@Test
fun test() {
val iterations = 1000 // Adjust the number of iterations as needed
// Test the old implementation
var oldTimeUsage = 0L
val oldMemoryUsage = measureMemoryUsage {
repeat(iterations) {
val toComplete: MutableSet<String> = mutableSetOf("A", "B", "C")
val toApply: MutableList<String> = mutableListOf("D", "E", "F")
oldTimeUsage += measureNanoTime {
toComplete += toApply
toApply.fastForEach { composition ->
composition.applyChanges()
}
}
}
}
// Test the new implementation
var newTimeUsage = 0L
val newMemoryUsage = measureMemoryUsage {
repeat(iterations) {
val toCompleteNew: MutableSet<String> = mutableSetOf("A", "B", "C")
val toApplyNew: MutableList<String> = mutableListOf("D", "E", "F")
newTimeUsage += measureNanoTime {
toApplyNew.fastForEach { composition ->
toCompleteNew.add(composition)
}
toApplyNew.fastForEach { composition ->
composition.applyChanges()
}
}
}
}
println("Old time usage: $oldTimeUsage, new time usage: $newTimeUsage, ratio: ${oldTimeUsage.toDouble() / newTimeUsage}")
println("Old memory usage: $oldMemoryUsage, new memory usage: $newMemoryUsage, ratio: ${oldMemoryUsage.toDouble() / newMemoryUsage}")
}
@Test
fun test5times(){
repeat(5) {
Runtime.getRuntime().gc()
test()
println()
}
}
private inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
for (index in indices) {
val item = get(index)
action(item)
}
}
private fun String.applyChanges() {
// Simulate applying changes to the string
}
private inline fun measureMemoryUsage(block: () -> Unit): Long {
val runtime = Runtime.getRuntime()
val before = runtime.freeMemory()
block()
val after = runtime.freeMemory()
return before - after
}
}
测试结果如下
Old time usage: 2060200, new time usage: 846300, ratio: 2.434361337587144
Old memory usage: 3921656, new memory usage: 547376, ratio: 7.164464645874134
Old time usage: 846600, new time usage: 898700, ratio: 0.9420273728719261
Old memory usage: 545328, new memory usage: 414432, ratio: 1.315844336344684
Old time usage: 595700, new time usage: 940100, ratio: 0.6336559940431868
Old memory usage: 503392, new memory usage: 589192, ratio: 0.8543768415049763
Old time usage: 640500, new time usage: 670500, ratio: 0.9552572706935123
Old memory usage: 587296, new memory usage: 463344, ratio: 1.267516143513243
Old time usage: 541900, new time usage: 772800, ratio: 0.7012163561076604
Old memory usage: 587288, new memory usage: 463368, ratio: 1.267433228017472
我在 Kotlin 1.8.10 上运行了多次,结果均有类似的情况。第一次测试,新的实现在内存和时间上都有明显的优势,但是后续的测试,内存上的优势就不明显了,时间上反而经常取得劣势。这里也请教一下各位大佬,这是什么原因呢?
结尾与实测
文章写到此已经非常长了,不知不觉花了我一天半的时间。Jetpack Compose 一直因为列表性能问题的差距而被人诟病,而如今这一点点问题也在逐步越变越好。
Jetpack Compose 构建的应用,现在用起来到底怎么样,我想只有亲身体验后才更有发言权。我自己的开源应用 译站 已经全局使用 Jetpack Compose 一年半,我也在昨天升级到了 Compose BOM 2023.08.00,感兴趣的同学可以到仓库的 release 下载体验 (官网上的版本没有更新,只有 Github 仓库的这个文件是更新到了 Compose 的最新稳定版)。例如,其中的 “致谢” 就是分页动态加载的长列表,滚动起来非常丝滑。