【鸿蒙心迹】桌面卡片实战:让码上清单在桌面上“活”起来
【鸿蒙心迹】桌面卡片实战:让码上清单在桌面上“活”起来
前言
上一篇把生命周期和页面跳转捋顺之后,码上清单的骨架算是立住了。但内测群里那句反馈一直悬在我头上:能不能不点开应用,在桌面上就把“买牛奶”勾掉?
行,桌面卡片安排上。这需求看着特别小——不就桌面上放个小挂件吗——真做下来,我对卡片的认识被整个掀翻了一次:它不是应用的缩小版窗口,而是应用递给桌面的一张数据快照。这句话是整篇的主线,后面几千字都在把它讲透,再把我自己踩过的坑一个个摆出来。
先交代环境:DevEco Studio 5.x、HarmonyOS NEXT、API 14+,卡片用 ArkTS 写。行为以这个版本实测为准,具体接口以官方《应用卡片》文档为准。
一开始就理解错了:我把它当成小窗口
动手第一天犯的错,现在回看特别典型:把卡片当成“主页面的小号版本”。界面缩小一点、按钮少放几个,不就行了?于是我打开主页面代码删删减减,把待办列表整个搬进卡片,模拟器一跑——一片白,干净得像没写过代码。
白板之后我才认真翻文档,把整个体系重新画了一遍。画完就知道错在哪了。桌面卡片这件事,参与的“人”比想象中多:

四拨角色各管一段。桌面 Launcher 是宿主,只负责把卡片摆出来、把用户的长按和移除转告系统,它不碰你的数据。formMgr 是系统里的卡片管理服务,分配 formId、按需拉起你的提供方、转发更新,所有数据流都从它手上过。FormExtensionAbility 是你代码的入口,拢共四个回调:添加、更新、移除、事件。而主应用 UIAbility 才是数据源头——数据库、业务逻辑都住这边,formId 也得自己存在 Preferences 里。
最要紧的是图里最后那句注脚:渲染卡片 UI 的不是你的进程。你交出去的只是一份布局加一份数据,真正的渲染由系统的卡片渲染服务完成。由此推出两件事:其一,卡片环境里没有主应用那些自由,网络长连接、重逻辑统统别想;其二,主应用进程哪怕被杀了,卡片还能躺在桌面上,显示最后一次推过去的数据——皮是系统的,馅才是你的。
所以动手前,值得把两个世界的能力差异贴在显示器上:

我的总结就一句:应用页是一块“活的屏幕”,卡片是一张“会换内容的贴纸”。后面所有设计决策,都是从这句话推出来的。把主页面缩小塞进卡片这个念头,从今天起戒了。
子集到底砍掉了什么,说几个实测遇到的。Web、XComponent 这类重组件没戏,自不必说;动效也只剩很有限的一档,我在主页面给勾掉动作配的缩放过渡,搬过来直接不渲染,也不报错——卡片对“不支持”的默认态度大多是静默,这比报错可怕;事件同样收窄,点击之外的手势基本别指望。反过来,Text、Image、Button、Progress、Column/Row、有限条件的 List 这些基础件都稳,拼一个信息展示型卡片绰绰有余。我的原则变成:卡片里只出现“支持列表里见过的东西 + 本地小图片”,任何拿不准的组件,先写个最小demo验一下再进正式代码。
快照:桌面显示的不一定是真的
第二个必须内化的模型。调试那天我做了个现在想起来很好笑的实验:往数据库插了一条“还书”,然后盯着桌面看了十秒,等卡片自己从 3 条变 4 条。
它当然没动。卡片显示的从来不是数据库,而是你最后一次推过去的那份数据。系统不会帮你盯着库,库变了卡片不会自己知道:

