☰
鸿蒙应用内添加服务卡片到桌面:权限配置至formId管理的完整实践
2026/10/14 18:22:43 网站建设 项目流程

前两天有个开发者在我写的卡片文章下面留言:应用里能不能直接触发“添加卡片到桌面”?用户根本不知道要长按桌面再选卡片,好多功能做出来都没人发现。

这个问题其实问的人挺多的。鸿蒙的服务卡片虽然好用,但系统默认的添加路径藏在桌面长按菜单里,曝光率低,而且用户很容易在几十张卡片里找不到你想要的那张。应用内添加卡片到桌面,就是把你自己的应用里某个按钮、某个引导页、某个开通成功的弹窗,直接变成一个“把卡片送上桌面”的入口。用户点一下,系统接管后续流程,卡片就出现在桌面上了。

这篇文章就把我在项目里验证过的完整方案写出来:从权限配置、API调用、formId管理,到踩过的坑和排查思路,一次讲清楚。适合正在做鸿蒙应用、想把服务卡片作为用户回访入口、但又搞不定“应用内主动添加”这个功能的开发者。

1. 卡片是怎么“上桌”的:系统路径与应用内路径的差异

1.1 服务卡片不只是一个“小组件”

很多人会把服务卡片理解成普通的小组件,其实它在鸿蒙里的地位比小组件重得多。服务卡片是应用与用户在桌面交互的主要载体,它可以不用打开应用就能展示最新数据,比如天气、待办、订单状态。它背后挂着一个系统级的ExtensionAbility,叫FormExtensionAbility,由系统在特定时机拉起,负责卡片的创建和数据刷新。

也就是说,一张卡片在桌面上能活多久、数据怎么更新、能不能被删除,都有一套完整的生命周期管理。开发者在代码里拿到的不是一个View,而是一个formId,一个代表“这张卡片在桌面上的身份标识”。后续所有更新、删除、事件处理,都要靠这个formId来定位。

这也是为什么“应用内添加卡片”这个功能值得单独做一套实现:它不是在应用里画一个卡片那么简单,而是要跟系统桌面服务做一次完整的交互。

1.2 系统默认路径的“三宗罪”

鸿蒙系统默认的添加卡片路径,是用户长按桌面空白区域,弹出“服务卡片”入口,再滑动找到对应应用,再选择具体卡片尺寸,才能把卡片加到桌面。

这条路径有三问题。第一是入口太深,大多数用户根本不知道长按桌面还有这个功能。第二是卡片太多时选择成本极高,一个应用往往配置了多张卡片,用户滑来滑去找不到目标卡片,干脆放弃。第三是应用开发者完全无法干预这个流程,即使你的卡片做得再好,也只能被动等用户自己去发现。

我参与过的一个项目,加了桌面卡片功能后,后台统计显示添加率只有0.3%。后来把“应用内添加”入口放到开通成功页,添加率直接涨到了百分之十几。这不是卡片本身的问题,是入口设计的问题。

1.3 应用内添加到底是在做什么

应用内添加卡片到桌面,本质上是应用在运行时向系统的桌面服务发起一个“请求添加卡片”的异步调用。系统收到请求后,会校验卡片的合法性,完成卡片实例的创建,最后通过回调返回一个formId给你。

这个过程中,系统会自己处理那些不可见的技术细节:卡片由哪个FormExtensionAbility实例化、onAddForm回调怎么写、卡片默认尺寸是多大、添加到桌面后首帧数据怎么填充,等等。开发者要做的,就是提前把自己的卡片工程配置好,然后在合适的业务节点发起请求,最后拿着返回的formId做后续的数据绑定。

需要注意的是,这里不存在“绕过用户”的操作。系统在添加过程中会有自己的确认或校验机制,不可能在用户毫不知情的情况下往桌面塞东西,这从设计上就杜绝了恶意应用刷屏的问题。所以我们做的时候不要想着“怎么偷偷加”,而是要把这个流程包装得像一个自然的引导动作。

