在自定义 View 中,如何正确处理 Accessibility(无障碍)支持?
解读
国内大厂(阿里、腾讯、字节、美团)的面试里,Accessibility 不是“加分项”,而是“合规项”。工信部 2021 年《App 侵害用户权益专项整治》把“适老化及无障碍”列为强制检查点;华为、小米、OPPO 商店审核也会扫描 Accessibility 声明。如果候选人只说“在 xml 里加 android:contentDescription”,会被直接判为“不了解国内政策”,挂掉一轮。
面试官真正想听的是:
- 你能否让 TalkBack/随选朗读/小米小爱朗读在自定义绘制区域内正确报出“角色、名称、状态、值、操作”五要素;
- 你能否处理“虚拟子节点”(例如一个折线图里有 20 个数据点),避免一次性读 20 次导致信息爆炸;
- 你能否在国产 ROM 的“手势放大+色彩反转+高对比度”三件套下不翻车;
- 你能否在关闭无障碍时零性能损耗,打开时又不掉帧(16 ms 红线)。
知识点
- 国内合规框架
- 《信息技术 互联网内容无障碍可访问性技术要求与测试方法》(GB/T 37668-2019)
- 工信部 2021-2023 专项整治行动:必须支持屏幕朗读、焦点顺序、色彩无关提示
- Android 无障碍流水线
- 探索(Explore):AccessibilityNodeInfo 树
- 交互:AccessibilityNodeInfo.AccessibilityAction
- 反馈:AccessibilityEvent + 文字转语音(TTS)
- 自定义 View 三大接入方式
- 轻量:setContentDescription/setStateDescription
- 中量:onInitializeAccessibilityNodeInfo + onInitializeAccessibilityEvent
- 重量:ExploreByTouchHelper(虚拟子节点)
- 性能红线
- 禁止在 onPopulateNodeInfo 里 new 对象;使用 Pools.SimplePool 复用
- 虚拟节点数量超过 100 时,必须实现“分段懒加载”与“可见区域裁剪”
- 国产 ROM 差异
- 华为 EMUI:TalkBack 3.5+ 之后强制开启“节点缓存”,若节点 id 不 stable 会报 warning
- 小米 MIUI:系统设置里“无障碍快捷方式”会强制把 targetSdk<30 的 App 降级到“半无障碍”模式,必须 targetSdk≥31 才能全功能
- ColorOS:在“高对比度文字”模式下,系统会忽略自定义 canvas.drawColor,必须在 nodeInfo 里额外写“高对比度文字=关闭”提示
- Jetpack 兼容
- Compose 1.5+ 的 Modifier.semantics 与 View 系统互通,自定义 View 需要声明“isImportantForAccessibility=true”才能被 Compose 的合并语义识别
答案
分五步落地,代码可直接写进白板:
第一步:声明角色与重要性
init {
importantForAccessibility = IMPORTANT_YES
isFocusable = true
isFocusableInTouchMode = true
}
第二步:提供“名称+状态”
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
info.className = "折线图" // 国产读屏会优先读 className 本地化映射
info.contentDescription = "${mTitle}, 共 ${mData.size} 个点"
info.isScrollable = true
info.isEnabled = isEnabled
if (Build.VERSION.SDK_INT >= 30) {
info.stateDescription = if (mIsLoading) "加载中" else "已加载"
}
}
第三步:暴露操作
info.addAction(AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, "下一页"))
info.addAction(AccessibilityNodeInfo.AccessibilityAction(
R.id.action_show_detail, "查看详情")) // 自定义 action id 在 res/values/ids.xml
第四步:虚拟子节点(重点)
private val mHelper = object : ExploreByTouchHelper(this) {
override fun getVirtualViewAt(x: Float, y: Float): Int {
return mData.indexOfFirst { it.rect.contains(x, y) }.coerceAtLeast(UNKNOWN_ID)
}
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfo) {
val point = mData[virtualViewId]
node.text = "${point.x} 日, 销售额 ${point.y} 万"
node.className = "数据点"
node.addAction(AccessibilityNodeInfo.ACTION_CLICK)
node.setBoundsInParent(point.rect)
}
}
init {
ViewCompat.setAccessibilityDelegate(this, mHelper)
}
第五步:事件发送与性能
private fun announceForAccessibility(text: String) {
val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
event.text.add(text)
event.className = this.javaClass.name
parent.requestSendAccessibilityEvent(this, event)
}
// 在数据刷新后
announceForAccessibility("折线图已更新,共 ${mData.size} 个点")
国内检查清单(面试时主动说出来):
- 打开 TalkBack,单指右滑能按“标题→折线图整体→每个数据点”顺序聚焦,不跳焦;
- 三指上下滑动触发 scroll 事件,系统朗读“下一页/上一页”;
- 设置→无障碍→色彩反转打开后,折线颜色与背景对比度≥4.5:1,且节点文字不依赖颜色;
- 打开小米“随选朗读”,点击数据点只读一次,不重复;
- 关闭无障碍后,Systrace 中无 AccessibilityNodeInfo 分配,帧率保持 90 Hz。
拓展思考
- 混合开发场景:如果自定义 View 里嵌套了 Flutter 引擎的 TextureView,Flutter 侧的无障碍树无法自动合并到 Android 节点。解决方案是在 Android 侧建立一个“代理节点”,把 Flutter SemanticsNode 的 id、label、actions 映射成虚拟节点,再通过 AccessibilityBridge 一次性同步,保证国产读屏不遗漏。
- 车载与 Wear:车载 ROM(AliOS、HarmonyOS 车机版)要求“驾驶模式”下只读关键信息。可以在 onInitializeAccessibilityNodeInfo 里判断 Settings.Secure.getInt(context.contentResolver, “car_mode_enabled”, 0) == 1,如果是,则把节点数量裁剪到 5 个以内,并增加“驾驶模式已简化”提示。
- 隐私与合规:工信部 2024 年新规范要求“无障碍通道不得用于采集用户行为数据”。因此,在 sendAccessibilityEvent 里禁止携带业务埋点字段;如需统计,应在无障碍事件之外单独埋点,避免被商店审核认定为“违规使用 Accessibility API”。