2D 组件在空间环境的复用边界

团队接到空间商品详情页需求时第一反应是"复用 2D 组件库"——我们 2D 端有 80 多个组件,Button、List、Dialog、Tabs、Grid 之类的都验证过两轮了,重写一遍得两个月。结果搬过来一跑,Button 能用,List 滚不动,Dialog 飘在半空,Tabs 切换时焦点丢了。每个组件都有自己的炸法,没有统一规律。这篇把我们试过的 10 个常用 2D 组件在空间环境的表现列清楚,给一份"直接可用 / 需适配 / 不可用"的复用边界表,省得后来人再一个个踩。

能力面:2D 组件在空间环境的兼容性分类

2D 组件搬到空间环境出问题,根上来自三处差异:

  • 坐标系:2D 组件假设 (x, y) 二维布局,空间环境是 (x, y, z) 三维。组件内部用 position(x, y) 的代码在空间里 z 永远是 0,会贴在最近平面。
  • 事件系统:2D 事件基于屏幕坐标的 hit test,空间事件基于射线 + 距离的 hit test。组件内部用 onClick 拿到的 event 在空间里没有 z 信息,做不了深度交互。
  • 渲染管线:2D 组件走的是屏幕空间渲染,空间环境走透视投影。组件内部用 width/height 设的尺寸在空间里是物理尺寸,不是视觉尺寸,远距离看着会变小。

按这三处差异把组件分三类:

直接可用类:组件内部不依赖坐标系、事件、渲染管线的 2D 假设。典型是 Text、Image、Progress——它们只显示内容,不做布局判断、不做坐标计算。这类组件加个空间容器包一下就能用。

需适配类:组件依赖 2D 假设但能改。典型是 Button、Tabs、Slider——它们要做 hit test、要做状态切换,但逻辑可控,改事件处理和坐标系换算能跑通。适配工作量 1~3 天/个。

不可用类:组件深度依赖 2D 渲染管线,改不动或改了不划算。典型是 List、Grid、Scroll——它们内部用了大量屏幕坐标优化(虚拟滚动按屏幕像素算可见区域),搬到空间里这些优化全失效,重写比改还快。

三类只是粗分,拿到一个具体组件时按下面这条判定链走一遍更稳。

不兼容

兼容

不兼容

兼容

不兼容

兼容

通过 1~2 项

全不过或深依赖渲染管线

列出目标 2D 组件

坐标系是否兼容?

标记一项不过

事件系统是否兼容?

渲染管线是否兼容?

三项全通: 直接复用

通过项数?

需适配: 改事件与坐标换算

重写空间版

三项里过一两项就适配,深依赖渲染管线的直接重写,省得改到一半发现救不活。

约束面:三处差异的具体边界

坐标系差异

2D 组件用 position(x, y) 时假设原点在父容器左上角,单位是 vp。空间环境里同一个 position(x, y) 仍然有效,但 z 默认 0,组件会贴在父容器的近平面。要让组件有深度,得用 translate({ z: -200 }) 或在空间容器里设 spatialPosition(x, y, z)。

问题出在组件内部用坐标的地方。比如 Tabs 组件下划线动画用 position(x, 0) 跟随选中 tab,x 是算出来的 vp 值。这套在空间里仍能跑,但下划线永远贴在 z=0 平面,而 tab 内容可能被推到 z=-50 做立体效果,下划线和内容脱节。我们改 Tabs 时把下划线的 z 也跟 tab 内容的 z 联动,加了一个 underlineZ 参数。

事件系统差异

2D 的 OnClick 事件 event 提供 x、y(屏幕坐标)和 target(事件源)。空间的 OnSpatialClick 在此基础上多了 z、distance、hitNormal。2D 组件用 event.x、event.y 做命中判断的代码在空间里仍能跑,但拿不到 z 信息,做不了"按深度排序响应"这类空间交互。

更麻烦的是手势。2D 的 OnSwipe 假设手势在屏幕平面内,方向用 0~360° 表示。空间手势是 3D 的,方向用三维向量表示。List 组件的滚动判断手势垂直分量,搬到空间里"垂直"这个概念要重新定义——是相机视角的垂直,还是面板法线方向的垂直?我们一开始按相机垂直做,结果面板倾斜时滚动手势方向不对,用户往上划列表往下滚。改成按面板法线投影到手势方向才算对。

