前言
给图图素素加桌面卡片时,我想做的不只是一个打开应用的入口:用户处理完图片,回到桌面就能看到最近一张作品;触发互动后,再用一段像素块生长动画回应用户。
第一版看起来已经跑通了。requestOverflow 返回成功,激活态页面也加载了,可连续截图一看,中心像素块始终停在卡片里面。原因不在手机,而在我们把上移距离写死成了 46vp——申请到更大的画布,不等于内容会自动飞出边框。
下面这张是普通展示状态。当前实现会在激活时切换到一个 3×3 的抽象像素方阵,并不是把用户原图拆成九块。这篇就从这个具体效果出发,说明配置、数据传递、触发链路和出框位移该怎么写。

互动卡片能做什么
先把版本说清楚:互动卡片并不是 HarmonyOS 7 才出现的能力。LiveFormExtensionAbility、requestOverflow 和 getFormRect 从 API 20 起提供;这里用到的 sceneAnimationParams.triggerTypes: ["shake"] 则是 HarmonyOS 7 / API 26 的能力。
在这个案例中,我们用了三件事:
- 普通状态展示作品:应用把图片写入文件,通过 Form Kit 的图片数据通道更新桌面卡片。
- 激活状态播放动效:提供方申请一个宽、高均为原卡片 1.4 倍的区域,激活态页面在里面绘制像素方阵。
- 点击回到应用:普通卡片通过
postCardAction的router动作跳转;激活态通过startAbilityByLiveForm跳转。
卡片右下角的「摇一摇」文字同时是一个可点击入口。点击它走消息事件,不是模拟手机传感器摇动。除了用按钮检查调用链,我也在 Mate 70 Pro 上亲手摇动手机,观察到了互动卡片效果。这是对物理摇动入口的直接观察,不是从按钮测试推导出来的结论。
不点卡片,摇动手机看看
这个示例的使用场景很简单:将图图素素的「像素画」卡片放在桌面,停留在能看到卡片的页面,手持手机摇动,而不是点击卡片上的按钮。我在真机上这样操作后,看到了互动效果。
从画面上理解,就是普通作品展示切换为像素方阵,中心像素向上越过原卡片边缘。它不是打开一个全屏应用,也不是像素飞到手机屏幕外;破框发生在桌面卡片与它周围的区域之间。

