HarmonyOS开发:动态卡片开发——DynamicCard与交互
HarmonyOS开发:动态卡片开发——DynamicCard与交互
📌 核心要点:动态卡片由应用进程渲染,支持实时数据更新和丰富交互,是静态卡片满足不了需求时的进阶选择。
背景与动机
静态卡片能覆盖大部分信息展示场景,但有些需求它真搞不定。
比如音乐播放器卡片——用户要点播放、暂停、切歌,还要实时看到播放进度条在动。静态卡片?更新频率最低1小时一次,进度条动个锤子。
再比如运动追踪卡片——实时显示跑步距离、配速、心率,数据每秒都在变。静态卡片的数据更新机制根本跟不上。
还有股票行情、即时通讯消息、倒计时……这些场景有一个共同特点:数据实时变化 + 用户需要即时交互。
这就是动态卡片存在的意义。它的UI由你的应用进程直接渲染,想怎么动就怎么动,不受系统更新频率限制。
但别高兴太早。动态卡片的能力更强,代价也更大——内存占用高、开发复杂度高、应用进程被杀卡片就白屏。选不选用,得想清楚。
核心原理
动态卡片 vs 静态卡片:架构差异
flowchart TB
subgraph 静态卡片架构
direction TB
A1[FormAbility] -->|FormBindingData| B1[系统渲染引擎]
B1 --> C1[桌面卡片]
end
subgraph 动态卡片架构
direction TB
A2[UIAbility进程] -->|直接渲染| B2[卡片渲染上下文]
B2 --> C2[桌面卡片]
A2 -->|formProvider| D2[卡片状态管理]
D2 --> B2
end
classDef static fill:#E3F2FD,stroke:#1565C0,color:#0D47A1
classDef dynamic fill:#FCE4EC,stroke:#C62828,color:#B71C1C
classDef engine fill:#FFF3E0,stroke:#E65100,color:#BF360C
class A1 static
class B1 engine
class C1 static
class A2,B2,D2 dynamic
class C2 dynamic
核心区别一目了然:
- 静态卡片:FormAbility喂数据 → 系统渲染引擎渲染 → 桌面显示。你的进程渲染完就撤了。
- 动态卡片:UIAbility进程直接渲染卡片UI → 桌面显示。你的进程得一直活着。
这意味着动态卡片的渲染性能、内存占用、进程保活,全都是你要操心的事。
动态卡片的工作流程
sequenceDiagram
participant User as 用户
participant Desktop as 桌面
participant FMS as FormManager
participant UIAbility as UIAbility进程
participant Card as 卡片渲染上下文
User->>Desktop: 长按添加动态卡片
Desktop->>FMS: 请求创建卡片
FMS->>UIAbility: onCreateForm回调
UIAbility->>Card: 创建卡片渲染上下文
Card-->>Desktop: 卡片UI显示
loop 实时更新
UIAbility->>Card: 更新状态数据
Card-->>Desktop: UI实时刷新
end
User->>Card: 点击交互
Card->>UIAbility: 处理交互事件
UIAbility->>Card: 返回更新结果
Card-->>Desktop: UI更新
动态卡片的关键在于"卡片渲染上下文"——它是UIAbility进程内的一个独立渲染区域,和你的主界面共享同一个进程,但有自己的组件树。
动态卡片的配置
动态卡片在form_config.json里的配置和静态卡片略有不同:
{
"forms": [{
"name": "musicPlayer",
"displayName": "音乐播放",
"description": "控制音乐播放",
"src": "./ets/widget/pages/MusicPlayerCard.ets",
"uiSyntax": "arkts",
"isDynamic": true,
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "10:00",
"updateDuration": 0,
"defaultDimension": "4*4",
"supportDimensions": ["2*4", "4*4"]
}]
}
关键区别是isDynamic: true。5.0里这个字段不是必须的(系统通过uiSyntax判断),但6.0建议显式声明。
代码实战
基础用法:音乐播放器动态卡片
先来个最经典的动态卡片场景——音乐播放控制。
// MusicPlayerCard.ets
@Entry
@Component
struct MusicPlayerCard {
// 卡片状态
@State songName: string = '未播放'
@State artist: string = ''
@State isPlaying: boolean = false
@State currentTime: number = 0
@State totalTime: number = 0
@State coverUrl: string = ''
build() {
Column() {
// 歌曲信息
Row() {
// 封面——动态卡片支持网络图片!
Image(this.coverUrl || $r('app.media.default_cover'))
.width(48)
.height(48)
.borderRadius(8)
.objectFit(ImageFit.Cover)
Column() {
Text(this.songName)
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.artist)
.fontSize(11)
.fontColor('#999999')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
// 进度条
Progress({
value: this.currentTime,
total: this.totalTime,
type: ProgressType.Linear
})
.width('100%')
.color('#FF6B35')
.backgroundColor('#EEEEEE')
.margin({ top: 12 })
.style({ strokeWidth: 4 })
// 时间显示
Row() {
Text(this.formatTime(this.currentTime))
.fontSize(10)
.fontColor('#999999')
Blank()
Text(this.formatTime(this.totalTime))
.fontSize(10)
.fontColor('#999999')
}
.width('100%')
.margin({ top: 4 })
// 控制按钮
Row() {
// 上一曲
Image($r('app.media.ic_prev'))
.width(32)
.height(32)
.onClick(() => {
this.sendAction('prev')
})
// 播放/暂停
Image(this.isPlaying ? $r('app.media.ic_pause') : $r('app.media.ic_play'))
.width(40)
.height(40)
.margin({ left: 24, right: 24 })
.onClick(() => {
this.sendAction(this.isPlaying ? 'pause' : 'play')
})
// 下一曲
Image($r('app.media.ic_next'))
.width(32)
.height(32)
.onClick(() => {
this.sendAction('next')
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 12 })
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
// 发送交互事件给UIAbility
private sendAction(action: string): void {
postCardAction(this, {
action: 'message',
params: {
action: action,
formId: this.formId // 卡片ID
}
})
}
// 格式化时间
private formatTime(seconds: number): string {
const min = Math.floor(seconds / 60).toString().padStart(2, '0')
const sec = (seconds % 60).toString().padStart(2, '0')
return `${min}:${sec}`
}
}
等等,postCardAction是什么?这是动态卡片专用的交互API,用来向UIAbility发送消息。它和静态卡片的router/call不同,动态卡片的交互更直接——直接在进程内通信,不需要跨进程。
进阶用法:UIAbility与动态卡片通信
动态卡片和UIAbility在同一个进程里,通信比静态卡片方便得多。核心API是formProvider。
// MusicUIAbility.ets
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit'
import { formProvider, formBindingData, formInfo } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
// 全局音乐状态(简化示例,实际项目用状态管理框架)
let musicState: MusicState = {
songName: '晴天',
artist: '周杰伦',
isPlaying: false,
currentTime: 0,
totalTime: 258,
coverUrl: 'https://example.com/cover.jpg'
}
// 存储所有动态卡片的formId
let activeFormIds: Set<string> = new Set()
export default class MusicAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(0x0000, 'MusicAbility', 'onCreate')
}
// 处理卡片发来的消息
onFormEvent(formId: string, message: string): void {
hilog.info(0x0000, 'MusicAbility', `收到卡片消息: ${message}`)
const msg = JSON.parse(message) as CardMessage
switch (msg.action) {
case 'play':
musicState.isPlaying = true
this.startPlayback()
break
case 'pause':
musicState.isPlaying = false
this.pausePlayback()
break
case 'prev':
this.playPrevious()
break
case 'next':
this.playNext()
break
}
// 更新所有活跃卡片
this.updateAllCards()
}
// 更新所有活跃的动态卡片
private updateAllCards(): void {
const formData = {
songName: musicState.songName,
artist: musicState.artist,
isPlaying: musicState.isPlaying,
currentTime: musicState.currentTime,
totalTime: musicState.totalTime,
coverUrl: musicState.coverUrl
}
const bindingData = formBindingData.createFormBindingData(formData)
activeFormIds.forEach(formId => {
formProvider.updateForm(formId, bindingData)
.then(() => {
hilog.info(0x0000, 'MusicAbility', `卡片更新成功: ${formId}`)
})
.catch((err: Error) => {
hilog.error(0x0000, 'MusicAbility', `卡片更新失败: ${err.message}`)
// 更新失败可能是卡片已被删除,从集合中移除
activeFormIds.delete(formId)
})
})
}
// 模拟播放进度更新(实际项目用定时器或播放器回调)
private startPlayback(): void {
// 每秒更新一次进度
this.playbackTimer = setInterval(() => {
if (musicState.currentTime < musicState.totalTime) {
musicState.currentTime++
this.updateAllCards()
} else {
this.playNext()
}
}, 1000)
}
private pausePlayback(): void {
if (this.playbackTimer) {
clearInterval(this.playbackTimer)
this.playbackTimer = null
}
}
private playPrevious(): void {
// 切歌逻辑
musicState = {
songName: '稻香',
artist: '周杰伦',
isPlaying: true,
currentTime: 0,
totalTime: 223,
coverUrl: 'https://example.com/cover2.jpg'
}
}
private playNext(): void {
musicState = {
songName: '七里香',
artist: '周杰伦',
isPlaying: true,
currentTime: 0,
totalTime: 299,
coverUrl: 'https://example.com/cover3.jpg'
}
}
private playbackTimer: number | null = null
}
关键点:onFormEvent是UIAbility处理卡片消息的回调。动态卡片通过postCardAction发送消息,UIAbility通过onFormEvent接收,然后更新数据,再通过formProvider.updateForm推送给卡片。
完整示例:运动追踪动态卡片
来个更复杂的——运动追踪卡片,实时显示跑步数据,支持开始/暂停/结束操作。
// SportTrackerCard.ets
@Entry
@Component
struct SportTrackerCard {
@State distance: string = '0.00'
@State duration: string = '00:00:00'
@State pace: string = "--'--\""
@State calories: string = '0'
@State heartRate: string = '--'
@State isRunning: boolean = false
@State sportType: string = '跑步'
build() {
Column() {
// 运动类型标题
Row() {
Text(this.sportType)
.fontSize(16)
.fontColor('#333333')
.fontWeight(FontWeight.Bold)
Blank()
// 运动状态指示
Row() {
Circle({ width: 6, height: 6 })
.fill(this.isRunning ? '#4CAF50' : '#999999')
Text(this.isRunning ? '进行中' : '已暂停')
.fontSize(10)
.fontColor(this.isRunning ? '#4CAF50' : '#999999')
.margin({ left: 4 })
}
}
.width('100%')
// 核心数据区
Row() {
// 距离
Column() {
Text(this.distance)
.fontSize(28)
.fontColor('#FF6B35')
.fontWeight(FontWeight.Bold)
Text('公里')
.fontSize(10)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
// 时长
Column() {
Text(this.duration)
.fontSize(16)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
Text('时长')
.fontSize(10)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
}
.width('100%')
.margin({ top: 16 })
// 次要数据
Row() {
Column() {
Text(this.pace)
.fontSize(14)
.fontColor('#333333')
Text('配速')
.fontSize(10)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(this.calories)
.fontSize(14)
.fontColor('#333333')
Text('千卡')
.fontSize(10)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(this.heartRate)
.fontSize(14)
.fontColor('#E53935')
Text('心率')
.fontSize(10)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
}
.width('100%')
.margin({ top: 16 })
// 控制按钮
Row() {
// 开始/暂停
Button(this.isRunning ? '暂停' : '开始')
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor(this.isRunning ? '#FF9800' : '#4CAF50')
.borderRadius(20)
.width(80)
.height(36)
.onClick(() => {
this.sendAction(this.isRunning ? 'pause' : 'start')
})
// 结束
Button('结束')
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#F44336')
.borderRadius(20)
.width(80)
.height(36)
.margin({ left: 16 })
.onClick(() => {
this.sendAction('stop')
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 20 })
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
private sendAction(action: string): void {
postCardAction(this, {
action: 'message',
params: { action: action }
})
}
}
UIAbility端的处理:
// SportUIAbility.ets
import { UIAbility } from '@kit.AbilityKit'
import { formProvider, formBindingData } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
interface SportState {
distance: number // 米
duration: number // 秒
isRunning: boolean
calories: number
heartRate: number
}
let sportState: SportState = {
distance: 0,
duration: 0,
isRunning: false,
calories: 0,
heartRate: 72
}
let sportTimer: number | null = null
let sportFormIds: Set<string> = new Set()
export default class SportAbility extends UIAbility {
onFormEvent(formId: string, message: string): void {
const msg = JSON.parse(message)
switch (msg.action) {
case 'start':
sportState.isRunning = true
this.startTracking()
break
case 'pause':
sportState.isRunning = false
this.stopTracking()
break
case 'stop':
sportState.isRunning = false
this.stopTracking()
this.saveSportRecord()
this.resetState()
break
}
this.updateAllCards()
}
private startTracking(): void {
if (sportTimer) return
// 每秒更新一次运动数据
sportTimer = setInterval(() => {
sportState.duration++
// 模拟数据:每秒跑2.8米(配速约6分/公里)
sportState.distance += 2.8
sportState.calories = Math.floor(sportState.distance / 100 * 0.6)
// 模拟心率波动
sportState.heartRate = 120 + Math.floor(Math.random() * 30)
this.updateAllCards()
}, 1000)
}
private stopTracking(): void {
if (sportTimer) {
clearInterval(sportTimer)
sportTimer = null
}
}
private resetState(): void {
sportState = {
distance: 0,
duration: 0,
isRunning: false,
calories: 0,
heartRate: 72
}
}
private saveSportRecord(): void {
// 保存运动记录到数据库
hilog.info(0x0000, 'Sport', `保存记录: ${(sportState.distance / 1000).toFixed(2)}km`)
}
private updateAllCards(): void {
const km = (sportState.distance / 1000).toFixed(2)
const hours = Math.floor(sportState.duration / 3600).toString().padStart(2, '0')
const mins = Math.floor((sportState.duration % 3600) / 60).toString().padStart(2, '0')
const secs = (sportState.duration % 60).toString().padStart(2, '0')
// 计算配速(分钟/公里)
let pace = "--'--\""
if (sportState.distance > 0) {
const paceMin = sportState.duration / 60 / (sportState.distance / 1000)
const paceM = Math.floor(paceMin)
const paceS = Math.floor((paceMin - paceM) * 60)
pace = `${paceMin.toFixed(0)}'${paceS.toString().padStart(2, '0')}"`
}
const formData = {
distance: km,
duration: `${hours}:${mins}:${secs}`,
pace: pace,
calories: sportState.calories.toString(),
heartRate: sportState.heartRate.toString(),
isRunning: sportState.isRunning
}
const bindingData = formBindingData.createFormBindingData(formData)
sportFormIds.forEach(formId => {
formProvider.updateForm(formId, bindingData).catch(() => {
sportFormIds.delete(formId)
})
})
}
}
踩坑与注意事项
坑1:动态卡片进程保活
动态卡片的UI由你的应用进程渲染。如果系统杀掉了你的进程,卡片就白屏了。
怎么保活?没有银弹。但有几个策略:
- 长时任务:用
backgroundTaskManager.startContinuousTask()申请长时后台任务,系统会尽量不杀你的进程 - 降低内存占用:系统杀进程主要看内存,你占得少就不容易被杀
- 优雅降级:进程被杀后,卡片会显示最后一次渲染的画面。你可以在布局里加个"数据可能已过期"的提示
import { backgroundTaskManager } from '@kit.BackgroundTasksKit'
// 申请长时后台任务(音乐播放场景)
function requestContinuousTask(context: Context): void {
backgroundTaskManager.startContinuousTask(context,
backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK)
.then(() => {
hilog.info(0x0000, 'Music', '长时任务申请成功')
})
.catch((err: Error) => {
hilog.error(0x0000, 'Music', `长时任务申请失败: ${err.message}`)
})
}
注意:长时任务不是你想申请就能申请的,系统会审核。滥用会被拒绝,甚至被系统拉黑。
坑2:动态卡片内存限制
动态卡片的内存不是无限的。系统对动态卡片的内存占用有上限,超过上限会被系统强制回收。
具体限制因设备而异,但一般规则是:
- 单张动态卡片建议不超过10MB内存
- 所有动态卡片合计不超过50MB内存
这意味着你不能在动态卡片里加载大图、做复杂动画、维护大数据结构。卡片UI要尽量精简。
坑3:postCardAction的使用限制
postCardAction是动态卡片发送交互事件的核心API,但它有几个限制:
- 只能在卡片的onClick回调里调用——不能在定时器、生命周期回调里调用
- 参数大小有限制——消息体不能超过1KB
- 发送频率有限制——1秒内不能发送超过5次
// ❌ 错误:在定时器里调用
setInterval(() => {
postCardAction(this, { action: 'message', params: {} }) // 报错!
}, 1000)
// ✅ 正确:在onClick里调用
Button('播放')
.onClick(() => {
postCardAction(this, {
action: 'message',
params: { action: 'play' }
})
})
坑4:动态卡片不支持所有组件
虽然动态卡片比静态卡片支持更多组件,但也不是全部。以下组件在动态卡片中不支持:
Web组件XComponentPluginComponent- 部分自定义绘制能力
在写动态卡片UI之前,查一下官方文档的"动态卡片支持能力"列表。
坑5:更新频率与电量消耗
动态卡片可以实时更新,但"可以"不等于"应该"。每秒更新一次卡片,意味着每秒都要走一遍渲染管线,电量消耗是实打实的。
建议策略:
- 音乐播放器:进度条每秒更新,其他数据只在切歌时更新
- 运动追踪:数据每5秒更新一次,足够精确
- 即时通讯:只在收到新消息时更新,不要轮询
// ❌ 过度更新:每秒更新所有数据
setInterval(() => {
this.updateAllCards() // 电量杀手
}, 1000)
// ✅ 合理更新:进度单独更新,其他数据按需更新
setInterval(() => {
this.updateProgress() // 只更新进度条
}, 1000)
// 切歌时才更新歌曲信息
function onSongChanged() {
this.updateSongInfo()
}
HarmonyOS 6适配说明
-
动态卡片渲染架构升级:6.0把动态卡片的渲染从"独立渲染进程"改为"共享渲染引擎",内存占用降低约30%。但这也带来一个变化——你的动态卡片代码里不能再使用
UIContext相关的API,因为渲染上下文和主界面共享了。 -
postCardAction增强:6.0新增了
postCardAction的call模式,支持直接调用UIAbility的方法,不需要走onFormEvent回调。这让卡片和UIAbility的通信更简洁:
// 6.0新增的call模式
postCardAction(this, {
action: 'call',
abilityName: 'MusicAbility',
params: {
method: 'playNext', // 直接调用UIAbility的方法名
params: { songId: 123 }
}
})
- 动态卡片生命周期回调增强:6.0新增了
onFormVisibilityChange回调,当卡片从可见变为不可见(或反之)时触发。你可以利用这个回调暂停/恢复更新,节省电量:
onFormVisibilityChange(formId: string, isVisible: boolean): void {
if (isVisible) {
this.resumeUpdates(formId)
} else {
this.pauseUpdates(formId) // 卡片不可见时暂停更新
}
}
- 卡片沙箱文件访问:6.0对动态卡片的文件访问做了更严格的沙箱限制。动态卡片只能访问自己沙箱内的文件,不能访问应用主沙箱的文件。如果需要共享文件,得通过
DataShare或分布式数据。
总结
动态卡片是静态卡片的进阶形态,适合需要实时交互和数据更新的场景。但它的代价也不小——进程保活、内存控制、电量消耗,每一个都是坑。
选型建议:
- 信息展示为主 → 静态卡片
- 需要实时交互 → 动态卡片
- 两者结合 → 主卡片静态,控制面板动态
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ 进程通信、状态同步、保活策略都需要深入理解 |
| 使用频率 | ⭐⭐⭐ 特定场景才需要,不是每个元服务都用 |
| 重要程度 | ⭐⭐⭐⭐ 音乐、运动、即时通讯等核心场景离不开 |
下一篇聊FormProvider和数据驱动——卡片数据怎么更新、什么时候更新、更新频率怎么控制。
- 点赞
- 收藏
- 关注作者
评论(0)