完整源码:SportTrackDemo
实况窗核心代码在 common/managers/LiveViewManager.ets

一、为什么需要实况窗?

运动过程中,用户最烦的事情是什么?频繁解锁手机看数据

  • 跑着跑着想看配速 → 掏出手机、解锁、打开 App → 打断节奏。
  • 锁屏后想知道跑了多远 → 只能点亮屏幕看通知栏(如果没有实况窗,什么都看不到)。

鸿蒙 Live View Kit(实况窗) 完美解决这个问题:状态栏胶囊一瞥即知配速和距离,锁屏卡片完整展示运动数据,通知中心随时查看详细指标,甚至卡片上直接控制暂停/继续。用户全程无需解锁,运动体验直线上升。

基于"运动轨迹"工程之前已实现运动数据采集、轨迹绘制、后台长时任务。本节在运动轨迹代码基础上,集成本地实况窗功能,实现:

  • 开始运动时自动创建实况窗,返回后台胶囊出现
  • 点击胶囊可以查看详细内容卡片
  • 运动数据每 3 秒自动刷新胶囊和卡片
  • 暂停/恢复时实况窗标题同步变化,卡片按钮图标自动切换
  • 运动结束后实况窗直接删除,不留残留卡片

运行效果
锁屏状态下录屏直接会退出,我就展示真实的手机截图了。

状态栏胶囊 锁屏卡片 通知中心卡片
实况窗状态栏胶囊.jpg 实况窗锁屏卡片.jpg 实况窗通知中心卡片.jpg

二、实况窗技术选型

能力 鸿蒙官方 API 说明
实况窗生命周期 liveViewManager 创建、更新、结束实况窗
场景类型 WORKOUT 运动锻炼(官方支持)
胶囊形态 TextCapsule 显示配速(大号)+ 距离(小号)
卡片形态 FlightLayout 左右文本模板,适合展示配速/距离
进度模板 ProgressLayout 有目标距离时显示完成百分比
点击卡片返回 WantAgent 点击卡片返回应用
带图标按钮 ExtensionData 卡片底部添加图标+文字的可点击区(暂停/继续)

三、实况窗权益申请

在使用实况窗前,需要在 AppGallery Connect 中申请“实况窗服务”和推送权益。 实况窗申请通过后通过 增长 → 推送服务 → 配置 → 添加测试设备 来绑定测试设备 push token。不是所有的应用类型都符合实况窗申请要求,确定你的应用类型在Live View Kit官方文档中查看是否有符合的类型。我写的这个“运动轨迹”整好符合运动锻炼场景类型。

3.1 申请入口

  1. 登录 AppGallery Connect
  2. 进入你的项目 → 项目设置 → 开放能力管理
  3. 找到 实况窗服务,点击 申请
  4. 申请通过后就会打钩
    申请实况窗服务.png

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);
})

调测设备管理.png

上边的一切准备妥当仅仅是开始,关于代码还有不少坑要踩,报错 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.contentFlightLayout 同时展示相同数据。
  • 解决primary.content 是副标题可以用来显示时长,FlightLayout 标题下方的左右布局容器,可负责显示配速/距离。

6. serviceButtons 无法显示图标

  • 原因ServiceButton 接口没有 icon 字段。
  • 解决:改用 ExtensionData + EXTENSION_TYPE_PIC 实现带图标的单个按钮(如暂停/继续)。

7. 点击卡片按钮后应用被拉起但页面不响应

  • 原因WantAgent 触发的 onNewWant 中未将动作传递给页面。
  • 解决:通过 AppStorage 全局状态通信,页面使用 @StorageLink 监听变化。

8. 运动结束后还残留“运动结束”卡片

  • 原因stopWorkoutLiveView 也需要完成的卡片内容不能缺失,否则会失败,其次keepTime设置停留时间。
  • 解决:提供简单结束卡片,keepTime: 0不停留,用户几乎无感知。

八、总结

第一次做实况窗确实会感觉比较麻烦,准备设计稿申请材料实况窗开通权益,然后还需完成相关配置才能开始测试代码。测试过程中慢慢熟悉实况窗布局参数,做到精准修改。如果觉得有用,请点赞、收藏、转发支持!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