聊一个我观察了很久的现象:手机里装了一大堆“自动跳过广告”的工具,很多人每天都在用,但真正能讲清楚它底层原理的,可能连百分之一都不到。以《李跳跳》为例,我一直很欣赏它的“极简”——没有后台联网,也没有复杂界面,核心就一个Android无障碍服务(AccessibilityService)。这篇文章我打算把它从里到外拆开,看看它是怎么做到“广告刚出现就被秒跳”的,并且带着你从头写一个最小可用的同类工具。
文章主要面向两类人:一类是好奇“李跳跳为什么能自动跳过广告”的安卓普通用户,另一类是想基于无障碍服务做自动化、辅助点击类应用的开发者。如果你只想知道规则怎么配,可以直接跳到规则库那一节;如果你是开发者,建议从上到下通读,因为很多坑不亲手踩一遍真的想不到。
1. 《李跳跳》这类工具的底层逻辑:无障碍服务到底在做什么
很多人以为《李跳跳》是用了什么黑科技,实际上它做了一件特别“笨”的事:代替你的手指去点屏幕。难点不在于“点”这个动作,而在于三点:什么时候点、怎么找到要点的按钮、以及点了之后怎么防止误触。这一整套逻辑,全部建立在Android系统的无障碍服务框架之上。
1.1 开屏广告的完整链路与机会窗口
要理解这类工具的价值,得先搞清楚开屏广告是怎么出现的。App冷启动时,广告SDK会先抢占首屏渲染,这时候用户看到的是一张全屏广告图,上面通常会有一个倒计时文字,比如“3秒后跳过”;倒计时结束或者点击“跳过”按钮,广告才退出,露出真正的业务页面。
关键就在这3到5秒的“机会窗口”。手动操作的问题是人的反应有延迟,而且广告按钮的位置五花八门,有的在右上角,有的在右下角,识别和移动手指都要时间。无障碍服务则不同,系统会在窗口切换或者内容变化时,主动把当前界面的控件树推给App,你的代码可以在几十毫秒内完成匹配和点击,比人眼定位快得多。
需要注意,不同广告形态对自动化工具的友好度完全不一样。传统类型的开屏广告,左上角或右上角有个文字按钮“跳过”,这是最好处理的;第二种是“摇一摇”广告,提示你摇晃手机进入详情页,这种就不能简单点击跳过,需要走返回键,属于另一套逻辑;第三种是弹窗式广告,关闭按钮经常伪装成×号藏在角落里。理解这些形态,后面写规则时才有针对性。
1.2 为什么不选Root、悬浮窗和“自动化脚本”
做自动化点击,技术路线其实有好几条,但各自的代价差异很大。
Root方案,最低层也最直接。有了Root权限,可以调用input命令模拟点击,无视应用层限制,控制力最强。代价是要刷机、要承担系统安全风险,而且现在主流手机厂商对Root越来越不友好,不是每个用户都愿意折腾。
悬浮窗方案,不需要Root,但要申请“显示在其他应用上层”权限。它的思路是自己画一个透明的全局悬浮层,通过坐标计算去点击目标按钮。问题在于悬浮层拿不到目标App的控件树,只能靠预设坐标,而不同手机分辨率不同,同一App在不同机型上布局也可能不同,维护成本极高。
无障碍服务方案,算是“中间路线”。它不需要Root,系统会主动提供界面控件信息,包括文本、按钮描述、控件ID等,我们可以做到基于语义匹配而不是固定坐标,适配性远好于悬浮窗。这也是《李跳跳》能“一套规则覆盖多机型”的根本原因。
顺带说一句,有些商业化的外挂工具会采用辅助功能+截图识别+云端规则的方式,精度更高,但隐私风险也高——你屏幕上所有内容都会被截走,值不值得用,自己权衡。
1.3 技术边界:哪些能自动跳过,哪些不能
无障碍服务不是万能的,提前知道边界能省不少劲。
能做的:文本明确的跳过按钮、带描述图标的关闭按钮、常见弹窗中的“取消”“以后再说”“暂不登录”等选项,以及部分系统级权限弹窗的自动处理。
做不了或很难做的:滑块验证码(需要模拟拖拽轨迹,且掌握不好会被识别为机器人)、图片验证码(需要OCR识图,语义判断复杂)、人脸识别(涉及系统安全认证,无障碍服务无法代替)、以及部分高度定制的视频广告(跳过按钮可能不是标准控件,而是整块可点击区域)。
更现实的是,有些App会对无障碍服务做检测,发现开启后故意调整广告样式,甚至首次启动时强制要求关闭辅助功能。遇到这种情况,技术上虽然也能绕,但涉及对抗性问题,且容易违反平台规则,我不建议普通开发者去碰。
2. 无障碍服务的关键机制与权限申请
写代码之前,先把AccessibilityService本身的机制吃透。它不是普通Service,而是由系统在特定时机自动绑定并调度的系统级服务。很多初学者最困惑的一点就是:为什么服务明明配置了,却一直收不到事件?多半是配置细节有问题。
2.1 AccessibilityService 的原理与生命周期
从开发视角看,AccessibilityService就是一个长期运行的后台服务,当系统检测到界面变化、焦点变化、窗口切换等事件时,会回调你注册的监听方法。核心回调方法有三个:
- onServiceConnected():服务被系统连接成功后调用,适合做初始化,比如加载规则、配置事件类型。
- onAccessibilityEvent():每收到一个无障碍事件都会走这里,这是业务逻辑的主战场。
- onInterrupt():服务被系统意外中断时的回调,需要做一些清理工作。
生命周期和普通Service不同,它不归你控制,而是由系统服务统一管理。用户没有在系统“无障碍”设置里手动开启时,系统不会绑定你的Service;一旦用户关闭,Service也会被系统终止。所以“权限开启”和“服务运行”是强绑定关系,这是这类工具的第一个拦路虎。
事件类型很重要,常用的是:
| 事件类型 | 含义 | 典型使用场景 |
|---|---|---|
| TYPE_WINDOW_STATE_CHANGED | 窗口状态变化,比如新Activity打开 | 开屏广告出现、弹窗出现 |
| TYPE_WINDOW_CONTENT_CHANGED | 当前窗口内容变化 | 进度条更新、按钮文本从“3秒”变“跳过” |
| TYPE_VIEW_CLICKED | 某个控件被点击 | 用于统计或防重复触发 |
| TYPE_VIEW_SCROLLED | 控件滚动 | 自动翻页、加载更多 |
开屏广告场景,主要监听TYPE_WINDOW_STATE_CHANGED,因为广告Activity一启动,窗口切换到新页面,事件就会立刻推过来。但注意,有些广告倒计时结束后按钮文本会变化,这时候需要TYPE_WINDOW_CONTENT_CHANGED配合,所以实际开发中通常两个都监听。
2.2 工程配置:清单、XML与“看不到我”的权限
配置一个无障碍服务分两步:第一步在AndroidManifest.xml中声明Service;第二步在res/xml/中写一个配置文件描述服务行为。这两步缺一不可,少了任何一个,系统都不承认你的服务。
先看清单文件中的声明:
<service android:name=".AutoJumpService" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" android:exported="true" android:label="@string/accessibility_service_label"> <intent-filter> <action android:name="android.accessibilityservice.AccessibilityService" /> </intent-filter> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_service_config" /> </service>注意android:exported="true"是必须的,否则系统外部组件没法绑定它。android:permission声明为BIND_ACCESSIBILITY_SERVICE,这个权限是系统签名权限,第三方App申请了也不会自动获得,但在这里写上是规范做法,避免系统拒绝绑定。
再看res/xml/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|flagReportViewIds" android:canRetrieveWindowContent="true" android:notificationTimeout="100" android:description="@string/accessibility_service_description" />canRetrieveWindowContent="true"是灵魂,它决定App能否读取当前界面的控件树。没有这一项,onAccessibilityEvent里调用getRootInActiveWindow()会返回null。android:notificationTimeout设为100毫秒,是让系统在事件产生后最多100毫秒内回调,太小会频繁唤醒服务耗电,太大则响应迟钝,实测100是比较平衡的值。
“看不到我”的权限,是用户必须在系统设置里手动开启。代码里可以检测是否开启:
private boolean isAccessibilityEnabled() { String enabledServices = Settings.Secure.getString( getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES ); return enabledServices != null && enabledServices.contains(getPackageName()); }检测到没开启时,引导用户跳转到系统无障碍设置页,Intent要用Settings.ACTION_ACCESSIBILITY_SETTINGS。
2.3 服务被系统回收后的自愈方案
无障碍服务最大的痛点不在配置,而是“今天开着明天就失效了”。国产ROM的后台管理十分激进,经常在内存紧张或应用长期不活跃时,把无障碍服务连同宿主进程一起杀掉。用户感知到的现象就是:昨天还能自动跳过广告,今天突然不行了,打开设置一看,服务已经变成“关闭”状态。
我踩过不少坑,总结出的自愈方案有三层。
第一层,前端引导。App启动时先检测服务是否存活,没存活就弹一个友好提示,直接跳转到无障碍设置页。很多用户连哪个是服务都分不清,所以开关旁边最好放一段通俗解释。
第二层,保活。设置一个前台Service持有通知,适当延长进程优先级。要注意,无障碍Service本身不一定需要前台服务,但宿主App如果完全退到后台,进程很容易被回收。让用户把App加入ROM的“不受限制”或“自启动白名单”也很关键。不同机型的设置路径差异很大,MIUI在“省电策略”、EMUI在“电池优化”、OriginOS在“后台耗电管理”。
第三层,自动重开。监听系统无障碍设置变化事件。设置内容变化时,尝试重新绑定服务。实测这层在部分ROM上有效,但别指望100%,因为有些系统限制第三方App读写ENABLED_ACCESSIBILITY_SERVICES。
我的心得是:保活方案点到为止就好,不要在对抗系统上过度投入,引导用户做一次设置,远比自己死磕“永久不被杀”来得实在。
3. 核心模块拆解:界面感知、规则匹配与自动点击
无障碍Service打通之后,真正干活的是三个模块:拿到控件树、按规则匹配目标按钮、执行模拟点击。这三个模块各有各的坑,我一一拆开讲。
3.1 从根节点开始:如何拿到当前界面的控件树
在onAccessibilityEvent中拿到事件后,通过getRootInActiveWindow()可以获取当前窗口的根节点AccessibilityNodeInfo,它是一个树形结构,包含屏幕上所有可读控件。节点上的关键属性有:
- getPackageName():当前界面对应的应用包名,用来匹配规则。
- getClassName():控件类型,比如android.widget.Button或android.widget.TextView。
- getText():控件文本,比如“跳过”“关闭”。
- getContentDescription():内容描述,很多图片按钮没有文本,但有描述。
- getViewIdResourceName():控件ID,形如com.example.app:id/btn_skip,在原生控件下可用,WebView和Flutter里可能是null。
- isClickable():节点本身是否可点击。
调试时有个大神级别的命令可以离线查看当前界面的控件树:
adb shell uiautomator dump /sdcard/ui.xml adb pull /sdcard/ui.xml拉下来之后用浏览器打开,或者用文本格式化工具查看,就能看到当前屏幕上所有控件的层级和属性。这个命令对“为什么我的规则没匹配上”这类问题,非常有诊断价值。
代码里遍历控件树,一般用深度优先搜索。我提供一个简化版框架:
private void dfs(AccessibilityNodeInfo node, List<AccessibilityNodeInfo> result) { if (node == null) return; result.add(node); for (int i = 0; i < node.getChildCount(); i++) { dfs(node.getChild(i), result); } }注意,getRootInActiveWindow()返回的根节点很“重”,遍历完一定要及时recycle()(无障碍节点需要手动回收,否则高频率事件下容易耗尽Binder缓存,导致后序事件变慢甚至收不到)。这是很多人忽略的细节。
3.2 规则库到底长什么样:JSON结构设计
《李跳跳》最聪明的设计之一,是把“跳广告的逻辑”从程序里抽出来,做成一份份外部规则。Everyone在网上搜“李跳跳规则库txt”,本质上搜的就是这些配置文件。它们之所以能一直更新,不是因为App本体更新,而是因为规则文件在变——新的App版本换了按钮ID或文案,只需要改规则,不需要重新安装应用。
一个简化的规则JSON长这样:
{ "package": "com.jingdong.app.mall", "windowRegex": ".*", "actions": [ { "id": "skip_ad", "action": "click", "target": { "textRegex": "跳过|跳过广告|SKIP", "clickableAncestor": true }, "maxExecutions": 1, "cooldownMs": 1000 } ] }字段含义拆解:
- package:限定规则只在某个App内生效,防止在别的应用里误点。
- windowRegex:匹配窗口类名,用正则表达式,默认" .*" 表示匹配所有窗口。
- actions:动作列表,每个动作包含匹配目标和执行策略。
- textRegex:控件文本需要匹配的正则表达式,比如“跳过|跳过广告”。
- clickableAncestor:表示如果目标控件本身不可点击,就向上找可点击的父节点。
- maxExecutions:这个动作最多执行几次,通常设1,防止反复触发。
- cooldownMs:冷却时间,两次匹配之间至少间隔多少毫秒。
为什么用正则而不写死文本?因为不同App的措辞五花八门,“跳过广告”“跳过”“广告可跳过”“5秒后可跳过”,写死的规则很快就失效。正则表达式的灵活性,是规则库能存活很关键的底气。
另外,规则里还应该支持“动作用返回键”“动作用自定义坐标”等扩展。返回键在处理摇一摇广告和全屏视频广告时很有用;自定义坐标则用于某些无法通过控件树定位的场景。
3.3 匹配算法:别上来就点击“跳过”
我见过很多初学者写的第一版匹配逻辑,就是遍历所有控件,看到文本等于“跳过”就立刻点击。这个方案在早期可能有效,但实际使用中会频繁误触。比如有些App的“跳过”按钮旁边有一个“继续观看”的链接,或者页面里原有内容就包含“跳过”二字,盲目匹配很容易点错。
成熟的匹配算法需要考虑几个因素。
第一,位置约束。开屏广告的跳过按钮,通常位于屏幕顶部或右上角的固定区域。匹配时可以先判断节点坐标是否符合“顶部区域”约束,不满足的直接排除。新增一个基于坐标范围的过滤条件,往往能把误触率降低一个量级。
第二,层级判断。很多情况下“跳过”文本的TextView本身并不可点击,真正可点击的是外层包了一层FrameLayout或Button。匹配到TextView后,不要急着执行点击,先检查它的父节点、祖父节点是否可点击,如果可点击再点击外层节点。这样逻辑上更贴近真实操作。
private AccessibilityNodeInfo findClickableAncestor(AccessibilityNodeInfo node) { AccessibilityNodeInfo current = node; while (current != null) { if (current.isClickable()) return current; current = current.getParent(); } return null; }第三,去重复。同一个窗口短时间内会推送多次CONTENT_CHANGED事件,如果不做去重,一次广告出现可能会被连点好几下。用“包名+窗口类名+规则ID”作为缓存键,记录最近一次执行时间,在冷却时间内直接返回。
第四,倒计时文本的干扰。常见的广告倒计时是“3秒后跳过”“5秒后可以跳过”,这类文本里的“跳过”字样不该触发点击。处理方式是优先匹配纯“跳过”或倒计时归零后的“跳过”,可以在正则里加负向前瞻,或者判断文本是否包含数字。
3.4 模拟点击的两种姿势
匹配到目标节点后,执行点击有两种方式。
第一种是performAction,这是最简单也最稳定可靠的:
boolean success = clickableNode.performAction(AccessibilityNodeInfo.ACTION_CLICK);它本质上是向无障碍框架发送一个点击请求,由系统模拟对该控件的点击。优点是无需关心控件在屏幕上的精确坐标,系统会根据控件位置触发触摸事件。缺点是只能点击“节点”,如果目标区域不是标准控件,或者页面是WebView,就无能为力。
第二种是dispatchGesture,从Android 7.0开始支持,可以模拟任意坐标的点击、滑动、长按:
Path clickPath = new Path(); clickPath.moveTo(x, y); GestureDescription.ClickBuilder builder = new GestureDescription.Builder() .addStroke(new GestureDescription.StrokeDescription(clickPath, 0, 100)) .build(); dispatchGesture(builder, null, null);dispatchGesture更灵活,但需要自己计算坐标。坐标可以来自节点缓存的getBoundsInScreen(),也可以用预设的偏移量。注意,如果节点已经被回收,getBoundsInScreen()可能抛异常,所以执行坐标点击前先判断节点是否仍然“活着”,可用isVisibleToUser()方法辅助判断。
我的建议是:能用performAction就尽量用,只有在performAction返回false或目标不在控件树上时才考虑dispatchGesture。理由很简单,performAction更省电,也不容易被系统判为异常操作。
4. 动手实现:打造一个最小可用的《李跳跳》
理论知识讲完了,开始真刀真枪地实现。这个章节我会给出一版完整的代码骨架,你可以在Android Studio里直接建工程跑起来。目标很明确:打开一个带开屏广告的App,在3秒内自动点击“跳过”。
4.1 准备开发环境
开发Android无障碍应用,首先需要准备好Android Studio。到官网下载最新稳定版即可,安装过程没有特别的门槛,SDK组件装默认的就行。如果你要连真机调试,注意在开发者选项里开启“USB调试”,不同手机厂商还有各自的勾选项,比如小米需要额外打开“USB安装”和“USB调试(安全设置)”,在弹窗里允许“通过USB安装应用”。
为什么建议真机调试?因为无障碍服务和系统UI交互太紧密,模拟器上的界面、广告SDK行为都与真机有差异。我一开始用模拟器看规则匹配,换了真机才发现坐标和层级关系完全不同。
开发语言推荐用Kotlin,代码会比Java简洁不少。如果工程是现有项目,混着写也没问题,核心逻辑不依赖语言特性。
4.2 服务端代码:核心Kotlin实现
先定义一个AutoJumpService,继承AccessibilityService。我只保留最核心的逻辑,方便阅读:
class AutoJumpService : AccessibilityService() { private lateinit var ruleManager: RuleManager override fun onServiceConnected() { super.onServiceConnected() ruleManager = RuleManager(this) ruleManager.loadRules() } override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event == null) return if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && event.eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) { return } val packageName = event.packageName?.toString() ?: return val root = rootInActiveWindow ?: return val handled = ruleManager.matchAndExecute(packageName, root) if (handled) { // 已执行点击,本次事件结束 } root.recycle() // 关键,防止节点缓存撑爆Binder } override fun onInterrupt() { Log.d("AutoJumpService", "service interrupted") } }然后是RuleManager,负责加载规则、匹配和执行:
class RuleManager(private val context: Context) { private val rules = mutableListOf<SkipRule>() private val lastExecutedAt = HashMap<String, Long>() fun loadRules() { val jsonString = readJsonFromAssets("rules.json") // 解析JSON,构造SkipRule列表 } fun matchAndExecute(packageName: String, root: AccessibilityNodeInfo): Boolean { val nodes = mutableListOf<AccessibilityNodeInfo>() dfs(root, nodes) for (rule in rules) { if (rule.packageName != packageName) continue val windowClassName = root.className?.toString() ?: "" if (!rule.windowRegex.toRegex().matches(windowClassName)) continue val cooldownKey = "${packageName}_${rule.id}" val lastTime = lastExecutedAt[cooldownKey] ?: 0L if (System.currentTimeMillis() - lastTime < rule.cooldownMs) continue for (node in nodes) { val text = node.text?.toString() ?: "" val desc = node.contentDescription?.toString() ?: "" if (!rule.targetRegex.toRegex().containsMatchIn(text + "|" + desc)) continue // 查找可点击祖先 val clickable = findClickableAncestor(node) ?: continue if (clickable.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { lastExecutedAt[cooldownKey] = System.currentTimeMillis() return true } } } return false } }这里我用assets目录缓存规则JSON,省去运行时读取外部文件的权限麻烦。规则的来源可以是内置、用户手动导入或云端下发,后两种属于扩展,这套骨架不影响。
4.3 规则文件与热加载
规则文件放在assets里是最省事的方案,但用户没法自定义。如果要做一个可配置版本,可以把规则放到应用私有目录:
val rulesFile = File(context.filesDir, "rules.json")私有目录不需要任何存储权限,但用户也没法用文件管理器直接改。更方便的做法是让用户通过系统的“文件选择器”选择一个JSON文件,然后拷贝到私有目录:
val input = contentResolver.openInputStream(uri) input?.copyTo(File(filesDir, "rules.json").outputStream())这里有个很常见的坑:Android 11以后分区存储限制更严格,直接读取/mnt/sdcard路径或者访问其他应用的Android/data目录,基本都会碰壁。网上搜到的“content://com.ss.android.uri.key/external_root/android/data/...”这类链接,本质上是某些App通过FileProvider暴露的路径,它不是一种通用的规则读取方式,版本一变就可能失效。所以正经方案是走Storage Access Framework,让用户自己选择文件,由系统授权。
热加载不需要重启整个App。如果规则文件被替换了,监听文件变化后重新解析即可。最简单的做法是在设置页放一个“重新加载规则”按钮,手动触发loadRules()。用FileObserver监听文件变更也能实现自动重载,但要注意在部分手机ROM上FileObserver并不总是稳定。
4.4 真机验收与日志排查
代码写完,迫不及待想测试。安装并开启无障碍服务后,打开一个带开屏广告的App,重点观察两点:广告是否在3秒内消失;日志里是否出现了匹配记录。
排查没生效时,我习惯按以下顺序定位:
- 服务本身是否开启?去系统无障碍设置页确认,App后台进程是否还活着。
- 事件有没有收到?在onAccessibilityEvent里打日志,如果压根没有事件回调,多半是XML配置或清单声明有问题。
- 控件树里有没有目标文本?用adb uiautomator dump把页面控件树拉下来,搜索“跳过”,看它在什么包名、什么窗口类名下。
- 规则匹配逻辑有没有执行?打印matchAndExecute中每个条件是否命中,尤其看正则表达式是否写对了。
- 点击是否成功?performAction返回false时,换dispatchGesture试坐标点击。
一条很建议的经验:把“完整匹配日志”放到Debug模式下输出,线上版本关闭。因为无障碍服务的日志量非常可观,全量打Log既拖慢速度,又会被某些ROM识别为异常行为。
5. 实战中踩过的坑与体验调优
源码写出来和真正跑起来顺滑,中间隔着十万八千里的坑。这节把我自己遇到过的典型案例整理成速查表,按频率排序,帮大家避开。
5.1 机型省电策略导致服务被杀
遇到的第一个大坑是服务“莫名奇妙”失效。最开始在小米手机上测试,下午还能用,晚上就发现跳广告不工作了。打开无障碍设置,服务开关已经变成关闭状态,甚至有时候开关还是开的,但服务进程已经没了。
排查后发现,是MIUI的省电策略在搞事情。系统检测到应用长期后台运行,自动把它列进“耗电异常”名单,杀掉进程并撤回无障碍绑定。解决办法是引导用户手动把App的“省电策略”改为“无限制”,同时App内用前台服务保活,降低被回收概率。
各机型设置路径汇总:
| 品牌 | 设置路径 | 关键项 |
|---|---|---|
| 小米/MIUI | 设置-应用设置-应用管理-该应用-省电策略 | 选择“无限制” |
| 华为/EMUI | 设置-电池-更多电池设置-休眠时始终保持网络连接 / 应用启动管理 | 关闭“自动管理”,改为“手动管理”并全部允许 |
| OPPO/ColorOS | 设置-电池-应用耗电管理-该应用 | 允许“后台运行”和“自启动” |
| vivo/OriginOS | 设置-电池-后台耗电管理-该应用 | 选择“允许后台高耗电” |
不要指望一个API能搞定所有机型,老老实实在设置页里做引导,是成本最低的方案。
5.2 分区存储与规则文件读取的坑
开发一个需要导入自定义规则的工具,迟早会遇到存储权限问题。Android 10开始强制分区存储,App不能随意读取其他应用的目录,尤其是/sdcard/Android/data/这个目录,权限收得特别紧。
网上有些教程会教你怎么通过FileProvider路径读取他人创建的规则文件,实测下来非常不可靠——这部分路径是各自App自行暴露的,结构五花八门,今天能用明天就可能失效。
我推荐的方案是SAF。在规则导入界面调用系统文件选择器,用户通过文件管理器选定规则文件后,系统返回一个content://URI,再用contentResolver读取内容。这条路最稳,且完全不需要申请存储权限,用户也没感知到数据被“偷走”。
val uri = ... // SAF返回的URI val content = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }唯一要注意的是,用户选择的文件可能不在Download目录,而是网盘、云盘等第三方存储位置,这时候读取速度可能会慢一些,最好在子线程里解析,避免阻塞UI线程。
5.3 WebView、Flutter等特殊界面的处理
现在的App技术栈五花八门,很多广告和页面是Flutter、React Native甚至纯WebKit渲染的,这些场景下AccessibilityService拿到的控件树会“缺胳膊少腿”。
WebView场景,控件树里往往只有一个WebView整体节点,内部的具体按钮完全不可见。这时候只能按照窗口类名去匹配,比如窗口类名是com.tencent.smtt.WebView或者android.webkit.WebView,再用文本搜索确认这是广告页,然后按坐标模拟点击。坐标从哪里来?如果广告页面的布局相对固定,坐标也可以写死在规则里,但不建议默认启用,误触风险高。
Flutter场景相对好一些,Flutter的语义控件会映射成原生无障碍节点,但resource-id通常是null,只能靠文本和内容描述匹配。而且同一控件的层级在不同Flutter版本里可能完全不一样,规则维护成本比原生高。
最麻烦的是游戏类应用或重度自定义绘制的界面,屏幕上所有东西都是Canvas画出来的,控件树为空,无障碍服务完全抓瞎。这种场景基本只能走截图+OCR识别+坐标点击的路子,属于另一套技术体系,而且有隐私争议。
5.4 规则库更新的正确姿势
最后聊一下规则库的维护。网上那些“2026李跳跳最新规则库下载”,看起来玄乎,其实本质就是一份文本配置文件,谁都可以打开看。更新的意义在于,App产品经理会改按钮文案、调布局,旧规则自然失效,所以要跟着版本迭代。
自己维护规则时,我建议遵循几个原则:
- 先小范围验证。单条规则改完后,先在目标App上跑一周,确认没有误触再发布。
- 用正则但别滥用。正则写得太宽容易误伤,写得太窄又匹配不到,锁定核心关键词,把可选词并到字符组里。
- 规则里加来源和版本号。多人协作时能快速回溯问题是哪个版本引入的。
- 不要自动下载规则文件。有供应链攻击风险,很容易被植入恶意JSON,导致App执行意外点击。最稳妥的做法是App内“手动导入+手动确认”规则。
- 尊重产品边界。这类工具适合跳过骚扰性广告,不应该用来绕过付费墙、自动签到刷积分,也不要去对抗App的安全机制。
谈谈我个人的体会,做了几个月的无障碍自动化后,最大的收获反而不是“能自动跳广告”这个结果,而是把Android的控件体系、系统服务、生命周期管理摸了个透。这个项目麻雀虽小,五脏俱全,从权限设计、进程保活、文件存储到界面分析和正则匹配,覆盖了移动开发的一整块拼图。技术之外,我也会优先用“最小权限、本地运行、透明可审查”的思路来设计自己的工具——不采集用户数据,不让规则偷偷联网,所有动作用户都看得见。这么做,既是对别人的尊重,也是保护自己。最后留个小技巧:如果你也做了类似工具,去朋友手机上测试时,别只关注“是否点掉了广告”,多看一眼“有没有在正常页面里误触发点击”,这类误触才是毁掉用户信任的头号杀手。