〖共创稿事节〗HarmonyOS 7 新特性实战(53):摇一摇之后卡片怎么长出那一圈——互动卡片的激活态、出框区与一条从没生效的消息通道
# 〖共创稿事节〗HarmonyOS 7 新特性实战(53):摇一摇之后卡片怎么长出那一圈——互动卡片的激活态、出框区与一条从没生效的消息通道
桌面卡片上有两颗小 chip:一颗写着「刷新」,一颗写着「摇一摇」。前者加进卡片的第一天就在,用户点了没反应;后者是我为了验证场景动效临时加的,只有在模拟器上点得动。
真正查下去才发现,问题不在点击,也不在界面。系统把消息递过来的时候,message 不是我以为的那个字符串,而是整个动作对象的 JSON。message === 'refresh' 这句判断永远为假——这颗按钮从上线起就没通过一次。
这篇沿着这条消息通道往下走:先看系统到底递了什么,再看动态卡片为什么不能用 FormLink,然后拆开场景动效的三层分工、出框区的几何算法,最后记录模拟器上 requestOverflow 返回 801 时,启动器与 AMS 各自的说法,以及那 6 件只能留给真机的事。

一、先承认:那颗「刷新」从上线起就是死的
先把事故讲清楚。卡片的「刷新」走的是 postCardAction 的 message 动作,回到 ShanhaiExploreFormAbility.onFormEvent。原始写法非常直白:
onFormEvent(formId: string, message: string): void {
if (message === 'refresh') { // 永远为假
this.updateFormSnapshot(formId);
}
}
点一次,日志长这样:
I A02026/ShanhaiForm: onFormEvent formId=1874857469 message={"message":"refresh","params":{"message":"refresh"},"action":"message"}
message 是一个 JSON 字符串,里面才有 message 字段。裸比较当然不成立,于是「刷新」这颗 chip 从上线起就没触发过任何刷新——不是这一轮改坏的,是一个潜伏 bug。
顺带说明一下日志里的 A02026:这是我后来改过的 domain。原始值 0x20260630 是 32 位,超出 hilog 允许的 16 位范围,整张卡片的日志被静默丢弃,一条都看不到。查这个 bug 的第一天,我一度以为 onFormEvent 根本没被调用。
| 观察到的现象 | 真实原因 | 结论 |
|---|---|---|
| 点「刷新」无反应 | message 是 JSON,裸比较恒假 | 潜伏 bug,与本次改动无关 |
hilog 一条都不输出 | domain 0x20260630 超出 16 位 | 越界 domain 被静默丢弃 |
| 卡片内容不更新 | 回调没进来 | 与刷新链路无关,是日志骗了人 |
二、FormLink 只服务静态卡片
修消息通道之前,还有一个更前置的限制。场景动效要求 form_config.json 里 isDynamic: true,而 FormLink 的声明文件写得很清楚:它只能用在静态卡片(static widgets)里。两者互斥。
所以卡片上的所有动作都换成了 postCardAction,统一回到 FormExtensionAbility:
/** message 动作回到哪个 FormExtensionAbility,必须与卡片配置里注册的 Ability 名一致。 */
const FORM_ABILITY_NAME: string = 'ShanhaiExploreFormAbility';
private sendCardAction(action: string, params: CardMessageParams | CardRouterParams): void {
postCardAction(this, {
action: action,
abilityName: FORM_ABILITY_NAME,
params: params
});
}
// 「刷新」
Text('刷新').onClick(() => {
this.sendCardAction('message', { message: 'refresh' });
})
// 「摇一摇」:模拟器上唯一能手动复现出框请求的入口
Text('摇一摇').onClick(() => {
this.sendCardAction('message', { message: 'overflow' });
})
两颗 chip 走同一个动作类型,只是 params.message 不同。这样卡片侧的代码只有一处,分支全部落在 Ability 里。
| 卡片形态 | 配置 | 可用动作接口 | 谁在管生命周期 |
|---|---|---|---|
| 静态卡片 | isDynamic: false | FormLink / postCardAction | FormExtensionAbility |
| 动态卡片 | isDynamic: true | 只能 postCardAction | FormExtensionAbility |
| 场景动效(激活态) | isDynamic: true + sceneAnimationParams | 由系统创建与销毁 | LiveFormExtensionAbility |
把 isDynamic 改成 true 还带来一个副作用:卡片进入动态模式后,刷新不再是「系统按时拉取」,而是「应用主动推」。配置里 updateEnabled 保持 false,每一次内容变化都由 updateFormSnapshot 主动调用 updateForm 推下去——这条路在静态时期就已经存在,只是动态模式下它的重要性被放大了,因为系统不会再替你兜底。
三、系统递过来的是整个动作的 JSON
把消息归一化是这次修复的全部内容。优先取 JSON 里的 message 字段,解析失败再按裸字符串兜底——因为不同系统版本的行为没有文档保证,只能两种都认:
interface FormEventMessage {
message?: string;
}
onFormEvent(formId: string, message: string): void {
hilog.info(LOG_DOMAIN, LOG_TAG,
'onFormEvent formId=%{public}s message=%{public}s', formId, message);
const event: string = this.resolveFormEventMessage(message);
if (event === REFRESH_EVENT) {
this.updateFormSnapshot(formId);
return;
}
if (event === OVERFLOW_EVENT) {
this.requestOverflow(formId);
}
}
private resolveFormEventMessage(raw: string): string {
try {
const parsed: FormEventMessage = JSON.parse(raw) as FormEventMessage;
if (typeof parsed.message === 'string' && parsed.message.length > 0) {
return parsed.message;
}
} catch (err) {
// 不是 JSON:按裸字符串处理(其他系统版本可能直接递字符串)
}
return raw;
}
改完之后再点「刷新」,日志出现 Resolved form 1874857469 rect 158 x 158, wide=false,链路走到 updateFormSnapshot。修复只用了一个 JSON.parse,但前提是先把「系统递什么」这件事当成未知量去测,而不是当成已知量去假设。

