昨日,在 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 商店、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
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
由于高档别 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 的最新稳定版)。例如,其间的 “称谢” 便是分页动态加载的长列表,翻滚起来十分丝滑。