用AccessibilityService复现李跳跳:从无障碍服务到自动跳过广告
2026/9/13 3:15:52 网站建设 项目流程

写这篇东西之前,我先交代一下背景。李跳跳这类工具,本质上是借助 Android 系统的无障碍服务(AccessibilityService),监听当前窗口的变化,然后在目标界面出现时,自动找到并点击对应的控件或者坐标,从而绕开开屏广告、更新弹窗、权限提示这些干扰。网上抄作业的多,讲清楚原理的少,所以这篇文章我想从零开始,把“用 AccessibilityService 复现李跳跳核心链路”这件事掰开揉碎讲一遍,涉及服务配置、窗口监听、节点查找、规则引擎、点击模拟、以及我踩过的各种坑。

如果你打算自己写一个“轻量版李跳跳”,或者想在无障碍这个方向上做点练手项目,这篇文章的记录应该能帮你省掉不少试错时间。

1. 整体设计思路:为什么非得是无障碍方案

1.1 对比了一圈,只有无障碍服务能“通用地”完成这件事

自动跳过开屏广告,听起来就两件事:发现特定界面、执行点击操作。真做起来有几种可选的路线:

  • ADB 模拟点击:通过adb shell input tap x y可以实现点击,但需要电脑连着手机,或者手机上有 root / 无线调试权限。普通用户不可能这么用。
  • 应用内嵌 SDK 判断:比如在自家 App 里搞广告拦截,但跳出自家应用就无能为力了。
  • 辅助功能(Accessibility)监听界面节点:系统允许一个 App 注册无障碍服务,当窗口内容变化时,回调里能拿到完整的控件树信息,还可以反向对节点执行点击、滚动等操作。这正是李跳跳类工具的基石方案。

所以结论很明确:在不动系统、不 root、不依赖外部设备的前提下,系统级辅助功能权限是唯一稳定且通用的入口。做技术选型的时候,其实不用纠结,这条路就是最合适的那条。

1.2 功能拆解:一个最小可用的“李跳跳”有几层结构

从实际运行角度看,一个“李跳跳”的完整链路分成四层:

  • 无障碍服务层:负责注册监听、接收系统事件回调、获得节点树访问能力。
  • 规则引擎层:负责把“当前界面特征”和“规则库里的规则”做匹配,判断是不是需要执行动作的界面,再决定执行哪类动作。
  • 动作执行层:找到对应控件后,通过performAction(AccessibilityNodeInfo.ACTION_CLICK)dispatchGesture完成点击;如果节点不可点击,还要自动向上寻找可点击的父容器。
  • 调试与展示层:你需要一个悬浮窗或者日志输出,方便看到当前识别到什么 Activity、命中了什么规则,否则这种自动化工具在真机上调试会非常痛苦。

李跳跳的成熟版本还会加上“规则文件更新”“内置规则备份”“按应用黑白名单管理”这些能力,但底层的结构就是上面四层。我建议个人项目先做好前两层,跑通之后再扩展。

1.3 关键技术点:窗口状态变化是核心信号

李跳跳类的工具,最依赖的事件就是TYPE_WINDOW_STATE_CHANGED。每次弹窗、Activity 切换、甚至对话框出现,系统都会发这个事件。在这个事件的回调里,你可以通过getRootInActiveWindow()拿到当前界面的根节点,这是一切规则的起点。

这里有个容易忽略的细节:不是所有窗口变化都会重新触发 Activity 的生命周期。开屏广告经常是一个 Activity,但很多 App 内部的更新弹窗是基于 Dialog 或自定义 View 的,它不会导致 onResume 变化,却会触发TYPE_WINDOW_STATE_CHANGED。所以监听这个事件就对了,别只盯着 Activity 生命周期去判断。

2. AccessibilityService 从配置到调通,全流程实录

2.1 声明服务:XML 配置里的几个关键参数

创建一个无障碍服务,需要两步。第一步是定义accessibility_service_config.xml,然后在AndroidManifest.xml里声明。

<service android:name=".service.AutoSkipService" android:exported="true" android:label="@string/accessibility_service_label" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> <intent-filter> <action android:name="android.accessibilityservice.AccessibilityService" /> </intent-filter> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_service_config" /> </service>

