# 〖共创稿事节〗HarmonyOS 7 新特性实战(53):摇一摇之后卡片怎么长出那一圈——互动卡片的激活态、出框区与一条从没生效的消息通道

桌面卡片上有两颗小 chip:一颗写着「刷新」,一颗写着「摇一摇」。前者加进卡片的第一天就在,用户点了没反应;后者是我为了验证场景动效临时加的,只有在模拟器上点得动。

真正查下去才发现,问题不在点击,也不在界面。系统把消息递过来的时候,message 不是我以为的那个字符串,而是整个动作对象的 JSON。message === 'refresh' 这句判断永远为假——这颗按钮从上线起就没通过一次。

这篇沿着这条消息通道往下走:先看系统到底递了什么,再看动态卡片为什么不能用 FormLink,然后拆开场景动效的三层分工、出框区的几何算法,最后记录模拟器上 requestOverflow 返回 801 时,启动器与 AMS 各自的说法,以及那 6 件只能留给真机的事。

加桌后的 2×2 卡片:两颗 chip 与底部四个入口

一、先承认:那颗「刷新」从上线起就是死的

先把事故讲清楚。卡片的「刷新」走的是 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: falseFormLink / postCardActionFormExtensionAbility
动态卡片isDynamic: true只能 postCardActionFormExtensionAbility
场景动效(激活态)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,但前提是先把「系统递什么」这件事当成未知量去测,而不是当成已知量去假设。

消息通道:从 chip 到归一分流

递入形态例子旧代码新代码
裸字符串refresh命中兜底命中
动作 JSON{"message":"refresh","params":{...},"action":"message"}漏掉解析后命中

四、三层分工:谁来画那一圈

场景动效不是「让卡片动起来」这么简单,它是三个角色接力:

  • form_config.json 里的 sceneAnimationParams 声明触发方式(这里只有 shake);
  • 系统的 LiveFormExtensionAbility 负责激活态,在出框成功后创建;
  • FormExtensionAbility 负责常驻态,并在合适的时机请求出框。
角色类型回调负责什么
ShanhaiExploreFormAbilityformonAddForm / onUpdateForm / onFormEvent桌面常驻卡片、按快照刷数据、发起出框请求
ShanhaiExploreLiveFormAbilityliveFormonLiveFormCreate / onLiveFormDestroy激活态宿主,挂载动效页
EntryAbilityUIAbilityonCreate / 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 的 wantParamsform_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 则是把这段节奏完全交给页面自己,不和系统默认曲线叠加。

出框区几何:158×158 卡片对应 190×205 的外扩区域

七、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 与深链点测

卡片细节:两颗 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)

Logo

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

更多推荐