Skip to content

优化无障碍服务事件处理性能并补充单元测试 - #108

Merged
zfdang merged 4 commits into
masterfrom
perf/accessibility-traversal
Sep 22, 2026
Merged

zfdang merged 4 commits into
masterfrom
perf/accessibility-traversal

Conversation

@zfdang

@zfdang zfdang commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

背景

对无障碍服务的事件处理路径做了架构分析、真机(模拟器)profiling,并针对热点和设计问题做了优化,同时补上了项目此前完全缺失的自动化测试。

架构分析摘要

事件流:系统以 50ms 节流推送 TYPE_WINDOW_STATE_CHANGED / TYPE_WINDOW_CONTENT_CHANGED → onAccessibilityEvent(主线程)→ 单线程执行器对节点树做 BFS,每个 getChild() 一次 Binder IPC。

simpleperf 采样(debug 包,8 个应用冷启动 ×1 轮,60s)显示 baseline 的 CPU 分布:GC 线程 39%、遍历线程 21%(其中 describeAccessibilityNode 占 9%)、Binder 反序列化线程约 23%、主线程 10%。

发现的设计问题(详见提交内的注释):

# 问题 处理
1 同一 event.getSource() 子树被 widget / keyword 两个任务各遍历一次,getSource() 也调两次 合并为一次遍历同时匹配两种规则
2 skipAdRunning == false 时每个 CONTENT_CHANGED 仍做 toString() + HashSet.contains 事件入口 fast-path 直接返回
3 执行器无背压:广告页每秒数十个事件排队,跑的是过期子树,最新内容反而最后扫 自维护有界队列:同源节点去重并保留最新快照(无损)、超限淘汰最旧子树、淘汰时限频调度一次全窗口补扫保证覆盖;每个执行器任务只处理一个子树,坐标点击任务不会被饿死
4 UI 线程 clear()+addAll() 直接修改服务线程正在遍历的同一个 ArrayList / TreeMap 实例 关键字列表写时复制;服务持有规则的不可变快照
5 clickedWidgets、keyWordList、setPackages 等跨线程共享但非线程安全 / 非 volatile ConcurrentHashMap.newKeySet() + volatile
6 node.getParent() 可能为 null 直接调用;规则 JSON 缺字段时 e.idName.isEmpty() NPE null-safe
7 关键字用单个空格 split,连续空格产生空字符串关键字 → 匹配所有短文本 按空白切分并丢弃空项、去重
8 onUnbind 未关闭执行器、未清理 Handler 延迟消息 补全生命周期
9 Settings 每次初始化都同步查 PackageManager 计算默认白名单 仅首次(无持久化值)时查询
10 白名单对话框在主线程同步解码全部应用图标 + 每个应用多一次 getApplicationInfo IPC 复用 ResolveInfo.applicationInfo,图标在列表滚动时按需加载
11 largeHeap=true 让常驻进程更易被 LMK 回收;Manifest 声明的 .TouchHelperServiceReceiver 类不存在 移除
12 describeAccessibilityNode 用 String.format + 多次拼接 StringBuilder

Profiling 与结果

环境:Pixel 9 Pro XL 模拟器(API 36 / arm64),白名单清空,8 个系统应用冷启动 × 2 轮(16 次启动,与「打开 App → 开屏广告」路径一致),统计服务进程 /proc/<pid>/stat CPU 时间与 debug 日志计数。

debug 包(含诊断日志)

指标 baseline 优化后
实际执行的遍历次数 295 110
访问节点总数 4470 2790
去重 / 淘汰 / 补扫 – 45 / 105 / 8
进程 CPU 时间 1030 ms 820 ms
simpleperf 60s 负载采样数(task-clock) 767 586
GC 线程(HeapTaskDaemon)采样占比 39% 29%

release 包(R8,AOT -m speed,同一负载)