accessibility_service_config.xml的配置项,我贴一个实战版本:

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged" android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews" android:canRetrieveWindowContent="true" android:canPerformGestures="true" android:description="@string/accessibility_service_description" android:notificationTimeout="50" android:packageNames="" android:settingsActivity="com.example.SettingsActivity" />

几个参数值得特别说明:

  • android:accessibilityEventTypes:我建议typeWindowStateChanged必须选上,这是触发规则判断的核心事件。typeWindowContentChanged可用于部分动态加载的界面判断,但注意它比较频繁,处理不好会有性能问题。
  • android:canRetrieveWindowContent:这是能否获得节点树的总开关,必须为 true。如果它没开,getRootInActiveWindow()会一直返回 null。
  • android:canPerformGestures:允许通过dispatchGesture执行手势点击,这也是模拟触摸的一种方式。如果不打算用手势,只想对节点执行ACTION_CLICK,可以不勾选,但建议还是打开,后面调试会有用。
  • android:accessibilityFlags里有一个flagIncludeNotImportantViews,这个很容易被忽略。很多 App 为了减少无障碍节点的数量,会把不重要的容器标记为importantForAccessibility="no"。如果不加这个 flag,这些控件会被裁剪掉,部分规则的匹配就会失效。
  • android:notificationTimeout:系统合并无障碍事件的窗口时间。设置成 50ms 左右比较合适,太短事件太密集,太长响应会有延迟。

第二步,在服务类里继承AccessibilityService,并实现关键方法。

class AutoSkipService : AccessibilityService() { override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event == null) return when (event.eventType) { AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> { handleWindowStateChanged(event) } AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> { handleWindowContentChanged(event) } } } override fun onInterrupt() { // 服务被系统中断时回调,可在这里做资源清理 } override fun onServiceConnected() { super.onServiceConnected() // 服务连接成功的回调,可在此读取规则库 } }

这一步做完,你的服务已经能收到系统事件了,核心链路跑通了一半。

2.2 获取根节点:getRootInActiveWindow 和 getWindows 的选择

在事件回调里获取当前窗口节点,有两种途径:

  • getRootInActiveWindow():获取当前活动窗口的根节点。大多数场景用这个就够了。
  • getWindows():返回所有可见窗口的列表,包括状态栏、输入法窗口、以及 App 自己创建的不同类型的窗口。如果有些弹窗是以TYPE_APPLICATION_OVERLAY形式存在的,getRootInActiveWindow()不一定能拿到你要的内容,这时候需要遍历getWindows(),找到对应的那个窗口节点。

我自己在工具里保留了一个 getBestRoot() 思路:优先拿活动窗口根节点,如果判断规则未命中,再遍历所有窗口去查找。特别是遇到一些“伪悬浮窗开屏广告”时,遍历窗口方案命中率会高很多。

private fun getCurrentRoot(): AccessibilityNodeInfo? { val root = rootInActiveWindow if (root != null) return root val windows = windows for (window in windows) { val node = window.root if (node != null && node.isVisibleToUser) { return node } } return null }

注意:getWindows()需要 API 21+,且必须在accessibilityFlags里配置flagRetrieveInteractiveWindows,否则拿不到完整窗口列表。拿到的AccessibilityNodeInfo用完后要调用recycle(),虽然新版系统已经不怎么强制了,但是好习惯还是要养。

2.3 节点查找:“去哪找”和“怎么找”是两回事

节点树拿到之后,就要写查找逻辑了。系统提供的查找方法主要是这几个:

  • findAccessibilityNodeInfosByText(String):按界面文本找节点。优点是简单直接,缺点是一旦匹配到,会返回所有包含该文本的节点,需要进一步过滤可点击性。
  • findAccessibilityNodeInfosByViewId(String):按资源 ID 找节点,条件是拿到资源 id 名字。一般需要通过viewIdResourceName看日志才能拿到,适合对某个特定按钮做定制化点击。
  • 手动递归遍历:遍历全部节点树,通过 text、desc、viewId、className 等字段综合判断。李跳跳里大量使用这种方案,因为它可以表达复杂规则,比如“文本以某开头”、“类名是 TextView”、“在某个父容器下”。
fun findClickableNodes( root: AccessibilityNodeInfo?, predicate: (AccessibilityNodeInfo) -> Boolean, result: MutableList<AccessibilityNodeInfo> ) { if (root == null) return if (predicate(root)) { result.add(root) } for (i in 0 until root.childCount) { val child = root.getChild(i) ?: continue findClickableNodes(child, predicate, result) child.recycle() } }

实际使用中,我结合了“关键词匹配 + 可点击性校验 + 父节点上溯”三步操作:

