状态机、节流与超时回收为什么该放进 Kotlin 共享层,push 裁决又为什么必须穿过 C 桥——这篇文章用一个跑在 API 26 模拟器上的实况窗 Demo,把这条链路从 commonMain 一直走到系统胶囊。

KMP/CMP 鸿蒙社区:https://atomgit.com/CPF-KMP-CMP

仓库地址:https://atomgit.com/oh-tpc/kmp-ohos-liveview

Kotlin 鸿蒙适配框架:https://atomgit.com/oh-kmp

0. 环境与实测设备

项版本/型号
DevEco Studio26(OpenHarmony SDK API 26,内置 hvigor 6.26.8 / JBR 25)
Kotlin2.2.21-1.0.0(KMP OpenHarmony 适配版,含 ohosArm64 与 ohosX64)
Gradle / JDK8.14.3(腾讯云镜像) / OpenJDK 17.0.2(华为云镜像)
鸿蒙 SDKHarmonyOS 7.0.0(API 26)
kmp-ohos-liveview1.0.0-ohos.1(本仓库)
Live View KitAPI 12+,PROGRESS 场景(受限开放权限)
实测设备DevEco 模拟器(Mate 90 Pro 镜像),详见下表

模拟器实测参数(以下均为 hdc shell param get 在运行中的模拟器上实查,非文档转述):

参数值
设备型号(镜像)HUAWEI Mate 90 Pro(DevEco 模拟器)
设备标识emulator(const.product.model),品牌 HUAWEI(const.product.brand)
系统版本const.ohos.fullname = OpenHarmony-7.0.0.105(Release)
软件版本号const.product.software.version = emulator 7.0.0.107(SP8DEVC00E999R4P11)
API 版本const.ohos.apiversion = 26(对应 HarmonyOS 7.0.0 / API 26)
CPU 架构x86_64(因此 HAP 内含 x86_64 版 .so,见坑十五)
hdc 目标127.0.0.1:5555

真机说明:本次实测未连接真机(hdc list targets 仅返回 127.0.0.1:5555),文中全部结论与截图均来自上述模拟器实跑。ohosArm64 真机产物已随库发布并参与编译,但真机上的胶囊态与锁屏卡片截图需在 AGC 申请 PROGRESS 场景权限后补拍,文中已如实标注。

本文 6 张截图全部来自该模拟器实跑(hdc shell snapshot_display 抓取),不是设计稿;本地素材按仓库惯例统一放在 assets/,图床地址与删除令牌见 图床链接.json。

1. 为什么跨端 App 值得为一扇"小窗"费劲

先还原一个所有跨端团队都见过的场景:App 里有一个"下载固件 / 配送订单 / 转写录音"之类的长任务,用户切到桌面或者锁屏之后,进度就"消失"了。想看一眼进度只能重新拉起 App,于是就有了"反复点亮屏幕进 App 看一眼再锁上"的经典动作。

鸿蒙给这个问题的系统级答案是实况窗(Live View):胶囊态挂在状态栏,锁屏后变成卡片,下滑通知中心也在——进度、状态、点击回拉全程可见。但很多跨端团队还没接,原因也很现实:实况窗是"事件驱动 + 强状态约束"的 API。LiveView 有创建、更新、暂停、完成、超时回收等状态,系统对更新频率有背压,primary / layoutData / capsule 三套数据结构都要按模板填齐,而且 d.ts 里标成"可选"的字段运行时还会强制校验(第 9 节会逐个踩)。

如果直接在 ArkTS 业务代码里手写这套状态流转,你会发现它本质上是一个必须全局一致的状态机 + 一个必须精确的节流器——而这两样东西,恰恰最值得放进跨端共享层:

  1. 状态机:暂停态不许收进度、终态不许收任何事件、进度不许回退——各端各写一遍迟早对不齐。放 Kotlin commonMain,JVM 单元测试锁死行为,所有端自动一致;
  2. 节流器:系统频控是黑盒,与其撞上去再降级,不如在应用内先合并更新——"≥5% 跳变或 ≥800ms 间隔才推送"这条策略在 Kotlin 核心算好,裁决还要作为返回值穿过 C 桥,ArkTS 只管把该推的推出去;
  3. 统计:推了几次、抑制了几次、平均推送间隔多少——既是 demo 的教学素材,也是线上排查"实况窗为什么没更新"的第一手证据。

所以这个库的分工是:Kotlin 拥有全部决策,ArkTS 只做系统翻译。实况窗的 UI 由系统渲染,App 侧只有数据,平台私有部分极少,这让它成为最适合 KMP 封装的鸿蒙特性之一。

另外要如实说明前提:Live View Kit 属于受限开放能力,商用需要在 AGC 申请对应场景权限。本 demo 在模拟器上实测 startLiveView / updateLiveView / stopLiveView 全链路都能调通并返回 resultCode=0,但胶囊与锁屏卡片的最终展示仍受场景授权约束;宿主因此在推送报错时自动降级为本地 mock 模式,KMP 状态机、节流和统计完全一致。

本文沿用仓库的 KMP/CMP 系列命名;本篇共享层是 Kotlin Multiplatform 的 commonMain,宿主页面完全由 ArkUI 绘制,没有引入 Compose Multiplatform。

2. 效果展示:六张图走完一次实况窗生命周期

Demo 是一个单页应用:点「开始模拟下载」,每 400ms 产生一次进度事件——故意比节流窗口密一倍——然后从三个区域同时观察行为:KMP 状态机卡片、节流统计区、KMP 自检区。

说明:图 2~5 来自同一次连续实跑(rev 25 → 27 → 31 → 40,数据严格连续);图 1 与图 6 分别是另一次启动的初始态与主动结束态。

图 1|初始页:自检先在设备上跑一遍

图 1:初始页,KMP 状态机、节流统计与 8 项设备自检全部通过

页面还没开始下载,rev 0 · 事件 0 · 推送 0 · 抑制 0,进度条为空。最下面是KMP 自检通过——这 8 条不是 UI 假数据,而是 liveViewEngine.runChecks() 经 C 桥在 Kotlin/Native 侧真实执行后返回的结果:

create enters ACTIVE with one event
progress update is clamped to the max
progress never moves backwards
paused view rejects progress updates
resume returns to ACTIVE
throttle pushes only on big jumps or elapsed interval
terminal views reject every event
idle ACTIVE view expires after the timeout

把单元测试的断言原样搬进设备自检,是这类"纯逻辑共享库"最省事的可信度证明:JVM 上的 12 个测试和设备上的 8 条自检,跑的是同一份 Kotlin 源码。

图 2|运行中:推送与抑制交替出现

图 2:运行中,→ 系统 与 ⊘ 抑制 交替,平均推送间隔 502ms

点「开始模拟下载」后,状态机进入 ACTIVE,顶部状态显示「进行中」。这一帧是 rev 25 · 事件 6 · 推送 5 · 抑制 1:

  • 日志里 → 系统: START 0% 是唯一一次 startLiveView,后面全是 updateLiveView;
  • → 系统: UPDATE 30% / 24% / 17% / 10% 是真正提交给系统的更新;
  • 中间夹着一条 ⊘ 抑制: UPDATE 10%——这次事件进了状态机(事件 计数 +1),但没打扰系统(抑制 计数 +1);
  • 平均推送间隔 502ms、最大 803ms,正好卡在 800ms 阈值附近:说明"≥5% 跳变或 ≥800ms 间隔"的并集策略在起作用——跳变够大就立刻推,跳变小就攒够时间再推。

图 3|暂停:状态机接管,定时器必须一起停

图 3:暂停态,已暂停,按钮变「继续」,系统收到 PAUSE 36%

点「暂停」后:状态卡片显示「已暂停」,中间按钮文字从「暂停」变成「继续」,日志顶部是 → 系统: PAUSE 36%。这一帧 rev 27 · 事件 8 · 推送 6 · 抑制 1,进度冻结在 36%。

这里藏着一个容易被忽略的细节:PAUSED 态在 Kotlin 状态机里会拒绝 UPDATE,并抛 IllegalStateException。所以宿主在发 PAUSE 的同时必须把 setInterval 停掉,否则接下来的每个 400ms 回调都会撞上状态机校验,界面上刷出一排红色错误。本文第一版就是这么翻车的,后来把定时器抽成 startTimer(),暂停时 clearInterval、继续时重启——状态机负责裁决,宿主负责别再喂它不该收的事件。

图 4|恢复:暂停留下的"间隔毛刺"会被统计如实记录

图 4:恢复后,RESUME 36% 后继续推进,最大间隔跳到 2053ms

点「继续」后,日志依次出现 → 系统: RESUME 36% 和新的 UPDATE,状态回到「进行中」。这一帧 rev 31 · 事件 12 · 推送 8 · 抑制 2。

注意统计区:平均推送间隔从 482ms 涨到 695ms,最大间隔从 803ms 跳到 2053ms。这个毛刺不是 bug——暂停期间没有任何推送,恢复后的第一次推送与上一次相隔了两秒多。统计口径是"真实推送之间的时间差",所以它如实反映了用户操作;也正因如此,线上排查时看到异常大的 maxIntervalMs,第一反应应该是"中间是不是暂停过/进程被挂起过",而不是"节流器坏了"。

另外 ⊘ 抑制: UPDATE 54% 说明恢复后节流立刻继续生效,并没有因为一次暂停就重置窗口。

图 5|完成:终态与 stopLiveView

图 5:完成态,已完成,最后一次推送是系统 END 100%

进度到 100% 后自动发 COMPLETE:状态卡片显示「已完成」,进度条满格,日志顶部是 → 系统: END 100%——对应 stopLiveView,而不是再发一次 update。整轮统计 rev 40 · 事件 21 · 推送 15 · 抑制 3,平均推送间隔 606ms。

也就是说:21 个业务事件里只有 15 次真正提交给系统,3 次被 Kotlin 节流器挡在进程内,其余是创建、暂停、恢复、结束等控制事件。这个比例不是 ArkTS 写的,是核心库算出来的,JVM 测试里同样的输入给出同样的数字。

图 6|结束:第五个终态与幂等收尾

图 6:结束态,已结束 / 会话已结束,进度冻结在 35%

在一次进行中的下载上点「结束」:顶部提示变成「会话已结束」,状态卡片显示「已结束」,进度条冻结在 35%,rev 8 · 事件 8 · 推送 7 · 抑制 0。

这里对应状态机的 STOPPED 终态。终态必须幂等:如果下载已经 COMPLETE 了再点「结束」,Kotlin 状态机会拒绝这次 STOP(terminal views reject every event),宿主不该把错误弹给用户,而是只做系统侧清理并更新提示文案。第一版没做这个判断,点一下就弹红字;现在 stopAll() 只在 ACTIVE/PAUSED 时投递 STOP。

六张图对应的状态流转

图KMP phase触发事件系统 API关键观察
1无会话——8 项自检通过
2ACTIVESTART / UPDATEstartLiveView → updateLiveView→ 系统 与 ⊘ 抑制 交替
3PAUSEDPAUSEupdateLiveView定时器同步停止,按钮变「继续」
4ACTIVERESUME / UPDATEupdateLiveView最大间隔 2053ms 反映暂停
5COMPLETEDCOMPLETEstopLiveViewEND 100%,终态拒绝后续事件
6STOPPEDSTOPstopLiveView进度冻结,终态幂等

3. 分层架构:决策与翻译彻底分离

┌──────────────────────────────────────────────┐
│ ArkTS 宿主 (ohosApp)                          │
│  LiveViewClient: @kit.LiveViewKit 封装        │
│   ├─ snapshot → LiveViewData(全量翻译)       │
│   └─ start / update / stopLiveView            │
├──────────────────────────────────────────────┤
│ C 桥 (napi_init.cpp + @CName Kotlin)          │
│   LiveViewCreate / EventApply / Stop / State  │
│   {"snapshot":…, "push":bool}                │
├──────────────────────────────────────────────┤
│ KMP 核心库 (liveview/, commonMain 纯 Kotlin)  │
│   LiveViewEngine: 状态机 · 节流 · 超时 · 统计 │
│   LiveViewJson:  依赖-free JSON 编解码        │
└──────────────────────────────────────────────┘

