HarmonyOS 互动卡片实战:破框动效、系统裁决与省电模式的坑
事情是这么开始的。我们项目里有个记账卡片,摆桌面上就显示个金额,四四方方一张图,点开进应用。某天产品拿着手机过来,给我看了一段别人的视频——桌面上一个睡眠卡片,点一下,整个卡片像活了一样破框飞出来,上面有个人偶在翻滚睡觉,动画流畅得不像卡片能干出来的事。然后他看着我:“咱的钱袋卡能不能也来一下?”
我说卡片就是个静态快照,哪来的动画。他不服,非说人家能做到。后来我去翻了鸿蒙的文档才知道,还真有这功能——互动卡片,官方英文叫 Live Form,是 Form Kit 里比较新的一套东西。简单说就是卡片可以有个"激活态",点下去之后卡片突破自身边界播放动画,动画内容由一个专门的 ExtensionAbility 来承载,能放序列帧、能播视频、能做各种花活。

听起来很美好对吧。我当时的想法是照着官方开发指导抄一遍就完事,撑死半天。结果这个功能前前后后折腾了我好几天,中间有两次我一度以为是自己代码写错了,疯狂怀疑人生,最后一次才发现根因压根不在代码上——是手机开着省电模式,系统直接把动效请求拒了,错误日志里藏了一行不起眼的提示。
这篇文章就把整个过程从头到尾写下来,包括链路怎么搭、配置怎么填、动画怎么做,以及那几个让我血压升高的坑。如果你也打算给鸿蒙卡片加动效,建议先看完再动手,能省不少时间。
互动卡片到底是个什么东西
先纠正一个直觉。传统的鸿蒙服务卡片(也就是普通 ArkTS 卡片)本质上是非激活态的——它是一张静态快照,由卡片框架定期刷新,能做的交互基本只有 postCardAction 那几种:跳页面、发消息、调 router。卡片自己没有生命周期,没有 UI 上下文,更别提动画了。
互动卡片在这之上加了一个激活态。这个设计挺有意思的,它把"破框"这个动作拆成了两段:
第一段是申请破框。卡片还在原地,但当用户点击卡片时,你的 FormExtensionAbility 会收到一个消息事件,然后你在这个事件里调用 formProvider.requestOverflow,告诉系统:“我要一块比卡片本体大的区域,时长多少毫秒,帮我撑开”。
第二段是系统拉起激活态。如果申请通过,系统会把卡片周围那块区域临时让给你的应用,并启动一个 type: "liveForm" 的 ExtensionAbility——这玩意儿有完整的 UI 渲染能力,你可以 loadContent 一个 ArkUI 页面进去,随便画。动画播完或者用户点了其他地方,系统收回区域,激活态销毁,卡片回到快照状态。
你可以这么理解:非激活态是卡片的"照片",激活态是卡片的"视频",点击那一刻系统帮你从照片切到了视频,并且允许视频超出照片的相框范围播放。
有个限制得提前知道:这功能对系统版本有硬要求,我们实测环境是 HarmonyOS 6.1.0(API 23)的真机,更早的版本上这套 API 存在性都成问题。另外示例仓库的 README 里明确写了约束——设备省电模式下动效会被禁止,这个后面踩坑部分细说,它坑了我整整一个下午。
整体链路:一次点击发生了什么
把整条链路捋清楚是动手前最重要的事,因为这条链上任何一环断了,表现都是"没反应"或者"没动效",特别难排查。
一次完整的点击流程是这样的:
- 用户点击桌面卡片,卡片页面里注册的
postCardAction发出一条message动作,目标是你的 FormExtensionAbility(一般叫 EntryFormAbility)。 - EntryFormAbility 的
onFormEvent回调收到消息,解析出意图(比如requestOverflow),然后调用formProvider.getFormRect(formId)拿到卡片本体的尺寸,算出破框区域的坐标和大小。 - 调用
formProvider.requestOverflow(formId, overflowInfo)向系统申请。注意这一步是异步的,而且会被系统裁决——不是你申请了就一定给。 - 申请通过后,系统根据 form_config.json 里
sceneAnimationParams.abilityName的配置,去启动对应的 LiveFormExtensionAbility。 - 激活态的
onLiveFormCreate被调用,拿到LiveFormInfo(里面有卡片尺寸、圆角这些关键数据)和UIExtensionContentSession,用session.loadContent加载动画页面。 - 动画页面渲染,开始播放。播完或用户退出,系统调用
onLiveFormDestroy,收回区域。
注意第 3 步和第 4 步之间隔着一次系统裁决。我当时想当然地以为申请了就会激活,结果测试的时候死活不出来动画,日志都没得看——因为压根没走到第 4 步,第 3 步就挂了。这就是为什么排查这类问题一定要看 hilog,光盯着 UI 猜是猜不出来的。
配置文件:三处一个都不能少
互动卡片的配置比普通卡片多了一层,我列一下我们最终跑通的完整配置,三处地方要动。
form_config.json 里声明卡片
普通卡片和互动卡片在同一个文件里声明,区别是多了 sceneAnimationParams:
{
"forms": [
{
"name": "livewidget",
"displayName": "$string:live_card_name",
"description": "$string:live_card_desc",
"src": "./ets/widget_live/pages/LiveCard.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"fitInArea": true,
"formConfigAbility": "ability://EntryAbility",
"cardType": "SELF",
"type": "JS",
"supportShapes": ["rect"],
"isDynamic": true,
"transparencyEnabled": false,
"sceneAnimationParams": {
"abilityName": "LiveFormAbility"
},
"supportDimensions": ["2*2"],
"defaultDimension": "2*2",
"updateEnabled": true,
"scheduledUpdateTime": "06:00",
"updateDuration": 1
}
]
}
两个点是必须的:isDynamic 要为 true,这是动态卡片的开关,静态卡片玩不了互动动效;sceneAnimationParams.abilityName 指向承载激活态的那个 ability,系统拉起激活态靠的就是这个名字,写错或者漏了,点击之后申请成功了也不会有动画,卡片就呆在那里。
另外官方示例里还有个 triggerTypes 字段(比如 ["shake"],摇一摇触发),那是给场景触发型互动卡片用的。我们做的是场景动效类型,靠点击触发,不需要这个字段。这两种类型别搞混了,文档里是分开讲的两篇。
module.json5 里声明 liveForm 能力
{
"extensionAbilities": [
{
"name": "LiveFormAbility",
"srcEntry": "./ets/liveformability/LiveFormAbility.ets",
"description": "$string:live_form_ability_desc",
"type": "liveForm",
"exported": false
}
]
}
type 必须是 "liveForm",这跟普通的 "form"、"entry" 是不同的类型。我见过有人图省事直接复制 EntryAbility 改个名,type 忘改,然后死活不激活——系统按 type 分发,type 不对链路就断在半路。
main_pages.json 注册激活态页面
激活态的页面路径要注册进去,这个跟普通页面一样:
{
"src": [
"pages/Index",
"liveformability/pages/LiveFormPage"
]
}
漏了这个的报错是页面加载失败,loadContent 抛异常,但激活态窗口可能已经拉起来了,你会看到一个空白的小窗,特别迷惑。别问我为什么知道,问就是踩过。
卡片侧:把点击意图发出去
非激活态卡片页面的写法和普通卡片没区别,关键在点击事件:
// widget_live/pages/LiveCard.ets 非激活态卡片
// 注意 postCardAction 是卡片页面里的全局函数,不用 import,直接就能调
@Entry
@Component
struct LiveCard {
build() {
Column() {
Text('¥').fontSize(28).fontWeight(FontWeight.Bold)
Text('本月支出').fontSize(12).opacity(0.8)
Text('¥ 3,286.50').fontSize(20).fontWeight(FontWeight.Medium)
Text('点击查看动效').fontSize(10).opacity(0.6)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
// widthRatio/heightRatio/duration 都带上,激活态和 FormAbility 都要用
postCardAction(this, {
action: 'message',
abilityName: 'EntryFormAbility',
params: {
message: 'requestOverflow',
widthRatio: 1.25,
heightRatio: 1.5,
duration: 3500
}
})
})
}
}
这里有个设计上的选择:破框比例(放大到卡片的几倍)和动画时长,到底在哪一端定?官方 LiveCard 示例的做法是卡片侧定义,通过 params 带过去。我一开始觉得多此一举,FormAbility 里写死不就行了。后来想明白了——同一个卡片可能有多种形态或者多种动效意图,点击不同的区域想放大到不同的尺寸,参数从发起方携带是最灵活的。而且这个设计还有个好处:卡片设计稿出图的时候,设计师就可以对着比例算激活态画面该铺多大,两边的数值天然对齐。
消息参数有 length 限制,别把大数据塞 params 里,传几个数值没问题,传对象序列化的大 JSON 就要掂量了。
FormAbility 侧:计算破框区域并申请
这是整条链路里数学含量最高的一步,也是我和官方文档"打了一架"的地方。
反面教材:文档算法和示例算法打架
先看错误写法,也就是我最初的版本,完全按官方开发指导的公式来的:
// 反面教材:官方指导的不对称扩展算法(在真机上跑不出理想效果)
private requestOverflow(formId: string, formWidth: number, formHeight: number): void {
// 卡片左上角往外扩,left/top 是负的
const left = -Constants.OVERFLOW_LEFT_RATIO * formWidth; // -0.1w
const top = -Constants.OVERFLOW_TOP_RATIO * formHeight; // -0.15h
const width = formWidth * Constants.OVERFLOW_WIDTH_RATIO; // 1.2w
const height = formHeight * Constants.OVERFLOW_HEIGHT_RATIO; // 1.3h
formProvider.requestOverflow(formId, {
area: { left, top, width, height },
duration: Constants.OVERFLOW_DURATION,
useDefaultAnimation: true
}).then(() => {
console.info('requestOverflow success');
}).catch((err: BusinessError) => {
console.error(`requestOverflow error, code: ${err.code}`);
});
}
这段代码编译没问题,逻辑看起来也对,实际跑起来——卡片确实会撑开一块区域,但是歪的。因为这套公式是从卡片左上角往外扩展的,左边扩 0.1 倍、上面扩 0.15 倍、右边扩 0.2 倍,整个破框区域相对卡片是偏右下的。如果你激活态页面里的画面是按"区域和卡片同心"来画的,画面就跟卡片对不齐,视觉上整个动画内容飘走了一截。
后来我把官方 LiveCard 示例仓库的 EntryFormAbility 拉下来对比,发现真实能跑的 demo 用的是另一套算法——居中对称扩展:
// 正确写法:官方示例的居中对称算法
private async requestOverflow(formId: string, widthRatio: number,
heightRatio: number, duration: number): Promise<void> {
try {
const formRect = await formProvider.getFormRect(formId);
// 卡片中心不动,向四周等比扩展,left/top 天然是负偏移
const cardWidth = formRect.width * widthRatio;
const cardHeight = formRect.height * heightRatio;
const leftOffset = (formRect.width - cardWidth) / 2;
const topOffset = (formRect.height - cardHeight) / 2;
// 比例做个上限保护,防止 params 被塞了离谱的值
formProvider.requestOverflow(formId, {
area: {
left: leftOffset,
top: topOffset,
width: cardWidth,
height: cardHeight
},
duration: duration
}).catch((err: BusinessError) => {
console.error(`requestOverflow error, code: ${err.code}, message: ${err.message}`);
});
} catch (err) {
const e = err as BusinessError;
console.error(`getFormRect error, code: ${e.code}, message: ${e.message}`);
}
}
区别一目了然:示例算法先算出放大后的宽高,再用 (原尺寸 - 放大尺寸) / 2 算偏移,保证放大区域和卡片中心重合。这样激活态页面只要把画布居中,画面就跟卡片严丝合缝。
还有一个细节差异:示例里没传 useDefaultAnimation。这个参数是让系统播一个默认的过渡动画,我加上它之后反而跟自己页面的动画叠了,观感很怪。按示例的来,不加,让动画完全由你的激活态页面自己控制。
getFormRect 是异步的而且可能抛异常,必须 try/catch 包住。我第一版没包,静态检查直接给我报了 Warning,说这个 API 可能抛异常——工具都能看出来,说明这真是个高频坑。
onFormEvent 里解析消息
onFormEvent(formId: string, message: string): void {
Logger.info(TAG, `onFormEvent, formId: ${formId}, message: ${message}`);
let params: Record<string, Object> = JSON.parse(message) as Record<string, Object>;
const msg = params.message as string;
if (msg === 'requestOverflow') {
// 卡片侧带过来的比例和时长,带默认值兜底
const widthRatio = this.clamp((params.widthRatio as number) ?? 1.25, 1, 1.5);
const heightRatio = this.clamp((params.heightRatio as number) ?? 1.5, 1, 1.5);
const duration = (params.duration as number) ?? 3500;
this.requestOverflow(formId, widthRatio, heightRatio, duration);
}
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
示例里对比例做了 clamp 处理,上限 1.5。这个值得学——卡片 params 属于外部输入,虽然正常情况下只有你自己的卡片在发,但万一哪里手滑传了个 10 倍上去,系统给不给另说,先把入口堵住总是对的。
激活态:LiveFormExtensionAbility 承载动画
Ability 侧把数据塞进 LocalStorage
import { LiveFormExtensionAbility, LiveFormInfo } from '@kit.FormKit';
import { UIExtensionContentSession } from '@kit.AbilityKit';
export default class LiveFormAbility extends LiveFormExtensionAbility {
onLiveFormCreate(liveFormInfo: LiveFormInfo, session: UIExtensionContentSession): void {
let storage: LocalStorage = new LocalStorage();
storage.setOrCreate('context', this.context);
storage.setOrCreate('session', session);
storage.setOrCreate('formId', liveFormInfo.formId);
storage.setOrCreate('borderRadius', liveFormInfo.borderRadius);
storage.setOrCreate('formRect', liveFormInfo.rect);
session.loadContent('liveformability/pages/LiveFormPage', storage);
}
onLiveFormDestroy(liveFormInfo: LiveFormInfo): void {
// 激活态销毁,卡片回到快照状态
}
}
LiveFormInfo 这个类型是从 @kit.FormKit 直接导出的,不在 formInfo 命名空间里。这个坑我后面细说。
liveFormInfo.rect 是卡片本体在屏幕坐标系里的位置和尺寸,borderRadius 是卡片圆角。这两个数据要传给动画页面用。
动画页面:帧动画,别用 animateTo
这里是我本次最大的认知修正。我最初的激活态页面是用 animateTo 写的属性动画,大概长这样:
// 反面教材:animateTo 驱动激活态动画
runOverflowAnimation(): void {
this.uiContext?.animateTo({
duration: 2000,
curve: Curve.Friction,
delay: 200
}, () => {
// 改一堆 @State,期望元素平滑过渡
this.bagScale = 1.6;
this.bagTranslateY = -60;
this.amountOpacity = 0;
});
}
编译通过,页面也正常渲染,就是动画完全没有过渡过程,元素直接跳到终态。钱袋"啪"一下出现在最终位置,中间该有的放大、上飞全没有。
我排查了半天:curve 换了、delay 去了、把动画挪到 onAppear 里触发、缓存了 getUIContext()……全没用。直到把官方示例的激活态页面(SleepLiveCard.ets)逐行看完才反应过来——示例里一处 animateTo 都没有,全部动画用的都是帧动画:
// 正确写法:官方示例的帧动画驱动方式
import { formInfo, formProvider } from '@kit.FormKit';
import { common } from '@kit.AbilityKit';
@Entry({ useSharedStorage: true })
@Component
struct LiveFormPage {
@LocalStorageProp('formRect') formRect?: formInfo.Rect;
@LocalStorageProp('borderRadius') radius?: number;
@LocalStorageProp('formId') formId?: string;
@LocalStorageProp('context') context?: common.LiveFormExtensionContext;
@State currentFrameIndex: number = 0;
private timerId: number = -1;
private startTime: number = 0;
// 总帧数由设计稿给出,路径按序号拼接,别手写一长串数组
private readonly frameCount: number = 84;
private readonly animDuration: number = 3500;
framePath(index: number): string {
return `frames/frame_${index.toString().padStart(3, '0')}.png`;
}
startImageSync(): void {
this.startTime = Date.now();
this.timerId = setInterval(() => {
// 16ms 一跳,约 60fps,按经过时间换算帧索引
const elapsed = Date.now() - this.startTime;
let index = Math.floor(elapsed / (this.animDuration / this.frameCount));
if (index >= this.frameCount) {
index = this.frameCount - 1;
clearInterval(this.timerId);
}
this.currentFrameIndex = index;
}, 16);
}
build() {
Stack({ alignContent: Alignment.Center }) {
// 卡片本体层:和卡片同尺寸同圆角,负责背景过渡
Column()
.width(this.formRect?.width)
.height(this.formRect?.height)
.borderRadius(this.radius ?? 24)
.clip(true)
// 动画层:序列帧,尺寸放大到超出卡片,实现"破框"
Image(this.framePath(this.currentFrameIndex))
.width('130%')
.height('150%')
.onAppear(() => {
// 动画元素出现时启动帧同步,别在 aboutToAppear 里启动
this.startImageSync();
})
}
.onClick(() => {
// 点击退出激活态,系统收回区域
if (this.formId) {
formProvider.cancelOverflow(this.formId);
}
})
.onDisAppear(() => {
// 双保险:页面消失时也取消,并清掉定时器
if (this.formId) {
formProvider.cancelOverflow(this.formId);
}
clearInterval(this.timerId);
})
}
}
这套写法里有几个点值得展开说说。
为什么用 @State + setInterval 驱动。帧动画的本质是每 16ms 换一张图,每次换图就是一次 @State 更新,触发一次重渲染。它不依赖任何动画编排 API,只要 @State 更新能触发 UI 刷新就行——这恰好是激活态环境里最可靠的一条路。至于 animateTo 在这个环境里为什么不工作,官方没给说法,我个人猜测是 UIExtension 的渲染管线对属性动画的支持有限制,反正示例用实际行动告诉了你别用。
.onAppear 挂在动画元素上而不是 aboutToAppear。页面加载完成后动画元素才真正挂到渲染树上,这时候起定时器算时间才是准的。放 aboutToAppear 里的话,从页面生命周期开始到元素真正渲染出来之间有个时间差,开头几帧会被跳过或者卡顿。
动画元素比卡片大。注意 width('130%')、height('150%')——动画画面本身就是超尺寸的,居中铺在破框区域里,超出卡片本体的部分就是"破框"露出来的部分。配合前面 FormAbility 申请的 1.25 倍宽、1.5 倍高的区域,刚好对上。这就是为什么卡片侧、FormAbility 侧、动画页面三处的比例要一致——它们是同一套设计数值在三处的落地,改哪儿都得连着改。
退出时机。示例把 cancelOverflow 同时挂在 onClick 和 onDisAppear 上,双保险。动画播完后系统其实会自己回收(requestOverflow 有 duration),但用户中途点一下就提前退出的体验也要有,不然动画播着播着用户想走都走不掉。
对了,还有一点差点忘了说:如果序列帧资源比较大,记得控制帧数和图片尺寸。示例里的睡眠卡是 84 张序列帧,全是设计师导出的等尺寸 PNG。你要是拿 4K 图硬铺,激活态窗口拉起来那一瞬间内存和渲染压力都不小,真机上可能直接掉帧。
两套算法怎么选
把官方文档和官方示例摆在一起看,你会得到两套互相矛盾的指导,这事我专门总结了一下。
官方开发指导的算法(不对称扩展):
left = -0.1 * w, top = -0.15 * h, width = 1.2 * w, height = 1.3 * h
官方示例的算法(居中对称扩展):
width = ratioW * w, height = ratioH * h
left = (w - width) / 2, top = (h - height) / 2
两套算法本身都能让系统接受申请,区别在激活态页面怎么配。文档那套的配套写法里,激活态页面会拿 rect.left/top 做背景层的 offset 来补偿不对称;示例那套则完全不看 left/top,直接居中。
我的建议是跟示例走。理由很实际:示例是完整可跑、经过真机验证的参考实现,文档的配套代码相对骨架化;居中算法心智负担小——所有东西围绕卡片中心对称展开,不容易算错;而且卡片侧带比例参数的设计天然支持多形态动效。除非你的动效设计就是不对称的(比如动画内容明显偏一侧),否则没必要给自己找麻烦。
踩坑记录
这部分是我这次折腾下来最想写的,每一条都是真实踩到的,附上排查过程。
省电模式:动效被系统静默拒绝
这是最坑的一个,坑了我一整个下午,必须放在第一个说。
现象:一切配置都对,代码和示例逐行比对过,卡片能点、能触发消息、requestOverflow 也调了,但动画永远不出来,卡片点了跟没点一样。而且不打断点根本看不出来哪一环断了。
排查过程:我先怀疑配置,把 form_config.json 和示例的逐字段对比,没毛病。又怀疑是 sceneAnimationParams 的 abilityName 写错,检查也没错。然后把示例的睡眠卡整个复制进项目,构建安装——示例的卡片也没有动效。这一步反而关键,官方原样代码都跑不出来,那就不是我的问题,是环境问题。
最后是抓 hilog 才看到真相:
requestOverflow error, code: 16501000,
message: Device status check failed, possibly due to power saving mode.
16501000,设备处于省电模式,系统禁止播放互动卡片动效。 我的测试机当时电量不太足,开着省电模式跑了好几天。
解决办法简单到离谱:设置里关掉省电模式(或者插上充电器),动画立刻就出来了。当时看到动画那一刻,说实话有点想笑,也可能想骂人。
这个坑的教训是:遇到互动卡片动效不生效,第一件事先抓 hilog 看 requestOverflow 的返回,别上来就跟代码较劲。devecocli log --keyword requestOverflow 或者 hilog | grep overflow 都能抓到。另外这个错误是 .catch 里才有的,如果你调用时没接 catch(或者只写了 then),这个错误就直接吞掉了,你连日志都看不到——所以前面代码里的 .catch 不是装饰,是保命的。
对了,这个坑后来我们还补了一个体验上的收尾。动效被系统拒绝时,用户点了卡片毫无反馈,看上去就像卡片坏了。按理说该弹个吐司提示,但前面说过 FormAbility 是后台组件,拿不到 UIContext,而新的提示接口必须走 UIContext.getPromptAction().showToast()——这条路在 FormAbility 里根本走不通。
最后用的是另一个思路:把提示文案刷到卡片上,显示几秒再刷空复位,效果类似吐司:
// FormAbility 里需要 import { formBindingData, formProvider } from '@kit.FormKit';
// 以及 import { BusinessError } from '@kit.BasicServicesKit';
// 在 requestOverflow 的 .catch 里识别 16501000 后调用
private showFormToast(formId: string): void {
// 文案放 string.json,别硬编码在 FormAbility 里
const message: string =
this.context.resourceManager.getStringByNameSync('live_card_toast_power_saving');
const toastData: Record<string, string> = { 'toastMessage': message };
formProvider.updateForm(formId, formBindingData.createFormBindingData(toastData))
.then(() => {
// 停留几秒后刷空,卡片上的提示随之复位
const timeoutId = setTimeout(() => {
const emptyData: Record<string, string> = { 'toastMessage': '' };
formProvider.updateForm(formId, formBindingData.createFormBindingData(emptyData))
.catch((err: BusinessError) => {
console.error(`clear toast error, code: ${err.code}`);
});
clearTimeout(timeoutId);
}, 3000);
})
.catch((err: BusinessError) => {
console.error(`show toast error, code: ${err.code}`);
});
}
// 卡片侧接收:非空时把底部提示行替换成这段文案
@LocalStorageProp('toastMessage') toastMessage: string = '';
能这么玩的前提是卡片配置了 isDynamic: true,动态卡片的数据才能被 updateForm 实时刷进去——巧了,互动卡片本来就要求动态,正好白捡这个能力。这个"卡片上模拟吐司"的手法不只用于省电模式,任何 requestOverflow 的失败场景都能复用。
LiveFormInfo 的导入路径
接下来说的这个坑是纯类型问题,报错信息还挺有迷惑性:
'formInfo' has no exported member named 'LiveFormInfo'.
Did you mean 'FormInfo'?
我第一反应是写错了命名空间,改成了 formInfo.LiveFormInfo——编译器还提示我"你是不是想写 FormInfo",看起来像是往"对"的方向指。结果改完更错,FormInfo 是另一个东西(普通卡片的信息结构体)。
真实情况是:LiveFormInfo 是 @kit.FormKit 顶层直接导出的接口,跟 LiveFormExtensionAbility 并列,压根不在 formInfo 命名空间里。正确写法:
// 正确
import { LiveFormExtensionAbility, LiveFormInfo } from '@kit.FormKit';
// 错误,LiveFormInfo 不在这个命名空间
import { formInfo } from '@kit.FormKit';
// 然后用 formInfo.LiveFormInfo
这类 Kit 顶层 re-export 的类型,最稳的确认方式是直接看本地 SDK 的 d.ts 文件,DevEco Studio 安装目录下 sdk/default/openharmony/ets/api/ 里翻对应文件,一眼就能看到导出结构。编译器那个 “Did you mean” 的建议别全信,它只是做名字相似度匹配,不管命名空间结构。
变量名撞了 ArkUI 内置属性
激活态页面要把卡片圆角存起来用,我最初起名就叫 borderRadius:
Property 'borderRadius' not assignable to same property in base type 'CustomComponent'
ArkUI 的所有自定义组件都隐式继承了一堆通用属性方法,borderRadius 就是其中之一。@LocalStorageProp('borderRadius') borderRadius 这种写法等于在组件上声明了一个跟内置属性方法同名的状态变量,冲突了。
改成 cardRadius 之类的名字就好。同一个坑还埋着另一个:我顺手把 storage key 也一起改了,结果 Ability 侧存的是 cardRadius、页面读的是 borderRadius,数据对不上,圆角拿的是 undefined,页面表现是圆角丢失但不报错。storage key 和变量名是两件事,改的时候两边都要检查。
rect.left/top 当页面 offset 用,画面错位
这个坑前面提过一嘴,展开说说。liveFormInfo.rect 里的 left/top 是卡片在屏幕坐标系里的位置——比如你的卡片摆在桌面第二行第一列,那 left/top 就是那个格子的屏幕坐标,可能是一千多这种数值。
我一度以为这个 rect 是"相对激活态窗口的坐标",直接拿来给背景层做 offset(x: rect.left, y: rect.top)。效果就是背景层飞出屏幕,只剩动画元素孤零零地飘着,怎么调都不对。
正确的心态是:非对称算法那套配套写法才需要用 rect 的 left/top 做补偿计算;如果用居中对称算法(前面推荐的),rect.left/top 根本不需要用,你只需要 rect.width/height 算出居中布局。系统拉起激活态窗口时已经保证窗口和卡片对齐了,页面内部按窗口中心布局即可。
animateTo 在激活态里没有过渡效果
这个坑的核心内容前面反面教材部分已经讲了,这里补一句排查心路。当时我试过的错误方向包括:怀疑 curve 类型不支持、怀疑 delay 参数问题、怀疑动画要在页面完全渲染后才能触发(所以挪到 onAppear)、怀疑需要缓存 UIContext——每个都试了,每个都没用。真正的教训是:在受限的运行环境里,官方示例用什么你就用什么。示例仓库里一处 animateTo 都没有,全是帧动画,这本身就是答案。我绕了两大圈才读懂这个信号,其实第一眼看到就该警觉。
调试技巧:怎么看这条链路死在哪一环
最后给一套排查流程,按这个顺序走基本能定位所有问题。
先看配置链。form_config.json 的 isDynamic 和 sceneAnimationParams.abilityName、module.json5 的 type: "liveForm"、main_pages.json 的页面注册,三处缺一不可。配置断链的表现是点击无响应或者激活一个空白窗。
配置没问题就抓 hilog 看裁决结果。点击卡片后立刻过滤日志,关键词 requestOverflow:
# 设备连接时抓最近 5 分钟的错误和关键日志
devecocli log --keyword overflow --from 5m --tail 100
看到 code: 16501000 是省电模式;看到 success 但没动画,问题在激活态侧;连日志都没有,说明消息没到 FormAbility,查 postCardAction 的 abilityName。
日志也正常的话,上排除法。把我们后来用的方法直接告诉你:把官方 LiveCard 示例的睡眠卡整套复制进项目构建安装,点它。示例能动而你的不能动,问题锁在你的代码;示例也不能动,问题在环境(省电模式、系统版本、设备不支持)。这个方法比任何推测都快。
最后还有个补日志的位置容易被忽略:onLiveFormCreate 和激活态页面 aboutToAppear 里各打一条,确认激活态到底有没有拉起来。有时候申请成功了、窗口拉起了,但 loadContent 的路径错了,你会看到一个白窗或者什么都不显示,误以为是没激活。
写在最后
回顾这次折腾,互动卡片本身的 API 面不大,链路也就五六个环节,真正的难度在于它是一条多进程协作的链路——卡片进程、FormAbility、系统卡片框架、激活态 UIExtension,任何一环掉链子,表现都一样:没动静。而中间还有一个隐形裁决者(系统状态检查)随时可能把请求打回来。
所以这个功能的开发心得可以浓缩成三句话:链路上每一环都要有日志;比例参数三处(卡片、FormAbility、激活态页面)保持同一套数值;遇到不动先关省电模式。
现在我们那个钱袋卡片点下去,金色的钱袋会从卡片里"撑"出来晃两下,金额数字跟着弹出来。产品看了很满意,说下次要做个"摇一摇红包雨"的版本——我看了眼文档里 triggerTypes 支持 shake,行吧,看来还得继续踩坑。到时候有新坑再写一篇。

更多推荐



所有评论(0)