请添加图片描述

前言

实况窗(Live View)是 HarmonyOS 中极具辨识度的系统级交互能力。它将应用的关键任务状态——如外卖配送进度、打车行程、航班动态——直接外显到状态栏胶囊、通知中心和锁屏界面,让用户无需打开应用即可"一眼掌握进度"。这种"信息主动找人"的范式,彻底改变了传统通知的被动模式。

本文将结合你提供的即时配送 Demo,从实况窗的三大形态、完整生命周期、本地与远程双通道更新机制、后台保活策略到工程化架构,系统性地拆解实况窗的开发实践。


一、 实况窗的三大形态:胶囊、卡片与沉浸态

实况窗在系统中有三种展示形态,分别对应不同的用户交互场景。Demo 中的 ProgressSection 通过 DELIVERY_STAGES[stageIndex]capsuleText 字段来驱动胶囊态的文案更新。

1.1 胶囊态(Capsule)

当应用退到后台后,实况窗以胶囊形态出现在状态栏。胶囊空间极其有限,只能展示最核心的信息——通常是一个图标 + 一行文字(如"配送中 2.5km"或"预计15分钟")。

在代码中,胶囊的配置通过 capsule 对象实现:

capsule: {
  type: liveViewManager.CapsuleType.CAPSULE_TYPE_TEXT,
  status: 1,
  icon: 'capsule_store.png',
  backgroundColor: '#FF308977',
  title: '待支付',
  content: '...'
}

胶囊态的设计原则是"一图一意"——一个图标只传达一个核心语义,避免信息过载。在 HarmonyOS 6.0 中,实况胶囊在挖孔设备上支持居中展示,左右文本信息量倍增。

1.2 卡片态(Card)

点击胶囊后展开为卡片,展示在通知中心或锁屏界面。卡片空间充裕,可以包含进度条、多行文本、操作按钮等丰富信息。

卡片的核心配置在 primary 对象中,包含标题、内容、布局类型和点击跳转:

primary: {
  title: '餐品待支付',
  content: [
    { text: '咖啡 ', textColor: '#FF000000' },
    { text: '等2件商品', textColor: '#FF000000' }
  ],
  keepTime: 15,
  clickAction: wantAgent,  // 点击跳转配置
  layoutData: {
    layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_PROGRESS,
    progress: 30,
    nodeIcons: ['icon_order.png', 'icon_finish.png']
  }
}

Demo 中的 ProgressSectionProgress 组件模拟了卡片中的进度条效果,通过 DELIVERY_STAGES[stageIndex] 驱动阶段标题和详情的切换。

1.3 沉浸态(Immersive)

在锁屏状态下点击实况窗胶囊,可展开为沉浸态,展示更全面的任务信息(如音乐专辑封面、运动轨迹、导航详情)。沉浸态的交互深度最高,但开发复杂度也最高,Demo 中未涉及。


二、 完整生命周期:从创建到结束的四个阶段

Demo 中的 LifecycleSection 清晰地列出了实况窗的四个核心阶段。理解这个生命周期是正确使用实况窗的前提。

2.1 阶段一:创建(startLiveView)

创建实况窗是整个流程的起点。在调用 liveViewManager.startLiveView() 之前,必须先检查实况窗能力是否开启:

// LiveViewController 中的能力检测
public static async checkEnabled(): Promise<boolean> {
  const enabled = await liveViewManager.isLiveViewEnabled()
  AppStorage.setOrCreate('liveViewEnabled', enabled)
  return enabled
}

isLiveViewEnabled() 返回 false 通常意味着:用户未登录华为账号、实况窗开关被关闭,或者应用未申请实况窗权益。

创建实况窗时,需要构造一个完整的 LiveView 对象,包含场景类型(event)、主视图数据(primary)和胶囊数据(capsule):

const liveView: liveViewManager.LiveView = {
  id: 0,                    // 实况窗ID,开发者生成
  event: 'DELIVERY',        // 场景类型:即时配送
  liveViewData: {
    primary: { /* 卡片数据 */ },
    capsule: { /* 胶囊数据 */ }
  }
}
await liveViewManager.startLiveView(liveView)

Demo 中的 DeliverySession.start() 封装了这一流程,调用后自动启动计时器驱动阶段流转。

2.2 阶段二:更新(updateLiveView)

