鸿蒙运动健康实战:给应用加上“实况窗”,锁屏也能看数据
摘要:本文介绍了鸿蒙实况窗(Live View Kit)在运动轨迹应用中的实现方案。通过状态栏胶囊、锁屏卡片和通知中心卡片三种形态,实况窗解决了运动过程中频繁解锁查看数据的痛点。文章详细讲解了实况窗的技术选型、权益申请流程,并提供了核心代码实现LiveViewManager单例,包括创建、更新和结束实况窗的全生命周期管理。该方案实现了运动数据的实时展示、自动刷新和卡片交互功能,提升了用户体验。
完整源码:SportTrackDemo
实况窗核心代码在 common/managers/LiveViewManager.ets
一、为什么需要实况窗?
运动过程中,用户最烦的事情是什么?频繁解锁手机看数据。
- 跑着跑着想看配速 → 掏出手机、解锁、打开 App → 打断节奏。
- 锁屏后想知道跑了多远 → 只能点亮屏幕看通知栏(如果没有实况窗,什么都看不到)。
鸿蒙 Live View Kit(实况窗) 完美解决这个问题:状态栏胶囊一瞥即知配速和距离,锁屏卡片完整展示运动数据,通知中心随时查看详细指标,甚至卡片上直接控制暂停/继续。用户全程无需解锁,运动体验直线上升。
基于"运动轨迹"工程之前已实现运动数据采集、轨迹绘制、后台长时任务。本节在运动轨迹代码基础上,集成本地实况窗功能,实现:
- 开始运动时自动创建实况窗,返回后台胶囊出现
- 点击胶囊可以查看详细内容卡片
- 运动数据每 3 秒自动刷新胶囊和卡片
- 暂停/恢复时实况窗标题同步变化,卡片按钮图标自动切换
- 运动结束后实况窗直接删除,不留残留卡片
运行效果:
锁屏状态下录屏直接会退出,我就展示真实的手机截图了。
| 状态栏胶囊 | 锁屏卡片 | 通知中心卡片 |
|---|---|---|
![]() |
![]() |
![]() |
二、实况窗技术选型
| 能力 | 鸿蒙官方 API | 说明 |
|---|---|---|
| 实况窗生命周期 | liveViewManager |
创建、更新、结束实况窗 |
| 场景类型 | WORKOUT |
运动锻炼(官方支持) |
| 胶囊形态 | TextCapsule |
显示配速(大号)+ 距离(小号) |
| 卡片形态 | FlightLayout |
左右文本模板,适合展示配速/距离 |
| 进度模板 | ProgressLayout |
有目标距离时显示完成百分比 |
| 点击卡片返回 | WantAgent |
点击卡片返回应用 |
| 带图标按钮 | ExtensionData |
卡片底部添加图标+文字的可点击区(暂停/继续) |
三、实况窗权益申请
在使用实况窗前,需要在 AppGallery Connect 中申请“实况窗服务”和推送权益。 实况窗申请通过后通过 增长 → 推送服务 → 配置 → 添加测试设备 来绑定测试设备 push token。不是所有的应用类型都符合实况窗申请要求,确定你的应用类型在Live View Kit官方文档中查看是否有符合的类型。我写的这个“运动轨迹”整好符合运动锻炼场景类型。
3.1 申请入口
- 登录 AppGallery Connect。
- 进入你的项目 → 项目设置 → 开放能力管理。
- 找到 实况窗服务,点击 申请。
- 申请通过后就会打钩

3.2 申请填写内容
1. 场景类型
运动锻炼(WORKOUT)
2. 场景描述
本应用为运动健康类应用,主要为用户提供跑步、骑行、健走等户外运动记录功能。为提升用户运动过程中的信息查看体验,现申请运动锻炼类实况窗场景权限,用于在用户运动过程中,通过实况窗实时展示运动时长、配速、距离、心率等核心运动数据,实现用户无需进入应用即可快速查看运动状态的业务需求,确保运动数据实时、直观、便捷展示。
3. 附件说明
附件为运动实况窗场景设计方案效果图。
关于设计规范官方文档中有,自行查看即可。
3.3 提交后等待审核
- 审核周期:1-5个工作日。
- 审核通过后,需在“证书、APP ID和Profile”页面重新生成 Profile 文件,并打包到应用中。
3.4 添加测试设备
- 获取测试设备push token
- 将push token 添加到测试设备管理中
- 重新配置Profile文件下载下来真机测试
// 获取 Push Token
pushService.getToken().then(token => {
hilog.info(0x0000, 'testTag', 'Succeeded in getting push token' + token);
}).catch((err: BusinessError) => {
hilog.error(0x0000, 'testTag', 'Failed to get push token: %{public}d %{public}s', err.code, err.message);
})

