起因很小:给一个支付成功卡片做入场动画。第一版我十五分钟写完------opacity 从 0 到 1,300ms,linear,一行 CSS 的事。跑起来能动,看回放总觉得别扭,但说不上来哪里别扭。
第一次返工:我跳过了规格单
tri-lottie 的 SKILL.md 里第一条强制契约写得很死:任何动画代码生成前必须先完成「动效规格单」,跳过决策层直接写代码的产物判不合格。我那第一版,标准地属于被判不合格的那批。
老老实实按它的 8 步走了一遍:
- 情绪目标------庆祝但有质感,走 joy 而不是狂欢
- 人格原型------UI 默认 Corporate,这里要更克制,选 Premium
- 主属性------position + opacity,两个是甜点,别堆
- 时长------卡片进出场,查表得 200-350ms
- 缓动------入场走 ease-out,落到
cubic-bezier(0.4,0,0.2,1) - 主角元素------卡片本体是 hero,阴影是配角
- 三层运动------primary 100%,secondary 阴影延迟 50ms
- 1/3 法则------位移 20px,远低于卡片高度的三分之一
走完输出一行:
〔卡片入场|Premium|position+opacity|350ms|cubic-bezier(0.4,0,0.2,1)|0%|primary+secondary(shadow 延迟 50ms)〕
这一行是技术栈无关的,六端都认。让我意外的是,stacks/harmonyos-arkts/implementation.md 第 62 行的黄金用例写的就是这一句------也就是说,规格单写对了之后,代码基本就是照着模板改两个参数的事。返工这一趟的成本,百分之八十花在「没写那一行」上。
第二次返工:350ms 是基线,不是终值
我把 350ms 直接搬到手机上、300px 位移的卡片。跑起来慢半拍,那种「卡了一下才出现」的迟滞感特别明显。
查 references/motion-tokens.md 才发现,第 4 步的 200-350ms 只是元素类型的基线,后面还挂着三个系数:
| 步骤 | 取值 | 系数 |
|---|---|---|
| 卡片进出场基线 | 200-350ms | --- |
| 距离缩放(300px) | ×1.5 | motion-tokens.md:25 |
| 材质缩放(纸质卡片) | ×1.0 | motion-tokens.md:62 |
| 平台缩放(Mobile) | ×0.8 | motion-tokens.md:105 |
乘完是 240-420ms,出场再按「进出不对称」折 65-75%,落到 156-315ms。我那个 350ms 落在这个区间的下沿偏中,配 300px 的位移确实拖沓;改成 280ms 之后立刻顺了。
顺带说个自己踩的小坑:这套连乘我一开始手算,写进脚本才发现 350 * 1.5 * 1.0 * 0.8 出来是 420.00000000000006,拿它跟 420 比等号是 False。浮点数的事在这儿不展开,总之别在断言里直接比。
第三次返工:卡片到位了还在抖
同一个交互的 H5 版我用了 spring 而不是 bezier。按 table 取了「极硬:stiffness 400+ / damping 25-30」,心里想的是「干脆、落地就停」。结果卡片到位之后还在轻微回弹,那种抖,看久了眼晕。
翻到表底下第 76 行那句注解:damping 语义:<1.0 振荡;=1.0 最快无振荡;>1.0 缓慢收敛。
这句话说的是阻尼比 ζ ,无量纲;而表里 damping 那一列给的 5 到 30,是 Framer Motion / React Spring 那套绝对阻尼系数。两个口径不同的东西印在相邻两行,我第一次读直接理解反了------以为 damping 25-30 远大于 1,早就「收敛」了,恰恰相反。
实际按 ζ = c / (2√(k·m))、mass 取 1 算:
| 取值 | ζ | 表现 |
|---|---|---|
| stiffness 400 / damping 27.5(表建议) | 0.688 | 振荡 |
| damping 40 | 1.000 | 临界,最快无振荡 |
| damping 56 | 1.400 | 踏实落地 |
我后来取了 56。表没写错,是我没算。
这几件东西我现在全塞进一个脚本,交付前跑一遍:
python
import math
def duration(base_lo, base_hi, dist=1.0, material=1.0, platform=1.0):
k = dist * material * platform
return base_lo * k, base_hi * k
enter = duration(200, 350, dist=1.5, material=1.0, platform=0.8)
print(f"[时长链] 卡片入场(Mobile/300px/纸质): {enter[0]:.0f}-{enter[1]:.0f}ms"
f" 出场: {enter[0]*0.65:.0f}-{enter[1]*0.75:.0f}ms")
def stagger(n, gap):
total = (n - 1) * gap
print(f"[stagger] {n} 元素 × {gap}ms = {total}ms -> "
f"{'通过 C5' if total < 500 else '被 C5 一票否决(须 <500ms)'}")
stagger(6, 100)
stagger(6, 80)
def zeta(k, c, m=1.0):
return c / (2 * math.sqrt(k * m))
print("[spring] stiffness=400:")
for d in (27.5, 40.0, 56.0):
z = zeta(400, d)
print(f" damping={d:<5} -> ζ={z:.3f} {'振荡' if z < 1 else '不振荡'}")
真实输出是这样的:
text
[时长链] 卡片入场(Mobile/300px/纸质): 240-420ms 出场: 156-315ms
[stagger] 6 元素 × 100ms = 500ms -> 被 C5 一票否决(须 <500ms)
[stagger] 6 元素 × 80ms = 400ms -> 通过 C5
[spring] stiffness=400:
damping=27.5 -> ζ=0.688 振荡
damping=40.0 -> ζ=1.000 不振荡
damping=56.0 -> ζ=1.400 不振荡

