可见性/焦点/深度的状态联动更新

空间组件有三个状态会同时变:可见性(视锥裁剪决定看不看得见)、焦点(交互选中决定谁在响应)、深度(Z 轴位置决定远近层级)。2D 里这三个状态基本独立——可见性靠 display 样式、焦点靠 requestFocus、深度没有 Z 轴。空间里它们咬合在一起:组件不可见时焦点要释放,深度变化时可见性可能被裁剪,焦点切换时深度要跟着调到顶层。我们第一版各管各的,结果出现"看不见的组件还在抢焦点"“焦点飘到 z=-500 的旧位置”"面板切可见性时中间闪一下旧深度"三种 bug,每个都查了半天。这篇把三个状态的依赖关系理清楚,给一份能跑通的更新顺序和批量更新策略。

能力面:三个状态的依赖关系

状态定义

  • 可见性 visible:组件是否在视锥内且未被裁剪。空间里可见性不是 display:none 那种二值,而是"在视锥内 + 未被遮挡 + alpha > 0"三个条件合算。视锥裁剪由相机位置和朝向决定,组件自己改不了,只能监听。
  • 焦点 focused:组件是否是当前交互选中目标。空间焦点是 3D 的,带 z 坐标。同一时刻只能有一个组件 focused(单焦点模型,API 26 不支持多焦点)。
  • 深度 z:组件在 Z 轴的位置,决定远近层级和渲染顺序。z 越大越靠前(接近相机),z 越小越远。

依赖关系

三个状态不是独立的,有四条依赖边:

  1. visible = false → focused 必须释放。看不见的组件不能抢焦点,否则用户视线点选时焦点对不上视觉。
  2. focused = true → z 提到顶层。聚焦的组件要在最前面,避免被其他组件遮挡影响交互。
  3. z 变化 → visible 重新计算。z 推远可能出视锥,z 拉近可能进视锥。
  4. focused = true → visible 必须为 true。聚焦组件必须可见,不能聚焦到看不见的东西。

这四条边形成一个有环的依赖图——z 影响 visible,visible 影响 focused,focused 又影响 z。环意味着更新顺序不能简单按拓扑排序,必须有打破环的规则。

更新优先级

我们定的优先级是:visible → focused → z。理由是 visible 是最被动的(由视锥决定,组件改不了),先更新它;focused 依赖 visible,第二步;z 依赖 focused(聚焦要提层),最后更新。

但 focused 又反过来影响 z(依赖边 2),这就是环。破环规则:当次更新中 focused 已经基于旧 z 算出来的,z 更新后不再回头改 focused。也就是说同一帧内 focused 只算一次,z 更新后下一帧再重新算 focused。这样会有一帧的 z 和 focused 不一致,但人眼察觉不到单帧差异。

这个破环规则是我们试了三种方案后定的。第一种试"z 优先",结果 z 先提到顶层,focused 还在旧位置,焦点飘到错误位置(坑一详述)。第二种试"focused 优先",结果 focused 先切到新组件,但旧组件 z 还在顶层,新组件被旧组件遮挡,焦点对不上视觉。第三种就是现在的"visible → focused → z + 不回头",能跑稳,单帧不一致人眼无感。

约束面:更新频率与性能开销

状态更新频率

三个状态的触发频率差很多:

  • visible:每帧都可能变(用户转头视锥变)。但实际只在跨视锥边界时变,大部分帧不变。实测平均 3~5 帧变一次。
  • focused:用户交互触发,频率低。实测平均 200~500ms 才变一次。
  • z:动画或交互触发。动画时每帧变,静态时不变。

如果每帧都把三个状态全跑一遍联动更新,性能浪费——大部分帧三个状态都没变。我们用脏标记优化,只有状态实际变了才跑联动。每个状态有个 dirty 标记,联动更新前先检查,全 false 就跳过。

联动更新的性能开销

单次联动更新(一个状态变 → 触发依赖边 → 联动其他状态)的耗时拆解(Vision,100 个组件的场景):

联动类型耗时主要开销
visible → focused 释放0.4ms遍历子组件检查是否有依赖焦点的
focused → z 提层1.2ms重排渲染顺序,触发一次 draw call 重排
z → visible 重算0.8ms视锥裁剪重新计算
focused → visible 强制0.3ms强制设 visible=true,开销小

单次最坏 1.2ms,一帧 16.67ms(60fps)占 7%。如果一帧内三个状态都变,联动链最长 4 步,总开销 2.7ms,占 16%。这个开销在 60fps 边缘,90fps(11.1ms/帧)就吃紧了。我们的策略是联动更新合并到一帧末尾批量跑,不要每个状态变了立刻联动。

