服务端驱动 Android 付费墙:原生 Compose 渲染与缓存机制拆解
Server-driven paywalls on Android: rendering UI your binary has never seen
The paywall is usually the highest leverage screen in a subscription app, and it is also the screen most tightly welded to your release cycle. Reordering the packages, rewriting the headline above them, or changing which one is preselected means a ticket, a build, a store review, and then waiting for users to update. Server-driven UI breaks that coupling by moving the screen's definition to a server and letting the app render whatever arrives. The moment you do that, though, you inherit a problem the compiler used to solve for you: the server can send your app a screen it has never heard of.
付费墙通常是订阅应用中杠杆率最高的界面,同时也是与发布周期紧密相连的界面。重新排列套餐、改写其上的标题,或更改预选项目,都意味着需要提交工单、构建、商店审核,然后等待用户更新。服务器驱动的UI通过将界面的定义移至服务器,并让应用渲染接收到的任何内容,打破了这种耦合。然而,一旦这样做,你就继承了一个编译器曾为你解决的问题:服务器可以向你的应用发送一个它从未见过的界面。
In this article, you'll dive deep into how the RevenueCat Android SDK renders server driven paywalls as native Compose UI, comparing to the WebView screen and what it defers, the two level offerings cache and asset pre-download, the three stage pipeline from wire format to render model, how unknown input and misconfiguration get absorbed by different mechanisms, the facts only the device can resolve, and the cost of holding every platform to the same JSON.
在本文中,你将深入了解RevenueCat Android SDK如何将服务器驱动的付费墙渲染为原生Compose UI,与WebView界面及其延迟处理进行比较,两级优惠缓存和资产预下载,从线格式到渲染模型的三阶段管道,未知输入和错误配置如何被不同机制吸收,只有设备才能解决的事实,以及将每个平台统一到同一JSON的成本。
The fundamental problem: The server can send what your binary does not know
根本问题:服务器可以发送你的二进制文件不知道的内容
There are broadly two ways to render a screen defined somewhere else. You can ship a runtime that resolves an open-ended instruction set at display time, or you can ship a renderer that understands a closed set of types fixed at compile time. A third option exists, shipping executable code from the server and running it against native widgets, but it trades the schema problem for a second runtime inside your APK and a longer conversation with store policy, so set it aside.
大致有两种方式可以渲染在别处定义的界面。你可以提供一个在显示时解析开放式指令集的运行时,或者提供一个理解编译时固定的封闭类型集的渲染器。还有第三种选择,即从服务器发送可执行代码并针对原生组件运行,但这将模式问题转化为APK内的第二个运行时以及与商店政策的更长对话,因此先将其搁置。
The first option has a ready-made implementation on every Android device. HTML is the instruction set, the browser engine resolves it, and WebView is the host. A paywall becomes a URL. When the design team invents a new layout, they write new markup, the server serves it, and an app that shipped two years ago renders it without ever having been taught what a carousel is.
第一种选择在每个Android设备上都有现成的实现。HTML是指令集,浏览器引擎解析它,WebView是宿主。付费墙变成了一个URL。当设计团队发明新布局时,他们编写新标记,服务器提供它,而两年前发布的应用无需被教导什么是轮播图就能渲染它。
The second option is what you get when you want native views. You define a schema, you write a renderer for each shape in it, and both live inside your binary. Consider the smallest possible version of that schema:
第二种选择是当你想要原生视图时得到的。你定义一个模式,为其中的每种形状编写渲染器,两者都存在于你的二进制文件中。考虑该模式的最小可能版本:
sealed interface PaywallComponent {
data class Text(val text: String) : PaywallComponent
data class Image(val url: String) : PaywallComponent
data class Stack(val children: List<PaywallComponent>) : PaywallComponent
}sealed interface PaywallComponent {
data class Text(val text: String) : PaywallComponent
data class Image(val url: String) : PaywallComponent
data class Stack(val children: List<PaywallComponent>) : PaywallComponent
}Three shapes, a recursive tree, and a renderer that walks it. This works, and it keeps working right up until the dashboard learns to emit a fourth shape. Now the server sends this to a binary compiled against the three shape schema:
三种形状,一个递归树,以及一个遍历它的渲染器。这有效,并且一直有效,直到仪表板学会发出第四种形状。现在服务器向针对三种形状模式编译的二进制文件发送这个:
{
"type": "carousel",
"pages": [ { "type": "text", "text": "Unlimited access" } ]
}{
"type": "carousel",
"pages": [ { "type": "text", "text": "Unlimited access" } ]
}Your deserializer has three options if it has to decide alone, and all of them are bad. It can throw, which fails the whole paywall and shows the user nothing on the screen your revenue depends on. It can skip the unknown node, which leaves a hole in the middle of a layout that was designed around it, so the user sees a headline, empty space, and a purchase button with no explanation of what they are buying. Or it can guess, which is worse than either.
当反序列化器必须独自做出决定时,它有三个选项,而这三个选项都不好。它可以抛出异常,这会导致整个付费墙失败,并在你依赖收入的屏幕上不向用户显示任何内容。它可以跳过未知节点,这会在围绕该节点设计的布局中间留下一个空洞,因此用户会看到标题、空白区域和一个购买按钮,却没有解释他们正在购买什么。或者它可以猜测,这比前两者更糟糕。
Notice what makes this asymmetric. The problem is not rendering. Drawing a carousel in Compose is a solved problem. The problem is that a native renderer knows a closed set of types while the server can emit an open one, and the gap between the two widens every time you ship a dashboard feature, because users update their apps on their own schedule and some of them never will.
注意是什么导致了这种不对称。问题不在于渲染。在Compose中绘制轮播图是一个已解决的问题。问题在于,原生渲染器知道一组封闭的类型,而服务器可以发出开放的类型,两者之间的差距每次你发布仪表板功能时都会扩大,因为用户按照自己的时间表更新应用,而有些人永远不会更新。
This is the actual decision behind native versus WebView paywalls, and it is not settled by a benchmark or a visual audit. Both of those follow from a prior question: where do you put the uncertainty about what the server might send? Inside a runtime you did not write and do not version, or inside a schema boundary you design deliberately?
这是原生与WebView付费墙背后的实际决策,它并非由基准测试或视觉审查来定论。这两者都源于一个先前的问题:你将关于服务器可能发送内容的不确定性放在哪里?是放在一个你未编写且未版本化的运行时内,还是放在一个你刻意设计的模式边界内?
The shortcut and what it defers: Hosting a page versus owning a tree
捷径及其推迟的内容:托管页面与拥有树结构
Before looking at how RevenueCat's SDK answers that question, it is worth accounting for what the WebView approach actually costs, because it genuinely does solve forward compatibility.
在查看RevenueCat的SDK如何回答这个问题之前,值得考虑WebView方法实际付出的代价,因为它确实解决了向前兼容性。
A WebView hosted paywall makes the SDK's job small. Its responsibilities reduce to roughly this shape:
WebView托管的付费墙使SDK的工作变得简单。其职责大致缩减为以下形式:
webView.loadUrl(paywallUrl)
webView.addJavascriptInterface(bridge, "native")webView.loadUrl(paywallUrl)
webView.addJavascriptInterface(bridge, "native")The page owns layout, typography, animation, and state. The bridge carries a handful of messages in both directions: the page tells native that the user tapped a product, native tells the page that the purchase succeeded. Everything visual is HTML and CSS, so the design team iterates without touching Kotlin and without a schema negotiation.
页面负责布局、排版、动画和状态。桥接层双向传递少量消息:页面告诉原生用户点击了某个产品,原生告诉页面购买成功。所有视觉内容都是HTML和CSS,因此设计团队无需接触Kotlin,也无需进行模式协商即可迭代。
What that architecture defers, rather than solves, is everything that depends on the paywall being present, fast, and native.
这种架构推迟而非解决的是所有依赖于付费墙存在、快速且原生的一切。
The paywall becomes a network resource. Before a single pixel can paint, the page has to be fetched. You can pre-warm a WebView to hide this, and well built implementations do, but pre-warming a WebView means keeping a renderer process alive on the speculative chance that the user will see a paywall. You are pre-warming a process. Pre-warming bytes on disk is a different proposition, and the next section is about what that difference buys.
付费墙变成了网络资源。在绘制第一个像素之前,必须先获取页面。你可以预热WebView来隐藏这一点,精心构建的实现确实会这样做,但预热WebView意味着在推测用户会看到付费墙的情况下,保持渲染进程存活。你是在预热一个进程。预热磁盘上的字节是另一回事,下一节将讨论这种差异能带来什么。
Caching the page yourself is possible, and more possible than it first looks. shouldInterceptRequest lets you serve every request from storage you control, and a service worker gives you explicit eviction and validation. What you cannot do is skip the work. You now maintain a second asset pipeline, its correctness, and its invalidation logic, in a language and a runtime separate from the app hosting it.
自己缓存页面是可行的,而且比乍看起来更可行。shouldInterceptRequest 让你能从自己控制的存储中提供每个请求,而 service worker 则赋予你明确的驱逐和验证机制。你无法避免的是工作量。你现在需要维护第二条资产管道、其正确性及其失效逻辑,使用的语言和运行时与应用宿主分离。
The layout engine is not yours to pin. Android System WebView updates independently of your app, through the Play Store, on a schedule set by the user's device, and devices without Play Services, with updates disabled, or on older Chromium builds form a version matrix nobody controls. A CSS rule that lays out correctly during QA can lay out differently on a device that updated its WebView provider last week. Compose does not remove platform variation, since text shaping still comes from StaticLayout and HarfBuzz, but the measure and layout code ships inside your APK, so the box model does not move under you. That is a much narrower moving target than an entire layout engine.
布局引擎并非你能固定不变的。Android System WebView 独立于你的应用更新,通过 Play Store 进行,更新时机由用户设备设置决定,而没有 Play Services、禁用更新或使用较旧 Chromium 构建设备的设备,形成了一个无人能控制的版本矩阵。在 QA 期间正确布局的 CSS 规则,在设备上周更新 WebView 提供商后可能布局不同。Compose 并不能消除平台差异,因为文本整形仍来自 StaticLayout 和 HarfBuzz,但测量和布局代码随你的 APK 一起发布,因此盒模型不会在你脚下变动。这比整个布局引擎的变动范围要小得多。
The page's content also sits outside Compose's reasoning. Compose can skip the AndroidView wrapper when its parameters are stable, and the hosted view does report a measured size, but Compose cannot reason about anything inside the document. The page's content height is only known after the document lays out, which happens after Compose has already measured. So a WebView inside a Compose column either gets a height you hardcode or a height that arrives over the bridge one frame late, and nested scrolling between the page's scroll container and a Compose parent becomes a coordination problem rather than a feature.
页面内容也超出了 Compose 的推理范围。当参数稳定时,Compose 可以跳过 AndroidView 包装器,且托管的视图确实报告了测量尺寸,但 Compose 无法推理文档内部的任何内容。页面内容的高度只有在文档布局完成后才知道,而这发生在 Compose 已经测量之后。因此,在 Compose 列中的 WebView 要么获得你硬编码的高度,要么获得通过桥接延迟一帧到达的高度,而页面滚动容器与 Compose 父级之间的嵌套滚动变成了协调问题,而非特性。
Finally, the seam between the page and the purchase is untyped. A tap crosses a postMessage boundary as a string, and nothing checks that identifier against your real product catalog until the user presses the button. As you'll see later, the native path checks the same mapping, just before the paywall renders rather than on the tap.
最后,页面与购买之间的接缝是无类型的。点击跨越 postMessage 边界时是字符串形式,且直到用户按下按钮前,没有任何机制检查该标识符是否与你的真实产品目录匹配。如你稍后所见,原生路径在付费墙渲染前而非点击时检查相同的映射。
None of this makes the WebView approach a mistake. It makes it a set of deferred payments. The rest of this article walks through what paying them up front looks like in code.
这些都不意味着 WebView 方法是错误的。它只是意味着这是一系列延迟支付。本文其余部分将展示在代码中提前支付这些成本的样子。
Nothing to fetch when the paywall opens: Two-level caching and asset predownload
付费墙打开时无需获取:两级缓存和资产预下载
Start with the property that is easiest to verify and hardest to retrofit. After the first successful offerings fetch, presenting the current offering's paywall no longer depends on the network. The component tree, the fonts, and the images are already on the device.
从最容易验证且最难事后改造的属性开始。在首次成功获取产品列表后,展示当前产品的付费墙不再依赖网络。组件树、字体和图片都已存在于设备上。
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力