Hero 区那次,是质量门把我拦下来的
形势好转之后我有点飘,Hero 区塞了 6 个元素做串行入场,按 motion-tokens.md 的 stagger 预算表选了「戏剧」档、间隔 100ms------表上那一档写的是总预算 <600ms,6×100 减 1 个间隔正好 500ms,我以为合规。
跑脚本的时候 C5 直接判否。C5 是 quality-gate.md:16 的 CRITICAL 条款:stagger 总时长必须 < 500ms,500 不 < 500,一票否决。改 80ms、总 400ms 才过。
这里有个细节值得说:同一张 tokens 表里,「戏剧」档的总预算写 600ms,而表末尾第 87 行又写「总 stagger 必 < 500ms」,两边口径不一致。我现在一律按严的那条执行------严的不会错,松的会让你上线后被追着改。
第四次返工:ArkTS 端的三条铁律,我全踩了
Web 端跑顺之后,我把同一份规格单落到鸿蒙 ArkTS 上。这一步折腾最久,而且三个坑全写在 stacks/harmonyos-arkts/implementation.md 第 48 行往下的「资产规范与坑位」里------是我没先看那一节。
第一坑,路径。loadAnimation 的 path 禁用 ./ 和 ../,基准是 pages 的父文件夹:
typescript
// 我写的(错): pages 目录下用相对路径,白屏,控制台一行报错都没有
lottie.loadAnimation({ path: './common/lottie/card_enter.json' })
// 正确写法:相对 pages 父文件夹
lottie.loadAnimation({ path: 'common/lottie/card_enter.json' })
这一条我躺了两个小时。白屏、零日志,翻遍构建产物才回头看文档。
第二坑,加载时机。动画必须放在 Canvas.onReady() 里,画布尺寸没就绪时加载出来的尺寸是错的。
第三坑,释放。ArkTS 是六端里唯一需要手动 destroy 且有全局销毁语义的,aboutToDisappear() 里必须调 lottie.destroy(),漏了就是内存泄漏。
我本机是 DevEco 6.1.1 + API 12,还有三条环境相关的也记一下:curves.cubicBezier 在 API 24 能编译通过但报 WARN(文档第 56 行已标注);ArkTS 严格模式的 arkts-no-any-unknown 会让隐式 any 的参数直接编译失败,回调必须写成 .catch((err: BusinessError) => ...);hvigor 6.x 要求根 hvigor/hvigor-config.json5 和根 oh-package.json5 声明相同的 modelVersion,少一个就报 00303024「project structure need to be upgraded」。
前两条是动效坑,后三条纯环境坑,但都属于「开工前读一遍端文档就能省下来」的类型。
这套流程我现在怎么跑
规格单先写,代码后写;数值一律走脚本算,不手算也不凭手感;换端之前先把那个端 implementation.md 的坑位节读一遍;交付时四样东西齐全------规格单行、代码块、坑位核对记录、reduced-motion 降级(系统开了「减弱动画」就直接落终态,别播)。
有一点要分清:scripts/compliance_check.py 跑出来 10/10 全绿,验的是 frontmatter 八字段、版本四件套、目录结构、六端断言这些结构项,跟你的动画好不好看、参数对不对是两回事。它该跑,但它是体检报告不是质检合格证。
真要说这套方法论给我省了什么------大概是把「这个动画看着有点廉价」这种说不清的反馈,翻译成了「你这段位移超了容器 1/3 / 时长落在区间外 / 缺 secondary 层」这种能直接改的话。下次你也被这种说不清的反馈卡住,不妨先花五分钟把规格单那 8 步填完。
关于作者:老三,10+ 年软件开发老兵,软件设计师 & 人工智能应用工程师,专注鸿蒙 ArkTS 北向开发 + Web 前端,探索 AI 辅助开发的边界。
本文遵循 MIT 协议,转载请注明出处。
安装
bash
skillhub install tri-lottie