数据流是严格单向的:宿主事件 → C 桥 → 状态机裁决 + 节流判定 → {snapshot, push} JSON 回宿主 → 仅 push=true 时调系统实况窗。Kotlin 不持有任何系统对象,ArkTS 不做任何业务判断,连"该不该推"都是 Kotlin 说了算。

3.1 状态机:迁移表就是业务规则

public fun apply(
  snapshot: LiveViewSnapshot,
  event: LiveViewEvent,
  progressValue: Int? = null,
  atMs: Long,
): LiveViewSnapshot {
  if (snapshot.isTerminal) {
    throw IllegalStateException("Live view ${snapshot.id} is ${snapshot.phase}; no further events")
  }
  if (snapshot.phase == LiveViewPhase.PAUSED && event != LiveViewEvent.RESUME &&
    event != LiveViewEvent.COMPLETE && event != LiveViewEvent.STOP
  ) {
    throw IllegalStateException("Live view ${snapshot.id} is paused; only RESUME/COMPLETE/STOP allowed")
  }
  if (event == LiveViewEvent.UPDATE) {
    val value = progressValue ?: throw IllegalArgumentException("UPDATE requires a progress value")
    require(value >= 0) { "Progress must not be negative" }
    // 越界收敛到 max;进度不许回退:下载类业务重试时可能乱序回调,这里直接钳制
    val next = maxOf(minOf(value, snapshot.progressMax), snapshot.progressValue)
    return snapshot.copy(
      progressValue = next,
      phase = LiveViewPhase.ACTIVE,
      updatedAtMs = atMs,
      eventCount = snapshot.eventCount + 1,
    )
  }
  // ... PAUSE / RESUME / COMPLETE / EXPIRE / STOP → 五态迁移
}

三个值得展开的设计决定:

  • 非法迁移抛异常而不是静默返回。实况窗是用户可感知的系统能力,状态错了宁可让宿主拿到显式错误(桥面把异常转成 {"error":…} JSON),也不要渲染出一张"完成了但进度 43%"的卡片;
  • 进度钳制而不回退。下载器重试时进度回调可能乱序(先 80% 又回 60%),实况窗上进度回跳会让用户以为出了 bug,这里 maxOf(minOf(...), ...) 同时处理越界与回退;
  • eventCount 进快照。排查"实况窗没更新"的第一个分叉点就是:业务根本没发事件(eventCount 不动),还是事件被节流吞了(看 suppressedCount)。

3.2 节流器:裁决必须穿过桥面

public fun shouldPush(
  lastPushedAtMs: Long,
  lastPushedPercent: Int,
  snapshot: LiveViewSnapshot,
  nowMs: Long,
  minIntervalMs: Long = DEFAULT_MIN_INTERVAL_MS,
): Boolean {
  if (snapshot.phase != LiveViewPhase.ACTIVE) return false
  val percent = snapshot.progressPercent
  val bigJump = percent - lastPushedPercent >= MIN_PERCENT_DELTA
  return bigJump || nowMs - lastPushedAtMs >= minIntervalMs
}

策略是"可见跳变或足够间隔,满足其一即推":跳变 ≥5% 保证胶囊上的数字肉眼可见地变了;间隔 ≥800ms 保证低速下载时数字也会缓慢爬升而不是完全冻结。两个条件的并集覆盖了高速下载(跳变频繁,按跳变推)和低速下载(跳变小,按间隔推)两类极端业务。800ms 留了参数口子,业务可按系统实测频控阈值调。

但光有判定还不够——第一版只把结果记进统计,宿主每个事件照样推系统,节流等于白做。这是本文调试中最值得记的一次返工:

public data class ApplyResult(val snapshot: LiveViewSnapshot, val push: Boolean)

public fun apply(event: String, progressValue: Int?, atMs: Long): ApplyResult {
  val current = snapshot ?: error("No live view; call create first")
  val next = LiveViewEngine.apply(current, eventValue, progressValue, atMs)
  snapshot = next
  revision++
  var push = true   // PAUSE / RESUME / COMPLETE / STOP 一律要推
  if (eventValue == LiveViewEvent.UPDATE && next.phase == LiveViewPhase.ACTIVE) {
    if (LiveViewEngine.shouldPush(lastPushAtMs, lastPushPercent, next, atMs)) {
      intervals.add(atMs - lastPushAtMs); pushedCount++
      lastPushAtMs = atMs; lastPushPercent = next.progressPercent
    } else {
      suppressedCount++
      push = false
    }
  }
  return ApplyResult(next, push)
}

C 桥把裁决一起回传:{"snapshot":…,"push":false}。ArkTS 只在 push === true 时调系统 API,否则写一条 ⊘ 抑制 日志——也就是图 2、图 4 里那些带斜杠的灰色行。

3.3 超时回收:让系统主动接管

shouldExpire() 实现"ACTIVE 态超过 30 秒无更新自动 EXPIRE",对应系统侧的实况窗超时回收。在 KMP 层显式建模它的额外好处是测试里时间可控:JVM 测试直接构造"30 秒零事件"的快照验证过期路径,不用真的等 30 秒。这里还有一条业务语义分歧值得锁死——PAUSED 是用户主动挂起,不该被超时回收,所以 shouldExpire() 只对 ACTIVE 计时(自检第 8 条与单元测试都有对应断言)。

3.4 C 桥与双模式宿主

C 桥与同系列库完全同构:@CName 导出 6 个符号、version-script 收敛导出面、全 JSON 过桥、异常转 {"error":…}、nativeHeap 谁分配谁释放。

ArkTS 侧 LiveViewClient.pushSnapshot() 是唯一一个"翻译"动作,必须按 SDK 真实声明写(@kit.LiveViewKit 导出的是 liveViewManager 命名空间):

  • LiveView.event 是场景字符串(本文用 PROGRESS),不是状态枚举;
  • id 由开发者生成(直接用 KMP 快照 id);sequence 是必须严格递增的版本号,用「Unix 秒」为下界、进程内逐次 +1,避免进程重启后低于上次会话遗留的实况窗版本;
  • LiveViewResult 只有 resultCode / message,失败不抛异常,必须显式判断 resultCode !== 0;
  • liveViewData 里 primary(含 LAYOUT_TYPE_PROGRESS 进度模板)与 capsule(CAPSULE_TYPE_TEXT)都要填齐,且 primary.clickAction、layoutData.nodeIcons 运行时强制校验(见坑三);
  • 调用顺序固定为 create → startLiveView → updateLiveView(仅 push=true) → stopLiveView(COMPLETE)。

权限不足或推送报错时自动切 mock 模式,UI 与统计不受影响——这也是图 1~6 在无场景权限的模拟器上仍能完整演示的原因。

4. 核心库逐文件精读:从 39 行模型到 12 个测试

前面讲的是"为什么这么分层",这一节把 liveview/ 模块四个文件几乎完整摊开。它不依赖任何鸿蒙 API,甚至不依赖序列化库——commonMain 里只有纯 Kotlin,所以同一份源码能同时编到 JVM(跑测试)与 Kotlin/Native(跑设备)。

4.1 建库脚本:目标尽量少,接口显式

// liveview/build.gradle.kts
plugins {
  kotlin("multiplatform")
  `maven-publish`
}

group = "com.ohos.liveview"
version = "1.0.0-ohos.1"

kotlin {
  explicitApi()
  jvm()
  jvmToolchain(17)
  ohosArm64()
  ohosX64()

  sourceSets {
    commonTest.dependencies { implementation(kotlin("test")) }
  }
}

publishing {
  publications.withType<MavenPublication>().configureEach {
    pom {
      name.set("CMP Ohos LiveView")
      description.set("Kotlin Multiplatform live-view state machine for HarmonyOS Live View Kit: phase transitions, progress throttling and session statistics")
      licenses { license { name.set("Apache-2.0"); url.set("https://www.apache.org/licenses/LICENSE-2.0") } }
    }
  }
}

三个决定值得说:

  • explicitApi():强制所有 public 声明写出可见性与返回类型。三方库的 API 一旦被各端调用,隐式推断的类型变化就是破坏性变更,显式声明能在编译期挡住;
  • jvm() + jvmToolchain(17):本地 gradlew :liveview:jvmTest 直接跑 12 个单元测试,不需要设备、不需要模拟器、不需要 DevEco;
  • ohosArm64() + ohosX64():真机与 Windows 模拟器各一份 klib,但源码只有一份。maven-publish 让它可以先 publishToMavenLocal 再被任意工程以坐标引用,而不是复制源码。

4.2 数据模型:LiveViewTypes.kt 全文

package com.ohos.liveview

/**
 * Live-view domain model shared by the JVM tests, Kotlin/Native and the
 * ArkUI host. Phases mirror the visible states of a HarmonyOS live view:
 * the capsule and the lock-screen card always render one of these.
 */
public enum class LiveViewPhase { ACTIVE, PAUSED, COMPLETED, EXPIRED, STOPPED }

/** Events the host can send; transition validity is checked by [LiveViewEngine]. */
public enum class LiveViewEvent { UPDATE, PAUSE, RESUME, COMPLETE, EXPIRE, STOP }

/** One immutable live-view snapshot owned by the KMP core. */
public data class LiveViewSnapshot(
  public val id: Long,
  public val title: String,
  public val phase: LiveViewPhase,
  public val progressValue: Int,
  public val progressMax: Int,
  public val updatedAtMs: Long,
  public val eventCount: Int = 0,
) {
  public val progressPercent: Int
    get() = if (progressMax <= 0) 0 else (progressValue * 100 / progressMax).coerceIn(0, 100)

  public val isTerminal: Boolean
    get() = phase == LiveViewPhase.COMPLETED || phase == LiveViewPhase.EXPIRED || phase == LiveViewPhase.STOPPED
}

/** Throttling statistics: how many updates reached the system vs. were suppressed. */
public data class LiveViewStats(
  public val revision: Int,
  public val snapshot: LiveViewSnapshot?,
  public val pushedCount: Int,
  public val suppressedCount: Int,
  public val avgIntervalMs: Long,
  public val maxIntervalMs: Long,
)

39 行里藏着三个设计点:

  1. 快照不可变。LiveViewSnapshot 全是 val,状态迁移一律走 snapshot.copy(...),没有任何就地修改。这让"上一帧"可以安全地被日志、UI、回退逻辑持有,也让 JVM 测试能直接 assertEquals(expected, parsed);
  2. 派生属性不下发。progressPercent 和 isTerminal 用 getter 计算,不存字段——存了就可能出现"phase 是 COMPLETED 但 isTerminal 是 false"的不一致状态;
  3. phase 与 event 分开枚举。系统 LiveView.event 是场景字符串(PROGRESS),而这里是业务状态迁移事件,两者不在一个维度上,混用会直接导致坑三。

LiveViewStats 里的 revision 是会话层的"第几次变更",和快照的 eventCount 不是一回事:前者包含创建、重置等宿主动作,后者只数状态机收到的事件。图 2~5 里 rev 25 / 事件 6 的差异就来自这里。

4.3 状态机:LiveViewEngine.kt 核心实现(省略文件头 KDoc)

public object LiveViewEngine {
  /** Default minimum interval between two pushed progress updates. */
  public const val DEFAULT_MIN_INTERVAL_MS: Long = 800L

  /** Minimum percent change worth a push even inside the interval window. */
  public const val MIN_PERCENT_DELTA: Int = 5

  /** An ACTIVE view untouched for this long expires (system reclaims it). */
  public const val IDLE_TIMEOUT_MS: Long = 30_000L

  /** Upper bound for progress normalization; host maps its own units onto it. */
  public const val PROGRESS_MAX: Int = 100