2. 动手前的准备工作:权限、扩展能力与依赖

2.1 卡片本体必须存在且配置正确

应用内添加卡片的前提,是你的应用里确实存在一张配置好的服务卡片。这个卡片不是写在Activity里的,而是在module.json5里通过extensionAbilities声明的。下面是一个最基础的卡片声明配置片段:

{ "module": { "name": "entry", "extensionAbilities": [ { "name": "EntryFormAbility", "type": "form", "srcEntry": "./ets/entryformability/EntryFormAbility.ets", "metadata": [ { "name": "ohos.extension.form", "resource": "$profile:form_config" } ] } ] } }

这里面有两个关键点。第一,type必须是form,这是系统识别卡片扩展能力的依据。第二,metadata里的ohos.extension.form指向一个profile资源文件,这个文件里定义了卡片的名称、支持尺寸、是否可更新等元信息。

我见过不少初学者在这里翻车:extensionAbilities配了,但profile里的form_config写错了尺寸格式,导致应用内添加时系统直接报“卡片信息不存在”。所以动手写代码之前,先确保长按桌面能手动添加成功,这条路通了再谈应用内添加。

2.2 权限:申请REQUIRE_FORM

应用内添加卡片涉及到一个权限:ohos.permission.REQUIRE_FORM。这个权限的作用是允许应用请求添加卡片到桌面。不同版本的系统对这个权限的管理策略有差异,但作为应用开发者,我们必须在module.json5里主动声明。

示例配置如下:

{ "requestPermissions": [ { "name": "ohos.permission.REQUIRE_FORM", "reason": "用于在应用内引导用户将服务卡片添加到桌面", "usedScene": { "abilities": [ "EntryAbility" ] } } ] }

reason字段建议写清楚用途,不要随便填,上架审核时有些应用市场会看这个描述。usedScene则标明哪些模块会用到这个权限,一般填调用的页面Ability或UIAbility名称即可。

这里有个细节:REQUIRE_FORM在不同系统版本里曾被归类为授权类权限,也可能被处理为受限权限,具体以目标SDK的权限说明为准。所以在开发阶段,如果发现调用接口直接返回权限错误,先别急着改代码,用命令行工具查看一下真机上这个应用是否真正拿到了权限。

2.3 依赖模块与API选型

实现应用内添加卡片,核心依赖是FormKit套件。在新版SDK中,推荐使用@kit.FormKit导入;在老版本工程里(API 9、API 10),对应的导入路径则是@ohos.app.form.formProvider和@ohos.app.form.formBindingData。两者本质是同一套能力,只是模块命名规范升级了。

在代码里通常这样导入:

import { formProvider } from '@kit.FormKit'; import { formBindingData } from '@kit.FormKit'; import { BusinessError } from '@kit.BasicServicesKit';

API版本选择上,我建议直接用你工程targetSdk对应的稳定版本。不要为了兼容老设备把API压得很低,因为应用内添加卡片的一些交互细节在后来的版本里优化过,低版本上的表现可能不完整。

3. 核心实现:API调用与完整封装

3.1 第一步:拿到你要添加的那张卡片的“档案”

在发起添加请求之前,应用要先知道目标卡片是谁。这里有两种做法:一种是查全量卡片信息,再筛选出目标卡片;另一种是根据已知的卡片名片信息直接定向获取。

全量查询用getAllFormsInfo,它会返回当前应用已配置的所有卡片信息数组。代码写起来像这样:

async function getTargetFormInfo(formName: string, moduleName: string, bundleName: string) { try { const formInfos = await formProvider.getAllFormsInfo(); const target = formInfos.find( (item) => item.formName === formName && item.moduleName === moduleName && item.bundleName === bundleName ); return target || null; } catch (error) { const err = error as BusinessError; console.error(`getAllFormsInfo failed, code: ${err.code}, message: ${err.message}`); return null; } }

