第一次把“美寇商城”的核心功能做成HarmonyOS APP卡片时,我对“桌面元服务”这四个字有了完全不一样的理解。很多人以为卡片就是把App界面缩小放到桌面,实际做完才知道,卡片是一种独立的产品形态:它要去掉所有干扰,只把用户当下最需要的状态和操作留在桌面上。这篇文章我把自己在“美寇商城”项目里的选型、配置、代码、踩坑全部分享出来,适合正在做鸿蒙应用卡片开发、又不知道从哪下手的开发者。如果你手里正好有一个电商类或工具类App,想尽快试水桌面元服务,下面的内容可以直接照着做。
1. 为什么我会把“美寇商城”的核心功能搬上桌面卡片
1.1 桌面元服务并不是“应用小图标”
传统App里,用户与服务的路径是:解锁手机 -> 找到图标 -> 打开应用 -> 输入关键词 -> 查看结果。有了卡片之后,路径可以缩短为:解锁手机 -> 看桌面 -> 点一下卡片。对电商这类目的性很强的应用,每次跳转的减少,都意味着流失率的下降。
我最初做“美寇商城”的产品分析时发现,用户大部分访问是为了搜索一个明确商品,还有一部分是为了看订单进度,领券则是典型的回访钩子。这三个场景有一个共同点:用户不需要完整的App,只需要一个“服务快照”。所谓桌面元服务,本质上是把服务能力切成一个个细小的入口,放到系统最前置的位置。它不追求把所有功能都塞进卡片,而是追求“在最合适的时间,让最需要的信息出现在眼前”。
举个例子,卡片像商场门口的服务台。用户想找某个品牌,不需要把整个商场逛一遍,服务台直接告诉他“在三楼左转”。传统App则更像是让用户自己一层一层逛。卡片承担的不是替代,而是加速。
1.2 电商App里,哪些功能真正适合做成卡片
不是所有功能都适合上桌面。我自己定了四个判断标准:高频、短时、状态强、闭环。高频是指用户每天都会碰;短时是指看一眼或者点一下就能完成;状态强是指信息有变化,而且变化后用户愿意回来查看;闭环是指点击卡片后,用户进入App能立刻接上原来的任务。
基于这个标准,我筛了“美寇商城”的所有核心模块:
| 候选功能 | 是否适合 | 理由 |
|---|---|---|
| 商品搜索 | 适合 | 打开App后的第一诉求,卡片可以直接展示热门词,点击进入搜索结果页 |
| 待付款/待收货订单 | 适合 | 用户非常关心订单进度,状态变化会促使用户回访 |
| 优惠券/满减进度 | 适合 | 有倒计时、进度条,能形成天然的营销钩子 |
| 商品详情浏览 | 不适合 | 需要大图、长文案,卡片空间承载不了 |
| 客服会话 | 不适合 | 交互链路长,场景复杂,不适合做成轻量入口 |
最后我决定只做三张卡片:商品搜索卡、订单动态卡、优惠券进度卡。它们分别对应“找购物目标”“看履约状态”“拿优惠激励”三个高频场景,每一张卡片都能独立回答一个问题,不重叠,也不冗余。
2. 卡片工程搭建:从零配置一个FormExtensionAbility
2.1 环境版本与工程准备
我使用的开发环境是DevEco Studio 5.0,HarmonyOS NEXT SDK API 12+,也就是5.0.0(12),Stage模型。为什么强调版本?因为API 9到API 12之间,卡片开发语法变化比较大。新项目建议直接走ArkTS卡片路线,不要再使用旧版Java或JS卡片模板。
新建工程时,我会选择“Empty Ability”模板,然后在已有工程里增加卡片模块。如果你用的是DevEco Studio的新版本,可以直接通过“File -> New -> Ability -> Empty Form Ability”生成卡片模板,框架会自动帮你建好FormExtensionAbility和form_config.json。手动创建的话,需要自己补全目录结构和注册信息。
有一点必须留意:卡片不是普通页面,它是通过extensionAbility方式注册到系统里的。所以工程里必须有一个FormExtensionAbility的子类,并且在module.json5里完成注册。很多新手在这一步就容易卡住,因为编译不报错,但卡片就是加不到桌面上。
2.2 form_config.json里的关键配置项
每张卡片都需要在resources/base/profile/form_config.json中描述基本属性。我以“搜索卡”为例,给出一个最小可用的配置:
{ "forms": [ { "name": "searchCard", "displayName": "$string:search_card_name", "description": "$string:search_card_desc", "src": "./ets/widget/pages/SearchCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "colorMode": "auto", "isDefault": true, "updateEnabled": true, "scheduledUpdateTime": "10:30", "updateDuration": 1, "defaultDimension": "2*2", "supportDimensions": ["2*2", "4*4"] } ] }几个字段我需要多说一句:
uiSyntax:必须写arkts,表示使用ArkTS声明式语法开发卡片。window.designWidth与autoDesignWidth:建议开启自适应宽度,这样在不同屏幕设备上不容易出现比例失调。defaultDimension:卡片默认尺寸,不一定是最终运行尺寸。supportDimensions:声明卡片支持的所有尺寸。要注意,卡片不是所有尺寸都一定适配得好,后面我会专门讲布局问题。updateDuration:定时刷新的周期,单位是30分钟。配置1代表30分钟,2代表1小时,最小单位就是30分钟,不能设置成10分钟这种更短的周期。scheduledUpdateTime:定点刷新时间,适合做早晚场景的运营推送。
需要强调的是,定时更新只代表“系统有机会刷新”,不代表“一定会准点刷新”。系统会综合用户使用习惯、电量、后台运行状态等条件调整调度,所以实时性要求高的数据,绝不能只靠定时刷新。
2.3 注册extensionAbilities时最容易踩的坑
模块注册信息写在module.json5中,大致结构如下:
{ "extensionAbilities": [ { "name": "SearchCardAbility", "srcEntry": "./ets/widget/ability/SearchCardAbility.ets", "type": "form", "metadata": [ { "name": "ohos.extension.form", "resource": "$profile:form_config" } ] } ] }这个配置有三个坑:
第一,type必须是form,写成其它值系统不会把它当作卡片服务。第二,metadata.resource指向的是profile资源文件,路径写错时编译阶段可能不报错,但卡片添加到桌面时会直接失败。第三,我的建议是每个业务域拆一个FormExtensionAbility。比如搜索卡、订单卡、优惠券卡分别建三个Ability,而不是在一个Ability里堆大量formName判断。这样onUpdateForm回调里逻辑清晰,后期维护成本低很多。
另外,还需要检查EntryAbility的exported配置是否为true。如果为false,卡片通过postCardAction拉起应用时会失败,报错信息又不太直观,排查起来很浪费时间。
3. “美寇商城”三张核心卡片的实现细节
3.1 商品搜索卡:把搜索行为从“打开App”缩短为“点击卡片”
商品搜索卡我采用了2*2尺寸。原型设计很简单:标题显示“美寇商城”,下面放四个热门搜索词,用户点击任意一个词,卡片直接拉起App的搜索结果页。
有人会问:为什么不在卡片上做一个输入框?我做过测试后发现,卡片对输入类组件的支持非常有限,强行放搜索框会带来键盘弹起、焦点管理等一系列问题。更合理的做法是:卡片只承担“推荐关键词”的角色,真正的搜索输入还是回到App内完成。这样既保证了体验,又避开了卡片能力边界。
卡片页面的核心代码大致如下:
@Entry @Component struct SearchCard { @State hotWords: string[] = ['口红', '眼影', '防晒', '香水']; build() { Column({ space: 8 }) { Text('美寇商城') .fontSize(14) .fontWeight(FontWeight.Bold) Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.hotWords, (word: string) => { Button(word) .fontSize(12) .height(28) .onClick(() => { postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { targetPage: 'searchResult', keyword: word } }); }) }, (word: string) => word) } } .padding(16) .width('100%') .height('100%') .backgroundColor('#FFFFFF') } }用过postCardAction的人应该知道,它支持三种action:router、call、message。这里我用的是router,作用是拉起一个Ability。需要特别注意的是abilityName必须与module.json5里注册的Ability名一致,否则点卡片没反应。
在EntryAbility侧,我通过onNewWant接收参数,并跳转到搜索结果页:
onNewWant(want: Want) { if (want.parameters?.targetPage === 'searchResult') { let keyword = want.parameters?.keyword as string; this.context.getRouting().pushUrl({ url: 'pages/SearchResult', params: { keyword: keyword } }); } }这样用户从看到热词到进入搜索结果,只需要一次点击。
3.2 订单动态卡:待收货状态实时可见
订单动态卡我用了4*4尺寸,展示三块核心数据:待付款数量、待发货数量、待收货数量,以及最新的物流节点信息。用户打开手机,不用进App就能知道自己的订单走到哪一步了。
这张卡的数据来源是用户登录后的订单中心接口。App在启动时会拉取一次订单汇总数据,缓存到本地。之后的关键操作,比如支付成功、商家发货、物流轨迹更新、确认收货,都会触发一次卡片更新。
更新卡片的核心代码是formProvider.updateForm:
import { formBindingData, formProvider } from '@kit.FormKit'; function pushOrderCard(formId: string, summary: OrderSummary) { let data = formBindingData.createFormBindingData({ pendingPayment: summary.pendingPayment, pendingDelivery: summary.pendingDelivery, pendingReceipt: summary.pendingReceipt, latestLogistics: summary.latestLogistics }); formProvider.updateForm(formId, data) .then(() => { console.info('订单卡更新成功'); }) .catch((err: Error) => { console.error(`更新失败,错误码:${err.code}`); }); }这里藏着一个很重要的问题:formId必须和用户绑定。因为同一台设备上可能存在多个账号登录过“美寇商城”,如果不区分用户,A用户会看到B用户的订单信息。我的做法是用键值数据库维护一张映射表:formId -> userId。在FormExtensionAbility的onAddForm回调中写入映射,在用户退出登录时清理对应关系。
对应的onAddForm逻辑大致是:
import { FormExtensionAbility, formBindingData, formInfo } from '@kit.FormKit'; import { Want } from '@kit.AbilityKit'; export default class OrderCardAbility extends FormExtensionAbility { onAddForm(want: Want) { let formId = want.parameters?.[formInfo.FormParam.FORM_ID] as string; if (formId) { saveFormUserMapping(formId, getCurrentUserId()); } let data = formBindingData.createFormBindingData({ pendingPayment: 0, pendingDelivery: 0, pendingReceipt: 0, latestLogistics: '' }); return data; } }订单卡不能只依赖事件推送,还要做兜底刷新。如果某个更新事件因为进程被杀或者异常漏掉了,就需要在onUpdateForm回调里重新拉取数据。兜底频率不用太高,我会在form_config里配置updateDuration为1,也就是30分钟一次。这个周期足够覆盖异常场景,又不会给服务端造成太大压力。
3.3 优惠券进度卡:用“进度条”驱动复购
优惠券进度卡使用了2*4尺寸,主要展示一句话:“距离满199减20还差50元”,下方放一个进度条,再通过“去凑单”按钮拉起App的凑单商品页。
进度条其实不建议在卡片里使用重量级组件。我直接用两层Row实现:背景是一条灰色圆角矩形,前景是一条橙色圆角矩形,宽度由消费进度百分比控制。
@State progress: number = 75; Row() { Row() .width(`${this.progress}%`) .height(8) .backgroundColor('#FF6A00') .borderRadius(4) } .width('100%') .height(8) .backgroundColor('#F2F2F2') .borderRadius(4)这里有个小细节:百分比字符串直接绑定状态变量就能完成宽度变化,不需要做像素级换算。卡片不同设备上的实际宽度由系统计算,百分比布局天然适配。
“去凑单”按钮的点击逻辑同样走postCardAction:
Button('去凑单') .onClick(() => { postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { targetPage: 'fillOrder', gapAmount: 50, couponThreshold: 199, couponDiscount: 20 } }); })在凑单页,我根据差值和品类偏好,从当前商品池里找出价格合适、促销标签匹配的商品,优先推荐给用户。这样卡片就不仅仅是一个“提醒”,而是直接参与了消费决策。
优惠券进度卡的更新策略稍微特殊:我在form_config.json里配置了两个定点刷新时间,早上10:30和晚上19:00,用来提醒用户“你的满减券快到期了”或者“再买50元就能用券”。同时,当用户支付完成、领取了新优惠券、或者优惠券即将过期时,App会立即推送一次更新。这种“事件驱动+定点兜底”的组合,既能保证信息及时,又不会频繁打扰系统调度。
4. 卡片数据刷新:定时更新、事件更新和智能刷新怎么选
4.1 三种刷新机制的应用场景
做卡片开发,数据刷新策略是最容易被低估的部分。我把HarmonyOS卡片常见的刷新机制整理成一张表:
| 刷新方式 | 触发方式 | 适用场景 | 注意点 |
|---|---|---|---|
| 定时更新 | form_config中的updateDuration或scheduledUpdateTime | 天气、汇率、报价、每日签到等变化频率可控的场景 | 系统会延迟调度,不适合强实时场景 |
| 事件更新 | App业务代码中调用formProvider.updateForm | 订单状态、支付结果、物流更新等业务事件触发 | 需要维护formId,且更新频率要克制 |
| 智能刷新 | 系统根据用户习惯和场景自动调整 | 资讯推荐、内容流等非强实时场景 | 开发者无法控制刷新时机,适合“锦上添花” |
整个“美寇商城”项目做完后,我的结论是:不要幻想靠某一种刷新机制打天下。业务数据该准实时,就必须走事件更新;运营内容该定点推,就配置scheduledUpdateTime;实在拿不准的,再用updateDuration做兜底。
4.2 我最终使用的组合策略
三张卡片的刷新策略各不相同,这完全取决于业务数据的特性。
搜索卡的热词列表属于运营配置,更新频率极低。我甚至没有给搜索卡配置定时刷新,只会在运营后台修改热词后,主动推送一次数据。这样做的好处是卡片几乎不产生后台唤醒,非常省电。
订单卡以事件更新为主,每次用户支付、商家发货、物流轨迹变化时都会触发updateForm。同时配上30分钟的定时兜底,防止漏更。
优惠券卡的更新来源除了用户领券、下单等事件外,我还在form_config中配置了"scheduledUpdateTime": ["10:30", "19:00"],让系统每天早晚各尝试唤醒一次,提醒用户查看满减进度。
这里需要提醒一句:定时更新不是越多越好。如果每张卡片都把updateDuration配置成30分钟,那么系统每个整点半点都会尝试唤醒应用接口。用户量一大,服务端压力立刻上来,手机后台功耗也会被系统记账。一个克制的做法是:只给“确实需要按时刷新”的卡片配置定时更新,其它卡片尽量走事件驱动。
5. 2x2与4x4布局适配、真机调试和常见坑
5.1 不同尺寸的卡片怎么理解为“适配”
卡片尺寸由桌面网格决定,常见的有22、44、2*4。新手最容易踩的坑,是把PC端的“响应式布局”思路原封不动搬过来,试图用同一套代码去适配所有尺寸。实际上,卡片空间非常小,强行自适应会导致信息密度失控。
我的做法是:先给每个卡片定义一个“主场景尺寸”。搜索卡只做22,订单卡只做44,优惠券卡只做2*4。如果你确实需要同一张卡支持多种尺寸,那就要在form_config的supportDimensions里声明,并且使用百分比宽度、Flex布局和自适应字号。
我踩过的一个具体问题是:22卡片里放了4个按钮,44卡片里也放4个按钮,看起来一模一样,但44卡片会很空。后来我把44的卡片信息结构升级成“汇总数字+列表摘要”,2*2只保留汇总数字,这样两个尺寸各有信息侧重点,而不是简单放大缩小。
5.2 真机调试的三个深坑
第一个坑,Previewer预览效果和真机不一致。卡片在DevEco Studio的Previewer里看着很完美,放到桌面上出现文字截断、圆角不准、背景色偏差。原因是卡片最终渲染由系统服务端负责,预览器只是模拟。我的经验是:不要依赖预览器调试卡片,做完基础布局后立刻上真机。
第二个坑,postCardAction拉起不了App。排查链路是这样:先确认button点击事件是否真的执行,可以通过日志输出验证;然后检查module.json5中被拉起Ability的exported是否为true;接着检查abilityName是否和注册名称完全一致;最后确认EntryAbility是否在onNewWant中处理了参数。大部分情况是exported漏配置了。
第三个坑,卡片更新时报“invalid formId”。这个错误很隐蔽,通常是formId已经失效。比如用户从桌面删除了卡片,但本地映射表里还残留旧formId,App又拿着这个formId去updateForm。我在代码里会对updateForm的失败回调做统一处理:收到错误后清理本地失效formId,并把卡片状态标记为“待重新添加”。这样下次用户重新添加卡片时,onAddForm会写入新的formId,链路重新对齐。
5.3 卡片首帧性能的一点点经验
卡片虽然是轻量入口,但它同样需要快速渲染。我在做完性能优化后总结出三个原则:不要在卡片页面里直接发网络请求;不要在build里做复杂计算;不要加载大图。
卡片页面更适合扮演“展示层”的角色,数据在FormExtensionAbility中准备好之后,通过formBindingData一次性下发。图片素材我会让设计同时输出一套卡片专用压缩图,控制在200KB以内,避免原图直接放进卡片导致首帧卡顿。实测下来,这三条原则能明显降低卡片添加到桌面时的等待感。
6. 上架前要做的元服务化检查与后续迭代
6.1 卡片功能上架前的自查清单
卡片功能开发完,不能直接提交。我给自己列了一个自查清单,每条都对照过才算完成。这里也分享给你参考。
第一,卡片展示的内容必须来自真实业务数据,不能出现占位图或假数据,应用市场审核会抽查卡片状态。第二,卡片涉及用户相关数据时,权限一定要在宿主App里声明完整,不能因为卡片跑在桌面上就“绕过”权限链。第三,营销类卡片要克制,不要放诱导性文案,不要频繁弹跳转,否则用户很快会反感。第四,深色模式和浅色模式都要检查一遍,尤其是促销色和背景色的对比度。
另外,提交应用市场时,需要补充桌面卡片的截图和功能说明。说明要写清楚每张卡片各自承担什么服务,不要笼统写成“支持桌面卡片”。
6.2 后续迭代:从“卡片”演进到完整“元服务”
做完这三张核心卡片后,我意识到“美寇商城”的卡片化只是第一步。真正有价值的是把卡片背后的服务能力进一步原子化。
后续可以考虑把“优惠券进度”单独拆成一个元服务,用户不需要下载完整App,就能通过“元服务”卡片直接完成“查看满减进度+凑单领券”的闭环。订单提醒也可以接入系统意图框架,让系统在用户出差、到家等场景下主动弹出卡片。这些方向比单纯把App页面搬到卡片上更符合HarmonyOS桌面元服务的本意。
我自己做完这轮迭代后,最明显的一个体会是:卡片开发逼着我把“美寇商城”从一个大而全的App重新拆成了一个个服务单元。以前设计功能时,我总想着“用户先打开首页再做下一步”。现在我会先问:这个动作值不值得被直接放到桌面上?如果值得,它最核心的信息是什么?最轻量的操作路径是什么?带着这个思路再去写代码,你会发现卡片开发其实不是技术难点,产品重构才是。