  /** Creates a new session in the ACTIVE phase. */
  public fun create(id: Long, title: String, atMs: Long): LiveViewSnapshot {
    require(id > 0) { "Live view id must be positive" }
    require(title.isNotBlank()) { "Live view title must not be blank" }
    return LiveViewSnapshot(id, title.trim(), LiveViewPhase.ACTIVE, 0, PROGRESS_MAX, atMs, eventCount = 1)
  }

  /** Applies one event to [snapshot]; throws on illegal transitions. */
  public fun apply(
    snapshot: LiveViewSnapshot,
    event: LiveViewEvent,
    progressValue: Int? = null,
    atMs: Long,
  ): LiveViewSnapshot {
    if (snapshot.isTerminal) {
      throw IllegalStateException("Live view ${snapshot.id} is ${snapshot.phase}; no further events")
    }
    if (snapshot.phase == LiveViewPhase.PAUSED && event != LiveViewEvent.RESUME &&
      event != LiveViewEvent.COMPLETE && event != LiveViewEvent.STOP
    ) {
      throw IllegalStateException("Live view ${snapshot.id} is paused; only RESUME/COMPLETE/STOP allowed")
    }
    if (event == LiveViewEvent.UPDATE) {
      val value = progressValue ?: throw IllegalArgumentException("UPDATE requires a progress value")
      require(value >= 0) { "Progress must not be negative" }
      // 进度不许回退:下载类业务重试时可能乱序回调,这里直接钳制;越界收敛到 max
      val next = maxOf(minOf(value, snapshot.progressMax), snapshot.progressValue)
      return snapshot.copy(
        progressValue = next,
        phase = LiveViewPhase.ACTIVE,
        updatedAtMs = atMs,
        eventCount = snapshot.eventCount + 1,
      )
    }
    val nextPhase = when (event) {
      LiveViewEvent.PAUSE -> LiveViewPhase.PAUSED
      LiveViewEvent.RESUME -> LiveViewPhase.ACTIVE
      LiveViewEvent.COMPLETE -> LiveViewPhase.COMPLETED
      LiveViewEvent.EXPIRE -> LiveViewPhase.EXPIRED
      LiveViewEvent.STOP -> LiveViewPhase.STOPPED
      LiveViewEvent.UPDATE -> throw IllegalStateException("unreachable")
    }
    return snapshot.copy(phase = nextPhase, updatedAtMs = atMs, eventCount = snapshot.eventCount + 1)
  }

  /** True when the view should expire because nothing happened for a while. */
  public fun shouldExpire(snapshot: LiveViewSnapshot, nowMs: Long): Boolean =
    !snapshot.isTerminal && snapshot.phase == LiveViewPhase.ACTIVE &&
      nowMs - snapshot.updatedAtMs >= IDLE_TIMEOUT_MS

  /**
   * Throttling decision: push to the system only when enough time passed or
   * the percent delta is large enough to be visible in the capsule.
   */
  public fun shouldPush(
    lastPushedAtMs: Long,
    lastPushedPercent: Int,
    snapshot: LiveViewSnapshot,
    nowMs: Long,
    minIntervalMs: Long = DEFAULT_MIN_INTERVAL_MS,
  ): Boolean {
    if (snapshot.phase != LiveViewPhase.ACTIVE) return false
    val percent = snapshot.progressPercent
    val bigJump = percent - lastPushedPercent >= MIN_PERCENT_DELTA
    return bigJump || nowMs - lastPushedAtMs >= minIntervalMs
  }

  /** Demo corpus used when the real permission is missing (local mock mode). */
  public fun demoTimeline(): List<Pair<LiveViewEvent, Int?>> = listOf(
    LiveViewEvent.UPDATE to 5,
    LiveViewEvent.UPDATE to 12,   // 节流窗口内的小跳,应被抑制
    LiveViewEvent.UPDATE to 40,
    LiveViewEvent.PAUSE to null,
    LiveViewEvent.RESUME to null,
    LiveViewEvent.UPDATE to 95,
    LiveViewEvent.COMPLETE to null,
  )

  /** Returns human-readable checks used by the device sample's self-test row. */
  public fun runChecks(): List<String> = buildList {
    val view = create(1L, "订单配送", 1_000L)
    check(view.phase == LiveViewPhase.ACTIVE && view.eventCount == 1)
    add("create enters ACTIVE with one event")
    val u1 = apply(view, LiveViewEvent.UPDATE, 3, 1_100L)
    check(u1.progressPercent == 3)
    add("progress update is clamped to the max")
    val u2 = apply(u1, LiveViewEvent.UPDATE, 2, 1_200L)
    check(u2.progressValue == 3)
    add("progress never moves backwards")
    val p = apply(u2, LiveViewEvent.PAUSE, null, 2_000L)
    check(p.phase == LiveViewPhase.PAUSED)
    var rejected = false
    try { apply(p, LiveViewEvent.UPDATE, 9, 2_100L) } catch (_: IllegalStateException) { rejected = true }
    check(rejected)
    add("paused view rejects progress updates")
    val r = apply(p, LiveViewEvent.RESUME, null, 3_000L)
    check(r.phase == LiveViewPhase.ACTIVE)
    add("resume returns to ACTIVE")
    check(!shouldPush(3_050L, 0, apply(r, LiveViewEvent.UPDATE, 1, 3_100L), 3_100L))
    check(shouldPush(3_099L, 0, apply(r, LiveViewEvent.UPDATE, 6, 3_100L), 3_100L))
    add("throttle pushes only on big jumps or elapsed interval")
    val c = apply(apply(r, LiveViewEvent.UPDATE, 100, 4_000L), LiveViewEvent.COMPLETE, null, 4_100L)
    check(c.isTerminal)
    var terminal = false
    try { apply(c, LiveViewEvent.UPDATE, 1, 4_200L) } catch (_: IllegalStateException) { terminal = true }
    check(terminal)
    add("terminal views reject every event")
    check(shouldExpire(apply(r, LiveViewEvent.UPDATE, 7, 0L), IDLE_TIMEOUT_MS + 1))
    add("idle ACTIVE view expires after the timeout")
  }
}

逐段看:

  • 四个常量都是 public const val,业务方可读、可传给 shouldPush(..., minIntervalMs = ...) 覆盖,而不是把 800/5/30s 硬编码在 UI 里;
  • create 把 eventCount 初始化为 1 而不是 0——创建本身就是一次状态变更,图 2 里 事件 6 包含 START,这样统计口径才自洽;
  • apply 的三道闸门:终态拒绝 → 暂停态白名单 → UPDATE 专属校验。when (event) 里 UPDATE -> throw IllegalStateException("unreachable") 看似多余,实际是给 when 穷尽性兜底:以后加新事件时编译器会逼你回来处理;
  • maxOf(minOf(value, max), current) 一行同时解决了"越界"与"回退"。注意 require(value >= 0) 只拦负数,正数越界是钳制而不是抛错——业务侧拿到 120% 的回调很常见,为它中断整条实况窗不值得;
  • shouldExpire 只对 ACTIVE 计时,PAUSED 不回收(见坑八);
  • runChecks() 是同一份逻辑在设备上的自检数据源。图 1 里那 8 条绿勾就来自这个 buildList,和 JVM 测试共享实现,不存在"测试是一套、设备跑的是另一套"。

4.4 零依赖 JSON:LiveViewJson.kt

过桥的数据只有两种形态:快照和统计。引入 kotlinx.serialization 当然更省事,但这个库要同时编到 JVM 与 Kotlin/Native、还要尽量小,所以干脆手写了一个只认自己字段的平面 JSON 编解码器:

public fun String.jsonQuote(): String {
  val sb = StringBuilder(length + 2)
  sb.append('"')
  for (ch in this) {
    when (ch) {
      '\\' -> sb.append("\\\\")
      '"' -> sb.append("\\\"")
      '\n' -> sb.append("\\n")
      '\r' -> sb.append("\\r")
      '\t' -> sb.append("\\t")
      else -> if (ch < ' ') sb.append("\\u").append(ch.code.toString(16).padStart(4, '0')) else sb.append(ch)
    }
  }
  sb.append('"')
  return sb.toString()
}

public fun LiveViewSnapshot.toJson(): String =
  "{\"id\":$id,\"title\":${title.jsonQuote()},\"phase\":\"${phase.name}\"," +
    "\"progressValue\":$progressValue,\"progressMax\":$progressMax," +
    "\"progressPercent\":$progressPercent,\"updatedAtMs\":$updatedAtMs,\"eventCount\":$eventCount}"

public fun LiveViewStats.toJson(): String =
  "{\"revision\":$revision,\"pushedCount\":$pushedCount,\"suppressedCount\":$suppressedCount," +
    "\"avgIntervalMs\":$avgIntervalMs,\"maxIntervalMs\":$maxIntervalMs," +
    "\"snapshot\":${snapshot?.toJson() ?: "null"}}"

/** Parses one flat snapshot JSON produced by the platform bridge (lenient). */
public fun parseSnapshotJson(json: String): LiveViewSnapshot? {
  val id = jsonNumberField(json, "id") ?: return null
  val title = jsonStringField(json, "title") ?: return null
  val phase = LiveViewPhase.entries.firstOrNull { it.name == jsonStringField(json, "phase") } ?: LiveViewPhase.ACTIVE
  return LiveViewSnapshot(
    id = id,
    title = title,
    phase = phase,
    progressValue = (jsonNumberField(json, "progressValue") ?: 0).toInt(),
    progressMax = (jsonNumberField(json, "progressMax") ?: LiveViewEngine.PROGRESS_MAX).toInt(),
    updatedAtMs = jsonNumberField(json, "updatedAtMs") ?: 0L,
    eventCount = (jsonNumberField(json, "eventCount") ?: 0).toInt(),
  )
}

读取侧只实现"扁平字段":

private fun jsonStringField(json: String, key: String): String? {
  val marker = "\"$key\""
  val keyAt = json.indexOf(marker)
  if (keyAt < 0) return null
  var i = json.indexOf('"', keyAt + marker.length)
  if (i < 0) return null
  i++
  val sb = StringBuilder()
  while (i < json.length) {
    val ch = json[i]
    if (ch == '\\') {
      i++
      when (json.getOrNull(i)) {
        'n' -> sb.append('\n')
        't' -> sb.append('\t')
        'r' -> sb.append('\r')
        '"' -> sb.append('"')
        '\\' -> sb.append('\\')
        'u' -> {
          val hex = json.substring(i + 1, (i + 5).coerceAtMost(json.length))
          sb.append(hex.toIntOrNull(16)?.toChar() ?: ' ')
          i += 4
        }
      }
    } else if (ch == '"') {
      return sb.toString()
    } else {
      sb.append(ch)
    }
    i++
  }
  return sb.toString()
}

private fun jsonNumberField(json: String, key: String): Long? {
  val marker = "\"$key\":"
  val keyAt = json.indexOf(marker)
  if (keyAt < 0) return null
  var i = keyAt + marker.length
  while (i < json.length && !json[i].isDigit() && json[i] != '-') i++
  val start = i
  while (i < json.length && (json[i].isDigit() || json[i] == '-')) i++
  return json.substring(start, i).toLongOrNull()
}

这里有个刻意的"不严谨":jsonNumberField 遇到缺字段返回 null,parseSnapshotJson 逐个给默认值,只有 id 和 title 缺失才整体失败。桥两端都是自己人,宽容一点比严格解析更有用——将来核心库往 JSON 里加字段,旧版解析器不会崩。但它明确不是通用 JSON 库,注释里写死了 “only for fields the bridge controls”,避免被误用去解析服务端响应。

jsonQuote() 处理了控制字符 \uXXXX,因为 title 来自业务(订单名、文件名)可能带换行;这一段是踩过"标题里有引号导致 ArkTS 侧 JSON.parse 抛错"之后补的。

4.5 JVM 测试:LiveViewEngineTest.kt 全文(为排版把 @Test 与 fun 合并到同一行)

package com.ohos.liveview

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

class LiveViewEngineTest {
  private val view = LiveViewEngine.create(1L, "订单配送", 1_000L)