如果明确知道自己要添加哪张卡片,也可以用定向接口直接查,参数更少,逻辑更清晰。返回的FormInfo里,需要重点关注supportDimensions字段,它是一个字符串数组,内容类似于['1 × 2', '2 × 2', '2 × 4'],这些是卡片支持的桌面尺寸规格。

这里提醒一句:supportDimensions里的字符串,中间是有空格的,“1 × 2”不是“1×2”。有些API在解析尺寸时会严格按格式匹配,手抖少个空格,请求就会被打回。

3.2 第二步:构造请求参数FormRequestInfo

拿到卡片档案后,把它转成添加请求需要的FormRequestInfo对象。这个对象里有几个字段比较关键:

  • formName:卡片名,对应profile配置里的name
  • moduleName:卡片所在模块名,通常是entry
  • bundleName:应用包名
  • supportDimensions:本次添加支持的尺寸数组,一般从卡片配置里选一个或几个
  • cardData:卡片首屏要展示的临时数据,用createFormBindingData创建

写出来是这样的:

import { formBindingData } from '@kit.FormKit'; function buildRequestInfo(target: formProvider.FormInfo): formProvider.FormRequestInfo { const supportedSizes = target.supportDimensions; // 优先选2 × 2,如果没有就取第一个支持尺寸 const preferredSize = supportedSizes.includes('2 × 2') ? '2 × 2' : supportedSizes[0]; const requestInfo: formProvider.FormRequestInfo = { formName: target.formName, moduleName: target.moduleName, bundleName: target.bundleName, supportDimensions: [preferredSize], cardData: formBindingData.createFormBindingData({ title: '欢迎使用', detail: '这是一张来自应用内添加的卡片', timestamp: Date.now() }) }; return requestInfo; }

cardData是可选的。如果你在FormExtensionAbility的onAddForm里已经准备好了数据源,这里也可以不传首屏数据,等添加完成后走正常的数据加载流程。我这里选择传cardData,是为了让卡片出现在桌面的那一刻就有内容展示,不会白屏或闪一下默认态。

3.3 第三步:发起requestForm并处理结果

请求对象构造好了,下面就是最关键的一步:调用formProvider.requestForm。注意这是一个异步过程,系统需要时间完成卡片实例化,所以一定要处理好回调,不能把它当成同步操作。

Promise写法的调用方式:

async function addCardToHome(requestInfo: formProvider.FormRequestInfo): Promise<string> { try { const result = await formProvider.requestForm(requestInfo); // result.formId 是这张卡片在桌面上的唯一标识 console.info(`add card success, formId: ${result.formId}`); return result.formId; } catch (error) { const err = error as BusinessError; console.error(`requestForm failed, code: ${err.code}, message: ${err.message}`); throw err; } }

result里返回的formId是后续操作的生命线。更新卡片数据、删除卡片、响应卡片事件,全部靠这个字符串。我的习惯是拿到formId之后立刻持久化到本地数据库或Preferences里,绝对不要只放在内存变量里,因为应用进程一死,formId就丢了,后续没法更新那张卡片。

3.4 第四步:整合成可复用的AddCardManager

上面的逻辑在实际项目里通常抽成一个独立的管理类,方便多个业务入口共用。下面是我在项目里用的一套结构,简化后分享出来:

export class AddCardManager { private static instance: AddCardManager; private addedFormIds: Set<string> = new Set(); static getInstance(): AddCardManager { if (!AddCardManager.instance) { AddCardManager.instance = new AddCardManager(); } return AddCardManager.instance; } async isFormAdded(formName: string): Promise<boolean> { // 从持久化存储里查formId是否存在 // 这里用Preferences举例 const formId = await this.getFormIdByCardName(formName); return formId.length > 0; } async addCard(formName: string, extraData?: Record<string, string>): Promise<string> { const target = await this.getTargetFormInfo(formName); if (!target) { throw new Error(`card not found: ${formName}`); } const requestInfo = this.buildRequestInfo(target, extraData); const formId = await this.requestAndSave(formInfo); return formId; } private getTargetFormInfo(formName: string) { // 略,和3.1节逻辑一致 } private buildRequestInfo(target: formProvider.FormInfo, extraData?: Record<string, string>) { // 略,和3.2节逻辑一致 } private async requestAndSave(requestInfo: formProvider.FormRequestInfo): Promise<string> { const result = await formProvider.requestForm(requestInfo); await this.saveFormId(result.formId, requestInfo.formName); return result.formId; } private async saveFormId(formId: string, formName: string) { // 持久化保存映射关系 } private async getFormIdByCardName(formName: string): Promise<string> { // 从持久化存储里按卡片名查formId } }

这个封装做的事情很简单:对外只暴露addCard接口,内部处理卡片档案查询、请求构造、formId保存。业务层调用的时候,只需要关心“我加的是哪张卡片”,不用管底层的API细节。

不过有一点要特别注意:requestForm每次调用都会生成一张新卡片,系统并不会替你去重。如果你的业务上不允许同一个用户反复添加同一张卡片,必须在应用层自己判断——调用addCard之前先查本地是否已经有这张卡片的formId,有就直接提示“卡片已在桌面”,而不是再次调用接口。

4. formId与生命周期:添加之后的数据绑定、更新和删除

4.1 formId是卡片生命的锚点

一张卡片从添加到桌面那一刻起,就有了一个唯一的formId。这个id由系统生成,应用侧无法自定义。从形态上看它是一长串字符串,但本质上它是系统桌面服务定位卡片的“主键”。

我见过有开发者把formId理解成“桌面卡片的临时标记”,觉得丢了也无所谓。这是大错特错的。桌面上的卡片数据更新,靠的是应用通过formId找到正在运行的那张卡片;用户点击卡片,系统通过formId找到卡片背后的ProviderAbility或FormExtensionAbility。formId丢了,就相当于你手里没有门牌号,根本不知道该去敲谁家的门。

所以我的项目里有一个铁律:formId必须在拿到后立刻落盘。落盘方案用什么都可以,Preferences够用,DataShare也行,关键是持久化。应用进程被杀、设备重启之后,这些信息还要能重新加载回来。

4.2 临时数据、持久数据和更新机制

卡片的数据分为两类,这个概念容易混淆。一类是添加卡片时传入的初始数据,可以理解为“见面礼”,对应requestForm里的cardData字段。另一类是卡片正常运行期间的动态数据,通过formBindingData.createFormBindingData创建后,用formProvider.updateForm推给系统。

更新卡片的代码结构如下:

async function updateCard(formId: string, data: Record<string, string>) { const bindingData = formBindingData.createFormBindingData(data); try { await formProvider.updateForm(formId, bindingData); console.info(`update card success: ${formId}`); } catch (error) { const err = error as BusinessError; console.error(`updateForm failed, code: ${err.code}, message: ${err.message}`); } }

updateForm的调用时机也要想清楚。如果卡片配置里没有开启定时刷新,卡片内容就只会因为主动调用updateForm而改变。我之前在一个项目里,把“用户修改昵称后同步更新桌面卡片”这个逻辑忘了写,结果用户改了昵称,桌面卡片还是显示旧名字,体验非常割裂。正确的做法是在数据变更的核心业务函数里,把updateForm跟业务逻辑一起调用,而不是指望系统帮你通知。

4.3 删除卡片的细节:管好你的本地状态

用户从桌面删除卡片,系统会回调应用的onRemoveForm;但应用内主动删除卡片,则需要调用formProvider.deleteForm。这个API同样接受formId作为参数。

async function removeCard(formId: string) { try { await formProvider.deleteForm(formId); await this.removeLocalFormId(formId); } catch (error) { const err = error as BusinessError; console.error(`deleteForm failed, code: ${err.code}, message: ${err.message}`); } }

这里有一个特别容易忽略的点:删除卡片之后,本地持久化的formId记录必须同步删除,否则会出现“本地以为卡片还在桌面,实际桌面上早就没了”的状态。后续如果用户再点添加,系统会生成一个全新的formId,而本地老旧的formId记录与它冲突,可能导致更新逻辑失效。

另外,如果你的应用因为这张卡片挂了定时任务、注册了数据订阅,删除卡片时也要一并清理掉。否则卡片已经删了,后台还在周期性刷新一个不存在的formId,既浪费资源,又会产生一堆无意义的错误日志。

4.4 多尺寸卡片的适配策略

一个卡片档案可能支持多种桌面尺寸。在应用内添加时,你可以一次性把supportDimensions全传过去,让用户自己决定用哪个尺寸;也可以只传某一个尺寸,默认按你指定的尺寸添加。

实际项目里我倾向于给用户选择。如果卡片支持2 × 2和2 × 4两种尺寸,而你的卡片内容在2 × 4下能展示更多信息,那就给用户一个选择弹层,让他按偏好添加。产品上这是一种很自然的引导,用户对“自己选的尺寸”的满意度通常会更高。

实现选择弹层不复杂,就是从FormInfo.supportDimensions里取出所有尺寸,做成列表展示,用户点选后,再根据选中的尺寸重新组装requestInfo并调用requestForm。需要注意的是尺寸字符串在UI上显示时,可以做一层映射,比如“2 × 2”显示为“小尺寸”,“2 × 4”显示为“中尺寸”,避免直接把技术字符串抛给用户。

5. 常见问题与避坑实录

5.1 权限配了,但调用依然报权限错误

这算是出现频率最高的问题。明明在module.json5里写了requestPermissions,为什么requestForm还是抛权限异常?

常见原因有三个。第一,权限配置写错了模块,比如写在build-profile里而不是module.json5里。第二,系统版本把该权限归类为受限权限,需要在设备上手动授予。第三,你改了配置后,没有卸载重装应用。鸿蒙的权限配置在应用安装时生效,增量更新往往不触发重新授权,最稳妥的测试方式是卸载重装一遍。

排查建议:先用系统工具查询应用的实际权限授权状态,确认权限真的到手了,再继续查别的问题。如果授权状态是denied,优先处理授权问题,而不是反复修改请求逻辑。

5.2 明明配置了卡片,getAllFormsInfo却查不到

getAllFormsInfo返回的是当前应用已配置的卡片列表。如果你查不到某张卡片,先检查三处:profile配置文件里是否真的写了这个卡片;form_config中的卡片name与代码里查的formName是否完全一致;卡片所在的module是否是当前运行模块。

还有一个小概率问题:profile资源文件在编译时没有正确打进包。这种情况在改了profile配置但没有clean build时经常出现。遇到这种“配置没问题但就是查不到”的情况,先做一次干净的重新编译,很多时候问题就消失了。

5.3 requestForm回调失败,错误信息五花八门

我整理了一张排查表,基本都是实操中遇见过的场景:

现象可能原因处理方式
报卡片信息不存在formName/moduleName/bundleName对不上打印FormInfo全量列表核对字段
报尺寸不合法supportDimensions字符串格式错误严格按“数字 × 数字”带空格格式传参
报权限校验失败权限未授权或未声明检查module.json5并重新安装应用
报卡片服务异常系统桌面服务未就绪或设备异常重启设备,确保桌面应用未被禁用
回调一直pendingrequestForm未返回或Promise未处理检查是否在UI线程同步等待异步结果
返回成功但桌面看不到卡片系统静默失败或尺寸不兼容用真实设备查看是否触发了桌面刷新

对于最后一种“返回成功但桌面没卡片”的情况,我的经验是:优先在真机上一台一台测,不要只在模拟器上验证。模拟器的桌面卡片渲染能力跟真机有差异,有些尺寸在模拟器上就是加不上去。

5.4 用户反复点击,桌面上多了一堆相同卡片

这是我前面提过的“应用层去重”问题。requestForm的语义就是“创建一张新卡片”,系统不会替你判断“用户是不是已经加过了”。用户手速快连点两次,桌面上就会出现两张几乎一样的卡片。

解决方案分两层。第一层是入口层的防抖:按钮点击后立即置灰或进入loading状态,防止重复提交。第二层是逻辑层的幂等:添加前查询本地存储的formId记录,如果该卡片已添加,引导用户直接进入卡片预览或编辑页面,而不是再次发起添加请求。

5.5 卡片显示出来之后,数据不更新

卡片添加成功后数据不更新,常见原因也分几种。一是updateForm传的formId不对,可能保存的是空的或者被覆盖了;二是卡片的updateEnabled配置为false,某些版本下主动更新会受限;三是卡片数据更新频率太高被系统限制,短时间内多次updateForm会触发频控。

另外要注意,updateForm只是告诉系统“这里有新数据”,卡片真正刷新还需要卡片渲染端配合。你可以在卡片的postCardUpdate回调里做日志输出,确认系统到底有没有触发卡片更新。如果回调没走,问题大概率出在formId或者配置上。

6. 实操心得:这些场景才值得做应用内添加

6.1 不是所有应用都适合做这个功能

应用内添加卡片,本质上是一个“系统级操作”,它比普通按钮交互重得多。如果卡片本身只是锦上添花的内容,用户没有强动机,做这个入口反而会打扰体验。

我做了几次实践之后,总结出三个比较合适的使用场景:

  • 用户完成某个关键动作后的引导,比如开通会员、创建第一个待办、完成一次支付,这时候用卡片承接“留住用户回访”的心智最自然
  • 功能配置页,用户在对卡片内容做精细设置时,顺手添加到桌面,转化率很高
  • 应用首页的固定引导位,配合“连点三次弹出预览”之类的产品设计,既突出存在感又不打扰

反过来说,如果你的卡片内容只是一个静态Logo或标语,没有动态数据,那就完全没有做应用内添加的必要,用户手动加一次就再也不会看了。

6.2 入口文案和交互要给足预期

应用内添加卡片的交互对用户来说比较陌生,很多人第一次遇到会想:这个按钮点了会不会装什么奇怪的东西?所以入口文案不要只写“添加到桌面”,要明确告诉用户“把卡片添加到桌面,无需打开应用即可查看最新状态”,降低未知感。

添加成功之后,最好给一个明确的成功反馈。我试过两种方案,一种是Toast提示“卡片已添加到桌面”,一种是直接弹窗展示卡片预览图并引导用户去桌面查看。实测下来,弹窗预览的满意度明显更高,因为用户能直观看到自己“得到了什么”。

6.3 真机矩阵测试别偷懒

最后再分享一条实操层面的建议。应用内添加卡片这个能力,非常依赖系统的桌面服务,不同设备、不同系统版本的表现都可能不一样。团队内部有条件的,多拿几台不同屏幕尺寸、不同系统版本的真机跑一遍添加流程。

我踩过一次很深的坑:在某款平板上,卡片以2 × 4尺寸添加成功后,桌面上只显示了卡片上半部分,下半部分全是空白。排查了很久,最终发现是相机孔径与桌面布局的适配问题,模拟器上完全复现不了。这种问题只有真机矩阵才能暴露。

应用内添加卡片这个功能本身不难,难的是把“添加前防重复、添加后存formId、数据变更后及时updateForm”这一整条链路做扎实。按照上面的方案走一遍,你的卡片入口就能真正变成一个靠谱的留存工具,而不是一个摆设。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询