  1. 先用findAccessibilityNodeInfosByText或者遍历拿到所有目标文本节点;
  2. 找到文本节点之后,还需要检查它是否可点击;如果不可点击,就向上找父节点,直到找到可点击的那个;
  3. 调用performAction(AccessibilityNodeInfo.ACTION_CLICK)

这个“找父可点击节点”的逻辑,是开屏广告里文案和按钮分离场景下最常见的解法。很多 App 把“跳过”文案放在一个 TextView 里,而真正可点击的是外层的一个 FrameLayout 或自定义 ViewGroup。

fun findClickableAncestor(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { var current = node while (current != null) { if (current.isClickable) return current current = current.parent } return null }

3. 规则引擎设计:让工具知道“看到什么画面就干什么活”

3.1 规则格式的取舍:JSON 足够,别轻易上脚本引擎

规则引擎是一个“李跳跳”的核心大脑。规则简单说就是:当符合 xx 条件的界面出现时,执行 xx 动作

规则的表达方式有几种:

  • 硬编码 if-else:适合自己测试用,几行代码搞定,但不灵活,规则一变就得改代码重新装包。
  • 本地 JSON 规则文件:把规则外置到 assets 或文件目录,主程序统一解析,支持运行时更新,这是李跳跳最常用的方式。
  • 内嵌脚本引擎(如 Lua):灵活是灵活,但性能开销、更新维护复杂度都会上升,个人项目不建议引入。

我做的版本里选用 JSON 文件,每一条规则大致长这样:

{ "app": "com.example.sample", "activity": "com.example.sample.SplashActivity", "match": [ { "type": "text", "value": "跳过", "contains": true }, { "type": "text", "value": "广告", "contains": true, "not": true } ], "action": "click", "description": "跳过开屏广告" }

字段含义说明:

  • app:应用包名,避免跨应用误触;
  • activity:目标界面 Activity 全限定名,如果能精确到 Activity 最准;不过有些 App 的广告是 Fragment 或者 Dialog 形式,Activity 名并不会变化,这时候要允许 activity 为空,走控件文本匹配。
  • match:匹配条件,一个规则里可以有多条,命中条件是“全部成立”(AND)。
  • action:目前支持click(点击命中节点)或clickFirst(点击结果列表第一个)。

3.2 匹配优先级:先精确、后包含、再正则

规则匹配的逻辑,我建议按下面的优先级顺序组合:

  • 精确匹配:文本内容、资源 ID 完全相等。
  • 包含匹配:文本里出现某关键词,比如“跳”、“广告”。
  • 正则匹配:比如“跳过.*广告”、“\d+ 秒后”这种动态数字场景。
  • 忽略大小写匹配:英文控件文案场景下用得上。

还需要注意“排除匹配”的处理,比如界面上既有“跳过”又有“广告”,你只想在按钮上点击,那么应加上“不含广告”这类约束。

fun nodeMatchesRule(node: AccessibilityNodeInfo, condition: MatchCondition): Boolean { val text = node.text?.toString() ?: "" val desc = node.contentDescription?.toString() ?: "" val viewId = node.viewIdResourceName ?: "" val target = when (condition.type) { "text" -> text "desc" -> desc "viewId" -> viewId else -> text + desc } return when (condition.matchMode) { "exact" -> target == condition.value "contains" -> target.contains(condition.value) "regex" -> Regex(condition.value).containsMatchIn(target) else -> false } }

一个匹配条件可以同时针对 text 和 desc,实际 App 里很多控件内容优先写在contentDescription里,尤其是自定义 View。所以建议匹配前把textcontentDescription都捞出来,拼接成一个候选源字符串。

3.3 规则库的组织:内置兜底 + 本地覆盖 + 在线更新

李跳跳的规则文件能干到几百万的下载量,靠的是规则库持续更新。个人项目不一定要做在线平台,但规则库的组织方式要留好接口:

  • 内置规则:打包在 assets 里的默认规则,首次运行自动复制到私有目录,起到兜底作用;
  • 本地规则覆盖:用户可以把网上分享的规则 JSON 放到指定目录,应用启动时读取外部规则,相同的app+activity+desc覆盖内置规则;
  • 在线下发:预留一个“拉取远程 JSON -> 校验 -> 替换本地规则”的接口,后续想接服务器更新规则,只需完善下载逻辑即可。

规则更新的时候,建议做一个简单的版本号字段,规则文件里带有自己的规则版本号,方便比较和提示。

{ "ruleVersion": 20260101, "rules": [] }

4. 核心执行链路:从事件回调到最终点击,中间隔着一万个坑

4.1 干扰过滤:白名单和黑名单的思路

不是所有应用的所有界面都要执行规则。在引擎前端,我们会做一次快速的包名过滤:

  • 自己的工具 App 本身不处理;
  • 系统界面(如com.android.systemui)基本可以过滤掉;
  • 输入法窗口(android.inputmethodservice.SoftInputWindow)也不要处理;
  • 想放行的应用加进白名单,优先处理;还没加白名单的,可以去规则库里匹配。

这个步骤的意义不光是为了效率,也是为了避免误触。没有包名过滤的话,比如你在设置界面里看到某个按钮文案和规则里的“跳过”一样,就可能触发误点击,非常危险。

private fun filterEvent(pkg: String?): Boolean { if (pkg.isNullOrEmpty()) return false if (pkg == context.packageName) return false if (pkg in ignoreSystemPackages) return false return true }

4.2 延迟等待的写法:别在事件回调里直接点击

无障碍事件回调里拿到的节点树,是系统在事件发生瞬间的“快照”。如果你立刻执行点击,很多时候界面动画还在加载,控件不可点击,规则匹配的文本还没渲染出来。所以我通常的做法是:解析规则 + 延迟检测 + 再次读取根节点执行

给一个简化后的流程:

override fun onAccessibilityEvent(event: AccessibilityEvent?) { val pkg = event?.packageName?.toString() ?: return if (!filterEvent(pkg)) return when (event.eventType) { TYPE_WINDOW_STATE_CHANGED -> scheduleHandle(pkg, 300) TYPE_WINDOW_CONTENT_CHANGED -> scheduleHandle(pkg, 100) } } private fun scheduleHandle(pkg: String, delayMs: Long) { handler.removeCallbacksAndMessages(null) handler.postDelayed({ handleWindowChanged(pkg) }, delayMs) }

delayMs不宜太短或太长。太短控件树没稳定,太长用户已经手动点了,也就失去意义。300ms 左右对大多数开屏广告场景都比较合适,部分动画时间较长的 App,可以单条规则里配置delay字段覆盖默认值。

这里要注意 Handler 的去重问题。TYPE_WINDOW_CONTENT_CHANGED事件触发频率非常高,一个界面可能导致几十个回调,如果每条都执行一次规则匹配,不只是性能问题,还容易在同一个规则上重复点击。我加的Handler.removeCallbacksAndMessages(null)只保留最后一次任务。但如果场景需要连续检测多次(比如广告倒计时结束后“跳过”按钮才可点),就要换成“重置延迟窗口”之类的逻辑。

4.3 点击动作的两种实现:performAction 与 dispatchGesture

  • performAction(AccessibilityNodeInfo.ACTION_CLICK):系统辅助功能框架里的标准节点动作。如果目标节点可点击,这种方式最简洁,也最不容易被系统拦截。
  • dispatchGesture:手势注入,模拟的是用户在屏幕上的触摸路径。即使你不是必须用坐标点击,但它解决一个特殊场景:部分 App 的“跳过”按钮只是自定义绘制 View,在无障碍节点树里根本没有可用节点,这时用坐标点击是最后的兜底手段。
fun clickByGesture(x: Float, y: Float) { val path = Path() path.moveTo(x, y) val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0L, 80L)) .build() dispatchGesture(gesture, null, null) }

