HarmonyOS律愈实战09:HarmonyOS Form播放器卡片实现

HarmonyOS 桌面卡片实战:实现一个可控制播放的律愈播放器卡片

本文适合标签:HarmonyOS、ArkTS、元服务卡片、FormExtensionAbility、桌面卡片

对应文件:EntryFormAbility.etsYulvPlayerCard.etsEntryAbility.ets

1. 卡片要实现什么

"律愈"的桌面卡片不是静态展示,而是一个小播放器。它支持:

  • 展示当前五音名称、五行和脏腑。
  • 展示播放进度。
  • 播放 / 暂停。
  • 上一首 / 下一首。
  • 支持 2x4 和 4x4 两种尺寸。

这类卡片的难点不在 UI,而在通信:卡片本身不能像普通页面那样直接控制主应用状态,需要通过 FormAbility 和 Ability 之间的事件传递。

2. 卡片配置

form_config.json 中声明卡片:

json 复制代码
{
  "forms": [
    {
      "name": "YulvPlayerCard",
      "src": "./ets/widget/pages/YulvPlayerCard.ets",
      "uiSyntax": "arkts",
      "isDynamic": true,
      "isDefault": true,
      "updateEnabled": false,
      "supportDimensions": ["2*4", "4*4"],
      "defaultDimension": "2*4"
    }
  ]
}

关键点:

  • isDynamic: true 表示动态卡片。
  • supportDimensions 同时支持横向播放器和方形播放器。
  • src 指向卡片页面。

3. FormAbility 生命周期

EntryFormAbility 继承 FormExtensionAbility

ts 复制代码
export default class EntryFormAbility extends FormExtensionAbility {
  private activeFormId: string = '';
  private activeDimension: number = 3;
  private progressSec: number = 0;
  private progressTimerId: number = -1;
}

新增卡片时返回初始数据:

ts 复制代码
onAddForm(want: Want): formBindingData.FormBindingData {
  const params = want.parameters as Record<string, Object> | undefined;
  const formId = stringParam(params, FORM_ID_PARAM, '');
  const dimension = numberParam(params, FORM_DIMENSION_PARAM, 3);
  this.activeFormId = formId;
  this.activeDimension = dimension;
  this.progressSec = 0;

  const initialData: YulvFormData = {
    toneKey: 'gong',
    formId: formId,
    formDimension: dimension,
    playing: false,
    progressPct: 0,
    progressText: '00:00',
    durationText: '20:00'
  };
  return formBindingData.createFormBindingData(initialData);
}

4. 卡片数据模型

ts 复制代码
interface YulvFormData {
  toneKey: string;
  formId: string;
  formDimension: number;
  playing: boolean;
  progressPct: number;
  progressText: string;
  durationText: string;
}

这些数据会同步到卡片的 LocalStorageProp

ts 复制代码
@LocalStorageProp('toneKey') @Watch('onToneKeyChanged') private toneKey: string = 'gong';
@LocalStorageProp('formId') private formId: string = '';
@LocalStorageProp('formDimension') private formDimension: number = 3;
@LocalStorageProp('playing') @Watch('onPlayingChanged') private playing: boolean = false;
@LocalStorageProp('progressPct') private progressPct: number = 0;
@LocalStorageProp('progressText') private progressText: string = '00:00';
@LocalStorageProp('durationText') private durationText: string = '20:00';

5. 卡片发送控制事件

卡片点击按钮时调用 sendControl()

ts 复制代码
private sendControl(control: CardControl, tone: CardToneKey): void {
  const callAction: CardCallAction = {
    action: 'call',
    abilityName: 'EntryAbility',
    params: {
      method: 'yulvCardControl',
      control: control,
      tone: tone,
      formId: this.formId,
      formDimension: this.formDimension
    }
  };
  postCardAction(this, callAction);

  const messageAction: CardCallAction = {
    action: 'message',
    params: {
      control: control,
      tone: tone,
      formDimension: this.formDimension,
      message: JSON.stringify({ control, tone, formDimension: this.formDimension })
    }
  };
  postCardAction(this, messageAction);
}

这里同时发送 callmessage,让主 Ability 和 FormAbility 都能拿到状态。

6. EntryAbility 接收 call

主 Ability 注册卡片控制回调:

ts 复制代码
private registerCardControl(): void {
  try {
    this.callee.on('yulvCardControl', (data: rpc.MessageSequence): rpc.Parcelable => {
      void this.handleCardControl(parseCardControl(data));
      return new YulvCardCallResult(0, 'ok');
    });
  } catch (err) {
  }
}

处理播放、暂停和切换:

ts 复制代码
private async handleCardControl(params: YulvCardControlParams): Promise<void> {
  const tone = parseToneKey(params.tone);
  this.cardFormId = params.formId.length > 0 ? params.formId : this.cardFormId;
  this.cardTone = tone;

  if (params.control === 'pause') {
    this.cardPlaying = false;
    await YulvCardAudioController.pause();
    this.stopCardProgressTimer();
    this.pushCardProgress();
    return;
  }

  this.cardPlaying = true;
  this.cardTone = tone;
  this.startCardProgressTimer();
  await YulvCardAudioController.play(this.context, tone);
}

