资讯详情

从注入时机到规则维护:Android WebView 净化壳的工程实践

📅 2026/10/29 7:04:27 | 华诺云谱 👁 阅读
从注入时机到规则维护:Android WebView 净化壳的工程实践
手机上追番这件事说起来挺简单实际上手就知道有多折腾点开一个页面先是浮层广告挡住半屏叉号小得跟针尖似的手一抖就点到了跳转好不容易进了播放页右下角又浮出一块推荐块横屏之后控件叠在字幕上看完一集想返回列表结果页面状态全丢得从头往下翻。某开发者就是被这套流程磨得没脾气干脆自己写了个 Android 端的小工具在网页外面套一层净化壳把页面结构、网络请求和播放交互统一管起来。我把它按代称叫作某动画站点净化插件。这篇不讲怎么装、怎么用而是把它当成一次完整的 Android 客户端工程来拆净化层该切在哪一层、WebView 注入在什么时机最稳、请求拦截的规则怎么写才不误杀、播放器的横屏和手势怎么处理不打架、规则包长期维护靠什么撑住。适合有基本 Android 开发经验、想自己动手做内容净化或网页壳应用的人看没有代码基础的读者也能看懂思路因为我会尽量用生活化的类比把原理讲清楚。需要提前说明的是标题里的那款插件我只知道名字和大致定位下面的技术细节是我按这类工具最常见的实现方式做的合理还原与补充具体实现细节各家可能不一样。1. 先想清楚净化到底是哪一层的事动手写代码之前最容易犯的错是把它当成屏蔽几个广告的小脚本。真做下去会发现净化其实是一个分层问题页面上让你难受的东西来源完全不同有的来自 DOM 结构里硬塞的元素有的来自网络请求返回的第三方脚本有的干脆是播放器本身的行为逻辑。你如果在同一层里混着处理代码很快就会变成一堆互相打架的 if。1.1 移动端看动画时体验究竟被什么拖累我把这些干扰源分成四类分类的意义在于后面每一类都有对应的治理层而不是靠一个大而全的脚本硬扛。第一类是结构性浮层也就是页面 HTML 里本来就有的广告位、推荐位、悬浮球。它们的特征是静态存在于 DOM 树里元素 ID 或 class 往往带明显语义拦截起来最简单只要选择器写得准一条规则就能清掉。第二类是动态注入的遮挡物典型是延迟两三秒才出现的弹窗、滚动到底部才插入的推荐条、点击播放后才浮出来的相关推荐。这类元素在你执行清理脚本的那一刻还不存在用querySelectorAll扫一遍是扫不到的必须靠监听 DOM 变化来解决。第三类是网络层的污染包括统计脚本、第三方广告 SDK、跳转中间页。它们的特点是还没渲染就已经在拖慢加载如果等 DOM 出来再删用户已经白白等了几秒。这一层必须放在请求阶段处理。第四类是交互层的别扭比如双击放大导致误触、横屏之后控件位置错乱、返回键直接退出应用而不是回到上一页。这类问题跟页面内容无关属于宿主容器也就是你自己的 WebView Activity要负责的部分。把这四类分开之后整个项目的模块划分就自然浮出来了DOM 净化器、请求拦截器、交互接管层各自独立互不依赖。1.2 三条技术路线为什么选了自建 WebView 壳做这类工具业内大致有三种路线我把对比列在下表里。需要说明的是这里只讨论技术可行性和维护成本不涉及任何绕开平台规则的做法。路线实现方式优点明显短板浏览器扩展在移动浏览器里装脚本管理器开发快脚本即改即生效移动端浏览器对扩展支持有限注入权限受浏览器策略限制兼容性极差系统级 Hook依赖设备已具备特殊权限环境可以作用于任意应用对设备状态有硬性要求绝大多数普通用户无法使用且极易随系统更新失效自建 WebView 壳自己写一个浏览器容器完全掌控注入时机、请求链路、生命周期需要自己实现前进后退、下载、全屏等基础能力工作量最大选第三条的理由很实际这个工具的核心价值是稳定地把净化逻辑塞进去而注入时机、请求拦截、生命周期这些恰恰是自建容器唯一能完全掌控的。前两条路线的根本问题不是难写而是你无法保证注入发生在页面渲染之前一旦时机不可控后面所有优化都是在赌运气。代价也要说清楚自建壳意味着你要自己处理 Cookie 同步、文件下载、页面前进后退、横屏旋转时的状态保存这些都是浏览器替你做了几十年的事。我在做类似项目时踩过最深的坑就在这——WebView 的onSaveInstanceState不保存页面滚动位置用户从播放页返回列表会回到顶部这个体验缺口比广告还让人烦躁后面第 4 节会专门讲怎么补。1.3 净化层的三条主线与它们的执行顺序确定路线之后还需要定死三层的执行顺序这个顺序错了会导致大量诡异问题。正确的顺序是请求拦截 → DOM 净化 → 交互接管。请求拦截在最前面因为它能把不需要的资源直接掐掉减少后续解析量DOM 净化在页面结构可用时介入交互接管是宿主层面的贯穿整个生命周期。反过来做会怎样我实测过一次先做 DOM 净化再补请求拦截结果发现某些浮层在被删除后页面脚本检测到元素消失又重建了一个形成删了建、建了删的循环CPU 直接飙上去。根源就在于那个重建脚本是通过一个第三方请求加载进来的请求层没拦住DOM 层再怎么删也是徒劳。所以先把源头的水关掉再打扫房间这是顺序问题的本质。2. DOM 净化层注入时机比选择器更决定成败很多人以为净化脚本的难点在于写出精准的选择器实际上手会发现时机错了再准的选择器也白搭时机对了选择器粗糙一点也能靠迭代补上。这一节重点讲时机和动态元素这两个最容易翻车的点。2.1 三个注入时机各自的适用场景WebView 提供的注入回调不止一个选错了会出现脚本执行了但元素还没解析出来的空转。onPageStarted是最早的时机此时页面刚开始加载DOM 基本是空的。它适合做一件事注入一个提前占位的初始化脚本比如定义全局的净化开关、挂载MutationObserver的容器、埋一些工具函数。这个阶段不要去删元素因为没东西可删。onPageCommitVisible是页面首帧可见的时机此时主文档的 DOM 已经解析到可以渲染的程度。它适合做首轮批量清理把静态存在的广告位一次性干掉让用户第一眼看到的页面就是干净的。这个时机做清理比等到完全加载完再清理视觉体验差别很大——前者用户看不到闪烁后者会先看到广告再看到它消失。onPageFinished是资源加载完成的时机它适合做兜底清理和状态修正比如修正视频元素的属性、补上缺失的样式、处理那些依赖完整资源才能确定位置的元素。我的做法是三个时机都用但每个时机只做它该做的事脚本拆成三段独立注入而不是一个大脚本在每个时机重复跑。重复跑的问题在于幂等性难保证尤其是带副作用的操作比如给元素加自定义属性跑两遍就可能出问题。2.2 选择器怎么写才不容易随页面改版碎掉选择器脆弱是这类工具最大的维护痛点。页面一改版前一天还好好的规则全部失效用户第一反应是插件坏了。降低脆弱性的思路是不要依赖单一特征而是分层降级。第一层优先用稳定的语义锚点比如元素上带固定含义的id或固定的>(function () { var MAX_HITS 500; var hits 0; var timer null; function cleanNode(root) { if (!root || !root.querySelectorAll) return; var killers [.ad-float, .pop-layer, [data-rolepromo]]; killers.forEach(function (sel) { root.querySelectorAll(sel).forEach(function (el) { el.remove(); }); }); } function onMutate(mutations) { if (hits MAX_HITS) { observer.disconnect(); return; } if (timer) clearTimeout(timer); timer setTimeout(function () { mutations.forEach(function (m) { m.addedNodes.forEach(function (n) { if (n.nodeType 1) cleanNode(n); }); }); }, 250); } var observer new MutationObserver(onMutate); observer.observe(document.documentElement, { childList: true, subtree: true }); cleanNode(document); })();这段代码里有个细节值得说document.documentElement比document.body更早就存在在onPageStarted阶段就能挂上而body那时可能还没解析出来。挂在documentElement上可以保证不漏掉body的首次插入。2.4 CSS 兜底和 pointer-events 的陷阱除了删元素还有一种更温和的手段不删只让它不可交互。典型做法是加一条全局样式把可疑浮层的pointer-events设成none这样它还在页面上但点不到。这个手段适合处理两种情况一是你判断不准该不该删的元素删了可能影响页面逻辑二是你想保留视觉但去掉误触风险。但它有个坑必须提前知道pointer-events: none会让元素完全穿透点击也就是说点在这个元素上事件会落到它下面的元素上。如果下面正好是一个下一集按钮用户以为点的是关闭广告结果跳到了下一集体验反而更差。所以我的实践是对遮挡物用display: none或remove()对非遮挡但可能误触的元素才用pointer-events而且必须配合visibility: hidden或透明度调整让用户视觉上知道这里没有东西。两者组合使用才不会出现看不见但点得到的诡异感觉。另外 CSS 兜底的一个好处是执行早于 JS。注入的样式表在页面首次绘制时就生效不会出现先看到广告再看到它消失的闪烁。我的做法是把高频出现的遮挡物的样式规则在onPageStarted阶段就以style标签形式注入JS 清理只作为补充。这条经验是从用户反馈里总结出来的——技术上最终页面是干净的和体验上一眼看过去就是干净的是两回事。3. 请求拦截层把脏东西挡在渲染之前如果说 DOM 净化是打扫房间请求拦截就是关水阀。这一层做得好能让页面加载时间直接减半因为大量第三方脚本和图片根本不需要下载。但它也是最容易误伤的一层一个正则写宽了可能把播放器依赖的核心资源也拦掉页面直接白屏。3.1 shouldInterceptRequest 的线程与性能边界Android WebView 拦截请求的入口是shouldInterceptRequest这个方法有一个必须记住的特征它在非 UI 线程被调用而且可能被并发调用。所有在里面做的事情必须是线程安全的不能碰任何 View 对象。性能上的边界也要清楚。这个回调里如果做耗时的字符串匹配或磁盘读取会直接阻塞资源加载页面会明显变慢。我的做法是把规则编译成内存中的数据结构匹配过程只做哈希查找和简单字符串判断避免在主路径上跑正则引擎。正则是强大但每次请求都跑一遍正则在低端机上就是灾难。具体实现上我用了两级匹配第一级是域名哈希表。把规则里的域名全部放进一个HashSetString请求进来先取 host做一次contains判断O(1) 复杂度。这一级能挡掉绝大部分纯广告域名。第二级是路径前缀树。只有第一级没命中、但域名在需要细看的白名单里的请求才进入路径匹配。前缀树相比正则的好处是可以做到 O(路径长度) 的复杂度而且不会出现回溯爆炸。大部分请求在第一级就结束了整个拦截开销可以忽略不计。3.2 规则表达式的结构设计规则文件如果设计得不好维护起来会非常痛苦。我见过一些开源项目把规则写成一行行的正则几千行下去之后没人敢改因为改一条不知道会影响什么。我倾向于把一条规则拆成几个正交的字段{ id: rule_0042, desc: 拦截页面底部悬浮推荐条, host: example-cdn.test, pathPrefix: /promo/, resourceTypes: [script, xhr], action: block, addedAt: 2024-05-11, hitCount: 0 }这样拆的好处是host和pathPrefix可以分开维护匹配时先比 host 再比 path逻辑清晰resourceTypes让规则只在特定类型上生效比如只拦脚本不拦图片避免误杀desc字段看着是给人类看的实际上它是最重要的字段——半年后你回头看这条规则只能靠它想起当初为什么要加。action字段我留了两个值block和fake。block直接返回空响应fake返回一个伪造的 204 或空 JSON。为什么需要fake因为有些页面脚本在资源加载失败时会触发降级逻辑弹一个加载失败请重试的提示这个提示本身就很烦。返回一个格式正确的空响应脚本会以为请求成功了只是没数据反而安安静静。这个技巧是我在排查一个拦截后反而更烦的问题时发现的值得记下来。3.3 误杀排查的完整链路误杀是这一层最难缠的问题因为它表现为页面某功能坏了而不是报错了定位成本很高。我踩过一次特别典型的拦截规则里加了一条针对/ad/路径的规则结果播放器的一个核心文件路径里恰好包含这段字符串导致视频加载不出来页面只显示一个黑框。完整的排查链路是这样的第一步确认是不是拦截导致的。把拦截开关关掉看问题是否消失。这一步看着废话但实际排查中经常有人跳过直接去查播放器代码浪费几个小时。第二步打开请求日志。在shouldInterceptRequest里把命中的规则 id 和请求 URL 打出来注意用异步日志或环形缓冲区不要在回调里做同步 IO。日志量大的时候要限流否则日志本身就成了性能瓶颈。第三步对比命中规则和请求的语义。上面那个例子里日志显示命中的是rule_0031规则描述写的是拦截广告位脚本但命中的 URL 是一个.m3u8播放列表。这就是典型的路径前缀写宽了——/ad/匹配到了一个叫load之类的路径片段。第四步修正规则而不是推翻规则。修法有三种加resourceTypes限制、加更长的路径前缀、加白名单排除。我选了第三种因为那个播放器域名是固定的加一条显式白名单最稳妥也不会影响后续同类规则的复用。第五步回归验证。改动之后不能只看这一个页面要把常见的几个页面类型都过一遍列表页、详情页、播放页、搜索页。误杀的可怕之处在于它可能在另一个你没注意到的页面上也发生了。这套流程看起来笨但比凭感觉改正则高效得多。我的体会是规则维护的成本里八成花在误杀排查上所以从一开始就要把日志和规则 id 做好这是给自己省事。3.4 缓存策略与离线兜底拦截层还有一个容易被忽略的价值它可以给页面做缓存。思路是在拦截回调里对特定类型的静态资源脚本、样式、图标先查本地缓存命中就直接返回本地内容不走网络。这在弱网环境下效果非常明显页面二次打开几乎瞬间完成。但缓存有两个坑。一是过期策略静态资源也可能更新如果永远用本地版本页面改版后会出现样式错乱。我用的策略是给缓存加一个 24 小时的 TTL超时后走网络并刷新缓存。二是响应头伪造返回本地内容时必须带上合理的Content-Type和Content-Length否则 WebView 可能解析失败。这一条我在早期版本上吃过亏返回的响应没设Content-Type浏览器按text/plain处理JS 文件被当成文本显示页面一片空白。4. 播放体验的工程化全屏、手势与生命周期前面两层解决的是看到什么这一层解决的是怎么用。很多净化类工具做到这里就停了觉得页面干净就够了但实际上用户的体感差异很大一部分来自播放环节的顺滑程度。4.1 接管视频元素的状态而不是重写播放器最容易走偏的做法是自己写一套播放器 UI 覆盖上去。这条路我试过成本极高而且不稳站点的播放逻辑、清晰度切换、字幕轨道都挂在原生video元素上你覆盖一层 UI就得把这些能力全部重新实现一遍任何一个细节没对齐就会出现能播放但不能切清晰度这类问题。更稳的思路是只做属性修正和事件补挂不碰播放逻辑本身。具体做三件事一是修正video的playsinline和webkit-playsinline属性保证内联播放而不是被系统播放器接管。这一点在移动端很关键系统播放器接管之后你就失去了对进度的控制。二是监听fullscreenchange事件同步宿主 Activity 的横屏状态。很多人以为全屏是 CSS 的事实际上在 WebView 里视频全屏需要原生层配合否则会出现视频放大了但页面还在竖屏布局的错位。三是补上preload和poster的合理值。默认的preloadauto会在页面加载时就预拉视频数据浪费流量设成metadata只拉元信息用户真正点播放时才加载内容。这个改动看着小但在移动网络下的流量节省相当可观。4.2 手势冲突的坐标换算横屏播放时最容易出问题的是手势。你在竖屏下写的滑动逻辑到了横屏坐标系就翻了 90 度用户往右滑却触发了上一集。根源在于 WebView 里的触摸事件坐标是相对于页面视口的而页面的视口在横屏旋转后会重新计算。如果宿主层和页面层各自维护一套手势判断必然打架。我的处理办法是手势判断只在宿主层做一次页面层完全不参与。具体做法是在 Activity 层拦截dispatchTouchEvent把触摸点换算成相对于视频区域的归一化坐标用这个坐标判断是横滑还是竖滑。归一化的好处是它跟屏幕尺寸、旋转方向都无关一套逻辑适配所有情况。换算公式很简单nx (x - videoLeft) / videoWidthny (y - videoTop) / videoHeight。拿到nx和ny之后判断|nx - 0.5|落在左三分之一还是右三分之一就能区分快退和快进而不用管当前是横屏还是竖屏。需要注意一点视频区域的位置要实时获取不能在页面加载时算一次就存下来。因为页面滚动、控件展开都会改变这个位置用旧坐标会导致手势热区漂移用户感觉滑了半天没反应。4.3 返回键与页面历史的正确对应返回键的逻辑看着简单实际是个坑窝。默认行为是finish()退出 Activity但用户的预期是回到上一页只有在历史栈空了的时候才退出。WebView.canGoBack()能判断历史栈但直接用它有两个问题。一是某些页面通过pushState制造了大量历史记录用户按十次返回都回不到列表页。二是某些站点的返回应该回到应用内的首页而不是网页历史。我的做法是分层判断先看canGoBack()能回就走goBack()不能回再看是否有应用内的目标页面有就跳转都没有才finish()。同时要处理onBackPressed的弃用问题在较新的 SDK 上用OnBackPressedDispatcher注册回调保证行为一致。还有个小细节goBack()之后页面会重新加载滚动位置丢失。解决办法是在onPageStarted时记录当前滚动位置onPageFinished时用scrollTo恢复。这个位置信息存在内存里就行不需要持久化。补上这个之后返回列表回到顶部的抱怨基本消失了。4.4 熄屏、切后台与音频焦点这一块属于细节但直接影响用起来像不像个正经播放器。熄屏的问题Android 默认在屏幕关闭后会降低 CPU 活动视频可能暂停或音频中断。解决方案是在播放期间持有WakeLock但必须在暂停或退出时释放否则会持续耗电。我的做法是绑在播放状态上play事件加锁pause和onPause释放用 try-finally 保证不会漏。切后台的问题用户切出去回消息回来发现视频从头开始了。原因是 WebView 在onPause时可能被暂停渲染页面脚本的播放状态丢失。要在onPause里记录currentTime和paused状态onResume时恢复。这里要注意onPause和onStop的区别前者可能因为弹窗触发不一定代表用户真的离开了。音频焦点的问题最容易被忽略。用户在播放视频时接了个电话或者打开了另一个音乐应用如果不处理音频焦点会出现两路声音同时响的尴尬场面。正确做法是请求AUDIOFOCUS_GAIN在onAudioFocusChange里处理丢失和恢复丢失时暂停播放恢复时根据用户之前的状态决定要不要继续。这块代码不多但加上之后整体质感提升很明显。5. 规则包的寿命更新机制才是长期可用性的关键这类工具真正的难点不在第一版而在第一百版。页面会改版规则会失效如果每次都要发一个新版本的应用用户很快就不追了。所以规则必须能和客户端解耦。5.1 规则包的结构与版本号设计规则包我建议做成独立的 JSON 文件带一个自增的整数版本号和一个最小客户端版本要求。结构上分三块meta版本、生成时间、兼容的客户端版本区间、domRulesDOM 净化规则、requestRules请求拦截规则。{ meta: { version: 137, minClient: 1.4.0, generatedAt: 2024-06-02T10:00:00Z }, domRules: [ { id: d_01, selector: .float-ad, action: remove, weight: 10 } ], requestRules: [ { id: r_01, host: example-cdn.test, pathPrefix: /promo/, action: block } ] }weight字段是给规则排优先级用的。同一批规则里权重高的先执行。为什么需要这个因为有些规则互相依赖比如先把某个容器的z-index降下来再删里面的子元素顺序反了可能触发页面脚本的异常分支。权重字段让这个顺序可以被显式控制而不是依赖数组顺序这种隐式约定。minClient字段的意义在于避免新规则把老客户端搞崩。新规则可能用到老版本不认识的字段或动作类型如果老客户端硬套轻则无效重则异常。有了这个字段客户端拿到规则包先比对版本不兼容就继续用本地缓存的旧包。5.2 下发、校验与本地兜底下发的逻辑要遵守三条原则有兜底、有校验、有回滚。兜底指的是应用内必须内置一份完整的规则包。首次启动、网络不可用、下载失败所有异常路径都落到内置包上。内置包不需要最新但必须可用。我见过一些工具规则全靠远程拉断网状态下整个功能就是废的这是设计缺陷。校验指的是拿到远程包之后不能直接用。至少要做三件事JSON 解析是否成功、version是否大于当前版本、规则条数是否在合理区间比如不能是 0也不能突然翻十倍。最后一条看着奇怪但它能挡住一类典型事故——服务端配置出错返回了一个空包或者错误页面客户端如果照单全收净化功能直接失效而用户完全不知道发生了什么。回滚指的是保留上一版规则包。新包生效后如果出现异常能一键退回。实现上就是存两个文件rules.json和rules.bak.json更新时先备份再覆盖。5.3 回归测试的极简做法规则包更新的最大风险是修好了 A 页面弄坏了 B 页面。完整的自动化回归成本很高但可以做一个极简版本性价比很高。做法是维护一份页面样本清单把常见的页面类型各取一个地址记下来配上每个页面应该出现什么和不应该出现什么的断言。比如播放页的断言是存在video元素且src非空列表页的断言是可见元素数量大于某个阈值。规则包更新后用无头环境跑一遍这份清单任何一条断言失败就阻断发布。这套东西搭起来大概半天但能挡住八成的低级事故。我在一个类似项目里加了这个之后规则相关的线上问题从每周两三次降到一个月一两次投入产出比相当高。另外补一个容易被忽略的点灰度。规则包不要一次全量推送先给 10% 的用户观察一天的错误率和反馈没问题再全量。这个机制在客户端应用里很常见但规则包这种配置往往被当成低风险内容直接全量实际上它的影响面比一次小版本更新还大。6. 收尾做这类工具这几年我最大的体会是净化功能本身的技术难度不高难的是让它一直有效。第一版能跑通大概一两周后面百分之八十的时间都花在规则维护、误杀排查、兼容性适配上。所以从第一天起就要把规则和客户端解耦、把日志做扎实、把兜底做完整这三件事看着是额外工作实际上是在给未来的自己减负。还有一个小技巧值得分享不管做什么净化规则都先把关闭净化的开关放在显眼位置。用户的容忍度是有阈值的一旦某个页面被误伤打不开他需要的是一条能立刻恢复的路而不是去翻设置里的深层菜单。这个开关花不了多少代码但能救回不少被误伤劝退的用户。最后提醒一句做这类工具时尽量把净化逻辑控制在提升自己观看体验的范围内不要去改动站点本身的数据交互逻辑也不要做任何绕过站点正常机制的事情。技术上能做到和应该做是两件事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