两种方式放一起,优先用节点点击,因为节点点击对界面变化的鲁棒性更强,不会因为控件位置移动而点击失效。坐标点击需要在规则的输出里带上“点击坐标”,且只适用于屏幕尺寸固定的设备,比较脆弱,但关键时刻很有用。

4.4 连续弹窗与二次确认的编排

实际场景里,一个 App 启动可能连续出现好几个弹窗:开屏广告 -> 更新提示 -> 青少年模式提示 -> 推送授权弹窗。一个合格的工具不能只干掉第一个弹窗就收工,而是要持续监听。

我采用的方式是:每一轮处理结束之后,重置一个小周期的监听窗口。比如:

private var lastHandleTime = 0L private var handledCount = 0 private fun handleWindowChanged(pkg: String) { val root = getCurrentRoot() ?: return val rule = ruleManager.match(root, pkg) ?: return val executed = executeRule(root, rule) if (executed) { handledCount++ lastHandleTime = System.currentTimeMillis() // 本轮执行完毕,等待下一个窗口变化事件 } }

在连续弹窗场景中,时间间隔很短,规则引擎会在多个窗口间反复匹配。为了防止某个规则被反复点击,需要给规则加上“已执行标记”。李跳跳里常见的字段是justOnce,表示一条规则在同一个界面实例里只执行一次。实现上可以在规则对象里记录最近一次命中的eventTime或页面 hash,一定时间窗口内不重复执行。

4.5 白屏保护的边界处理

有时规则命中并点击之后,界面还没加载完成,新窗口的节点树里可能暂时空白,这时去匹配很容易误判。我的处理是在匹配前先简单判断根节点是否有可交互的子节点,如果整个窗口的根节点既没有文本也没有可点击节点,就认为“界面还没起来”,跳过这次匹配。

private fun isValidRoot(root: AccessibilityNodeInfo): Boolean { val candidates = mutableListOf<AccessibilityNodeInfo>() findClickableNodes(root, { true }, candidates) return candidates.size > 0 || !root.text.isNullOrEmpty() || !root.contentDescription.isNullOrEmpty() }

注意这个判断只能算启发式,不能完全准确。但实测下来,它能避开很多“闪一下白屏就误触打开应用市场”的问题。

5. 真实调试中的常见问题与排查技巧

5.1 服务收不到事件,或者偶尔自动失效

这是无障碍开发最恶心的问题之一。表现为:服务已经开启,日志里onServiceConnected也调用了,但就是没有事件回调。

排查顺序从我自己的经验看是这样的:

  • 确认系统权限里“辅助功能”确实打开,部分国产 ROM 会默认关闭,或者服务被智能省电模式杀掉。
  • accessibility_service_config.xml里检查android:packageNames,如果写死了包名,那就只能收到指定 App 的事件,建议留空表示要接收所有应用的事件。
  • 检查客户端是否在最近任务里被“上锁”,或者允许后台运行。很多 ROM 的“纯净模式”会限制后台服务。

针对这些情况,可靠的缓解方法是:

  • SettingsActivity里增加一个“检测服务是否存活”的逻辑;
  • 提供一个startActivity跳转到系统辅助功能设置页的入口;
  • 通过AccessibilityManager.getEnabledAccessibilityServiceList判断自己的服务当前是否启用。
fun isServiceEnabled(context: Context): Boolean { val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) return enabledServices.any { it.resolveInfo.serviceInfo.packageName == context.packageName } }

5.2 结论是“节点找到了,但点击没反应”

节点查找逻辑返回了可点击节点,执行了performAction,但屏幕上没有反应。常见原因有:

  • 节点已经不可用:在事件回调后你抱着那次拿到的AccessibilityNodeInfo不放,过了很久再执行,它对应的窗口已经销毁。所以每次执行动作前,最好重新通过getRootInActiveWindow()再查一次,拿到新鲜的节点。
  • 窗口类型不是应用窗口:比如你在getWindows()里找到的是输入法窗口或者其他 overlay 窗口,点击它们通常无效。
  • 目标节点在无障服务看来可点击,但实际响应区域被某个上层 View 拦截了:这时候纯performAction就不太灵了,可以考虑用dispatchGesture按坐标点一次。
排查建议: 1. 先打印目标节点的 className、viewId、text、isClickable、isEnabled。 2. 用 dispatchGesture 点击同一个节点 bounds 的中心点,验证是否有响应。 3. 如果手势有响应而节点点击无响应,基本可以确定是上层拦截问题。

5.3 规则能被正确匹配,但误触了不想点的控件

误触的破坏力比不执行还大。用户如果看到自己手机自动打开了什么奇怪的应用,第一时间就会卸载你的工具。

规避误触需要在匹配条件上做更严格的控制:

  • 能限定appactivity的规则,尽量限定;
  • 多条件命中优于单条件,文本“跳过” + 节点 className 是TextViewButton,比直接找“跳过”更安全;
  • 给每条规则加一个“最近执行时间”限制,避免同一个规则短时间内反复触发。

比如我真实遇到的案例:有个 App 的弹窗上“跳过”文案和“再看一会”都具有“跳”字,如果只按contains("跳")来匹配,很容易把“再看一会”给点了。后来我把规则改成同时要求文案排除“再看”“视频”“回放”等词,误触才没再出现。

5.4 应用升级后规则全失效

很多规则依赖 Activity 类名和 viewId。App 一升级,这些信息一变,旧规则就立刻失效。处理思路有几个:

  • 优先使用“文本匹配”,文本相对稳定,viewId 反而经常被混淆;开源工具被加固过了,viewId 更是经常变。
  • 对 activity 名变化不敏感时,放宽 activity 限制,只按包名 + 文案匹配。
  • 规则文件不能只依赖一份原样拷贝,最好给用户提供“导入外部规则”的功能,这样别人分享的新规则可以直接导进来。

5.5 全机型适配的心得:不同系统版本、不同 ROM 的差异

Android 系统的无障碍能力一直都在变化,特别是高版本上:

  • Android 11+ 的包可见性:部分手机在查询后台应用、包名列表时会被限制,但无障碍服务拿到event.packageName通常不受影响。
  • Android 12+ 的“无障碍按钮”和“服务状态提示”:系统会加强“始终显示”类型窗口的限制,同时部分 ROM 会频繁提醒“某某正在使用辅助功能”,这是系统行为,无法彻底屏蔽,只能引导用户理解。
  • 部分国产 ROM:对无障碍服务有额外限制,比如不允许读取通知、不允许长截图等,这属于 ROM 自己加的策略。这类适配问题只能靠“抓日志 + 逐个厂商适配”慢慢磨。

这里给个实际结论:无障碍工具不可能做到 100% 全机型适配,但 90% 的常见机型是可以稳定工作的。开发的时候,最好在几台不同品牌、不同 Android 版本的机器上做一次完整回归测试,至少保证主流程能跑通。

5.6 一个快速排查问题的调试面板思路

无障服务操作是“看不到”的,所以调试面板很重要。我建议在早期版本里就加一个悬浮窗开关,实时显示以下信息:

  • 最近一次事件来源包名;
  • 最近一次 Activity 类名;
  • 规则命中列表;
  • 执行动作的结果;
  • 当前窗口根节点树(可折叠)。

悬浮窗本身就是TYPE_APPLICATION_OVERLAY,要注意 Android 10+ 对 overlay 窗口的权限可以运行时请求,但部分 ROM 仍不允许自启动。调试完可以把这个开关默认关掉,只在大版本更新时开启。

写一个简单的dumpNodeTree()方法,把节点树打印出来,是调试阶段最有用的工具流程:

fun dumpNodeTree(node: AccessibilityNodeInfo?, depth: Int) { if (node == null) return val info = buildString { append(" ".repeat(depth * 2)) append("class=").append(node.className) append(" text=").append(node.text) append(" desc=").append(node.contentDescription) append(" clickable=").append(node.isClickable) append(" viewId=").append(node.viewIdResourceName) append(" bounds=").append(node.boundsInScreen.toString()) } Log.d("NodeTree", info) for (i in 0 until node.childCount) { val child = node.getChild(i) ?: continue dumpNodeTree(child, depth + 1) child.recycle() } }

这个日志可以直接输出到 Logcat,在开发阶段配合 adb 看,能显著缩短排查时间。

6. 往深了做:规则标注、在线更新和更优雅的“无感”体验

写到这里,一个最小可用的“李跳跳”其实已经完成了。但如果你想让工具达到“能长期用”的状态,还有几个方向值得扩展。

6.1 规则的“可视化标注”思路

规则文件靠手写 JSON 维护,效率很低。更优雅的方案是:开启“标注模式”后,悬浮窗实时显示当前界面的节点树和 Activity 信息,用户手动选中节点,工具自动生成一条规则 JSON。这个过程本质上就是“录制 + 生成”,生成的规则在工具里先验证一遍,再正式导入规则库。

实现思路可以这样抽象:

  1. 用户开启“规则制作模式”;
  2. 悬浮窗显示当前界面的可见控件列表;
  3. 用户点击某个控件,工具就读取该控件的 text、viewId、bounds 等信息;
  4. 再让用户选择动作(点击 / 返回 / 忽略);
  5. 生成标准 JSON,存入规则库文件。

这套东西做成以后,规则库的更新会容易非常多,也是我从“自己写死规则”到“能让别人一起贡献规则”的关键一步。

6.2 在线更新机制的轻量设计

在线规则更新不必太重。凌晨拉一次 JSON 接口,比对ruleVersion,有更新就下载并校验,再原子替换本地文件即可。校验的时候注意:

  • 必须校验 JSON 结构,不符合格式直接丢弃;
  • 文件必须限制大小,避免恶意大文件撑爆存储;
  • 规则里不要允许任意代码执行逻辑,只允许描述性的匹配和动作字段。

规则更新的接口没做之前,可以用本地 “导入/导出规则文件” 的方式过渡,很多用户需要的也只是导入别人整理好的规则。

6.3 更“无感”的体验细节

从用户体验角度,李跳跳这类工具最大的缺点是“开屏广告还没来得及跳,页面就已经过了”,或者“点击动了但感觉有延迟”。我做了几个优化后,体感提升非常明显:

  • 缩短从事件到动作的链路:不要在主线程做耗时的规则解析;把规则按包名做索引缓存,命中时直接取对应的规则,不用遍历全部规则。
  • 并行的“预解析”策略:对于包名已知的应用,在服务刚启动时就尝试扫描一次它的包信息,标注出它常见的 SplashActivity,这样事件到达时能直接和预置信息比对,减少解析时间。
  • 执行动作后不必立刻再分析:在一个小时间窗口内(比如 800ms),忽略同一个界面的后续内容变化事件,避免规则反复触发,同时减少 CPU 占用。

还有一个小细节:设置里要留给用户“是否在点击时震动反馈”的开关,某些用户很在意“到底有没有被执行”的反馈,这个开关能很好缓解这类焦虑。

6.4 安全与合规的底线

最后必须强调一句:无障碍权限属于敏感权限,做工具可以,但一定不能做恶意用途。工具只做“跳过广告/弹窗”这类用户主动授权的操作,不采集用户隐私数据,不注入代码,不更改系统设置。应用商店上架时,很多平台也会对无障碍服务的使用场景做审查,所以需要在应用描述里明确说明使用该权限的目的,并且在实际代码中恪守边界。我的心得是:权限用到哪一步,就在代码里写清楚注释,规则引擎只做窗口匹配和点击,不碰其他任何数据

合规这块不能心存侥幸,毕竟这类工具常年游走在系统能力和应用商店规则的边缘,越是热门的“李跳跳给类产品”,越容易被重点审查。开发者心里要有一根弦。

写在最后的一些体会

这一整套东西从“看懂事件回调”到“跑通一条规则点击”,再到“整理出一份能覆盖常见场景的规则库”,我实际花了大概两周的业余时间。踩坑最多的地方不是无障碍 API 本身,而是各个 App 千奇百怪的界面实现方式——有的广告是 Activity,有的是 Dialog,有的开屏广告本身就是一个View直接铺在 Window 上。规则引擎必须足够灵活,才能覆盖这些差异。

另外想说,规则库才是这类工具的护城河。代码本身并不难,真正的工程量在不停适配新的广告形式和新的弹窗策略。所以如果你也想做类似的工具,建议把精力重点放在规则格式设计和可视化标注这层,而不是反复打磨点击的毫秒级速度。速度当然重要,但规则覆盖率决定了一个工具能不能让用户真的留下来。

还有个小建议:调试的时候,手里准备一台老一点的 Android 备用机,很多系统新版本收紧权限之后,部分老机型上反而能暴露更多兼容性问题,两边对照着看,排查会更有方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询