baseline 与优化后的进程 CPU 都在 560–700 ms 区间波动,多轮交错测量差异在噪声内。这个负载下服务每 100 秒只消耗约 0.6 秒 CPU,事件投递与 getSource() IPC 等每事件固定成本两版相同且占主导,所以 release 下 CPU 总量没有可测量的变化;优化收益体现在遍历/IPC 次数、可控的队列深度与延迟,以及上表中的正确性修复。

期间否决的一个方案:仅靠「超限丢弃 + 全窗口重扫」做背压,在 release 下反而略慢(全窗口重扫要拉取上百个节点,代价高于省下的小子树)。最终版用去重与淘汰最旧子树减少重扫,全窗口补扫限频 1 次/秒,只在确实丢弃过子树时触发。

审查后修复的三处回归(见第 3 个提交):去重曾保留旧节点快照导致文字更新后漏点;drain 独占执行器导致坐标点击被饿死;关键字命中曾提前结束遍历、抢占了控件规则的优先级。

测试

新增 39 个 JVM 单元测试(./gradlew :app:testDebugUnitTest,全部通过):

  • SkipAdRulesTest(17):关键字解析、关键字匹配(长度上限、排除自身名称、优先级、空关键字)、控件规则匹配(坐标 / id / 描述 / 文本、JSON 缺字段)。
  • TouchHelperServiceImplTest(22,Robolectric):用真实 AccessibilityEvent / AccessibilityNodeInfo 树驱动服务:跳过流程的启动 / 停止 / 超时,关键字点击、同一控件只点一次、非目标包忽略、控件规则命中后停止控件匹配,背压(同源去重保留最新快照、同窗口全扫合并、淘汰最旧、全窗口扫描不被淘汰、淘汰后触发补扫、执行器在两次扫描间让出)、控件规则优先于更早出现的关键字、全窗口扫描仅限目标包窗口(输入法/桌面/白名单窗口不扫描),onUnbind 关闭执行器,遍历不泄漏 AccessibilityNodeInfo。

构建配置:JUnit 4.13.2、Robolectric 4.14.1、android.jetifier.ignorelist=bcprov(Robolectric 依赖的 bcprov 为 Java 21 字节码,Jetifier 无法处理也无需处理)。

兼容性说明

  • 控件规则与关键字匹配语义与之前一致:单次遍历内控件规则仍优先,关键字命中仅在整棵树没有控件命中时才点击。
  • 自定义控件 / 位置对话框保存后会刷新服务内的规则快照,行为不变。

🤖 Generated with Claude Code

Profiling the service (simpleperf on an API 36 emulator, 16 cold app
launches) showed 39% of CPU in GC, 21% in the node-tree traversal and
~23% in binder threads deserializing AccessibilityNodeInfo parcels.

Service (TouchHelperServiceImpl):
- merge widget and keyword matching into a single traversal per event
  instead of two traversals of the same sub-tree
- return early from TYPE_WINDOW_CONTENT_CHANGED while no skip-ad
  process is running, before any allocation
- bound the traversal backlog with a small queue of our own: drop events
  whose source sub-tree is already queued, evict the oldest sub-tree when
  the queue is full, and make up for evictions with one rate-limited
  full-window scan once the queue drains
- keep immutable snapshots of widget/position rules and a copy-on-write
  keyword list so the worker never iterates a collection the UI mutates;
  make cross-thread fields volatile and clickedWidgets concurrent
- fix NPE when node.getParent() is null, tolerate rules imported from
  JSON with missing fields, shut down the executor and clear pending
  handler messages on unbind

Settings / UI / manifest:
- only query PackageManager for the default whitelist on first run
- split keywords on whitespace and drop blanks (a blank keyword matched
  every short label)
- whitelist dialog: reuse ResolveInfo.applicationInfo and decode icons
  lazily while scrolling instead of all up-front on the UI thread
- describeAccessibilityNode: StringBuilder instead of String.format
- remove largeHeap and the declared but non-existent
  TouchHelperServiceReceiver