渲染管线差异

2D 组件走屏幕空间渲染,width/height 单位是 vp,1vp 在屏幕上是固定像素。空间环境走透视投影,1vp 是物理尺寸,远距离视觉变小。组件内部用 width(200) 设固定宽,在 2D 里是 200vp 屏幕 px,在空间 3m 外看着可能只有 0.5° 视角。

Text、Image 这类只显示内容的组件对这个不敏感——视觉小就小,用户走近自然就大。但 List、Grid 这类要做虚拟滚动的组件,它们按屏幕像素算可见区域,搬到空间里"可见"的定义变了——3m 外一个 200vp × 1000vp 的 List 视觉上只占视野 5°,按屏幕像素算它全可见不虚拟滚动,但渲染 100 个 item 的开销在空间里没必要,因为用户根本看不全。这种优化失效不会让组件炸,但会浪费性能。

场景落地:10 个常用 2D 组件逐一测试

我们在空间商品详情页里逐个测了 10 个常用组件,记录可用性、适配工作量、性能差异。测试设备 Vision,面板视距 1.5m,对比度 7:1。

组件分类直接复用表现适配工作量主要问题
Text直接可用正常0无
Image直接可用正常,远距离变小符合预期0无
Progress直接可用正常0无
Button需适配可点击,但 hit 区域按 2D 算偏小1 天空中点选误差大,hit 区域要扩 1.5 倍
Tabs需适配切换正常,下划线 z 脱节2 天下划线 z 要联动内容 z
Slider需适配拖动响应,但深度方向误触2 天拖动手势要限制在面板平面内
Rating需适配显示正常,点选偏小1 天同 Button,hit 区域扩大
Dialog需适配飘在半空,遮挡关系错3 天默认贴屏,要改空间锚定
List不可用滚动手势方向不对,虚拟滚动失效重写 2 周坐标系 + 事件 + 渲染全炸
Grid不可用同 List,二维布局错位重写 3 周同 List,更严重

Button 的适配细节

Button 是需适配里最简单的。2D Button 的 hit 区域是 width × height,空间里空中点选有手部抖动,hit 区域要扩 1.5 倍。改法是在 Button 外包一层透明 hit 容器:

// SpatialButton.ets —— 2D Button 的空间适配
@Component
struct SpatialButton {
  @Prop text: string
  @Prop onClick: () => void
  // 空间点选误差放大系数,1.5 是 1.5m 视距实测
  private readonly hitScale: number = 1.5

  build() {
    // 外层透明 hit 容器,按 1.5 倍放大
    Stack() {
      // 内层 Button 视觉不变
      Button(this.text)
        .width('100%')
        .height('100%')
        .onClick(this.onClick)
    }
    .width('150%')   // hit 区域放大 1.5 倍
    .height('150%')
    .hitTestBehavior(HitTestMode.Default)
  }
}

这个改法有个副作用——多个 Button 横向排列时,扩大的 hit 区域会重叠,A 的 hit 区压到 B 上。我们试过两种解法:一是 hit 区不重叠地放大(按钮间距留够),二是 hit 区重叠时按距离近的优先响应。第一种简单但要布局配合,第二种要改事件分发链。我们选了第一种,按钮间距强制 ≥ 16vp。

Dialog 的适配细节

Dialog 是需适配里最坑的。2D Dialog 默认行为是全屏遮罩 + 居中弹出,搬到空间里"全屏"和"居中"都没意义——空间没有屏幕边界,居中是相对什么居中?

第一版我们直接用 2D Dialog,结果它贴在最近平面 z=0 处,遮罩只覆盖面板区域不覆盖整个空间,用户转头能看到面板后面的东西,焦点没真正被锁定。体验很怪。

适配做法是把 Dialog 改成"空间锚定"模式——锚定到触发它的组件位置,在组件前方 0.1m 弹出,遮罩用半透明球面把整个视野罩住。这要重写 Dialog 的布局逻辑,工作量 3 天。

// SpatialDialog.ets —— 2D Dialog 改空间锚定
@Component
struct SpatialDialog {
  @Prop anchorZ: number  // 触发组件的 z
  @Prop content: string
  @State visible: boolean = false