场景落地:空间面板切换可见性时的联动

具体场景:商品详情页有一个"参数面板",默认不可见(visible=false)。用户点"查看参数"按钮,参数面板 visible 切到 true,同时要聚焦到这个面板(focused=true),并把面板 z 提到顶层(z=200)。

正确的联动链:

  1. 用户点按钮,触发 panel.visible = true
  2. 联动检查:visible 变 true,无强制释放焦点需求
  3. 业务逻辑:要聚焦到新面板,触发 panel.focused = true
  4. 联动检查:focused 变 true,强制 visible=true(已经是 true,no-op),z 提到顶层
  5. 触发 panel.z = 200
  6. 联动检查:z 变化,重算 visible(z=200 仍在视锥内,visible 仍 true)

整个链 4 步联动,按"visible → focused → z + 不回头"顺序跑通。如果顺序错了——比如先 focused=true 再 visible=true,第 4 步 visible 强制检查会基于旧 visible=false 算,逻辑分支走错。

错误顺序的 bug 复现

我们第一版没定顺序,开发者各写各的,bug 复现率统计(100 次面板切换):

更新顺序bug 复现次数bug 类型
visible → focused → z(正确)0无
focused → visible → z17焦点飘到 z=0 旧位置
z → focused → visible23面板被旧顶层遮挡,焦点对不上
visible → z → focused8中间闪烁旧深度
三个并行更新31三种 bug 都有

错误顺序不是每次都 bug,因为依赖边触发条件可能不满足(比如 z 没变就不触发 visible 重算)。但复现率 17%~31% 足够测试提 P0。

踩坑与取舍

坑一:先更新深度再更新焦点导致焦点飘到错误位置

第一版面板切换代码是这么写的:

// 错误顺序:先 z 后 focused
panel.z = 200          // 先提层
panel.focused = true   // 再聚焦

跑起来面板确实提层了,但焦点位置不对——requestFocus 内部记录的焦点坐标是 (x, y, z=0),因为聚焦时 z 还没改。z 改了之后焦点没跟着更新,焦点停在 z=0 平面,而面板在 z=200,用户视线点选时焦点对不上面板。

修复是把顺序倒过来:

// 正确顺序:先 focused 后 z
panel.focused = true   // 先聚焦,焦点坐标基于当前 z
panel.z = 200          // 再提层,焦点 z 联动到 200

但这样仍有问题——focused 时 z 还是旧值,焦点 z 先记成旧值,z 改了再联动改焦点 z。中间有一帧焦点 z 是旧的。这就是前面说的"不回头"破环规则的代价,单帧不一致人眼无感,但 log 里能看到。

坑二:批量更新少了中间闪烁

面板切换可见性时,如果分三帧更新 visible、focused、z,每帧更新一个,会出现中间闪烁——第一帧 visible=true 面板出现但 z 还是旧值(在远处),第二帧 focused=true 焦点切过来,第三帧 z=200 面板跳到近处。用户看到面板先在远处闪一下再跳到近处。

修复是把三个状态合并到同一帧批量更新。ArkUI 的 @State 默认是异步更新,连续赋值会合并到下一帧一起渲染,正好满足需求。但如果更新之间有 await 或 setTimeout,合并就失效,会拆成多帧。我们的代码规范定死:状态联动更新必须在同一个同步函数内完成,中间不能有 await。

// 正确:同步批量更新
function showPanel(panel: SpatialPanel) {
  // 三个状态在同一同步函数内赋值,ArkUI 合并到一帧
  panel.visible = true
  panel.focused = true
  panel.z = 200
}

// 错误:异步拆帧,会闪烁
async function showPanelWrong(panel: SpatialPanel) {
  panel.visible = true
  await Promise.resolve()  // 这里让一帧
  panel.focused = true
  await Promise.resolve()
  panel.z = 200
}

坑三:不可见组件抢焦点

视锥裁剪把面板裁出可见范围时,visible 自动变 false,但 focused 没自动释放,面板还在抢焦点。用户视线点选别的组件时,焦点切不过去,因为旧面板还 focused=true。

这是依赖边 1(visible=false → focused 释放)没自动执行。我们加了监听:

// 监听 visible 变化,自动联动 focused
watch(() => panel.visible, (newVisible) => {
  if (!newVisible && panel.focused) {
    panel.focused = false  // 自动释放
  }
})

