ADB uiautomator自动采集Android App数据实战

做数据采集的人,迟早要面对一个困境:想要的数据就在屏幕上,肉眼可见,伸手可及,却没有任何 API 可以调用。App 的开发者从未打算把这些数据交给你。

古人说「取之有道」,但道在何处?root 权限是一条路,无障碍服务是另一条,各有各的代价。还有一条更朴素的路——adb shell uiautomator dump,把屏幕上的控件树导出为 XML,然后像考古学家清理陶片一样,从层层嵌套的节点中辨认出有意义的文字。

不需要 root,不需要无障碍权限,不需要安装额外的 App。只需要一根数据线(或者无线调试),和一点耐心。

本文记录的是一个健康数据自动采集项目的技术经验。方法本身是通用的。

技术方案

核心链路

ADB 启动 App → 模拟点击/滑动导航到目标页面
→ uiautomator dump 导出屏幕 XML
→ 程序解析 XML 提取数据 → 保存/上传

关键工具

工具作用
adb shell uiautomator dump /sdcard/ui_dump.xml导出当前屏幕的 UI 控件树为 XML
adb shell cat /sdcard/ui_dump.xml读取 XML 内容
adb shell input tap x y模拟点击指定坐标
adb shell input swipe x1 y1 x2 y2 duration模拟滑动
adb shell input keyevent 4模拟返回键

权限要求

仅需开启 USB 调试(或无线调试),不需要 root、不需要无障碍权限、不需要安装额外 App。在 Termux 环境下可通过 localhost:5555 进行本机 ADB 通信。

UI 树结构探索方法论

编写解析代码之前,必须先确认目标页面的 UI 节点结构。这一步不可省略,不可凭直觉代替。以下是经过反复验证的排查流程。

第一步:导出并预览

1
2
adb shell uiautomator dump /sdcard/ui_dump.xml
adb shell cat /sdcard/ui_dump.xml | sed 's/></>\n</g' | grep -E 'text="[^"]+"' | grep -v 'text=""'

sed 's/></>\n</g' 将紧凑的 XML 按节点换行,配合 grep 快速筛选有文本内容的节点。这一步用于确认页面是否正确,以及初步了解有哪些数据字段。

第二步:提取 content-desc

1
adb shell cat /sdcard/ui_dump.xml | sed 's/></>\n</g' | grep -E 'content-desc="[^"]+"' | grep -v 'content-desc=""'

许多控件(如卡片、时长视图)的关键数据存储在 content-desc 属性中而非 text 中。这个属性本是为无障碍服务准备的,却意外地成为数据采集的富矿。

第三步:分析层级关系

当需要确认两个节点是父子关系还是兄弟关系时,grep 就力不从心了——紧凑 XML 没有缩进信息,肉眼无法判断层级。将 XML 下载到本地,用 Python 脚本遍历 ElementTree:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import xml.etree.ElementTree as ET

tree = ET.parse('ui_dump.xml')
root = tree.getroot()

def find_parents(node, parent=None):
    rid = node.get('resource-id', '')
    text = node.get('text', '')
    if '目标关键词' in text:
        if parent is not None:
            print(f'Parent: {parent.get("resource-id", "")}')
            for child in parent:
                c_rid = child.get('resource-id', '').split('/')[-1]
                c_desc = child.get('content-desc', '')
                print(f'  sibling: [{c_rid}] desc={c_desc!r}')
    for child in node:
        find_parents(child, node)

find_parents(root)

这是确认节点层级关系最可靠的方法。后文的实战案例会证明,省略这一步的代价有多大。

节点匹配模式

三种定位属性

属性适用场景示例
resource-id开发者命名的控件 ID,最稳定com.mi.health:id/txtValue
content-desc无障碍描述,常包含汇总信息"最近一次心率83次/分, 2月24日 21:14"
text显示文本,可能随语言/数据变化"82", "心率范围"

匹配策略优先级

  1. 精确 resource-id 匹配n.ResourceID == "com.mi.health:id/txtValue" —— 最可靠
  2. resource-id 包含匹配strings.Contains(n.ResourceID, "duration_view") —— 适用于 ID 有固定前缀的情况
  3. content-desc 包含匹配strings.Contains(n.ContentDesc, "心率") —— 适用于查找卡片入口
  4. text 精确/包含匹配n.Text == "热身" —— 最后手段,易受数据变化影响

父容器遍历 vs 全局顺序配对

这是整篇文章最重要的一节。

全局顺序配对是一种诱人的反模式:收集所有同名节点,按出现顺序一一配对。代码写起来简洁,看起来优雅,测起来也能过——直到页面上出现第二组同名节点。

1
2
3
4
5
6
7
// 危险:如果页面上有其他同名 resource-id 的节点,配对会错位
var titles, values []string
WalkNodes(h.Nodes, func(n *Node) {
    if n.ResourceID == ".../txt_title" { titles = append(titles, n.Text) }
    if n.ResourceID == ".../txt_value" { values = append(values, n.Text) }
})
// titles[i] 和 values[i] 可能不是同一组数据

父容器遍历才是正道:利用 UI 树的层级结构,在父容器内查找同级或子树中的关联节点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 安全:在同一个父容器内配对 title 和 value
WalkNodes(h.Nodes, func(n *Node) {
    var title, value string
    for i := range n.Nodes {
        child := &n.Nodes[i]
        switch child.ResourceID {
        case ".../txt_title":
            title = child.Text
        case ".../txt_value":
            value = child.Text
        }
    }
    if title != "" {
        // title 和 value 确保来自同一组
    }
})

