
提到Android上的《李跳跳》很多人第一反应是“一键跳过广告的小工具”但作为开发者我第一反应是这个工具把AccessibilityService的潜力发挥到了极致。系统明明把无障碍服务设计成帮助视障用户操作屏幕的接口结果被用来做了一款自动跳过开屏广告的插件而且一度火到像系统App一样装在各色人的手机上。这背后其实是一套非常标准的无障碍自动化流程监听窗口变化、解析控件树、定位跳过按钮、模拟点击。如果你也想做一个类似的东西或者想在项目里用无障碍能力做点自动化操作这篇内容会从原理到代码一步步拆开讲。我会用自己实际做的一个Demo项目为主线涵盖服务声明、节点解析、各种点击姿势、规则引擎设计以及那些不踩一遍根本想不到的系统和厂商ROM坑。不搞花活只讲能跑通的关键路径。1. 无障碍服务到底凭什么能“看见”屏幕——AccessibilityService的运行机制与李跳跳类工具的切入点1.1 无障碍服务不是神秘黑魔法AccessibilityService是Android官方提供的一套系统级辅助接口最早是为了服务视障用户而设计的。屏幕上的内容对普通人是视觉信息对依赖屏幕阅读器的用户来说系统需要把这些界面结构转换成可朗读的语义同时允许用户在不解锁视线的情况下触发操作。这套机制拆开其实就三件事系统主动推送事件比如窗口切换、控件状态变化、滚动、点击等只要你在配置文件里声明了要监听哪些事件系统就会“推”给你一个AccessibilityEvent。读取当前界面结构你可以随时拿到当前活动窗口的控件树根节点AccessibilityNodeInfo这棵树装载了屏幕上所有可见控件的文本、位置、类型、资源ID、可点击状态等信息。模拟用户操作你可以对控件执行点击、长按、滚动、设置文本等操作也可以直接派发全局手势。关键点在于这套能力不需要root也不需要修改系统文件用户只需要在系统设置里授权“无障碍服务”开关就行。这也就是为什么李跳跳这类工具能在大量非root用户中普及——它借用的通道是系统官方提供的本身没有碰任何系统私有接口。我打个比方无障碍服务就像一个坐在旁边的人屏幕上每换一页他就会告诉你“页面变了这里有个按钮那个按钮在屏幕左上角”然后你还允许他替你去按一下。相当于系统给自动化脚本开了一扇合法的门。1.2 李跳跳类工具锁定的核心事件与节点模型要实现“自动跳过开屏广告”第一件事就是搞清楚广告出现时系统到底发生了什么。绝大多数App的开屏广告都是启动一个新的Activity或者在当前Activity上加载一个全屏Dialog/View。不管是哪种情况系统一般在界面层级发生变化时都会发出TYPE_WINDOW_STATE_CHANGED事件。这个事件就是整个工具的信号源。收到事件后我们可以从当前活动窗口的根节点开始搜索整棵控件树找“跳过”“5s”“G调”这类关键词然后追踪到对应的可点击控件最后performAction点击它。这里有个很关键的概念AccessibilityNodeInfo不是简单的“按钮”或“文字”它是一棵树结构。你打开任意一个页面系统眼里不是一张二维图片而是一个层级分明的节点树根节点下有FrameLayout、LinearLayout、ViewGroup、Button、TextView等等。广告页也一样那些“跳过”按钮只是这棵树里的叶子节点或分支节点。所以在实现时节点解析的效率和准确性直接决定这个工具的成败。很多初学无障碍开发的人写出的自动跳过工具总是点不准基本都是没理解节点树的结构只去找“文本等于跳过”的节点忽视了父节点、兄弟节点和资源ID这些信息。1.3 为什么“全局监控精准点击”能跳过开屏广告广告SDK的实现虽然有差异但大部分“跳过”按钮都是可点击的View且几乎都带着“跳过”这类文本或图标特征。李跳跳这类工具本质上是拿无障碍服务做了一个全局的UI事件监视器和指令执行器监视每一屏的界面内容变化根据一组预设规则判断当前界面是不是广告页如果命中规则就对目标控件执行点击或者返回操作整个流程的核心矛盾是“快”和“准”。快是指从广告出现到点击发生要尽量及时不然广告已经播放了几秒甚至可点击关闭了准是指不能乱点否则很容易点进广告落地页反而害了用户。这也是为什么这类工具基本都是事件驱动、轻量无UI的常驻服务。它不需要什么华丽界面一个开关、一段规则、一堆日志就够用了。2. 先搭一个能跑起来的无障碍服务骨架——工程配置、服务声明和权限引导2.1 AndroidManifest与无障碍服务XML配置的几个易错细节在Android Studio里新建一个空项目后第一步不是写逻辑而是把服务声明和配置文件配好。这一步我踩过最深的坑是配置文件路径写错或者eventTypes写成了其它值导致服务根本收不到事件系统设置里开关也打不开。正确的做法是在res/xml目录下新建一个accessibility_service_config.xml?xml version1.0 encodingutf-8? accessibility-service xmlns:androidhttp://schemas.android.com/apk/res/android android:accessibilityEventTypestypeWindowStateChanged|typeWindowContentChanged|typeWindowsChanged android:accessibilityFeedbackTypefeedbackGeneric android:accessibilityFlagsflagDefault|flagIncludeNotImportantViews|flagReportViewIds android:canPerformGesturestrue android:notificationTimeout50 android:descriptionstring/accessibility_service_description android:settingsActivitycom.example.autoskipper.SettingsActivity /解释一下几个容易被忽略的属性android:accessibilityEventTypes声明这个服务要监听哪些事件。typeWindowStateChanged是招牌事件必须带为了应对部分广告页内容变化但没切窗口的情况我也会加上typeWindowContentChanged但要注意这种事件频率很高后面要自己做节流和去重。android:accessibilityFlagsflagIncludeNotImportantViews很关键。有些App里的控件在无障碍体系中被标记为“不重要”如果不加这个标志节点树里直接看不到它们自然也就找不到“跳过”按钮。flagReportViewIds则让你能读取viewIdResourceName做规则匹配时非常有用。android:canPerformGestures从API 24开始支持手势派发如果你打算用dispatchGesture做坐标点击这个必须设置为true否则调用会直接抛异常。android:notificationTimeout系统回调事件的最小间隔单位毫秒。设得太小事件会非常密集设得太大又容易错过瞬时的开屏页。我会用50ms实测下来不会明显增加功耗。然后是AndroidManifest里的Service注册service android:name.AutoSkipService android:labelstring/accessibility_service_label android:permissionandroid.permission.BIND_ACCESSIBILITY_SERVICE android:exportedtrue intent-filter action android:nameandroid.accessibilityservice.AccessibilityService / /intent-filter meta-data android:nameandroid.accessibilityservice android:resourcexml/accessibility_service_config / /service权限android.permission.BIND_ACCESSIBILITY_SERVICE是必填的这个权限等级很高普通第三方应用拿不到只有系统才能绑定你的服务。如果你忘了声明服务在系统设置里会出现但无法启动。2.2 服务生命周期与onAccessibilityEvent回调服务本体继承自AccessibilityService核心代码其实非常短class AutoSkipService : AccessibilityService() { override fun onServiceConnected() { super.onServiceConnected() // 服务连接成功可以在这里初始化规则引擎 } override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event null) return when (event.eventType) { AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOWS_CHANGED, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED - { handleWindowChanged(event) } } } override fun onInterrupt() { // 系统中断服务时回调比如资源紧张或被用户关闭 } }onAccessibilityEvent是整个服务的事件入口后面所有逻辑都从这里分发。需要注意这个回调运行在服务的Handler线程上不是主线程。当然因为无障碍服务本身是后台组件UI操作也不多很多轻量逻辑可以直接在这边跑但耗时操作比如规则下载、文件读写建议还是丢到协程或者线程池避免卡事件流。2.3 引导用户开启服务直接跳系统设置页与检测授权状态服务写完后用户想要生效还必须去系统设置里手动打开。这里有两个关键步骤检测服务是否开启以及引导用户去设置页。检测方式是通过AccessibilityManagerfun isAccessibilityServiceEnabled(context: Context): Boolean { val manager context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager val enabledServices manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) val expected ComponentName(context, AutoSkipService::class.java) return enabledServices.any { it.resolveInfo.serviceInfo.packageName expected.packageName it.resolveInfo.serviceInfo.name expected.className } }判断服务是否开启后如果没开就跳系统设置context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) })这里有个厂商ROM的坑部分手机尤其是国产ROM在用户跳到“无障碍”页面后自己的App可能不出现在列表里或者出现在列表里但开关打开后又被自动关闭。常见原因有两个一是应用被判断成“未安装到系统应用白名单”二是用户在系统的“应用管理”里关掉了后台启动权限。这种情况下我只能引导用户到系统管家类App里把“后台弹出界面”“关联启动”“自启动”这几个权限都打开。另一个更隐蔽的坑来自Android 12/13的“受限设置”从应用商店外的渠道安装的应用首次开启无障碍服务时会遇到系统拦截提示需要到“安全设置”里允许受限设置。这个问题没办法用代码绕过只能把用户引导到对应的设置项很多普通用户就是在这一步放弃了。3. 窗口结构分析与“跳过”按钮定位——用节点树判断该点哪里3.1 从rootInActiveWindow拿到当前窗口的节点树服务开启并且收到窗口变化事件后下一步是拿当前页面节点树val root rootInActiveWindow ?: returnrootInActiveWindow返回的是当前活动窗口的根节点AccessibilityNodeInfo。拿到根节点之后就可以在这棵树上搜索目标控件。注意不是所有窗口变化事件触发时root都能立刻拿到。比如在Activity转场动画还没结束的时候rootInActiveWindow有时会返回null或者返回的是上一个页面的树。所以在事件回调里我会做一点容错拿不到就return等下一个事件拿到之后做一次包名校验确认当前界面确实属于目标App再继续。3.2 候选节点的三种识别维度文本、资源ID、控件层级定位“跳过”按钮不能只靠文本匹配否则一大批广告SDK都搞不定。我总结下来有三个维度要一起用。文本与内容描述最基础也最直观的匹配方式通过node.text或者node.contentDescription去匹配关键词。很多广告页的跳过按钮文本是动态变化的比如“跳过 5s”“跳过 4s”“G调过的几条5”所以匹配时不能做全量“等于”要跟关键词做包含匹配而且关键词要覆盖变体。比如“跳过”基本兼容大多数“G调过”这种带倒计时的也要考虑。资源ID用viewIdResourceName匹配非常靠谱因为资源ID是写死在App和广告SDK里的一般不会随页面文案变化而变。比如穿山甲广告SDK的跳过按钮ID就是类似tt_splash_skip_btn这样的格式。但资源ID对多语言、混淆、加固的情况会失效所以只能作为辅助匹配条件不能完全依赖。控件层级与可点击状态文本匹配到的节点不一定可点击但它父节点或者更高层的容器很可能是一个可点击的View。比如“跳过”的TextView外层套了一个Button或者整个卡片区域外层是可点击的FrameLayout。这时候我们要从匹配节点往上爬找到最近的clickable节点去执行点击。这里我贴一个自己的递归搜索逻辑Kotlin简化版private fun findCandidateNode( node: AccessibilityNodeInfo?, textKeywords: ListString, idKeywords: ListString ): AccessibilityNodeInfo? { if (node null) return null val text node.text?.toString() ?: val desc node.contentDescription?.toString() ?: val nodeId node.viewIdResourceName ?: val hitText textKeywords.any { text.contains(it) || desc.contains(it) } val hitId idKeywords.any { nodeId.contains(it) } if (hitText || hitId) { var target node while (target ! null !target.isClickable) { target target.parent } return target } for (i in 0 until node.childCount) { val child node.getChild(i) ?: continue val result findCandidateNode(child, textKeywords, idKeywords) if (result ! null) return result } return null }这段逻辑把“文本命中”和“ID命中”结合起来并且自动把目标上移到可点击的父节点。实际效果比单纯用findAccessibilityNodeInfosByText稳定很多。3.3 广告SDK的文本混淆与动态布局为什么不能只匹配“跳过”如果你只是匹配“跳过”两个字第一周可能感觉挺好用后面会遇到一堆翻车场景。广告SDK的文案变化五花八门。有的跳过按钮写的是“点击跳过广告”有的是“X”有的是“| 5s”这种倒计时加小字有的把跳过按钮做成图片没有文字还有的会在不同版本切换Activity/Dialog的展示方式。只靠固定文本关键词压根追不上。更麻烦的是有些广告页点“跳过”按钮并不响应传统click事件。有些是点击区域在TextView的某个角落有些是整屏可点击有些必须等待倒计时结束才允许点击。再加上Compose逐步普及节点树里的一部分控件不出现在传统View层级里而是以AndroidComposeView及内部节点展示解析方式跟传统View树又不一样。所以一个能用的规则引擎必须做到支持多条件组合文本 资源ID 控件类型支持向上找父级可点击节点支持“找不到跳过按钮就等待超时”的超时策略支持对同一个包名做多个不同规则尝试我实际跑下来用文本ID父级可点击的组合策略已经能覆盖市面上绝大多数广告SDK的跳过场景。剩下极少数不可控的就只能靠后续的手势坐标点击兜底。4. 模拟点击的实现路径与兼容性——performAction、dispatchGesture与Android版本差异4.1 最简单的方式ACTION_CLICK以及它不灵光的场景对定位到的节点执行点击最直接的方法是node.performAction(AccessibilityNodeInfo.ACTION_CLICK)这个方法本质上是把无障碍点击事件派发给目标控件效果接近用户在触摸屏上点了一下。过去我用它实现的成功率非常高但后来发现它在三种场景下容易失灵某些ROM对无障碍模拟点击做了限制特别是国产系统在特定场景下会静默忽略ACTION_CLICK。广告页里的WebView或自定义View处理touch事件的方式特殊单纯ACTION_CLICK不触发点击效果。目标控件的可点击区域很小或者节点树里标记的bounds和实际渲染不一致。遇到这些情况不能只靠ACTION_CLICK一条路走到底。4.2 手势派发dispatchGesture支持区域坐标点击从API 24开始AccessibilityService支持派发全局手势也就是dispatchGesture。这种方式是在指定的屏幕坐标上模拟一次从按下到抬起的触摸动作跟用户在屏幕上真实点一下几乎等价。简单实现一个点击坐标的手势private fun dispatchTap(x: Float, y: Float) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.N) return val path Path().apply { moveTo(x, y) } val gesture GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, 50)) .build() dispatchGesture(gesture, null, null) }这里的x、y一般取自目标节点bounds的中心点val rect Rect() node.getBoundsInScreen(rect) val centerX rect.exactCenterX() val centerY rect.exactCenterY()dispatchGesture比ACTION_CLICK的“穿透力”更强能处理更多自定义绘制View但也不是100%能命中。因为它是模拟手势如果目标页面做了“手指滑动”判定或者点击位置被另一个浮层拦截一样会点错。实际操作中我的策略是优先用ACTION_CLICK失败后再用dispatchGesture。如果两种方式都失败再尝试向上找更大的可点击容器节点用它的bounds中心坐标去做手势。这里还要注意一个API差异dispatchGesture的StrokeDescription有持续时间参数点击动作不要设太长50毫秒就够。如果设成几百毫秒那么系统会把这次手势当成一次“长按”触发完全不同的行为。4.3 低版本与高版本上的行为差异以及进程重启问题AccessibilityService在不同Android版本上的行为差异很大这是我做兼容测试时最头疼的部分。Android 7.0API 24以下没有dispatchGesture只能靠performAction节点失效后还要手动recycle性能差很多。Android 8.0/9.026/28系统开始对大图标的“不重要视图”做过滤必须打开flagIncludeNotImportantViews才能看到部分控件。Android 10/1129/30外部存储访问限制收紧如果要让用户手动导入规则文件直接访问/storage/emulated/0/Android/data/xxx路径会失败需要用系统文件选择器SAF。Android 12/1331/33新增受限设置侧载应用开启无障碍服务会遇到额外确认同时系统对后台发起Activity的限制更严格做引导跳转时要特别注意FLAG_ACTIVITY_NEW_TASK。Android 1434对无障碍服务读取敏感信息的隐私提示更醒目用户授权时会看到明确的警告文案。进程重启问题也值得一提。服务运行过程中系统可能因为内存压力回收进程之后会自动重新拉起但此时rootInActiveWindow如果拿得太早可能拿不到窗口树。我给服务加了一套简单的状态保存机制把当前规则版本和用户配置存在SharedPreferences里服务被系统重启后onServiceConnected会先恢复配置再开始监听事件这样不会因为进程重建而丢规则。5. 规则引擎设计——让工具像李跳跳一样“免更新”地适配不同App5.1 用JSON规则而不是硬编码硬编码适配单个App很简单但如果你想做的是一个通用的“跳过广告工具”那马上会遇到一个现实问题每个人的手机上装的App完全不一样同一个App在不同版本的广告SDK也是动态变化的。所以我把整个命中逻辑做成了规则驱动规则用JSON描述保存到本地。每条规则的核心结构大致是{ name: 通用开屏跳过规则, packageName: *, priority: 10, matchType: text_id_desc, keywords: [跳过, G调过], idKeywords: [splash, skip, ad], maxClickCount: 1, delayBeforeClickMs: 100, fallbackAction: back }字段含义packageName要匹配的App包名*表示所有App。matchType匹配维度组合文本、资源ID、内容描述。keywords文本关键词列表。idKeywords资源ID关键词列表匹配方式也是包含。delayBeforeClickMs事件触发后延迟多久执行点击避免在页面切换动画还没结束时点击失效。fallbackAction如果点击失败或者找不到跳过按钮是否执行返回操作。开屏广告有时候会弹一个立即打开的回调页此时返回比点击更安全。5.2 通用规则与App专属规则的优先级规则的优先级设计非常影响最终体验。我的做法是分两层先匹配App专属规则再匹配通用规则。专属规则是用户手动添加的具有最高优先级通用规则是内置的用来覆盖大多数常见广告SDK的场景。比如某个App的跳过按钮文案极其特殊而且资源ID也变了通用规则没覆盖到这时用户可以在规则页面手动添加一条包名关键词的规则。下次这个App再弹开屏广告时专属规则会优先触发命中率更高。规则匹配的伪逻辑val rules RuleStore.rulesFor(eventPackageName) .sortedByDescending { it.priority } val target rules.firstNotNullOfOrNull { rule - findCandidateNode(root, rule.keywords, rule.idKeywords) }实测中通用规则已经能覆盖大多数场景专属规则更多是给用户一个“自己动手解决问题”的路径。这个设计还能降低更新成本不需要为了一个App改代码发布新版本只需要更新规则文件。5.3 采集规则查看节点树信息的小工具页面规则怎么来总不能靠开发者一台台手机去截取。所以我在Demo里加了一个“节点树查看器”页面。用户开启服务后在这个页面点一下“抓取当前窗口”就能把当前Activity的节点树dump出来看到所有控件的包名、类名、文本、资源ID、bounds坐标等信息。这个小工具对普通用户来说可能有点技术味但对规则调试非常有用。我自己调试新App规则时基本都是开着这个页面反复抓取广告页节点把关键节点的文本和资源ID提取出来再更新到JSON规则里。这里还涉及一个体验问题页面抓取过程需要服务持有节点数据后再dump但节点不能无限期持有。我写了个定时清理机制抓到节点后立刻提取成纯字符串或JSON不要持有AccessibilityNodeInfo对象本身避免内存膨胀。6. 后台存活、系统限制与合规边界——上线前必须想清楚的事6.1 无障碍服务被系统回收的问题很多用户使用这类工具最大的痛点是用几天后突然失效了打开设置一看无障碍服务开关被系统自动关了。这个问题的根源很复杂一是部分ROM的省电策略默认清理“不常用”的后台服务二是系统在某些场景下会回收AccessibilityService三是应用如果长期不打开系统会认为它是僵尸应用。针对这个问题标准方案是做“常驻服务守护”但这里需要克制不要用流氓手段。我能分享的合规做法是提供一个“检测”能力比如每天固定时间检查一次服务是否还活着如果被关闭在通知栏发一条提醒点击后引导用户重新开启。定期打开App首页时也检查一次状态用尽可能少的打扰把用户拉回设置页。那些在后台反复弹窗、强制拉活、关联启动的做法既容易被应用商店拒审也容易引起用户反感。6.2 外部存储访问限制Android 11分区存储对规则配置导入的影响规则文件最方便的分享方式是导出成JSON放到网盘或社区里让别人下载。这就带出了分区存储的兼容问题。从Android 11开始应用不能直接访问/storage/emulated/0/Android/data/包名/目录下其他应用的数据也不要试图传file:///路径。我自己测试时如果直接把下载好的规则文件路径硬编码给读取逻辑很容易抛出权限异常或者拿到一个无效的Uri。正确的做法是通过Storage Access Framework用系统文件选择器让用户自己选文件val intent Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type application/json }用户选择后拿到的是content://格式的Uri再通过ContentResolver读文件流解析规则并入库。这个过程在Android 7以上同样适用可以绕开FileUriExposedException的各种问题。顺带一提如果你在日志或测试时看到类似content://com.xxx.fileprovider/external_path/...这样的Uri那说明是某个App通过FileProvider暴露出来的文件正常情况下第三方应用未必有权限读。所以在写导入功能时一定不要幻想能直接拿路径访问老老实实用SAF选择器。6.3 无障碍权限的滥用风险与合规底线最后聊一个必须面对的话题无障碍权限是一把高风险钥匙。Android系统把无障碍服务设计出来是要让开发者帮助视障、听障用户更好使用手机。它可以读取屏幕上的所有文本可以看到用户在哪些App里做了什么输入甚至可以代替用户点击任何位置。这样的能力一旦被滥用就是一场隐私事故。所以做这类工具至少要做到这几点只在本地处理节点数据绝不把用户屏幕上出现的内容回传到任何服务器。隐私政策里明确说明工具会读取当前界面内容目的是识别广告跳过按钮不会上传任何涉及用户身份、账号、密码等信息。不在后台继续用无障碍能力做与核心功能无关的事情比如采集用户行为、追踪使用习惯。不要尝试隐藏无障碍服务的存在应用市场审核时会要求你说明无障碍服务的使用场景并在App内提供清晰的开关。如果你打算发布到应用市场还要清楚一个趋势主流应用市场对“自动点击类无障碍权限”应用的审核越来越严格尤其是要求你证明每一条无障碍能力都对应着明确且必要的用户功能。李跳跳这类工具因为跳广告的特性天然在合规边缘很多开发者选择用“本地规则更新”来绕过部分问题但这并不能解决核心的红线。以我个人的判断这个项目最大的价值是学习Android系统UI自动化的完整链路事件监听、节点解析、手势模拟、规则引擎、兼容性适配。它锻炼的是对系统能力的理解和工程化落地的能力而不是一套一定能上架赚钱的商业模式。如果你正想找个难度适中、又能把Android系统层面许多知识串起来的练手项目无障碍自动跳过工具绝对是一个好选择。做的时候多想想权限边界多想想用户隐私至少你得保证自己做的工具不会变成一把失控的钥匙。