这个监听要小心死循环——focused 释放可能触发 z 联动下降,z 下降可能触发 visible 重算,visible 重算可能又触发 focused 联动。我们用脏标记 + 单帧不回头避免死循环:同一帧内 visible 联动 focused 后,focused 联动 z 后,z 联动 visible 时不再触发 focused 联动(脏标记已清)。

坑四:多面板同时切换时焦点抢断

用户一次操作关 3 个面板开 2 个,5 个面板的状态同时变。我们第一版按操作顺序逐个切换,结果出现焦点抢断——A 面板先聚焦,B 面板后聚焦把 A 的焦点抢走,但 A 的关闭动画还没完,视觉上 A 还在但焦点已经在 B,用户视线点选 A 没反应。

修复是批量切换时先关后开。先把 3 个要关的面板 visible 设 false(联动释放焦点),等关闭动画完(约 200ms),再把 2 个要开的面板 visible 设 true(联动聚焦)。这样焦点切换顺序明确:旧焦点先释放,新焦点再聚焦,不抢断。

代价是切换总时长增加 200ms(等关闭动画)。我们加了过渡动画掩盖,200ms 的等待用淡出动画填充,用户感知是"旧面板淡出 → 新面板淡入",不觉得慢。

坑五:z 联动 visible 重算时视锥边界抖

z 变化触发 visible 重算(依赖边 3),重算要判断新 z 是否在视锥内。视锥边界是相机位置和朝向算出来的,相机有微小抖动(头戴设备用户头部自然抖动),视锥边界跟着抖,z 联动 visible 时会出现"在边界上的组件 visible 反复跳变"。

具体表现:一个 z 恰好在视锥边界的组件,用户头微微一动,visible 在 true/false 间跳,组件闪烁。

修复是视锥边界加滞后区——边界内外各扩 5cm 的滞后带,组件 z 在滞后带内时 visible 保持原值不跳变,只有 z 越过滞后带才切 visible。5cm 滞后带能吸收头部抖动(实测头部抖动幅度 < 3cm)。这个滞后区是空间界面稳定性的关键,不加的话边界组件永远在闪。

状态联动流程图

visible

focused

z

是

否

是

否

状态变化触发

哪个状态变?

visible 更新

focused 更新

z 更新

visible 变 false?

联动: focused = false

无联动

联动: z 不变 (不回头)

focused 变 true?

联动: visible = true

联动: z 归位

联动: z 提到顶层

z 更新

联动: 重算 visible (视锥裁剪)

脏标记: 不触发 focused 联动 (不回头)

本帧联动结束

可运行代码:状态联动管理器

把上面的逻辑写成 ArkTS 状态管理器,业务侧只调 setPanelVisible 等接口,联动顺序内部保证。

// SpatialStateSync.ets —— 可见性/焦点/深度联动管理器

// 单个空间组件的状态
class SpatialState {
  visible: boolean = true
  focused: boolean = false
  z: number = 0
  // 脏标记,避免循环联动
  private dirtyVisible: boolean = false
  private dirtyFocused: boolean = false
  private dirtyZ: boolean = false
  // 不回头标记:本帧已联动过,不再触发
  private frameLock: boolean = false
}

class SpatialStateManager {
  private states: Map<string, SpatialState> = new Map()
  // 当前焦点组件 id,单焦点模型
  private currentFocusId: string | null = null

  // 注册组件
  register(id: string, initial: Partial<SpatialState> = {}): void {
    this.states.set(id, {
      visible: initial.visible ?? true,
      focused: initial.focused ?? false,
      z: initial.z ?? 0,
      dirtyVisible: false,
      dirtyFocused: false,
      dirtyZ: false,
      frameLock: false
    })
  }

  // 主接口:设可见性,触发联动
  setVisible(id: string, visible: boolean): void {
    const s = this.states.get(id)
    if (!s || s.frameLock) return
    if (s.visible === visible) return
    s.visible = visible
    s.dirtyVisible = true
    this.syncVisible(id)
    this.flushFrameLock()
  }

  setFocused(id: string, focused: boolean): void {
    const s = this.states.get(id)
    if (!s || s.frameLock) return
    if (s.focused === focused) return
    // 单焦点:聚焦前先释放旧焦点
    if (focused && this.currentFocusId && this.currentFocusId !== id) {
      this.releaseFocus(this.currentFocusId)
    }
    s.focused = focused
    s.dirtyFocused = true
    if (focused) this.currentFocusId = id
    else if (this.currentFocusId === id) this.currentFocusId = null
    this.syncFocused(id)
    this.flushFrameLock()
  }

