互动卡片实战:摸鱼鸭的“破框动效“是怎么做出来的
互动卡片实战:摸鱼鸭的"破框动效"是怎么做出来的
把一张只能看、不能摸的桌面卡片,变成会顶破屏幕边框、撒花庆祝的小鸭子。
本文记录我在 HarmonyOS「摸鱼鸭」项目里接入 Live Form(互动卡片)的全过程,从怎么让卡片"活"起来,到踩过的坑和对应的解法。读完你不仅能照着做,还能少走很多弯路。


一、先讲个痛点:你的卡片是不是也"死"的?
最初我的「摸鱼鸭」桌面卡片长这样:一张 2×2 的小卡,上面写着"周末倒计时 2 天",配一只呆萌的鸭子。
能用,但很无聊。
- 它不会动;
- 你戳它,它没反应;
- 它永远缩在那个方方正正的圆角矩形里,像被框住的囚徒。
我想做的效果是:用户一点卡片上的鸭子,鸭子"嘭"地从卡片里钻出来,顶破边框冲到屏幕上方,还撒一把彩花,1 秒多之后又缩回去。 华为把这种效果叫做"破框动效",技术载体就是 Live Form(互动卡片)。
这篇文章要解决的,就是"怎么让这张卡片活过来"。
二、什么是互动卡片(Live Form)?
普通的桌面卡片(FormExtensionAbility)是"静态渲染"——系统把你的 UI 截图成一张图贴在桌面上,你之后只能通过 updateForm 换内容,没法做动画、也没法真正响应手势。
互动卡片(Live Form) 不一样:它能在用户触发时,临时加载一个真正的 ArkUI 页面,这段时间里卡片可以播放动画、响应点击。触发方式有两种:
- 手势触发(shake):系统在
module.json5里声明sceneAnimationParams,由桌面(如摇一摇、长按等系统手势)自动唤起。 - 卡片内主动触发(点击):卡片里的 UI 通过
postCardAction发一条消息给FormExtensionAbility,由它去调用requestOverflow申请破框。
破框的本质,是系统在不放大卡片本身的前提下,临时在卡片周围多给你一圈"溢出区"(overflow area),让动画内容可以画到卡片边界之外——看起来就像内容"钻"出了卡片。
三、整体链路:一次点击是怎么变成破框动画的
用一句话串起来:
用户点鸭子 → 卡片发
MESSAGE→FormExtensionAbility.onFormEvent收消息 → 调requestOverflow申请溢出区 → 系统创建LiveFormExtensionAbility→ 加载DuckLiveCard页面 → 鸭子顶出边框 + 撒花 → 主动cancelOverflow收起。
代码层面拆分三条角色:
| 角色 | 文件 | 职责 |
|---|---|---|
| 普通卡片 UI | widgets/SmallCard2x2.ets | 展示文案 + 鸭子热区,点击发消息 |
| 卡片能力 | formextensions/WeekendFormExtension.ets | 收消息,申请破框 |
| 互动页面 | liveform/DuckLiveFormAbility.ets + liveform/pages/DuckLiveCard.ets | 破框后加载的动画 UI |
四、手把手实现
1. 注册 LiveForm 能力
在 module.json5 的 extensionAbilities 里加一条,类型填 liveForm:
{
"name": "DuckLiveFormAbility",
"srcEntry": "./ets/liveform/DuckLiveFormAbility.ets",
"type": "liveForm",
"exported": false
}
2. 给普通卡片声明"手势触发"
在卡片的 form_profile.json(resources/base/profile/form_weekend.json)里,加上 sceneAnimationParams,把破框页面指过去:
"sceneAnimationParams": {
"abilityName": "DuckLiveFormAbility",
"triggerTypes": ["shake"]
}
⚠️ 这个配置是在卡片"被添加到桌面"那一刻就被系统读走了。改完之后,必须把卡片从桌面删掉重新添加,否则新配置不生效。
3. 在卡片里点鸭子发消息
鸭子是一个 @Builder,用 responseRegion 把热区放大到 66px(比实际图大一圈,桌面才好点),点击后 postCardAction 发一条 message:
@Builder
DuckHotspot(size: number) {
Image(DuckImageUtil.getImageResource(this.duckState))
.width(size).height(size)
.responseRegion({ x: -14, y: -14, width: size + 28, height: size + 28 })
.hitTestBehavior(HitTestMode.Block) // 阻断冒泡,避免同时跳转 App
.onClick(() => this.requestLiveForm())
}
private requestLiveForm(): void {
postCardAction(this, {
action: 'message',
params: { message: 'requestOverflow', widthRatio: 1.45, heightRatio: 1.45, duration: 2200 }
})
}
4. FormExtension 收消息并申请破框
onFormEvent(formId: string, message: string): Promise<void> {
const params = LiveFormOverflowUtil.parseMessage(message)
if (params['message'] === 'requestOverflow') {
return this.activateLiveForm(formId, params) // 申请破框
}
return this.computeAndUpdate(formId) // 其他消息走普通刷新
}
private async activateLiveForm(formId: string, params: Record<string, Object>): Promise<void> {
const ok = await LiveFormOverflowUtil.requestOverflow(
formId,
LiveFormOverflowUtil.readNumber(params, 'widthRatio', 1.45),
LiveFormOverflowUtil.readNumber(params, 'heightRatio', 1.45),
LiveFormOverflowUtil.readNumber(params, 'duration', 2200)
)
this.saveLiveFormData() // 数据落盘,不 await,避免拖慢响应
if (!ok) await this.computeAndUpdate(formId) // 不支持时降级为刷新
}
注意一个性能细节:先申请破框(用户立刻看到反应),再把数据落盘,而且不 await 落盘。否则点下去要等几百毫秒才有动静,用户会以为没点上。
5. 互动页面:鸭子顶出 + 撒花
DuckLiveFormAbility.onLiveFormCreate 里同步 loadContent,把卡片信息塞进 payload,系统随后加载 DuckLiveCard:
onLiveFormCreate(liveFormInfo: LiveFormInfo, session: UIExtensionContentSession): void {
const payload = getDuckLiveFormPayload()
payload.rectWidth = liveFormInfo.rect.width
payload.rectHeight = liveFormInfo.rect.height
// ... 写 formId / cardType / 文案 / 圆角 ...
session.loadContent('liveform/pages/DuckLiveCard', new LocalStorage())
}
页面里鸭子居中后上移露出上边界,撒花用 ForEach + Circle().fill() 做:
Stack({ alignContent: Alignment.Center }) {
Column() { /* 原卡片文案底板,居中 */ }
.width(this.rectWidth).height(this.rectHeight)
.borderRadius(this.radius)
.linearGradient({ angle: 180, colors: [...] })
Image(this.duckImage())
.scale({ x: this.duckScale, y: this.duckScale })
.offset({ y: this.duckOffsetY() }) // 向上偏移,顶部钻出
ForEach(this.confettiList, (item) =>
Circle({ width: item.size, height: item.size }).fill(item.color)
.offset({ x: item.dx * this.confettiProgress, y: item.dy * this.confettiProgress - this.rectHeight * 0.15 }))
}
动画跑完(约 1.3s)主动 cancelOverflow,把系统的"破框请求锁"提前释放掉,下次点击才能更快响应:
this.dismissTimer = setTimeout(() => {
formProvider.cancelOverflow(this.formId).catch(() => {})
}, 1600)
五、踩坑实录(重点来了)
下面这些都是我用真机 + hdc shell hilog | grep MoyuyaLiveForm 一条条日志磨出来的。
坑 1:破框申请直接报错 code=16501000
现象:点卡片没反应,日志里 requestOverflow failed code=16501000,系统侧还提示 overflow duration is out of range:3500。
原因:formProvider.requestOverflow 有硬上限。我一开始设的 duration=4000、ratio=1.8 全部超限。
解决:把参数全部 clamp 到安全区间,并预留余量:
private static readonly MAX_RATIO: number = 1.5 // 比例上限
private static readonly MAX_DURATION: number = 3500 // 时长上限
- 时长:申请 2200ms,永远 ≤ 3500ms;
- 比例:申请 1.45 倍(≤ 1.5)。
坑 2:2×4 大卡片反而破框失败
现象:2×2 卡片能破框,换成 2×4(高 300px)就报 area out of range, scaleRatio:1.5, overflowWidthHalf:37.5。
原因:系统限制的不是"比例",而是单边绝对溢出量。2×2(150px)按 1.45 倍算单边溢出约 33px,刚好过线;但 2×4 高 300px,1.45 倍算出来单边要溢出 67px,被系统策略直接拒绝。
解决:先按比例算,再按"单边绝对溢出上限"钳一次。实测单边最多约 37.5px,我留余量取 36:
private static readonly MAX_OVERFLOW_HALF: number = 36
const areaWidth = Math.min(rect.width * w, rect.width + 2 * MAX_OVERFLOW_HALF)
const areaHeight = Math.min(rect.height * h, rect.height + 2 * MAX_OVERFLOW_HALF)
这样无论 2×2、2×4 还是 4×4,都不会超限。
坑 3:点完卡片"消失"约 3 秒又出现
现象:点击触发破框后,整张卡变成一片白(背景白色 16777215),大约 3 秒后才恢复。
排查:日志显示 loadContent success,但 DuckLiveCard 的 aboutToAppear 根本没打印——页面没真正渲染出来,露出了默认白色底。
两个根因,都修了:
(a) loadContent 必须在 onLiveFormCreate 中同步调用。 我最初把它包在 async 逻辑后面,导致系统认为页面没准备好,直接上白底。改成同步立即调用即可(所有数据准备走同步 payload 写入,不 await 外部 IO)。
(b) rect 坐标语义陷阱。 我一度想用 rect.left / rect.top 做绝对定位,结果不同系统版本下,这两个值有时是"相对偏移"、有时是"屏幕绝对坐标",用错就把内容推出可见区,看着就像"卡片消失"。
正确思路:溢出区一定是以卡片为中心放大的,所以"溢出区中心 == 卡片中心"。于是不依赖 rect.left/top 的坐标系,直接用 Stack 居中布局 + 向上偏移即可:
private duckOffsetY(): number {
const halfCard = this.rectHeight / 2
const halfDuck = this.duckSize() / 2
const reveal = Math.min(this.rectHeight * 0.17, 26) // 露出量钳到 26,不超每边 36px 额度
return -(halfCard + reveal - halfDuck)
}
坑 4:互动页面拿不到最新文案
现象:破框后鸭子旁边的文案是空的,或者显示的是上一次的旧数据。
原因:互动卡片(LiveFormExtensionAbility)和 FormExtensionAbility 不在同一个进程。普通卡片的 Preferences 共享在互动进程里读不到。
解决:跨进程数据走文件。FormExtension 在申请破框前(不阻塞地)把最新文案写入 LiveFormDataStore(文件存储),互动页面在 aboutToAppear 里读取;读不到就用默认值兜底(cardType === 'fish' ? '摸鱼中,勿扰' : '冲鸭,周末在前方')。页面同时用 @Entry({ useSharedStorage: true }) 提升存储兼容性。
坑 5:摇一摇没反应
现象:声明了 triggerTypes: ["shake"],但摇手机不触发。
原因:手势触发不是走 onFormEvent,而是系统回调 onUpdateForm 并带上 shake 相关参数。我一开始只在 onFormEvent 里接消息,自然接不到。
解决:在 onUpdateForm 里也识别 shake:
onUpdateForm(formId: string, wantParams?: Record<string, Object>): Promise<void> {
if (wantParams !== undefined && LiveFormOverflowUtil.isShakeTrigger(wantParams)) {
return this.activateLiveForm(formId, {})
}
return this.computeAndUpdate(formId)
}
isShakeTrigger 做宽松匹配——遍历参数值,只要包含 shake 就认为是摇一摇触发,兼容不同系统版本的参数命名。
坑 6:消息解析在老版本下崩
现象:某些系统版本下 postCardAction 传来的 message 不是合法 JSON,直接 JSON.parse 会抛异常。
解决:parseMessage 加兜底——解析失败就把原串当作 message 字段返回,保证后续 params['message'] === 'requestOverflow' 的判断仍能走通。
六、给排查者的小抄
- 看互动链路卡在哪一步:过滤日志
hdc shell hilog | grep MoyuyaLiveForm,从onFormEvent→requestOverflow→onLiveFormCreate→DuckLiveCard aboutToAppear一路追。 - 看系统为什么拒绝破框:看
com.ohos.sceneboard/FORM相关日志,会直接打印out of range、area out of range这类原因。 - 改了
sceneAnimationParams或动画 UI 后,删卡重加再测。
七、收尾
从"一张死气沉沉的静态卡"到"会破框、会撒花的互动卡",核心就三件事:
- 声明能力:
module.json5注册liveForm+form_profile.json配sceneAnimationParams; - 打通触发链:卡片
postCardAction→FormExtension调requestOverflow→ 系统加载互动页面; - 守住系统边界:时长 ≤ 3500ms、比例 ≤ 1.5、单边溢出 ≤ 36px,且
loadContent必须同步。
剩下的,就是给鸭子选一张可爱的 PNG、调一调撒花的角度,让用户在摸鱼间隙会心一笑。
更多推荐



所有评论(0)