数据的出口只有三个:onAddForm、onUpdateForm 回调时返回,或者代码里主动调 formProvider.updateForm。除此之外没有任何“自动同步”。这个模型乍看是限制,其实也送了礼物——卡片永远诚实显示你推给它的东西,显示层和数据管线彻底解耦,出问题时界面上不用猜,去查推送记录就行。
顺着这个模型,“卡片什么时候刷新”就不再是 UI 问题,而是数据管线设计问题:什么时机推、推多大一份、推给谁。这三个问题分别对应接下来三节。
先插一段关于“推多大一份”的教训。我的第一版快照是把当天待办整个数组序列化了塞进去——直觉上“数据全一点,卡片想显示啥都行”。后果是快照体积随待办数量线性膨胀,测试账号一多,推送偶尔能感到延迟,而且 2×2 的卡片根本显示不了几条,多推的全是浪费。第二版砍成了 summary 结构:条数、最近一条的名字、完成比例,2×4 规格再带前四条的名称。快照稳定在几百字节,推送再没出过状况。给卡片的数据要按“它显示得下多少”来设计,而不是按“库里有什么”来设计——这句也是快照模型的一部分。
从长按桌面到卡片上屏,中间发生了什么
用 DevEco 的卡片模板起步(右键模块目录 New → Service Widget),骨架会生成 FormExtensionAbility 和一个卡片页面。先别急着美化 UI,我把“添加一张卡片”的完整时序画了出来——后面两个大坑都藏在这条链路里:

第 4 步是全篇最重要的一行代码。onAddForm(want) 的 want 参数里,藏着这张卡的身份证:
import formManager from '@ohos.app.form.formManager';
import formBindingData from '@ohos.app.form.formBindingData';
onAddForm(want) {
// formId 就藏在 want 参数里,取出来第一件事是存盘
const formId = want.parameters[formManager.FormParam.IDENTITY_KEY as string];
saveFormId(formId); // 内部是 Preferences 落盘,细节下一节
// 第二件事才是准备首屏数据
return formBindingData.createFormBindingData({
title: '今日待办',
total: queryToday().length,
first: queryToday()[0]?.name ?? '今天没有安排',
});
}
模板骨架只做了第二件事。我第一周照着骨架写,卡片显示得好好的,转头去开发刷新功能,写到一半愣住:updateForm 的第一个参数 formId,从哪来?翻遍代码,添加的时候根本没存。这就是下一节要还的债。
再补一个真实的细节。onAddForm 里我最初写的是同步查库,数据量小毫无感觉;后来测试账号的待办表积到一千多条,添加卡片时就能感到一顿,偶尔还白板。最后改成回调里先返回一个占位快照(“正在加载”),真实数据用 updateForm 随后补推。快照模型在这里送了第二份礼物:可以先给一张能看的,再给一张对的,用户全程无感。
formId 这颗雷:只存不删,等于给未来埋 bug
formId 是每张卡片的身份证,updateForm 全靠它寻址。它的一生该有三个动作,成对才健康:

添加时存、更新时用、移除时删。翻车姿势我已经替各位试过了:只存不删。用户把卡片从桌面划走,onRemoveForm 回调会来敲门,我一开始嫌麻烦没处理——反正不影响主流程。两周后看日志,updateForm 报了一排无效更新,冲着早就不存在的 formId 推数据。单张卡片是小事,要是配了刷新任务,这就是每个周期都在触发的固定报错。
现在我的实现长这样,顺便把清理逻辑也放进去了:
import formProvider from '@ohos.app.form.formProvider';
// 业务数据变化后,给所有存量卡片推新快照
async function pushAllCards() {
const ids = await getFormIds(); // Preferences 里那份存量清单
const data = formBindingData.createFormBindingData(summaryToday());
for (const id of ids) {
try {
await formProvider.updateForm(id, data);
} catch (e) {
// 推不到的大概率是卡片已被删除,顺势清理,下次不再推
removeFormId(id);
}
}
}
onRemoveForm(formId) {
removeFormId(formId); // 与 onAddForm 里的 saveFormId 严格成对
}
这套写法跑了一个多月,日志再没出现过无效更新。存和删就写在同一个文件里隔行相望,谁也别想再被“暂时跳过”。
让卡片活起来:一慢一快两条路
数据会变了,怎么让桌面跟上?只有两条路,一慢一快,各有各的本分:

慢的那条是系统闹钟,在卡片的 form_config.json 里配置:
{
"forms": [{
"name": "TodoWidget",
"supportDimensions": ["2*2", "2*4"],
"defaultDimension": "2*2",
"updateDuration": 2,
"scheduledUpdateTime": "08:00"
}]
}
两个键的脾气完全不同。updateDuration 按间隔触发,单位是 30 分钟,填 2 就是每 1 小时回调一次 onUpdateForm——注意它没有分钟档,“想一分钟刷一次”在这个字段里就不存在;而且系统对刷新有配额和功耗调度,频繁也不保证准点。scheduledUpdateTime 则是每天一个固定时刻,适合“早上八点刷新今日待办”这种节奏。
快的那条就是前面 pushAllCards 走的路:应用内勾掉一条待办、新建一条,数据变化的瞬间顺手推一版新快照。用户手指还没离开屏幕,桌面已经变了。体验全靠这条快路撑着,慢路只是兜底。
列表类卡片还有个更聪明的定时策略:不按固定间隔,而是以“下一个截止时间”为锚点。最近一条待办 15:00 截止,就让卡片 15:00 刷一次,把“过期”这个状态点亮。比每小时无脑刷一遍有用得多,也省配额。我现在的做法是每次推数据时算好下一次有意义的时刻,动态设置更新节奏,配合快路基本覆盖所有场景。
卡片上那个勾:三种交互的取舍
数据活了,交互呢?卡片上放个“完成”按钮,点一下就把待办勾掉——这是内测群的原话需求。卡片里的交互只有三种姿势,选型其实一问就能定:

我的“完成”按钮最终走了 router,而不是看起来更炫的 call:
import postCardAction from '@ohos.arkui.postCardAction';
Button('完成')
.onClick(() => {
postCardAction(this, {
action: 'router', // 跳转:拉起主应用并自动定位
abilityName: 'EntryAbility',
params: { todoId: this.firstId, from: 'card' }
});
})
理由很实际。call 后台直接勾掉确实更“丝滑”,但它要求应用侧实现对应的后台方法入口,链路长、出错点多,而且用户完全没有反馈路径——点了没反应,是勾掉了还是没勾掉?而 router 拉起应用后自动滚动到那条待办并高亮,用户看见状态变化,顺手还能改个备注。卡片交互的正确形态,几乎都能改写成“跳转 + 自动定位”,一步直达比半屏小控件好用得多。等后面真有高频轻操作的需求,再给最重要的那一条加 call 通道也不迟。
顺带一提,message 姿势我到现在都没用上。它只触发 onFormEvent 递个话,存在感很弱,先知道有这么个东西就好。
2×2 和 2×4:一张卡的两张脸
规格这事值得单开一节,因为它直接影响快照设计。form_config 里 supportDimensions 声明了 2*2 和 2*4 之后,同一张卡在桌面上有两种摆法,而两种规格能承载的信息量天差地别。
我的分法很朴素:2×2 是“一眼档”,只放三个元素——今日条数、完成比例、最近一条的名字。桌面上图标那么密,2×2 的卡片本质上是个带数字的图标,多一个字都是打扰。2×4 是“一屏档”,放下前四条待办的名称加勾选状态,再加一条进度。快照结构也按这个分:summary 字段两种规格共用,top4 数组只有 2×4 用得上,2×2 的推送里干脆不填。
布局上偷了个懒,没为两种规格写两套页面,用百分比布局让同一套结构自然伸缩。实践下来有个小坑:2×2 下字号如果沿用 2×4 的设置,会显得头重脚轻,最后是给标题和正文分别设了两档字号,按宽度百分比切的。说不上优雅,但卡片就那么大,够用且好维护比什么都强。
还有个容易忽略的细节:用户添加卡片时选的规格,会一直跟着那张卡。也就是说同一种卡片在桌面上同时存在 2×2 和 2×4 两拨用户,推数据时两种都得喂饱——这也是我坚持快照按“显示得下多少”裁剪的原因之一,你永远不知道对面那张卡是哪个规格。
白板十分钟:一次教科书式翻车
开头说过我第一版卡片白板的事,这里把完整排查过程摊开,因为白板是卡片开发中概率最高的故障,没有之一。定位思路浓缩成了一张图:

我的翻车现场回放:第一版卡片是从主页面搬代码搬出来的,白板后先看 hilog——onAddForm 没抛异常,数据也正常返回了。第二步查组件:卡片页面里用了一个主页面写的自定义滚动组件,里面套了支持列表之外的组件,砍掉换成 Column + Text 的朴素组合,卡片立刻亮了。整个定位过程十分钟,但如果是第一周的我,大概会盯着代码看两个小时。
所以现在我做卡片的流程固定成三步:先用最朴素的 Text 把链路跑通,确认数据能上屏;再逐块加上真实 UI,加一块看一眼;最后才动花活。白板了就回到这张图,从上往下走,基本十分钟内定位。
还有个隐蔽的坑值得单说:卡片页面里的 Image 如果填了一个加载不出来的占位图链接,某些情况下也会整卡渲染失败。图片资源放本地、体积压小,能少一类玄学。
另外要学会区分“白板”和“空数据”,两者在桌面上都表现为没内容,但性质完全不同。白板是渲染失败——连背景、标题这些静态部分都没有,说明问题出在组件或异常;空数据是正常渲染了,只是数据是空的——背景和标题都在,列表区空着。前者按上面的流程图走,后者去查数据管线:onAddForm 里查库的条件是不是太严、推送的数据结构字段名对不对。我第一次遇到“空数据”时也当白板排查了半天,日志翻烂了没异常,最后发现是快照字段名从 title 改成了 headline,卡片页面还在读旧字段。快照结构和卡片页面的字段约定,值得单独写个类型定义共享,别像我一样靠记忆对齐。
收个尾
到这里,码上清单在桌面上有了一块真正“活”的入口:早上瞟一眼今日进度,长按进详情,勾选一步直达。回顾这一篇,其实就三句话——卡片是快照不是窗口;formId 存删要成对;刷新慢路兜底、快路撑体验。
临发布前我把这阶段的经验整理成一份自查清单,新卡片开发完过一遍,比翻聊天记录管用:
- onAddForm 里 formId 取出来了没有?落盘了没有?
- onRemoveForm 有没有和它成对?updateForm 的 catch 里有没有顺势清理?
- 定时刷新的档位是不是按 30 分钟的倍数配的?有没有把体验寄托在准点回调上?
- 快照里有没有塞“卡片显示不下”的数据?字段名和卡片页面共享同一份类型定义了吗?
- 卡片页面里有没有支持列表之外的组件?图片是不是本地的、小的?
- 2×2 和 2×4 两种规格都真机看过吗?添加和移除各测过一遍吗?
写卡片最大的收获反倒在心态上:承认它“小”,把它当一张会换内容的贴纸来设计,比什么都重要。那些想在卡片里塞进整个应用的努力,最后都会被支持列表和配额教做人。
下一篇回到工程侧。应用写到这个规模,包管理的问题藏不住了:ohpm 的 lock 文件该不该提交、HAR 和 HSP 到底怎么选、三方库没有鸿蒙版怎么办——ohpm 与包管理的实录,下篇见。
*本文环境:DevEco Studio 5.x / HarmonyOS NEXT(API 14+)。卡片能力、组件支持列表与刷新配额以官方《应用卡片》《ArkTS 卡片》文档为准。系列上一篇:《【鸿蒙心迹】Ability 生命周期与页面跳转》,下一篇:ohpm 与包管理实录。*
更多推荐


所有评论(0)