| 递入形态 | 例子 | 旧代码 | 新代码 |
|---|---|---|---|
| 裸字符串 | refresh | 命中 | 兜底命中 |
| 动作 JSON | {"message":"refresh","params":{...},"action":"message"} | 漏掉 | 解析后命中 |
四、三层分工:谁来画那一圈
场景动效不是「让卡片动起来」这么简单,它是三个角色接力:
form_config.json里的sceneAnimationParams声明触发方式(这里只有shake);- 系统的
LiveFormExtensionAbility负责激活态,在出框成功后创建; FormExtensionAbility负责常驻态,并在合适的时机请求出框。
| 角色 | 类型 | 回调 | 负责什么 |
|---|---|---|---|
ShanhaiExploreFormAbility | form | onAddForm / onUpdateForm / onFormEvent | 桌面常驻卡片、按快照刷数据、发起出框请求 |
ShanhaiExploreLiveFormAbility | liveForm | onLiveFormCreate / onLiveFormDestroy | 激活态宿主,挂载动效页 |
EntryAbility | UIAbility | onCreate / onNewWant | 卡片深链回到应用内页面 |
激活态宿主做的事很少:把系统给的几何与快照塞进 LocalStorage,然后 loadContent 挂页面:
onLiveFormCreate(liveFormInfo: LiveFormInfo, session: UIExtensionContentSession): void {
hilog.info(LOG_DOMAIN, LOG_TAG,
'onLiveFormCreate formId=%{public}s rect=%{public}d x %{public}d @ (%{public}d,%{public}d) radius=%{public}d',
liveFormInfo.formId, liveFormInfo.rect.width, liveFormInfo.rect.height,
liveFormInfo.rect.left, liveFormInfo.rect.top, liveFormInfo.borderRadius);
const storage: LocalStorage = new LocalStorage();
storage.setOrCreate('formId', liveFormInfo.formId);
storage.setOrCreate('rectLeft', liveFormInfo.rect.left);
storage.setOrCreate('rectTop', liveFormInfo.rect.top);
storage.setOrCreate('rectWidth', liveFormInfo.rect.width);
storage.setOrCreate('rectHeight', liveFormInfo.rect.height);
storage.setOrCreate('cardRadius', liveFormInfo.borderRadius);
this.service.loadSnapshot().then((snapshot) => {
storage.setOrCreate('formData', toFormData(snapshot, liveFormInfo.rect));
this.loadLiveFormPage(session, storage);
}).catch(() => {
storage.setOrCreate('formData', createInitialShanhaiExploreFormData(
liveFormInfo.rect.width > liveFormInfo.rect.height));
this.loadLiveFormPage(session, storage);
});
}
rect 是系统给的动效渲染区,比卡片宫格本身大一圈——出框元素要有地方画,靠的就是它。borderRadius 是卡片圆角(vp),出框元素的圆角必须跟着它,否则接缝处会露馅。
五、出框区:以卡片左上角为原点的一圈
formProvider.requestOverflow 接收一个 OverflowInfo,其中 area 的坐标原点是卡片的左上角,left / top 取负值就是向卡片外扩,宽高按卡片尺寸按比例放大:
const OVERFLOW_LEFT_RATIO: number = 0.1;
const OVERFLOW_TOP_RATIO: number = 0.15;
const OVERFLOW_WIDTH_RATIO: number = 1.2;
const OVERFLOW_HEIGHT_RATIO: number = 1.3;
const OVERFLOW_DURATION: number = 1200; // 合法区间 [0, 3500] 的整数
private async requestOverflow(formId: string): Promise<void> {
const size = await this.resolveFormSize(formId);
if (size === undefined) { return; }
const overflowInfo: formInfo.OverflowInfo = {
area: {
left: Math.round(-OVERFLOW_LEFT_RATIO * size.width),
top: Math.round(-OVERFLOW_TOP_RATIO * size.height),
width: Math.round(OVERFLOW_WIDTH_RATIO * size.width),
height: Math.round(OVERFLOW_HEIGHT_RATIO * size.height)
},
duration: OVERFLOW_DURATION,
useDefaultAnimation: false
};
try {
await formProvider.requestOverflow(formId, overflowInfo);
} catch (error) {
hilog.error(LOG_DOMAIN, LOG_TAG, 'requestOverflow failed: %{public}s', JSON.stringify(error));
}
}
158×158 的卡片代进去:190 × 205 @ (-16, -24),与日志逐位一致。
难点不在公式,在 width 从哪来。加桌时 onUpdateForm 的 wantParams 并不保证带尺寸键:当前 API 26 模拟器只递 8 个参数(form_location、update_form_reason、form_connect_id 等),既没有官方示例里的 form_width_vp,也没有 FormParam 里的 form_width。所以代码两套键名都认,取不到才退回 getFormRect:
| 尺寸来源 | 取值 | 注意 |
|---|---|---|
onUpdateForm 的 wantParams | form_width_vp / form_width | 系统不一定递,递了才用 |
formProvider.getFormRect | 屏幕坐标下的占位尺寸 | 含桌面间距,2×4 实测 366.4×160.8(比值 2.28,不是 2) |
getFormRect 只能兜底,不能反推卡片比例。真正可靠的几何只有一个来源:激活态里的 LiveFormInfo.rect。
六、激活态页面:根容器不能裁
动效页最容易踩的一脚是「顺手加个 clip」。非激活态卡片为了圆角干净,根容器普遍会裁切;但激活态要画的恰恰是卡片外面那一圈,一裁就全没了:
@Entry
@Component
struct ShanhaiExploreLiveFormCard {
@LocalStorageProp('rectWidth') rectWidth: number = 320;
@LocalStorageProp('rectHeight') rectHeight: number = 320;
@LocalStorageProp('cardRadius') cardRadius: number = 22;
@State reveal: number = 0;
onAppear(): void {
animateTo({ duration: 900, curve: Curve.EaseOutCubic }, () => {
this.reveal = 1;
});
}
build() {
Stack() {
this.OutOfFrameDecor(); // 灵纹与兽影,允许画到渲染区边缘之外
this.CardBody() // 宽度取 rect - 8,至少留 4vp 的出框落差
}
// 注意:这里不写 .clip(true)
}
}
具体取值:本体宽度取 rectWidth - 8,保证四周至少各留 4vp 的出框落差;rect 本身已经由系统相对卡片宫格外扩过一层,所以出框元素不需要自己再算外扩,只要别被裁掉。
还有一处时长要配平:出框请求的 duration 是 1200ms,激活态页面的入场动画是 900ms。前者决定系统把渲染区留给你多久,后者决定元素自己从 0 到 1 的进度。两者不必相等,但入场必须早于出框结束,否则元素还没露头,渲染区已经收回去。useDefaultAnimation: false 则是把这段节奏完全交给页面自己,不和系统默认曲线叠加。