当关联节点不在同一层直接子节点中(如嵌套在中间容器内),需要搜索子树:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
WalkNodes(h.Nodes, func(n *Node) {
    var duration, name string
    for i := range n.Nodes {
        child := &n.Nodes[i]
        // 直接子节点
        if strings.Contains(child.ResourceID, "duration_view") {
            duration = child.ContentDesc
        }
        // 搜索子树(处理 ll_title > txt_title 嵌套)
        WalkNodes(child.Nodes, func(gc *Node) {
            if gc.ResourceID == ".../txt_title" && isZoneName(gc.Text) {
                name = gc.Text
            }
        })
    }
    if name != "" && duration != "" {
        // 配对成功
    }
})

一个案例:三轮迭代才采到的数据

背景

某健康 App 的心率详情页包含两个数据区域:

  • 今日概览:4 组 txt_value + txt_title(心率范围、平均心率、静息心率、睡眠平均心率)
  • 心率区间:5 组 duration_view(时长)+ txt_title(热身/燃脂/有氧/无氧/极限)

两个区域的 txt_title 使用相同的 resource-id。这是问题的根源。

第一轮:全局顺序配对

方案:收集所有 txt_titletxt_value,按索引配对。

结果:滚动后页面包含 4 个概览标题 + 5 个区间名称(共 9 个 txt_title),但只有 4 个 txt_value,配对全面错位。区间数据完全无法采集。

第二轮:父容器遍历

方案:改为遍历父容器,在直接子节点中查找配对。概览数据用白名单过滤标题文本,区间数据查找同级的 duration_view + txt_title

结果:概览 4 项全部采集成功。但区间数据仍然为空——代码假设 duration_viewtxt_title 是兄弟节点,实际并非如此。

这是最微妙的错误:逻辑上「应该如此」的层级关系,在实际 UI 树中并不成立。

第三轮:XML 层级分析

回到第三步的方法论——下载 XML,用 Python 脚本分析实际结构:

<匿名容器>
  <duration_view content-desc="5小时39分钟">
    <tv_data_first/>
    <tv_unit_first/>
  </duration_view>
  <ll_title>
    <color_block/>
    <txt_title>热身</txt_title>
  </ll_title>
</匿名容器>

duration_viewll_title 是兄弟节点,而 txt_title 嵌套在 ll_title 内部。修正后在匿名容器层级搜索直接子节点找 duration_view,同时递归子树找 txt_title

结果:5 个区间全部采集成功。

教训

  1. 绝不凭直觉假设节点层级关系,必须通过 XML 层级分析确认
  2. 同一个 resource-id 可能在不同语义区域重复使用,全局收集必然出错
  3. 迭代式排查比一次性设计更现实——UI 结构的复杂度只有实际 dump 后才能确认

三条教训,说到底是同一件事:不要相信自己的想象。去看真实的数据。

采集架构模式

每个详情页的采集遵循统一模式:

EnsureHomeTop()          ← 确保在首页最顶部
  ↓
FindCard & ClickNode()   ← 在首页找到目标卡片并点击
  ↓
DumpUI & VerifyTitle()   ← dump 验证已进入正确的详情页
  ↓
parseDetailPage()        ← 解析第一屏数据
  ↓
SwipeUp & DumpUI()       ← 滚动获取更多内容
  ↓
parseMoreData()          ← 解析后续屏数据
  ↓
PressBack()              ← 返回首页

批量补采模式的优化:只进入一次详情页,在页面内通过日历导航切换日期,避免反复回首页的开销。

enterDetailPage()        ← 进入一次
  ↓
for date in dates:
    NavigateToDate(date)  ← 页面内切换日期
    parseDetailPage()     ← 解析当前日期数据
  ↓
PressBack()              ← 最后才返回

这个模式的好处是显而易见的:每多采一天,只多一次日历操作和两次 dump,而非完整的「回首页→找卡片→进详情」流程。

Termux 环境的陷阱

uiautomator dump 的 exit 137 问题

在 Termux 通过 localhost:5555 执行 uiautomator dump 时,偶尔会出现 exit code 137(被 SIGKILL),此时 /sdcard/ui_dump.xml 文件不会更新,读到的是上次 dump 的缓存内容。

这个问题的阴险之处在于:命令并未报错,XML 文件也确实存在,只是内容是旧的。如果不做校验,程序会安静地解析过期数据,输出看似合理但实际错误的结果。

应对策略:

  • 检测 dump 命令的返回码,失败时增加等待后重试
  • 对比前后 XML 文件大小,如果相同则认为是缓存数据,触发重试
  • 关键数据可通过检查预期节点是否存在来判断 dump 是否有效

ADB 连接可靠性

Termux 本机 ADB 通过 localhost:5555 通信。手机重启后无线调试端口会随机分配(35000-42000 范围),需要扫描恢复到固定端口 5555。通过 crontab 定时保活配合开机自启脚本,可以构建足够可靠的连接维护机制。

适用场景与局限

适用

  • 采集 App 页面上的静态文本数据(健康数据、设置项、状态信息等)
  • 定时自动化采集(配合 crontab)
  • 无 root、无需修改目标 App

局限

  • 无法采集动态渲染内容(如 Canvas 绑制的图表)
  • uiautomator dump 有性能开销(每次约 1-3 秒),不适合高频采集
  • App 更新可能改变 UI 结构,导致解析逻辑失效
  • 屏幕必须亮起且解锁,不能在息屏状态下采集

说到底,这套方案的本质是「屏幕阅读」——程序所能获取的信息,不会超过一个坐在屏幕前的人所能看到的。在 API 缺位的世界里,这已经是最诚实的办法了。