FormExtensionAbility 卡片 call 事件唤醒主应用的正确姿势

前言

HarmonyOS 服务卡片是系统级入口,用户可以在桌面直接点击卡片完成快捷操作。但很多业务场景需要卡片执行一次不弹窗的后台动作,例如:点击卡片开始录音、同步一次健康数据、触发 IoT 设备控制等。

这类需求天然会想到 postCardActioncall 事件——它可以在不拉起页面的情况下,后台唤起主应用的 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 回调,执行完后进程仍然受系统后台策略约束。如果业务需要持续播放、持续定位等长时间行为,必须:

  1. module.json5 声明 ohos.permission.KEEP_BACKGROUND_RUNNING
  2. 用户触发后调用 startBackgroundRunning() 申请长时任务;
  3. 任务结束后调用 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);
}

总结

避坑要点

  1. method 必须放在 params 对象内部,与 action/abilityName 不同级。
  2. call 只是瞬时唤醒,不能替代后台长时任务;持续行为要申请 startBackgroundRunning
  3. 跨进程状态不要放内存:卡片与主应用进程隔离,使用 Preferences / RDB 做持久化。
  4. 生命周期内管理 callee 监听onCreate 注册、onNewWant 去重、onDestroy 注销。
  5. 卡片状态要主动回刷:用 formProvider.updateForm(formId, data) 把结果反馈给用户。

后续预防方案

  • 静态扫描门禁:在 CI 中扫描 postCardActioncall 用法,检测 method 是否错误地写在 params 外:

    rg -n "action:\s*['\"]call['\"]" --glob "*.ets" -A 5 | grep -E "method:" --color=auto
    
  • 全场景测试矩阵

    场景 预期
    主应用未启动 卡片点击后触发 call,业务执行并回刷
    主应用前台 直接命中 callee,不弹新页面
    主应用后台 后台唤起,执行后允许系统挂起
    主应用被系统回收 从持久化恢复状态,仍可正常响应
    连续快速点击 callee 回调幂等,不产生重复业务
  • 日志埋点:在卡片点击、主应用 call 命中、状态回刷三个节点加日志,线上可快速定位“没反应”的环节。

卡片是鸿蒙生态中效率极高的入口,但越是系统级入口,越要尊重其跨进程、短生命周期的本质。把 call 事件用对、把状态持久化做好、把长时任务申请规范,就能做出既轻量又可靠的桌面卡片体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