视力受限用户的空间布局降级

空间界面强依赖视觉深度感知——用户要靠双眼视差判断物体远近,靠视角变化感知空间布局,靠对比度识别层级关系。视力受限用户(低视力、单眼、色弱)这三条都打折:单眼看不出视差,低视力看不清远距离细节,色弱分不清对比度依赖的层级。我们做商品空间详情页时无障碍测试找了位低视力用户(视力 0.3),他戴上头显后完全无法判断商品在哪个深度,伸手去抓总是抓空。空间界面默认体验对这部分用户不仅不友好,是直接不可用。这篇讲怎么检测需要降级的用户、降级到 2D + 语音的方案、以及降级误触发的处理。

能力面:无障碍检测与降级路径

检测能力

HarmonyOS API 26 提供三类无障碍检测:

  • 系统无障碍设置读取:AccessibilityManager.getAccessibilityState() 返回当前用户开启的无障碍服务(屏幕放大、高对比度、语音朗读等)。这是最可靠的检测信号——用户主动开了这些服务,大概率需要降级。
  • 视力自评数据:用户在系统健康设置里填的视力档位(正常/低视力/重度低视力)。这个数据可选,不是所有用户都填。
  • 行为信号推断:用户长时间眯眼、频繁调整设备距离、连续操作失败。这个信号最弱,容易误判,我们没采用。

第一类检测最稳。我们统计线上数据,开启屏幕放大或高对比度服务的用户约 3.2%,开启语音朗读的约 1.8%,三个都开的约 0.7%。这些用户进空间界面时应该自动降级。

降级路径

降级有三档:

  • 轻度降级:保留空间布局,加大字号、提高对比度、关闭依赖视差的深度效果。适合轻度低视力、色弱。
  • 中度降级:空间布局压平到 2D 平面,保留所有内容,去掉深度层级。适合单眼、中度低视力。
  • 重度降级:完全切到 2D 界面 + 语音朗读,空间界面不展示。适合重度低视力、全盲。

三档对应不同的检测信号组合,不是单一信号决定。比如只开屏幕放大走轻度,开屏幕放大 + 语音朗读走中度,自评重度低视力走重度。

语音辅助

降级到 2D 后语音是关键补充。语音辅助分两类:

  • 主动播报:用户聚焦到某个组件时自动朗读组件内容。这个跟 2D 屏幕阅读器(TalkBack)行为一致。
  • 指令交互:用户说"下一个"“返回”"加入购物车"等指令,语音识别后执行。这个比触控对视力受限用户更友好。

语音播报有个节奏问题——播太快用户听不清,播太慢用户等不及。我们测下来最佳语速是默认 1.0 倍速,不要加速。加速到 1.3 倍速用户反馈"听着累",1.0 倍速接受度最高。

约束面:信息丢失与性能开销

降级后的信息丢失

空间界面有些信息是 2D 表达不了的:

  • 深度层级:3D 空间里 A 在 B 前面靠 z 坐标表达,2D 里只能靠遮挡或阴影暗示,信息量降低。
  • 空间关系:3D 里"商品在展柜第二层左侧"靠空间坐标表达,2D 里要用文字描述,啰嗦。
  • 沉浸感:3D 商品展示的"环绕观看"体验,2D 只能多角度图片切换,体验降级。

我们的策略是降级时主动补信息——深度层级用文字标签补(“前景”“中景”“背景”),空间关系用结构化列表补,沉浸感用多角度图片 + 语音描述补。补的信息要克制,不要把空间界面所有细节都翻译成文字,那会信息过载。

辅助功能性能开销

无障碍检测和语音辅助都有性能开销:

辅助功能CPU 开销内存开销备注
无障碍设置读取00启动时读一次,缓存
行为信号推断2~3ms/帧20MB我们没用,太贵
语音朗读(每句)50~80ms30MB离线 TTS 引擎
语音识别(持续)5~8ms/帧80MB持续监听关键词
高对比度渲染00只是换色

语音识别持续监听最贵,8ms/帧 占 60fps 预算的一半。我们只在用户主动开启语音交互模式时才启动持续监听,默认只用语音朗读(聚焦时一次性播报),开销小。

场景落地:低视力用户使用商品空间页

具体场景:低视力用户(视力 0.3,开了屏幕放大 + 高对比度)打开商品空间详情页。

检测与降级决策

// 入口检测
const a11yState = AccessibilityManager.getAccessibilityState()
const decision = decideDegradation(a11yState)
// decision = { level: 'medium', reasons: ['screenMagnifier', 'highContrast'] }