需要注意的是,破框扩大的是绘制区域,不是点击区域。浮到原卡片边界之外的部分只负责展示,不响应触摸;要点击进入应用,应点原卡片范围内的内容。
不适合把它当作常驻小窗口或连续编辑器:本例使用的是场景动效类型,播放时间有限,桌面操作也可能打断动效;省电、温度、宿主状态及机型能力都可能限制请求。需要持续交互的业务,仍应放在应用页面里,而不是依靠反复请求动效维持界面。
接口与约束
关键接口
| 接口或配置 | 用途 | 需要分清的地方 |
|---|---|---|
formProvider.getFormRect(formId) |
取得卡片位置和尺寸 | 返回相对屏幕左上角的 Rect,单位 vp |
formProvider.requestOverflow(formId, overflowInfo) |
申请场景动效 | 返回 Promise<void>,不会返回一个新的 area |
LiveFormExtensionAbility.onLiveFormCreate() |
创建激活态页面 | 在回调里同步调用 session.loadContent() |
LiveFormInfo.rect |
对齐原卡片与激活态页面 | 是原卡片相对激活画布的位置,不是屏幕坐标 |
formProvider.updateForm() |
更新普通卡片数据 | 接收 FormBindingData,不代表动效已经启动 |
postCardAction() |
普通卡片发送动作 | 本例使用 router 和 message,不是完整动作列表 |
startAbilityByLiveForm() |
激活态跳转到应用 | 通过 LiveFormExtensionContext 调用 |
sceneAnimationParams.triggerTypes |
声明场景触发方式 | 本例配置 shake,需要 API 26 对应能力 |
本例采用的范围
| 项目 | 约束或取值 |
|---|---|
| 卡片位置 | 支持场景动效的桌面卡片;不能把预览页等同于桌面实测 |
| 卡片尺寸 | 本例仅声明 2×2,不推广到其他尺寸 |
| 申请区域 | 包含原卡片,位于允许的居中外扩范围内;2×2 的宽、高上限为原来的 1.5 倍 |
| 实际申请 | 宽、高各 1.4 倍,四周各预留原尺寸的 20% |
| 请求时长 | OVERFLOW_DURATION = 2600ms,不超过 3500ms 上限 |
| 页面动画 | GROW_DURATION = 2400ms,这是自绘动画时长,与请求参数不是同一个值 |
| 系统过渡 | useDefaultAnimation: true;负责状态切换,不替我们计算像素的出框轨迹 |
| 并发和中断 | 全局同时只播放一张;其他桌面操作可能终止动效 |
排查时用到的错误码
| 错误码 | 含义 | 排查方式 |
|---|---|---|
801 |
当前设备能力不支持相关操作 | 保留普通卡片展示,不反复请求动效 |
16501000 |
内部功能错误,可能伴随设备或宿主状态检查失败 | 同时看 message,不能只凭错误码断言省电模式 |
16501001 |
操作的卡片 ID 不存在 | 检查卡片实例是否已移除或失效 |
16501011 |
该卡片不支持此操作 | 检查卡片类型、场景动效配置和当前操作 |
实际日志里出现过 16501000 Form Host status check failed.,也出现过更笼统的内部错误提示。它们不能统一解释为“手机不支持”或“开了省电”。这次桌面按钮触发得到的是成功结果。
开发步骤
以下代码是当前工程的关键节选
第 1 步:把普通卡片与激活态入口连起来
form_config.json 的关键配置如下:
{
"name": "PixelCard",
"src": "./ets/widget/pages/PixelCard.ets",
"uiSyntax": "arkts",
"defaultDimension": "2*2",
"supportDimensions": ["2*2"],
"isDynamic": true,
"sceneAnimationParams": {
"abilityName": "PixelLiveFormExtensionAbility",
"triggerTypes": ["shake"]
}
}
这段放在 forms 数组中。还要在 module.json5 里分别声明 type: "form" 的 EntryFormAbility 和 type: "liveForm" 的激活态扩展,前者通过 ohos.extension.form 元数据关联 $profile:form_config。
激活态扩展的声明节选:
{
"name": "PixelLiveFormExtensionAbility",
"srcEntry": "./ets/widget/PixelLiveFormExtensionAbility.ets",
"label": "$string:form_label",
"description": "$string:liveform_desc",
"type": "liveForm"
}
同时,main_pages.json 的 src 中包含 widget/pages/PixelLiveFormPage。只有配置入口,没有对应的页面注册和实现,链路仍不完整。
配置完成后,用户可以从卡片中心找到图图素素的「像素画」卡片,通过预览页的「添加至桌面」入口放到桌面。

