FormExtensionAbility 卡片 call 事件唤醒主应用的正确姿势
FormExtensionAbility 卡片 call 事件唤醒主应用的正确姿势
前言
HarmonyOS 服务卡片是系统级入口,用户可以在桌面直接点击卡片完成快捷操作。但很多业务场景需要卡片执行一次不弹窗的后台动作,例如:点击卡片开始录音、同步一次健康数据、触发 IoT 设备控制等。
这类需求天然会想到 postCardAction 的 call 事件——它可以在不拉起页面的情况下,后台唤起主应用的 singleton UIAbility 并调用其注册方法。然而论坛里大量踩坑帖都集中在两点:method 到底该放在 params 里还是外面? 以及 点了卡片,主应用为什么好像“没反应”?
本文用一个可控示例还原故障,厘清 router / message / call 的区别,并给出可直接落地的正确写法。
问题描述
故障现象
某音乐播放器希望实现“桌面卡片点击即播放”:
- 点击卡片按钮后,音乐没有开始播放;
- 偶发点击后 1~2 秒才播放;
- 主应用被系统回收后,点击卡片完全无响应;
- 卡片上的播放状态无法回刷,永远是“暂停”图标。
错误代码(可复现问题)
// ❌ 卡片端:method 写在了 params 外面
Button('播放')
.onClick(() => {
postCardAction(this, {
action: 'call',
abilityName: 'EntryAbility',
method: 'playFromCard', // 错误位置
params: { formId: this.formId }
});
})
// ❌ 主应用端:没有按生命周期管理 callee 监听,且认为 call 能保活
export default class EntryAbility extends UIAbility {
onCreate() {
this.callee.on('playFromCard', (msg) => {
// 直接开始播放,没有处理进程被回收场景
audioPlayer.play();
});
}
}
复现条件
method参数位置写错;- 主应用对
call事件的理解是“持续唤醒 / 保活”,没有按规范申请长时任务; - 跨进程状态放在内存单例中,进程回收后丢失;
- 没有回刷卡片状态。
细节解析
1. postCardAction 三种事件对比
| 事件 | 是否拉起 UI | 调用位置 | 适用场景 |
|---|---|---|---|
router |
是,拉起页面到前台 | 主应用 UIAbility | 需要用户可见反馈,如打开详情页 |
message |
否 | 卡片 FormExtensionAbility 的 onFormEvent |
仅在卡片进程内处理,不唤醒主应用 |
call |
否(后台唤起) | 主应用 singleton UIAbility 的 callee.on |
需要主应用执行一次后台逻辑,不弹窗 |
选择 call 的潜台词是:我只需要主应用执行一次短暂动作,完成后就让它继续受系统调度。
2. method 为什么必须在 params 内部?
这是 postCardAction 接口最容易踩的坑。官方接口定义 call 事件的 method 字段不是与 action/abilityName/params 同级的字段,而是作为业务参数放在 params 对象中。框架侧解析时会从 want.parameters 里读取 method,然后分发给 callee 对应的事件名。位置写错后,框架找不到 method,自然无法命中 callee.on('xxx') 的回调。
3. call 不是保活
call 只是把 singleton UIAbility 拉起来执行一次 callee 回调,执行完后进程仍然受系统后台策略约束。如果业务需要持续播放、持续定位等长时间行为,必须:
- 在
module.json5声明ohos.permission.KEEP_BACKGROUND_RUNNING; - 用户触发后调用
startBackgroundRunning()申请长时任务; - 任务结束后调用
stopBackgroundRunning()释放。
不能把 call 当成“让 App 一直活着”的开关。
4. 跨进程状态持久化
卡片与主应用运行在不同进程,主应用进程被回收后,内存里的 formId、播放状态都会丢失。因此:
- 卡片点击时透传
formId; - 主应用在
call回调里从 Preferences / RDB 读取真实状态; - 更新业务后,用
formProvider.updateForm(formId, data)回刷卡片。
示例代码
卡片端:正确的 call 事件写法
import { postCardAction } from '@kit.ArkUI';
@Entry
@Component
struct MusicCardWidget {
@State isPlaying: boolean = false
@LocalStorageProp('formId') formId: string = ''
build() {
Row() {
Button(this.isPlaying ? '暂停' : '播放')
.onClick(() => {
postCardAction(this, {
action: 'call',
abilityName: 'EntryAbility',
params: {
method: 'togglePlayFromCard', // ✅ 正确:method 在 params 内部
formId: this.formId,
current: this.isPlaying ? 'playing' : 'paused'
}
});
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
主应用端:完整 callee 处理与状态回刷
import { Want, UIAbility } from '@kit.AbilityKit';
import { formProvider, formBindingData } from '@kit.FormKit';
import { preferences } from '@kit.ArkTS';
export default class EntryAbility extends UIAbility {
private cardCallback = (msg: object) => {
const params = (msg as Want).parameters as Record<string, string>;
const formId = params['formId'];
const current = params['current'];
// 1. 从持久化读取真实状态(不要依赖内存单例)
const realState = this.loadPlayState();
// 2. 执行业务
if (realState === 'paused') {
this.startPlay();
} else {
this.pausePlay();
}
// 3. 回刷卡片
if (formId) this.refreshCard(formId);
};
onCreate(want: Want): void {
this.callee.on('togglePlayFromCard', this.cardCallback);
}
onNewWant(want: Want): void {
// 冷启动已有监听时先 off 再 on,避免重复注册
this.callee.off('togglePlayFromCard');
this.callee.on('togglePlayFromCard', this.cardCallback);
}
onDestroy(): void {
this.callee.off('togglePlayFromCard');
}
private loadPlayState(): string {
try {
const pref = preferences.getPreferencesSync(this.context, { name: 'music_state' });
return pref.getSync('state', 'paused') as string;
} catch (e) {
return 'paused';
}
}
private savePlayState(state: string) {
const pref = preferences.getPreferencesSync(this.context, { name: 'music_state' });
pref.putSync('state', state);
pref.flushSync();
}
private startPlay() {
// 如需要持续播放,申请长时任务
// backgroundTaskManager.startBackgroundRunning(...)
this.savePlayState('playing');
}
private pausePlay() {
// backgroundTaskManager.stopBackgroundRunning()
this.savePlayState('paused');
}
private refreshCard(formId: string) {
const state = this.loadPlayState();
const data = formBindingData.createFormBindingData({
isPlaying: state === 'playing'
});
formProvider.updateForm(formId, data).catch((err) => {
console.error(`回刷卡片失败:${JSON.stringify(err)}`);
});
}
}
错误写法 vs 正确写法对比
// ❌ 错误
postCardAction(this, {
action: 'call',
abilityName: 'EntryAbility',
method: 'playFromCard', // method 在外,框架收不到
params: { formId: this.formId }
});
// ✅ 正确
postCardAction(this, {
action: 'call',
abilityName: 'EntryAbility',
params: {
method: 'playFromCard', // method 在 params 内
formId: this.formId
}
});
需要长时任务时的最小示例
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
async function startLongRunning(context: Context) {
const wantAgentInfo: backgroundTaskManager.WantAgent = {
wants: [{ bundleName: 'com.example.demo', abilityName: 'EntryAbility' }],
operationType: backgroundTaskManager.OperationType.START_SERVICE,
requestCode: 0
};
await backgroundTaskManager.startBackgroundRunning(context,
backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK, wantAgentInfo);
}
总结
避坑要点
method必须放在params对象内部,与action/abilityName不同级。call只是瞬时唤醒,不能替代后台长时任务;持续行为要申请startBackgroundRunning。- 跨进程状态不要放内存:卡片与主应用进程隔离,使用 Preferences / RDB 做持久化。
- 生命周期内管理
callee监听:onCreate注册、onNewWant去重、onDestroy注销。 - 卡片状态要主动回刷:用
formProvider.updateForm(formId, data)把结果反馈给用户。
后续预防方案
-
静态扫描门禁:在 CI 中扫描
postCardAction的call用法,检测method是否错误地写在params外:rg -n "action:\s*['\"]call['\"]" --glob "*.ets" -A 5 | grep -E "method:" --color=auto -
全场景测试矩阵:
场景 预期 主应用未启动 卡片点击后触发 call,业务执行并回刷主应用前台 直接命中 callee,不弹新页面主应用后台 后台唤起,执行后允许系统挂起 主应用被系统回收 从持久化恢复状态,仍可正常响应 连续快速点击 callee 回调幂等,不产生重复业务 -
日志埋点:在卡片点击、主应用
call命中、状态回刷三个节点加日志,线上可快速定位“没反应”的环节。
卡片是鸿蒙生态中效率极高的入口,但越是系统级入口,越要尊重其跨进程、短生命周期的本质。把 call 事件用对、把状态持久化做好、把长时任务申请规范,就能做出既轻量又可靠的桌面卡片体验。
更多推荐

所有评论(0)