  build() {
    if (this.visible) {
      // 半透明球面遮罩,罩住整个视野
      SpatialSphere()
        .radius(2.0)
        .materialColor('#00000066')
        .renderMode(SpatialRenderMode.InsideOut)
        .onClick(() => this.visible = false)

      // Dialog 内容锚定到触发组件前方 0.1m
      Column() {
        Text(this.content)
          .fontSize(32)   // 空间字号,按可读性门禁
        Button('确认')
          .onClick(() => this.visible = false)
      }
      .translate({ z: this.anchorZ - 100 })  // 锚定前方 0.1m(100vp ≈ 0.1m)
    }
  }
}

List 不可用的根因

List 我们试了三天没救活,最后重写。根因是 List 内部用了三个 2D 假设:

  1. 虚拟滚动按屏幕像素算可见区域:空间里"可见"是视锥裁剪,不是屏幕矩形。List 内部的 visibleRange 算法完全错位,要么不虚拟(100 个 item 全渲染,卡),要么乱虚拟(该显示的 item 不渲染,白屏)。
  2. 滚动手势按屏幕 y 方向:空间里手势是 3D 的,List 内部只取 event.y 做滚动,倾斜面板上手势方向不对。
  3. item 布局按 vp 流式排:空间里 item 要随视距缩放,固定 vp 布局会让远处 item 视觉过小。

重写的 SpatialList 改了三处:可见区域改视锥裁剪算、手势方向改按面板法线投影、item 尺寸改 distance-based scaling。重写后 200 个 item 60fps 稳定,但工作量 2 周。

Swiper 和 Stepper 的边界

测完 10 个组件后我们又补测了 Swiper 和 Stepper,结果跟预测不一样。

Swiper 内部用了屏幕像素算翻页距离,预测要重写。实测发现 Swiper 的翻页逻辑用的是 vp 单位不是像素,vp 在空间里仍能跑,只是翻页距离要按视距放大。适配工作量 1 天,归到"需适配"。预测错了,原因是 Swiper 比较新,ArkUI 团队设计时考虑了多端,没用屏幕像素硬编码。

Stepper(步进器,+/- 按钮调数值)预测直接可用,实测发现点选误差大——Stepper 的 +/- 按钮默认 24vp 宽,空间里空中点选 hit 区域不够,频繁误触。归到"需适配",hit 区域扩 1.5 倍,工作量 0.5 天。

这两个补测说明复用边界不能光看组件内部代码猜,必须真机测。我们后来把组件库 80 多个全过了一遍真机,又找出 7 个分类错的,其中 3 个从"直接可用"改成"需适配",4 个从"需适配"改成"不可用"。

真机数据:性能差异实测

同一个商品详情页,用 2D 组件直接搬 vs 适配后 vs 重写空间组件,帧率和内存对比(Vision,1.5m 视距):

方案帧率内存峰值首屏渲染备注
2D 组件直接搬38~45fps280MB480msList 卡,Dialog 飘
适配后(含 SpatialList)58~60fps210MB320ms全部正常
全重写空间组件59~60fps195MB290ms略快,但开发成本 6 周

直接搬方案帧率低主要因为 List 不虚拟滚动,100 个 item 全渲染。适配后内存反而降了,因为 SpatialList 虚拟滚动生效,可见 item 只有 8~10 个。

全重写比适配略快 30ms 首屏,但开发成本 6 周 vs 2 周,性价比不值。我们的结论是:能适配就别重写,重写只在 List/Grid 这种深度依赖 2D 渲染管线的组件上做。

踩坑与取舍

坑一:List 滚动事件在空间里不响应

第一版搬 List 上去,手指划了半天列表不动,以为事件没绑。debug 发现事件触发了,但 List 内部判断 event.y 变化量 < 阈值,认为不是有效滚动手势。空间里手势坐标系的 y 跟 2D 不一样,划同样的物理距离 event.y 变化小,被 List 内部判成抖动过滤掉了。

临时解法是把 List 的滚动阈值改小,从 8vp 改到 2vp。但这会导致正常悬停时手部抖动被识别成滚动,列表一直微微动。最终解法还是重写 SpatialList,按面板法线方向算手势投影。

坑二:Dialog 默认贴屏导致空间中飘在半空

