多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

解压浏览器源码:WebView请求拦截与广告过滤实战

解压浏览器源码:WebView请求拦截与广告过滤实战 简介面向毕业设计与移动开发学习者的安卓浏览器源码工程包围绕浏览器从架构设计到功能实现的完整链路展开既可作为课设/毕设参考也能帮助开发者理解安卓系统下网络通信、UI渲染与性能优化。压缩包内共284个文件以81个Java源码、55个XML布局与配置、133个PNG界面素材为主另有HTML起始页、CSS样式和工程配置等文件整体约594KB结构清晰便于按模块查阅。源码覆盖WebView组件、浏览器分层架构、渲染与网络请求、JavaScript与Java交互、安全与隐私、多进程机制、手势导航、缓存与GPU加速等关键知识点同时提供主界面入口与书签、历史记录、搜索等自定义起始页面并包含广告过滤等辅助功能可支撑二次开发、源码讲解或毕业设计文档撰写。已有350人学习浏览适合想通过一个完整项目快速上手安卓浏览器开发、并深入理解移动端浏览器工作机制的读者。 解压这份Android浏览器源码包时最先吸引我的不是MainActivity.java而是start_style.css和四个start_*.html——一个浏览器工程的重心居然放在了起始页上。这其实暴露了WebView开发的核心现实系统WebView已经把HTML解析、JavaScript引擎、网络栈全部打包好了你自己要写的只剩下两层Activity壳和请求拦截器。这份源码的价值不在“从零实现浏览器内核”而在“怎么把一个WebView约束成有完整浏览器体验的应用外壳”对做毕业设计需要可演示项目的学生以及对WebView网络拦截、缓存策略、JS交互想看到完整闭环的开发者都有直接的参考意义。接下来会从入口配置、网络层、广告拦截与起始页四条线拆开讲。1. 把WebView约束成浏览器这套Android源码的真正骨架解压这份Android浏览器源码包时最先吸引我的不是MainActivity.java而是start_style.css和四个start_*.html——一个浏览器工程的重心居然放在了起始页上。这其实暴露了WebView开发的核心现实系统WebView已经把HTML解析、JavaScript引擎、网络栈全部打包好了你自己要写的只剩下两层Activity壳和请求拦截器。这份源码的价值不在“从零实现浏览器内核”而在“怎么把一个WebView约束成有完整浏览器体验的应用外壳”对做毕业设计需要可演示项目的学生以及对WebView网络拦截、缓存策略、JS交互想看到完整闭环的开发者都有直接的参考意义。后面四章按入口配置、网络层、广告拦截与起始页、调试复现的顺序展开大多数代码可以直接改成你自己的工程。2. WebView三件套Settings、WebViewClient与WebChromeClient的职责边界2.1 从文件列表反推工程结构压缩包里的文件并不是一整套Android Studio工程而是把关键源码和资源拆散了checkstyle和.classpath是Eclipse时代留下来的工程配置Android Studio导入时会直接忽略MainActivity.java是唯一入口adsweep大概率是一个广告过滤模块目录或规则文件start_*.html和start_style.css明显放在assets目录下会被WebView以file:///android_asset/方式加载。把这些文件拼起来得出的结论是这是一个以MainActivity为壳以WebView为核心用HTML文件承载浏览器首页的轻量级项目。这种结构对学习反而更友好。没有Gradle脚本的噪音你新建一个Empty Activity工程把MainActivity.java替换进去把start_*.html放进main/assets/就能跑起来。而浏览器相关的全部能力都集中在两个回调类上WebViewClient负责导航、资源加载、SSL错误这类“网络与文档”事件WebChromeClient负责进度、标题、JS弹窗、视频全屏这类“窗口与UI”事件。分配清楚这两个类的职责后面所有功能才有地方挂靠。2.2 WebSettings参数矩阵决定WebView行为边界MainActivity里那些看起来琐碎的setSettings调用实际上决定了WebView是“一个能显示网页的View”还是“一个浏览器”。以下这组配置是这套源码最基础的部分注释里已经标了每个开关为什么存在。WebSettings settings webView.getSettings(); // 核心开关JS 不开现代页面基本不可用 settings.setJavaScriptEnabled(true); // 允许 H5 使用 localStorage/sessionStorage否则很多站点白屏 settings.setDomStorageEnabled(true); // 默认缓存模式按 HTTP 头语义走 revalidation settings.setCacheMode(WebSettings.LOAD_DEFAULT); // 让宽页面按屏幕宽度适配两个开关必须成对出现 settings.setUseWideViewPort(true); settings.setLoadWithOverviewMode(true); // 保留双指缩放但隐藏系统自带缩放按钮 settings.setSupportZoom(true); settings.setBuiltInZoomControls(true); settings.setDisplayZoomControls(false); // 改写 UA让服务端能识别你的客户端版本 settings.setUserAgentString(settings.getUserAgentString() BrowserDemo/1.0);setUseWideViewPort和setLoadWithOverviewMode要放在一起写。只开前者PC版页面会按980px宽度渲染出一整行只开后者布局视口和屏幕宽度不匹配缩放比例会奇怪。两个都开页面初始就缩到屏幕宽度用户双击放大时才触发真正viewport逻辑。setUserAgentString是在系统UA后面追加标记而不是覆盖这样既能保留平台识别信息又能让服务端通过“BrowserDemo/1.0”做特性分发有些站点看到非Chrome内核UA会返回低配页面所以追加是比替换更安全的做法。参数作用建议值setJavaScriptEnabled是否执行JStrue除非只展示纯静态页setDomStorageEnabled是否启用DOM存储true依赖localStorage的站点必须开setCacheMode缓存策略LOAD_DEFAULT调试期可临时LOAD_NO_CACHEsetUseWideViewPort启用宽视口true与LoadWithOverviewMode配套setLoadWithOverviewMode页面缩放至屏幕宽truesetSupportZoom是否允许缩放true阅读类App可关setMixedContentMode混合内容策略API 21兼容http资源时设MIXED_CONTENT_COMPATIBILITY_MODEsetMixedContentMode是一个容易被忽略的坑。默认情况下HTTPS页面里加载HTTP图片或脚本会被Chromium直接拦掉页面表现为“有一部分图挂了”却没有任何错误提示。兼容模式只阻塞危险类型如HTTP iframe允许图片和样式加载是浏览器类应用的合理折中。2.3 WebViewClient与WebChromeClient各管一摊MainActivity里这两个Client的覆写顺序有讲究。WebViewClient管“导航到哪里”WebChromeClient管“页面表现如何”。把进度条、标题、错误页这三个需求拆开看就能知道回调为什么必须分属两个类。webView.setWebViewClient(new WebViewClient() { // 新API签名旧版shouldOverrideUrlLoading(WebView, String)在API 24后废弃 Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { // 返回 true 表示外部接管WebView 不自动加载 view.loadUrl(request.getUrl().toString()); return true; } Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { // 子资源失败也会走到这里记得先判断 request.isForMainFrame() super.onReceivedError(view, request, error); } }); webView.setWebChromeClient(new WebChromeClient() { Override public void onProgressChanged(WebView view, int newProgress) { // 0-100 进度回调可绑到顶部 ProgressBar } Override public void onReceivedTitle(WebView view, String title) { // 不仅改标题还是书签和历史记录的数据来源 } });shouldOverrideUrlLoading返回false和调用view.loadUrl再返回true行为差别很大。返回false表现的是“WebView自己继续导航”适合页面内普通跳转先loadUrl再返回true的写法用于需要改写URL的场景——比如把某些链接强制换成移动版。onReceivedError在API 23以后会收到子资源错误直接在回调里弹错误页会把一张加载完好的页面误判成崩溃标准做法是先看request.isForMainFrame()只对主框架错误做全屏兜底。onProgressChanged不只在页面初始加载时触发页面内iframe请求也可能刷新进度。如果进度条跳了一下又回退先排查页面里是否嵌了会重定向的iframe不要急着改WebView配置。onReceivedTitle拿到的是当前主框架document.title在单页应用里title是动态变化的这个回调可能比onPageFinished更频繁历史记录入库前最好做去重。2.4 生命周期与内存回收Activity销毁不等于WebView销毁WebView持有独立的渲染线程和浏览器进程GC管不到它。MainActivity的onPause、onResume、onDestroy都要显式告诉WebView“你该暂停还是自杀”。Override protected void onPause() { super.onPause(); // API 11暂停所有 layout/绘制/定时器页面视频也会停 webView.onPause(); } Override protected void onDestroy() { // 先从View树移除再destroy顺序反了必崩 ViewGroup parent (ViewGroup) webView.getParent(); if (parent ! null) { parent.removeView(webView); } webView.destroy(); super.onDestroy(); }这里最常见的崩溃是WebView.destroy() called while still attached消息本身说得很直白WebView还挂在ViewGroup上就被销毁了。先removeView再destroy不是习惯问题而是WebView内部回调链里还在访问父容器的引用。onPause和onResume成对调用还影响着页面里HTML5视频的播放状态只暂停Activity不暂停WebView视频声音会继续外放。提示不要在一个Activity里同时创建两个WebView实例。同一时刻每个WebView都尝试打开同一个浏览器进程数据目录后创建的实例可能直接白屏或闪退。需要多标签页时复用同一个WebView只切换URL和缓存快照。3. 网络栈与请求拦截缓存策略和shouldInterceptRequest实战3.1 移动端Chromium网络栈的两个关键事实Android从API 19开始WebView的内核切换为Chromium之前那套基于WebKit的老行为全部作废。这意味着现代WebView默认支持HTTP/2、Brotli压缩、QUIC等协议能力但你没法像在浏览器里一样看到具体的网络细节——WebView的网络栈没有可视化界面。Chrome和Edge在桌面上被反复讨论的内存占用问题到了移动端更粗暴系统内存吃紧时Chromium会直接杀掉整个渲染进程WebView表现为页面上方一片空白回到前台重新创建。这个背景下缓存策略就成了除了网络质量之外最影响体感的变量。WebView的缓存目录在getCacheDir()/webviewChromium下不在老的webview.db里。如果你在代码里找“webview.db”去清理缓存那是旧版本的残留写法新内核已经不再使用。3.2 四种缓存模式怎么选WebSettings.setCacheMode接受四个常量对应Chromium网络栈里不同的缓存语义不是“有缓存和没缓存”这么简单的二分。模式行为适用场景LOAD_DEFAULT默认按HTTP头做revalidation普通页面加载兼顾新鲜度和速度LOAD_CACHE_ELSE_NETWORK本地有缓存就不走网络离线包、起始页静态资源LOAD_NO_CACHE不用缓存全部走网络开发调试期、实时数据页面LOAD_CACHE_ONLY只读缓存没缓存就失败断网时的历史页浏览LOAD_DEFAULT并不代表每次都强刷而是遵循服务端返回的Cache-Control和ETag。如果服务端没给任何缓存头这个模式下的资源每次都会重新下载所以你会看到同一个页面上某些图片总是闪一下——那是它们根本没被缓存。把不想请求网络的资源域名单独设一条“缓存优先”规则更合理而不是全局切到LOAD_CACHE_ELSE_NETWORK否则页面永远拿到旧版本。开发时我习惯先切LOAD_NO_CACHE跑一轮确认页面加载正常再切回LOAD_DEFAULT做性能验证。如果反过来改动代码后页面永远展示旧内容排查半天发现是缓存策略太激进这就浪费时间了。3.3 shouldInterceptRequest在请求离开应用之前动手shouldInterceptRequest是WebView最强大的拦截点。它在网络请求真正发给系统网络栈之前回调你可以修改URL、返回本地资源、甚至完全吞掉这个请求。这份源码里广告过滤和部分资源替换都依赖这个方法。Nullable Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { String url request.getUrl().toString(); // 命中本地资源映射把网络图替换成assets里的占位图 if (url.endsWith(.png) url.contains(/static/)) { try { InputStream in getAssets().open(images/placeholder.png); return new WebResourceResponse(image/png, utf-8, in); } catch (IOException e) { // 资源不存在时继续走原网络逻辑不阻塞页面 } } return super.shouldInterceptRequest(view, request); }WebResourceResponse的构造函数三个参数分别是MIME类型、编码方式和输入流MIME写错或者编码方式与实际内容不符时页面会报“resource interpreted as Image but transferred with MIME type application/octet-stream”一类的控制台警告。返回null就表示“这个请求我不拦截交给Chronium网络栈”而返回一个空的输入流表示“请求成功但没有内容”——两种语义的差别在广告过滤里很关键广告位拿到空响应会正常留白页面不会因为资源加载失败触发错误布局。这个回调运行在主线程耗时操作会卡掉整个页面的加载流程。规则匹配逻辑要用HashSet或数组这类O(1)查找结构不要在这里做正则匹配或远程请求。把规则文件放在assets里并一次性读入内存就是这套源码里adsweep模块采用的常规做法。3.4 file协议与分区存储的限制file:///android_asset/是加载assets内置资源的传统路径不需要权限也没有跨域问题。另一个容易踩坑的是file:///storage/emulated/0/开头的路径Android 11之后分区存储收紧应用访问自身外部目录之外的文件会被拦截WebView里直接加载会白屏。很多浏览器源码在“下载文件打开”这个功能上都会遇到这类报错典型表现是URL形如file:///storage/emulated/0/Android/data/...看着存在但打不开根因是分区存储的file路径访问限制正确做法是把文件映射成content://URI通过FileProvider转交出来。// 常规做法用 FileProvider 把私有目录映射成 content:// URI Uri contentUri FileProvider.getUriForFile(context, com.example.fileprovider, file); webView.loadUrl(contentUri.toString());getUriForFile的第二个参数是manifest里配置好的provider authorities必须和应用包名对应。这段代码解决的是“文件确实存在但WebView打不开”的问题映射完成之后读取文件的权限由FileProvider控制不再受原始路径的权限边界约束。4. adsweep与起始页源码里最能迁移的两个自定义模块4.1 adsweep广告拦截引擎的最小实现这套源码里adsweep承担了广告拦截职责。没有混淆的源码能直接看出它的设计思路外置规则文件加运行时Set匹配拦截逻辑放在shouldInterceptRequest里。它和Adblock那套“URL阻塞元素隐藏内容注入”的完整体系不同属于移动端WebView场景下性价比最高的实现——只拦截请求不注入CSS不做元素分析因为WebView里做DOM操作开销太大而且很容易被页面脚本检测到。public class AdSweep { private final SetString blockedHosts new HashSet(); private static final String RULE_FILE adsweep_rules.txt; public void loadRules(Context context) { try (BufferedReader reader new BufferedReader( new InputStreamReader(context.getAssets().open(RULE_FILE)))) { String line; while ((line reader.readLine()) ! null) { String rule line.trim(); if (!rule.isEmpty() !rule.startsWith(#)) { blockedHosts.add(rule.toLowerCase(Locale.ROOT)); } } } catch (IOException e) { // 规则文件缺失时整个模块退化为空实现不影响主流程 } } public boolean shouldBlock(String url) { try { String host Uri.parse(url).getHost(); return host ! null blockedHosts.contains(host); } catch (Exception e) { return false; } } }blockedHosts用的是HashSet因为广告拦截是典型的读多写少场景O(1)命中比ArrayList的线性查找在页面加载大量子资源时优势明显。规则匹配只做host级别匹配不匹配完整URL路径这个取舍来自一个观察广告系统为了提高填充率通常把域名固定下来但路径随意变换只看域名能覆盖绝大多数请求代价是如果某个广告域名同时托管了正常服务会一起被误杀。提示规则文件放在assets目录里意味着App升级时规则会跟着新包替换。如果你自己维护这个模块把规则文件放到私有目录再启动时同步用户改了规则App升级后还能保留。规则文件本身用纯文本按行组织方便手工维护# assets/adsweep_rules.txt # 每行一个域名井号开头为注释 adservice.google.com ads.example.org static.doubleclick.net mopub.comloadRules里的BufferedReader逐行读取过滤空行和注释行再把域名转成小写存进Set。为什么统一小写URL的host部分在DNS解析里不分大小写但Uri.parse返回的字符串保留原始大小写不清一色就会漏掉那种大小写混杂的请求。4.2 起始页五件套start_style.css和四个HTML的分工start_style.css管全局样式start.html负责任务调度start_search.html是搜索框组件start_bookmarks.html是书签网格start_history.html是最近访问列表。这个拆分说明整个浏览器的首屏被当成一个轻量级前端在做样式统一的CSS行为分化的四个组件。用网页而不是原生View画首屏在这套代码里是合理选择——状态栏、图标网格、搜索框的聚焦动画用CSS实现成本远低于原生View而且所有H5开发者都能直接改。// MainActivity 中加载起始页 webView.loadUrl(file:///android_asset/start.html); // 注入JS桥起始页输入关键词后由Java层发起真实搜索 webView.addJavascriptInterface(new Object() { JavascriptInterface public void search(String keyword) { runOnUiThread(() - { try { String query URLEncoder.encode(keyword, UTF-8); webView.loadUrl(https://www.bing.com/search?q query); } catch (UnsupportedEncodingException e) { // 编码失败不应该导致页面崩溃 } }); } }, androidBridge);start_search.html里可以直接调用window.androidBridge.search(keyword)把关键词传给Java层。keyword是用户在输入框里填的原串如果直接拼URL中文和特殊字符会破坏URL结构所以必须URLEncoder.encode后再拼接。搜索行为放在Java层而不是在start_search.html里写window.location是为了保留一个统一出口将来你想拦截某个搜索词去本地历史里匹配只改这一处就行。4.3 书签和历史记录存储选型与写入时机书签和历史记录在浏览器里是一体两面的数据。源码里没有引入Room这类重框架而是用SQLite加ContentValues这种最直白的方式对学习项目反而清晰。存储方案优点缺点适用场景SharedPreferences简单kv结构不适合列表查询与排序只存“上次打开的页面”SQLite ContentValues零依赖SQL灵活需要手写Helper书签、历史记录这种结构化列表Room编译期校验SQL依赖注解处理器大型工程团队协作Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); // 只记录http/https页面file:和content:跳过 if (url.startsWith(http)) { ContentValues values new ContentValues(); values.put(title, view.getTitle()); values.put(url, url); values.put(created_at, System.currentTimeMillis()); dbHelper.getWritableDatabase().insert(history, null, values); } }onPageFinished在每次主文档加载完成后回调一次用于记录历史是准确的时机。但页面内重定向也可能触发它同一个URL连续入库两三次的情况需要你去重常见做法是入库前先查一遍最近记录里是否已有相同URL。view.getTitle()拿到的标题就是WebChromeClient.onReceivedTitle里更新的那一位Android系统没有主动维护这两个回调的同步关系所以如果你改写了onReceivedTitle里标题变量的传递逻辑这里也要一起改。4.4 广告模块与起始页的联动页面加载完成后adsweep过滤的是网络层起始页处理的是交互层两者通过MainActivity的中转状态串联。start_bookmarks.html里的书签点击行为可以在JS桥里再暴露一个openBookmark(url)方法Java层收到后先清理历史栈再loadUrl达到“从书签进页面”的浏览器导航语义。JavascriptInterface public void openBookmark(String url) { runOnUiThread(() - { // 清空返回栈确保从首页进书签后按返回键直接回首页 webView.clearHistory(); webView.loadUrl(url); }); }clearHistory的作用是让返回键不会把用户带回那个被替换掉的首页这是浏览器App和普通WebView容器在交互设计上的一个关键区别。用户从书签进入内容页后按返回键预期是回到首页而不是退到上一次书签点击前的空白帧这个状态管理比SQLite里的数据存储更容易被忽略但对体验的影响最直接。5. 编译复现与排错WebView调试、渲染崩溃恢复和数据目录5.1 用Chrome DevTools远程调试WebViewWebView的页面调试不像普通Android View那样打Logcat就能搞定渲染、网络、JS异常全部发生在Chromium内部。API 19之后的系统WebView支持远程调试但有一个前提——必须在代码里显式开启if (Build.VERSION.SDK_INT Build.VERSION_CODES.KITKAT) { webView.setWebContentsDebuggingEnabled(true); }注意这个开关不受AndroidManifest里android:debuggable控制不调用上面这行代码就算App是debug包也看不到调试面板。开启后PC Chrome地址栏输入chrome://inspect设备列表中会出现当前WebView页面点击inspect就能看到完整的DOM树、Network瀑布流、Console输出和性能录制。排查页面加载慢、JS报错、资源被拦时这一步比任何源码分析都直接。5.2 渲染进程崩溃后的恢复策略WebView的渲染进程崩溃与Java层异常完全不同它不会抛出任何能catch的Exception。API 26之后提供了onRenderProcessGone回调系统把“渲染进程没了”这件事显式交给了开发者。webView.setWebViewClient(new WebViewClient() { RequiresApi(api Build.VERSION_CODES.O) Override public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { // detail.didCrash() 区分是系统回收还是真实崩溃 if (!detail.didCrash()) { // 系统内存不足回收渲染进程返回false让系统自行恢复 return false; } // 真正崩溃时先摘掉旧View再重建避免残留状态 ViewGroup parent (ViewGroup) view.getParent(); if (parent ! null) { parent.removeView(view); } recreateWebViewAndReload(); return true; } });didCrash为false时属于系统在内存压力下的主动回收通常返回false让WebView内部机制处理即可为true才是页面脚本或资源把渲染进程搞崩了这时手动重建WebView比让系统恢复更可控。重建时注意把旧的WebView从View树里移除和onDestroy里的顺序一样这个约束来自同一个底层原因数据目录同一时刻只允许一个WebView实例打开。5.3 覆盖安装与数据目录问题覆盖安装后App原有的WebView缓存、Cookie、DOM存储数据都会保留但如果新版本内核和旧版本的数据格式不兼容可能出现“打开白屏、清除数据后恢复”的现象。这和你看到的“已安装32位浏览器内核组件”这类系统提示是同一类问题——设备上系统WebView实现与App的ABI不匹配不是工程代码的bug。排查顺序是先确认设备系统WebView已更新到与Chrome内核版本同源的最新版再确认compileSdkVersion不低于WebView最新API要求最后把targetSdkVersion与数据目录策略对齐。拿到这份源码后第一件事不是直接跑而是先核对你的Android Studio和SDK编译版本能否匹配它的compileSdk和targetSdkWebView的API在这十年里改过三批签名版本映射对不上所有功能都是白谈这就是复现任何WebView源码时真正的第一道坎。本文还有配套的精品资源点击获取
返回列表