决策逻辑:开了屏幕放大 + 高对比度 → 中度降级(压平到 2D,保留内容)。

降级执行

中度降级做的事:

  1. 把空间布局的 z 坐标全压到 0,3D 面板变 2D 卡片。
  2. 字号按可读性门禁放大(参考第 23 篇),1m 视距最小 18sp。
  3. 对比度按 8:1 门禁(比默认 6:1 更严)。
  4. 商品 3D 模型换成多角度图片轮播。
  5. 启用语音朗读,聚焦组件时播报内容。

降级后界面跟正常 2D 商品页几乎一样,但保留了空间界面的内容结构(哪些是主信息、哪些是参数、哪些是评论)。

降级后任务完成率对比

我们找了 12 位低视力用户做 A/B 测试,A 组用未降级的空间界面,B 组用自动降级到 2D + 语音的界面。任务:找到商品价格、加购物车、查看参数。

指标A 组(空间)B 组(降级 2D + 语音)
任务完成率42%89%
平均完成时间95s(仅完成者)38s
误操作次数4.2 次/人0.8 次/人
主观满意度2.1/54.0/5

A 组 42% 完成率意味着一半多用户根本完不成任务——他们在空间界面里找不到价格、加不了购物车。降级后完成率 89%,跟正常 2D 商品页持平。不降级不是体验差,是不可用。

真机数据:降级检测耗时

降级检测要快,不能让用户等。我们测了从应用启动到降级决策完成的耗时(Vision,10 次平均):

检测项耗时备注
系统无障碍设置读取8ms启动时一次
视力自评数据读取3ms可选,未填返回 null
降级决策计算1ms简单条件判断
总计12ms启动延迟无感

12ms 在启动流程里无感。真正慢的是降级执行——把空间布局压平到 2D 要重新构建组件树,约 180ms。这个延迟用户能感知到"界面闪一下"。我们加了过渡动画掩盖,把 180ms 的硬切分成 300ms 的淡入淡出,体验顺滑。

踩坑与取舍

坑一:自动降级误触发,正常用户被强制 2D

第一版降级检测太激进——只要检测到任何无障碍服务开启就降级到 2D。结果有些视力正常的用户开了"语音朗读"服务(为了听小说),进商品页被强制 2D,投诉"为什么别人是 3D 我的是 2D"。

根因是检测信号过强——开了语音朗读不代表用户视力有问题,可能只是习惯听内容。修复是把降级决策改成多信号组合:

  • 单一信号(只开语音朗读 或 只开屏幕放大)→ 轻度降级(保留空间布局,加字号 + 对比度)
  • 两个信号(屏幕放大 + 高对比度)→ 中度降级
  • 自评重度低视力 或 三个信号全开 → 重度降级

并且加了手动覆盖入口——用户可以在设置里强制指定"使用空间界面"或"使用 2D 界面",覆盖自动检测。被误触发的用户可以自己切回空间界面。这个入口要明显,我们放在设置页第一项,不要藏在无障碍子菜单里。

坑二:语音播报太慢,用户等不及

语音播报商品信息时,"商品名 + 价格 + 参数 + 评论摘要"全播完要 18 秒。用户听完前 5 秒就烦了,开始手动操作,但语音还在播,干扰用户操作。

修复是分段播报 + 可打断:

  • 聚焦到商品时只播报名 + 价格(3 秒)。
  • 用户停留 2 秒不操作,继续播参数(5 秒)。
  • 用户操作(点击、滑动)立刻打断播报。
  • 用户说"详细"才播完整信息。

打断逻辑要可靠——我们第一版打断有 200ms 延迟,用户操作后还要听 0.2 秒尾音,反馈"打断不干脆"。改成同步打断(操作事件直接调 tts.stop())才彻底干净。

坑三:降级后空间锚点丢失,焦点混乱

空间界面里焦点是 3D 的,降级到 2D 后焦点变 2D,但组件树还是空间组件树(带 z 坐标)。焦点切换时有些组件 z 不为 0,焦点位置算错。

这个坑是因为我们降级时只压平了视觉(z=0),没压平组件树结构。修复是降级时重建组件树,把所有空间组件替换成对应 2D 组件(SpatialPanel → Panel,SpatialButton → Button)。重建耗时 180ms,就是前面说的降级执行耗时。

坑四:色弱用户没被检测覆盖

