Android Paywalls V2 集成 Rive/Lottie
Adding Rive and Lottie Animations to Android Paywalls
直接给出了 RevenueCat Paywalls V2 集成 Rive/Lottie 的具体代码实现、构建约束和避坑指南,独立开发者和移动端产品经理可直接参照修改现有支付墙。
Paywalls V2 gives you a component tree to build with: stacks, text, buttons, images, video, carousels, timelines. It covers the layouts most paywalls need, and the whole tree is defined on the server, so you change it from the dashboard without shipping an app release. But the vocabulary is fixed. When you want a Rive character reacting through a state machine, or a Lottie celebration that fires the moment a trial starts, no component expresses it. Custom components close that gap by reserving a box on the paywall and letting you decide what goes inside it.
Paywalls V2 为你提供一套组件树用于构建:堆栈、文本、按钮、图片、视频、轮播图、时间线。它涵盖了大多数付费墙所需的布局,且整棵树在服务端定义,因此你可以从仪表板进行修改而无需发布应用版本。但词汇表是固定的。当你希望一个 Rive 角色通过状态机做出反应,或是一个在试用开始时立即触发的 Lottie 庆祝动画时,没有现成组件能表达这些需求。自定义组件通过在付费墙上预留一个区域并让你决定其中内容来弥补这一空白。
In this article, you'll explore where the component vocabulary runs out, how the Rive and Lottie bundles are structured and the two decisions that make them work, what the upload step involves, what changes in your Android code, and the constraints worth knowing before you commit to one.
在本文中,你将探索组件词汇表耗尽之处、Rive 和 Lottie 包的结构以及使它们工作的两个关键决策、上传步骤涉及的内容、Android 代码中的变更,以及在承诺采用之前值得了解的约束条件。
Where the component vocabulary stops
组件词汇表的边界
Every component in a Paywalls V2 tree is something the SDK knows how to interpret. A stack arranges children, a text renders a localized string, an image draws a remote asset. Your paywall config is a composition of these known types, which is exactly why it can be served as JSON and rendered by a binary that has never seen that particular paywall.
Paywalls V2 树中的每个组件都是 SDK 知道如何解释的。堆栈排列子元素,文本渲染本地化字符串,图片绘制远程资源。你的付费墙配置是这些已知类型的组合,这正是它可以作为 JSON 提供并由从未见过该特定付费墙的二进制文件渲染的原因。
The consequence is that you can compose the vocabulary, but you cannot extend it. An animation format is not expressible as a stack of text and images. Before custom components, you had three options:
其后果是你只能组合词汇表,而无法扩展它。动画格式无法表示为文本和图片的堆栈。在自定义组件出现之前,你有三个选择:
- Export the animation as a video. The video component gives you motion with no bundle to build, and for a fixed clip it is still the right answer. It cannot react to state, and a video costs far more bytes than the vector data that produces the same motion.
- Ship a native Composable and gate it. You write the animation in your app, then guard it behind a flag or an offering identifier. This works, but it needs an app release for every change, which gives up the reason you moved to server driven paywalls.
- Fall back to a static image. Simple and immediate, and it throws away the motion entirely.
- 将动画导出为视频。视频组件提供无需构建包的动态效果,对于固定片段来说,这仍然是正确的选择。但它无法对状态做出反应,且视频占用的字节数远多于产生相同运动的矢量数据。
- 发布原生 Composable 并进行限制。你在应用中编写动画,然后通过标志位或产品标识符对其进行保护。这可行,但每次更改都需要发布应用版本,这就放弃了转向服务端驱动付费墙的理由。
- 回退到静态图片。简单直接,但完全丢弃了动态效果。
A custom component targets the case none of those cover: content that is interactive, vector based, and changeable from the dashboard. The RevenueCat docs name the use case directly, describing custom components as usable "for interactive elements such as Lottie or Rive animations and animated backgrounds."
自定义组件针对上述选项均未覆盖的情况:交互式、基于矢量且可从仪表板更改的内容。RevenueCat 文档直接命名了这一用例,将自定义组件描述为可用于“Lottie 或 Rive 动画及动态背景等交互元素”。
What a custom component is
什么是自定义组件
A custom component is a folder of web files with index.html at its root. You zip that folder and upload it to the component in the dashboard. RevenueCat validates the archive, unpacks it, and serves the files over HTTPS from a subdomain dedicated to that upload. In the tree, you place it and size it the way you place any other component.
自定义组件是一个包含 index.html 作为根目录的 Web 文件文件夹。将该文件夹压缩后上传至仪表板中的组件。RevenueCat 会验证归档文件,解压它,并通过专用于该上传的子域名通过 HTTPS 提供这些文件。在树状结构中,你可以像放置任何其他组件一样放置并调整其大小。
Three properties shape how you build one:
有三个属性决定了你如何构建一个自定义组件:
- It is self contained. Everything the bundle needs travels inside the zip: scripts, styles, fonts, animation data, and the animation runtime itself. Once the bundle has loaded there is no further third party fetch, so the animation cannot fail because some CDN was slow.
- It runs under a strict Content Security Policy. This is the rule that shapes all the code below. The bundle cannot call fetch(), cannot use eval() or new Function, and cannot use inline <script> blocks. These are hard failures, not degradations.
- It is decorative. The docs are explicit that "paywall elements such as packages and purchase buttons must remain native." Selection and purchase stay on real components, where the SDK owns the purchase path.
- 它是自包含的。捆绑包所需的一切都在 zip 文件中:脚本、样式、字体、动画数据以及动画运行时本身。一旦捆绑包加载完成,就不会有进一步的第三方请求,因此动画不会因某些 CDN 缓慢而失败。
- 它在严格的内容安全策略(CSP)下运行。这是塑造以下所有代码的规则。捆绑包不能调用 fetch(),不能使用 eval() 或 new Function,也不能使用内联 <script> 块。这些都是硬性失败,而不是降级处理。
- 它是装饰性的。文档明确指出,“支付墙元素如套餐和购买按钮必须保持原生”。选择和购买操作保留在真实组件上,SDK 拥有购买路径的控制权。
Inside the box you are not bound by the component vocabulary. Any markup, any animation runtime, any layout technique the bundle rules allow. What you give up is the constraint being removed entirely: it is traded for a smaller set of rules, collected in the last section.
在框架内部,你不受组件词汇表的限制。任何标记、任何动画运行时、任何布局技术只要符合捆绑包的规则都可以使用。你所放弃的是完全移除约束:它被一组更小的规则所取代,这些规则收集在最后一节中。
Two authoring requirements are easy to miss. The bundle must fill its frame, which means sizing to 100% width and height with margin: 0; padding: 0 and no hardcoded pixel dimensions. And index.html must contain a <head>, because RevenueCat injects its content SDK there at upload time. You do not add that script tag yourself.
有两个编写要求容易被忽略。捆绑包必须填满其框架,这意味着以 margin: 0; padding: 0 设置宽高为 100%,并且没有硬编码的像素尺寸。此外,index.html 必须包含 <head>,因为 RevenueCat 会在上传时在其内容 SDK 中注入脚本。你不需要自己添加这个脚本标签。
One more thing about placement. A custom component's visibility can be varied by condition through the override system, but its size cannot. Since framing depends on the ratio of the box to the animation, pick a size that frames acceptably in every configuration you support.
关于放置还有一点需要注意。自定义组件的可见性可以通过覆盖系统按条件变化,但其大小不能。由于框架取决于盒子与动画的比例,请选择在所有支持的配置中都能良好适配的大小。
The CSP rule is the one to internalize first, because nearly every Rive and Lottie tutorial loads its animation by URL or file path. A bundle cannot fetch anything at display time, so both examples below are built around handing the animation data to the runtime directly.
CSP 规则是最需要内化的,因为几乎所有的 Rive 和 Lottie 教程都通过 URL 或文件路径加载其动画。捆绑包不能在显示时获取任何内容,因此下面的两个示例都是围绕直接将动画数据传递给运行时来构建的。
Rive: A state machine driven character
Rive:由状态机驱动的角色
Rive is a runtime for interactive vector animation. What distinguishes it from most animation formats is the state machine: rather than playing a fixed timeline, a .riv file defines states and transitions, so a character can idle, react, and settle back. Files are compact, usually tens of kilobytes.
Rive 是一个用于交互式矢量动画的运行时。它与大多数动画格式的区别在于状态机:.riv 文件定义的是状态和转换,而不是播放固定的时间线,因此角色可以处于空闲、反应并重新安顿下来的状态。文件体积紧凑,通常只有几十 KB。
The bundle for the Marty example is six files:
Marty 示例的包包含六个文件:
rive-03-marty/
├── index.html
├── styles.css
├── app.js
├── config.js
├── rive.js
└── riv-data.jsrive-03-marty/
├── index.html
├── styles.css
├── app.js
├── config.js
├── rive.js
└── riv-data.jsrive.js is the Rive runtime and riv-data.js holds the .riv file as a base64 string. config.js carries the per animation settings, which lets one template serve several bundles. The snippets below inline those values instead, so each one reads on its own.
rive.js 是 Rive 运行时,riv-data.js 将 .riv 文件作为 base64 字符串保存。config.js 携带每个动画的设置,这使得一个模板可以服务于多个包。下面的代码片段将这些值内联,以便每个片段都能独立阅读。
The interesting part of index.html is the script order:
index.html 中有趣的部分是脚本顺序:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="stage"><canvas id="canvas"></canvas></div>
<script src="./rive.js"></script>
<script src="./riv-data.js"></script>
<script src="./app.js"></script>
</body>
</html><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="stage"><canvas id="canvas"></canvas></div>
<script src="./rive.js"></script>
<script src="./riv-data.js"></script>
<script src="./app.js"></script>
</body>
</html>Every script is a file reference, since inline blocks are rejected. The stylesheet is what gives the stage a height, and without it a canvas collapses to nothing:
每个脚本都是文件引用,因为内联块被拒绝。样式表为舞台提供了高度,如果没有它,画布会坍缩为空:
html, body { margin: 0; padding: 0; width: 100%; height: 100%; }
#stage { width: 100%; height: 100%; }
#canvas { display: block; width: 100%; height: 100%; }html, body { margin: 0; padding: 0; width: 100%; height: 100%; }
#stage { width: 100%; height: 100%; }
#canvas { display: block; width: 100%; height: 100%; }Two decisions are specific to shipping Rive this way. The first is which Rive build to use. The default @rive-app/canvas build fetches its WebAssembly from a CDN at startup, which the CSP blocks, so use @rive-app/canvas-single instead. That build inlines the WebAssembly into the JavaScript file, which is also why it is around 1.8 MB.
以这种方式发布 Rive 有两个特定的决定。第一个是使用哪个 Rive 构建版本。默认的 @rive-app/canvas 构建在启动时从 CDN 获取其 WebAssembly,这会被 CSP(内容安全策略)阻止,因此请改用 @rive-app/canvas-single。该构建版本将 WebAssembly 内联到 JavaScript 文件中,这也是为什么它的大小约为 1.8 MB 的原因。
The second is how the animation reaches the runtime. Rive's src option takes a path, and loading it triggers a fetch. So riv-data.js carries the bytes as base64:
第二个是动画如何到达运行时。Rive 的 src 选项接受一个路径,加载它会触发 fetch。因此 riv-data.js 将字节作为 base64 携带:
window.__RIV_B64 = "UklWRQ...";window.__RIV_B64 = "UklWRQ...";You generate that from the .riv file with one command:
你可以使用一条命令从 .riv 文件生成它:
printf 'window.__RIV_B64 = "%s";\n' "$(base64 -i marty.riv)" > riv-data.jsprintf 'window.__RIV_B64 = "%s";\n' "$(base64 -i marty.riv)" > riv-data.jsIn app.js, decoding base64 back to bytes uses atob, which returns a string of character codes that you copy into a typed array:
在 app.js 中,将 base64 解码回字节使用 atob,它返回一个字符代码字符串,你可以将其复制到类型化数组中:
var bin = atob(window.__RIV_B64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) {
bytes[i] = bin.charCodeAt(i);
}var bin = atob(window.__RIV_B64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) {
bytes[i] = bin.charCodeAt(i);
}Those bytes go to the runtime through buffer rather than src. Fit and Alignment control framing, which the next section covers:
这些字节通过 buffer 而不是 src 传递给运行时。Fit 和 Alignment 控制框架,下一节将对此进行介绍:
var r = new window.rive.Rive({
buffer: bytes.buffer,
canvas: document.getElementById('canvas'),
autoplay: true,
layout: new window.rive.Layout({
fit: window.rive.Fit.Cover,
alignment: window.rive.Alignment.TopCenter
}),
onLoad: function () { start(r); }
});var r = new window.rive.Rive({
buffer: bytes.buffer,
canvas: document.getElementById('canvas'),
autoplay: true,
layout: new window.rive.Layout({
fit: window.rive.Fit.Cover,
alignment: window.rive.Alignment.TopCenter
}),
onLoad: function () { start(r); }
});onLoad fires once the file is parsed, which is the first point where you can ask what the file actually contains. That matters because autoplay only covers files holding a single linear animation. When a file has a state machine, autoplay starts one of the linear clips instead of the intended behavior, so you stop that playback and start the machine:
onLoad 在文件解析完成后触发,这是你可以询问文件实际内容的第一个时机。这很重要,因为 autoplay 仅适用于包含单个线性动画的文件。当文件具有状态机时,autoplay 会启动其中一个线性剪辑,而不是预期的行为,因此你需要停止该播放并启动状态机:
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力