  setZ(id: string, z: number): void {
    const s = this.states.get(id)
    if (!s || s.frameLock) return
    if (s.z === z) return
    s.z = z
    s.dirtyZ = true
    this.syncZ(id)
    this.flushFrameLock()
  }

  // 联动:visible 变 false → 释放焦点
  private syncVisible(id: string): void {
    const s = this.states.get(id)
    if (!s || !s.dirtyVisible) return
    if (!s.visible && s.focused) {
      // 不可见必须释放焦点
      s.focused = false
      s.dirtyFocused = true
      if (this.currentFocusId === id) this.currentFocusId = null
      // 焦点释放后 z 归位(不回头:z 联动不再触发 visible 重算)
      s.z = 0
      s.dirtyZ = true
    }
    s.dirtyVisible = false
  }

  // 联动:focused 变 true → 强制 visible=true + z 提层
  private syncFocused(id: string): void {
    const s = this.states.get(id)
    if (!s || !s.dirtyFocused) return
    if (s.focused) {
      // 聚焦必须可见
      if (!s.visible) {
        s.visible = true
        s.dirtyVisible = true
      }
      // 聚焦提层到 200
      s.z = 200
      s.dirtyZ = true
    } else {
      // 失焦 z 归位
      if (s.z === 200) {
        s.z = 0
        s.dirtyZ = true
      }
    }
    s.dirtyFocused = false
  }

  // 联动:z 变化 → 重算 visible(视锥裁剪)
  // 简化:假设视锥 z 范围 [-1000, 500]
  private syncZ(id: string): void {
    const s = this.states.get(id)
    if (!s || !s.dirtyZ) return
    const inFrustum = s.z >= -1000 && s.z <= 500
    if (s.visible && !inFrustum) {
      // z 推出视锥,不可见
      s.visible = false
      // 不回头:不触发 focused 联动
    } else if (!s.visible && inFrustum) {
      s.visible = true
    }
    s.dirtyZ = false
  }

  private releaseFocus(id: string): void {
    const s = this.states.get(id)
    if (!s) return
    s.focused = false
    s.dirtyFocused = true
    this.syncFocused(id)
  }

  // 帧末清锁,下一帧可再联动
  private flushFrameLock(): void {
    this.states.forEach(s => {
      s.frameLock = false
    })
  }

  // 调试用:dump 当前所有状态
  dump(): void {
    this.states.forEach((s, id) => {
      console.info(`[${id}] visible=${s.visible} focused=${s.focused} z=${s.z}`)
    })
  }
}

// 用法
const mgr = new SpatialStateManager()
mgr.register('panelA', { visible: false, z: 0 })
mgr.register('panelB', { visible: true, z: 100 })

// 用户点按钮显示 panelA:一次调用,内部保证联动顺序
mgr.setVisible('panelA', true)
mgr.setFocused('panelA', true)
// 内部:visible=true → focused=true → z=200,一帧内完成

真机数据:状态更新耗时

100 个组件的场景,单次联动更新耗时(Vision,10 次平均):

联动链耗时帧率影响
单状态变(无联动)0.1ms无
visible → focused 释放0.5ms无
focused → visible + z 提层1.6ms无
z → visible 重算0.9ms无
完整链(visible → focused → z)2.8ms60fps 下占 17%
完整链 + 100 个组件全联动12.3ms60fps 下占 74%,掉帧

完整链 + 100 组件全联动会掉帧,但实际场景不会 100 个组件同时联动——通常只有 1~3 个组件状态在变。真正危险的是列表滚动时每个 item 都触发 visible 联动,这个我们在 SpatialList 里做了批处理,滚动时积累 16ms 内的所有 visible 变化,末尾统一跑一次联动。

总结一下

  • 联动顺序固定 visible → focused → z,不要让业务侧自己定顺序,统一走 state manager。
  • 联动更新必须在同一同步函数内,中间不能有 await / setTimeout,否则拆帧闪烁。
  • 单焦点模型,聚焦前先释放旧焦点,API 26 不支持多焦点,硬设会覆盖。
  • z 联动 visible 时不回头触发 focused,用脏标记 + frameLock 避免循环。
  • 列表滚动 visible 联动要批处理,16ms 内的 visible 变化合并,否则 100 个 item 各自联动会掉帧。
  • 不可见组件必须释放焦点,监听 visible 变化自动联动,不要靠业务侧手动释放。
Logo

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

更多推荐