关于 android-emulator-skill
Android Emulator Skill 提供脚本,通过无障碍导航和结构化界面数据构建、测试及自动化 Android 应用,适用于需要管理模拟器和应用生命周期的开发与测试流程,无需依赖屏幕坐标。
主要功能
- 01通过界面层级检查屏幕
- 02启动、安装和管理应用
- 03启动、创建和关闭模拟器
- 04清除或删除 AVD
- 05返回机器可读的 JSON 结果
适用场景
- 01测试时映射应用界面元素
- 02启动应用并打开深层链接
- 03为测试创建或启动 AVD
- 04在测试运行之间重置模拟器数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ fluxxion82/ android-emulator-skill --skill android-emulator-skill安装后在 Claude Code 中输入/android-emulator-skill确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdexamples/login_flow_example.shscripts/accessibility_audit.pyandroid_health_check.shanr_watcher.pyapp_launcher.pyapp_state_capture.pyappearance.pyavd.pybuild_and_test.pycontainer.pycrash_triage.pydevice_list.pyemulator_boot.pyscripts/common/__init__.pyadb_exec.pyanr_pipeline.pyanr_sessions.pybatch.pycache_utils.pycli.pydevice_utils.pyemu_console.pyenv_config.pyhierarchy.pylog_lifecycle.pylogcat.pyrun_as.pyscreenshot_utils.pysdk_tools.py详细介绍
Android Emulator 技能
使用无障碍驱动的导航和结构化数据(而非像素坐标)来构建、测试和自动化 Android 应用。
调用这些脚本
脚本位于此文件旁边的 scripts/ 目录中。请使用以技能目录为根目录的路径,而不要使用单纯的相对路径——当此技能作为插件安装且你正在项目中工作时,python scripts/foo.py 会报找不到文件。
SKILL_DIR=/path/to/skills/android-emulator-skill
python3 "$SKILL_DIR/scripts/screen_mapper.py" --json
以下示例均假定 $SKILL_DIR 已按这种方式设置。
快速入门
# 0. Check the environment (exit 1 names what to fix: adb, a JDK below 17, ...)
bash "$SKILL_DIR/scripts/android_health_check.sh" --json
# 1. Launch app
python3 "$SKILL_DIR/scripts/app_launcher.py" --launch com.example.app
# 2. Map screen to see elements
python3 "$SKILL_DIR/scripts/screen_mapper.py"
# 3. Tap button (add --scroll-to-find if it may be below the fold)
python3 "$SKILL_DIR/scripts/navigator.py" --find-text "Login" --tap
# 4. Enter text
python3 "$SKILL_DIR/scripts/navigator.py" --find-type EditText --enter-text "[email protected]"
# 5. Run accessibility audit
python3 "$SKILL_DIR/scripts/accessibility_audit.py"
所有脚本都支持 --help 以查看详细选项,并支持 --json 以输出机器可读的数据。
脚本(v0.8.0)
已实现(32 个脚本)
核心实用工具(common/ 中的 15 个模块)
-
common/device_utils.py - 构建 ADB 命令并检测设备
-
common/screenshot_utils.py - 截取并处理屏幕截图
-
common/cache_utils.py - 渐进式披露缓存系统
common/中还包括:cli.py(每个脚本最终都会调用的错误边界cli.run(main);其约定以及尚未涵盖的内容,见下方“已知缺口”中的“故障”条目)、batch.py(唯一的批处理结构,会逐项报告结果——items中的每一项都包含success/error/remedy;以及设备错误何时会中止批处理)、adb_exec.py(所有 ADB 调用的唯一有界入口,带有注明解决方法的类型化错误)、hierarchy.py(捕获 UI 层级结构的唯一方式——不使用临时文件,因此并发运行时不会读取彼此的屏幕)、emu_console.py(adb emu即使失败也会以状态码 0 退出)、logcat.py(唯一负责构建adb logcat参数并解析时长的模块,供所有四个日志读取器共用)、sdk_tools.py(解析 SDK 二进制文件;在以 SDK 根目录为 PATH 的环境中,仅用emulator这个名称会命中<sdk>/emulator目录并引发PermissionError)、run_as.py(使用adb shell -T run-as读取可调试应用的文件,从不使用exec-out;文件不存在时,前者会将错误文本作为字节返回;会先检查 ADB 客户端是否遵守-T;判断何种情况属于 run-as 拒绝,并将目录列为 toybox find 的 NUL 终止记录,供两个检查器使用)、env_config.py、anr_pipeline.py、anr_sessions.py、log_lifecycle.py(system_server 的进程状态语句——Start proc/Killing/has died——使用同一方式进行验证,供log_monitor --app和 ANR 会话工作进程共用)。
应用管理(1 个脚本)
- app_launcher.py - 应用生命周期管理
- 按包名启动应用。
--launch会等待 Activity 显示出来(am start -W);如果系统报告的状态不是Status: ok,则会失败,确保下一条命令映射到应用的屏幕,而不是仍显示在前台的其他内容。消息会报告启动的 Activity 及所用时间。 - 终止应用
- 安装/卸载 APK
- 深层链接导航。
--open-url会像--launch一样等待,并且在 Intent 未解析到任何内容时失败,而不会仅仅报告已将 URL 交给系统处理。 - 列出已安装的软件包
- 检查应用状态
- 选项:
--launch、--terminate、--install、--uninstall、--open-url、--list、--state、--json
- 按包名启动应用。
设备生命周期(5 个脚本)✓ 已完成
-
emulator_boot.py - 启动模拟器,可选择验证是否就绪
- 按 AVD 名称启动
- 等待设备就绪,支持超时设置
- 批量启动操作
- 支持无头模式
--list-avds会报告空列表(No AVDs found或{"avds": []}),并且仅当模拟器已运行且此主机未定义任何 AVD 时退出码为 0。找不到emulator二进制文件或该文件运行失败都属于错误,会指出查找位置,并以退出码 1 退出(使用--json时为{"success": false, "error": ..., "remedy": ...})——“没有 AVD”和“无法查找”是不同的结果。--all以相同方式报告此类失败--all是批量操作(见失败情况):返回一个对象{"success", "action", "succeeded", "failed", "total", "items"};每个 AVD 对应一个条目,包含avd、message、success、error、remedy;只要有任何 AVD 启动失败,就以退出码 1 退出,并在顶层包含error/remedy。v0.9.0 中的重大变更:results现在为items- 如果任何已连接的模拟器无法说明自己对应哪个 AVD(状态不是
device,或控制台无响应),或者根本无法列出模拟器,就会拒绝启动:该设备可能就是此 AVD,而同一 AVD 的第二个实例会损坏其 userdata。拒绝信息会指出序列号及其状态,并要求终止过期模拟器的进程——控制台无响应时,无法通过控制台将其关闭 - 失败情况——拒绝启动或启动失败、缺少
--avd——都采用统一的拒绝响应:使用--json时为{"success": false, "error": ..., "remedy": ...},否则在 stderr 输出Error:/Remedy:,退出码为 1。使用--json成功启动时返回{"success": true, ...}(在 v0.8.0 之前,报告缺陷时退出码为 1) - 选项:
--avd、--wait-ready、--timeout、--headless、--list-avds、--json
-
emulator_shutdown.py - 优雅地关闭模拟器
- 按序列号关闭。仅限模拟器:如果序列号不是
emulator-NNNN,则会在运行任何 adb 命令前拒绝,因此绝不会关闭已连接的手机或平板电脑 --name AVD会将 AVD 解析为序列号。如果某个模拟器无法查询,或根本无法列出模拟器,就会如实报告并以非零退出码退出,绝不会报告“未找到正在运行的模拟器”——对于一个根本无人成功查询过的设备,这种说法过于武断- 失败情况采用统一的拒绝响应:使用
--json时为{"success": false, "error": ..., "remedy": ...},否则在 stderr 输出Error:/Remedy:,退出码为 1(adb 表示设备不存在时为 3) - 可选择验证关机是否完成
- 批量关闭操作(仅限正在运行的模拟器)。
--all是批量操作(见失败情况):返回{"success", "action", "success_count", "fail_count", "items"};每个模拟器对应一个条目,包含serial、message、success、error、remedy(此前只报告计数);如果某个模拟器在被终止前消失,该模拟器对应的条目会标记为失败,其余模拟器仍会关闭。只要有任何失败,就以退出码 1 退出 - 选项:
--serial、--name、--verify、--timeout、--all、--json
- 按序列号关闭。仅限模拟器:如果序列号不是
-
emulator_create.py ⭐ 新增 - 动态创建 AVD
- 按设备类型和 API 级别创建
- 列出可用的设备定义
- 列出可用的系统映像
--abi默认使用此主机的 ABI:在 arm64/aarch64 机器上为arm64-v8a(Apple 芯片 Mac 上的模拟器无法启动其他 ABI),否则为x86_64;--help会说明将使用的值。在 Rosetta 下运行的 Python 会报告x86_64,因此此处的默认值不正确:请显式传入--abi arm64-v8a- 创建前会检查映像是否已安装。若没有
sdkmanager(或没有avdmanager),则报错并指出查找位置,退出码为 1——绝不会显示空列表, 也绝不会创建无法检查的映像 - 所有拒绝操作——未找到设备定义、没有 API 级别、映像未安装(错误会列出已安装的映像)、avdmanager 拒绝创建、缺少
--device——在--json下都会返回统一格式{"success": false, "error": ..., "remedy": ...}(此前为{"success": false, "message": ...});否则会在 stderr 上输出Error:/Remedy:,退出码为 1 - 支持读取
sdkmanager的两种列表格式:Java 工具的管道表格 (所有早于 23 的 cmdline-tools,以及 CI 映像)和 cmdline-tools 23 的 Android-CLI 包装器中的斜杠路径列。此前只读取第二种格式,因此使用 Java 工具时,已安装的映像会被报告为未安装 - 选项:
--device、--api、--name、--abi、--variant、--list-devices、--list-images、--json
-
emulator_delete.py ⭐ 新增 - 永久删除 AVD
- 按 AVD 名称删除,或使用
--all/--old N批量删除(--yes可跳过提示) - 列出可用的 AVD
- 所有模式对缺少或运行失败的
avdmanager都采用相同处理方式:退出码为 1, 并给出cmdline-tools解决办法,绝不会以退出码 0 显示“No AVDs deleted” --old N按<name>.avd的修改时间排序;如果找不到某个 AVD 的目录, 整个排序就会失败(退出码为 1),而不会按最旧顺序排序并删除。AVD 主目录依次为$ANDROID_AVD_HOME、$ANDROID_SDK_HOME/.android/avd,若均未设置则为~/.android/avdN必须至少为 1:--old 0会保留零个 AVD,因此会作为用法错误被拒绝(退出码为 2),并提示使用--all --yes- 未使用
--yes时,问题会输出到 stderr,答案从 stdin 读取;输入任何非yes内容(包括输入结束)都会取消删除,并在所有模式下作为拒绝操作处理——deletion cancelled,退出码为 1——因此--all和--old不会再因对“No”回答而以退出码 0 返回空批次 - 失败时,在
--json下返回{"success": false, "error": ..., "remedy": ...}——包括--name指定的名称未列出,以及 avdmanager 拒绝删除或未能完成删除的情况。--all/--old属于批量操作(参见 Failures):始终返回汇总信息(action、succeeded、failed、total、keep_count)和items;其中每个 AVD 对应一项,包含name、message、success、error、remedy,不再像以前那样只在使用--verbose时返回results。v0.9.0 中的破坏性变更。 若任何 AVD 删除失败,退出码为 1,并在顶层提供error/remedy - 选项:
--name、--all、--old、--yes、--list、--json、--verbose
- 按 AVD 名称删除,或使用
-
emulator_erase.py ⭐ 全新 - 将 AVD 恢复出厂设置
- 擦除用户数据,但不删除 AVD
- 保留 AVD 配置
- 直接读取 AVD 主目录(
$ANDROID_AVD_HOME,否则为$ANDROID_SDK_HOME/.android/avd,再否则为~/.android/avd);不会调用 SDK 工具。AVD 主目录不存在时会报错并指出路径,退出码为 1,而不是“No AVDs found” - 使用
--all批量擦除;--verify会轮询磁盘上的 AVD,直到擦除生效, 等待时间受--timeout限制(默认 90s,ANDROID_EMU_ERASE_TIMEOUT) - 只有在已识别所有已连接的模拟器,且确认其中没有这台 AVD 时,才允许擦除。
控制台查询失败或模拟器尚未处于
device状态时,均视为“未知”,并拒绝擦除; 错误会指出序列号、状态和解决办法——使用--force可强制擦除 - 所有失败都会以统一格式拒绝:使用
--json时返回{"success": false, "error": ..., "remedy": ...},否则在 stderr 上输出Error:/Remedy:,退出码为 1。--all为批量操作(参见 Failures):输出摘要和items,每个 AVD 对应一项, 包含avd、message、success、error、remedy;如果有任何 AVD 失败, 顶层还会包含拒绝信息中的error/remedy(退出码为 1)。 v0.9.0 中的破坏性变更:results现已改为items - 保留快照:擦除成功时会输出“快照已保留;使用
snapshot.py --delete
<name>将其删除”,因为之后使用snapshot.py --load可能会撤销出厂状态 - 选项:
--name、--all、--force、--verify、--timeout、--list、--json、--verbose
构建与开发(2 个脚本)✓ 已完成
- build_and_test.py - 使用渐进式披露实现 Gradle 构建/测试自动化(由
gradle/子包提供支持)- 默认提供单行摘要和结果 ID;可按需深入查看
- 解析 JUnit XML,统计通过/失败数量及失败测试名称;提取 Gradle/Kotlin/Java 错误和警告
- Gradle 失败的
location(file、line)取自其* Where:行,且仅记录一种形式 (Build file '<path>' line: N,即求值失败的构建脚本)。没有此信息或形式未记录的失败 (设置文件、应用的脚本、初始化脚本),其位置为 null - 对于
--module ... --test,模块的报告目录以 Gradle 自身的结果为准:它会先运行./gradlew -q <module>:properties --property projectDir(每次此类运行会额外配置一次 构建;不带--test的构建不会查询),且绝不会自行读取设置脚本,因此会遵循任何projectDir/setProjectDir/includeFlat/设置插件映射。Gradle 不认识的模块、Gradle 失败或超时(ANDROID_EMU_BUILD_TIMEOUT)、结果中没有且仅有一行projectDir:,以及目录缺失或位于--project之外的情况,都会在删除任何报告或启动测试任务前按名称拒绝 --test只读取本次运行写入的报告:先删除测试任务旧的TEST-*.xml报告,删除前会确认其解析后的路径位于所选模块和准确的任务目录内, 然后重新运行任务(Gradle 7.6+ 使用--rerun;旧版 wrapper 会按名称拒绝)。只有在 Gradle 退出码为 0 且报告中没有失败或错误时, 测试才算通过——ignoreFailures = true无法将失败的测试变成通过。报告无法读取或格式错误时,会拒绝给出部分判定。格式正确的空测试套件与非空报告并存时不计入测试数;如果所有收集到的报告都没有测试用例,则会按现有的无结果规则拒绝运行。运行未写入任何测试结果时,属于失败,而非“0 项测试通过”。如果 Gradle 超时,会缓存部分日志,并拒绝运行,同时建议为当前模式调高ANDROID_EMU_BUILD_TIMEOUT或ANDROID_EMU_TEST_TIMEOUT,或检查卡住的任务- 选项:
--project、--module/-p、--variant、--clean、--test、--suite、--get-errors/--get-warnings/--get-log <ID>、--list-builds、--verbose、--json
- log_monitor.py ⭐ 新增 - 实时 logcat 监控
- 读取 logd 的记录(
logcat -B),而非文本:每条记录都是完整的,并包含 logd 从写入套接字的凭据中取得的 pid 和 uid——这是应用无法写入的唯一元数据(tid 和时间则由写入方自行提供)。在文本中,应用选择的 tag 可以包含换行符和完整的标头,因此一条应用记录打印出的第二行可能看起来像是“来自” system_server。每条记录的输出效果与-v threadtime -v usec相同,使用此主机的本地时间,并对 tag 中的控制字符以及消息中的换行和终端控制字符进行转义(\r转为\\r,ESC 转为\\x1b;保留 TAB),因此无论在什么阅读器中,打印或保存的内容都能按原样还原为相同的行 - 在 API 33(Android 13)上,实时读取通过转储而非流式读取实现:该版本的 logcat 通过 4 KiB 的 stdio 缓冲区写入
-B记录,只有在写入文本记录后才会刷新,因此被读取方停止的流会丢失缓冲区中尚未写出的内容(已在 API 33 模拟器上记录:静默 5 秒的流没有传送任何字节;--duration 3s的结果为total_lines: 0,且成功)。在此版本上,捕获由一系列转储组成,名义上每隔一次轮询执行一次(若读取耗时超过轮询间隔,则会立即进行一次追赶轮询):读取从系统进程写入的最新记录开始(logcat -d -B -T 1,不断将尾部数量翻倍,直到找到一条记录:即锚点;读取开始前已记录的后续内容不属于本次窗口)。当缓冲区中没有可用作锚点的系统记录时——缓冲区为空或只有应用记录,通常发生在--clear之后——log_monitor 会向设备日志写入一条标记行:adb shell log -t aes-log-monitor anchor-<128-bit hex>(通过 shell 写入:这是应用无法写入的帧,由随机数保证唯一),再按该随机数查找标记(从 1 开始不断翻倍尾部数量;若整个缓冲区中都找不到,则拒绝)。随后读取标记之后的内容;它会在 stderr、文本摘要(Anchor written:)和 JSON(anchor_written: true)中说明此操作(未写入标记时为false;此键仅在 API 33 上存在)。仅凭应用记录不会继续读取:同一数据报的副本可能逐字节完全相同。之后每隔ANDROID_EMU_LOG_POLL_SECONDS(1),无论模式如何,都会读取 logd 在已读记录之后交付的内容;--duration结束或按下 Ctrl-C 时还会再读一次——启用--follow时会边读边打印。读取位置由 logd 的顺序决定,从已读取到的最新系统写入记录开始;应用无法伪造其身份。不会使用时间戳定位。系统记录指的是 uid——由 logd 根据写入套接字的凭据取得,而非写入方所声称的值——在所属用户下小于 10000 的记录(uid % 100000):包括 root、system_server、守护进程和 shell(2000);adb shell log写入的记录也归于 shell。应用可以将同一数据报发送两次(包括时间和负载),logd 会保留两条字节完全相同的帧;因此应用记录永远不会决定读取位置。每次读取都会转储最新的 N 条记录(logcat -d -B -T N,N 至少为 64,且足以容纳上次读取新增记录数量的两倍),逐字节查找该系统记录,并逐帧检查其后已读取的记录是否紧随其后,然后保留它们之后的所有内容;若 N 条记录中未找到,则将 N 翻倍并重新请求转储。转储中的最新系统记录就是下一次读取的位置;若新增记录中没有系统记录,则读取位置保持不变,新增记录也会加入待检查内容。若没有新的系统记录,记住的后缀和所需转储都会不断增长,直到缓冲区覆盖旧记录或达到字节上限后拒绝继续。应用重新发送自己的记录——无论是锚点相邻记录还是全部记录——都会增加读取到的记录,但无法移动读取位置。记录时间由写入方提供(原生应用可以将时间戳设为任意值,设备时钟也可能跳变),因此时间戳或时钟跳变都不会移动读取位置,也不会使记录无法被读取;系统不会查询设备时钟。若转储中发现同一条系统记录两次或更多次:由于无法确定从何处继续,直接拒绝——会明确报错,绝不静默处理;这种情况只有系统写入方重复帧时才可能发生,而系统写入方不会这样做。受信任的写入方不得重放完整帧;重复检测会覆盖搜索转储中存在的副本。若转储返回的记录数少于 N(即整个缓冲区),且其中没有目标记录,或已读取的后续记录缺失或不同,则表示 logd 缓冲区已覆盖该记录,拒绝。若翻倍后的尾部数量会使转储超过ANDROID_EMU_LOG_DUMP_CAP_BYTES(超过上次转储字节数的两倍),则会在发起请求前拒绝。每次转储都以相同顺序结束:若仍在运行则停止(先杀死进程,再在 2 秒内回收;发生中断时,会在中断继续传播前停止子进程,否则 stderr 会说明无法停止);读取其 stderr 的最后 64 KiB;若 stderr 中有 adb 自己写出的设备错误信息——无论退出状态如何——则以状态码 3 退出;然后运行adb get-state,确认 adb 仍能看到设备(设备消失或状态不是device:以状态码 3 退出;查询本身失败:以状态码 1 退出);只有完成这些步骤后才作出判断——无法停止、等待失败、无法读取 stderr(此时不会检查 adb 自己写出的错误信息,并会在拒绝说明中注明)、输出超过上限、超过截止时间、非零退出、无法读取输出:均拒绝(以状态码 1 退出)。每次拒绝都会在partial中保留已读取的内容。找到的记录证明该转储开始时 logd 仍保留着这些记录,但不能证明窗口完整:捕获内容仅限 logd 实际交付的记录;任何读取方落后时,logd 都可能丢弃在清理过程中删除的记录(包括流式读取在内的所有 logd 读取方式都有此情况)。零行表示锚点通过筛选器(--app、--severity)后,logd 没有交付任何记录。以下时限各自独立:开始搜索时的每次转储最长 15 秒,且不得超过--duration;写入标记时(若有)最长 15 秒;每次轮询转储的截止时间为 5 秒加上距上次读取的秒数(约一个轮询间隔),最长 60 秒;同一次读取中的每次翻倍也适用相同限制;每 50 毫秒检查一次转储输出是否超过ANDROID_EMU_LOG_DUMP_CAP_BYTES(32 MiB)——这是检测阈值,而非最大值:adb 在此期间以及终止信号生效前写入的内容都可能使文件超出阈值,超出后绝不读取该文件;若转储在截止时间仍在运行,则再给 2 秒回收进程;每次作出判断前,以ANDROID_EMU_LOG_STATE_TIMEOUT(15 秒)为限执行adb get-state——若 adb 的 stderr 提到设备缺失,还会按其消息所列设备执行有时限的adb devices,以列出已连接设备。每次转储检查能证明 adb 仍能看到设备,但不能证明 logcat 在设备上正常工作;若 logcat 停止响应,则会依据转储本身的判断拒绝。其他所有 Android 版本仍像以前一样通过流式方式读取。只查询一次版本(getprop ro.build.version.sdk),且在--clear或任何其他 logcat 调用之前执行;若设备无法返回数字,则拒绝(以状态码 1 退出),而不是猜测应采用流式读取 - 流在记录中途结束时,绝不视为完整捕获:对于
--last转储,或自行结束的实时读取,会再次查询设备(设备消失:以状态码 3 退出),否则拒绝捕获(以状态码 1 退出)。仅当读取因自身的--duration或中断而停止时,才会丢弃正在到达的记录,并计数(statistics.cut_records、attribution.complete: false、stderr 提示,以及文本摘要中的INCOMPLETE行) - 按应用包名筛选:筛出设备确认属于该应用的各进程在其生命周期内产生、且由 logd 标记为该包名某个 uid 的记录。进程的 uid 与包名 uid 相同时,该进程才计入:包括
pidof列出的进程(通过ps检查其 uid),或 system_server 的Start proc/Killing记录中以该包名 uid 列出的进程(has died没有 uid,只能结束进程生命周期)。只计入 system_server 自身的记录(其 pid 由 logd 提供,并通过pidof system_server检查),而且必须是以记录消息开头的句子;system_server 或 uid 未知时,不计入。崩溃记录中的Process: <pkg>, PID: N不是证据:任何应用都可以写出这样的内容。因此,重启前发生的崩溃仍会保留在窗口中(JSON 中的app_pids和attribution会说明判定依据)。实时读取会暂存尚未确认的 pid 的包名 uid 记录;若之后确认该 pid 属于此应用,则保留这些记录(按输入顺序存储;启用--follow时标记为[held back]),最多ANDROID_EMU_LOG_PENDING_CAP(2000)行,并统计丢弃的记录数。若窗口中没有该应用的进程,则拒绝,而不会扩大到整个设备 - 当窗口不完整时会予以说明:对于 logd 标记为该包名 uid、但窗口中没有任何内容能证明进程属于该包名的记录,将予以排除、计数并列出(
attribution.unresolved_lines/unresolved_pids、attribution.complete: false、stderr 提示,以及文本摘要中的Attribution: INCOMPLETE行)。例如,进程在--last窗口开始前已启动,并在窗口结束前退出的情况,如太晚才读取崩溃记录(has died不包含 uid)。计数仅针对显示的内容;增大--last,将进程启动时间纳入窗口 - 实时读取(
--follow、--duration)从设备缓冲区末尾开始(logcat -T 1,会先输出最近一条记录的所有行),并读取之后发生的内容;以前会先重放整个缓冲区。要查看历史记录,请使用--last。实时读取开始时已运行的进程,从读取到的第一条记录开始计入;因此,重放缓冲区可能会把具有相同 pid 的其他进程的较早记录也交给它 - 设备不可用时绝不会静默捕获:所有模式都会先查询
adb get-state,自行结束的实时读取也会再次查询。(面向未连接设备的流式adb logcat会等待而非失败,因此以前--duration可能会报告零行。)设备不可用时以状态码 3 退出,与其他地方的行为一致 - 实时读取(
--follow、--duration)只会在停止时结束:达到--duration或发生中断;两者都会完成捕获。自行结束的读取——无论 adb 的退出状态如何,也无论是否截断——都无法证明它已观察到所请求的内容:若设备已消失,则以状态码 3 退出;否则拒绝(以状态码 1 退出),并保留已读取内容、错误中的行数以及 JSON 摘要(--json)中的partial。以前会将其视为捕获结果,并在 stderr 中提示 --last转储有独立的截止时间:ANDROID_EMU_ADB_TIMEOUT的两倍(默认 60 秒)。若转储届时仍未结束——例如首次查询后设备丢失(logcat -d也会等待设备),或流在记录中途停滞——则停止转储并再次查询设备(设备消失:以状态码 3 退出),否则拒绝转储(以状态码 1 退出),绝不将其报告为窗口内容。以前会一直等待,直到停滞结束。--follow(以及未指定模式标志时)会持续读取,直到收到中断- 身份依据是 uid(该包名在所有用户下的 uid)。如果另一个包名在同一个共享 uid 下运行,并被命名为此包名,则会计入此应用:设备无法区分它们
- 按严重性筛选:
error、warning、info、debug、verbose;其他名称(warn)会导致用法错误(以状态码 2 退出)。在主机上、完成归属判定后应用最低严重性级别(-B会忽略 filterspecs),因此--severity error仍能看到标记应用进程的记录 - 智能去重。在 JSON 中,
statistics.errors/statistics.warnings统计行数(E/F、W);statistics.unique_errors/unique_warnings统计不同消息的数量;顶层的errors/warnings是这些不同消息组成的列表,最多显示ANDROID_EMU_LOG_JSON_CAP(20)条 - 基于时长或跟踪模式。
--duration/--last必须带单位(30s、5m、1h);单独的数字会被拒绝,并作为用法错误处理(以状态码 2 退出) - 将日志保存到文件
- 选项:
--app、--serial、--severity、--follow、--duration、--output、--clear、--verbose、--json
- 读取 logd 的记录(
导航与交互(4 个脚本)
-
screen_mapper.py - 分析当前屏幕并列出可交互元素
- 列表是默认行为,不是一种模式:没有
--list。 直接运行命令会打印摘要;--verbose会展开为逐个元素的详细信息,--hints会添加导航建议。 - 默认输出会列出每个可交互控件的名称,每种类型占一行(
Button:、Control:、CheckBox:、EditText:……),并限制每种类型的数量。这些名称与navigator.py --find-text接受的名称完全一致,因此快速入门的第 2 步可以直接衔接第 3 步。以前这里只打印数量;在 Compose 屏幕上,名称也只有在使用--verbose/--json时才会显示。 - 没有自身文本的控件会使用从其子树或所在行中提取的标题命名;带有资源 ID 的控件则使用不带前缀的 ID 命名(
com.android.settings:id/search_action_bar会显示为search_action_bar),这也是navigator输出的名称,以及--find-id接受的名称。Compose 测试标签本身就不带前缀,因此两种工具包的命名方式一致。 --hints会打印下一条要运行的命令,并预先填好参数。- 选项:
--serial/-s、--verbose/-v、--hints、--json
- 列表是默认行为,不是一种模式:没有
-
navigator.py - 按语义查找并与元素交互
- 按文本、类型、资源 ID 查找
- 点击、输入文本、获取边界
- 支持模糊匹配
--find-text会匹配屏幕报告中打印的任意名称:元素自身的文本或 content-desc、为未标注控件恢复的说明文字,或资源 ID(可为简写或完整限定名称,按完整内容匹配)。如果匹配到说明文字,会解析到拥有该说明文字的控件——它所在的控件,或同一行中它旁边的控件——这样点击会落在复选框上,而不是其右侧 143px 处。所属控件必须可点击(可点击/可长按/可勾选):滚动容器虽然可交互,但它不是其中说明文字所指的对象;若解析到滚动容器,点击位置就会落在屏幕中央。如果名称只匹配到没有此类控件的被动标签,则会拒绝操作,不会点击。--tap和--enter-text必须指定目标:使用--find-text、--find-exact、--find-type、--find-id之一,或显式指定--tap-at x,y。否则会报用法错误(退出码 2),且不会向设备发送任何内容。--max-scrolls上限为 50(ANDROID_EMU_MAX_SCROLLS也一样);--scroll-to-find搜索也有墙钟时间限制(ANDROID_EMU_SCROLL_SEARCH_DEADLINE,默认 120s——参见--help),这也限制了屏幕转储的时间。每次滚动时都会向 stderr 打印一行进度信息。- 使用
--json时,成功的--tap/--enter-text会报告tapped_at: [x, y]——即实际发送到设备的坐标,调用方无需解析说明文字即可检查点击位置。所有失败都会使用统一的拒绝响应:使用--json时为{"success": false, "error": ..., "remedy": ...},否则在 stderr 上输出Error:/Remedy:(此前只在 stdout 上输出消息),退出码为 1;查找操作的拒绝响应会在对象中包含其search详情。用法错误使用相同对象,退出码为 2(参见 Known gaps 下的 Failures)。 --scroll-to-find会搜索首屏下方的内容。不使用此选项时,查找只会检查可见屏幕,而“未找到”无法区分“项目在下面两行”。默认路径现在会说明具体情况:(searched 1 screen; this screen scrolls -- retry with --scroll-to-find)。滚动需要显式启用是有意为之——滑动不等于读取,因此静默滚动的查找可能会让--find-text "Save is visible"匹配到三屏之外的按钮。- 选项:
--find-text、--find-type、--find-id、--tap、--enter-text、--list、--scroll-to-find、--scroll-direction {down,up}、--max-scrolls N、--serial、--json、--verbose
-
gesture.py - 执行滑动、滚动和长按
--scroll {up,down}表示内容移动的方向;--swipe表示手指的动作。二者方向相反。(混淆二者曾是一个实际缺陷:--scroll down会把列表拖回顶部,却报告成功。)- 定向滑动
- 自定义滑动坐标
- 滚动和长按
- 选项:
--swipe、--from-edge、--duration、--long-press、--scroll、--serial、--json
-
keyboard.py - 文本输入和硬件按钮
- 使用
--type输入文本。没有--text——该标志属于push_notification.py,在这里猜错会浪费一次操作。--delay N会逐个字符输入,适用于有防抖机制的字段。 - 按下特殊按键(
--key enter,可用--count N重复按下)和硬件 按钮(--button back);--keys a,b会按顺序按下按键。--key和--button共用一个名称表,因此两者都接受任一名称。 --clear N会删除 N 个字符;--hide-keyboard/--dismiss会收起 软键盘。两者都会先读取dumpsys input_method,仅当 IME 确实显示时才按 BACK——BACK 并非“隐藏键盘”按键;如果键盘未显示,按下它会离开当前屏幕。 如果没有键盘显示,则会报告“No keyboard shown”,不发送按键事件并以状态码 0 退出;如果无法读取 IME 状态,则以状态码 1 退出,而不会猜测。无论如何 都要按 BACK 时,请明确指定:--button back。(状态通过mInputShown读取,并以mIsInputViewShown作为备用值——这两者均已在 API 33 和 API 35 上测量。)- 选项:
--type、--delay、--key、--count、--keys、--button、--clear、--hide-keyboard、--dismiss、--serial/-s、--json
- 使用
测试与分析(4 个脚本)✓ 已完成
-
accessibility_audit.py ⭐ 新增 - WCAG 合规性检查
- 缺少内容描述:任何可操作控件(根据其 uiautomator 属性判定,而非类名)
若自身或其子树中没有描述文本,则属于严重问题。旧的类名门槛在 Compose
屏幕上完全不会触发,因为其中的控件都是普通的
android.view.View。 - 验证触控目标尺寸
- 检查 EditText 提示文本
- 图像无障碍检查
- 按严重程度分类(严重/警告/信息)
- 保存报告(JSON + Markdown)
- 选项:
--serial、--output、--verbose、--json
- 缺少内容描述:任何可操作控件(根据其 uiautomator 属性判定,而非类名)
若自身或其子树中没有描述文本,则属于严重问题。旧的类名门槛在 Compose
屏幕上完全不会触发,因为其中的控件都是普通的
-
visual_diff.py - 比较屏幕截图以检测视觉变化
- 逐像素精确比较
- 高亮显示差异
- 生成差异图像
- 用法:
visual_diff.py BASELINE CURRENT [--output DIR] [--threshold N] [--json] - 注意:两张图像是位置参数。
--json现在会输出完整报告,因此此脚本与 其他所有脚本保持相同的契约;对于在此功能加入前编写的调用方,--details仍作为已弃用的别名保留。 - 选项:
--output、--threshold、--json、--details
-
test_recorder.py - 逐步记录测试运行
- 基于会话,每个步骤运行一个进程——没有
--test-name、--output或--inline。--start NAME会开启一个会话并输出其 ID;之后每个--step "description"都会将当前屏幕截图保存到该会话中;--stop会写入report.md+manifest.json。--step和--stop默认针对最新会话,也可使用--session ID指定会话。 - 对同一会话中的命令逐个运行:不支持并发运行——
--step会读取会话的元数据, 并在截图后写回,因此如果--stop在此过程中执行,其结果可能会丢失。 - 每个步骤都会记录屏幕截图和 UI 层级结构,还可选记录
--screen、--state和--assert(--assert-failed会将其标记为失败)。 - 会话存储在
~/.android-emulator-skill下,而不是你指定的输出目录中:使用--list/--get-details ID检索,使用--clear [--older-than 24h]删除。 - 选项:
--start、--step、--stop、--list、--get-details、--clear、--session、--serial、--app-name、--size、--screen、--state、--assert、--assert-failed、--failed、--older-than、--verbose、--json
- 基于会话,每个步骤运行一个进程——没有
-
app_state_capture.py ⭐ 新增 - 完整的调试快照
- 捕获屏幕截图 + UI 层级结构 + 日志 + 应用信息
- 创建带时间戳的快照,默认存放在
~/.android-emulator-skill/cache/app-state-snapshots/下(绝不存放在工作 目录中);系统会打印快照路径,使用--json时该路径位于snapshot_path - 日志为应用自身的日志,从 logd 的记录(
logcat -B)中读取,规则与log_monitor --app相同:运行中 pid(通过ps检查 uid),以及每个 pid 对应的 system_server 自身的Start proc/Killing记录中带有包名 uid 的名称,且仅限该进程存续期间——被其他进程复用的 pid 在此期间之外不属于 应用;并且只纳入 logd 标记为包名 uid 的记录。重启前发生的崩溃会通过其Start proc记录保留。以下内容不能作为证据:has died(没有 uid;它只会 结束一个进程生命周期)、AndroidRuntime 的Process: <pkg>, PID:(任何应用 都能写入)、以及应用可能伪造的任何标签或消息行。错误和警告根据优先级列 统计,行仅按\n拆分;消息的行尾和终端控制字符会以转义形式保存(\r表示为\\r),因此app-logs.txt读回的行与统计的行一致。如果转储在一条记录 中途结束,或 60 秒内未完成,则会像log_monitor一样再次调用adb get-state:设备已断开时,以退出码 3 返回已接收的内容;设备仍连接时,截断 或超时会导致日志失败(退出码 1)。截断情况优先于 adb 的退出状态判断,因为 传输丢失也会使其返回 255。若时间窗口内应用没有进程,日志会失败,而不会 返回整个设备的日志 - 因缺少证据而未纳入的包名 uid 行(例如崩溃发生时,其启动记录早于
--logs) 会进行计数并列出:摘要中的logs.attribution(complete: false、unresolved_lines、unresolved_pids),--json下的log_attribution,summary.md中的一行、文本输出中的一行,以及 stderr 提示 - 如果捕获期间设备变得不可用,退出码为 3,与其他地方的行为一致;已收集到的
内容会放在
partial下 - 一体化调试产物
- 选项:
--package、--output、--serial、--logs、--no-logs、--log-lines、--screenshot-size、--json
高级测试与权限(3 个脚本)✓ 已完成
-
privacy_manager.py ⭐ 新增 - 应用权限管理
- 授予/撤销权限
- 列出应用权限
- 支持 20 多种权限类型
- 批量操作:
--grant/--revoke/--reset接受逗号分隔的列表,并返回一个 对象{"success", "action", "items"}(以及test_trail);每个权限对应一项, 包含permission、message、success、error、remedy;任一项失败时,顶层 返回error/remedy并以退出码 1 结束;设备断开时以退出码 3 结束(参见 失败)。**v0.9.0 中存在破坏性变更:**此前格式为{"action", "results"} --user <id>针对一个 Android 用户(次要用户或工作资料)执行操作:该 id 会传入pm grant/revoke --user,回读和--list则读取dumpsys package中该用户的User <id>:部分(位于包名下,或共享 uid 应用的Shared users:下)。 不指定时,两者都使用用户 0。id 必须是pm list users列出的名称之一(授予 不存在的用户权限会静默地以退出码 0 结束,因此会先检查);拒绝--user all/current。如果应用未安装给该用户,会提示使用pm install-existing --user <id>进行修复;如果回读时没有该用户对应的部分,则会失败,而不是读取其他用户的状态。- 选项:
--grant、--revoke、--reset、--list、--package、--user、--serial、--list-permissions、--json
-
status_bar.py ⭐ NEW - 状态栏控制
- 设置电池电量和充电状态
- 设置 WiFi/移动网络信号强度,或隐藏任一图标:
--wifi hide、--mobile hide(单独使用--wifi/--mobile可显示图标) - 设置时间显示(确保截图一致):
--time接受H:MM或HH:MM(9:41会以0941的形式传给 SystemUI);其他格式均不接受 - 支持演示模式
- 选项:
--preset、--battery、--charging、--wifi、--mobile、--time、--reset、--serial、--json - 每项请求的操作各对应一项(参见 Failures):
{"success", "items"},每项包含operation、message、success、error、remedy。**v0.9.0 中的破坏性变更:**此前格式为{"results": [...]} - 以上操作都会驱动 SystemUI 演示模式,改变状态栏的绘制内容。
不会改变应用从系统读取的信息(
BatteryManager仍会报告实际电量)。
-
push_notification.py - 向通知栏发布通知,并读取已发布的通知
- v0.6.0 起已重新限定功能范围,以符合 adb 实际能做到的事。它不再声称
可以发送应用的通知:
--post通过cmd notification post发布通知, 结果归属 com.android.shell,所在频道为shell_cmd, 而非被测应用。因此不会触发应用自身的频道、接收器和通知渲染。 但仍可用于驱动 NotificationListenerService、通知栏 UI,或响应通知的 Agent。--tag是标题和键,--text是正文。 --list会读取实际发布的通知,不限所属包——用于检查通知是否存在, 而不是仅确认命令是否以 0 退出。--expect-package PKG会将该读取结果转换为退出状态。- 发布、读取或更改权限失败时,均返回通用拒绝结果
(使用
--json时为{"success": false, "error": ..., "remedy": ...}, 并附带发现的信息),退出码为 1;adb 无法连接设备时退出码为 3 --grant-permission/--revoke-permission可为--package指定的 包切换POST_NOTIFICATIONS(API 33+),用于测试运行时权限流程。 更改结果会从dumpsys package的User 0:部分读取确认,规则与privacy_manager相同:如果没有该部分,或其中显示installed=false(即使授权仍显示granted=true),则拒绝操作,并提供pm install-existing --user 0 <pkg>作为解决方法。- 选项:
--post、--list、--grant-permission、--revoke-permission、--tag、--text、--no-verify、--expect-package、--package、--serial、--json、--verbose - ⚠️ 无法将通知交给应用自身的 FCM 处理器——adb 没有可用路径,因为
c2dm 接收器受一项权限保护,该权限由 Play services 持有,而非 shell 用户。
请改用 FCM 发送。也无法列出频道:
cmd notification没有list channels子命令,因此旧版--list-channels一直无法使用。
- v0.6.0 起已重新限定功能范围,以符合 adb 实际能做到的事。它不再声称
可以发送应用的通知:
发现与设备状态(14 个脚本)⭐ NEW
-
android_health_check.sh - 检查环境(ANDROID_HOME、adb、emulator、avdmanager、sdkmanager、java、Python 3.12+、Pillow);列出已连接的设备和 AVD。如果缺少 adb 或 JDK 版本低于 17,则以非零状态退出。快速入门步骤 0。
- JDK 最低版本为 17(AGP 8 无法在更低版本上运行 Gradle)。主版本号
从
java -version(两个输出流)中以java version "或openjdk version "开头的第一行读取,而非从第一行读取, 因为启动提示(例如Picked up JAVA_TOOL_OPTIONS: ...)可能出现在版本横幅之前;也包括 Java 9 之前的"1.8.0_392"格式(主版本号为 8);--json会将其作为java_major返回;如果缺少 java 或其版本横幅中没有可读取的版本号,则返回null(这种情况会发出警告,而非判定为失败)。 - 每项探测都有 10 秒超时,包括 Python 版本和 Pillow 探测。如果 PATH 中的
python3无法运行,会发出相应警告,而不会显示“检测到 Python 0.0” --json会在 stdout 输出一个对象(进度信息写入 stderr)。如果python3无法对其编码,则由 Bash 编码器处理;如果报告完全无法输出(没有临时目录,或检查记录无法 写入或读回),则以状态码 1 退出,并输出一行{"success": false, "error": ..., "remedy": ...}——stdout 绝不会为空- Python 可运行时,会将编码器 stdout 解析为包含预期键的完整报告; 没有 Python 时,Bash 守卫会确保顶层对象的花括号配对。使用 Bash 后备方案前,会丢弃启动钩子输出的拼接内容
- 选项:
--json、--help
- JDK 最低版本为 17(AGP 8 无法在更低版本上运行 Gradle)。主版本号
从
-
device_list.py - 渐进式展示已连接的设备(
adb devices -l)和已定义的 AVD(emulator -list-avds)。- 以状态码 0 退出但清单为空,表示工具已运行且未发现任何内容。缺少或无法运行的
adb或emulator会以状态码 1 退出,并提供补救方法 (使用--json时为{"success": false, "error": ..., "remedy": ...})。avdmanager是例外:它只会补充目标/ABI 详情,因此缺少时会在 stderr 发出警告(并写入 JSON 中的warnings),而非判定为失败 - 选项:
--get-details、--device-type/--name、--json
- 以状态码 0 退出但清单为空,表示工具已运行且未发现任何内容。缺少或无法运行的
-
emulator_selector.py - 推荐最佳 AVD(按运行中 → 最近使用 → API 版本最新 → 常见机型排序);列出或启动 AVD。
- 主机没有 AVD 时,不会推荐任何对象,并以状态码 0 退出;缺少或无法运行的
emulator二进制文件会以状态码 1 退出并提供补救方法,而非返回空排名 (使用--json时为{"success": false, "error": ..., "remedy": ...});被拒绝的--boot操作也会如此 - 无法报告自身对应哪个 AVD 的模拟器不会获得“当前正在运行”的
加分,但仍会连同其状态列出(使用
--json时列于unidentified_emulators,否则在 stderr 发出警告),以免排名声称自身信息完整。如果无法列出模拟器,排名仍会继续——磁盘上的 AVD 确实存在——并会对此作出说明 (使用--json时列于warnings)。AVD 的config.ini无法读取时也同样处理:该 AVD 仍会参与排名,但不包含其 API 级别和设备信息,并会在warnings中列出(同时在 stderr 发出警告),且附上尝试读取的路径 - 选项:
--suggest、--list、--boot NAME、--headless、--count、--json、--verbose
- 主机没有 AVD 时,不会推荐任何对象,并以状态码 0 退出;缺少或无法运行的
-
localization_audit.py - 审计
res/values*/strings.xml,检查各语言区域是否缺少键,以及占位符是否不匹配;可选与源文件交叉引用。- 审计范围为空时应拒绝执行(退出码为 1),绝不能显示“未发现问题”:例如,
--res指向的目录中没有values/strings.xml(通常是路径少了一层,如app/src/main,而实际路径是app/src/main/res),或指定的--locale代码在该目录下没有对应集合(不存在values-<code>/和values-<code>-*/下的strings.xml;拒绝执行时会列出可接受的代码)。--locale de会选择所有德语集合,包括values-de-land和values-de-port;--locale default会选择默认语言的覆盖项。发现问题时,--strict仍以退出码 2 退出 - 只有符合 Android 语法的语言区域限定符才表示翻译(可选的
mcc/mnc,后接fr、fr-rCA或b+sr+Latn;后面还可跟其他限定符)。不含语言区域的values-*目录(values-night、values-sw600dp、values-v31、values-land),或与普通目录并存的语言区域限定目录(values-fr旁边的values-fr-night),都属于覆盖项:不会检查其是否缺少键,但会将其提供的每个字符串与values/比较(发现项标记为default (night)、fr (night)),并在skipped中列出及说明原因。没有普通目录的语言区域会按配置分别审计,每个配置都与values/比较(de (land)、de (port)),绝不会合并审计 - 使用
--json时,每个经审计的集合(locales)和每项发现(missing_keys、placeholder_mismatches)都会以三种方式标识其集合:locale,即--locale接受的代码(de、fr-rCA、b+sr+Latn;默认语言的覆盖项则为default);qualifiers,即目录中的其他限定符(land、night),没有则为 null;以及label,即文本输出中使用的显示字符串(de (land)、default (night)) - 选项:
--res DIR、--source DIR、--locale CODE、--strict、--json、--verbose
- 审计范围为空时应拒绝执行(退出码为 1),绝不能显示“未发现问题”:例如,
-
appearance.py - 控制深色/浅色模式(
cmd uimode night)和字体缩放(settings put system font_scale);尽力设置语言区域。- 选项:
--theme {light,dark}、--text-size {small,default,large,xl}、--font-scale、--locale、--reset、--serial、--json - 每项请求的更改对应一个条目(参见 Failures):
{"success", "items"},每项包含operation、message、success、error、remedy。**v0.9.0 中的重大变更:**之前使用的是{"results": [...]}
- 选项:
-
location.py - 通过
adb emu geo fix在模拟器上模拟 GPS(固定坐标、城市预设、GPX 路线回放)。仅限模拟器。- 选项:
--lat/--lng、--city、--gpx FILE、--interval/--speed、--clear、--list-cities、--serial、--json
- 选项:
-
container.py ⭐ NEW - 通过
adb shell run-as检查可调试应用的沙箱(对发布版应用会明确报错)。- 列出/读取文件、转储
shared_prefsXML、列出数据库并转储 SQLite 架构(Room == SQLite)、导出快照 - 文件名来自应用,因此不可信。目录通过
find -H <dir> -maxdepth 1 -printf列出(toybox;在 0.8.11、Android 15 上测得):每个条目包含六个以 NUL 结尾的字段——模式、所有者、组、大小、符号链接目标、名称——按字节读取。NUL 是名称无法包含的唯一字节,因此即使名称中含有换行符、CR 或整行伪造内容,也仍是一个名称:它无法再像通过ls -la那样添加或隐藏条目。目录自身的记录排在第一位,因此路径若是文件,会被拒绝为非目录;不是完整记录的响应(空响应、被截断或来自其他工具)会被整体拒绝——绝不会当作空目录读取,也绝不会改用ls重试。逐条拒绝以下内容:非 UTF-8 或非普通文件名的名称(/、\、NUL、CR、LF、./..),find未输出的字段,以及为空、非 UTF-8 或包含 LF、CR、NUL 的符号链接目标。被拒绝的条目会计入rejected,并在rejected_entries中以转义形式列出(--verbose会打印这些条目);响应仍显示可读取的内容,并以 1 退出——即使全部被拒绝,也绝不会显示“No shared_prefs found” - 此列表有两项限制。完整性仅取决于 adb 的退出状态:记录不包含结束标记,因此若响应在完整记录后被截断且退出状态为 0,读取结果会是条目较少的目录(adb 或 find 报告截断、退出状态非零时会被拒绝)。并且,
-H会跟随指定路径自身的符号链接,指向应用 uid 可搜索的任意目录,无论是否位于其数据目录内——例如,若应用将x链接到其他位置,--ls files/x会列出该目录的名称和模式。列出目录不会读取文件内容;--export读取列表中的文件时也会跟随其符号链接,且不会跳过 FIFO 或其他特殊文件(与之前的列表行为相同;v0.9.0) - 需要 adb 1.0.36 或更高版本(platform-tools 24+),首次读取文件前会通过
adb version检查一次:较旧的客户端不会拒绝shell -T,而是将其转发给设备,并在报错后以 0 退出,因此读取时会把错误当作文件内容。较旧版本或不含版本号的横幅会被拒绝(退出码 1),且不会读取或写入任何内容 - 文件通过
adb shell -T读取,因此读取失败即为失败:对缺失文件执行--cat会以 1 退出(通过exec-out时,错误文本会作为内容返回)。--cat会读取整个文件,并将输出限制在ANDROID_EMU_CONTAINER_CAT_MAX_BYTES(64 KiB),截断位置按字符处理:若 UTF-8 文件的输出上限落在某个字符中间,结果仍为text,结尾为最后一个完整字符,并设置truncated: true;截断前出现任何无效字节,或整个文件以不完整字符结尾,则结果为binary。--export会将无法读取的列表文件计入unread/unread_entries,并以 1 退出;若名称会使文件落在快照目录之外,则会拒绝(退出码 1,不写入任何内容) - 只有当数据目录中存在
shared_prefs/和databases/时,--export才会列出它们。数据目录中不存在的目录会列在absent中——表示应用没有该目录、退出码为 0 的完整导出,且databases.json会显示"absent": true——但前提是该目录列表未拒绝任何条目:被拒绝的条目可能就是该目录,因此会先拒绝导出(presence_unknown,退出码 1,不写入任何内容)。 若数据目录中存在某目录,但其自身列表失败(无法读取,或已不存在),则会将其列入unlisted/unlisted_dirs,并附上设备给出的原因;不会为该目录写入任何内容,导出以 1 退出,并指出已写入的内容及位置(以前列表失败会被当作空目录,且以 0 退出) - 检查失败或不完整时,会采用通用拒绝格式(
--json下为{"success": false, "error": ..., "remedy": ...},并包含已读取的内容;run-as 拒绝时的提示会作为 remedy)。没有 adb、发生挂起或设备不存在时,会返回 adb 自身的错误(设备错误以 3 退出),而非在退出码为 1 时显示“the command failed” - run-as 拒绝(调试版应用的解决方法)仅指 run-as 自身打印的拒绝信息:非零退出时,stderr 的第一行就是设备对此软件包打印的完整拒绝信息(
run-as: package not debuggable: <pkg>、package not an application、unknown package;均有记录);且一次有界的run-as <pkg> id检查(不指定任何文件)不会以应用身份运行。此检查期间的设备错误属于设备错误(退出码 3)。run-as 已运行后设备拒绝的路径——例如find: <dir>: Permission denied、缺失文件、名称中包含换行符且自身带有run-as: …行的文件——属于该路径的失败,其解决方法是执行adb shell run-as <pkg> ls -ld <path>检查。若列表在某个条目处失败——例如目录有 r 权限位但没有 x 权限位时出现find: '<dir>/<entry>': Permission denied——会指出失败的具体条目(名称经转义),并说明目录需要这两个权限位,而不是说目录本身遭到拒绝。若 find 列表操作在未指定路径的情况下被拒绝(例如此 toybox 对不支持的选项或转义序列返回find: bad arg …、find: bad -printf …),这是 find 的失败,而非目录的失败:解决方法会说明设备的 find 无法以此方式列出目录。较旧的 toybox 未经测试。较旧版本的拒绝措辞没有记录,读取时会按失败处理 - 选项:
--package、--ls [SUBPATH]、--cat FILE、--shared-prefs [NAME]、--databases [NAME]、--export DIR、--serial、--json
- 列出/读取文件、转储
-
model_inspector.py ⭐ 新增 - 检查 Android 持久化数据:源代码中的 Room 注解、Room 导出的架构 JSON,以及通过
run-as获取的实时 SQLite 架构(Room == SQLite)。- 实时模式会使用
adb shell -T(common/run_as.py)读取数据库,且与 container.py 一样,先检查 adb 1.0.36。它之前使用adb exec-out,在文件缺失时会以 0 退出,并将错误文本作为字节返回,因此不存在的数据库会被报告为“不是有效的 SQLite 数据库”;现在会返回设备给出的原因,退出码为 1。 - 所有模式下的失败都会使用通用拒绝格式(
--json下为{"success": false, "error": ..., "remedy": ...};run-as 拒绝时的提示就是 remedy)。 - common/run_as.py 会根据 container.py 使用的唯一规则判断 run-as 是否被拒绝:stderr 的第一行必须与设备针对此软件包打印的完整拒绝信息一致(
run-as: package not debuggable: <pkg>、package not an application、unknown package;均已记录),且执行一次有界的run-as <pkg> id后不能以应用身份运行。不可读取的databases/(find: <dir>: Permission denied),或名称中包含换行符或回车符、且有一行run-as: …的数据库,都会作为相应路径的失败处理;其 remedy 是检查ls -ld,而不是“安装调试版”。 - 实时模式会像 container.py 列出目录一样列出
databases/(common/run_as.py:toybox find、以 NUL 结尾的字段、按字节读取)。之前在文本模式下使用ls -1,会在退出码为 0 时将x\ry.db当作两个数据库x和y.db;之后又改为使用ls -la并按换行符拆分,导致fixture.db+ LF + 一整行ls -la输出被列为ghost.db,尽管应用并没有这个数据库,且退出码为 0。现在,这两种名称都会被视为单个名称、拒绝并列入rejected_entries;列出结果时退出码为 1,并显示读取到的内容(databases (0, 1 refused),绝不会显示“未找到数据库”)。 - 选项:
--source DIR、--schema PATH、--show-versions、--raw NAME、--package、--db NAME、--serial、--json、--verbose
- 实时模式会使用
-
anr_watcher.py ⭐ 新增 - 通过 logcat 记录并汇总 Android ANR/卡顿(Choreographer 跳帧和 ActivityManager ANR),采用基于会话的渐进式信息披露。
- 会话模式:
--start [--package PKG]→ id(纯文本形式为不带修饰的 id,可用于SID=$(...);使用--json时为{"success": true, "session_id": ...});--stop ID [--abandon]→ 精简摘要;--get-details ID [--cluster N];--list-sessions;--clear-sessions;--diff A B - 会话生命周期会经过串行化处理(
common/anr_sessions.py):pending→(工作进程认领)running⇄deciding→stopped|crashed,或pending→cancelled(--stop或放弃等待工作进程的--start),或running/deciding→abandoned(--stop --abandon)。每次读取-修改-写入会话的meta.json和summary.json都会在其锁下进行(使用flock锁定anr-sessions/locks/<session>.lock,锁位于会话目录之外,因此 删除会话时不会解除某个进程正持有的锁;每个打开者先获取locks/.base,拿到会话锁后即释放它;等待有时限,持有锁没有时限, 超过等待时限会拒绝操作,退出码为 1,并提示“请再次运行相同命令”), 每次状态转换仅由一个所有者执行,且只能从其预期状态转换——否则 操作会被拒绝,不会应用;写入会先保存到各自的临时文件,再重命名到 目标位置。工作进程拥有其认领、deciding、计数器和结束状态,并会在 退出前发布这些信息(收到停止请求、设备断开、重启次数耗尽、流中断、 无法预见的错误;此时 SIGTERM 仅会请求停止):一次写入stopped, 或写入crashed及其故障信息——除非它在 5 分钟内无法获取锁,或会话 已被删除;此时--stop会发现工作进程已消失但未发布结束状态,而--abandon是退出办法。--list-sessions和--get-details也会像--stop一样判断工作进程 (依据其令牌和记录的启动时间,而非 pid 是否存活):列表保留已持久化的status(running/deciding),并在 JSON 中添加"unpublished_end": true和unpublished_end_reason(其他会话均为false/null);文本中显示gone和worker gone, capture end unpublished: <why>;对这类没有摘要的会话执行--get-details会拒绝 操作(退出码为 1),并指出应运行--stop ID --abandon——它曾提示Run --stop ID first,但该--stop会拒绝操作。CLI 拥有cancelled、abandoned和summary.json。events.jsonl不加锁: 工作进程读取时会向其中追加内容。认领被拒绝的工作进程——会话已取消、 已被认领或令牌不匹配——退出时不写入任何内容;会话被放弃或删除的工作 进程会在下一次写入 meta 时停止(流安静时最多每 5 秒写入一次计数器, 锁忙时会跳过,或在下一次状态转换时停止),在此之前可能继续追加事件; 会话的结果会忽略这些事件(见下文)。锁需要支持flock的文件系统, 即本地文件系统;位于网络文件系统上的主目录只有在服务器和挂载选项支持 时才具备串行化能力;从另一个进程识别工作进程需要使用ps。较早版本 的工作进程(meta 中没有lifecycle的会话)不会获取这些锁:记录的 pid 可能是该工作进程,--stop不会信任会话提供的任何信息——它会发送 信号并等待进程消失;只要进程仍在运行或无法识别,就会拒绝操作(退出码 为 1),--abandon也不例外,因为它无法隔离该进程;--get-details和--diff也会拒绝这类会话(退出码为 1,并指出 pid:它们曾从可能仍 会被旧工作进程修改的快照中返回 0);--list-sessions会将其标为provisional(JSON 中为"provisional": true和provisional_reason, 文本中会注明provisional:)。之所以拒绝,而不是给出 provisional 结果,是因为 Agent 会根据退出码为 0 的结果采取行动。四次审查逐次关闭了 一个时序漏洞:读取器的流结束宽限期内收到停止请求,会跳过判定(设备断开 却返回退出码 0);--stop在工作进程尚未发布故障时返回退出码 0,覆盖 了工作进程已持久化的 meta,并为另一个进程记录了未记录的启动时间;工作 进程认领了--stop已经返回结果的会话;--start将已启动会话在running状态下发布的故障当成启动失败;第五次审查发现系统信任stoppedmeta,但该版本的工作进程仍在运行(返回退出码 0,之后才持久化 设备断开),并发现已放弃会话的结果会随放弃后追加的每个事件而增长 - 清理操作只会移除已结束的会话。 TTL
(
ANDROID_EMU_ANR_SESSION_TTL_HOURS) 和总容量上限 (ANDROID_EMU_ANR_TOTAL_CAP_MB) 都会在每次--start时应用;--clear-sessions也会在会话各自的锁下移除stopped、crashed、cancelled或abandoned状态的会话;若另一个命令持有该锁则跳过, 绝不移除pending、running或deciding状态的会话,也不移除无法 确认工作进程已结束的较早版本会话;绝不单凭时间清理。没有meta.json且锁空闲的会话目录,是创建过程中途失败留下的目录,会被移除。--start会在会话锁下创建会话,从目录创建之前一直持有到 meta 文件创建完成,因而 并发执行的清理不会将其误判为可移除会话。--start在工作进程认领前 崩溃的pending会话会保留,直到--stop将其取消;工作进程没有说明 结束方式便已崩溃的running会话会保留,直到--stop --abandon。 只有名称符合--start命名格式(anr-YYYYMMDD-HHMMSS-XXXX)的目录 才会被这些操作或--list-sessions视为会话;locks/和其中保留的 其他内容都不会动。容量上限尽力执行:若不能移除的会话总容量本身已超过 上限,--start仍会启动,并向 stderr 输出警告 (warning: ANR sessions hold N bytes over ANDROID_EMU_ANR_TOTAL_CAP_MB (M MB): the K session(s) left have not ended, or another command holds them, ...);stdout 仍只输出 id - 未知会话、尚无摘要的会话或超出范围的
--cluster都会以退出码 1 退出, 并说明应改为运行什么命令(--json下为拒绝操作对象)——不会只打印句子 后以退出码 0 退出 --older-than接受30s/5m/24h/7d;会话 id 格式为anr-YYYYMMDD-HHMMSS-XXXX。两者格式错误时,对于所有接受它们的模式 (包括--stop)均属于用法错误(退出码 2);若--clear-sessions遇到无效参数,则不会删除任何内容- 每个读取器——
--watch、--since和会话工作进程——都读取 logd 的 记录(logcat -B、common/logcat.py),绝不读取文本:每条记录都带有 logd 从写入套接字获取的 pid 和 uid,ANR 报告则以一条完整记录的形式 到达。每个事件都会保留该 uid(--watch/--since的 JSON 和会话的events.jsonl中为"uid";--get-details --cluster N中为uid=); 较早版本写入的事件没有该字段,但仍可加载 - 根据日志所能确认的范围,每起事件只计为一次 ANR。ActivityManager
会将一条 ANR 记录写成一条包含多行的记录;其中的
PID:(卡住的应用 自身的 pid——记录的 pid 是 system_server 的)和Reason:(超时原因) 会作为事件的app_pid和reason提供,而不会被当成额外的 ANR;读取 到该记录后就会输出事件。WindowManager 的ANR in Window{<hash> u0 <title>记录是发往 ActivityManager 的报告: 若 ActivityManager 在 10 秒内(按日志时间,前后均可)通过 hash 指出 同一个 WINDOW,则丢弃该记录——无论是输入 ANR 报告的Reason:(记录 时间比 WindowManager 晚 0.46 秒),还是Skipping duplicate ANR判定 (记录时间比广播 ANR 晚,后者自己的报告没有指出窗口)。否则,它会作为 独立事件处理——例如两条记录之间流被截断,或 ActivityManager 提前返回。 系统服务器的日志累计 10 秒或静默 10 秒后,或流结束时,就会输出该事件, 因而它可能晚于后续事件输出。如果 10 秒内可能会有 ActivityManager 对 无窗口(广播)ANR 的报告与之对应,则会保留该事件并标记possible_duplicate(会话会计入possible_duplicates,并输出Possible duplicate:行):只有窗口标题能将两者关联,而据此定案的 标题会导致任何使用该标题的独立 ANR 消失。只有 system_server 的记录 会推进该时钟:记录时间属于写入者;曾有应用的一条记录比当前时间早 一分钟,提前释放了待定报告,导致同一 ANR 被计数两次。若 ActivityManager 的记录比 WindowManager 报告晚超过 10 秒,仍会计数两次 - 窗口不会指明包名。
Window{…}中 hash 后的内容是窗口的 TITLE,由应用设置(activity 的默认值为其组件名,录制内容也显示如此)—— 标题为com.example.victim/…会被误认为该应用的 ANR,标题为Skipped 80 frames!则会将报告误判为卡顿。标题是显示文本:不会对其中 的任何内容进行分类或据此判定;如果只有 WindowManager 报告 ANR,则它是 某个窗口的 ANR——anr_package: null、identity: "window",标题保留为window,不会从中提取符号 - ActivityManager 指出的是进程,而非包。
ANR in <name>是卡住进程 的名称,而应用可以将进程命名为任意包名(android:process;录制内容中 fixture 应用自身卡住时,报告为ANR in com.example.victim)。因此, 只有当PID:被确认是在以该包的某个 uid 运行时,ANR 才会归入该包 ("attribution": "verified"):确认方式是读取内容中 system_server 较早记录的Start proc <pid>:<name>/<uid>;若没有该记录,则通过一次 有时限的ps -o PID,UID -p <pid>查询。若 pid 自 ANR 写入后被复用, 此查询无法证明当时是谁持有该 pid。否则,该 ANR 归属于某个进程:anr_package: null、identity: "process",名称保留为process_name,attribution为unverified或contradicted。如果 进程已不存在,且读取期间没有启动记录,则该 ANR 不会归属到任何对象 --diff不会比较任一种此类事件(两种都会列在not_comparable下; 应用 ANR 只有这种事件可与之对应时也一样),--package则会丢弃两者—— 计入会话的unattributed_anrs(文本中为Unattributed:行),并由--watch/--since在 stderr 中说明。文本中每条此类行也会注明原因 ([unverified: process <name> names no verified package]、[an ANR of a window: its title names no package]、[possible duplicate of the ANR at <time>]);--get-details --cluster N(使用已保存的attribution)和--since摘要 (“ANR/卡顿事件:2 个,其中可能重复 1 个;未验证 1 个”)也会如此- 只有 system_server 写入的 ANR 报告才会计数:
ANR in记录和 ActivityManager 的Skipping duplicate ANR判定必须带有完全匹配的 tag、以该内容开启记录,并由 system_server 写入——通过将记录的 pid 与pidof system_server检查(每种模式只查询一次,时限为 3 秒)。任何 应用都能使用这些 tag 写日志,甚至可以伪造另一个包的 ANR,或让真实 ANR 消失;文本模式下,应用 tag 中的换行符曾打印出一整行以 system_server 的 pid 开头(记录为logcat_tag_injection),但该记录 实际属于应用。如果无法确定 system_server 的 pid,则不计入任何内容,并 会明确说明:会话摘要包含"verification": "system_server pid unknown — ANR records could not be authenticated"和unverified_anr_lines;文本 中会输出Unverified:行(“没有已验证的 ANR/卡顿”);--watch/--since会在 stderr 输出warning:(文本中还会显示“没有已验证的 ANR/卡顿事件”) - 卡顿按进程聚类,而非按掉帧数聚类:同一个屏幕掉帧 31 次和 120 次仍属
一个聚类,两个应用出现相同卡顿则属于两个聚类。会话会为卡顿行标明其
进程,并以进程名作为键(
identity: "package"),因此应用中途重启仍 属于同一位置。进程名仅取自同一流中较早的Start proc <pid>:<name>/<uid>记录,由common/log_lifecycle.py读取——与log_monitor --app使用相同的读取逻辑:记录必须由 system_server 写入(通过记录的 pid 与pidof system_server比对,只向设备查询一次)、 tag 必须与 ActivityManager 完全一致、记录内容必须以精确的开头格式起始, 且进程必须恰好是某个包并以其 uid 运行(每个包查询一次cmd package list packages -U)——任何应用都能使用 ActivityManager 的 tag 写日志,也能将自身进程命名为其他包名。不会命名私有进程 (<package>:<name>)。每次查询都有 3 秒时限;未知结果不会命名任何 对象。system_server 为该 pid 记录Killing/has died时,该名称即 失效;每次流(重新)启动都会清空所有名称。绝不查询设备以确定当前哪个 进程持有某个 pid:ps … ELAPSED是根据读取日志行时的时间进行判断, 而延迟读取的日志行曾被错误分配给复用 pid 的新进程。因此,只有会话 运行期间启动的进程才会被命名:调用--start时已经运行的应用 (在后台预先启动)会按 pid 作为键。进程名只是标签:使用--package时, 哪个应用产生了卡顿或 StrictMode 记录由其 uid 判定(见下文),与是否 命名无关。无法命名进程的卡顿以 pid 为键(identity: "pid")——除非--package通过 uid 将其归属到某个包,此时以包名为键;摘要会列出这些 pid——"unnamed_pids": {"count": N, "pids": [...]},文本中有一条Unnamed:说明;--diff会将按 pid 键控的聚类列在not_comparable下,而不称其为已解决或新增;若有名称的聚类在另一个会话中唯一可能的 对应项,是同类型且文本相同的按 pid 键控聚类,也会如此处理 --stop只有在确认记录的 pid 当前仍属于会话工作进程时才会向其发送信号:--start放在该进程命令行中的随机令牌,以及它认领该 pid 时由ps提供的启动时间。绝不会向持有该 pid 的其他进程发送信号。如果某个存活的 pid 既无法确认属于该工作进程,也无法确认不属于它——ps无法判断、 信号被拒绝,或认领时没有记录启动时间(当时ps未返回结果,不能证明 后来的进程)——就不会将其视为已结束的工作进程:--stop会拒绝操作 (退出码为 1,并说明 pid 和原因),不保存任何内容,也会保留该 pid, 供之后再次执行--stop- 只有会话结束后,
--stop才会返回摘要:工作进程已发布stopped或crashed,或会话已取消或放弃。它会等待收到信号的工作进程发布结束状态 (最多 5.5 秒:包括进行中的查询、排空流和额外宽限时间;若工作进程正在 判定流结束方式,则还要等到该判定的时限结束,从开始判定到结束约 31 秒)。 流的结束方式会在流结束时确定,因此读取器处于流结束宽限期时到达的停止 请求不再会跳过对流结束方式的判定。等待结束时若工作进程仍在运行,则 拒绝操作(退出码为 1:“尚未完成”,请再次运行--stop);若工作进程已 消失却未说明采集如何结束——可能被杀死,或未能说明原因便已崩溃,因此 无法确定流是否故障(退出码为 1)——也会拒绝操作;两种情况下都不会保存 内容:曾有摘要先保存并以退出码 0 返回,之后工作进程却持久化了设备断开。 当此版本的工作进程永远不会说明结束方式时(已消失、卡住或 pid 无法识别), 可通过--stop --abandon退出;如果较早版本的工作进程可能仍在运行,则 不能这么做(见下文):会话会标记为abandoned——此后工作进程的 meta 写入都不会生效——并将已读取内容保存为"incomplete": true,原因以 “abandoned with --abandon”开头,退出码为 0。在隔离措施生效后,工作 进程仍可能向原始事件文件追加行(该文件没有加锁);放弃操作会记录events.jsonl中当时已有的完整行数量,而会话的所有结果——--stop、--get-details、--cluster、--raw——都只读取这些内容,因此之后再次 执行--stop会得到相同结果(它曾从不断增长的文件重建摘要:事件数先为 1,后为 2)。它绝不会声称结果完整。尚无工作进程认领的pending会话 会被取消:--stop会保存标记为不完整的空摘要并拒绝操作(退出码为 1), 之后认领它的工作进程也会被拒绝。对已结束会话执行两次--stop会得到 相同结果;重复执行--stop会保留首次执行时所述的缺失内容。只要采集 可能缺少事件,摘要就会保存为"incomplete": true,并通过incomplete_reason说明原因:流中断或自行结束且没有重启;停止时有记录 被截断(cut_records);流终止并重启(stream_restarts:-T 1只会重放流停止期间记录的最新一条记录);ANR 报告无法通过身份验证;或 会话被放弃或取消——因此--diff不会根据此类采集缺少的内容作出结论。 流发生故障的会话也会被拒绝操作(见下文):摘要会保存,--stop以退出码 3 或 1 退出。所有不完整摘要的文本形式也会说明这一点(--stop、--get-details、--get-details --cluster N,后者的 JSON 包含incomplete);其中的“无 ANR/卡顿”会改为“已采集部分中无 ANR/卡顿”。 令牌预算或--terse只会截断聚类信息,不会省略说明:单行形式会将其 保留为方括号中的附注([流中断;停止时截断 1 条记录;1 次流重启造成数据缺口;2 条 ANR 记录未验证;1 个 pid 未命名;1 条卡顿/StrictMode 记录因未命名而丢弃;无 uid 归属:未知包 uid;uid 与 2 个包共享;1 条孤立卡顿/StrictMode 记录未计入;1 条记录可能早于会话,未计入]) --diff绝不会对无法查看的内容说“没有变化”:若某些聚类被列为不可比较, 或会话保存为不完整,且没有其他变化,结论会为inconclusive: …并说明 原因;若结论发现了变化,也会在其后附上相同的注意事项。某个聚类存在于 一个会话中,但在另一个保存为不完整的会话中不存在时,绝不会被称为已解决 或新增——缺失事件可能只是尚未写入;它会列在unconfirmed下,并指出 它所在的会话以及它在哪个会话中absent_from;完整会话中缺少的内容仍会 被判定为已解决或新增。会话中持有未计入的证据时也是如此——例如ambiguous_tail或unattributed_anrs,缺失的聚类可能就在其中:其缺失 项会列为unconfirmed,结论会说明未计入的内容(若一个会话计入了某条 ANR,而另一个仅保留了同一条 ANR,两者都会被读取为“改善:已解决 1 个”)。 对--package会话而言,卡顿或 StrictMode 记录可能属于该包但未计入: 因为无法确定其归属而丢弃(unnamed_dropped,见下文)、由孤立进程或包的 SDK 沙盒写入(isolated_unattributed、sdk_sandbox_unattributed,见下文), 或属于包在会话期间更换的 uid(package_uid_changed,见下文)。在该会话 中缺失的卡顿或 StrictMode 聚类会标记为unconfirmed(相同卡顿的进程在 采集前就已启动,却被读取为“改善:已解决 1 个”);即使没有缺失内容,结论 也会说明未计入的内容,因为未计入的记录可能正是另一会话中缺少的聚类。 它的 ANR 聚类仍会进行比较:丢弃卡顿记录并不代表 ANR。若一侧通过 uid 归属--package的卡顿和 StrictMode 记录,而另一侧没有——例如摘要由较早 版本保存(缺少或包含null的uid_attribution),或设备未提供包的 uid ("package_uids_unknown")——结论会说明原因:用inconclusive: <id> has no uid attribution (recorded by an earlier version, whose --package kept jank/StrictMode only when a Start proc it saw named the process)代替“没有变化”;若结论发现了变化,也会附上相同的注意事项。较早版本在 丢弃计数功能加入前保存的--package摘要没有unnamed_dropped(对较早版本工作进程运行的会话执行--stop时,结果为"unnamed_dropped": null,并显示Dropped: not recorded):丢弃了什么 内容并不明确,因此也会以相同方式读取,并在结论中说明“<id>未记录--package丢弃的内容”。未按包过滤的会话不会丢弃任何内容,无论是否 有记录。 即使一侧不完整,两侧都有的聚类也不会出现变化——该侧的最大值只是下界—— 但会列在not_compared下,且"reason": "not comparable: <id> is incomplete"; 与未计入此类记录的一侧进行比较时,卡顿或 StrictMode 聚类也会如此处理 ("<id> dropped N jank/StrictMode records of M pids it never named, which --package could not attribute"、"<id> did not count N jank/StrictMode records of M isolated uids, which may be the package's"、"<id> did not count N jank/StrictMode records of the package's SDK sandbox"、"<id> saw the package's uid changed during the session (uid N), whose jank/StrictMode went uncounted")- 会话会保留采集时使用的指纹版本——对较早版本的会话执行
--stop不会 重新标记版本;若两个会话采集时使用的版本不同,--diff会拒绝操作 (退出码为 1,并指出两个版本)。此版本的指纹版本为 3(窗口 ANR 或进程 ANR 不归入任何包);版本 2 会将仅由 WindowManager 报告的 ANR 归到其 标题所指的包,因此 v2 会话不会与 v3 会话进行比较。在记录版本号之前保存 的会话均为版本 1 --diff --json:每个drift条目都包含from_zero;若其值为 true (该聚类在第一个会话中的最大持续时间为 0),则delta_pct为null——不会出现非 JSON 的Infinity——且该条目计为恶化- 实时流(
--start、--watch)从设备缓冲区末尾开始读取 (logcat -B -T 1,首先会输出最新的一条记录);过去它会把整个缓冲区当作 实时内容重放。这条记录可能早于读取开始时间,因此会显示但绝不计入: logcat 会拒绝-T 0(显示-T 0 invalid, setting to 1,并已记录),且 记录中没有任何内容能表明它是在--start之后写入的——执行logcat -c后,它才是实时记录。会话会通过ambiguous_tail保存该记录 显示的内容(文本中为Ambiguous:行),但不会计入event_count、聚类和--diff:若计入,就可能把会话开始前就已发生的卡顿当成会话期间的事件, 并在下一个会话中称为“已解决”。因此,应在测试场景开始前启动会话:在--start返回时刚写入的记录可能就是那条记录,会显示但不会计入。--watch会通过"ambiguous_tail": true输出它。会话重启的流会跳过已 完整读取过的重放记录,并读取尚未读取过的记录(即流中断期间写入的记录); 时间戳不会用于比较,因此时钟变化不会改变这条边界。--since会读取其 自身的时间范围 - 若流未能完整读取到 logd 记录的结束位置,绝不会将其当作完整内容读取。
若我们停止读取时(
--duration、Ctrl-C、--stop)仍有记录到达——无论 流是否仍打开——该记录都会丢失并明确说明:--watch会在 stderr 输出warning:,会话中则记录为cut_records(文本中为Cut:行)。若流在 一条记录中间自行结束,则首先根据 adb 自身的 stderr 确定错误类型——adb 在结束时报告的设备错误属于退出码 3,即使查询时设备已恢复(此情况曾被log_monitor误判为帧格式拒绝错误并返回退出码 1)——随后--watch和--since会再次运行adb get-state(先于判断 adb 的其他 退出状态);若设备已断开,则以退出码 3 退出;否则会指出帧格式错误和 adb 的退出方式并拒绝操作(退出码为 1)。若字节根本不构成记录,则拒绝操作 (退出码为 1),且不会查询设备——先读取 adb 的输出,再等待 adb 有 0.5 秒完成后读取;会话工作进程也会使用同样的宽限时间;此时可能会检测到设备 错误(退出码为 3)。adb 的 stderr 会从进程启动和结束时各读取一段 (各 64 KiB):如果只读取结尾,adb 先输出的错误随后可能被大量日志覆盖。 两段读取窗口之间的内容会在读取时逐行扫描,不会保留:只保留 adb 自身的 行(adb:/error:,设备错误分类会读取这些行),且不同的行最多 64 KiB,因此只要此前不同的 adb 前缀行未超过该大小,且没有单行超过 64 KiB,流中任何位置的设备错误都会被识别;若超过这些限制,则可能漏掉 错误——在前后两个合计超过 128 KiB 的窗口之间,若adb get-state发现 设备已恢复,原本会返回退出码 3 的情况可能变为退出码 1。错误消息会截短 adb 的 stderr。 会话工作进程不会重启任何一种流:它会保留每条流的 stderr(输出到/dev/null),无论 adb 是否已退出,都会读取 adb 的输出;若 adb 报告 设备错误,则直接据此判定,只有未报告错误时才会查询设备。会话会标记为crashed;--stop会保存已读取内容(摘要中的stream_failure、文本中 的Broken:行,可通过--get-details查看,并说明 adb 如何结束流), 然后根据检测到的错误拒绝操作;错误类型本身会作为错误再次返回:设备断开 时退出码为 3;有多个设备但未指定--serial时退出码为 1,并指出应传入 的 serial;其他错误也为退出码 1。会话流自行完整结束时,也会以相同方式 检查:若有设备错误(adb 的错误信息或adb get-state的结果),则会以该 错误结束会话(--stop的退出码为 3);否则——设备仍在线,或adb get-state没有响应——会重启流(ANDROID_EMU_ANR_MAX_RESTARTS, 默认值为 3),直到没有剩余重启次数,此时会话会以流故障结束,--stop会拒绝操作(退出码为 1,并说明 adb 如何结束流)。过去这两类 流都会重启,之后以退出码 0 保存不完整摘要。若在计划重启前收到停止请求, 摘要会保存为不完整(流结束后记录的内容会缺失)。每条流的进程都会在下一 条流启动前结束 --package PKG只保留包名与 PKG 完全相同的事件,依据读取器确认的身份 判定:ActivityManager 的 ANR 报告所指的包(或其组件),以及对于卡顿和 StrictMode 事件,记录的 uid。过去它会匹配消息和 tag 的子字符串以及包名 最后一段,因此录制的 Choreographer 文本“The application may…”会同时 通过com.example.app和com.example.application的筛选。若卡顿或 StrictMode 记录的 uid 属于该包的 uid,则该记录属于该包——适用于所有 模式,包括--watch、--since和会话,无论进程是否在读取期间启动。 Choreographer 和 StrictMode 都由应用内部写入;logd 会为每条记录标注写入 进程的 uid,应用无法自行指定(录制内容:API 35 上 fixture 应用的每条卡顿 记录都带有 uid 10205,API 33 上每条 StrictMode 记录都带有 uid 10174)。 包的 uid 来自cmd package list packages -U <package>,每个安装该包的用户 各有一个,并且会完整保留:该包在第二个用户下的 uid 会计入;如果某个用户 未安装该包,该用户下相同的 app id 则不会计入。随后事件会显示"app": "<package>"(会话以包名为键记录卡顿,因此重启或预热启动都属于 同一位置)。其他 uid 写入的记录不属于该包,会被丢弃,如同其他包的 ANR 一样。无法判定或存在疑问时会明确说明:shared_uid:若设备提供的完整 uid 表 (cmd package list packages -U,不筛选,只查询一次)显示包的 uid 也 分配给其他包(sharedUserId),这些包的卡顿记录会使用同一 uid,因此会 计入,并在摘要中列出这些包——"shared_uid": ["<other>", ...](无法读取完整表时为null),文本中会输出Shared uid: the package's uid is also <others>'s (sharedUserId), so jank/StrictMode counted for it by uid may be theirs.--watch/--since会在每个此类事件中加入"shared_uid"(文本中为[uid shared with …]),并在 stderr 输出警告isolated_unattributed:孤立进程(android:isolatedProcess)或 应用 zygote 派生的进程会使用自身 uid(app id 为 99000–99999 或 90000–98999),包列表中没有该 uid,因此其记录可能属于该包,也可能属于 该用户下的任何应用。绝不计入:摘要中的"isolated_unattributed": {"records": N, "uids": [...]}(记录数只统计 达到帧数阈值的记录);文本中有Isolated:行;--watch/--since会在 stderr 输出warning: --package did not count N jank/StrictMode records from isolated processes (uid …);--diff不会根据这类会话中的卡顿作出结论(见上文)sdk_sandbox_unattributed:包自身的 SDK 沙盒(运行其 SDK 的 Privacy Sandbox 进程)使用的 uid,是同一用户下包的 uid 加 10000 (app id 为 20000–29999)。其中的记录属于 SDK,而非应用;包列表中没有 该 uid:绝不计入,也不会不作说明地丢弃——摘要中为"sdk_sandbox_unattributed": {"records": N, "uids": [...]};文本中有SDK sandbox:行;stderr 中有warning: --package did not count N jank/StrictMode records from the package's SDK sandbox (uid …);--diff不会根据这类会话中的卡顿作出结论。其他应用的沙盒属于该应用 (映射关系固定),会作为其他 uid 的记录丢弃。过去这类记录会被静默丢弃, 导致沙盒卡顿被读取为“改善:已解决 1 个”- 每次读取时只查询一次包的 uid(
--watch、--since、会话),并将 查询结果保留;完整 uid 表也同样处理。在读取期间安装或重装包,或为包 添加用户,都会使包获得查询结果中没有的新 uid,其记录随后会被当作其他 uid 的记录:进行上述任一操作后,应启动新的读取。当 system_server 在此 类 uid 下启动了名称恰好等于该包名的进程时,会明确说明——--watch/--since会在 stderr 输出warning: system_server started a process named <package> under uid N, which the package's uids as this read asked them at its start do not include … -- start a new read.;"package_uid_changed": [N]、文本中的Uid changed:行,以及会话--diff中不对卡顿作出结论。(其他应用也可能将其进程命名为该包名, 该提示无法排除这种情况。) unnamed_dropped:设备未提供包的 uid 时,无法证明任何记录属于该包, 也无法通过Start proc为其进程命名,因此所有卡顿和 StrictMode 记录都 会丢弃;此情况会计数,绝不会不作说明地丢弃:摘要中为"unnamed_dropped": {"records": N, "pids": [...]}和"uid_attribution": "package_uids_unknown";文本中有Dropped:和No uid attribution:行;--watch/--since会在 stderr 输出warning: --package dropped N jank/StrictMode records(其文本摘要也会 说明);--diff不会根据这类会话中的卡顿作出结论(见上文)uid_attribution:会话摘要会说明归属方式——"uid"(依据每条记录 的 uid)、"package_uids_unknown"、"unfiltered"(未指定--package)或null:未记录,表示摘要由较早版本保存;--diff会 对此作出说明(见上文)。"events_without_uid": N用于统计写入时没有 uid 的卡顿/StrictMode 事件(由较早版本的工作进程写入);文本中会输出No uid:行
- 旧版模式:
--watch [--duration N]、--since 5m。若 adb 以失败状态结束 logcat 读取,会拒绝操作,而不会显示“无 ANR”:设备不可用时退出码为 3 (开始流式读取前会捕获未知的--serial),其他情况为 1;错误类型根据 adb 写入的 stderr 判断。实时流只有在停止时才会结束,因此若流自行结束 ——无论 adb 的退出状态如何,甚至没有退出状态——--watch都会再次运行adb get-state:设备已断开时退出码为 3;否则拒绝操作(退出码为 1), 绝不会在不输出任何内容的情况下成功退出。若--since转储超过 60 秒 时限,也会先执行相同检查:设备断开时 adb 会等待而非失败(- waiting for device -), 因此时限就是此类转储的结束方式 - 设备不存在时,
adb logcat不会失败,而是继续等待,因此--watch、--since和--start会先运行adb get-state(有时限, 指定或未指定--serial均适用);若 adb 未报告任何处于device状态的 设备,会立即拒绝操作并以退出码 3 退出(存在多个设备但未指定--serial时退出码为 1);--start不会创建会话。--start只有在工作 进程已认领会话且状态为running时才会成功——这时没有故障信息,因为 只有会话结束时才会发布故障。若工作进程正在判定流的结束方式,程序会等待 它;若工作进程在--start返回前先登记并结束——例如流立即中断——这不 属于“未登记”(退出码为 1,这是 CI 遇到的竞态):--start会停止会话, 保存摘要,并按--stop的方式根据结束原因拒绝操作(设备断开时退出码为 3)。若工作进程在 3 秒内未认领会话,则会放弃等待:会话被取消(退出码为 1),之后启动的工作进程不会向其中写入任何内容。若工作进程遇到无法预见的 错误,会以该错误结束会话并标记为crashed,作为流故障之外的附加信息, 而不是像过去那样将会话留在running且不作说明。由 v0.8.0 main 写入的 会话可原样加载(文件和键相同,此版本只新增键):已停止会话会按已保存内容 返回;已崩溃会话仍会像以前一样被拒绝;若该版本工作进程已消失,处于running状态的会话会被拒绝并提示--abandon,因为该版本的工作进程 从未说明会话如何结束 - 兼容性边界。 请在升级前先停止所有 ANR 会话,确保升级过程不会有会话
活跃。仍在记录的 v0.8.0 main 工作进程只能通过等待它结束来隔离(见上文);
该版本尚在进行中的
--start仍可能认领此版本已取消的会话——该版本的 认领操作不会进行任何检查。此版本写入的会话不适用于较旧的二进制文件 (不保证向前兼容),两个版本同时写入同一会话也不受支持。这也适用于 紧邻此版本的前一个版本:其工作进程的 meta 中带有lifecycle,因此上文 所述的隔离机制无法识别它;并且它会在会话目录中锁定<session>/lock, 而此版本锁定locks/<session>.lock,因此两者互不排斥——升级前也要停止 该版本的会话。较早版本的清理操作还会将locks/视为没有 meta 的会话并 删除它;此版本会重新创建该目录,但此时持有的锁无法排斥任何进程。旧会话 目录中遗留的lock文件现在只是普通文件。若较早版本的工作进程可能仍在 运行或无法识别,--stop和--stop --abandon都会拒绝操作;若ps不可用且 pid 仍存在,则要等到ps恢复运行或进程退出后才能继续——否则 唯一的恢复办法是在此工具之外操作:自行确认工作进程已结束后删除会话目录 (出于同样原因,--clear-sessions会保留该目录)。若较早版本会话的工作 进程已结束,则会从其保存的摘要中返回结果;先运行--stop,可根据该工作 进程持久化的内容重建摘要 ANDROID_EMU_ANR_MAX_RESTARTS、_TOTAL_CAP_MB、_DEFAULT_TOP_N和_BUDGET_TOKENS接受0(不重启、不设容量上限、 不限制 top-N、不设预算);过去会将其提升为 1。除非 adb 或adb get-state报告设备错误,否则流结束后会重启;值为0时,自行结束的 流会立即结束会话- 选项:
--watch、--since、--start、--stop、--get-details、--list-sessions、--clear-sessions、--diff、--package、--serial、--duration、--min-frames、--top、--all、--budget-tokens、--cluster、--raw、--older-than、--terse、--abandon、--json
- 会话模式:
-
sms.py ⭐ NEW - 向模拟器发送一条入站短信,并确认短信已送达。仅适用于模拟器。
--send会读取收件箱并分别报告accepted和delivered:控制台的OK表示命令已受理,并不代表短信已存在。短信为异步送达(实测约需 2 秒),因此脚本会轮询。--otp会从最新短信中提取一次性验证码,并打印验证码所在的短信,以便核验提取逻辑。- 选项:
--send --to NUM --body TEXT [--no-verify]、--list [--limit N]、--otp、--serial、--json、--verbose
-
snapshot.py ⭐ NEW - 保存、加载和删除模拟器快照(
adb emu avd snapshot)。可用约 2 秒重置状态,替代 60 秒重启。仅适用于模拟器。- 加载失败会报告为失败,这正是关键所在:即使
adb emu返回KO,其退出码也为 0;否则,实际上未执行的恢复操作会被报告为成功,后续测试也会在未知状态下运行。 - 选项:
--list、--save NAME、--load NAME、--delete NAME、--no-verify、--timeout、--serial、--json、--verbose
- 加载失败会报告为失败,这正是关键所在:即使
-
crash_triage.py ⭐ NEW - 将专用崩溃缓冲区(
logcat -b crash)解析为结构化崩溃信息,并按故障分组。- 按软件包、异常和签名堆栈帧对重复崩溃分组(否则崩溃循环会淹没输出),并标出最有助于分类的堆栈帧,同时说明选择依据;其中也包括诚实说明“没有应用堆栈帧;所有堆栈帧均为框架代码”。
- 退出状态表示“分类流程是否运行”,而非“是否发生崩溃”——请根据
--json中的crash_count进行判断,或传入--fail-on-crash。 - 选项:
--package PKG、--clear、--fail-on-crash、--serial、--json、--verbose
-
logs.py ⭐ NEW - 读取日志的统一入口;根据所要处理的问题进行路由。
logs.py tail(主缓冲区)→log_monitor.py;logs.py crashes(-b crash)→crash_triage.py;logs.py anr(ANR/卡顿,包括会话模式)→anr_watcher.py。- 参数会原样传递,因此每个子命令都支持其委派脚本的完整标志集;这些脚本仍可照常独立调用。
- 选项:
<verb> [verb options]、--json(路由表)、--help
-
avd.py ⭐ NEW - 管理模拟器/AVD 生命周期的统一入口;根据所要处理的问题进行路由。
avd.py list→device_list.py;pick→emulator_selector.py;create→emulator_create.py;start→emulator_boot.py;stop→emulator_shutdown.py;reset(清除数据,保留 AVD)→emulator_erase.py;delete(移除 AVD)→emulator_delete.py。reset和delete是两个独立的子命令,因为它们删除的对象不同;遇到容易混淆的情况时会询问,而不是自行猜测。- 参数会原样传递,因此每个子命令都支持其委派脚本的完整标志集,包括脚本各自的确认标志(
delete --yes、reset --force);这些脚本仍可照常独立调用。 - 选项:
<verb> [verb options]、--json(路由表)、--help
构建系统位于
gradle/子包(builder、results、cache、config、reporter),由build_and_test.py使用。ANR 监视器的聚类/会话机制位于common/anr_pipeline.py和common/anr_sessions.py。SDK 二进制文件的解析位于
common/sdk_tools.py(get_emulator_path())。切勿直接按名称执行emulator:SDK 根目录中有一个名为emulator的目录,因此,如果 PATH 中包含的是$ANDROID_HOME而非$ANDROID_HOME/emulator,就会导致 execve 引发PermissionError,而不是调用方通常会处理的FileNotFoundError。
Android 与 iOS 对照
| iOS 工具 | Android 对应项 | 状态 |
|---|---|---|
| xcrun simctl | adb / avdmanager / emulator | ✓ 完成 |
| IDB | adb shell uiautomator / input | ✓ 完成 |
| iOS Simulator | Android Emulator | ✓ 完成 |
| xcodebuild | Gradle wrapper | ✓ 完成 |
| Accessibility tree | UI hierarchy dump | ✓ 完成 |
| simctl privacy | pm grant/revoke | ✓ 完成 |
| xcresult | Gradle 测试报告 / JUnit XML | ⚠ 基础支持(正在改进) |
脚本类别
🚀 核心(每日使用)
- app_launcher.py - 启动/终止应用
- screen_mapper.py - 查看当前屏幕
- navigator.py - 与 UI 元素交互
- gesture.py / keyboard.py - 用户输入
🔧 开发
- build_and_test.py - 构建项目并运行测试
- log_monitor.py - 通过筛选后的日志进行调试
- emulator_boot.py / emulator_shutdown.py - 设备管理
🧪 测试
- accessibility_audit.py - 检查无障碍合规性
- visual_diff.py - 视觉回归测试
- test_recorder.py - 记录测试执行过程
- app_state_capture.py - 调试测试失败
⚙️ 高级
- privacy_manager.py - 测试权限流程
- push_notification.py - 以 shell 身份向通知栏发布;验证应用发布的内容
- status_bar.py - 精细控制
- emulator_create/delete/erase.py - CI/CD 资源配置
- sms.py - 接收短信,并端到端测试 OTP 登录流程(仅限模拟器)
- snapshot.py - 在测试之间约 2 秒完成状态重置(仅限模拟器)
- crash_triage.py - 从专用崩溃缓冲区获取结构化崩溃信息
典型工作流
手动测试流程
# 1. Launch app
python3 "$SKILL_DIR/scripts/app_launcher.py" --launch com.example.app
# 2. See what's on screen
python3 "$SKILL_DIR/scripts/screen_mapper.py"
# 3. Interact
python3 "$SKILL_DIR/scripts/navigator.py" --find-text "Login" --tap
python3 "$SKILL_DIR/scripts/navigator.py" --find-type EditText --index 0 --enter-text "[email protected]"
python3 "$SKILL_DIR/scripts/keyboard.py" --button enter
# 4. Verify
python3 "$SKILL_DIR/scripts/screen_mapper.py"
自动化测试流程
test_recorder.py 通过 shell 驱动,每个步骤对应一个进程——会话存储在磁盘上,因此执行命令之间无需保持进程打开。
# 1. Start recording; the session id is printed, and later steps default to it
python3 "$SKILL_DIR/scripts/test_recorder.py" --start "Login Flow" --app-name MyApp
# 2. Execute test steps, capturing the screen after each interaction
python3 "$SKILL_DIR/scripts/test_recorder.py" --step "Launch app" --screen Splash
# ... interactions ...
python3 "$SKILL_DIR/scripts/test_recorder.py" --step "Verify logged in" \
--screen Home --assert "Home screen shown"
# 3. Finish: writes report.md + manifest.json (add --failed to mark it failed)
python3 "$SKILL_DIR/scripts/test_recorder.py" --stop
CI/CD 流程
# 1. Create fresh emulator
python3 "$SKILL_DIR/scripts/emulator_create.py" --device pixel_7 --api 34 --name test-device
# 2. Boot emulator
python3 "$SKILL_DIR/scripts/emulator_boot.py" --avd test-device --wait-ready
# 3. Build and test
python3 "$SKILL_DIR/scripts/build_and_test.py" --project . --test
# 4. Run UI tests
# ... your test scripts ...
# 5. Cleanup
python3 "$SKILL_DIR/scripts/emulator_shutdown.py" --serial emulator-5554
python3 "$SKILL_DIR/scripts/emulator_delete.py" --name test-device
调试流程
# 1. Capture complete state (prints where the snapshot went)
python3 "$SKILL_DIR/scripts/app_state_capture.py" --package com.myapp
# 2. Monitor logs in real-time
python3 "$SKILL_DIR/scripts/log_monitor.py" --app com.myapp --severity error,warning --follow
# 3. Check accessibility issues
python3 "$SKILL_DIR/scripts/accessibility_audit.py" --output audit-reports/ --verbose
要求
- macOS、Linux 或 Windows——但
anr_watcher.py的实时读取(--watch,以及会话工作进程的--start运行)除外:这些操作会通过select()等待 adb 管道,而 Python 的select()在 Windows 上仅支持套接字,因此这两项仅支持 Unix(macOS、Linux) - Android SDK,包含 platform-tools 和 emulator
- Python 3.12+
- ADB(Android Debug Bridge)
- 可选:用于构建的 Gradle
- 可选:用于调整截图大小的 Pillow
安装
环境设置
# 1. Install Android SDK (via Android Studio or command line tools)
# Download from: https://developer.android.com/studio
# 2. Set environment variables
export ANDROID_HOME=$HOME/Library/Android/sdk # macOS
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/emulator
# 3. Verify installation
adb version
emulator -version
作为 Claude Code 技能使用
# As a plugin (preferred; updating is then two commands -- see README.md)
claude plugin marketplace add fluxxion82/android-emulator-skill
claude plugin install android-emulator-skill@fluxxion82
# From a clone: the skill is the INNER directory (this file's own), not the
# repository root, so clone the repo and link that directory into place.
git clone https://github.com/fluxxion82/android-emulator-skill ~/src/android-emulator-skill
# Personal installation
ln -s ~/src/android-emulator-skill/android-emulator-skill/skills/android-emulator-skill ~/.claude/skills/android-emulator-skill
# Project installation
ln -s ~/src/android-emulator-skill/android-emulator-skill/skills/android-emulator-skill .claude/skills/android-emulator-skill
文档
- SKILL.md(本文件)- 脚本参考;准确的内容清单
- README.md - 面向读者的简要说明
- examples/ - 完整的自动化工作流(随软件包一同发布)
仅在源代码仓库中:
- CLAUDE.md - 架构、约定和已记录的固件策略
- references/ - adb、测试模式、无障碍方面的深入说明 (不包含在发布的软件包中)
值得了解的平台限制
无法通过 adb 访问剪贴板。 在任何现代 Android 设备上,都无法从 shell 设置剪贴板文本:
- 在 API 33 和 35 上,
cmd clipboard会报告 "No shell command implementation"—— 剪贴板服务完全没有提供 shell 命令接口。 service call clipboard <setPrimaryClip>也无法使用:该调用需要一个ClipDataParcelable,其文本通过writeString8()和依赖版本的字段进行封送, 而service只能写入i32/s16/绑定器。- 模拟器控制台(
adb emu)没有剪贴板命令。
读取和清除剪贴板是可行的——使用正确的签名时,shell uid 可以调用
service call clipboard 3/4/9,因为它具有
READ_CLIPBOARD_IN_BACKGROUND 权限——但写入不行,因此粘贴流程测试需要应用端钩子或辅助输入法。
仅在模拟器上,gRPC 控制 API 确实提供 setClipboard /
getClipboard(参见 $ANDROID_HOME/emulator/lib/emulator_controller.proto)。
这需要 gRPC 客户端和模拟器的身份验证令牌,此处未接入该功能。
因此,clipboard.py 已在 v0.6.0 中移除:其中保留下来的代码路径会调用
使用 Android 10 之前签名的 service call clipboard 1,并且总是失败。
Jetpack Compose 屏幕在转储中的呈现方式不同。 以下内容均基于对真实 Compose 应用转储的实测, 而非假设——三个听起来合理的 Compose 假设最终都被证明是错误的。
- 宿主类是
androidx.compose.ui.platform.ComposeView。AndroidComposeView根本不会出现在转储中,因此查找它的检测器什么也匹配不到。 - 没有 resource-id。 默认的 Compose 屏幕只公开
android:id/content,它属于 AOSP FrameLayout。--find-id无法发挥作用;请使用--find-text或screen_mapper。 - 交互节点本身没有标签。 每个可点击和可勾选节点的
text=""和content-desc=""。uiautomator 会转储 未合并的语义树,因此可点击的Card会将其 Text 保留为单独的子节点,而不是合并成一个标签——不会在任何地方进行拼接。screen_mapper会从控件的后代节点(Button、Card、列表行)以及与行相邻的同级节点(Checkbox、Switch、 图标)中恢复标签,这就是它能为转储中未命名的控件命名的原因。
如需让自己的 Compose 应用可寻址,请在树的根节点启用将测试标签用作 resource-id:
Modifier.semantics { testTagsAsResourceId = true } // once, at the root
Modifier.testTag("submit_button") // on each control
该标签随后会显示为一个裸 resource-id——submit_button,而不是
com.example.app:id/submit_button。任何通过 ":id/" 拆分 resource-id 来提取名称的工具,都会漏掉屏幕上的所有 Compose 标签。
核心设计原则
语义导航:按含义(文本、类型、ID)而非像素坐标查找元素。可适应 UI 变化。
Token 效率:默认输出简洁(3-5 行),使用 --verbose 提供面向用户的详细信息,使用
--json 输出机器可读内容。与直接传递工具原始输出相比,输出量大幅减少,但尚未测量;目前计划编制预算表,而非编造百分比。
无障碍优先:基于标准无障碍 API 构建,确保可靠性和兼容性。
零配置:安装 Android SDK 后即可立即使用。无需复杂设置。
结构化数据:脚本输出 JSON 或格式化文本,而非原始日志。便于解析和集成。
跨平台:适用于 macOS、Linux 和 Windows(anr_watcher.py 的实时读取仅支持 Unix:参见 Requirements)。
真实设备:与 iOS 不同,既适用于模拟器,也适用于真实设备。
Android 专属功能
真实设备支持:支持通过 USB/WiFi 连接的模拟器和实体设备。
多个模拟器:支持同时运行多个模拟器并执行批量操作。
灵活的架构:兼容 x86_64 和 ARM 模拟器架构。
Gradle 集成:原生集成 Android 的 Gradle 构建系统。
高级日志记录:支持按正则表达式、严重级别和特定应用过滤 Logcat。
权限测试:以编程方式授予或撤销运行时权限。
通知测试:以 shell 身份向通知栏推送通知,并读取软件包实际发布的通知。通过 adb 无法将通知送入应用自身的 FCM 处理程序——请参阅上文的 push_notification.py。
状态与路线图
v0.8.0 — 诊断结果不再说谎。 v0.7.0 证明了 Agent 循环可以通过技能自身的输出闭环。本次发布着眼于 Agent 读取的数据:判定结果、计数或“未发现任何内容”,这些信息此前并非设备实际生成。以下每项修复都始于针对录制的 fixture 编写的 strict-xfail 规范,并且在修复前均为红色。
- 每个脚本都有统一的错误边界(
common/cli.py)。拒绝操作时,--json下会返回一个{"success": false, "error", "remedy"}对象(或在 stderr 上输出Error:/Remedy:),并以代码 1 退出;设备不可用时以代码 3 退出。AST 防护会枚举全部 32 个脚本,退出代码扫描会断言 39 种已记录模式的载荷,分别在所有工具均失败和没有安装任何工具时各运行一次。 - “无法检查”不再等于“什么都没有”。
emulator_create、emulator_erase、device_list和android_health_check.sh遇到缺失或运行失败的 SDK 工具或 AVD 主目录时会拒绝操作,而不是返回空列表。emulator_create默认使用主机的 ABI,不会跳过镜像检查,并能读取sdkmanager的两种输出格式。Java 工具的管道表格此前解析结果为空,导致每次 CI 运行都跳过该流水线的 AVD 生命周期测试。 - 构建结论来自本次运行的测试。
build_and_test会删除测试任务的旧 JUnit 报告、重新运行任务,并且只有在 Gradle 退出代码为 0 且没有<failure>/<error>时才通过。如果运行未写入结果,则会拒绝操作,绝不会报告为“0 项通过”。低于 7.6 的 Gradle 会按名称拒绝。 - 日志窗口只记录属于应用的内容。
log_monitor --app和app_state_capture只保留来自已证实属于该应用的进程的行,包括已崩溃后又重启的进程。只有在证据表明记录由 system_server 写入,且其 uid 与包匹配时,pid 才会被计入;两者都会读取 logd 的记录(-B),因此应用写入的任何标签或消息都无法伪造归属。代价是覆盖范围受限:窗口未见到此类证据的进程——例如在读取开始前启动的进程、私有的pkg:name进程——不会纳入,窗口会明确说明这一点(attribution.complete: false、unresolved_pids),而不会猜测。--severity会验证名称,计数按行计算。快照不再写入调用方的工作目录。 - 一次 ANR 就是一个事件。
anr_watcher也会读取 logd 的记录,因此Reason:/PID:行会包含在其报告中;只有 WindowManager 报告的 ANR 也会计入,但必须是 system_server 写入的记录——其 pid 无法通过标签伪造——且是某个窗口的 ANR:窗口标题是应用提供的文本,因此不能据此命名、分类或判定任何内容。ActivityManager 关于同一广播 ANR 的报告可能已涵盖某个窗口报告;这类报告仍会计入并标记为possible_duplicate,绝不会仅凭标题合并。只有当证据表明 ANR 所属进程以该包的 uid 运行时,才会将 ANR 归入该包(ActivityManager 会给出进程名称,而应用可以把进程命名为其他包的名称)。Jank 按进程归类,只有在同一数据流中看到 system_server 自己的Start proc记录时,才会为 pid 标注名称;--package会完整匹配该身份(从 v0.9.0 起,jank 和 StrictMode 则依据每条记录的 uid 归属于相应包)。会话的捕获丢失——被 stop 截断的记录、数据流重启造成的间隙——都会将会话标记为不完整;启动时-T 1重放的那一条记录会显示出来,但不会计入。--diff不会仅凭可能缺少某项记录的一侧,就判定其已解决、为新增或稳定——无论保存的一侧不完整,还是持有未计入的证据(对于 jank 和 StrictMode,指--package因缺少名称而丢弃的内容,或未记录的内容)——并会在结论中说明:没有其他变化时判为“无法确定”,否则报告变化及其注意事项。它绝不会输出Infinity。实时读取器不再吞掉 adb 的退出状态:数据丢失会明确披露;--watch数据流自行结束时会拒绝操作,而会话数据流会重启;除非 adb 或adb get-state指明设备错误,否则不会拒绝,设备错误则会在--stop时拒绝——在--watch、--since和会话工作进程中,都会优先显示 adb 自己的错误文本(设备错误时退出代码为 3)。会话生命周期会在每个会话独有的锁下串行执行(meta 和 summary;events 以无锁方式追加),每次状态转换都由工作进程或 CLI 负责,且只会从其预期状态应用:工作进程退出前会公布其结束方式(除非它在 5 分钟内无法取得锁);只有会话结束后,--stop才会返回 summary(否则会拒绝操作,退出代码为 1;--stop --abandon则会保存已读取的内容并标记为不完整,事件也会冻结在放弃时的状态);只有正在录制的工作进程才能让--start成功。对于不使用锁的上一版本工作进程,会等待其退出后再响应会话请求。 - 如实读取应用文件。
adb exec-out run-as … cat会以退出代码 0 返回错误文本作为内容(并已测量),因此container和model_inspector现在会通过adb shell -T读取,并拒绝使用无法遵守该选项的旧版 adb 客户端。设备文件名会被视为不可信输入:目录以 NUL 结尾的记录形式列出(toybox find),因此含换行符的名称无法伪造或隐藏条目;不安全、无法解码或包含 CR/LF 的名称会被拒绝并报告,导出文件也会留在其目录内。 - 必需的模拟器流水线无法再不经测试就通过。 任何跳过都会导致失败;设备丢失时会附带诊断信息并以代码 3 退出;只会针对这种情况重试一次,并在步骤摘要中计入此次重试。 测试数量:从 1,653 增至 3,110,且没有 xfailed。在版本号更新和发布之间,合并了 14 个修复 PR,每个 PR 都来自对已合并代码的独立审查;上文介绍了本次发布的内容。Fixture 策略方面的技术债务已降至两项,边界防护的债务清单为空。
修正仍由 Fixture 驱动:tests/fixtures/recorded/ 保存了从两个 API 级别的真实设备上逐字捕获的输出,解析器测试会使用这些输出;如果存在已知缺陷,则会通过 xfail(strict=True) 固定下来,以确保修复后必须移除该标记。v0.8.0 中没有固定任何缺陷。请参阅源代码仓库中的 CLAUDE.md。
已知差距,留待下个版本解决:
accessibility_audit不会检查对比度。对比度检查需要从屏幕截图中采样像素,目前尚未实现。anr_watcher --package无法将卡顿或 StrictMode 记录归因于隔离进程(android:isolatedProcess)、由应用 zygote 启动的进程,或该软件包的 SDK 沙箱:每个进程都使用自己的 uid,而任何软件包的列表中都没有列出这些 uid,因此其记录会列在isolated_unattributed/sdk_sandbox_unattributed下,绝不会计入该软件包;--diff也不会据此得出任何与这些记录相关的卡顿结论。这是明确说明的覆盖范围限制,而非待解决的问题。端到端尚未记录:没有已记录的进程表包含这类进程,因此分类器只会使用从软件包已记录的 uid 和 AOSP 的 app-id 范围推导出的 uid 进行测试。anr_watcher --package在每次监视或会话开始时仅查询一次软件包的 uid(以及整个 uid 表),之后不会刷新:如果在读取期间安装、重新安装软件包,或为其添加用户,新 uid 下的记录就不会被计入。若在此类 uid 下出现命名该软件包的Start proc,会报告为package_uid_changed;但如果读取期间未能看到该记录(例如记录丢失在重启后的流间隙中),则不会报告。更改安装后请开始新的读取。- 在 Android 13(API 33)上,ANR 实时读取器——
anr_watcher --watch和会话工作线程——会丢失停止时 logcat 尚未写入的所有记录:该版本的 logcat 通过 4 KiB 的 stdio 缓冲区写入-B记录,并且只在写入文本记录后才刷新(AOSP logcat.cpp,android13-release;Android 12 会逐条写入,Android 14 及更高版本会刷新缓冲区);而读取器通过终止 logcat 来停止它。因此,由--duration或 Ctrl-C 结束的--watch,以及在--stop时结束的会话,都会丢弃自流中上一个完整的 4 KiB 块以来记录的所有内容——在安静的设备上,可能就是全部内容。在 API 33 模拟器上的测量结果:在 4.5 秒会话开始 2 秒后记录的一条卡顿记录,没有出现在--stop摘要中;摘要显示event_count: 0、incomplete: false。在该记录之后再记录 5 KB 内容后,卡顿记录出现了,摘要显示有一条记录被截断(cut_records: 1),但没有说明其余尚未写入的尾部内容;--watch --duration 6也表现相同。流保持打开时,事件也可能留在该缓冲区中,直到又有 4 KiB 内容到来才会被看到。log_monitor通过该层级的转储进行读取,因此不受此缓冲影响。 log_monitor捕获的是 logd 实际交付的内容,而不是窗口完整无缺的证明。logd 可能会丢弃读取器落后时清理掉的记录——Android 13 会在向缓慢的读取器刷新时清理缓冲区,且不会断开读取器——这种情况会发生在每个级别、每个读取器上,无论是流读取还是转储读取;我们无法区分被清理的记录和从未记录的内容。在 API 33 上,通过转储读取(见上文)还会带来自身的限制:读取器继续读取的记录只能证明 logd 在下一次转储开始时仍保留着这些记录,而且只能在各自的缓冲区中找到(main、system、crash 和 kernel 各自独立滚动,因此,如果另一个缓冲区在轮询间隔内滚动,就会丢失尚未看到的记录——在繁忙的设备上,可通过adb logcat -G扩大其环形缓冲区);如果某个缓冲区滚动越过了读取器已在其系统记录之后取得的记录,读取器就会拒绝继续,即使没有尚未读取的内容可能丢失;如果没有新的系统记录,已记住的后缀和所需转储会持续增长,直到缓冲区滚动或字节数上限拒绝继续;如果打开时缓冲区中没有系统记录,就会在其中写入一行标记(见上文),因此在 API 33 上执行--clear后,设备日志中会留下一个aes-log-monitor行。Android 13 的SerializedLogBuffer::Log会先为记录编号,再获取logd_lock,因此来自 logd 自身内核(LogKlog)或审计(LogAudit)线程的记录可能会被存储在读取器已取得的记录之后——窗口期是从编号到存储之间的时间:如果记录落在已记住的后缀之内,就会触发明确的丢失拒绝;如果恰好落在游标之前,则这条记录不会被捕获。监听器写入方——每个应用和系统进程——彼此之间会保持顺序,但在同样的竞争条件下,它们的记录也可能落在内核或审计游标之前而被漏掉;保留默认缓冲区选择,因为流读取路径也会读取该缓冲区。读取位置由系统写入的记录确定,因此应用再次发送一条记录时——无论内容逐字节相同、是否发生过清理——都无法再利用副本让读取器从该处恢复:重发记录的旧限制不再适用。读取器遵循 logd 的顺序,而不是时间戳,因此写入方的时钟远远落后(或超前),或设备时钟跳变,也不再会造成缺口。- 未使用
--package时,ANR 会话只会命名运行期间启动的进程(它只以 system_server 的Start proc作为证据);调用--start时已经运行的应用会按 pid 标识,列在unnamed_pids下,且--diff不会比较其卡顿情况。使用--package时,其卡顿和 StrictMode 记录会按 uid 归属于该软件包。 - 失败(
common/cli.py;每个脚本都以cli.run(main)结束)。已承诺的行为,以及尚未承诺的行为:- 类型化失败——缺少
adb或 SDK 工具、adb 调用失败、屏幕无法转储、超时,或脚本通过cli.fail抛出的失败——在--json下会在 stdout 上以单行返回{"success": false, "error": ..., "remedy": ...};否则会在 stderr 上显示Error:/Remedy:,并以 1 退出。拒绝操作前若找到了一些内容,可以将其作为额外键附加在这三项之后(查询的search、捕获的partial、列表操作能够读取的条目);这三项始终存在。 - 不可用设备(未找到、离线、未授权)会返回相同对象,并以 3 退出——无论是在解析
--serial时发现,还是在运行期间由 adb 发现。检测到多台设备且未指定--serial时,退出码为 1。 - 用法错误——未知标志、类型化标志的值无效、缺少必需参数、脚本拒绝的标志组合、未知的
avd.py/logs.py动词——会以 2 退出,并且在--json下返回相同对象:stdout 上一行,其中error为 argparse 的消息,remedy为用法行(对于未知动词,则为动词列表)。在路由器动词内部,会返回委托脚本自己的结果(例如avd.py list --bogus --json)。不使用--json时,stderr 上会显示 argparse 的文本(对于未知动词,则显示路由指南)。--之后的--json是位置参数值,而不是标志。 - 脚本自身的退出状态会原样传递,包括表示发现问题的状态:
crash_triage --fail-on-crash和localization_audit --strict在发现问题时以 2 退出,而accessibility_audit在存在严重问题时以 1 退出。 - 流式模式(
log_monitor --follow、anr_watcher --watch)无法撤回已打印的记录:在这些模式下,拒绝对象会作为 stdout 的最后一行输出,并且可以单独解析。anr_watcher --since --json也使用 JSON Lines 格式,与--watch --json一样:每个事件对应一个对象,没有外围文档,因此应逐行读取(单个文档解析会在第二个事件处失败)。其他所有模式在--json下都只会在 stdout 上输出一个对象(emulator_delete的确认提示会输出到 stderr)。 - 某些位置会有意捕获设备错误,因此错误不会传到边界处。以下列表并不完整,其中包括:启动完成轮询(
emulator_boot)、快照保存/删除的验证轮询(snapshot会持续查询直到达到截止时间,然后将设备报告为不可用)、可选的前台 Activity 查询(device_utils.get_current_activity)、模拟器控制台扫描(emu_console),以及test_recorder步骤和app_state_capture的部分捕获策略(保留已收到的内容并向上传递——如果设备不可用,app_state_capture会在拒绝对象中提供这些内容,但仍以退出码 3 结束),还有anr_watcher的归因查询(system_server 的 pid、软件包的 uid、实时 pid 的 uid 和整个 uid 表;查询失败时均视为未知答案,并将其披露为未归因或归因未知,而不会终止读取或会话)。 - 已经过检查,不只是承诺:
tests/test_exit_code_sweep.py会在 Android 工具可用但执行失败,以及 PATH 中完全没有 Android 工具这两种情况下,分别运行每条 Quick Start 命令及其表格中列出的模式,并断言实际拒绝结果(一个对象,或Error:/Remedy:;错误中点名工具;以及准确的状态码),而不只是断言退出码非零。它也会在这两种模式下,对每个脚本运行一次用法错误测试。 - 批处理会按项报告结果——
status_bar和appearance中的每个操作、privacy_manager中的每项权限授予/撤销/重置,以及emulator_boot/emulator_shutdown/emulator_erase的--all和emulator_delete的--all/--old中的每台 AVD 或模拟器(common/batch.py)——会返回一个对象:任意项失败时success为 false,之后包含上述error和remedy、批处理自身的键,以及items;每个项都包含其自身字段及success、error、remedy(成功时为 null)。只有所有项都成功时才以 0 退出;有一项失败时以 1 退出;即使批处理中每一项都失败,也仍然只返回一个对象。**v0.9.0 中的破坏性变更:**此前这些批处理都输出results(而emulator_shutdown只统计数量);现在改为items。在单设备批处理中(status_bar、appearance、privacy_manager),如果设备中途消失,批处理会在遇到该设备的项处停止:该项包含设备错误,之后的每一项都标记为not attempted,对象以 3 退出(检测到多台设备且未指定--serial时以 1 退出)。在emulator_*批处理中,每项对应一台独立的模拟器,因此某台模拟器消失只会使该项失败,其余项仍会继续运行。(anr_watcher --all会保留所有 ANR 集群;它不是批处理。) - 设备错误确认适用于应用文件读取(
container和model_inspector会为run_argv请求此确认,使用confirm_device_errors):它们的 stderr 来自设备命令,文件名可能会打印一行adb: …。logcat 读取器不会请求确认:这类 stderr 来自 adb 本身,而稍后才发现设备的探测会将真实设备错误变成退出码 1。在参数由 Agent 提供的run_adb调用方中,有三个会将该文本打印回 stderr,因此伪造的adb: device ... not found行会被误认为是 adb 自身输出;它们也会向run_adb请求相同的确认:privacy_manager的pm grant/pm revoke(未知权限的名称)、appearance --locale(setprop会指出其拒绝的值)和app_launcher --install(adb 自身的adb: failed to stat <path>)。这些调用均已与其一起记录(tests/record_fixtures.py中的*_echo测试夹具),其文本会返回到 stdout,或完全不返回,因此绝不会被分类:privacy_manager的dumpsys package;app_launcher的启动、--open-url、--terminate、--uninstall和启动器查询;device_utils的pm dump和软件包 uid 查询;pidof(log_monitor的流读取路径、app_state_capture、app_launcher --state)和app_state_capture的版本探测;status_bar --mobile-type;keyboard/navigator的input text;以及push_notification --post。探测无法区分这种回显和真实的连接丢失后在探测前恢复的情况,因此 stderr 仅来自 adb 的调用方不会请求确认。
- 类型化失败——缺少
- 仪器化测试不属于功能范围。这里没有任何内容会运行
connectedAndroidTest;构建和测试脚本只会运行单元测试。 已删除而非修复:clipboard.py——请参阅上文“平台限制”;在任何现代 Android 设备上,都无法通过 adb 写入剪贴板,因此无法让该脚本实现其宣称的功能。
贡献
新脚本应:
- 对超过 50 行逻辑的脚本采用基于类的设计
- 支持 --serial 和自动检测
- 支持 --json 输出
- 提供 --help 文档
- 遵循 Black 和 Ruff 规范
- 更新此 SKILL.md
- 提交前使用真实模拟器进行测试
与 iOS 的区别
架构
- iOS:使用 IDB 进行 UI 交互,使用 xcrun simctl 管理设备
- Android:所有操作都使用 adb,使用 uiautomator 进行 UI 交互
元素类型
- iOS:Button、TextField、SecureTextField、StaticText 等
- Android:Button、EditText、TextView、ImageView 等
设备管理
- iOS:仅支持模拟器(需要 macOS)
- Android:支持模拟器和真实设备(跨平台)
构建系统
- iOS:Xcode 项目文件(.xcodeproj)
- Android:Gradle 构建文件(build.gradle)
状态:正在积极现代化,以实现与 ios-simulator-skill 的功能对等。请参阅上文“状态与路线图”部分。
可直接使用这些脚本,也可以在你的请求符合该技能的描述时,让 Claude Code 自动调用它们。