  @Test fun createEntersActive() {
    assertEquals(LiveViewPhase.ACTIVE, view.phase)
    assertEquals(1, view.eventCount)
    assertEquals(0, view.progressPercent)
  }

  @Test fun createValidatesInput() {
    assertFailsWith<IllegalArgumentException> { LiveViewEngine.create(0L, "x", 0L) }
    assertFailsWith<IllegalArgumentException> { LiveViewEngine.create(1L, "  ", 0L) }
  }

  @Test fun updateProgressAndClamp() {
    val u = LiveViewEngine.apply(view, LiveViewEvent.UPDATE, 42, 1_100L)
    assertEquals(42, u.progressPercent)
    val over = LiveViewEngine.apply(u, LiveViewEvent.UPDATE, 120, 1_200L)
    assertEquals(LiveViewEngine.PROGRESS_MAX, over.progressValue)
  }

  @Test fun progressNeverMovesBackwards() {
    val u = LiveViewEngine.apply(view, LiveViewEvent.UPDATE, 50, 1_100L)
    val back = LiveViewEngine.apply(u, LiveViewEvent.UPDATE, 10, 1_200L)
    assertEquals(50, back.progressValue)
  }

  @Test fun updateRequiresProgressValue() {
    assertFailsWith<IllegalArgumentException> {
      LiveViewEngine.apply(view, LiveViewEvent.UPDATE, null, 1_100L)
    }
  }

  @Test fun pauseRejectsProgressAndResumeWorks() {
    val paused = LiveViewEngine.apply(view, LiveViewEvent.PAUSE, null, 2_000L)
    assertEquals(LiveViewPhase.PAUSED, paused.phase)
    assertFailsWith<IllegalStateException> {
      LiveViewEngine.apply(paused, LiveViewEvent.UPDATE, 9, 2_100L)
    }
    val resumed = LiveViewEngine.apply(paused, LiveViewEvent.RESUME, null, 2_500L)
    assertEquals(LiveViewPhase.ACTIVE, resumed.phase)
  }

  @Test fun terminalViewsRejectEverything() {
    val done = LiveViewEngine.apply(view, LiveViewEvent.COMPLETE, null, 2_000L)
    assertTrue(done.isTerminal)
    assertFailsWith<IllegalStateException> {
      LiveViewEngine.apply(done, LiveViewEvent.UPDATE, 1, 2_100L)
    }
    assertFailsWith<IllegalStateException> {
      LiveViewEngine.apply(done, LiveViewEvent.STOP, null, 2_200L)
    }
  }

  @Test fun expiryDetection() {
    assertFalse(LiveViewEngine.shouldExpire(view, 1_000L + LiveViewEngine.IDLE_TIMEOUT_MS - 1))
    val idle = LiveViewEngine.apply(view, LiveViewEvent.UPDATE, 1, 0L)
    assertTrue(LiveViewEngine.shouldExpire(idle, LiveViewEngine.IDLE_TIMEOUT_MS + 1))
    val paused = LiveViewEngine.apply(view, LiveViewEvent.PAUSE, null, 0L)
    assertFalse(LiveViewEngine.shouldExpire(paused, LiveViewEngine.IDLE_TIMEOUT_MS * 10))
  }

  @Test fun throttleRules() {
    val active = LiveViewEngine.apply(view, LiveViewEvent.UPDATE, 1, 3_000L)
    // 小跳变 + 间隔不足 → 不推
    assertFalse(LiveViewEngine.shouldPush(2_500L, 0, active, 3_000L))
    // 跳变 ≥5% → 推(用 6% 进度的快照验证)
    val jumped = LiveViewEngine.apply(view, LiveViewEvent.UPDATE, 6, 3_000L)
    assertTrue(LiveViewEngine.shouldPush(2_999L, 0, jumped, 3_000L))
    // 间隔足够 → 推
    assertTrue(LiveViewEngine.shouldPush(2_100L, 0, active, 3_000L))
    // 非活跃态一律不推
    val paused = LiveViewEngine.apply(view, LiveViewEvent.PAUSE, null, 3_000L)
    assertFalse(LiveViewEngine.shouldPush(0L, 0, paused, 999_999L))
  }

  @Test fun selfChecksPass() {
    assertTrue(LiveViewEngine.runChecks().isNotEmpty())
  }

  @Test fun jsonRoundTrip() {
    val updated = LiveViewEngine.apply(view, LiveViewEvent.UPDATE, 66, 1_100L)
    assertEquals(updated, parseSnapshotJson(updated.toJson()))
  }

  @Test fun parseSnapshotToleratesMissingFields() {
    assertNull(parseSnapshotJson("{}"))
    assertEquals(LiveViewPhase.ACTIVE, parseSnapshotJson("{\"id\":1,\"title\":\"t\"}")?.phase)
    assertEquals(100, parseSnapshotJson("{\"id\":1,\"title\":\"t\"}")?.progressMax)
  }
}

12 个测试分成四组:创建与入参(2)、进度语义(3)、状态迁移(3)、节流/超时/编解码(4)。全部是纯函数测试,没有 mock、没有协程、没有 runBlocking,一条 gradlew :liveview:jvmTest 十几秒跑完。

为什么值得把测试写这么细?因为这些规则一旦在 Kotlin 侧确定,ArkTS 侧就不再需要任何对应判断。比如 pauseRejectsProgressAndResumeWorks 锁死"暂停拒收 UPDATE",宿主就必须配合停定时器(坑五);terminalViewsRejectEverything 锁死"终态拒绝一切",宿主就必须做幂等收尾(坑六)。共享层的测试其实是在给宿主层定契约。

throttleRules 里那条 assertFalse(shouldPush(999_999L, ...paused...)) 容易被读漏:即使时间过去了 999 秒,只要状态不是 ACTIVE 就一律不推——节流判定和状态机是耦合的,不能只当成一个时间函数。

5. 共享会话与 C 桥:把裁决送过边界

核心库是纯函数式的(输入快照 + 事件 → 新快照),但设备侧需要一个"当前会话"来记住上一次推送时间、累计统计。这个可变状态放在 example/shared,不在可发布的核心库里——库保持无状态,会话归示例工程,这是三方库该有的边界。

5.1 LiveViewExamples.kt:会话与统计

package com.ohos.liveview.example

/** Result of one event: the new snapshot plus the throttle verdict. */
public data class ApplyResult(public val snapshot: LiveViewSnapshot, public val push: Boolean)

public object LiveViewExamples {
  private var snapshot: LiveViewSnapshot? = null
  private var revision: Int = 0
  private var pushedCount: Int = 0
  private var suppressedCount: Int = 0
  private var lastPushAtMs: Long = 0L
  private var lastPushPercent: Int = 0
  private var intervals: MutableList<Long> = mutableListOf()

  /** Creates a live view and returns the new snapshot. */
  public fun create(id: Long, title: String, atMs: Long): LiveViewSnapshot {
    snapshot = LiveViewEngine.create(id, title, atMs)
    revision++
    pushedCount = 1          // 创建本身算一次推送
    suppressedCount = 0
    lastPushAtMs = atMs
    lastPushPercent = 0
    intervals = mutableListOf()
    return snapshot!!
  }

  /**
   * Applies one event and returns the new snapshot together with the
   * throttle verdict: the host must push to the system only when [push]
   * is true, otherwise the update is coalesced inside the shared layer.
   */
  public fun apply(event: String, progressValue: Int?, atMs: Long): ApplyResult {
    val current = snapshot ?: error("No live view; call create first")
    val eventValue = LiveViewEvent.entries.firstOrNull { it.name == event }
      ?: error("Unknown live-view event: $event")
    val next = LiveViewEngine.apply(current, eventValue, progressValue, atMs)
    snapshot = next
    revision++
    var push = true
    if (eventValue == LiveViewEvent.UPDATE && next.phase == LiveViewPhase.ACTIVE) {
      if (LiveViewEngine.shouldPush(lastPushAtMs, lastPushPercent, next, atMs)) {
        intervals.add(atMs - lastPushAtMs)
        pushedCount++
        lastPushAtMs = atMs
        lastPushPercent = next.progressPercent
      } else {
        suppressedCount++
        push = false
      }
    }
    return ApplyResult(next, push)
  }

  public fun checkExpiry(nowMs: Long): Boolean =
    snapshot != null && LiveViewEngine.shouldExpire(snapshot!!, nowMs)

  public fun expire(atMs: Long): LiveViewSnapshot? {
    snapshot?.let { snapshot = LiveViewEngine.apply(it, LiveViewEvent.EXPIRE, null, atMs) }
    return snapshot
  }

  public fun stats(): LiveViewStats {
    val avg = if (intervals.isEmpty()) 0L else intervals.sum() / intervals.size
    val max = intervals.maxOrNull() ?: 0L
    return LiveViewStats(revision, snapshot, pushedCount, suppressedCount, avg, max)
  }

  public fun current(): LiveViewSnapshot? = snapshot

  public fun reset(): LiveViewStats {
    snapshot = null
    revision = 0
    pushedCount = 0
    suppressedCount = 0
    lastPushAtMs = 0L
    lastPushPercent = 0
    intervals = mutableListOf()
    return stats()
  }

  public fun runChecks(): List<String> = LiveViewEngine.runChecks()
  public fun demoTimeline(): List<Pair<LiveViewEvent, Int?>> = LiveViewEngine.demoTimeline()
}

几个数字口径要和截图对上:

  • pushedCount = 1 在 create 里就置好,因为创建快照后宿主会立刻 startLiveView,算一次推送;
  • intervals 只记录真正推送之间的时间差,所以暂停期间不产生元素,但恢复后的第一次推送会带上一个很大的差值——这正是图 4 里 最大间隔 2053ms 的来源;
  • var push = true 的默认值很关键:PAUSE / RESUME / COMPLETE / STOP 这些控制事件不参与节流,必须每次都提交,否则系统会一直停在旧状态;
  • apply() 里 LiveViewEvent.entries.firstOrNull { it.name == event } 把字符串映射成枚举,未知事件走 error(...),由桥面统一转成 {"error":…}。

5.2 NativeBridge.kt:6 个 @CName 导出

@file:OptIn(kotlin.experimental.ExperimentalNativeApi::class, kotlinx.cinterop.ExperimentalForeignApi::class)

package com.ohos.liveview.example

import com.ohos.liveview.jsonQuote
import com.ohos.liveview.toJson
import kotlinx.cinterop.*

private fun textBuffer(text: String): CPointer<ByteVar> {
  val bytes = text.encodeToByteArray()
  val buffer = nativeHeap.allocArray<ByteVar>(bytes.size + 1)
  bytes.forEachIndexed { index, byte -> buffer[index] = byte }
  buffer[bytes.size] = 0
  return buffer
}

private inline fun response(block: () -> String): CPointer<ByteVar> = try {
  textBuffer(block())
} catch (error: Throwable) {
  textBuffer("{\"error\":${(error.message ?: "Native error").jsonQuote()}}")
}

/** LiveViewCreate(id, title, atMs): snapshot JSON. */
@CName("LiveViewCreate")
public fun createNative(id: Long, title: String, atMs: Long): CPointer<ByteVar> = response {
  LiveViewExamples.create(id, title, atMs).toJson()
}

/** LiveViewEventApply(event, progressValue, atMs): {"snapshot":…,"push":bool} (-1 progress = none). */
@CName("LiveViewEventApply")
public fun eventNative(event: String, progressValue: Int, atMs: Long): CPointer<ByteVar> = response {
  val progress = if (progressValue < 0) null else progressValue
  val result = LiveViewExamples.apply(event, progress, atMs)
  "{\"snapshot\":${result.snapshot.toJson()},\"push\":${result.push}}"
}

/** LiveViewStop(): snapshot JSON (STOP 不受节流限制). */
@CName("LiveViewStop")
public fun stopNative(atMs: Long): CPointer<ByteVar> = response {
  LiveViewExamples.apply("STOP", null, atMs).snapshot.toJson()
}

