mcpField
A
android-emulator-skill

android-emulator-skill

github.com/fluxxion82测试与质量1184 次安装5 天前更新
代码仓库

关于 android-emulator-skill

Android Emulator Skill 提供脚本,通过无障碍导航和结构化界面数据构建、测试及自动化 Android 应用,适用于需要管理模拟器和应用生命周期的开发与测试流程,无需依赖屏幕坐标。

主要功能

  1. 01通过界面层级检查屏幕
  2. 02启动、安装和管理应用
  3. 03启动、创建和关闭模拟器
  4. 04清除或删除 AVD
  5. 05返回机器可读的 JSON 结果

适用场景

  1. 01测试时映射应用界面元素
  2. 02启动应用并打开深层链接
  3. 03为测试创建或启动 AVD
  4. 04在测试运行之间重置模拟器数据

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/fluxxion82/android-emulator-skill --skill android-emulator-skill

安装后在 Claude Code 中输入/android-emulator-skill确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
examples/
login_flow_example.sh
scripts/
accessibility_audit.py
android_health_check.sh
anr_watcher.py
app_launcher.py
app_state_capture.py
appearance.py
avd.py
build_and_test.py
container.py
crash_triage.py
device_list.py
emulator_boot.py
scripts/common/
__init__.py
adb_exec.py
anr_pipeline.py
anr_sessions.py
batch.py
cache_utils.py
cli.py
device_utils.py
emu_console.py
env_config.py
hierarchy.py
log_lifecycle.py
logcat.py
run_as.py
screenshot_utils.py
sdk_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 个模块)
  1. common/device_utils.py - 构建 ADB 命令并检测设备

  2. common/screenshot_utils.py - 截取并处理屏幕截图

  3. 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 个脚本)
  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 个脚本)✓ 已完成
  1. 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
  2. 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
  3. 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
  4. 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/avd
    • N 必须至少为 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
  5. 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 个脚本)✓ 已完成
  1. 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
  2. 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
导航与交互(4 个脚本)
  1. 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
  2. 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
  3. gesture.py - 执行滑动、滚动和长按

    • --scroll {up,down} 表示内容移动的方向;--swipe 表示手指的动作。二者方向相反。(混淆二者曾是一个实际缺陷:--scroll down 会把列表拖回顶部,却报告成功。)
    • 定向滑动
    • 自定义滑动坐标
    • 滚动和长按
    • 选项:--swipe、--from-edge、--duration、--long-press、--scroll、--serial、--json
  4. 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 个脚本)✓ 已完成
  1. accessibility_audit.py ⭐ 新增 - WCAG 合规性检查

    • 缺少内容描述:任何可操作控件(根据其 uiautomator 属性判定,而非类名) 若自身或其子树中没有描述文本,则属于严重问题。旧的类名门槛在 Compose 屏幕上完全不会触发,因为其中的控件都是普通的 android.view.View。
    • 验证触控目标尺寸
    • 检查 EditText 提示文本
    • 图像无障碍检查
    • 按严重程度分类(严重/警告/信息)
    • 保存报告(JSON + Markdown)
    • 选项:--serial、--output、--verbose、--json
  2. visual_diff.py - 比较屏幕截图以检测视觉变化

    • 逐像素精确比较
    • 高亮显示差异
    • 生成差异图像
    • 用法:visual_diff.py BASELINE CURRENT [--output DIR] [--threshold N] [--json]
    • 注意:两张图像是位置参数。--json 现在会输出完整报告,因此此脚本与 其他所有脚本保持相同的契约;对于在此功能加入前编写的调用方,--details 仍作为已弃用的别名保留。
    • 选项:--output、--threshold、--json、--details
  3. 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
  4. 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 个脚本)✓ 已完成
  1. 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
  2. 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 仍会报告实际电量)。
  3. 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 一直无法使用。
发现与设备状态(14 个脚本)⭐ NEW
  1. 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
  2. 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
  3. 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
  4. 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
  5. 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": [...]}
  6. location.py - 通过 adb emu geo fix 在模拟器上模拟 GPS(固定坐标、城市预设、GPX 路线回放)。仅限模拟器。

    • 选项:--lat/--lng、--city、--gpx FILE、--interval/--speed、--clear、--list-cities、--serial、--json
  7. container.py ⭐ NEW - 通过 adb shell run-as 检查可调试应用的沙箱(对发布版应用会明确报错)。

    • 列出/读取文件、转储 shared_prefs XML、列出数据库并转储 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
  8. 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
  9. 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 状态下发布的故障当成启动失败;第五次审查发现系统信任 stopped meta,但该版本的工作进程仍在运行(返回退出码 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
  10. sms.py ⭐ NEW - 向模拟器发送一条入站短信,并确认短信已送达。仅适用于模拟器。

    • --send 会读取收件箱并分别报告 accepted 和 delivered:控制台的 OK 表示命令已受理,并不代表短信已存在。短信为异步送达(实测约需 2 秒),因此脚本会轮询。
    • --otp 会从最新短信中提取一次性验证码,并打印验证码所在的短信,以便核验提取逻辑。
    • 选项:--send --to NUM --body TEXT [--no-verify]、--list [--limit N]、--otp、--serial、--json、--verbose
  11. 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
  12. crash_triage.py ⭐ NEW - 将专用崩溃缓冲区(logcat -b crash)解析为结构化崩溃信息,并按故障分组。

    • 按软件包、异常和签名堆栈帧对重复崩溃分组(否则崩溃循环会淹没输出),并标出最有助于分类的堆栈帧,同时说明选择依据;其中也包括诚实说明“没有应用堆栈帧;所有堆栈帧均为框架代码”。
    • 退出状态表示“分类流程是否运行”,而非“是否发生崩溃”——请根据 --json 中的 crash_count 进行判断,或传入 --fail-on-crash。
    • 选项:--package PKG、--clear、--fail-on-crash、--serial、--json、--verbose
  13. 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
  14. 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 simctladb / avdmanager / emulator✓ 完成
IDBadb shell uiautomator / input✓ 完成
iOS SimulatorAndroid Emulator✓ 完成
xcodebuildGradle wrapper✓ 完成
Accessibility treeUI hierarchy dump✓ 完成
simctl privacypm grant/revoke✓ 完成
xcresultGradle 测试报告 / 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> 也无法使用:该调用需要一个 ClipData Parcelable,其文本通过 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 自动调用它们。

评论

支持 Markdown