创建后,通过 updateLiveView() 推进节点。每次更新都需要传入完整的 LiveView 对象(而非增量),系统会用新数据替换旧数据:

// 更新进度到下一阶段
liveViewData.primary.title = '骑手已取餐'
liveViewData.primary.layoutData.progress = 50
liveViewData.capsule.title = '配送中'
await liveViewManager.updateLiveView(liveView)

Demo 中的 DeliverySession 通过计时器自动推进 stageIndex,每次推进时调用 updateLiveView() 更新实况窗内容。sequence++ 确保每次更新都有唯一的序列号,系统据此判断更新顺序。

2.3 阶段三:后台处理(onBackground)

当应用退到后台,本地更新能力受限。此时有两种策略:

  • 本地更新:仅在应用进程存活时有效。如果进程被系统冻结或回收,更新将中断。
  • Push Kit 远程更新:通过系统级推送通道更新实况窗,不依赖应用进程存活。这是生产环境的推荐方案。

Demo 中的 deliveryPaused 状态标记了应用切入后台的场景,并提示"真机可用 Push Kit 继续更新实况窗"。

2.4 阶段四:结束(stopLiveView)

结束实况窗时,需要先进行最后一次更新(如将进度设为100%、标题改为"已送达"),然后调用 stopLiveView()

// 结束前的最终更新
liveViewData.primary.title = '商品已送达'
liveViewData.primary.layoutData.progress = 100
liveViewData.capsule.title = '已送达'

// 结束实况窗
await liveViewManager.stopLiveView(liveView)

结束后,系统会保留实况窗约15秒的"留存期",让用户有机会看到最终状态,然后自动清理。


三、 双通道更新机制:本地 vs Push Kit

实况窗的更新有两条通道,各有适用场景。理解它们的差异是架构设计的关键。

3.1 本地更新通道

通过 liveViewManager.updateLiveView() 直接在端侧更新。优势是延迟极低、无需网络;劣势是依赖应用进程存活

适用场景:

  • 应用在前台运行时的高频更新(如计时器每秒刷新)
  • 用户主动操作触发的状态变更(如点击"确认取餐")

3.2 Push Kit 远程更新通道

通过服务端向 Push Kit 发送更新消息,系统代为更新实况窗。优势是不依赖应用进程,即使应用被杀也能更新;劣势是有一定延迟,且受频率限制。

远程更新的完整流程:

  1. 端侧创建实况窗后,将 liveViewIdpushToken、场景 event 等信息上报到业务服务端
  2. 业务状态变更时,服务端通过 Push Kit API 推送更新消息
  3. 系统接收推送后,自动更新实况窗内容
应用端 → 创建实况窗 → 上报 liveViewId + pushToken → 业务服务端
业务服务端 → 状态变更 → Push Kit API → 系统 → 更新实况窗

3.3 频率限制

Push Kit 更新有严格的频率限制:

  • 出行打车与赛事比分:每设备每5分钟最多30次,每小时最多180次
  • 其余场景:每设备每5分钟最多10次,每小时最多60次
  • 系统级流控:所有应用合计,创建每秒最多15次,更新每秒最多30次
  • 应用级流控:单个应用,创建每秒最多10次,更新每秒最多20次

超过频率限制的部分将被丢弃不下发。因此,本地更新适合高频场景(如计时器),Push Kit 适合低频但关键的节点更新(如"骑手已取餐"、“已送达”)。


四、 后台保活与时效性约束

4.1 后台进程管控

在 HarmonyOS 7.0(API 26)中,系统对后台进程的管控极为严格。退到后台的应用可能被冻结甚至直接回收,导致实况窗数据停滞。

Demo 中的 deliveryPaused 状态正是对这一场景的模拟。在生产环境中,推荐的解决方案是:

  • 首选 Push Kit:将更新逻辑从端侧迁移到服务端,通过推送通道更新实况窗
  • 备选后台保活:申请原子化服务后台权限,但审核门槛较高

4.2 时效性自动调整

系统会根据实况窗的更新频率自动调整展示策略:

  • 超过2小时未更新:状态栏胶囊和锁屏胶囊被隐藏,仅保留通知中心展示
  • 超过4小时未更新:系统判定实况窗已结束,从所有展示入口清除
  • 生命周期上限:单个实况窗最长不超过8小时

