news 2026/9/24 11:52:44

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
互动卡片实战:摸鱼鸭的“破框动效“是怎么做出来的

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

把一张只能看、不能摸的桌面卡片,变成会顶破屏幕边框、撒花庆祝的小鸭子。
本文记录我在 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

@BuilderDuckHotspot(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())}privaterequestLiveForm():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>{constparams=LiveFormOverflowUtil.parseMessage(message)if(params['message']==='requestOverflow'){returnthis.activateLiveForm(formId,params)// 申请破框}returnthis.computeAndUpdate(formId)// 其他消息走普通刷新}privateasyncactivateLiveForm(formId:string,params:Record<string,Object>):Promise<void>{constok=awaitLiveFormOverflowUtil.requestOverflow(formId,LiveFormOverflowUtil.readNumber(params,'widthRatio',1.45),LiveFormOverflowUtil.readNumber(params,'heightRatio',1.45),LiveFormOverflowUtil.readNumber(params,'duration',2200))this.saveLiveFormData()// 数据落盘,不 await,避免拖慢响应if(!ok)awaitthis.computeAndUpdate(formId)// 不支持时降级为刷新}

注意一个性能细节:先申请破框(用户立刻看到反应),再把数据落盘,而且不await落盘。否则点下去要等几百毫秒才有动静,用户会以为没点上。

5. 互动页面:鸭子顶出 + 撒花

DuckLiveFormAbility.onLiveFormCreate同步loadContent,把卡片信息塞进 payload,系统随后加载DuckLiveCard

onLiveFormCreate(liveFormInfo:LiveFormInfo,session:UIExtensionContentSession):void{constpayload=getDuckLiveFormPayload()payload.rectWidth=liveFormInfo.rect.width payload.rectHeight=liveFormInfo.rect.height// ... 写 formId / cardType / 文案 / 圆角 ...session.loadContent('liveform/pages/DuckLiveCard',newLocalStorage())}

页面里鸭子居中后上移露出上边界,撒花用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到安全区间,并预留余量:

privatestaticreadonlyMAX_RATIO:number=1.5// 比例上限privatestaticreadonlyMAX_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:

privatestaticreadonlyMAX_OVERFLOW_HALF:number=36constareaWidth=Math.min(rect.width*w,rect.width+2*MAX_OVERFLOW_HALF)constareaHeight=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居中布局 + 向上偏移即可:

privateduckOffsetY():number{consthalfCard=this.rectHeight/2consthalfDuck=this.duckSize()/2constreveal=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)){returnthis.activateLiveForm(formId,{})}returnthis.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、调一调撒花的角度,让用户在摸鱼间隙会心一笑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 11:52:26

STM32驱动振镜打标:XY2-100协议与AM26LS31差分信号链详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 11:50:51

CH32X035深度评测:不只是PD诱骗,更是RISC-V电源与电机控制全能MCU

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 11:48:57

AXI UART 16550 IP核配置与中断调试全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 11:48:51

写好Project Change Report:变更管理全流程实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 11:47:05

魔百盒M401A刷Armbian安装Home Assistant Supervised全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 11:46:08

消防安防一体化:执行器智能化联动实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华