【共创稿事节】HarmonyOS 7视力受限用户的空间布局降级
视力受限用户的空间布局降级
空间界面强依赖视觉深度感知——用户要靠双眼视差判断物体远近,靠视角变化感知空间布局,靠对比度识别层级关系。视力受限用户(低视力、单眼、色弱)这三条都打折:单眼看不出视差,低视力看不清远距离细节,色弱分不清对比度依赖的层级。我们做商品空间详情页时无障碍测试找了位低视力用户(视力 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 开销 | 内存开销 | 备注 |
|---|---|---|---|
| 无障碍设置读取 | 0 | 0 | 启动时读一次,缓存 |
| 行为信号推断 | 2~3ms/帧 | 20MB | 我们没用,太贵 |
| 语音朗读(每句) | 50~80ms | 30MB | 离线 TTS 引擎 |
| 语音识别(持续) | 5~8ms/帧 | 80MB | 持续监听关键词 |
| 高对比度渲染 | 0 | 0 | 只是换色 |
语音识别持续监听最贵,8ms/帧 占 60fps 预算的一半。我们只在用户主动开启语音交互模式时才启动持续监听,默认只用语音朗读(聚焦时一次性播报),开销小。
场景落地:低视力用户使用商品空间页
具体场景:低视力用户(视力 0.3,开了屏幕放大 + 高对比度)打开商品空间详情页。
检测与降级决策
// 入口检测
const a11yState = AccessibilityManager.getAccessibilityState()
const decision = decideDegradation(a11yState)
// decision = { level: 'medium', reasons: ['screenMagnifier', 'highContrast'] }
决策逻辑:开了屏幕放大 + 高对比度 → 中度降级(压平到 2D,保留内容)。
降级执行
中度降级做的事:
- 把空间布局的 z 坐标全压到 0,3D 面板变 2D 卡片。
- 字号按可读性门禁放大(参考第 23 篇),1m 视距最小 18sp。
- 对比度按 8:1 门禁(比默认 6:1 更严)。
- 商品 3D 模型换成多角度图片轮播。
- 启用语音朗读,聚焦组件时播报内容。
降级后界面跟正常 2D 商品页几乎一样,但保留了空间界面的内容结构(哪些是主信息、哪些是参数、哪些是评论)。
降级后任务完成率对比
我们找了 12 位低视力用户做 A/B 测试,A 组用未降级的空间界面,B 组用自动降级到 2D + 语音的界面。任务:找到商品价格、加购物车、查看参数。
| 指标 | A 组(空间) | B 组(降级 2D + 语音) |
|---|---|---|
| 任务完成率 | 42% | 89% |
| 平均完成时间 | 95s(仅完成者) | 38s |
| 误操作次数 | 4.2 次/人 | 0.8 次/人 |
| 主观满意度 | 2.1/5 | 4.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 界面",让用户自己选。
降级策略流程图
可运行代码:降级决策与执行
// 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 体验顺滑。
- 降级时重建组件树,不要只压平视觉,组件树结构不重建焦点会算错。
更多推荐
所有评论(0)