这意味着开发者必须确保更新频率足够高,否则用户将看不到实况窗。Demo 中的计时器默认每15秒更新一次,这个频率在大多数场景下是合理的。


五、 工程化架构:Demo 的分层设计

Demo 的代码结构体现了清晰的职责分离,分为四个层次:

5.1 UI 层(LiveViewDemo)

负责页面渲染和用户交互。通过 @StorageLinkAppStorage 双向绑定,实现数据驱动的 UI 刷新。所有状态变量(liveViewEnabledliveViewActiveprogress 等)都通过 AppStorage 管理,确保跨组件状态一致性。

5.2 会话层(DeliverySession)

封装业务逻辑。管理配送阶段的流转、计时器的启停、进度的计算。DeliverySession.start() 是业务入口,它协调实况窗的创建和阶段推进。

5.3 控制层(LiveViewController)

封装 Live View Kit 的 API 调用。将 startLiveViewupdateLiveViewstopLiveViewisLiveViewEnabled 等系统 API 封装为业务友好的接口,屏蔽底层细节。

5.4 数据层(LiveViewTypes)

定义常量和类型。DELIVERY_STAGES 数组定义了配送的各个阶段(标题、详情、胶囊文案),是驱动整个流程的数据源。


六、 支持场景与权益申请

实况窗并非所有应用都能使用,它受权益管控,需要在 AppGallery Connect 中申请。

6.1 支持的场景类型

场景类型 EVENT 取值 适用范围
出行打车 TAXI 网约车、出租车、拼车
即时配送 DELIVERY 外卖、生鲜、同城配送
航班 FLIGHT 航班动态、登机提醒
高铁/火车 TRAIN 车次信息、检票提醒
排队 QUEUE 医院、银行、餐饮排队
取餐 PICK_UP 线下取餐、取件
赛事比分 SCORE 游戏、体育赛事
共享租赁 RENT 共享单车、充电宝
计时 TIMER 番茄时钟、倒计时(仅端侧)
运动锻炼 WORKOUT 跑步、骑行记录
导航 NAVIGATION 步行、骑行、车辆导航

6.2 场景准入原则

  • 活动有明确的开始和结束时间,总时长不超过8小时
  • 用户对该活动的实况窗通知有明确预期(通常是用户主动触发)
  • 展示内容对用户有足够价值,不可用于营销、广告场景
  • 内容需要动态更新,确保时效性

6.3 开发准备流程

  1. 在 AppGallery Connect 中申请推送服务权益
  2. 在"开放能力管理"中申请实况窗服务权益
  3. 设置数据处理位置为中国(实况窗仅支持中国境内)
  4. 通过白名单设备联调测试(上限100台,24小时内生效)
  5. 提交正式审核,审批通过后即可全量使用

七、 常见踩坑与排查指南

7.1 isLiveViewEnabled 返回 false

  • 确认设备已登录华为账号
  • 确认"设置 > 通知和状态栏 > 实况窗"中开关已开启
  • 确认应用已在 AppGallery Connect 中申请实况窗权益
  • 确认设备在调试白名单中(联调阶段)

7.2 实况窗创建成功但不显示

  • 确认 event 取值与申请的场景类型一致
  • 确认胶囊和卡片的数据符合设计规范(颜色、文案、图标等)
  • 确认应用未被用户在系统设置中关闭实况窗

7.3 后台更新中断

  • 本地更新依赖进程存活,进程被杀后更新将中断
  • 生产环境应使用 Push Kit 远程更新作为兜底方案
  • 确保更新频率不低于每2小时一次,否则胶囊会被隐藏

7.4 Push Kit 更新返回错误码

  • 错误码 155/265:用户已手动清除实况窗或关闭实况窗开关,应停止对该 liveViewId 的后续推送
  • 频率超限:检查是否超过每5分钟/每小时的更新次数限制

7.5 实况窗超时被系统清除

  • 超过4小时未更新,系统自动清除
  • 超过8小时生命周期上限,系统强制结束
  • 解决方案:合理设计更新频率,确保关键节点及时推送

完整代码

import { DELIVERY_STAGES, LiveViewLogItem } from '../liveview/LiveViewTypes'
import { DeliverySession } from '../liveview/DeliverySession'
import { LiveViewController } from '../liveview/LiveViewController'



