一、卡片是入口,不是迷你应用
1.0.4 版本给健康菜谱助手加了元服务模块和第一张服务卡片:2×2 的"今日菜谱",摆上桌面就能看到今天推荐吃什么。做之前看了不少案例,最容易犯的错是把卡片当"缩小版应用"塞满内容。我的结论相反:卡片是入口,不是迷你应用。本文用这张卡片的真实代码讲克制设计的四个决定。
二、决定一:静态卡片够用,就不上动态
form_config.json 里的关键配置:
json
{
"name": "daily_recipe_card",
"isDynamic": false,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2"]
}
isDynamic 设为 false------静态卡片。卡片只展示"今日推荐菜名 + 时长 + 口味"三行信息,内容按天变化(scheduledUpdateTime 07:30 定时刷新,早饭前换好"今天吃什么"),不需要每秒动效、不需要复杂交互。静态卡的桌面资源占用几乎为零,刷新由系统按计划驱动。
07:30 这个时间点不是随手填的:用户早饭前后决定"今天吃什么",卡片在这个节点前更新完,才真正参与决策。
三、决定二:FormLink 路由,一个参数直达详情
卡片点击用的是 FormLink:
ts
FormLink({
action: 'router',
abilityName: 'AtomicServiceAbility',
params: { entry: 'daily_recipe_card' }
})
整个卡片就是一个大按钮:点击从桌面直达原子服务的首页(带 entry 参数标识来源)。没有在卡片上堆多个热区、没有做"卡片内翻页"------2×2 的面积只够承载一个动作意图。点击后的选择、看步骤、加购物清单,都留给应用内完成。
四、决定三:只放"一个决定",不放"一个应用"
2×2 面积的信息取舍:菜名(最大字号)、时长、口味标签,加一个"食"字角标。放不下菜谱列表,也不该放------卡片的任务是回答"今天吃什么"这一个决策,回答完就把用户送进应用。克制让卡片反而更有用:桌面上的信息密度越低,被看的概率越高。
五、决定四:元服务独立模块,复用而不复制
卡片放在独立的 atomicservice 模块(AtomicServiceAbility + DailyRecipeFormAbility + widget/pages),主应用 entry 的代码不直接混入。两边共享菜谱数据模型,但生命周期、入口、页面栈各自独立------这也是上架卡片类元服务的结构要求(1.0.4 的上架检查清单里专门有这一项)。
六、总结
第一张卡片的四个决定可以复述成一句话:静态渲染、单点路由、一个决定、独立模块。卡片的克制不是功能妥协,是对"桌面一寸一金"的尊重------用户愿意留着你的卡片,它才有资格参与每天的那顿饭。
下一步计划:结合定时能力做"饭点前 30 分钟推新菜"的定点刷新,让卡片更像一个贴心的提醒,而不是一张静态海报。
参考资料
- HarmonyOS 服务卡片开发指导(FormConfig / FormLink)
- 元服务上架说明
- 工程源码:atomicservice 模块(DailyRecipeCard.ets、DailyRecipeFormAbility.ets、form_config.json)
#鸿蒙 #HarmonyOS 7(API 26)

五、卡片数据链路:formProvider 的更新时机
静态卡片不是"不用更新",而是"由系统按计划更新"。form_config.json 里 scheduledUpdateTime 07:30 加 updateDuration 1(天级),系统会在每天早晨刷新一次卡片数据。但数据从哪来?答案是 FormExtensionAbility 的 onUpdateForm:系统到点回调,应用在回调里用 formProvider.updateForm 把新的菜名、时长、口味推给卡片。
ts
onUpdateForm(formId: string) {
const recipe = recommendToday() // 本地推荐算法取今日菜
const formData = { name: recipe.name, duration: recipe.duration, taste: recipe.taste }
formProvider.updateForm(formId, formBindingData.createFormBindingData(formData))
}
这里有个必踩的坑:回调里拿到的 formId 是单个卡片的实例 ID,用户可能往桌面加了两张同款卡片,onUpdateForm 会被分开调用------别在回调外面缓存"上一个 formId"做优化,老老实实每次都按参数更新。测试时把系统时间拨到 07:29 等一分钟,比改代码重装快得多。

