无障碍基础:理解Android的无障碍世界

各位好,我是老张。做UI自动化测试这么多年,我越来越觉得无障碍测试不是“附加题”,而是必修课。今天咱们先聊聊最基础的东西——Android的无障碍服务、TalkBack到底在干嘛,以及那个看不见摸不着的“无障碍树”长什么样。

1.1 Android无障碍服务:背后的“监听者”

简单说,无障碍服务是Android系统提供的一个后台服务。它运行在独立的进程里,可以监听用户的交互事件,也能主动查询界面上的元素信息。说白了,它就是系统的“眼睛和耳朵”。

TalkBack、Voice Access这些工具,本质上都是无障碍服务。它们通过系统API拿到当前屏幕上的所有控件信息,然后转换成语音或手势操作反馈给用户。

我在项目中遇到过一个问题:某个自定义控件,TalkBack死活读不出它的状态。后来发现是因为控件没有正确实现无障碍事件的回调。这事折腾了我两天,最后用AccessibilityService的日志一查,看到事件根本没发出来。嗯,从那以后我写控件都会顺手加一句无障碍支持。

无障碍服务的工作流程

  1. 用户开启无障碍服务(比如TalkBack)
  2. 系统将当前界面的AccessibilityNodeInfo树发送给服务
  3. 服务解析节点信息,决定如何反馈(朗读、聚焦、执行动作)
  4. 用户操作时,服务监听AccessibilityEvent并更新反馈

这里有个关键点:无障碍服务能访问的只是暴露出来的信息。如果一个控件的contentDescription是空的,或者focusable没设置,那服务就“瞎”了。

小技巧: 你可以在开发者选项里打开“无障碍调试”功能,或者用adb shell dumpsys accessibility看到当前窗口的无障碍节点树。我在排查问题时经常用这招。

1.2 TalkBack的角色:不是“读屏”那么简单

很多人以为TalkBack就是个读屏软件,屏幕上有啥它就念啥。其实它的角色比这复杂得多。TalkBack本质上是Android原生的一整套交互解决方案,包括:

  • 语音反馈:朗读文字、控件类型、状态
  • 手势导航:单指滑动切换焦点,双指滑动滚动列表
  • 上下文菜单:提供自定义操作(长按触发)
  • 焦点管理:循环移动无障碍焦点

举个例子:一个普通的Button,TalkBack会朗读它的文本内容,然后说“按钮”。如果你的Button还附带了一个状态(比如“已选中”),TalkBack会通过AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED来更新这个状态。你想想看,如果开发者忘了更新无障碍事件,用户听到的数据就是错的。

我个人习惯在写自动化脚本前,先手动操作一遍TalkBack。用真实用户视角去感受,能发现很多代码层面看不到的问题。

TalkBack和自动化测试的关系

我们做TalkBack UI自动化,本质上就是模拟TalkBack的操作方式:通过AccessibilityService发送手势、触发事件、读取节点信息。这样一来,我们的测试覆盖了真实的用户场景,而不仅仅是点击坐标。

一句话总结: TalkBack是我们的“用户代理”,自动化测试是模拟这个代理的行为。

1.3 无障碍树结构:一颗“看不见”的DOM树

做过前端的朋友都知道DOM树。Android的无障碍树类似,但结构更简化。每个可见的控件,在无障碍树里对应一个AccessibilityNodeInfo节点。树的结构直接映射了界面的视图层级。

我画了一张图,先让你对整体有个感觉:

Android 无障碍树结构 RootNode LinearLayout TextView EditText Button RelativeLayout ImageView CheckBox 说明: 每个视图控件对应一个 AccessibilityNodeInfo 节点 节点包含:类名、文本、描述、可点击、可见性等属性

无障碍节点的重要属性

每个AccessibilityNodeInfo都带着一堆属性。做自动化测试时,我们最常用的是这些:

属性 类型 说明 自动化用途
getClassName() String 控件对应的Java类名 定位特定类型控件
getText() CharSequence 控件显示的文本(如TextView) 匹配文本内容
getContentDescription() CharSequence 开发者为无障碍设置的描述 TalkBack朗读的内容
isClickable() boolean 控件是否可以点击 判断是否可交互
isFocusable() boolean 控件是否能获得无障碍焦点 控制遍历顺序
getBoundsInScreen() Rect 控件在屏幕上的坐标范围 模拟点击手势
曾经踩过的坑: 我试过用findAccessibilityNodeInfosByText()去找一个“确定”按钮,结果因为按钮是ImageView加上contentDescription设置的,文本匹配直接返回空。后来改成用findAccessibilityNodeInfosByViewId()才解决。所以写脚本千万别死磕一种方式。

1.4 无障碍树如何影响自动化测试

我们做自动化测试时,最核心的工作就是遍历无障碍树,找到目标节点,然后执行操作。这个过程跟TalkBack的行为是一模一样的。区别在于:TalkBack是给人用的,我们是给机器用的。

你会发现,无障碍树的层级越深,遍历效率越低。如果一个界面上有100个节点,你每次找元素都在全树扫描,那速度肯定慢。我个人的做法是:先按viewId或className缩小范围,再精确匹配。

一个简单的代码示例

下面这段代码演示了如何从无障碍服务中获取当前屏幕上的所有节点并打印它们的文本内容:

// 在AccessibilityService中获取根节点
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root != null) {
    // 遍历所有子节点
    LinkedList<AccessibilityNodeInfo> queue = new LinkedList<>();
    queue.add(root);
    while (!queue.isEmpty()) {
        AccessibilityNodeInfo node = queue.poll();
        // 打印节点信息
        CharSequence text = node.getText();
        CharSequence desc = node.getContentDescription();
        Log.d("Accessibility", "节点:" + node.getClassName()
                + " 文本:" + text
                + " 描述:" + desc);
        // 将子节点加入队列继续遍历
        for (int i = 0; i < node.getChildCount(); i++) {
            AccessibilityNodeInfo child = node.getChild(i);
            if (child != null) queue.add(child);
        }
        node.recycle();
    }
    root.recycle();
}

注意这个recycle()方法,我刚开始写的时候经常忘了调用,结果内存狂涨。系统在跨进程传节点信息时用了缓存,你不回收它就会一直占用。这是个大坑。

1.5 小结:打好地基

这一章我们聊了三件事:无障碍服务是怎么工作的、TalkBack到底在扮演什么角色、以及那颗“看不见”的无障碍树长啥样。这些东西看似理论,但直接决定了你后面写自动化脚本的思路。就拿contentDescription来说,如果你不理解它和text的区别,写定位逻辑时就会碰壁。

核心思维: 无障碍树就是我们的UI映射。自动化测试的第一步,就是学会“读树”。

嗯,今天就先到这里。记住我上面说的那些细节,后面几章我们就能直接上手写脚本了。