struct LiveViewDemo {
  ('liveViewEnabled') liveViewEnabled: boolean = false
  ('liveViewActive') liveViewActive: boolean = false
  ('liveViewStageIndex') stageIndex: number = 0
  ('liveViewProgress') progress: number = 0
  ('liveViewStatusText') statusText: string = ''
  ('liveViewLogs') logs: LiveViewLogItem[] = []
  ('deliveryRunning') deliveryRunning: boolean = false
  ('deliveryPaused') deliveryPaused: boolean = false
  ('abilityState') abilityState: string = ''
  ('lastTransition') lastTransition: string = ''

  aboutToAppear(): void {
    LiveViewController.getInstance().checkEnabled()
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        this.HeaderSection()
        this.StatusSection()
        this.ProgressSection()
        this.ControlSection()
        this.LifecycleSection()
        this.ArchitectureSection()
        this.LogSection()
      }
      .width('100%')
      .padding({ top: 36, left: 16, right: 16, bottom: 24 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0C0A09')
    .scrollBar(BarState.Off)
  }

   HeaderSection() {
    Column({ space: 6 }) {
      Text('实况窗 Live View')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FAFAF9')
      Text('具有生命周期的后台交互 · 即时配送场景')
        .fontSize(14)
        .fontColor('#A8A29E')
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

   StatusSection() {
    Column({ space: 8 }) {
      Row() {
        Text('系统能力')
          .fontSize(13)
          .fontColor('#E7E5E4')
        Blank()
        Text(this.liveViewEnabled ? '已开启' : '未开启/无权益')
          .fontSize(12)
          .fontColor(this.liveViewEnabled ? '#34D399' : '#FBBF24')
      }
      .width('100%')

      Row() {
        Text('实况窗状态')
          .fontSize(13)
          .fontColor('#E7E5E4')
        Blank()
        Text(this.liveViewActive ? '运行中' : '未创建')
          .fontSize(12)
          .fontColor(this.liveViewActive ? '#60A5FA' : '#78716C')
      }
      .width('100%')

      Text(this.statusText)
        .fontSize(12)
        .fontColor('#A8A29E')
        .width('100%')

      Text(`Ability: ${this.abilityState} · ${this.lastTransition}`)
        .fontSize(11)
        .fontColor('#78716C')
        .width('100%')
    }
    .width('100%')
    .padding(14)
    .backgroundColor('rgba(99,102,241,0.12)')
    .borderRadius(14)
  }

   ProgressSection() {
    Column({ space: 10 }) {
      Text('配送进度')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FAFAF9')
        .width('100%')

      if (this.deliveryRunning) {
        Text(DELIVERY_STAGES[this.stageIndex]?.title ?? '准备中')
          .fontSize(15)
          .fontColor('#C4B5FD')
          .fontWeight(FontWeight.Medium)
        Text(DELIVERY_STAGES[this.stageIndex]?.detail ?? '')
          .fontSize(12)
          .fontColor('#A8A29E')
      } else {
        Text('点击「开始配送」创建实况窗并自动推进节点')
          .fontSize(12)
          .fontColor('#78716C')
      }

      Progress({ value: this.progress, total: 100, type: ProgressType.Linear })
        .width('100%')
        .color('#8B5CF6')
        .backgroundColor('rgba(255,255,255,0.1)')

      Text(`${this.progress}% · 胶囊: ${DELIVERY_STAGES[this.stageIndex]?.capsuleText ?? '-'}`)
        .fontSize(12)
        .fontColor('#A8A29E')
    }
    .width('100%')
    .padding(14)
    .backgroundColor('rgba(255,255,255,0.05)')
    .borderRadius(14)
    .alignItems(HorizontalAlign.Start)
  }

   ControlSection() {
    Column({ space: 10 }) {
      Button(this.deliveryRunning ? '配送进行中…' : '开始配送实况窗')
        .width('100%')
        .height(46)
        .fontSize(14)
        .backgroundColor('#7C3AED')
        .enabled(!this.deliveryRunning)
        .onClick(() => DeliverySession.start())

      Row({ space: 8 }) {
        Button('手动结束')
          .layoutWeight(1)
          .height(42)
          .fontSize(12)
          .backgroundColor('#DC2626')
          .enabled(this.liveViewActive)
          .onClick(() => DeliverySession.stop())

        Button('检测能力')
          .layoutWeight(1)
          .height(42)
          .fontSize(12)
          .backgroundColor('#475569')
          .onClick(() => LiveViewController.getInstance().checkEnabled())
      }
      .width('100%')

      if (this.deliveryPaused) {
        Text('已切入后台 · 计时暂停 · 真机可用 Push Kit 继续更新实况窗')
          .fontSize(11)
          .fontColor('#FBBF24')
          .width('100%')
      }
    }
    .width('100%')
  }

   LifecycleSection() {
    Column({ space: 8 }) {
      Text('生命周期')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FAFAF9')
        .width('100%')

      this.StepRow('①', 'startLiveView → 创建胶囊 + 进度卡片')
      this.StepRow('②', 'updateLiveView → sequence++ 推进节点')
      this.StepRow('③', 'onBackground → 本地更新受限,Push 远程更新')
      this.StepRow('④', 'stopLiveView → 结束任务,15s 留存期')
    }
    .width('100%')
    .padding(14)
    .backgroundColor('rgba(139,92,246,0.1)')
    .borderRadius(14)
    .alignItems(HorizontalAlign.Start)
  }

   ArchitectureSection() {
    Column({ space: 8 }) {
      Text('后台交互架构')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FAFAF9')
        .width('100%')

      this.StepRow('UI', 'Index 页展示进度 · AppStorage 双向绑定')
      this.StepRow('会话', 'DeliverySession 计时器驱动阶段流转')
      this.StepRow('控制', 'LiveViewController 封装 Kit API')
      this.StepRow('系统', '状态栏胶囊 + 通知中心卡片 + 锁屏')
      this.StepRow('远程', 'Push Kit 推送 payload 更新(应用被杀仍可用)')
    }
    .width('100%')
    .padding(14)
    .backgroundColor('rgba(255,255,255,0.05)')
    .borderRadius(14)
    .alignItems(HorizontalAlign.Start)
  }

   LogSection() {
    Column({ space: 8 }) {
      Row() {
        Text('事件日志')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FAFAF9')
        Blank()
        Text(`${this.logs.length} 条`)
          .fontSize(12)
          .fontColor('#A78BFA')
      }
      .width('100%')

      if (this.logs.length === 0) {
        Text('等待操作…')
          .fontSize(12)
          .fontColor('#78716C')
      } else {
        ForEach(this.logs, (item: LiveViewLogItem) => {
          Column({ space: 2 }) {
            Text(`[${item.time}] ${item.phase}`)
              .fontSize(11)
              .fontColor('#F472B6')
            Text(item.message)
              .fontSize(11)
              .fontColor('#D6D3D1')
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
        }, (item: LiveViewLogItem) => item.id)
      }
    }
    .width('100%')
    .padding(14)
    .backgroundColor('rgba(255,255,255,0.05)')
    .borderRadius(14)
    .alignItems(HorizontalAlign.Start)
  }

   StepRow(index: string, text: string) {
    Row({ space: 10 }) {
      Text(index)
        .fontSize(12)
        .fontColor('#A78BFA')
        .width(24)
      Text(text)
        .fontSize(12)
        .fontColor('#D6D3D1')
        .layoutWeight(1)
    }
    .width('100%')
  }
}

在这里插入图片描述

总结

实况窗是鸿蒙生态中最具"感知度"的系统级能力之一。通过本文的实战分析,我们可以总结出以下工程化要点:

  • 三种形态各司其职:胶囊态传递核心信息,卡片态展示详细进度,沉浸态提供深度交互。开发时应根据场景特点合理设计每种形态的内容。
  • 生命周期四步走:创建 → 更新 → 后台处理 → 结束,每一步都有对应的 API 和注意事项,尤其是结束前必须做一次最终更新。
  • 双通道更新策略:前台高频更新用本地通道(延迟低),后台/关键节点更新用 Push Kit(不依赖进程)。两者配合使用,才能实现全生命周期的稳定更新。
  • 时效性是生命线:超过2小时不更新胶囊隐藏,超过4小时实况窗被清除。必须确保更新频率满足系统要求。
  • 权益申请是前置条件:实况窗受权益管控,必须在 AppGallery Connect 中完成申请和白名单配置后才能使用。

掌握这些核心概念和工程实践,你就能在鸿蒙应用中构建出流畅、可靠的实况窗体验,让用户在任何界面、任何时刻都能"一眼掌握进度"。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