桌面像素画破框生长:HarmonyOS 7 互动卡片

0 评论 1399 浏览 0 收藏 24 分钟

给图图素素加桌面卡片时,我想做的不只是一个打开应用的入口:用户处理完图片,回到桌面就能看到最近一张作品;触发互动后,再用一段像素块生长动画回应用户。

前言

给图图素素加桌面卡片时,我想做的不只是一个打开应用的入口:用户处理完图片,回到桌面就能看到最近一张作品;触发互动后,再用一段像素块生长动画回应用户。

第一版看起来已经跑通了。requestOverflow 返回成功,激活态页面也加载了,可连续截图一看,中心像素块始终停在卡片里面。原因不在手机,而在我们把上移距离写死成了 46vp——申请到更大的画布,不等于内容会自动飞出边框。

下面这张是普通展示状态。当前实现会在激活时切换到一个 3×3 的抽象像素方阵,并不是把用户原图拆成九块。这篇就从这个具体效果出发,说明配置、数据传递、触发链路和出框位移该怎么写。

Mate 70 Pro 上的图图素素桌面卡片,动效结束后恢复作品展示

互动卡片能做什么

先把版本说清楚:互动卡片并不是 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 本身仍不能用来识别一次物理摇动。

总结

最近作品、轻量状态反馈这类桌面场景,值得用一段短动效做回应。关键不是把卡片整体放大,而是分清原卡片、激活画布和元素终点三个坐标关系。

按钮触发和亲手摇动都能让这个示例产生互动效果,但它仍不适合承担持续编辑;框外内容只用于展示,交互保持在原卡片范围内。

一句话:申请到框外画布只是第一步,像素真正越过边界,要靠正确的位移计算和真机画面来确认。

本文由 @华为开发者联盟 授权发布于人人都是产品经理。未经作者许可,禁止转载

题图来自Unsplash,基于CC0协议

该文观点仅代表作者本人,人人都是产品经理平台仅提供信息存储空间服务

更多精彩内容,请关注人人都是产品经理微信公众号或下载App
海报
评论
评论请登录
  1. 目前还没评论,等你发挥!