六、FormLink 的参数在目标页怎么接
FormLink 的 params 会原封不动传到目标 Ability 的启动参数里。我在 AtomicServiceAbility 的 onCreate 和 onNewWant 里都处理了 want.parameters.entry:冷启动在 onCreate 拿,热启动在 onNewWant 拿------只处理前者的话,应用在后台时点卡片会出现"点了没反应"的假死现象。entry 的值 daily_recipe_card 只做一件事:路由到首页并滚动到"今日推荐"区块,来源标识留着做埋点,不参与业务分支。
ts
onNewWant(want: Want) {
const entry = want.parameters?.entry as string
if (entry === 'daily_recipe_card') router.replaceUrl({ url: 'pages/Home', params: { scrollTo: 'today' } })
}
七、2×2 的排版细节:三个字号和一个角标
2×2 卡片实际可用面积约 220×220vp,我的排版方案:菜名用 20fp 加粗(最多两行,超出截断加省略号)、时长和口味用 12fp 次要色、"食"字角标 28fp 半透明放在右下。三个层级的字号差异让卡片在桌面上保持可辨识度------一眼先看到菜名,需要时再看时长。容易犯的错是把四行信息塞成同样字号,用户在桌面上根本分不清主次,卡片就变成了噪声。
| 元素 | 字号 | 层级职责 |
|---|---|---|
| 菜名 | 20fp 加粗 | 决策主体,最多两行 |
| 时长/口味 | 12fp 次要色 | 辅助判断,一行放下 |
| "食"角标 | 28fp 半透明 | 品牌识别,不参与信息 |

八、卡片适配的两轮真机验收
卡片和页面不一样,它渲染在系统的桌面进程里,模拟器上看不出真实效果。我跑了两轮真机验收:第一轮覆盖三种桌面场景------默认壁纸、深色壁纸、极简模式,检查文字对比度是否仍然达标;第二轮验证刷新链路------拨系统时间到刷新点、重启手机、连续两天不打开应用,确认卡片内容都按计划更新而不是停留在安装那天的数据。第二轮抓到一个真问题:手机重启后 FormExtensionAbility 的首次回调比预期晚了几分钟,不影响功能但让"07:30 前更新完"的承诺打了折扣,最终把 scheduledUpdateTime 提前到 07:00 留出余量。
| 验收项 | 场景 | 判定 |
|---|---|---|
| 对比度 | 亮/暗壁纸+极简模式 | 文字清晰可读 |
| 定时刷新 | 拨时间/重启/跨天 | 内容按日更新 |
| 点击路由 | 冷启/热启两次点卡片 | 都能进首页今日区 |
| 多实例 | 桌面加两张同款卡片 | 各自独立更新 |

