在自定义 View 中,如何正确处理 Accessibility(无障碍)支持?

解读

国内大厂(阿里、腾讯、字节、美团)的面试里,Accessibility 不是“加分项”,而是“合规项”。工信部 2021 年《App 侵害用户权益专项整治》把“适老化及无障碍”列为强制检查点;华为、小米、OPPO 商店审核也会扫描 Accessibility 声明。如果候选人只说“在 xml 里加 android:contentDescription”,会被直接判为“不了解国内政策”,挂掉一轮。
面试官真正想听的是:

  1. 你能否让 TalkBack/随选朗读/小米小爱朗读在自定义绘制区域内正确报出“角色、名称、状态、值、操作”五要素;
  2. 你能否处理“虚拟子节点”(例如一个折线图里有 20 个数据点),避免一次性读 20 次导致信息爆炸;
  3. 你能否在国产 ROM 的“手势放大+色彩反转+高对比度”三件套下不翻车;
  4. 你能否在关闭无障碍时零性能损耗,打开时又不掉帧(16 ms 红线)。

知识点

  1. 国内合规框架
    • 《信息技术 互联网内容无障碍可访问性技术要求与测试方法》(GB/T 37668-2019)
    • 工信部 2021-2023 专项整治行动:必须支持屏幕朗读、焦点顺序、色彩无关提示
  2. Android 无障碍流水线
    • 探索(Explore):AccessibilityNodeInfo 树
    • 交互:AccessibilityNodeInfo.AccessibilityAction
    • 反馈:AccessibilityEvent + 文字转语音(TTS)
  3. 自定义 View 三大接入方式
    • 轻量:setContentDescription/setStateDescription
    • 中量:onInitializeAccessibilityNodeInfo + onInitializeAccessibilityEvent
    • 重量:ExploreByTouchHelper(虚拟子节点)
  4. 性能红线
    • 禁止在 onPopulateNodeInfo 里 new 对象;使用 Pools.SimplePool 复用
    • 虚拟节点数量超过 100 时,必须实现“分段懒加载”与“可见区域裁剪”
  5. 国产 ROM 差异
    • 华为 EMUI:TalkBack 3.5+ 之后强制开启“节点缓存”,若节点 id 不 stable 会报 warning
    • 小米 MIUI:系统设置里“无障碍快捷方式”会强制把 targetSdk<30 的 App 降级到“半无障碍”模式,必须 targetSdk≥31 才能全功能
    • ColorOS:在“高对比度文字”模式下,系统会忽略自定义 canvas.drawColor,必须在 nodeInfo 里额外写“高对比度文字=关闭”提示
  6. 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。

拓展思考

  1. 混合开发场景:如果自定义 View 里嵌套了 Flutter 引擎的 TextureView,Flutter 侧的无障碍树无法自动合并到 Android 节点。解决方案是在 Android 侧建立一个“代理节点”,把 Flutter SemanticsNode 的 id、label、actions 映射成虚拟节点,再通过 AccessibilityBridge 一次性同步,保证国产读屏不遗漏。
  2. 车载与 Wear:车载 ROM(AliOS、HarmonyOS 车机版)要求“驾驶模式”下只读关键信息。可以在 onInitializeAccessibilityNodeInfo 里判断 Settings.Secure.getInt(context.contentResolver, “car_mode_enabled”, 0) == 1,如果是,则把节点数量裁剪到 5 个以内,并增加“驾驶模式已简化”提示。
  3. 隐私与合规:工信部 2024 年新规范要求“无障碍通道不得用于采集用户行为数据”。因此,在 sendAccessibilityEvent 里禁止携带业务埋点字段;如需统计,应在无障碍事件之外单独埋点,避免被商店审核认定为“违规使用 Accessibility API”。