七、801 不是参数错误
模拟器上点「摇一摇」,全链路是通的,最后一步被能力挡住:
onFormEvent formId=1874857469 message={"message":"overflow",...}
resolveFormSize fallback getFormRect 158 x 158
requestOverflow formId=1874857469 card=158 x 158 area=190 x 205 @ (-16,-24)
requestOverflow failed formId=1874857469: {"code":801}
801 在 SDK 里的原文是 Capability not supported:设备侧没有这个能力。启动器日志给了旁证——LiveFormCommonUtil --> device is not support scene animation form。同一个时间窗里,点卡片内容行走 router 深链也起不来应用:AMS 报 startAbility with specified token error:2097202,随后没有任何 EntryAbility 启动记录。
三条日志互相印证,说明这不是参数写错,也不是坐标算错:
- 消息通道通:
postCardAction→onFormEvent到达; - 几何算对:
area与公式逐位相符; - 能力缺失:出框请求被设备拒绝,启动器自己也知道。
于是这一轮能验的和不能验的,被清楚地分成两栏:
| 模拟器已通过(有日志) | 待真机(模拟器做不了) |
|---|---|
| 消息通道与 JSON 归一 | 系统级摇一摇触发 |
| 「刷新」潜伏 bug 修复 | requestOverflow 真正播放出框动效 |
area 计算与调用发出 | 激活态 loadContent 的路径基准 |
| 2×2 卡片上桌与渲染 | 卡片 router 深链拉起应用 |
| — | isDynamic: true 下的刷新行为差异 |
| — | 2×4 宽卡片的 chip 与深链点测 |

