Jetpack Compose 1.5 上新:性能升级,内存优化!

6,098 阅读12分钟

昨天,在 KUG 群看到了江佬分享 Compose 的新版本,这次的亮点在于性能上的升级。Compose 的大版本更新我都有发文章,那么这次自然也不落下。一起来看看新版本有些啥吧

前几篇:
1.3.0:Jetpack Compose 上新:瀑布流布局、下拉加载、DrawScope.drawText
1.4.0:Jetpack Compose 上新:Pager、跑马灯、FlowLayout

官方文档

以下内容翻译自 android-developers.googleblog.com/2023/08/wha…

今天(2023-08-09),作为 Compose 2023 年 8 月版本材料清单(BOM) 的一部分,我们发布了 Jetpack Compose 版本 1.5,这是安卓现代的本地 UI 工具包,被许多应用程序(例如 Play 商店DropboxAirbnb)所使用。此版本主要专注于性能改进,因为我们在 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 也已经稳定下来,包括:

核心功能的改进和修复

我们还在核心 API 中添加了新功能和改进,同时稳定了一些 API:

  • LazyStaggeredGrid 现在已经稳定。
  • 添加了 asComposePaint API,用于替换 toComposePaint,返回的对象包装了原始的 android.graphics.Paint。
  • 添加了 IntermediateMeasurePolicy,以支持 SubcomposeLayout 中的Lookahead 测量。
  • 添加了 onInterceptKeyBeforeSoftKeyboard Modifer ,以在软键盘出现之前拦截键盘事件。

开始吧!

我们对所有提交到我们的 问题追踪器 的错误报告和功能请求表示感谢 — 它们帮助我们改进 Compose 并构建您所需的 API。请继续提供您的反馈,帮助我们使 Compose 变得更好!

想知道接下来会发生什么?请查看我们的路线图,了解我们目前正在思考和努力开发的功能。我们迫不及待地想看到您接下来会构建什么!

Happy composing!

看看代码

我们可以挑一些变化,看看代码层面到底干了什么

Clickable 迁移到新的 Modifier API

android.googlesource.com/platform/fr…

 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

image-1.png

由于高级别 Modifier 实际依赖于单个或多个低级别 Modifier,而且有些 Modifier 还会持有状态,在旧的实现中,通过 Modifier.materialize 方法展开后,上面的 Composable 会被展开成下面这样的结构

image-2.png

这还不是全部,只是再展开屏幕放不下了 😂

而在新的实现中,通过 Modifier.Node 结构,每一个 Modifier 会被对应成一个 Node (也就是通过 ModifierNodeElement::create 创建,ModifierNodeElement::update 更新)。从结构上,它就能被缩减为

image-3.png

新版下 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.ObjectRefFunction0

  1. Ref.ObjectRef 是局部函数 bar 的闭包,因为 bar 里面用到了 invalidated,所以 invalidated 会被编译成一个 Ref.ObjectRef,而 Ref.ObjectRef 会被传入 bar 中,这样 bar 就能修改 foo 中的 invalidated 了。
  2. 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 的最新稳定版)。例如,其中的 “致谢” 就是分页动态加载的长列表,滚动起来非常丝滑。