我们检测只覆盖了屏幕放大、高对比度、语音朗读三个无障碍服务。色弱用户(约人口 8%)大多不开任何无障碍服务——他们习惯了,不觉得自己需要辅助。但空间界面用颜色编码层级(红色危险、绿色安全、黄色警告),色弱用户分不清,误操作率高。

我们补了色弱检测——系统设置里有"色彩校正"选项,开了这个的用户走轻度降级(颜色编码加文字标签补,红绿之外加形状区分)。但没开色彩校正的色弱用户仍检测不到,靠设置页提示"色弱?开启色彩辅助"引导。

这个坑说明无障碍检测不能只靠系统服务信号,要主动引导用户表达需求。我们在设置页加了"无障碍自检"入口,列出几种常见视力情况让用户自选,选了之后走对应降级。自检入口比被动检测覆盖率高——上线后自检使用率 4.2%,比无障碍服务开启率 3.2% 还高一点。

坑五:降级后空间交互的替代方式缺失

空间界面有些交互是 3D 特有的——环绕商品看背面、拉近看细节、手势缩放。降级到 2D 后这些交互没了,用户想看背面只能点"切换视角"按钮看预设图,想看细节只能点"放大"按钮看静态大图,体验降级明显。

我们补了语音指令替代空间手势——用户说"转一下"商品图按预设角度切换,说"放大"切大图,说"看背面"切背视图。语音指令不能完全替代空间手势的自由度(环绕只能切 8 个预设角度,不能任意旋转),但比纯按钮点选更自然。

这个补偿的关键是预设角度要够用。我们测下来 8 个角度(每 45°)用户接受度 78%,12 个角度(每 30°)接受度 91%,但 12 个角度语音指令识别准确率降到 85%("转 30 度"和"转 60 度"容易混)。最终选 8 个角度,准确率 95% + 接受度 78%,比 12 个角度的 85% × 91% 综合更好。

取舍:降级检测的灵敏度

检测太灵敏误触发正常用户,太迟钝漏掉需要降级的用户。我们定的灵敏度:

  • 系统无障碍服务开启 → 必检,这是用户主动信号,不能漏。
  • 视力自评数据 → 仅作辅助,不单独触发降级(用户可能没填)。
  • 行为信号 → 不检,误判率太高。

漏掉的风险:用户视力差但没开任何无障碍服务,进空间界面不可用。这种用户我们能做的有限——他没主动表达需求,系统没法替他决定。最终靠设置页提示"视力不便?切换 2D 界面",让用户自己选。

降级策略流程图

无信号

单一信号

两个信号

自评重度 或 三信号

是

否

应用启动

读取无障碍设置

信号组合?

不降级,空间界面

轻度降级

中度降级

重度降级

保留空间布局

字号 +2 档

对比度 8:1

关闭视差深度效果

压平到 2D 平面

重建组件树 180ms

启用语音朗读

保留所有内容

完全切 2D 界面

语音朗读 + 指令交互

空间界面不展示

用户手动覆盖?

按用户选择

渲染

可运行代码:降级决策与执行

// SpatialAccessibilityFallback.ets —— 空间布局无障碍降级

import { AccessibilityManager } from '@kit.Accessibility'
import { TTSManager } from '@kit.Speech'

// 降级档位
enum DegradationLevel {
  None,      // 不降级
  Light,     // 轻度:保留空间,加字号对比度
  Medium,    // 中度:压平 2D + 语音
  Heavy      // 重度:完全 2D + 语音指令
}

// 降级决策结果
interface DegradationDecision {
  level: DegradationLevel
  reasons: string[]
  // 用户手动覆盖标志
  manualOverride: boolean
}

// 检测无障碍信号
function detectA11ySignals(): string[] {
  const signals: string[] = []
  const state = AccessibilityManager.getAccessibilityState()
  if (state.screenMagnifier) signals.push('screenMagnifier')
  if (state.highContrast) signals.push('highContrast')
  if (state.screenReader) signals.push('screenReader')
  // 视力自评(可选)
  const selfAssessment = AccessibilityManager.getVisualAcuityLevel()
  if (selfAssessment === 'severe') signals.push('severeLowVision')
  return signals
}

