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



本文适合标签:HarmonyOS、ArkTS、元服务卡片、FormExtensionAbility、桌面卡片
对应文件:
EntryFormAbility.ets、YulvPlayerCard.ets、EntryAbility.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);
}
这里同时发送 call 和 message,让主 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
- formId 必须保留,否则 updateForm 找不到目标卡片
- 2x4 和 4x4 要分开设计,不能只缩放同一套布局
- 卡片点击要有即时反馈,避免用户误以为没点上
- 主应用销毁时要 release 卡片播放器
- 删除卡片时要同步暂停音频
`
要分开设计,不能只缩放同一套布局
-
卡片点击要有即时反馈,避免用户误以为没点上
-
主应用销毁时要 release 卡片播放器
-
删除卡片时要同步暂停音频
`