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 节点结构。这一步不可省略,不可凭直觉代替。以下是经过反复验证的排查流程。
第一步:导出并预览
| |
sed 's/></>\n</g' 将紧凑的 XML 按节点换行,配合 grep 快速筛选有文本内容的节点。这一步用于确认页面是否正确,以及初步了解有哪些数据字段。
第二步:提取 content-desc
| |
许多控件(如卡片、时长视图)的关键数据存储在 content-desc 属性中而非 text 中。这个属性本是为无障碍服务准备的,却意外地成为数据采集的富矿。
第三步:分析层级关系
当需要确认两个节点是父子关系还是兄弟关系时,grep 就力不从心了——紧凑 XML 没有缩进信息,肉眼无法判断层级。将 XML 下载到本地,用 Python 脚本遍历 ElementTree:
| |
这是确认节点层级关系最可靠的方法。后文的实战案例会证明,省略这一步的代价有多大。
节点匹配模式
三种定位属性
| 属性 | 适用场景 | 示例 |
|---|---|---|
resource-id | 开发者命名的控件 ID,最稳定 | com.mi.health:id/txtValue |
content-desc | 无障碍描述,常包含汇总信息 | "最近一次心率83次/分, 2月24日 21:14" |
text | 显示文本,可能随语言/数据变化 | "82", "心率范围" |
匹配策略优先级
- 精确 resource-id 匹配:
n.ResourceID == "com.mi.health:id/txtValue"—— 最可靠 - resource-id 包含匹配:
strings.Contains(n.ResourceID, "duration_view")—— 适用于 ID 有固定前缀的情况 - content-desc 包含匹配:
strings.Contains(n.ContentDesc, "心率")—— 适用于查找卡片入口 - text 精确/包含匹配:
n.Text == "热身"—— 最后手段,易受数据变化影响
父容器遍历 vs 全局顺序配对
这是整篇文章最重要的一节。
全局顺序配对是一种诱人的反模式:收集所有同名节点,按出现顺序一一配对。代码写起来简洁,看起来优雅,测起来也能过——直到页面上出现第二组同名节点。
| |
父容器遍历才是正道:利用 UI 树的层级结构,在父容器内查找同级或子树中的关联节点。
| |
当关联节点不在同一层直接子节点中(如嵌套在中间容器内),需要搜索子树:
| |
一个案例:三轮迭代才采到的数据
背景
某健康 App 的心率详情页包含两个数据区域:
- 今日概览:4 组
txt_value+txt_title(心率范围、平均心率、静息心率、睡眠平均心率) - 心率区间:5 组
duration_view(时长)+txt_title(热身/燃脂/有氧/无氧/极限)
两个区域的 txt_title 使用相同的 resource-id。这是问题的根源。
第一轮:全局顺序配对
方案:收集所有 txt_title 和 txt_value,按索引配对。
结果:滚动后页面包含 4 个概览标题 + 5 个区间名称(共 9 个 txt_title),但只有 4 个 txt_value,配对全面错位。区间数据完全无法采集。
第二轮:父容器遍历
方案:改为遍历父容器,在直接子节点中查找配对。概览数据用白名单过滤标题文本,区间数据查找同级的 duration_view + txt_title。
结果:概览 4 项全部采集成功。但区间数据仍然为空——代码假设 duration_view 和 txt_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_view 和 ll_title 是兄弟节点,而 txt_title 嵌套在 ll_title 内部。修正后在匿名容器层级搜索直接子节点找 duration_view,同时递归子树找 txt_title。
结果:5 个区间全部采集成功。
教训
- 绝不凭直觉假设节点层级关系,必须通过 XML 层级分析确认
- 同一个
resource-id可能在不同语义区域重复使用,全局收集必然出错 - 迭代式排查比一次性设计更现实——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 缺位的世界里,这已经是最诚实的办法了。