【共创稿事节】HarmonyOS 7之2D 组件在空间环境的复用边界
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——它们内部用了大量屏幕坐标优化(虚拟滚动按屏幕像素算可见区域),搬到空间里这些优化全失效,重写比改还快。
三类只是粗分,拿到一个具体组件时按下面这条判定链走一遍更稳。
三项里过一两项就适配,深依赖渲染管线的直接重写,省得改到一半发现救不活。
约束面:三处差异的具体边界
坐标系差异
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 假设:
- 虚拟滚动按屏幕像素算可见区域:空间里"可见"是视锥裁剪,不是屏幕矩形。List 内部的
visibleRange算法完全错位,要么不虚拟(100 个 item 全渲染,卡),要么乱虚拟(该显示的 item 不渲染,白屏)。 - 滚动手势按屏幕 y 方向:空间里手势是 3D 的,List 内部只取
event.y做滚动,倾斜面板上手势方向不对。 - 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~45fps | 280MB | 480ms | List 卡,Dialog 飘 |
| 适配后(含 SpatialList) | 58~60fps | 210MB | 320ms | 全部正常 |
| 全重写空间组件 | 59~60fps | 195MB | 290ms | 略快,但开发成本 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)重写。具体看三个信号:
- 组件内部有
position(x, y)不带 z 的代码 → 加 z 联动,适配 - 组件内部按屏幕像素算可见区域 → 重写
- 组件内部用
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 在空间里焦点会脱节。 - 手势方向按面板法线投影,不要按相机垂直方向,倾斜面板上会反向。
更多推荐
所有评论(0)