Tests:
- extract the matching rules into SkipAdRules (pure Java) with JUnit
  tests, and add Robolectric tests that drive TouchHelperServiceImpl with
  real AccessibilityEvent / AccessibilityNodeInfo trees (start/stop,
  clicks, dedupe, back-pressure, eviction, rescan, unbind, node
  recycling); 31 tests in total
- dedupe by source view now replaces the queued AccessibilityNodeInfo
  with the newest snapshot instead of dropping it: node text/bounds are
  cached at fetch time, so a label that changed from "3s" to "跳过" while
  the worker was busy was scanned with its stale text. The same
  collapsing now also applies to repeated full-window scans of one window
- the drain processes one sub-tree per executor task and re-submits
  itself, so scheduled position clicks are no longer starved by a
  continuous stream of content-changed events
- the full-window rescan is scheduled (rate limited) when a sub-tree is
  evicted rather than when the queue happens to drain
- widget rules keep priority over keywords inside the single traversal:
  a keyword hit is only remembered while widget rules are active and is
  clicked at the end if no rule matched anywhere in the tree, matching
  the former two-pass order

Adds regression tests for each case; 35 unit tests in total.
@zfdang

zfdang commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

已按审查意见修复,见提交 fix traversal scheduling regressions found in review:

  1. P1 去重丢失更新后的按钮:去重时用新到的 AccessibilityNodeInfo 替换队列中的旧快照(text/bounds 是获取时缓存的,只有 getChild() 是实时的)。回归测试:同一标签依次上报 "3s" → "2s" → "跳过",队列中只保留一条,执行时点击 1 次。
  2. P2 drain 独占执行器:每个执行器任务只处理一个子树,队列非空时重新提交自己;补扫改为在淘汰发生时就限频调度,不再等待队列排空。回归测试:3 个子树排队时,其后提交的任务在第一个子树扫完后即执行。
  3. P2 关键字抢占控件优先级:控件规则激活时关键字命中只记为候选,继续遍历找控件;整棵树无控件命中时才点击候选。回归测试:BFS 先遇到 "跳过" 文本、后遇到自定义关闭按钮,只点击关闭按钮;无控件命中时点击关键字节点。
  4. 非阻塞建议(mustRun 绕过上限):去重逻辑对全窗口扫描同样生效,同一窗口的重复请求合并为一条最新快照。回归测试:5 次全窗口请求排队 1 条。

35 个单元测试全部通过;模拟器冒烟(8 次冷启动)无异常。

Full-window scans (on window-state changes and the rescan after evicted
sub-trees) are requested asynchronously, so by the time the root is
fetched the active window may be an IME, the launcher or a whitelisted
app. IME window-state events deliberately keep the skip-ad process
running, so the rescan could traverse the keyboard window and click a
suggestion matching a keyword. The root's package is now checked against
the same package set that filters content-changed events, and windows of
excluded packages are never scanned.

Adds tests for the IME window, a stopped process, an evicted sub-tree
being covered by the rescan, and a window-state scan hitting another
package's window; 39 unit tests in total.
@zfdang

zfdang commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

已修复,见提交 only scan the active window when it belongs to a target package:

补扫和 STATE_CHANGED 的全窗口扫描都改为经 rootOfActiveTargetWindow() 取根节点:读取 getRootInActiveWindow() 后校验 root.getPackageName() 属于 setPackages(与 CONTENT_CHANGED 路径同一过滤集合,输入法、桌面、白名单、系统设置都不在其中),不属于则回收根节点、跳过本次扫描。

回归测试(4 条新增):输入法弹出触发补扫时不点击键盘窗口;补扫覆盖被淘汰的目标应用子树;流程停止后不再读取活动窗口;STATE_CHANGED 处理时活动窗口已切到其他包则不扫描。另外用您的 RescanReviewTest(5 条)在本地跑了一遍,全部通过。当前共 39 个单元测试。

@zfdang
zfdang merged commit 1a86c63 into master Sep 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant