跳到主内容
@wquguru
精选90RevenueCat 博客(RSS)产品与增长

Jetpack Compose Paywall性能优化:减少无效派生状态读取

Recalculated, not recomposed: what does one tap cost in a Compose paywall?

原文
发到 X
推荐理由

为独立开发者和移动端团队提供了具体的 Compose 性能调优方案,包含可复现的测试方法和明确的代码级改进点,直接解决用户交互卡顿痛点。

We received an issue report about an Android paywall for RevenueCat Android SDK, with a screen recording attached: a bottom sheet with two subscription plans over a blurred background. The user taps the second plan, the ripple starts, and the screen pauses for about 0.4 seconds before the new plan is selected. The paywall was built with RevenueCat Paywalls, a server driven UI that the Android SDK renders with Jetpack Compose. So what does one tap ask a Compose tree to do, and how can that take longer than twenty frames?

我们收到了一份关于 RevenueCat Android SDK 的 Android 付费墙的 issue 报告,附带了一段屏幕录制视频:背景模糊,底部弹出一个包含两个订阅方案的 bottom sheet。用户点击第二个方案时,涟漪效果开始播放,随后屏幕暂停约 0.4 秒后才选中新方案。该付费墙基于 RevenueCat Paywalls 构建,这是一种服务端驱动的 UI,由 Android SDK 使用 Jetpack Compose 渲染。那么,一次点击会要求 Compose 树执行什么操作,为何耗时可能超过二十帧?

In this article, you'll start from that recording and trace the cost of one package tap inward: the override model behind a server driven paywall, an instrumented test and a set of kill switches for measuring a tap, what derivedStateOf skips and what it doesn't, lazy condition inputs, a regex cache, fonts without equals, work moved into the draw phase, an invisible spinner, and the limits of reordering code inside one main thread task.

在本文中,你将从这段录屏出发,向内追踪一次 package 点击的成本:服务端驱动付费墙背后的覆盖模型、用于测量点击的仪器化测试及一系列开关、derivedStateOf 跳过与未跳过的内容、惰性条件输入、正则表达式缓存、缺少 equals 方法的字体、移入绘制阶段的工作、不可见的加载指示器,以及单个主线程任务内代码重排的限制。

The fundamental problem: One selection, read by every component

根本问题:每个组件都读取一次选择状态

A RevenueCat paywall is designed in the dashboard and arrives in the app as a tree of components: stacks, texts, images, packages, buttons. Any component can carry overrides, which are partial property sets applied when a condition holds. If you've used a <selector> drawable with state_selected, it's the same idea, applied to any property of any component. The condition can be "the package this component belongs to is selected", "the user is eligible for an intro offer", "the selected package, wherever it is, is the annual one", or a window size. A package card that gets a thicker, colored border when it's selected looks like this in the SDK's model:

RevenueCat 付费墙在仪表盘中设计完成,以组件树的形式进入应用:包括栈布局、文本、图片、package 和按钮。任何组件都可以携带覆盖配置(overrides),即在满足特定条件时应用的局部属性集。如果你曾使用过带有 state_selected 的 <selector> drawable,其理念相同,只是应用于任何组件的任何属性。条件可以是“该组件所属的 package 已被选中”、“用户符合入门优惠资格”、“无论位置如何,选中的 package 是年度套餐”,或是窗口尺寸。当被选中时获得更粗彩色边框的 package 卡片,在 SDK 模型中如下所示:

代码 · 14 行
PackageComponent(
    packageId = "\$rc_annual",
    isSelectedByDefault = true,
    stack = StackComponent(
        components = listOf(planName, planPrice),
        border = Border(color = gray, width = 1.0),
        overrides = listOf(
            ComponentOverride(
                conditions = listOf(ComponentOverride.Condition.Selected),
                properties = PartialStackComponent(border = Border(color = accent, width = 2.5)),
            ),
        ),
    ),
)

Resolving overrides means checking each override's conditions against the current state and merging the matching ones over the base style. The result is the set of properties the component draws with. Every component has a state holder that does this inside a derivedStateOf, so the resolution reruns only when something it read changes. The inputs it reads, such as whether this component's package is selected or which package is selected overall, are backed by Compose state, and a derived state records every state object it reads as a dependency. Before October 2026, StackComponentState resolved its overrides like this, and every other component state did the same:

解析覆盖配置意味着将每个覆盖条件的当前状态进行比对,并将匹配项合并到基础样式之上。结果即为组件绘制所使用的属性集。每个组件都有一个状态持有者,它在 derivedStateOf 内部执行此解析操作,因此仅当其读取的内容发生变化时才会重新运行。它读取的输入项,例如本组件的 package 是否被选中或全局哪个 package 被选中,均由 Compose 状态支持,派生状态会将读取到的每个状态对象记录为依赖项。2026 年 10 月之前,StackComponentState 按以下方式解析其覆盖配置,其他所有组件状态也是如此:

代码 · 16 行
private val presentedPartial by derivedStateOf {
    val windowCondition = ScreenCondition.from(windowSize)
    val componentState =
        if (packageAwareDelegate.isSelected) ComponentViewState.SELECTED else ComponentViewState.DEFAULT
    style.overrides.buildPresentedPartial(
        windowCondition,
        packageAwareDelegate.offerEligibility,
        componentState,
        conditionContext = ConditionContext(
            selectedPackageId = selectedPackageInfoProvider()?.rcPackage?.identifier,
            customVariables = customVariablesProvider(),
            stateReader = stateStoreProvider()::currentValueOrDefault,
            windowDpSize = windowDpSize,
        ),
    )
}

Notice the order: isSelected, offerEligibility, and the selected package's identifier are all read before buildPresentedPartial looks at a single condition. A component with no overrides at all still read the selection. There's a difference between "the selection changed" and "this component's inputs changed". Every tap is the first, and it should be the second only for components whose overrides mention the selection. The old code made it the second for every component.

注意执行顺序:在 buildPresentedPartial 检查单个条件之前,会依次读取 isSelected、offerEligibility 以及所选套餐的标识符。即使组件没有任何覆盖逻辑(overrides),它仍然会读取选择状态。“选择发生变化”与“该组件的输入发生变化”之间存在区别。每次点击都是第一次触发,但仅当组件的覆盖逻辑引用了选择状态时,才应被视为第二次触发。旧代码却让每个组件都将其视为第二次触发。

If you've written a selectable list in Compose, you've probably written the same shape yourself:

如果你用 Compose 编写过可选择的列表,你可能自己写过类似的结构:

代码 · 6 行
val badgeStyle by remember {
    derivedStateOf {
        val isSelected = selection.selectedId == plan.id
        resolveBadgeStyle(plan.badgeRules, isSelected)
    }
}

For a plan whose badge rules never look at the selection, the read is wasted, and the derived state now depends on every tap anyway. In a list of five items that's harmless. The rest of this article is about what it costs when the list is a whole screen.

对于那些徽章规则从不查看选择状态的计划而言,这种读取是多余的,而且派生状态现在无论如何都依赖于每次点击。在一个包含五个项目的列表中,这并无大碍。本文的其余部分将探讨当列表占据整个屏幕时,这种做法带来的代价。

Measuring before guessing: The recording and an instrumented test

先测量再猜测:录制过程与仪器化测试

The first theory was the background: maybe every tap re-rendered and re-blurred the paywall behind the sheet. The recording made that unlikely. Sampled at 30 frames per second, each tap showed the ripple starting, then about a dozen identical frames, then the new selection in one frame. Across this and a second recording, the pauses lasted 0.33 to 0.50 seconds. The background didn't change on the frame where the selection flipped, and in the second recording, where it animated, the animation paused during every delay. When unrelated animations pause at the same moment, the main thread is the usual suspect.

第一个假设是背景问题:也许每次点击都会重新渲染并重新模糊处理弹窗背后的付费墙。录制结果使这一假设变得不太可能。以每秒 30 帧采样,每次点击都显示出涟漪效果开始,接着大约十二个相同的帧,然后在一个帧中显示新的选择状态。在这段录制和另一段录制中,停顿时间持续了 0.33 到 0.50 秒。在选择状态翻转的那一帧,背景并未发生变化;而在第二段包含动画效果的录制中,动画在每次延迟期间都暂停了。当无关的动画在同一时刻暂停时,主线程通常是罪魁祸首。

A blocked main thread right after a state change points at composition, layout, or something they call. To find out which, we wrote an instrumented test that renders a paywall with the real SDK on an Android runtime, changes the selected package on the UI thread, and waits for Compose to go idle:

状态改变后立即阻塞的主线程指向了组合(composition)、布局或它们调用的某些内容。为了确定具体是哪一部分,我们编写了一个仪器化测试,它在 Android 运行时上使用真实 SDK 渲染付费墙,在主线程上更改所选套餐,并等待 Compose 进入空闲状态:

代码 · 7 行
repeat(ITERATIONS) { i ->
    val target = if (i % 2 == 0) idA else idB
    val started = System.nanoTime()
    composeTestRule.runOnUiThread { state.update(target) }
    composeTestRule.waitForIdle()
    samples[i] = (System.nanoTime() - started) / 1_000_000.0
}

Each run does 6 warmup selections and 20 measured ones and reports the median, and every timing from this test below is the median of at least three such runs. The paywall is synthetic: N plain texts with no overrides, plus two packages, and no blur at all. The cost reproduced anyway, so it doesn't need the blur. Changing N answers the first question to ask about any slowdown: does the cost grow with the number of components, or is it a constant paid once per tap?

每次运行包括 6 次预热选择和 20 次正式测量,并报告中位数;下文所有来自该测试的时间数据均为至少三次此类运行的中位数。该付费墙是合成的:由 N 个纯文本组成,无任何覆盖逻辑,加上两个套餐,且完全没有模糊效果。即便如此,成本依然存在,因此无需依赖模糊效果来复现问题。改变 N 的值可以回答关于任何性能放缓的第一个问题:成本是否随组件数量增长,还是每次点击只需支付一次固定成本?

Two caveats apply to every timing in this article. The runs use an Android 15 emulator and a debug build, so the values are only meaningful as before and after comparisons on the same bench, and phones aren't necessarily faster: during the investigation, an earlier build on a different paywall took 190 ms per tap on a Galaxy S23 against 112 ms on an Android 14 emulator. And waitForIdle advances in whole frames, so the resolution is one frame (16.7 ms) and the floor is two frames, about 33 ms.

本文中的每项计时数据都需考虑两个注意事项。测试运行环境为 Android 15 模拟器及调试版本(debug build),因此这些数值仅适用于同一基准测试下的前后对比,且手机未必更快:在调查过程中,Galaxy S23 上某个较早构建版本在处理不同付费墙时,每次点击耗时 190 毫秒,而 Android 14 模拟器上仅为 112 毫秒。此外,waitForIdle 以整帧为单位推进,因此分辨率为一帧(16.7 毫秒),下限为两帧,约 33 毫秒。

To see what each fix bought on its own, the fixes that touch the tap got kill switches: flags in a measurement build that restore the old behavior at one choke point. With every flag off, the build is the current SDK, so every "before" number in this article is the current SDK with a fix switched off, not an old release. Next to the switches sit counters for the work a tap causes: override resolutions, text recompositions, font file loads, and variable passes, which count how many times a text template was processed. Here's one tap on 300 texts, with #4383, the main fix this article explains, switched off and on:

为了观察每项修复单独带来的效果,涉及点击操作的修复均设置了开关(kill switches):即在测量版本中通过标志位(flags)在某个瓶颈点恢复旧行为。当所有标志位关闭时,该构建即为当前 SDK,因此本文中的所有“之前”数值均指关闭某项修复后的当前 SDK,而非旧版发布版本。开关旁边还列出了单次点击引发的工作计数器:覆盖分辨率(override resolutions)、文本重排版(text recompositions)、字体文件加载以及变量传递次数(variable passes),后者统计文本模板被处理的次数。以下是在 300 个文本上的一次点击测试,其中 #4383(即本文解释的主要修复)分别处于关闭和开启状态:

  • #4383 switched off: 700 ms per tap, with 308 override resolutions, 0 text recompositions, and 600 variable passes.
  • Current SDK: 184 ms per tap, with 0 override resolutions, 0 text recompositions, and 600 variable passes.
  • #4383 关闭:每次点击 700 毫秒,包含 308 次覆盖分辨率、0 次文本重排版和 600 次变量传递。
  • 当前 SDK:每次点击 184 毫秒,包含 0 次覆盖分辨率、0 次文本重排版和 600 次变量传递。

The chart below repeats the measurement at 20, 100, and 300 texts:

下图重复了针对 20、100 和 300 个文本的测量结果:

更进一步:量化金融体系

看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力

进入量化体系 →

相似阅读

关联信息,但可能不是同一事件