上边的一切准备妥当仅仅是开始,关于代码还有不少坑要踩,报错 401 几乎都是参数问题(必填字段缺失、图片资源不存在等)。开通实况窗权益后只有三个月测试时间哦。
四、核心实现:LiveViewManager
我们封装一个 LiveViewManager 单例,统一管理实况窗的创建、更新、结束。
4.1 完整代码
// common/managers/LiveViewManager.ets
import { liveViewManager } from '@kit.LiveViewKit';
import { Want, wantAgent } from '@kit.AbilityKit';
import { common } from '@kit.AbilityKit';
export class LiveViewManager {
private static instance: LiveViewManager;
private context?: common.UIAbilityContext;
private currentLiveViewId: number = -1;
private isEnabled: boolean = false;
private iconName: string = 'icon_running.png';
private currentTargetDistance: number = 0;
static getInstance(): LiveViewManager {
if (!LiveViewManager.instance) {
LiveViewManager.instance = new LiveViewManager();
}
return LiveViewManager.instance;
}
initialize(context: common.UIAbilityContext) {
this.context = context;
}
private async checkEnabled(): Promise<boolean> {
try {
this.isEnabled = await liveViewManager.isLiveViewEnabled();
return this.isEnabled;
} catch (err) {
console.error(`检查实况窗开关失败: ${JSON.stringify(err)}`);
return false;
}
}
private async buildWantAgent(): Promise<Want> {
if (!this.context) throw new Error('Context not initialized');
const wantAgentInfo: wantAgent.WantAgentInfo = {
wants: [{ bundleName: this.context.abilityInfo.bundleName, abilityName: this.context.abilityInfo.name }],
actionType: wantAgent.OperationType.START_ABILITY,
requestCode: 0,
actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
};
return await wantAgent.getWantAgent(wantAgentInfo);
}
private async buildControlWantAgent(action: string): Promise<Want> {
if (!this.context) throw new Error('Context not initialized');
const wantAgentInfo: wantAgent.WantAgentInfo = {
wants: [{
bundleName: this.context.abilityInfo.bundleName,
abilityName: this.context.abilityInfo.name,
parameters: { 'action': action }
}],
actionType: wantAgent.OperationType.START_ABILITY,
requestCode: 0,
actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
};
return await wantAgent.getWantAgent(wantAgentInfo);
}
// ========== 公共辅助方法 ==========
private formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
// 胶囊灵动岛
private buildCapsule(paceStr: string, distanceKm: number, isPaused: boolean = false): liveViewManager.TextCapsule {
return {
type: liveViewManager.CapsuleType.CAPSULE_TYPE_TEXT,
status: isPaused ? 2 : 1,
icon: this.iconName, // 图标
backgroundColor: '#FF007Dff',
title: paceStr, // 配速
content: `${distanceKm.toFixed(isPaused ? 1 : 2)} km`, // 距离
};
}
// 内容信息左右排版
private buildFlightLayout(paceStr: string, distanceKm: number, isUpdate: boolean = false): liveViewManager.FlightLayout {
const distanceFormatted = isUpdate ? `${distanceKm.toFixed(1)}km` : `${distanceKm.toFixed(2)} km`;
return {
layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_FLIGHT,
firstTitle: paceStr,
firstContent: '配速',
lastTitle: distanceFormatted,
lastContent: '距离',
spaceIcon: 'space_arrow.png',
isHorizontalLineDisplayed: true,
};
}
// 进度内容
private buildProgressLayout(progressPercent: number): liveViewManager.ProgressLayout {
return {
layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_PROGRESS,
progress: progressPercent,
color: '#FF317AF7',
backgroundColor: '#FFE6F0FA',
indicatorType: liveViewManager.IndicatorType.INDICATOR_TYPE_UP,
lineType: liveViewManager.LineType.LINE_TYPE_NORMAL_SOLID_LINE,
indicatorIcon:'indicator_default.png', // 进度条上方的指示器
nodeIcons: ['node_default.png','node_default.png','node_default.png'] // 节点图片
};
}
// 辅助按钮
private async buildExtensionData(isPaused: boolean): Promise<liveViewManager.ExtensionData> {
return {
type: liveViewManager.ExtensionType.EXTENSION_TYPE_PIC,
pic: isPaused ? 'resume_icon.png' : 'pause_icon.png',
text: isPaused ? '继续' : '暂停',
clickAction: await this.buildControlWantAgent(isPaused ? 'resume' : 'pause')
};
}
// ========== 创建实况窗 ==========
async createWorkoutLiveView(
sportType: string = '跑步',
targetDistance: number = 0,
initialDistance: number = 0,
initialPace: number = 0,
initialDuration: number = 0
): Promise<boolean> {
if (!await this.checkEnabled()) {
console.warn('实况窗未开启,跳过创建');
return false;
}
if (this.currentLiveViewId !== -1) {
await this.stopWorkoutLiveView();
}
this.currentTargetDistance = targetDistance;
const id = Date.now();
const clickAction = await this.buildWantAgent();
const initialKm = initialDistance / 1000;
const paceMin = Math.floor(initialPace / 60);
const paceSec = Math.floor(initialPace % 60);
const paceStr = initialPace > 0 ? `${paceMin}'${paceSec.toString().padStart(2, '0')}"` : '0\'00"';
const durationStr = this.formatDuration(initialDuration);
const capsule = this.buildCapsule(paceStr, initialKm, false);
const layoutData = targetDistance > 0
? this.buildProgressLayout(Math.min(100, Math.floor((initialDistance / targetDistance) * 100)))
: this.buildFlightLayout(paceStr, initialKm, false);
const extensionData = await this.buildExtensionData(false);
const content: liveViewManager.RichText[] = [{ text: `时长 ${durationStr}` }];
const liveView: liveViewManager.LiveView = {
id: id,
event: 'WORKOUT',
liveViewData: {
primary: {
// 标题
title: targetDistance > 0 ? `${sportType} 目标${(targetDistance / 1000).toFixed(1)}km` : `${sportType}中`,
content: content, // 子标题
keepTime: 10,
clickAction: clickAction,
layoutData: layoutData,
extensionData: extensionData
},
capsule: capsule
}
};
try {
const result = await liveViewManager.startLiveView(liveView);
if (result.resultCode === 0) {
this.currentLiveViewId = id;
console.info('实况窗创建成功');
return true;
} else {
console.error(`实况窗创建失败: ${result.message}`);
return false;
}
} catch (err) {
console.error(`实况窗创建异常: ${JSON.stringify(err)}`);
return false;
}
}
// ========== 更新实况窗 ==========
async updateWorkoutLiveView(
durationSec: number,
distanceMeters: number,
avgPaceSecPerKm: number,
currentSpeedKmh: number,
targetDistance: number = 0,
isPaused: boolean = false
): Promise<boolean> {
if (this.currentLiveViewId === -1) return false;
if (!await this.checkEnabled()) return false;
const effectiveTarget = targetDistance > 0 ? targetDistance : this.currentTargetDistance;
const distanceKm = distanceMeters / 1000;
const paceMin = Math.floor(avgPaceSecPerKm / 60);
const paceSec = Math.floor(avgPaceSecPerKm % 60);
const paceStr = `${paceMin}'${paceSec.toString().padStart(2, '0')}"`;
const durationStr = this.formatDuration(durationSec);
const title = isPaused ? `跑步已暂停` : `跑步中`;
const content: liveViewManager.RichText[] = [{ text: `时长 ${durationStr}` }];
const layoutData = effectiveTarget > 0
? this.buildProgressLayout(Math.min(100, Math.floor((distanceMeters / effectiveTarget) * 100)))
: this.buildFlightLayout(paceStr, distanceKm, true);
const extensionData = await this.buildExtensionData(isPaused);
const capsule = this.buildCapsule(paceStr, distanceKm, isPaused);
const liveView: liveViewManager.LiveView = {
id: this.currentLiveViewId,
event: 'WORKOUT',
liveViewData: {
primary: {
title: title,
content: content,
keepTime: 10,
clickAction: await this.buildWantAgent(),
layoutData: layoutData,
extensionData: extensionData
},
capsule: capsule
}
};
try {
const result = await liveViewManager.updateLiveView(liveView);
return result.resultCode === 0;
} catch (err) {
console.error(`更新实况窗失败: ${JSON.stringify(err)}`);
return false;
}
}
// ========== 结束实况窗 ==========
async stopWorkoutLiveView(): Promise<boolean> {
if (this.currentLiveViewId === -1) return false;
if (!await this.checkEnabled()) {
this.currentLiveViewId = -1;
return false;
}
const layoutData: liveViewManager.FlightLayout = {
layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_FLIGHT,
firstTitle: '运动结束',
firstContent: '感谢使用',
spaceIcon: 'space_arrow.png',
};
const liveView: liveViewManager.LiveView = {
id: this.currentLiveViewId,
event: 'WORKOUT',
liveViewData: {
primary: {
title: '运动结束',
content: [{ text: '感谢使用' }],
keepTime: 1,
layoutData: layoutData,
}
}
};
try {
const result = await liveViewManager.stopLiveView(liveView);
if (result.resultCode === 0) {
this.currentLiveViewId = -1;
this.currentTargetDistance = 0;
console.info('实况窗已删除');
}
return result.resultCode === 0;
} catch (err) {
console.error(`结束实况窗失败: ${JSON.stringify(err)}`);
return false;
}
}
getCurrentLiveViewId(): number {
return this.currentLiveViewId;
}
}
4.2 核心设计说明
- 胶囊:
icon小图标title显示配速,content显示距离,用户一瞥即知关键指标。 - 卡片布局:使用
FlightLayout左右布局,左侧大号配速数值 + 小字“配速”,右侧大号距离数值 + 小字“距离”。spaceIcon字段必须提供图片(可用透明占位图),否则创建失败。 - 卡片顶部副标题:
primary.content仅显示运动时长,避免与下方左右布局重复。 - 辅助区按钮:通过
ExtensionData实现带图标的可点击区,根据isPaused动态切换图标和文字,点击后通过WantAgent携带动作参数(pause/resume)通知应用。 - 更新频率:建议每 3 秒调用一次
updateWorkoutLiveView(通过节流控制),避免触发系统频率限制(错误码1003500008)。 - 结束处理:提供合法的结束卡片,
keepTime: 1让卡片快速消失,几乎无感。
关于实况窗、每一种类型都有不同的设计规范要求。我当前把FlightLayout完整的功能做出来,但是进度类型的我只做了简单测试能显示,进度类型更适合流程步骤,例如定外卖从下单到送达每一个节点。
五、在运动页面集成实况窗
修改 Index.ets,在运动控制流程中加入实况窗调用。
5.1 初始化 LiveViewManager
import { LiveViewManager } from '../../common/managers/LiveViewManager';
@Entry
@Component
struct Index {
private liveViewManager: LiveViewManager = LiveViewManager.getInstance();
private lastLiveViewUpdate: number = 0;
aboutToAppear() {
// ... 原有初始化 ...
this.liveViewManager.initialize(this.context);
}
}
5.2 开始运动时创建实况窗
private async startTracking() {
// ... 重置状态、初始化 trackManager 等 ...
const created = await this.liveViewManager.createWorkoutLiveView('跑步', 0, 0, 0, 0);
if (!created) console.warn('实况窗创建失败');
// 启动定位和定时器...
}
5.3 运动数据更新时刷新实况窗(节流 3 秒)
在 workoutSession.onUpdate 回调中添加:
this.workoutSession.onUpdate((data: SportData): void => {
this.sportData = data;
const now = Date.now();
if (now - this.lastLiveViewUpdate >= 3000) {
this.lastLiveViewUpdate = now;
this.liveViewManager.updateWorkoutLiveView(
data.duration,
data.distance,
data.avgPace,
data.currentSpeed,
0,
this.isPaused
);
}
});
5.4 暂停/恢复时立即更新状态
private pauseTracking() {
// ... 暂停定位 ...
this.liveViewManager.updateWorkoutLiveView(
this.sportData.duration,
this.sportData.distance,
this.sportData.avgPace,
this.sportData.currentSpeed,
0,
true
);
}
private resumeTracking() {
// ... 恢复定位 ...
this.liveViewManager.updateWorkoutLiveView(
this.sportData.duration,
this.sportData.distance,
this.sportData.avgPace,
this.sportData.currentSpeed,
0,
false
);
}
5.5 处理按钮点击
在 EntryAbility.ts 中重写 onNewWant,将动作存储到 AppStorage:
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
const action = want.parameters?.action as string;
if (action === 'pause') {
AppStorage.setOrCreate('workoutAction', 'pause');
} else if (action === 'resume') {
AppStorage.setOrCreate('workoutAction', 'resume');
}
}
在 Index.ets 中监听并执行:
@Watch('onWorkoutActionChange')
@StorageLink('workoutAction') workoutAction: string = '';
onWorkoutActionChange() {
if (this.workoutAction === 'pause') {
this.pauseTracking();
} else if (this.workoutAction === 'resume') {
this.resumeTracking();
}
AppStorage.setOrCreate('workoutAction', '');
}
5.6 结束运动时删除实况窗
private async stopTracking() {
// ... 停止定位、保存轨迹 ...
await this.liveViewManager.stopWorkoutLiveView();
}
六、图标资源清单
时实况涉及到的图标文件都放在 entry/src/main/resources/rawfile/ 目录下这是规定,需注意API中很多参数是可选地,但是如果不写会抛出401错误创建失败。
七、踩坑与经验总结
1. 实况窗创建失败,返回 code 401
- 原因:未在 AGC 申请实况窗权益;或
primary.content为空数组;或图片文字资源不全; - 解决:提交权益申请并等待通过;确保资源信息齐全;可通过查看err.message 查看错误信息,会具体到某一个参数。
2. 胶囊或卡片不显示
- 原因:系统实况窗开关未开启或创建失败
- 解决:引导用户去 设置 → 应用 → 你的应用 → 通知管理 → 实况窗 开启。
3. 更新频率过高导致性能问题或系统限流(错误码 1003500008)
- 原因:每次定位回调都更新实况窗(可能每秒多次)。
- 解决:增加节流控制,每 3 秒更新一次。
4. 应用退到后台后实况窗不更新
- 原因:更新逻辑放在 UI 定时器中,后台被冻结。
- 解决:将实况窗更新放在运动数据回调(
workoutSession.onUpdate)中,依赖运动会话的持续计时。
5. 卡片信息重复(配速和距离显示两次)
- 原因:
primary.content和FlightLayout同时展示相同数据。 - 解决:
primary.content是副标题可以用来显示时长,FlightLayout标题下方的左右布局容器,可负责显示配速/距离。
6. serviceButtons 无法显示图标
- 原因:
ServiceButton接口没有icon字段。 - 解决:改用
ExtensionData+EXTENSION_TYPE_PIC实现带图标的单个按钮(如暂停/继续)。
7. 点击卡片按钮后应用被拉起但页面不响应
- 原因:
WantAgent触发的onNewWant中未将动作传递给页面。 - 解决:通过
AppStorage全局状态通信,页面使用@StorageLink监听变化。
8. 运动结束后还残留“运动结束”卡片
- 原因:
stopWorkoutLiveView也需要完成的卡片内容不能缺失,否则会失败,其次keepTime设置停留时间。 - 解决:提供简单结束卡片,
keepTime: 0不停留,用户几乎无感知。
八、总结
第一次做实况窗确实会感觉比较麻烦,准备设计稿申请材料实况窗开通权益,然后还需完成相关配置才能开始测试代码。测试过程中慢慢熟悉实况窗布局参数,做到精准修改。如果觉得有用,请点赞、收藏、转发支持!
更多推荐







所有评论(0)