/** LiveViewState(): stats JSON. */
@CName("LiveViewState")
public fun stateNative(): CPointer<ByteVar> = response {
  LiveViewExamples.stats().toJson()
}

@CName("LiveViewRunChecks")
public fun checksNative(): CPointer<ByteVar> = response {
  val checks = LiveViewExamples.runChecks()
  "{\"passed\":true,\"checks\":${checks.joinToString(prefix = "[", postfix = "]") { it.jsonQuote() }}}"
}

@CName("LiveViewFree")
public fun freeNative(pointer: CPointer<ByteVar>?) {
  if (pointer != null) nativeHeap.free(pointer)
}

整套桥只有三个约定:

  1. 字符串进、字符串出。Kotlin/Native 与 C 之间只传 const char* 和基本类型,复杂对象一律 JSON,避免定义一套跨语言 struct;
  2. 内存谁分配谁释放。textBuffer 用 nativeHeap.allocArray,导出 LiveViewFree 让 C 侧用完立刻 nativeHeap.free,不留悬挂指针;
  3. 异常不出边界。inline response { } 把任何 Throwable 转成 {"error":"..."}。状态机抛的 IllegalStateException(暂停收 UPDATE、终态收事件)就是这样变成 ArkTS 可读的错误文本,而不是让进程 abort。

注意 eventNative 的 progressValue < 0 哨兵:C 没有 Int?,约定 -1 代表"无进度值",PAUSE / RESUME / COMPLETE 都传 -1。这是 C ABI 里最朴素的可空类型编码。

导出的符号名要和 linker 脚本严格一致:

// example/nativeApp/src/ohosArm64Main/linker/shared-library.map
{
  global:
    LiveViewCreate;
    LiveViewEventApply;
    LiveViewStop;
    LiveViewState;
    LiveViewRunChecks;
    LiveViewFree;
  local: *;
};

local: * 把 Kotlin 运行时和 stdlib 的符号全部隐藏,只露出 6 个入口,既能减小动态符号表,也避免与其他 .so 的 Kotlin 运行时符号冲突(坑十)。

5.3 napi_init.cpp:C 胶水层

Kotlin/Native 生成 libohos_liveview.so 与 libohos_liveview_api.h,但 ArkTS 不能直接 import 它,需要一层 NAPI 把 C 函数注册成 JS 方法。以下代码是 napi_init.cpp 的关键节选,重复的错误处理分支为排版压缩成单行,完整文件见仓库:

#include "napi/native_api.h"
#include "libohos_liveview_api.h"
#include <cmath>
#include <string>

static napi_value ReadNativeText(napi_env env, const char* text) {
  if (text == nullptr) {
    napi_throw_error(env, nullptr, "Native live-view engine returned null");
    return nullptr;
  }
  napi_value result = nullptr;
  const auto status = napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &result);
  if (status != napi_ok) {
    napi_throw_error(env, nullptr, "Unable to create native text result");
    return nullptr;
  }
  return result;
}

static bool ReadUtf8(napi_env env, napi_value value, std::string& output) {
  size_t length = 0;
  if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) return false;
  std::string text(length, '\0');
  if (napi_get_value_string_utf8(env, value, &text[0], length + 1, &length) != napi_ok) return false;
  output = text;
  return true;
}

static bool ReadNumber(napi_env env, napi_value value, double& output) {
  double number = 0;
  if (napi_get_value_double(env, value, &number) != napi_ok || !std::isfinite(number)) return false;
  output = number;
  return true;
}

ReadUtf8 的"先查长度再读内容"两段式是 NAPI 标准写法;ReadNumber 用 std::isfinite 顺手挡掉 NaN/Infinity——它们转成 long long 是未定义行为。

再看两个核心导出(其余三个同构):

// liveViewCreate(id, title, atMs): string
static napi_value Create(napi_env env, napi_callback_info info) {
  size_t argc = 3;
  napi_value args[3] = {nullptr, nullptr, nullptr};
  if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc < 2) {
    napi_throw_type_error(env, nullptr, "liveViewCreate requires an id and a title");
    return nullptr;
  }
  double id = 0, atMs = 0;
  std::string title;
  if (!ReadNumber(env, args[0], id) || id <= 0) {
    napi_throw_type_error(env, nullptr, "liveViewCreate id must be a positive number");
    return nullptr;
  }
  if (!ReadUtf8(env, args[1], title) || title.empty()) {
    napi_throw_type_error(env, nullptr, "liveViewCreate title must be a non-empty string");
    return nullptr;
  }
  if (argc > 2 && !ReadNumber(env, args[2], atMs)) {
    napi_throw_type_error(env, nullptr, "liveViewCreate atMs must be a number");
    return nullptr;
  }
  auto out = LiveViewCreate(static_cast<long long>(id), title.c_str(), static_cast<long long>(atMs));
  auto result = ReadNativeText(env, reinterpret_cast<const char*>(out));
  LiveViewFree(out);
  return result;
}

// liveViewEventApply(event, progressValue, atMs): string
static napi_value EventApply(napi_env env, napi_callback_info info) {
  size_t argc = 3;
  napi_value args[3] = {nullptr, nullptr, nullptr};
  if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc < 1) {
    napi_throw_type_error(env, nullptr, "liveViewEventApply requires an event name");
    return nullptr;
  }
  std::string event;
  double progress = -1;
  double atMs = 0;
  if (!ReadUtf8(env, args[0], event) || event.empty()) {
    napi_throw_type_error(env, nullptr, "liveViewEventApply event must be a non-empty string");
    return nullptr;
  }
  if (argc > 1 && !ReadNumber(env, args[1], progress)) {
    napi_throw_type_error(env, nullptr, "liveViewEventApply progressValue must be a number");
    return nullptr;
  }
  if (argc > 2 && !ReadNumber(env, args[2], atMs)) {
    napi_throw_type_error(env, nullptr, "liveViewEventApply atMs must be a number");
    return nullptr;
  }
  auto out = LiveViewEventApply(event.c_str(), static_cast<int>(progress), static_cast<long long>(atMs));
  auto result = ReadNativeText(env, reinterpret_cast<const char*>(out));
  LiveViewFree(out);
  return result;
}

每个函数都是同一个模板:取参数 → 校验 → 调 C → 转 JS 字符串 → 释放 native 内存。LiveViewFree(out) 必须在 ReadNativeText 之后调用,因为 napi_create_string_utf8 会复制内容;顺序反了就是 use-after-free。

注册部分:

static napi_value Init(napi_env env, napi_value exports) {
  napi_property_descriptor methods[] = {
      {"liveViewCreate", nullptr, Create, nullptr, nullptr, nullptr, napi_default, nullptr},
      {"liveViewEventApply", nullptr, EventApply, nullptr, nullptr, nullptr, napi_default, nullptr},
      {"liveViewStop", nullptr, Stop, nullptr, nullptr, nullptr, napi_default, nullptr},
      {"liveViewState", nullptr, State, nullptr, nullptr, nullptr, napi_default, nullptr},
      {"getChecks", nullptr, RunChecks, nullptr, nullptr, nullptr, napi_default, nullptr},
  };
  if (napi_define_properties(env, exports, 5, methods) != napi_ok) {
    napi_throw_error(env, nullptr, "Unable to register live-view exports");
    return nullptr;
  }
  return exports;
}

static napi_module liveViewModule = {1, 0, nullptr, Init, "entry", nullptr, {0}};

extern "C" __attribute__((constructor)) void RegisterLiveViewModule() {
  napi_module_register(&liveViewModule);
}

注意 napi_module 里的名字是 "entry",而 ArkTS 侧 import 的是 libentry.so——前者是 NAPI 模块逻辑名,后者是产物文件名,两者由 add_library(entry SHARED ...) 对齐,改错一个就会在运行时报 “module not found”。

5.4 类型声明与 ETS 再导出

// entry/src/main/cpp/types/libentry/index.d.ts
export const liveViewCreate: (id: number, title: string, atMs: number) => string;
export const liveViewEventApply: (event: string, progressValue: number, atMs: number) => string;
export const liveViewStop: (atMs: number) => string;
export const liveViewState: () => string;
export const getChecks: () => string;
// entry/src/main/ets/liveview/NativeLiveView.ets
import { liveViewCreate, liveViewEventApply, liveViewStop, liveViewState, getChecks } from 'libentry.so';

export { liveViewCreate, liveViewEventApply, liveViewStop, liveViewState, getChecks };

为什么要多一层 .ets 再导出?因为页面里可能有多处调用,把 from 'libentry.so' 收拢到一个文件,将来换库名或加 mock 实现只改一处。另外 types/libentry/oh-package.json5 的 name 必须是 libentry.so(坑十二),与依赖键、import 路径三者一致,ArkTS 才找得到声明。

6. 双 ABI 构建脚本:一份 Kotlin,两个 .so

6.1 example/nativeApp/build.gradle.kts

plugins { kotlin("multiplatform") }

kotlin {
  jvmToolchain(17)
  ohosArm64 {
    binaries.sharedLib {
      baseName = "ohos_liveview"
      linkerOpts("--entry=0", "--version-script=${project.file("src/ohosArm64Main/linker/shared-library.map")}")
      linkerOpts("-lace_napi.z", "-luv", "-lhilog_ndk.z")
    }
  }
  // DevEco 模拟器是 x86_64;SDK 自带 x86_64-linux-ohos sysroot,因此同一份 Kotlin
  // 源码可以再出一个 ABI,让 demo 在模拟器与真机上都能跑。
  ohosX64 {
    binaries.sharedLib {
      baseName = "ohos_liveview"
      linkerOpts("--entry=0", "--version-script=${project.file("src/ohosArm64Main/linker/shared-library.map")}")
      linkerOpts("-lace_napi.z", "-luv", "-lhilog_ndk.z")
    }
  }
  sourceSets {
    commonMain.dependencies { implementation(project(":shared")) }
  }
}

val prepareOhos by tasks.registering(Copy::class) {
  dependsOn("linkDebugSharedOhosArm64")
  from(layout.buildDirectory.dir("bin/ohosArm64/debugShared")) {
    include("libohos_liveview.so")
    into("libs/arm64-v8a")
  }
  from(layout.buildDirectory.dir("bin/ohosArm64/debugShared")) {
    include("libohos_liveview_api.h")
    into("src/main/cpp/include")
  }
  into(rootProject.layout.projectDirectory.dir("ohosApp/entry"))
}