2D Dialog 的 z 默认 0,空间里 z=0 是最近平面,Dialog 看着像贴在用户脸上。更糟的是遮罩也是 z=0 的平面,只覆盖面板区域,用户转头能从面板边缘看到后面,焦点没锁定。

这个坑我们提了 P0,修复时把 Dialog 改成空间锚定 + 球面遮罩。球面遮罩有个意外好处——它自然限制了用户视野,转头也只看到遮罩内的 Dialog,反而比 2D 的全屏遮罩更聚焦。这个意外发现后来成了我们空间 Dialog 的默认行为。

坑三:Tabs 切换焦点丢失

Tabs 切换 tab 时焦点应该跟着移到新 tab 的内容,2D Tabs 这套逻辑用 requestFocus。搬到空间里 requestFocus 仍能跑,但空间焦点是 3D 的(带 z 坐标),Tabs 内部 requestFocus 只设了 x、y,z 沿设,焦点跑到了 z=0 平面,而 tab 内容在 z=-50,焦点和内容脱节,用户用视线点选时焦点对不上内容。

改法是 Tabs 切换时把焦点 z 也设到当前 tab 内容的 z。这个坑隐藏了两天,因为开发调试时用鼠标点选不依赖焦点,测试用视线点选才暴露。

取舍:哪些组件值得适配,哪些直接重写

我们内部定了个判断标准——看组件内部用了多少 2D 假设。用得少的(Text、Image、Button)适配或直接用,用得多的(List、Grid、Scroll)重写。具体看三个信号:

  1. 组件内部有 position(x, y) 不带 z 的代码 → 加 z 联动,适配
  2. 组件内部按屏幕像素算可见区域 → 重写
  3. 组件内部用 event.x、event.y 做命中判断 → 看命中逻辑复杂度,简单的适配,复杂的重写

按这个标准,80 多个 2D 组件里我们适配了 15 个,重写了 5 个,剩下的 60 个是直接可用类(Text、Image、Icon 之类)。

取舍:适配 vs 重写的成本对比

适配一个组件平均 2 天,重写平均 2.5 周。但适配的组件后续维护成本高——2D 组件库升级时适配层要跟着改,每次改 0.5~1 天。重写的组件独立,2D 库升级不影响。

按 3 年维护周期算:

方案初始成本单次升级成本3 年总成本(假设 6 次升级)
适配2 天0.8 天6.8 天
重写12.5 天0 天12.5 天

适配 3 年总成本仍低于重写。除非组件适配层极不稳定(每次 2D 升级都要大改),否则适配更划算。我们重写的 5 个组件(List、Grid、Scroll、Swiper、Carousel)是因为适配层确实不稳定——List 内部虚拟滚动逻辑 2D 库每两个版本改一次,适配层每次都要重对,比重写还累。

这个成本核算是我们跟架构组吵了一架才定的——架构组倾向全重写,理由是"重写一次干净"。我拿这份成本表去,加上"适配的 15 个组件 3 年没出过 P0"的数据,才说服他们接受适配方案。

复用边界速查表

把上面的结论压成一张速查表,团队评审新组件时直接对照:

组件内部特征分类处理方式
只显示内容,不做布局/事件直接可用包空间容器即用
做事件但命中逻辑简单需适配扩 hit 区 + 加 z 联动,1~3 天
做虚拟滚动/屏幕像素布局不可用重写空间版,2~3 周
用屏幕坐标做命中优化不可用重写,命中改视锥裁剪
内部用 requestFocus 设 2D 焦点需适配焦点 z 联动到内容 z

注意一下下

  • 直接可用类组件包 SpatialContainer 再用,不要直接放空间场景里,容器负责 distance-based scaling。
  • 需适配类组件 hit 区域扩 1.5 倍,1.5m 视距实测系数,其他视距按距离线性调。
  • Dialog 改空间锚定 + 球面遮罩,不要用 2D 全屏遮罩,空间里没有"全屏"概念。
  • List/Grid/Scroll 不要适配直接重写,重写成本 2~3 周,适配成本 3 周+ 还不一定能跑稳。
  • 焦点 z 必须联动内容 z,2D requestFocus 只设 x、y 在空间里焦点会脱节。
  • 手势方向按面板法线投影,不要按相机垂直方向,倾斜面板上会反向。
Logo

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

更多推荐