八、先丢日志,再丢时间
前面提过 hilog domain 的事故,单独拎出来说,因为它的代价不是多打几行字,而是整轮排查方向被带偏:
// ❌ 0x20260630 是 32 位,超出 [0x0, 0xffff],日志被静默丢弃
const LOG_DOMAIN: number = 0x20260630;
// ✅ 16 位
const LOG_DOMAIN: number = 0x2026;
越界时没有任何警告,应用照常运行,只是这张卡片的日志一条都不出现。第一次查「刷新」为什么没反应时,看到的是空日志,于是先怀疑 postCardAction 没发出去、再怀疑 Ability 没注册,绕了一圈才回到「是不是日志本身没打出来」。
同样的静默还出现在另一处:-compatibleVersion 写错时,签名工具仍然打印 success,但签名块根本没写进包里,直到验签报 signature not found 才暴露。两件事的共同点是——工具说成功,不等于副作用发生了,唯一可靠的办法是回读结果。
九、真机上要回答的四个问题
模拟器能力边界之外,还有四件事只有真机能给出答案。代码里已经把它们全部做成可观察量,不需要靠猜:
| 待回答 | 怎么观察 | 为什么现在不敢写死 |
|---|---|---|
loadContent 的路径基准 | 激活态是否正常渲染、日志是否打到 loadContent done | 官方示例是相对 src/main 的路径,本项目写法尚未实测 |
LiveFormInfo.rect 与 getFormRect 是否同一套坐标 | 两组日志并排比对 | 后者实测含桌面间距,前者是渲染区,混用会让出框偏移 |
| 摇一摇时系统递什么参数 | onUpdateForm 已埋全量 wantParams JSON 日志 | 声明文件没写带什么键,只能靠实测问出来 |
router 深链与 2×4 宽卡片 | 四个入口逐个点、宽卡片单独截图 | 模拟器上 router 已被 AMS 挡掉,不能当作结论 |
这四条都不需要改代码就能采集,只需要一次真机连接:摇一次、点四次、截两张图,日志会自己把答案交出来。
十、收尾:把能力探测写进代码,而不是写进文案
这一轮最值钱的产出不是那张会动的卡片,而是三件可复用的东西:
- 消息归一:系统递什么就按什么解析,解析失败再兜底,不用「应该是什么」写判断;
- 能力分层:能跑的先跑通并留日志,跑不动的明确标注成因(
801、启动器判定、AMS 拒绝),不混进「已完成」; - 副作用回读:签名、日志、出框,凡是工具声称成功的动作,都要再从结果侧读一遍。
卡片上的两颗 chip 现在都在做正确的事。至于那一圈会不会真的长出来,等真机接上,日志会给出答案——requestOverflow ok,或者另一个错误码。
官方参考:场景动效类型互动卡片开发指导(核对日期 2026-10-25)
更多推荐
所有评论(0)