互动卡片实战:摸鱼鸭的"破框动效"是怎么做出来的

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


在这里插入图片描述
在这里插入图片描述

一、先讲个痛点:你的卡片是不是也"死"的?

最初我的「摸鱼鸭」桌面卡片长这样:一张 2×2 的小卡,上面写着"周末倒计时 2 天",配一只呆萌的鸭子。

能用,但很无聊。

  • 它不会动;
  • 你戳它,它没反应;
  • 它永远缩在那个方方正正的圆角矩形里,像被框住的囚徒。

我想做的效果是:用户一点卡片上的鸭子,鸭子"嘭"地从卡片里钻出来,顶破边框冲到屏幕上方,还撒一把彩花,1 秒多之后又缩回去。 华为把这种效果叫做"破框动效",技术载体就是 Live Form(互动卡片)

这篇文章要解决的,就是"怎么让这张卡片活过来"。


二、什么是互动卡片(Live Form)?

普通的桌面卡片(FormExtensionAbility)是"静态渲染"——系统把你的 UI 截图成一张图贴在桌面上,你之后只能通过 updateForm 换内容,没法做动画、也没法真正响应手势。

互动卡片(Live Form) 不一样:它能在用户触发时,临时加载一个真正的 ArkUI 页面,这段时间里卡片可以播放动画、响应点击。触发方式有两种:

  1. 手势触发(shake):系统在 module.json5 里声明 sceneAnimationParams,由桌面(如摇一摇、长按等系统手势)自动唤起。
  2. 卡片内主动触发(点击):卡片里的 UI 通过 postCardAction 发一条消息给 FormExtensionAbility,由它去调用 requestOverflow 申请破框。

破框的本质,是系统在不放大卡片本身的前提下,临时在卡片周围多给你一圈"溢出区"(overflow area),让动画内容可以画到卡片边界之外——看起来就像内容"钻"出了卡片。


三、整体链路:一次点击是怎么变成破框动画的

用一句话串起来:

用户点鸭子 → 卡片发 MESSAGEFormExtensionAbility.onFormEvent 收消息 → 调 requestOverflow 申请溢出区 → 系统创建 LiveFormExtensionAbility → 加载 DuckLiveCard 页面 → 鸭子顶出边框 + 撒花 → 主动 cancelOverflow 收起。

代码层面拆分三条角色:

角色文件职责
普通卡片 UIwidgets/SmallCard2x2.ets展示文案 + 鸭子热区,点击发消息
卡片能力formextensions/WeekendFormExtension.ets收消息,申请破框
互动页面liveform/DuckLiveFormAbility.ets + liveform/pages/DuckLiveCard.ets破框后加载的动画 UI

四、手把手实现

1. 注册 LiveForm 能力

module.json5extensionAbilities 里加一条,类型填 liveForm

{
  "name": "DuckLiveFormAbility",
  "srcEntry": "./ets/liveform/DuckLiveFormAbility.ets",
  "type": "liveForm",
  "exported": false
}

2. 给普通卡片声明"手势触发"

在卡片的 form_profile.jsonresources/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=4000ratio=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,但 DuckLiveCardaboutToAppear 根本没打印——页面没真正渲染出来,露出了默认白色底。

两个根因,都修了:

(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,从 onFormEventrequestOverflowonLiveFormCreateDuckLiveCard aboutToAppear 一路追。
  • 看系统为什么拒绝破框:看 com.ohos.sceneboard / FORM 相关日志,会直接打印 out of rangearea out of range 这类原因。
  • 改了 sceneAnimationParams 或动画 UI 后,删卡重加再测。

七、收尾

从"一张死气沉沉的静态卡"到"会破框、会撒花的互动卡",核心就三件事:

  1. 声明能力module.json5 注册 liveForm + form_profile.jsonsceneAnimationParams
  2. 打通触发链:卡片 postCardActionFormExtensionrequestOverflow → 系统加载互动页面;
  3. 守住系统边界:时长 ≤ 3500ms、比例 ≤ 1.5、单边溢出 ≤ 36px,且 loadContent 必须同步。

剩下的,就是给鸭子选一张可爱的 PNG、调一调撒花的角度,让用户在摸鱼间隙会心一笑。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