/** 模拟器用:把 x86_64 版 KMP 共享库拷进 HAP 工程。 */
val prepareOhosX64 by tasks.registering(Copy::class) {
  dependsOn("linkDebugSharedOhosX64")
  from(layout.buildDirectory.dir("bin/ohosX64/debugShared")) {
    include("libohos_liveview.so")
    into("libs/x86_64")
  }
  into(rootProject.layout.projectDirectory.dir("ohosApp/entry"))
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink>().configureEach {
  inputs.file("src/ohosArm64Main/linker/shared-library.map")
}

逐项:

  • baseName = "ohos_liveview" 决定产物 libohos_liveview.so 和头文件 libohos_liveview_api.h,napi_init.cpp 的 #include 与 CMake 的 IMPORTED_LOCATION 都依赖这个名字;
  • --version-script=... 挂上 5.2 节的 linker map;-lace_napi.z、-luv、-lhilog_ndk.z 是 Kotlin/Native 侧生成 C 导出时需要的鸿蒙系统库;
  • 两个 target 共用同一个 shared-library.map 和同一份 NativeBridge.kt:后者复制一份到 src/ohosX64Main/ 即可,源码内容完全一致(构建时校验过两个文件的哈希相同);
  • 两个 Copy 任务把产物直接送进 DevEco 工程的 entry/libs/<abi>/,不需要手工拷文件;
  • 最后那段 inputs.file(...) 把 map 声明为链接任务的输入,改了 map 会触发重新链接——否则会遇到"加了函数却还是 undefined symbol"的诡异缓存问题。

6.2 CMake 与 ABI 过滤

# entry/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(ohos_liveview_openharmony_sample)

# hvigor 会为每个 ABI 传入 -DOHOS_ARCH=arm64-v8a / x86_64
add_library(ohos_liveview SHARED IMPORTED)
set_target_properties(ohos_liveview PROPERTIES
  IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/${OHOS_ARCH}/libohos_liveview.so")

add_library(entry SHARED napi_init.cpp)
target_include_directories(entry PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(entry PRIVATE ohos_liveview libace_napi.z.so)
// entry/build-profile.json5
{
  "apiType": "stageMode",
  "buildOption": {
    "externalNativeOptions": {
      "path": "./src/main/cpp/CMakeLists.txt",
      "arguments": "",
      "cppFlags": "",
      "abiFilters": ["arm64-v8a", "x86_64"]
    }
  },
  "targets": [{ "name": "default" }]
}

${OHOS_ARCH} 是 hvigor 在调 CMake 时通过 -DOHOS_ARCH=<abi> 传进来的(可以在 hvigor-ohos-plugin/src/builder/native-command-builder.js 的 addOhosArch() 里看到),所以不要写死 libs/arm64-v8a,否则模拟器那条 ABI 会链到真机库上,安装时直接报 9568347 install parse native so failed。

6.3 构建命令与产物核对

# 工程根
.\gradlew.bat :liveview:jvmTest --console=plain
.\gradlew.bat :liveview:publishToMavenLocal --console=plain   # 可选:以坐标被其他工程引用

# example/
.\gradlew.bat prepareOhos prepareOhosX64 --console=plain

产物应当是这样:

example/ohosApp/entry/
├── libs/
│   ├── arm64-v8a/libohos_liveview.so     # 真机,约 2.1 MB
│   └── x86_64/libohos_liveview.so        # 模拟器,约 1.8 MB
└── src/main/cpp/include/libohos_liveview_api.h

打出的 HAP 里可以核对两套库是否都在:

Add-Type -AssemblyName System.IO.Compression.FileSystem
$z = [System.IO.Compression.ZipFile]::OpenRead("entry\build\default\outputs\default\entry-default-signed.hap")
$z.Entries | Where-Object FullName -like "*libs/*" | Select-Object FullName, Length
$z.Dispose()

实测输出:

libs/arm64-v8a/libc++_shared.so    1262504
libs/arm64-v8a/libentry.so           59648
libs/arm64-v8a/libohos_liveview.so 1187256
libs/x86_64/libc++_shared.so       1294904
libs/x86_64/libentry.so              61864
libs/x86_64/libohos_liveview.so    1028832

libentry.so 是 NAPI 胶水层(每个 ABI 一份,只有几十 KB),libohos_liveview.so 是 Kotlin/Native 产物(含运行时,1 MB 量级)。Kotlin 逻辑在两个 ABI 里是同一份源码编出来的——这就是 KMP 相对"每端各写一套桥"最直接的收益。

6.4 签名与安装(可直接复现本文截图)

$env:PATH = "D:\DevEco Studio\tools\node;D:\DevEco Studio\jbr\bin;$env:PATH"
$env:DEVECO_SDK_HOME = "D:\DevEco Studio\sdk"
& "D:\DevEco Studio\tools\hvigor\bin\hvigorw.bat" --mode module -p product=default assembleHap --no-daemon

$hdc = "D:\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe"
$hap = "entry\build\default\outputs\default\entry-default-signed.hap"
& $hdc -t 127.0.0.1:5555 shell aa force-stop com.example.hm_webview_demo
& $hdc -t 127.0.0.1:5555 install -r $hap
& $hdc -t 127.0.0.1:5555 shell aa start -b com.example.hm_webview_demo -a EntryAbility -m entry

# 截图(注意扩展名必须是 .jpeg)
& $hdc -t 127.0.0.1:5555 shell uinput -T -c 240 910       # 点「开始模拟下载」
& $hdc -t 127.0.0.1:5555 shell snapshot_display -f /data/local/tmp/shot.jpeg
& $hdc -t 127.0.0.1:5555 file recv /data/local/tmp/shot.jpeg .\shot.jpeg

uinput -T -c <x> <y> 是触摸点击,坐标基于 1280×2832 的物理分辨率。本文 6 张截图就是用这组命令配合 Start-Sleep 串成一条脚本连续抓下来的——把点击和截图放在同一条脚本里很重要,否则两次工具调用之间下载就自己跑完了,抓到的状态会和预期错开。

7. ArkTS 宿主:翻译层与页面

7.1 LiveViewClient.ets:唯一知道系统 API 的类

先把桥面类型原样声明一遍。KmpSnapshot 的字段和 4.2 节 LiveViewSnapshot.toJson() 逐字段对应,这是两侧唯一的契约:

import { liveViewManager } from '@kit.LiveViewKit';
import { wantAgent, WantAgent, Want, bundleManager } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export interface KmpSnapshot {
  id: number;
  title: string;
  phase: string;
  progressValue: number;
  progressMax: number;
  progressPercent: number;
  updatedAtMs: number;
  eventCount: number;
}

export class LiveViewClient {
  private systemViewId: number = -1;
  private sequence: number = 0;
  public realMode: boolean = true;

  /** sequence 必须严格递增;进程重启后也要大于上次会话遗留的实况窗版本号。 */
  private nextSequence(): number {
    const base = Math.floor(Date.now() / 1000);
    this.sequence = Math.max(this.sequence + 1, base);
    return this.sequence;
  }

  async isEnabled(): Promise<boolean> {
    return liveViewManager.isLiveViewEnabled();
  }

nextSequence() 是坑三·再补的解法。sequence 的合法范围是 [0, 2147483647] 且"更新/结束时必须大于当前值",直接用 eventCount 会在进程重启后从 1 开始,低于上一次会话残留的实况窗版本;用 Unix 秒当下界,既单调又不越界。

推送与结束:

  /** 把 KMP 快照翻译成系统 LiveView 并推送。 */
  async pushSnapshot(snapshot: KmpSnapshot): Promise<number> {
    const view = await this.buildLiveView(snapshot, 1);
    if (this.systemViewId < 0) {
      const result = await liveViewManager.startLiveView(view);
      this.assertSuccess(result, 'startLiveView');
      this.systemViewId = snapshot.id;
      return result.resultCode;
    }
    const result = await liveViewManager.updateLiveView(view);
    this.assertSuccess(result, 'updateLiveView');
    return result.resultCode;
  }

  /** 结束系统实况窗(胶囊 status = -1 表示结束展示)。 */
  async stop(snapshot: KmpSnapshot | null): Promise<void> {
    if (this.systemViewId < 0) return;
    const view = snapshot !== null
      ? await this.buildLiveView(snapshot, -1)
      : await this.buildStopFallback();
    try {
      const result = await liveViewManager.stopLiveView(view);
      this.assertSuccess(result, 'stopLiveView');
    } finally {
      this.systemViewId = -1;
    }
  }

systemViewId 是"是否创建过"的开关:< 0 走 startLiveView,否则走 updateLiveView。stop() 的 finally 无论成功失败都把 id 复位,保证下次开始新会话时不会误用旧 id。

clickAction 与全量构造:

  /** 点击实况窗拉回本应用主页面。 */
  private async clickAction(): Promise<WantAgent> {
    const info = await bundleManager.getBundleInfoForSelf(
      bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
    return wantAgent.getWantAgent({
      wants: [{ bundleName: info.name, abilityName: 'EntryAbility' } as Want],
      operationType: wantAgent.OperationType.START_ABILITY,
      requestCode: 0,
      wantAgentFlags: [wantAgent.WantAgentFlags.CONSTANT_FLAG],
    });
  }

  /** 全量构造 LiveViewData:primary + 进度模板 + 文本胶囊。 */
  private async buildLiveView(
    snapshot: KmpSnapshot,
    capsuleStatus: number,
  ): Promise<liveViewManager.LiveView> {
    const id = this.systemViewId > 0 ? this.systemViewId : snapshot.id;
    return {
      id: id,
      event: 'PROGRESS',
      sequence: this.nextSequence(),
      isMute: true,
      liveViewData: {
        primary: {
          title: snapshot.title,
          content: [
            { text: `进度 ${snapshot.progressPercent}%` },
            { text: this.phaseText(snapshot.phase) },
          ],
          keepTime: 30,
          clickAction: await this.clickAction(),
          layoutData: {
            layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_PROGRESS,
            progress: snapshot.progressPercent,
            color: '#FF317AF7',
            // 运行时强校验:nodeIcons 长度必须 2~5,元素为 rawfile 文件名或 PixelMap
            nodeIcons: ['node_start.png', 'node_end.png'],
          } as liveViewManager.ProgressLayout,
        },
        capsule: {
          type: liveViewManager.CapsuleType.CAPSULE_TYPE_TEXT,
          status: capsuleStatus,
          icon: 'capsule.png',
          backgroundColor: '#FF1E6BB8',
          title: `${snapshot.progressPercent}%`,
          content: this.phaseText(snapshot.phase),
        } as liveViewManager.TextCapsule,
      },
    };
  }

这段就是坑二、坑三的全部"标准答案":

  • event: 'PROGRESS' 是场景字符串,来自 AGC 申请时填的类目,不是状态枚举;
  • primary.clickAction 传 WantAgent,包名通过 bundleManager.getBundleInfoForSelf() 动态取,避免把 bundleName 硬编码进组件;
  • layoutData.nodeIcons 必须给 2~5 个元素,元素是 resources/rawfile/ 下的文件名(本项目放了 node_start.png / node_end.png / capsule.png 三个 32×32 小图标);
  • capsule.status:1 展示、-1 结束;颜色用 #AARRGGBB 八位格式,#1E6BB8 这种六位写法不合规;
  • content 里的两行文字直接来自 KMP 快照的 progressPercent 与 phase,宿主不做任何业务计算。

收尾的两个工具方法:

  private async buildStopFallback(): Promise<liveViewManager.LiveView> {
    return {
      id: this.systemViewId,
      event: 'PROGRESS',
      sequence: this.nextSequence(),
      liveViewData: {
        primary: { title: '任务已结束', clickAction: await this.clickAction() },
        capsule: {
          type: liveViewManager.CapsuleType.CAPSULE_TYPE_TEXT,
          status: -1,
          icon: 'capsule.png',
          backgroundColor: '#FF1E6BB8',
          title: '已结束',
        } as liveViewManager.TextCapsule,
      },
    };
  }

  private assertSuccess(result: liveViewManager.LiveViewResult, api: string): void {
    if (result.resultCode !== 0) {
      throw new Error(`${api} 返回 ${result.resultCode}: ${result.message}`);
    }
  }

  private phaseText(phase: string): string {
    switch (phase) {
      case 'ACTIVE': return '进行中';
      case 'PAUSED': return '已暂停';
      case 'COMPLETED': return '已完成';
      case 'EXPIRED': return '已超时';
      case 'STOPPED': return '已结束';
      default: return phase;
    }
  }
}

export function errorText(error: BusinessError | Error | string): string {
  if (typeof error === 'string') return error;
  const biz = error as BusinessError;
  return biz.code !== undefined ? `${biz.code} ${biz.message}` : String(error.message ?? error);
}

assertSuccess 是必须的:LiveViewResult 里固定区/扩展区/胶囊更新失败会以 resultCode 1~5 正常返回,不抛异常。封装层统一在这里转成 Error,上层 .catch() 才能切 mock 模式。

7.2 Index.ets:事件入口与节流落地

桥面返回的类型声明(ArkTS 不允许隐式 any,所以每个 JSON.parse 都要断言):

interface KmpStats {
  revision: number;
  pushedCount: number;
  suppressedCount: number;
  avgIntervalMs: number;
  maxIntervalMs: number;
  snapshot: KmpSnapshot | null;
}

interface ErrorPayload { error: string }

interface ApplyResultView {
  snapshot: KmpSnapshot;
  push: boolean;
}

const PHASE_LABELS: Record<string, string> = {
  'ACTIVE': '进行中',
  'PAUSED': '已暂停',
  'COMPLETED': '已完成',
  'EXPIRED': '已超时',
  'STOPPED': '已结束',
};

整个应用最关键的 30 行——所有事件都从这里过:

  private applyEvent(event: string, progressValue: number): void {
    // 1) 事件先进 KMP 状态机(校验 + 节流判定都在 Kotlin 侧完成)
    const raw = liveViewEventApply(event, progressValue, Date.now());
    this.refreshStats();
    if (raw.includes('"error"')) {
      const failure = JSON.parse(raw) as ErrorPayload;
      this.errorText = `KMP 拒绝:${failure.error}`;
      return;
    }
    const result = JSON.parse(raw) as ApplyResultView;
    const snapshot = result.snapshot;
    // 2) 只有 KMP 判定「该推」时才打扰系统实况窗
    if (!result.push) {
      this.log(`⊘ 抑制: ${event} ${snapshot.progressPercent}%`);
      return;
    }
    if (!this.realMode) {
      this.log(`→ 本地: ${event} ${snapshot.progressPercent}%`);
      return;
    }
    if (event === 'COMPLETE' || event === 'STOP' || event === 'EXPIRE') {
      this.client.stop(snapshot).then(() => {
        this.log(`→ 系统: END ${snapshot.progressPercent}%`);
      }).catch((err: BusinessError) => {
        this.errorText = `系统实况窗结束失败:${errorText(err)}`;
        this.realMode = false;
      });
      return;
    }
    this.pushToSystem(snapshot, event);
  }

  private pushToSystem(snapshot: KmpSnapshot, event: string): void {
    this.client.pushSnapshot(snapshot).then(() => {
      this.log(`→ 系统: ${event} ${snapshot.progressPercent}%`);
    }).catch((err: BusinessError) => {
      this.errorText = `系统实况窗推送失败:${errorText(err)}`;
      this.realMode = false;
    });
  }

  private log(line: string): void {
    this.pushLog.unshift(line);
    if (this.pushLog.length > 8) this.pushLog.pop();
  }

分支顺序是有讲究的:错误 → 抑制 → mock → 终态 → 普通推送。COMPLETE/STOP/EXPIRE 走 client.stop() 而不是 pushSnapshot(),对应图 5 的 END 100%;任何一步系统报错都把 realMode 置 false,后续事件只写 → 本地 日志,界面与统计不受影响。

启动、定时器、暂停、结束四个交互:

  private startDemo(): void {
    if (this.busy) return;
    this.busy = true;
    this.errorText = '';
    this.progress = 0;
    this.pushLog = [];
    try {
      const created = JSON.parse(
        liveViewCreate(Date.now() % 100000 + 1, '模拟固件下载', Date.now())) as KmpSnapshot;
      this.refreshStats();
      // 创建后必须显式 startLiveView,后续 UPDATE 才会走 updateLiveView
      if (this.realMode) this.pushToSystem(created, 'START');
      else this.log('→ 本地: START 0%');
      this.startTimer();
    } catch (error) {
      this.busy = false;
      this.errorText = `启动失败:${String(error)}`;
    }
  }

  /** 每 400ms 推一次进度:比 KMP 节流窗口(800ms)密一倍。 */
  private startTimer(): void {
    if (this.timerId >= 0) clearInterval(this.timerId);
    this.timerId = setInterval(() => {
      this.progress += 4 + Math.floor(Math.random() * 5);
      if (this.progress >= 100) {
        this.progress = 100;
        this.applyEvent('UPDATE', this.progress);
        clearInterval(this.timerId);
        this.timerId = -1;
        this.applyEvent('COMPLETE', -1);
        this.busy = false;
        return;
      }
      this.applyEvent('UPDATE', this.progress);
    }, 400);
  }

  private pauseOrResume(): void {
    const phase = this.stats?.snapshot?.phase;
    if (phase === 'PAUSED') {
      this.applyEvent('RESUME', -1);
      this.startTimer();
    } else if (phase === 'ACTIVE') {
      // PAUSED 态会拒绝 UPDATE,必须先停定时器,否则回调会撞上状态机校验
      if (this.timerId >= 0) {
        clearInterval(this.timerId);
        this.timerId = -1;
      }
      this.applyEvent('PAUSE', -1);
    }
  }

  private stopAll(): void {
    if (this.timerId >= 0) {
      clearInterval(this.timerId);
      this.timerId = -1;
      this.busy = false;
    }
    try {
      const phase = this.stats?.snapshot?.phase;
      // 终态幂等:状态机拒绝任何后续事件,这里不再重复投递 STOP
      if (phase === 'ACTIVE' || phase === 'PAUSED') {
        JSON.parse(liveViewStop(Date.now()));
        if (this.realMode) this.client.stop(this.stats?.snapshot ?? null).catch(() => {});
      }
      this.refreshStats();
      this.statusText = '会话已结束';
    } catch (error) {
      this.errorText = `结束失败:${String(error)}`;
    }
  }

注意 startDemo() 里 liveViewCreate 之后立刻 pushToSystem(created, 'START')——这是坑三·再补的修复点。少了这一句,后面每个 UPDATE 都会因为 systemViewId < 0 反复调 startLiveView。

7.3 页面:三张卡片全部由 KMP 数据驱动

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('KMP 鸿蒙实况窗(Live View Kit)').fontSize(22).fontWeight(FontWeight.Bold)
        Text(this.statusText).fontSize(14).opacity(0.8)

        Row({ space: 10 }) {
          Button(this.busy ? '下载中…' : '开始模拟下载')
            .layoutWeight(1).enabled(!this.busy)
            .onClick(() => this.startDemo())
          Button(this.stats?.snapshot?.phase === 'PAUSED' ? '继续' : '暂停')
            .layoutWeight(1)
            .enabled(this.stats?.snapshot?.phase === 'ACTIVE' ||
                     this.stats?.snapshot?.phase === 'PAUSED')
            .onClick(() => this.pauseOrResume())
          Button('结束').layoutWeight(1).backgroundColor('#7B3F00')
            .onClick(() => this.stopAll())
        }.width('100%')

        // ---- KMP 状态机卡片 ----
        Column({ space: 6 }) {
          Row() {
            Text('KMP 状态机').fontSize(16).fontWeight(FontWeight.Medium)
            Blank()
            if (this.stats?.snapshot) {
              Text(PHASE_LABELS[this.stats.snapshot.phase] ?? this.stats.snapshot.phase)
                .fontSize(13)
                .fontColor(this.stats.snapshot.phase === 'ACTIVE' ? '#1E8449' : '#7F8C8D')
            }
          }.width('100%')

          Progress({ value: this.stats?.snapshot?.progressValue ?? 0,
                     total: 100, type: ProgressType.Linear }).width('100%')

          Text(`rev ${this.stats?.revision ?? 0} · 事件 ${this.stats?.snapshot?.eventCount ?? 0} · ` +
               `推送 ${this.stats?.pushedCount ?? 0} · 抑制 ${this.stats?.suppressedCount ?? 0}`)
            .fontSize(12).opacity(0.7)
        }.padding(12).borderRadius(12).backgroundColor('#F4F6F8')

        // ---- 节流统计 ----
        Column({ space: 6 }) {
          Text('节流统计(KMP 核心计算)').fontSize(16).fontWeight(FontWeight.Medium)
          Text(`平均推送间隔 ${this.stats?.avgIntervalMs ?? 0} ms · 最大间隔 ${this.stats?.maxIntervalMs ?? 0} ms`)
          ForEach(this.pushLog, (item: string) => {
            Text(item).fontSize(12).opacity(0.7).width('100%')
          }, (item: string, idx: number) => `${idx}-${item}`)
        }.padding(12).borderRadius(12).backgroundColor('#F4F6F8')

        // ---- KMP 自检 ----
        Column({ space: 6 }) {
          Row() {
            Text('KMP 自检').fontSize(14).fontWeight(FontWeight.Medium)
            Blank()
            Text(this.checksPassed ? '通过' : '未通过')
              .fontColor(this.checksPassed ? '#1E8449' : '#C0392B')
          }.width('100%')
          ForEach(this.checks, (item: string) => {
            Text(`✓ ${item}`).fontSize(11).opacity(0.7).width('100%')
          }, (item: string) => item)
        }.padding(12).borderRadius(12).backgroundColor('#F4F6F8')
      }.padding(16).width('100%')
    }.scrollable(ScrollDirection.Vertical)
     .width('100%').height('100%').backgroundColor('#FAFBFC')
  }

上面这段是 build() 的节选,省略了重复的 .width('100%')、.textAlign(...)、.opacity(...) 以及红色错误文本分支,结构与仓库一致。

@State 只有 7 个,且没有任何一个是业务状态——busy 只管按钮,stats / pushLog / checks 全部来自 KMP。真正在决定"现在是暂停还是完成、这次该不该推"的,永远是 Kotlin 那份 LiveViewSnapshot。这就是本文开头说的"Kotlin 拥有全部决策"。

顺带一提 ArkUI 的一个语法约束:最外层必须是 Scroll 包 Column,scrollable() 要挂在 Scroll 上而不是 Column 上;第一版写反了,编译直接报错。

8. 把一次 UPDATE 从头到尾走一遍

前面把每一层的代码都摊开了,最后用一条具体的事件把链路串起来。假设进度从 24% 跳到 30%,且距上次推送刚过 400ms:

① ArkTS:Index.applyEvent('UPDATE', 30)

const raw = liveViewEventApply('UPDATE', 30, 1762944000000);

② NAPI:EventApply 取参数、调 C、转字符串、释放

auto out = LiveViewEventApply("UPDATE", 30, 1762944000000);
auto result = ReadNativeText(env, reinterpret_cast<const char*>(out));
LiveViewFree(out);

③ Kotlin/Native:eventNative 把 -1 哨兵还原成 Int?,调会话层

val progress = if (progressValue < 0) null else progressValue   // 30
val result = LiveViewExamples.apply("UPDATE", progress, atMs)

④ 核心库:apply → shouldPush

当前快照: progressValue=24, phase=ACTIVE, lastPushPercent=24, lastPushAtMs=...-400ms
新快照:   progressValue=30, phase=ACTIVE, eventCount+1
shouldPush: percent(30) - lastPushedPercent(24) = 6 >= MIN_PERCENT_DELTA(5) → true

这里体现的是"跳变优先":虽然距离上次推送只有 400ms(不足 800ms),但 6% 的跳变足够让胶囊数字明显变化,所以照推。

⑤ 桥面回传 JSON

{
  "snapshot": {
    "id": 48273,
    "title": "模拟固件下载",
    "phase": "ACTIVE",
    "progressValue": 30,
    "progressMax": 100,
    "progressPercent": 30,
    "updatedAtMs": 1762944000000,
    "eventCount": 6
  },
  "push": true
}

liveViewState() 同时可以拉到这一刻的统计(snapshot 字段就是上面那份完整快照,这里省略):

{
  "revision": 25,
  "pushedCount": 5,
  "suppressedCount": 1,
  "avgIntervalMs": 502,
  "maxIntervalMs": 803,
  "snapshot": { ... }
}

⑥ ArkTS 按 push 分支

const result = JSON.parse(raw) as ApplyResultView;
if (!result.push) { this.log(`⊘ 抑制: ...`); return; }
this.pushToSystem(result.snapshot, 'UPDATE');

push=true → LiveViewClient.pushSnapshot() → 因为 systemViewId > 0,走 updateLiveView。

⑦ 系统侧最终收到的数据(对象结构示意,<WantAgent> 是运行时实例,非字面量)

{
  id: 48273,
  event: 'PROGRESS',
  sequence: 1762944000,          // nextSequence(),严格递增
  isMute: true,
  liveViewData: {
    primary: {
      title: '模拟固件下载',
      content: [{ text: '进度 30%' }, { text: '进行中' }],
      keepTime: 30,
      clickAction: <WantAgent>,
      layoutData: {
        layoutType: LAYOUT_TYPE_PROGRESS,
        progress: 30,
        color: '#FF317AF7',
        nodeIcons: ['node_start.png', 'node_end.png']
      }
    },
    capsule: {
      type: CAPSULE_TYPE_TEXT, status: 1,
      icon: 'capsule.png', backgroundColor: '#FF1E6BB8',
      title: '30%', content: '进行中'
    }
  }
}

⑧ 结果检查

const result = await liveViewManager.updateLiveView(view);
this.assertSuccess(result, 'updateLiveView');   // resultCode !== 0 就抛错 → 上层切 mock

如果第 ④ 步算出来是 push=false(比如 24% → 26%、间隔 400ms),第 ⑤ 步的 JSON 里只有 "push": false,第 ⑥~⑧ 步完全不执行,界面只多一条 ⊘ 抑制: UPDATE 26%。状态机照常推进、统计照常累加,系统一次都没被打扰——这就是图 2 里灰色行的全部含义。

8.1 这条链路上必须守住的不变量

不变量由谁保证破坏了会怎样
快照不可变,迁移只走 copy()LiveViewTypes.kt / LiveViewEngine.kt日志与 UI 拿到半更新状态
终态拒绝一切事件apply() 第一道闸门完成后又冒出一条 UPDATE,卡片状态错乱
PAUSED 拒收 UPDATEapply() 第二道闸门暂停后进度继续爬
进度单调不减、不越界maxOf(minOf(...), current)胶囊数字回跳
节流裁决随快照一起回传ApplyResult / eventNative统计好看但系统照刷
控制事件不参与节流var push = truePAUSE/COMPLETE 被吞,卡片卡住
sequence 严格递增nextSequence()1003500011 sequence is incorrect
startLiveView 只调一次systemViewId + 创建后显式 START重复创建、版本冲突
native 字符串用完即释放LiveViewFree(out)内存泄漏 / use-after-free
异常不越过 C 边界inline response { }进程 abort 而非可读错误

8.2 从"能跑"到"能查"

这套分层还有个附带好处:出问题时定位路径是线性的。

# 1) 核心逻辑对不对?不碰设备就能验证
.\gradlew.bat :liveview:jvmTest --console=plain

# 2) 设备上 Kotlin 自检过没过?看页面最下面那 8 条绿勾
#    (对应 NativeBridge 的 LiveViewRunChecks)

# 3) 桥通没通?看 stats 的 revision 有没有随事件递增
#    停住不动 → 桥没调通或参数被 NAPI 校验挡了

# 4) 事件进没进状态机?看 eventCount
#    涨了但 pushedCount 不涨 → 正常,被节流抑制了

# 5) 系统收没收?看日志是 "→ 系统" 还是 "→ 本地"
#    出现 "→ 本地" → 之前某次推送失败,已降级 mock

# 6) 系统参数对不对?看红色 errorText(401 / 1003500011 等)

线上排查"实况窗没更新"时,eventCount 与 pushedCount / suppressedCount 这组三元组基本能一刀切出问题在哪一侧:业务没发事件、Kotlin 主动抑制、还是系统侧拒绝。

9. 踩坑记录

坑一:实况窗是受限开放能力,先申请权限再排期。 Live View Kit 不像普通 Kit 那样声明权限就能用,需要在 AGC 提交 event 场景权限申请(场景类目审核)。实测中 startLiveView 在模拟器上能正常返回,但胶囊/卡片展示是否生效仍取决于场景授权;另外参数缺漏也会以 401 打回,容易和"没权限"混淆。demo 因此把 mock 模式做成一等公民:真调用与 mock 两套路径都验证过,权限或参数出问题时自动降级。

坑二:LiveViewResult 不抛异常,只看 resultCode。 startLiveView / updateLiveView / stopLiveView 全都返回 Promise<LiveViewResult>,参数错误、服务未连接等场景是 BusinessError 异常,而「固定区/扩展区/胶囊更新失败」是 resultCode 1~5 的正常返回。第一版只 await 不检查,界面显示"已推送"但胶囊根本没变。封装层统一 assertSuccess(result, api) 后再更新 UI。

坑三:胶囊的 type/status 必填,文本字段不能为空串。 capsule 是 TextCapsule | TimerCapsule | ProgressCapsule 联合类型,必须给 type: CapsuleType.CAPSULE_TYPE_TEXT 和 status: 1(结束时传 -1),title 固定写百分比文本。传空数组或空字符串会被参数校验打回 401。

坑三·补:primary.clickAction 与 layoutData.nodeIcons 在 d.ts 里是可选的,运行时却强制校验。 实测依次报 401 Parameter error. The type of primary.clickAction must be WantAgent [or the parameter is mandatory] 和 401 ... layoutData.nodeIcons must be Array<string | image.PixelMap>。clickAction 用 wantAgent.getWantAgent() 构造拉回本应用 EntryAbility 的 WantAgent;nodeIcons 传 ['node_start.png','node_end.png'] 两个 rawfile 图标(数组长度 2~5)。文档声明与运行时校验不一致,是本 Kit 最容易踩的坑,建议封装层把这两个字段固定为必填。

坑三·再补:必须显式调用且只调用一次 startLiveView。 第一版 startDemo() 只调了 KMP 的 create,没把初始快照推给系统,于是后面每个 UPDATE 都因 systemViewId < 0 走 startLiveView,重复创建触发 1003500011 The liveView sequence is incorrect。正确顺序是 create → startLiveView(start) → updateLiveView(仅 push=true 时) → stopLiveView(COMPLETE);sequence 还得跨进程重启保持单调,否则旧会话残留的实况窗版本号更大,同样报错。

坑四:节流裁决不穿过桥面,等于没做节流。 第一版 Kotlin 只统计 pushedCount / suppressedCount,宿主每个事件照样推系统,图 2 里的 ⊘ 抑制 就只是个装饰。改成 ApplyResult(snapshot, push) 随 JSON 回宿主后才真正生效。

坑五:暂停时必须同步停定时器。 PAUSED 态会拒绝 UPDATE 并抛异常,400ms 的 setInterval 若不停,界面会连续弹红字。把定时器抽成 startTimer(),PAUSE 时 clearInterval、RESUME 时重启——图 3 的干净界面就是这么来的。

坑六:终态要幂等。 COMPLETE 之后再点「结束」,状态机会拒绝 STOP。stopAll() 现在只在 ACTIVE/PAUSED 时投递 STOP,其余情况只做系统清理与文案更新,对应图 6。

坑七:400ms 定时器事件与 COMPLETE 竞态。 模拟下载到 100% 时先 clearInterval 再发 COMPLETE,但已入队的最后一次 UPDATE 仍可能晚于 COMPLETE 到达。有状态机之后这不再是 bug——终态拒绝事件,错误被桥面转成 JSON 由 UI 静默处理。把竞态裁决收进共享层,业务代码不需要再加各种 isDone 标志位。

坑八:paused 态的 EXPIRE 判定。 最初 shouldExpire 对所有非终态生效,导致"暂停下载去回消息,回来实况窗没了"。PAUSED 是用户主动挂起,不该被超时回收——修正为只对 ACTIVE 计时。这类语义分歧正是 JVM 测试要锁死的(测试里有"paused 十倍超时不失效"用例)。

坑九:Kotlin 编译器不认 DevEco 自带 JBR。 DevEco 内嵌 JBR 25 的版本号字符串让 Kotlin Gradle 插件 / Gradle 8.14 解析失败,gradle.properties 里 org.gradle.java.home 钉死 JDK 17 即可。

坑十:version-script 漏符号。 往桥里加 LiveViewStop 后忘更新 shared-library.map,CMake 链接报 undefined symbol。每加一个 @CName 函数,map 文件必须同步。

坑十一:ArkTS 不是 TypeScript,禁用 in 且不允许隐式 any。 桥面返回 {"error":…} 时第一版用 'error' in obj 判断,直接报 arkts-no-in;JSON.parse 的结果必须显式断言成接口(ErrorPayload / KmpSnapshot / ApplyResultView),否则报 arkts-no-any-unknown。改成先检查原始字符串 raw.includes('"error"') 再分别解析,两个错误一起消失。

坑十二:NAPI 类型包名必须与 .so 一致。 src/main/cpp/types/libentry/oh-package.json5 的 name 写成 libentry,而 entry/oh-package.json5 依赖键和 ETS 导入都是 libentry.so,ohpm 虽不报错但 ArkTS 找不到类型声明。改成 libentry.so 后编译通过。

坑十三:Windows 上 ohpm/hvigor 的 junction 可能整体失效。 本机 Test-Path 对任何 junction 都返回 false(连"同盘自指向"也一样),oh_modules/libentry.so 与 .hvigor/project_caches/.../node_modules/@ohos/hvigor 都是坏链接,表现为 UNKNOWN: unknown error, stat ...。解决办法:把这两处链接删掉后复制成实体目录;命令行编译时用 node 直接执行缓存里的 hvigor.js 并把 NODE_PATH 指向同一份 @ohos/hvigor(避免双实例报 “root node is not yet available”)。另外 AppScope/resources/base/element/string.json 必须是 {"string":[…]} 格式,写成裸数组会在 CompileResource 阶段报 11203002。

坑十四:Gradle 发行包与 JDK 都要换成本地可达的源。 services.gradle.org 直连超时,wrapper 改 https://mirrors.cloud.tencent.com/gradle/gradle-8.14.3-bin.zip;本机只有 DevEco 的 JBR 25,从华为云镜像拿 OpenJDK 17 并在两处 gradle.properties 里钉死 org.gradle.java.home。

坑十五:Windows 模拟器是 x86_64,只编 arm64-v8a 会装不上。 hdc install 直接报 9568347 install parse native so failed ... Abi type ... does not match。HMS SDK 的 sysroot 里本来就有 x86_64-linux-ohos,Kotlin/Native 侧加一个 ohosX64() 目标、同一份 NativeBridge.kt 放进 ohosX64Main,prepareOhosX64 把产物拷到 libs/x86_64/;entry/build-profile.json5 的 abiFilters 写 ["arm64-v8a","x86_64"],CMake 用 hvigor 传入的 ${OHOS_ARCH} 选 .so 路径:

set_target_properties(ohos_liveview PROPERTIES
  IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/${OHOS_ARCH}/libohos_liveview.so")

这样一套 Kotlin 源码同时覆盖真机与模拟器,HAP 里两套 .so 各 1~2 MB。另外本机 hvigor CLI 对含中文的工程路径会解析失败(Cannot find project build file build-profile.json5 under folder: ...鸿蒙生态PGC.),命令行构建 / DevEco 打开工程建议放到纯 ASCII 路径。

10. 结论与建议

  • 实况窗是跨端框架最值得优先接入的鸿蒙特性之一:数据驱动、无相机等重权限依赖、KMP 封装形态天然成立(决策在共享层、翻译在宿主层);
  • 先建状态机再接系统 API。实况窗的坑大多在状态语义(暂停拒收、终态拒绝、幂等收尾)而不是调用本身,把语义收进带单元测试的 Kotlin 核心,系统对接只剩翻译;
  • 节流的裁决必须穿过桥面。只统计不拦截等于没做节流;push 布尔值随快照一起回宿主,ArkTS 才真正少打扰系统。模拟器实测一次完整下载 21 个事件:15 次推送、3 次抑制、平均推送间隔 606ms;
  • 受限权限要当一等公民设计。mock 模式不是妥协,它让状态机开发与权限申请两条线并行;本次在模拟器上 start/update/stopLiveView 全链路已实调通过,仅胶囊/锁屏卡片展示受场景权限限制;
  • 下一步计划:申请 PROGRESS 场景权限后补拍真机胶囊态与锁屏卡片,并接入 LAYOUT_TYPE_PICKUP(取餐码)/ LAYOUT_TYPE_SCORE(比分)模板,验证同一套 KMP 状态机在多场景下的复用边界。

参考资料

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