// 决策降级档位
function decideDegradation(): DegradationDecision {
  // 优先检查手动覆盖
  const override = getUserOverride()  // 'spatial' | '2d' | null
  if (override === 'spatial') {
    return { level: DegradationLevel.None, reasons: ['manualOverride'], manualOverride: true }
  }
  if (override === '2d') {
    return { level: DegradationLevel.Heavy, reasons: ['manualOverride'], manualOverride: true }
  }
  // 自动检测
  const signals = detectA11ySignals()
  if (signals.includes('severeLowVision') || signals.length >= 3) {
    return { level: DegradationLevel.Heavy, reasons: signals, manualOverride: false }
  }
  if (signals.length === 2) {
    return { level: DegradationLevel.Medium, reasons: signals, manualOverride: false }
  }
  if (signals.length === 1) {
    return { level: DegradationLevel.Light, reasons: signals, manualOverride: false }
  }
  return { level: DegradationLevel.None, reasons: [], manualOverride: false }
}

// 用户手动覆盖(持久化)
function getUserOverride(): string | null {
  return preferences.getSync('a11yOverride', null)
}

function setUserOverride(value: 'spatial' | '2d' | null): void {
  if (value === null) {
    preferences.deleteSync('a11yOverride')
  } else {
    preferences.putSync('a11yOverride', value)
  }
}

// 降级执行器
class DegradationExecutor {
  private tts: TTSManager = new TTSManager()

  async execute(decision: DegradationDecision): Promise<void> {
    switch (decision.level) {
      case DegradationLevel.None:
        // 不降级,正常渲染空间界面
        break
      case DegradationLevel.Light:
        this.applyLight()
        break
      case DegradationLevel.Medium:
        await this.applyMedium()
        break
      case DegradationLevel.Heavy:
        await this.applyHeavy()
        break
    }
  }

  private applyLight(): void {
    // 字号 +2 档
    globalFontSizeScale = 1.4
    // 对比度门禁提到 8:1
    globalContrastThreshold = 8.0
    // 关闭视差深度效果
    SpatialConfig.enableParallax = false
  }

  private async applyMedium(): Promise<void> {
    // 压平到 2D:z 坐标全置 0
    await flattenSpatialTree()
    // 字号按可读性门禁
    globalFontSizeScale = 1.6
    globalContrastThreshold = 8.0
    // 启用语音朗读
    this.tts.setEnabled(true)
    this.tts.setOnFocusCallback((node) => {
      // 聚焦时播报节点内容,分段:先名 + 价格
      const brief = `${node.name},${node.price}`
      this.tts.speak(brief, { speed: 1.0, interruptible: true })
    })
  }

  private async applyHeavy(): Promise<void> {
    // 完全切 2D 界面
    await switchTo2DInterface()
    // 语音朗读 + 指令交互
    this.tts.setEnabled(true)
    this.startVoiceCommandListener()
  }

  // 语音指令监听
  private startVoiceCommandListener(): void {
    const recognizer = new VoiceRecognizer()
    recognizer.onCommand((cmd) => {
      switch (cmd) {
        case 'next':
          focusNextNode()
          break
        case 'back':
          navigateBack()
          break
        case 'add':
          addToCart()
          break
      }
      // 指令执行后打断当前播报
      this.tts.stop()
    })
    recognizer.start()
  }
}

// 用法:应用启动时
async function onAppStart() {
  const decision = decideDegradation()
  const executor = new DegradationExecutor()
  await executor.execute(decision)
  if (decision.level !== DegradationLevel.None && !decision.manualOverride) {
    // 提示用户可以手动切换
    showToast('已根据无障碍设置调整界面。设置中可手动切换空间/2D 模式')
  }
}

// 设置页:手动覆盖入口
function onUserSelectOverride(value: 'spatial' | '2d' | 'auto'): void {
  if (value === 'auto') {
    setUserOverride(null)  // 清除覆盖,回到自动检测
  } else {
    setUserOverride(value)
  }
  // 重新决策并执行
  const decision = decideDegradation()
  const executor = new DegradationExecutor()
  executor.execute(decision)
}

注意一下下

  • 降级检测只信系统无障碍服务信号 + 视力自评,不信行为推断,误判率太高。
  • 降级分三档:轻度(保留空间 + 加字号对比度)、中度(压平 2D + 语音)、重度(完全 2D + 语音指令)。
  • 必须有手动覆盖入口,放在设置页显眼位置,自动检测会误触发。
  • 语音播报分段 + 可打断,先播名 + 价格(3 秒),用户操作立刻打断,不要一次播 18 秒。
  • 降级执行加过渡动画掩盖 180ms 重建,硬切会闪,淡入淡出 300ms 体验顺滑。
  • 降级时重建组件树,不要只压平视觉,组件树结构不重建焦点会算错。
Logo

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

更多推荐