7. 共享播放器控制器

卡片播放使用 YulvCardAudioController

ts 复制代码
export class YulvCardAudioController {
  private static player: ToneAudioPlayer | null = null;
  private static currentTone: ToneKey = 'gong';
  private static playing: boolean = false;

  static async play(context: common.Context, tone: ToneKey): Promise<void> {
    YulvCardAudioController.currentTone = tone;
    await YulvCardAudioController.ensure(context).playTone(tone);
    YulvCardAudioController.playing = true;
  }

  static async pause(): Promise<void> {
    await YulvCardAudioController.player?.pause();
    YulvCardAudioController.playing = false;
  }
}

它复用了主应用的 ToneAudioPlayer,避免卡片另起一套音频逻辑。

8. 卡片 UI 自适应

YulvPlayerCard 根据尺寸切换布局:

ts 复制代码
private isSquare(): boolean {
  return this.formDimension === 4;
}

build() {
  Stack() {
    if (this.isSquare()) {
      this.SquarePlayer()
    } else {
      this.CompactPlayer()
    }
  }
}

2x4 用横向播放器,4x4 用居中大图和更完整信息。

9. 通信流程图

#mermaid-svg-nF1fDzoegRk27H5P{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nF1fDzoegRk27H5P .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nF1fDzoegRk27H5P .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nF1fDzoegRk27H5P .error-icon{fill:#552222;}#mermaid-svg-nF1fDzoegRk27H5P .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nF1fDzoegRk27H5P .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nF1fDzoegRk27H5P .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nF1fDzoegRk27H5P .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nF1fDzoegRk27H5P .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nF1fDzoegRk27H5P .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nF1fDzoegRk27H5P .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nF1fDzoegRk27H5P .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nF1fDzoegRk27H5P .marker.cross{stroke:#333333;}#mermaid-svg-nF1fDzoegRk27H5P svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nF1fDzoegRk27H5P p{margin:0;}#mermaid-svg-nF1fDzoegRk27H5P .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nF1fDzoegRk27H5P .cluster-label text{fill:#333;}#mermaid-svg-nF1fDzoegRk27H5P .cluster-label span{color:#333;}#mermaid-svg-nF1fDzoegRk27H5P .cluster-label span p{background-color:transparent;}#mermaid-svg-nF1fDzoegRk27H5P .label text,#mermaid-svg-nF1fDzoegRk27H5P span{fill:#333;color:#333;}#mermaid-svg-nF1fDzoegRk27H5P .node rect,#mermaid-svg-nF1fDzoegRk27H5P .node circle,#mermaid-svg-nF1fDzoegRk27H5P .node ellipse,#mermaid-svg-nF1fDzoegRk27H5P .node polygon,#mermaid-svg-nF1fDzoegRk27H5P .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nF1fDzoegRk27H5P .rough-node .label text,#mermaid-svg-nF1fDzoegRk27H5P .node .label text,#mermaid-svg-nF1fDzoegRk27H5P .image-shape .label,#mermaid-svg-nF1fDzoegRk27H5P .icon-shape .label{text-anchor:middle;}#mermaid-svg-nF1fDzoegRk27H5P .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nF1fDzoegRk27H5P .rough-node .label,#mermaid-svg-nF1fDzoegRk27H5P .node .label,#mermaid-svg-nF1fDzoegRk27H5P .image-shape .label,#mermaid-svg-nF1fDzoegRk27H5P .icon-shape .label{text-align:center;}#mermaid-svg-nF1fDzoegRk27H5P .node.clickable{cursor:pointer;}#mermaid-svg-nF1fDzoegRk27H5P .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nF1fDzoegRk27H5P .arrowheadPath{fill:#333333;}#mermaid-svg-nF1fDzoegRk27H5P .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nF1fDzoegRk27H5P .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nF1fDzoegRk27H5P .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nF1fDzoegRk27H5P .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nF1fDzoegRk27H5P .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nF1fDzoegRk27H5P .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nF1fDzoegRk27H5P .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nF1fDzoegRk27H5P .cluster text{fill:#333;}#mermaid-svg-nF1fDzoegRk27H5P .cluster span{color:#333;}#mermaid-svg-nF1fDzoegRk27H5P div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nF1fDzoegRk27H5P .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nF1fDzoegRk27H5P rect.text{fill:none;stroke-width:0;}#mermaid-svg-nF1fDzoegRk27H5P .icon-shape,#mermaid-svg-nF1fDzoegRk27H5P .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nF1fDzoegRk27H5P .icon-shape p,#mermaid-svg-nF1fDzoegRk27H5P .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nF1fDzoegRk27H5P .icon-shape .label rect,#mermaid-svg-nF1fDzoegRk27H5P .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nF1fDzoegRk27H5P .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nF1fDzoegRk27H5P .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nF1fDzoegRk27H5P :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户点击卡片按钮
YulvPlayerCard postCardAction
action call: EntryAbility
action message: EntryFormAbility
handleCardControl
YulvCardAudioController
ToneAudioPlayer
formProvider.updateForm
onFormEvent
更新卡片本地进度

10. 小结

这个卡片案例覆盖了 HarmonyOS 桌面卡片开发中最常见的几个问题:

  • 初始数据如何绑定。
  • 卡片如何向 Ability 发送控制事件。
  • 主应用如何更新卡片状态。
  • 卡片尺寸如何适配。
  • 音频控制逻辑如何复用。

如果你正在做音乐、播客、冥想、课程类 App,这个播放器卡片结构可以直接作为参考。

9. 卡片为什么同时发 call 和 message

在 YulvPlayerCard 中,点击按钮后同时发送两类 action:

` s

postCardAction(this, {

action: 'call',

abilityName: 'EntryAbility',

params: { method: 'yulvCardControl', control, tone }

});

postCardAction(this, {

action: 'message',

params: { control, tone, message: JSON.stringify({ control, tone }) }

});

`

call 让 EntryAbility 执行真正的播放控制,message 让 FormAbility 更新卡片自身状态。这样卡片 UI 和主应用音频状态能一起动。

10. 卡片 UI 的本地乐观更新

卡片按钮点击后,先改变本地状态:

s this.localPlaying = !this.localPlaying; if (this.localPlaying) { this.startBeatLoop(); } else { this.stopBeatLoop(); }

这叫乐观更新。它能让用户点击后立刻看到反馈,不必等 Ability 处理完成再变化。

11. EntryAbility 中的进度同步

主 Ability 自己维护卡片进度:

s private startCardProgressTimer(): void { this.stopCardProgressTimer(); this.cardProgressSec = 0; this.pushCardProgress(); this.cardProgressTimerId = setInterval((): void => { this.cardProgressSec += 1; this.pushCardProgress(); }, 1000) as number; }

再通过 ormProvider.updateForm() 推给卡片:

s formProvider.updateForm( this.cardFormId, formBindingData.createFormBindingData(data) );

这部分是文章的重点,因为它展示了桌面卡片不是普通页面,状态更新必须通过系统提供的 Form 数据通道。

12. 删除卡片时为什么要暂停

EntryFormAbility 的 onRemoveForm() 中调用:

s void YulvCardAudioController.pause(); void this.writePauseSignal();

用户删除卡片后,如果音频继续播放,会造成状态不一致。项目还写入 pause_seq,让主 Ability 的轮询监控能感知暂停信号。

13. 卡片文章可以强调的踩坑点

` ext

  1. formId 必须保留,否则 updateForm 找不到目标卡片
  2. 2x4 和 4x4 要分开设计,不能只缩放同一套布局
  3. 卡片点击要有即时反馈,避免用户误以为没点上
  4. 主应用销毁时要 release 卡片播放器
  5. 删除卡片时要同步暂停音频
    `

要分开设计,不能只缩放同一套布局

  1. 卡片点击要有即时反馈,避免用户误以为没点上

  2. 主应用销毁时要 release 卡片播放器

  3. 删除卡片时要同步暂停音频

`

相关推荐
爱吃大芒果8 小时前
复刻 HarmonyKit 工具箱:基于新版 @kit 开发本地工具 App
华为·harmonyos
风华圆舞8 小时前
鸿蒙上跑通第一个 AI Agent:5 分钟接入智谱/DeepSeek
人工智能·华为·harmonyos·arkts·arkui·toolregistry
xd1855785559 小时前
电影匹配引擎-基于鸿蒙的心情电影推荐应用开发实践
人工智能·安全·华为·harmonyos·鸿蒙
千逐6810 小时前
HarmonyOS 实战 | @State 不神秘——变量一变界面就跟着变
华为·harmonyos·鸿蒙
不才难以繁此生11 小时前
NavPathStack 返回错页怎么排查:中式美食详情页从搜索、收藏和推荐进入怎么回到原处
harmonyos·arkts·arkui·中式美食·navpathstack
xd18557855513 小时前
梦境解析师-基于鸿蒙的梦境心理分析应用开发实践
人工智能·华为·harmonyos·鸿蒙
千逐6814 小时前
鸿蒙实战:一多自适应与原子化布局 —— 一次开发多端部署的工程化落地
华为·harmonyos·鸿蒙
解局易否结局15 小时前
鸿蒙原生开发实战:Native 并发与高性能计算
华为·harmonyos
xo1988201115 小时前
鸿蒙 AI 自动化开发:让 AI 学会自己编译、自己调试
人工智能·自动化·harmonyos
HarmonyOS_SDK15 小时前
差异化推送消息能力,助力支付宝腕上消息业务闭环
harmonyos