九、复盘:克制是能力不是偷懒
这张卡片最终只用了约 120 行 ArkTS 和一段 JSON 配置,砍掉的功能清单反而更长:不做卡片内翻页、不做多热区、不做动态数据、不做 2×4 大卡。每砍一项都有明确的理由------2×2 面积只够一个决策,多塞一个功能就稀释一次。做完回头看,"克制"最难的部分不是少写代码,而是抵抗"把应用搬上桌面"的冲动:用户从桌面进来是要一个答案(今天吃什么),不是要一个入口全景图。第一张卡片立住了这个设计基调,后面如果做 2×4 或者媒体类卡片,尺寸变、决策密度变,但"一个卡片一个决定"的原则不会变。
回过头把整张卡片从设计到上线的过程串一遍,还有几段经验值得单独记录下来,它们不专门属于某个技术点,却是决定这张卡片最终好不好用的隐性部分。
先说信息决策的取舍过程。最初的设计稿里,卡片除了菜名还有一张菜品缩略图,视觉稿看起来确实更"完整"。真机摆上桌面后我把它撤了,理由有两个:第一,2×2 的空间里图片会挤压菜名字号,20fp 掉到 16fp,桌面一眼扫过去辨识度明显下降;第二,图片需要网络加载,卡片冷启动的瞬间会先出现空白占位,把"今天吃什么"这个答案推迟了几百毫秒。决策的核心标准不是界面好不好看,而是这张卡片存在的目的------帮用户在最短时间里拿到今天的答案。砍掉图片后,卡片在亮屏的一瞬间就把菜名摆出来,这个"即时性"才是它被摆上桌面的理由。
再说推荐算法的输入。卡片显示的"今日推荐"来自本地的一套加权规则:食材库存的匹配度占大头,用户的历史偏好做修正,最近七天内重复出现过的菜会被降权------避免连着三天吃同一道菜。这些计算全部发生在手机本地,不联网、不拉用户数据,一方面是元服务的包体和隐私约束,另一方面也是产品定位:菜谱是个人工具,不是内容平台。推荐结果每天只变一次,变化本身也是体验的一部分------如果每次亮屏推荐的菜都不同,用户反而会怀疑这套推荐的稳定性。
第三段经验是关于失败路径的。onUpdateForm 回调里如果推荐算法抛异常,卡片会保持旧数据不动------这其实是最好的降级:用户看到的还是昨天的菜,而不是一块报错的白板。但我在回调里额外加了一层保护:算法结果要先过一遍字段校验(菜名非空、时长是正数),校验不过就沿用旧数据并记一条本地日志。桌面组件的容错原则和应用内不同------应用内出错可以弹提示引导用户操作,卡片出错没有任何交互出口,唯一正确的姿势就是静默保持可用。
最后记录一下上架后的真实反馈。卡片上线一周,比预期更受欢迎的居然是"时长"这个字段------好几位用户说他们早上瞄一眼时长就能决定要不要做这道菜,十分钟以内的菜点开率明显更高。这个反馈直接影响了下一步计划:如果做第二张卡片,"按可用时间筛选"可能比"按菜系浏览"更值得放上来。功能上线不是设计过程的终点,桌面上的真实使用数据才是下一轮迭代的输入,这张两英寸见方的小卡片,教的其实是同一件事:把用户在一个具体时刻的具体问题放在正中间,其他一切都为它让路。
回看整个开发周期,还有一处隐藏的时间黑洞值得记录:调试卡片时的反馈回路比页面长得多。页面改一行代码重新编译就能看到效果,卡片的刷新却要等系统的调度------scheduledUpdateTime 改到一分钟后,也得真等到那一分钟。后来我摸索出两个提速办法:一是用 FormExtensionAbility 的日志打点确认回调确实到达(回调没来,改 UI 代码毫无意义);二是准备一个调试按钮临时塞在应用设置页里,直接调用 formProvider 的请求刷新接口,把等系统变成手动触发,排版调整的效率立刻翻了倍。调试完再删掉按钮,不留死代码。反馈回路的长短决定了迭代速度,能缩短就别硬等。
另一个记录在案的细节是卡片文案的写法。菜名超过两行会截断,最初我用了系统的 ellipsis 截断,后来发现红烧狮子头配时蔬...这样的结尾让人一头雾水------截断应该截在有意义的位置。最终的方案是菜名做了预裁剪:超过十四个字就换成短菜名(数据源里维护了每道菜的短名),宁可信息略简也不出残缺句。桌面卡片上每一个字符都贵,一个省略号占的位置未必比一个准确的词更有用,文案和排版一样需要为 2×2 的空间做裁剪。
写完这篇再补一句给同路人的话:元服务和卡片对个人开发者是特别划算的能力------代码量小、桌面曝光的价值高、又能倒逼自己把信息架构想清楚。不必等一个完整的版本周期,先从一张静态卡开始,把一个具体场景的答案送到桌面上,比一次规划五张卡片更有价值。我的下一张卡片计划也已经有了雏形:一个只显示"库存快见底的食材"的提醒卡,同样两乘二,同样一个决定,同样克制。
这篇写完,服务卡片系列的第二篇已经在脑子里成型了:库存提醒卡会把"什么快用完了"这件事送到桌面上,和这张"今天吃什么"形成一对互补------一个回答现在,一个回答将来。届时会继续沿用本篇的四个决定做起点,把新场景里不成立的部分逐条推翻重来。卡片开发的乐趣就在这里:每一次尺寸和场景的变化,都是对"克制"二字的一次重新考试。