第 2 步:刷新卡片,并区分节点复用
EntryFormAbility 是卡片提供方,负责返回和更新卡片数据,不是绘制 PixelCard 的系统渲染服务。
onUpdateForm 会由多种更新原因触发。代码先发起数据更新,再检查原因,跳过 FORM_NODE_REUSE = 0 的节点复用:
const UPDATE_REASON_KEY = 'ohos.extra.param.key.update_form_reason';
const FORM_NODE_REUSE = 0;
onUpdateForm(formId: string, wantParams?: Record<string, Object>): void {
formProvider.updateForm(formId, buildCardData(this.context))
.catch((err: BusinessError) => {
hilog.warn(DOMAIN, TAG, 'onUpdateForm failed: %{public}s', `${err?.code}`);
});
const reason = wantParams?.[UPDATE_REASON_KEY] as number | undefined;
hilog.info(DOMAIN, TAG, 'onUpdateForm formId=%{public}s reason=%{public}s', formId, `${reason}`);
if (reason === FORM_NODE_REUSE) {
return;
}
void this.requestOverflow(formId);
}
这里有两个容易误读的地方:一是 updateForm 已经发起,但没有等待它完成再请求动效;二是 reason !== 0 不等于“确认用户摇了手机”。普通更新也可能进入这条路径,不能拿它统计摇一摇次数。
原因键对应 Form Kit 的更新原因参数,并不是未公开的“隐藏字段”。当前工程兼容版本仍是 API 23,而 FormUpdateReason 枚举从 API 24 提供,因此现有实现使用字符串键和数值进行判断。
第 3 步:保留可重复测试的按钮入口
PixelCard.ets 的「摇一摇」点击处理发送 message 动作。下面的 messageAction 和 formAbilityName 分别是类里定义的 'message' 与 'EntryFormAbility':
postCardAction(this, {
action: this.messageAction,
abilityName: this.formAbilityName,
params: { message: 'requestOverflow' }
});
提供方接到消息后,走同一个 requestOverflow 方法:
onFormEvent(formId: string, message: string): void {
hilog.info(DOMAIN, TAG, 'onFormEvent formId=%{public}s msg=%{public}s', formId, message);
if (formId && message.indexOf(MSG_REQUEST_OVERFLOW) >= 0) {
void this.requestOverflow(formId);
}
}
MSG_REQUEST_OVERFLOW 的值为 'requestOverflow'。普通卡片打开应用的 router 事件放在图片、标题等节点上,没有挂到整个普通卡片根容器,避免点动效按钮时又把应用拉起,打断播放。
第 4 步:申请画布,但不要把它当成动画轨迹
requestOverflow 先读取卡片尺寸,宽、高无效时直接跳过。下面是它的请求参数部分:
const offset = (OVERFLOW_SCALE - 1) / 2;
await formProvider.requestOverflow(formId, {
area: {
left: -offset * rect.width,
top: -offset * rect.height,
width: OVERFLOW_SCALE * rect.width,
height: OVERFLOW_SCALE * rect.height
},
duration: OVERFLOW_DURATION,
useDefaultAnimation: true
});
其中 OVERFLOW_SCALE = 1.4,OVERFLOW_DURATION = 2600。area 以原卡片左上角为原点,不要把 getFormRect() 返回的屏幕 left/top 加进去。完整实现把尺寸读取和请求包在 try/catch 中,失败时记录错误码与消息。
到这一步,系统只是接受了一块更大的绘制区域。像素从哪里开始、移动多远、是否越过原卡片边缘,仍由页面代码决定。
第 5 步:同步加载激活态页面
激活态扩展从 onLiveFormCreate 取得原卡片相对画布的位置,再通过 LocalStorage 交给页面:
onLiveFormCreate(liveFormInfo: LiveFormInfo, session: UIExtensionContentSession): void {
const storage: LocalStorage = new LocalStorage();
storage.setOrCreate('formId', liveFormInfo.formId);
storage.setOrCreate('borderRadius', liveFormInfo.borderRadius);
storage.setOrCreate('formRect', liveFormInfo.rect);
storage.setOrCreate('context', this.context);
storage.setOrCreate('bundleName', this.context.extensionAbilityInfo.bundleName);
hilog.info(DOMAIN, TAG, 'onLiveFormCreate formId=%{public}s', liveFormInfo.formId);
session.loadContent('widget/pages/PixelLiveFormPage', storage);
}
loadContent 在这个回调中同步调用,不要先等一轮异步数据加载才创建页面。这里的 formRect.top 表示原卡片上边缘在激活画布中的位置,也就是接下来计算上方余量的依据。
第 6 步:让中心像素真正越过边框
旧实现把中心块固定上移 46vp。实测时,系统接受了请求,方阵也在生长,但浮块仍处于卡片内侧。修复不是继续猜一个更大的常数,而是先确定坐标:
- 原卡片高度为
H = formRect.height。 - 原卡片顶边在激活画布中的位置为
T = formRect.top。 - 方阵中心对齐原卡片中心,因此中心块起点为
T + H / 2。 - 把终点放在上方预留区域的中点,即
T / 2。 - 向上的总位移就是
(H + T) / 2。
当前页面增加了 private popDistance: number = 0,初始化代码如下:
aboutToAppear(): void {
const storage = this.getUIContext().getSharedLocalStorage();
this.formRect = storage?.get<formInfo.Rect>('formRect');
if (this.formRect) {
this.popDistance = (this.formRect.height + this.formRect.top) / 2;
}
this.cardRadius = storage?.get<number>('borderRadius') ?? 0;
this.liveContext = storage?.get<common.LiveFormExtensionContext>('context');
this.bundleName = storage?.get<string>('bundleName') ?? '';
}
中心块的位移改为使用计算值:
.translate({
y: index === 4 ? -this.pop * this.popDistance : 0,
x: index === 4 ? this.pop * 10 : 0
})
.zIndex(index === 4 ? 2 : 1)
只换这一个表达式还不够。 原来方阵和文字放在同一个居中的 Column 里,居中的是整组内容,方阵中心会被文字高度挤偏。现在改成 Stack({ alignContent: Alignment.Center }),以原卡片的尺寸和位置对齐方阵;文字独立定位在方阵下方。
方阵容器的尺寸与位置节选:
.width(this.formRect ? this.formRect.width : 0)
.height(this.formRect ? this.formRect.height : 0)
.offset({
x: this.formRect ? this.formRect.left : 0,
y: this.formRect ? this.formRect.top : 0
})
文字不再参与方阵居中,定位节选:
.position({
x: 0,
y: (this.formRect ? this.formRect.height / 2 : 0) + (BLOCK * 3 + GAP * 2) / 2 + 10
})
方阵仍由边长 BLOCK = 14vp、间距 GAP = 3vp 的九个块组成。页面启动时执行原有动画:
private playAnimation(): void {
this.getUIContext().animateTo({ duration: GROW_DURATION, curve: Curve.EaseOut }, () => {
this.grow = 1.7;
this.pop = 1;
});
}
GROW_DURATION = 2400ms。每块的目标缩放由 scaleOf(index) 计算,中心块同时向上运动;根容器的 onAppear 启动动画,点击处理也保留在激活画布根节点。
这个终点对本例的余量足够:14vp 的块放在上方区域中点,要完整留在画布内且与原卡片分离,需要上方余量大于块边长。本次卡片约高 158vp、上方余量约 31.6vp,满足条件;更换外扩范围或块大小后,应重新检查,不能只照抄公式。

