一个卡片入场动画我返工 4 次:tri-lottie 规格单落地到 ArkTS 的踩坑记录

起因很小:给一个支付成功卡片做入场动画。第一版我十五分钟写完------opacity 从 0 到 1,300ms,linear,一行 CSS 的事。跑起来能动,看回放总觉得别扭,但说不上来哪里别扭。

第一次返工:我跳过了规格单

tri-lottie 的 SKILL.md 里第一条强制契约写得很死:任何动画代码生成前必须先完成「动效规格单」,跳过决策层直接写代码的产物判不合格。我那第一版,标准地属于被判不合格的那批。

老老实实按它的 8 步走了一遍:

  1. 情绪目标------庆祝但有质感,走 joy 而不是狂欢
  2. 人格原型------UI 默认 Corporate,这里要更克制,选 Premium
  3. 主属性------position + opacity,两个是甜点,别堆
  4. 时长------卡片进出场,查表得 200-350ms
  5. 缓动------入场走 ease-out,落到 cubic-bezier(0.4,0,0.2,1)
  6. 主角元素------卡片本体是 hero,阴影是配角
  7. 三层运动------primary 100%,secondary 阴影延迟 50ms
  8. 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
相关推荐
南京兴帝文化传媒有限公司1 小时前
AI大模型如何抓取和推荐无锡本地商户?GEO技术链路与POI权重算法拆解
大数据·人工智能·生活·geo 优化·ai搜索获客·无锡geo优化·长三角geo优化
是枚小菜鸡儿吖1 小时前
Windows 和 iPhone 怎么互传文件?用 PairDrop 搭一个自己的浏览器传输入口
服务器·人工智能·大模型
小刘快学习1 小时前
高并发下的优雅降级:企业AI网关的流量整形与过载保护
人工智能
知几蜗牛1 小时前
语义相近却总找错资料?从Embedding看懂向量检索
人工智能
知几蜗牛1 小时前
AI算力开始听电网指挥:比换GPU更现实的增产方法
人工智能
智慧医养结合软件开源1 小时前
【源码交付】智慧养老系统 · Java + Vue3-技术架构
大数据·人工智能·信息可视化·云计算
知几蜗牛1 小时前
票据抽取不一定要上最大模型:先看版式是否真的变化
人工智能
QXWZ_IA1 小时前
化工园区安全风险智能化管控平台怎么建?千寻位置对标路径
人工智能·科技·安全
湘美书院--湘美谈教育1 小时前
湘美书院夜谈录:AI时代的体验感与选择价值观念
人工智能·深度学习·学习·安全·生活