点击桌面卡片的按钮后,中心粉色像素块越过原卡片顶边,其他块和文字仍保留在卡片区域内:

完整实现位于 Application/entry/src/main/ets/widget/pages/PixelLiveFormPage.ets,对应的请求方法位于 Application/entry/src/main/ets/entryformability/EntryFormAbility.ets。
第 7 步:把图片交给正确的进程
普通卡片显示图片与激活态绘制方阵是两件事。这里要分开四个角色,不能把卡片提供方和系统渲染服务画在同一个进程里:

- 应用进程:生成作品,写入
latest.png和标题数据,通过pushToCards更新已有卡片。 - 应用的卡片提供方
:form:运行EntryFormAbility,读取应用级文件与配置,响应卡片事件并申请动效。 - 系统卡片渲染服务:绘制
PixelCard,消费传入的卡片数据,不直接读取应用的 preferences 或图片路径。 - 激活态进程
:liveForm:通过扩展回调和LocalStorage取得位置、上下文等参数;当前页面绘制抽象像素块,没有从 preferences 或formImages读取原图。
CardStore 在应用与提供方两侧都使用 context.getApplicationContext(),把图片和 preferences 定位到应用级目录。这解决的是本例中两侧取到不同文件目录的问题,不意味着所有渲染进程共享应用沙箱,也不意味着 preferences 自动完成实时数据同步。
图片通道里,formImages 的键是普通标识 pixel,值是文件描述符;memory://pixel 才是卡片里使用的图片地址。现有组装代码节选:
const data: Record<string, string | Record<string, number>> = {
'caption': caption,
'imgSrc': fd >= 0 ? `memory://${CARD_IMAGE_KEY}` : ''
};
if (fd >= 0) {
const images: Record<string, number> = {};
images[CARD_IMAGE_KEY] = fd;
data['formImages'] = images;
}
return formBindingData.createFormBindingData(data);
这里 CARD_IMAGE_KEY = 'pixel'。卡片通过 @LocalStorageProp('imgSrc') 接收地址,再交给 Image(this.imgSrc)。完整实现会在 finally 中关闭已打开的 fd;资源应成对释放,但这次没有进行文件描述符泄漏压力测试,不能把它写成已经测过的故障结论。
两条触发入口,共用破框链路

按钮入口,已验证:
PixelCard 点击 → postCardAction(message) → onFormEvent → getFormRect → requestOverflow → onLiveFormCreate → 扩展同步 loadContent → 页面自绘动画。
物理摇一摇入口,已亲手观察到效果:
桌面系统检测摇动 → 回调提供方 onUpdateForm → 发起 updateForm → 排除节点复用原因 → 申请同一套动效。
第二条按官方流程接入,手摇后的互动效果也已在 Mate 70 Pro 上观察确认。这里区分两类依据:调用流程来自官方文档和代码,手摇能出现效果来自亲手操作的观察;没有同步测量物理动作到首帧的时间,因此不提供延迟数字,也不把示意图当成真实录制证据。reason !== 0 本身仍不能用来识别一次物理摇动。
总结
最近作品、轻量状态反馈这类桌面场景,值得用一段短动效做回应。关键不是把卡片整体放大,而是分清原卡片、激活画布和元素终点三个坐标关系。
按钮触发和亲手摇动都能让这个示例产生互动效果,但它仍不适合承担持续编辑;框外内容只用于展示,交互保持在原卡片范围内。
一句话:申请到框外画布只是第一步,像素真正越过边界,要靠正确的位移计算和真机画面来确认。

起点课堂会员权益




