1. 项目背景与需求分析
1.1 这个需求到底在说什么
做 Android 开发的人应该经常遇到这种需求:“我不想重写原生页面,你直接把某某网页塞进 App 里吧。” 老板嘴里的“塞”,落到代码上基本就是 WebView。所谓“使用 webview 实现加载第三方的网页效果”,本质上是把原本用于展示网页内容的浏览器内核组件,嵌入到自己的 Android 应用里,替代原生 Activity 去承载一个完整的网页交互流程。
我在实际项目中遇到过很多类似场景:用户协议和隐私政策要放一个官网页面;运营后台发了一篇文章,客户端只能加载 URL;接入支付前需要跳转一个 H5 完成授权;甚至有的整个模块就是用 WebView 包了一个响应式网站。如果每次都调起系统浏览器,用户一转身就离开你的 App,体验是断裂的。用 WebView 直接把网页内容内嵌到自己的页面里,用户点开链接、返回、继续操作,体感上就像在用原生页面,这是最常见的落地方式。
这篇文章就把我从零开始搭 WebView 加载第三方页面时踩过的坑、验证过的写法、配置过的参数都整理出来。适合刚接触 Android 的开发者,也适合想把 WebView 模块升级得更稳的进阶同学。代码以 Kotlin 为主,XML 布局和 WebView 配置这套逻辑用 Java 理解完全一致,即使你平时写 Java,框架概念不会变。
1.2 “第三方网页”和原生页面对比,难点在哪
这里说的第三方网页,通常不是你自己公司的前端团队写好的页面,而是内容完全由别人控制的页面。你要加载一个完全存在于 Web 服务端的地址,页面包着什么 JS 逻辑、会调什么系统能力、里面会不会弹窗、会不会跳转外部链接,你都不知道。这跟加载自己的本地 HTML 完全不是一个难度等级。
难点在于 WebView 默认看起来很听话,但实际它会把很多问题悄悄放出来:URL 跳转到系统浏览器、页面加载超时、混合内容被拦截、摄像头权限没有处理、下载文件没反应、返回键直接退出 App 而不是返回上一页等等。真正接第三方网站时,这些东西都会接二连三地冒出来。所以做 WebView 封装时,不是写完 loadUrl 就结束了,而是要围绕用户交互、生命周期、系统权限、安全策略几个维度逐个适配。
1.3 WebView 真的适合你的场景吗
在动手写代码前,我建议你先判断一下需求是否真的适合 WebView。凡是第三方网页能稳定提供移动端版本、交互层级不深、没有极其复杂的原生能力依赖,WebView 就是低成本的正解。但如果网页的交互链路很长、动画密集、对性能要求很高,或者需要频繁访问摄像头、蓝牙这类设备能力并和原生深度联动,那纯 WebView 会因为渲染性能和桥接复杂度带来麻烦。
我之前接过一个投票活动页,H5 要求调用相册上传图片,还要人脸识别,这种就必须把原生相机、图片选择、人脸 SDK 的逻辑全部接起来再返回给网页,原生和 JS 来回穿,沟通成本很高。后续排期足够的话,还不如直接用原生实现页面,或者使用 App 内嵌 H5 + 原生组件混合。先用这个标准判断一下,能省下后面大量填坑的时间。
2. 工程准备与 WebView 小档案
2.1 创建工程与基础依赖
WebView 不需要额外引第三方库,它是 Android 系统包里的原生组件,核心类集中在android.webkit.WebView、android.webkit.WebSettings、android.webkit.WebViewClient、android.webkit.WebChromeClient这几个里面。
新建项目时,我习惯选择 Empty Activity,开发语言 Kotlin。真正写代码前,确认一下build.gradle里的配置:
android { compileSdk 34 defaultConfig { applicationId "com.example.webviewdemo" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" } }minSdk 21覆盖了绝大数还在使用的 Android 5.0 以上设备,WebView 的内核从 Android 7.0 开始支持多进程,而从 Android 10 之后 WebView 基本跟随 Chrome 独立升级,版本差异会在第 2.3 节展开聊。
2.2 Android 网络权限和安全配置
加载网页离不开网络,AndroidManifest.xml第一件事就是申请网络权限:
<uses-permission android:name="android.permission.INTERNET" />在 Android 9(API 28)之后,系统默认对明文 HTTP 流量进行了限制。如果你要访问的第三方网页恰好还是http://,那么需要打开明文流量开关,或者只在特定域名内放开:
<application android:usesCleartextTraffic="true" ... >但我强烈建议只在开发阶段打开这个开关,生产环境如果第三方站点还没有升级到 HTTPS,你就要跟对方确认是否提供 HTTPS 版本。明文流量不仅会被系统默认拒绝,还容易被运营商在链路层做各种内容挟持,第三方网页一旦被注入脚本,WebView 的安全性就会大打折扣。能上 HTTPS 的一律上 HTTPS。
如果你的 targetSdk 在 30 以上,并且目标是加载的文件内容位于外部存储,我建议直接放弃直接读取第三方文件路径这种写法,改用 FileProvider + Content Uri 的方式传递文件,尤其是第 6 章讲文件上传的时候,老写法file://已经被很多版本封得非常死。
2.3 WebView 版本差异:必须心里有数
Android 系统的 WebView 不像我们平时依赖的 AndroidX 库可以随 APK 打包直接升级,它是由系统安装的独立组件。大方向上,Android 10 之后 WebView 与 Chrome 版本同步,设备厂商可以通过应用商店更新 WebView,应用里通过 WebView 渲染页面的行为,会随用户手机上的 WebView 版本不同而变化。
这会造成什么问题?同一个 H5 页面,在你的测试机上运行正常,在用户旧手机上可能就是白屏,因为用户手机上的 WebView 版本过旧,不支持新版 JavaScript 语法。
解决思路有两个:一是把你的 App 最低支持的 WebView 版本作为系统要求写进发布说明,同时在前端做兼容;二是对网页做基础能力检测,如果脚本注入失败或者页面加载失败,给出友好提示。另外android:hardwareAccelerated这个属性,WebView 在不开启硬件加速的情况下渲染性能很惨,要注意在 Activity 或 Application 上默认开启。
3. 加载第三方网页的最小可用实现
3.1 XML 布局里的 WebView
在 Activity 对应布局文件里加一个 WebView:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <ProgressBar android:id="@+id/progressBar" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="3dp" android:max="100" android:progress="0" android:visibility="gone" /> <WebView android:id="@+id/webView" android:layout_width="match_parent" android:layout_height="match_parent" /> </LinearLayout>头部放一个细长的 ProgressBar,是为了在网页加载过程中给用户进度反馈。这个进度条不是必须的,但加上以后,体验上会比白屏干等强很多,第三方网页资源多、图片多时尤其实用。
3.2 MainActivity 里的基本加载逻辑
Activity 里先把 WebView 实例化出来,加载指定 URL:
class MainActivity : AppCompatActivity() { private lateinit var webView: WebView private lateinit var progressBar: ProgressBar override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) webView = findViewById(R.id.webView) progressBar = findViewById(R.id.progressBar) initWebView() webView.loadUrl("https://www.example.com/index.html") } private fun initWebView() { val settings = webView.settings settings.javaScriptEnabled = true settings.domStorageEnabled = true webView.webViewClient = MyWebViewClient() webView.webChromeClient = MyWebChromeClient() } }这里有两个最容易踩的坑。
第一个坑:不加WebViewClient,页面链接一被点击就跳到系统浏览器。因为默认的 WebView 会把页面导航交给系统处理,让用户离开应用。
第二个坑:不打开domStorageEnabled,部分依赖 localStorage 的第三方网页会初始化失败。尤其现在前端框架都离不开 localStorage 做状态缓存,不打开这个设置,页面看起来就像个半残废版本。
3.3 自定义 WebViewClient 和 WebChromeClient
WebViewClient 负责处理页面渲染和导航事件,比如是否在应用内打开链接、页面加载完成后回调给原生。WebChromeClient 负责处理网页里弹窗、标题、进度等 UI 相关事件。
它们两个日常容易搞混。我打个比方:WebViewClient 是页面内部的管家,管导航、渲染错误;WebChromeClient 是页面与浏览器的沟通员,管进度条、JS 弹窗、文件选择器。
最简单的两个版本如下:
class MyWebViewClient : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { // 返回 false 表示继续在当前 WebView 中加载 return false } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) // 页面加载完成,可以隐藏 loading } } class MyWebChromeClient : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { super.onProgressChanged(view, newProgress) // 更新顶部进度条 } override fun onReceivedTitle(view: WebView?, title: String?) { super.onReceivedTitle(view, title) } }3.4 WebSettings 关键配置参数
把 WebSettings 常用参数配齐全,可以让第三方页面少出很多幺蛾子。下面是我每次搭 WebView 都会过一遍的参数清单:
| 参数 | 影响 |
|---|---|
| javaScriptEnabled | 是否启用 JS,第三方页面基本都要开 |
| domStorageEnabled | 是否开启 localStorage,现代 H5 需要 |
| allowFileAccess | 是否允许访问 file://,尽量关 |
| allowContentAccess | 是否允许内容访问器,按需开 |
| setAppCacheEnabled | 应用缓存开关,开 |
| setCacheMode | 缓存策略,默认 LOAD_DEFAULT |
| databaseEnabled | Web SQL 数据库,按需开 |
| mediaPlaybackRequiresUserGesture | 是否需要用户手势才能播放媒体 |
| mixedContentMode | 混合内容策略 |
| userAgentString | 自定义 UA,用于标识或切换页面 |
配置时要注意性能。有些老的写法会把cacheMode设置成LOAD_NO_CACHE,本地调试没问题,但线上会让第三方 H5 每次都重新拉资源,速度慢很多。建议平时用LOAD_DEFAULT,只有在页面数据实时性要求极高时再考虑禁用缓存。另外,allowFileAccess在 targetSdk 30 之后默认是关闭的,如果我确实需要从本地读文件,我宁愿让前端把文件放到 assets 再用https://appassets.androidplatform.net这类路径加载,也不直接开文件权限。
4. WebViewClient 与 WebChromeClient:深水区交互
4.1 WebViewClient 到底管了什么
WebViewClient里有几个干活核心:
shouldOverrideUrlLoading:链接点击后的导航拦截,返回 true 表示自己处理,false 表示继续 WebView 加载。onPageStarted:页面开始加载,可以在这里显示 loading。onPageFinished:页面加载完成,这里做数据初始化或隐藏 loading。onReceivedError:页面加载出错,这里给用户一个友好提示。shouldInterceptRequest:拦截资源请求,可以用于动态替换资源、注入本地静态文件。
我实际接第三方网页时,真正要用shouldOverrideUrlLoading的不多,因为返回 false 让它直接在当前 WebView 里跳转,就能满足大部分场景。真正的问题是第三方网页里可能会带tel:、mailto:这样的协议链接,这时 WebView 处理不了,必须跳转出去交给系统电话、邮件应用。
所以更的写法不是统一返回 false:
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val url = request?.url.toString() if (url.startsWith("tel:") || url.startsWith("mailto:")) { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) return true } return false }如果第三方网页里有一点分享、跳转到其它 App 的逻辑,也要在shouldOverrideUrlLoading里做好白名单判断。否则本来在 App 内访问一个登录授权页,授权完成回调到https://example.com/callback,这时候如果让它在 WebView 里加载回调地址,用户可能又回到了一个空页面,而本该由原生来接收回调。这就是为什么要认真看 URL 决定是 native 还是有 WebView 自己处理。
4.2 WebChromeClient 负责进度和弹窗
WebChromeClient更多处理页面与用户浏览器层交互:
onProgressChanged:网页加载进度 0-100onReceivedTitle:页面标题onJsAlert/onJsConfirm/onJsPrompt:JS 原生弹窗onShowFileChooser:H5<input type="file">文件选择onPermissionRequest:摄像头、麦克风等敏感权限onGeolocationPermissionsShowPrompt:网页获取定位的权限弹窗
我见过很多项目只设置了 WebViewClient,没设置 WebChromeClient。这样页面里如果调了alert,JS 逻辑就会停在那里,用户侧却完全没有任何弹窗反应,就像页面卡死了一样。
要给用户正常的网页体验,onJsAlert可以用默认实现,也可以自己在原生弹一个 Dialog。更常见的是重写onGeo...和onShowFileChooser,因为第三方页面经常需要传照片、获取定位。这两个模块我在第 6 章展开讲。
4.3 浏览器弹窗的处理
网页用window.open()打开的新窗口,在 WebView 里默认也是不出来的。如果需要支持window.open,通常在WebChromeClient里做处理:
override fun onCreateWindow( view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message? ): Boolean { val newWebView = WebView(this) val transport = WebView.WebViewTransport() transport.webView = newWebView resultMsg?.obj = transport WebView(this).apply { webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean { return false } } } resultMsg?.sendToTarget() return true }不过说句实话,为了稳妥和体验,我对第三方网页里弹出的新窗口,更建议直接拦截掉,放到系统浏览器,或者引导用户在当前页面内查看。因为每次创建子 WebView 就要维护它的生命周期,复杂度和坑点都会成倍增加。如果遇到大量弹窗功能的 H5,可以让前端改成在当前页 modal,而不是独立的 window。
5. 生命周期管理和返回键
5.1 不复用的问题
WebView 有个比较恶心的特征:如果 Activity 销毁了但 WebView 没有被正确回收,很容易出现内存泄漏。很多项目早期不会注意,后来发现内存一路上涨,就是因为 Activity 退出时,WebView 还在后台持有 Activity 的引用,一直在跑 loading 或者保存着 JS 环境。
所以在onDestroy中,我会先按下面顺序清理:
override fun onDestroy() { webView.loadUrl("about:blank") webView.stopLoading() webView.removeAllViews() webView.destroy() super.onDestroy() }先加载about:blank的意图是让 WebView 把当前页面的所有 JS 资源和复杂 DOM 先释放掉,再进行 destroy,内存回收会干净很多。
另外还要留个心眼:如果 WebView 是从 XML 里拿到的,它依赖 Activity 的 Context,销毁时顺序要小心。如果 App 主界面退出后还有后台 Service 或者广播还在引用这事,会造成 WebView 泄漏。该剥离的一定要剥离干净。
5.2 onPause / onResume 相关的处理
WebView 的页面在后台时,JS 可能还会不断运行。
一个比较直接的例子:某个商城的首页会定时轮播,或者用户在 WebView 里看视频,切到后台后视频如果还继续播,用户会很头疼。
Android 提供了两个方法:
override fun onPause() { super.onPause() webView.onPause() } override fun onResume() { super.onResume() webView.onResume() }注意,webView.onPause()和webView.pauseTimers()是两回事。onPause()只是让当前 WebView 暂停,不停止 WebView 全局的 JS 执行;pauseTimers()是全局暂停所有 WebView 里的 JS 计时器。
如果 Activity 被完全切到后台,我建议两个都调用;如果只是进入了另一个原生 Activity,页面还有可能回来,就只调用webView.pauseTimers()。不过pauseTimers()是全局性的,如果 App 里有多个 WebView,暂停会导致其它 WebView 的 timer 也停了,这一点要结合业务权衡。
5.3 返回键与导航栈
加载第三方网页时,用户经常需要点击返回键回到上一页,而不是直接退出整个 App。原生写法是:
override fun onBackPressed() { if (webView.canGoBack()) { webView.goBack() } else { super.onBackPressed() } }这个写法看起来简单,但实际用的时候还要考虑一种情况:有大量用户通过返回键想要退出 App,却发现每次点击都在网页里后退,会觉得很烦。我的做法是记录一个“进入 WebView 页面的入口时间”,如果用户连续按返回键多次还在网页栈里,就不继续 goBack,而是直接触发退出逻辑。
另外,onBackPressed在 Android 13 里已经废弃,如果 targetSdk 是 34,建议使用OnBackPressedDispatcher:
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (webView.canGoBack()) { webView.goBack() } else { isEnabled = false onBackPressedDispatcher.onBackPressed() } } })不管写法怎么变,核心思路没变:优先消费 WebView 自己的导航栈。第三方网页内部跳了很多层,返回键顺着历史记录一层层退,是最符合用户直觉的交互。
6. 和第三方网页做交互
6.1 addJavascriptInterface 双向通信
大多数第三方网页只需要单向展示,但有些场景要求“网页里的按钮点击后,唤醒原生拍照”,或者“网页支付完成后,回到原生页面”。这时就需要使用addJavascriptInterface。
在原生侧,先定义一个桥接类:
class JsBridge(private val activity: Activity) { @JavascriptInterface fun closePage() { activity.runOnUiThread { activity.finish() } } @JavascriptInterface fun openNativeDetail(id: String) { activity.runOnUiThread { // 跳转到原生页面 } } }然后在 WebView 中注册:
webView.addJavascriptInterface(JsBridge(this), "NativeBridge")网页侧就可以用:
window.NativeBridge.closePage();这种方式非常方便。但我必须反复强调一个安全理念:如果不是完全可信的网页,不要随便开放addJavascriptInterface。在某些 Android 版本中,JS 可以调用被注入对象的任何方法,如果网页里有恶意脚本,存在反射漏洞的隐患。现在 SDK 版本已经修复了大量此问题,但作为开发习惯,只暴露非常有限的方法,不要在 JS 注入对象里放高危方法,比如getDeviceId、读文件、删除数据之类的。就算真要获取,也一定在服务端做二次鉴权。
6.2 安全的呼起与回调
如果你的“第三方网页”只是公司自己的运营页面,那接口调用的安全性还有一层可控空间:域名白名单。
在shouldOverrideUrlLoading时判断当前 URL 是不是在白名单内,不是在白名单的 URL 统统转系统浏览器或拦截。这个动作虽然简单,但对防止滥用 JS Bridge 很关键。因为 WebView 可能被诱导跳转到一个钓鱼页面,而这个页面也带着你的NativeBridge一并用 JS 调用。
我习惯把需要桥接的域名独立出来放在一个数组里,每次初始化 WebView 时做一次校验,如果loadUrl传入的 URL 不在可信任域名列表内,就直接不注册 JS Bridge:
if (isTrustedDomain(url)) { webView.addJavascriptInterface(JsBridge(this), "NativeBridge") }这样最外层就过滤掉了不安全的调用场景。
6.3 Cookie 管理
第三方 H5 经常依赖 Cookie 维持登录态。WebView 默认是允许 Cookie 的,但在 API 21 之后建议显式设置:
val cookieManager = CookieManager.getInstance() cookieManager.setAcceptCookie(true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { cookieManager.setAcceptThirdPartyCookies(webView, true) }注意“第三方 Cookie 并不等同于第三方网页”。比如说你的 App 打开一级页面a.com,页面上有b.com的嵌套内容,那 b.com 给用户写入的 Cookie 就是第三方 Cookie。如果你的业务场景中包含高度定制的前端页面,需要根据登录用户的 Session 去请求资源,务必检查setAcceptThirdPartyCookies是否放行。
还有一个常见做法:原生登录成功后,想把这个登录态带给 WebView。办法是拿 Cookie 字符串直接做初始化:
cookieManager.setCookie("https://example.com", "sessionId=xxxx;Path=/")这种操作要在WebView创建以后、加载页面之前完成。否则网页一发起请求,Cookie 还没写入,就会出现反复跳转回登录页的效果。
7. 第三方页面里的系统能力适配
7.1 文件选择:拍照或相册上传
用户在 H5 里点了一个input type="file",正常情况下 WebView 是不会有任何反馈的。想让它弹出图片选择器,要在 WebChromeClient 里重写onShowFileChooser。
网上随手一搜会有各种老教程,有些已经不适合现在 targetSdk 30+ 的环境。核心逻辑是这样:
private var filePathCallback: ValueCallback<Array<Uri>>? = null override fun onShowFileChooser( webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams? ): Boolean { this.filePathCallback?.onReceiveValue(null) this.filePathCallback = filePathCallback // 创建 Intent 去选择图片或拍一张照片 val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) val contentUri = createImageUri() takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, contentUri) val selectPictureIntent = Intent(Intent.ACTION_GET_CONTENT).apply { type = "image/*" addCategory(Intent.CATEGORY_OPENABLE) } val chooser = Intent.createChooser(selectPictureIntent, "选择图片") chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(takePictureIntent)) (activity as Activity).startActivityForResult(chooser, REQUEST_FILE_CHOOSE) return true } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_FILE_CHOOSE) { if (resultCode == Activity.RESULT_OK) { filePathCallback?.onReceiveValue( WebChromeClient.FileChooserParams.parseResult(resultCode, data) ) } else { filePathCallback?.onReceiveValue(null) } filePathCallback = null } }拍照之后得到的 Uri 一定要通过 FileProvider 使用,把相机返回的原始文件地址传给 H5。如果直接传file://,很多 WebView 版本会认为 uri 非法或者没有权限打开。这就是我在第 2.2 节提到的 Content Uri 原因所在。
从 API 34 开始,直接把MediaStore.ACTION_IMAGE_CAPTURE的EXTRA_OUTPUT传成 file 路径会触发FileUriExposedException,所以别偷懒,自己写一个用于拍照的 FileProvider 很有必要。
7.2 文件下载与打开
网页如果要下载一个文件,WebView 默认行为是弹一个系统下载框,或者说干脆什么都发生不了。如果你想让下载的文件存到 App 目录,或者引导用户用系统下载器下载,那么给 WebView 挂个DownloadListener就行:
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> val request = DownloadManager.Request(Uri.parse(url)).apply { setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, URLUtil.guessFileName(url, contentDisposition, mimeType)) setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) } val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager downloadManager.enqueue(request) }这里有一个需要注意的地方:部分第三方 CDN 链接会带请求头鉴权,比如需要携带Referer或者Token,DownloadManager直接访问会拿到 403。这时你可以用原始 WebView 的方式下载,或者自己写一个带 Header 的下载请求。实际要用到什么方案,取决于对接站点服务端的校验策略,没有统一文档可抄,需要和前端/后端核对。
7.3 获取地理位置
网页应用要想拿到用户的定位,光在明文 App Manifest 里申请 ACCESS_FINE_LOCATION 还不行。HTML5 Geolocation 在 WebView 里默认是不弹权限提示的,必须在WebChromeClient里处理:
override fun onGeolocationPermissionsShowPrompt( origin: String?, callback: GeolocationPermissions.Callback? ) { callback?.invoke(origin, true, false) }直接调用callback.invoke(origin, true, false)可以立即授权。如果你们业务讲究隐私合规,要在原生弹对话框,由用户选择同意还是拒绝,然后再把结果 callback 传回去。
真正开发中还有个容易漏的:第三方网页因为用的是http明文协议,在 WebView 里访问定位接口时,浏览器级安全策略可能直接把定位权限判定为不可用。这种并不是你的授权代码有问题,而是页面本身没跑在 HTTPS 下。要让定位功能正常,尽量要求页面用 HTTPS 访问。
7.4 网页里播放视频
第三方网页里可能会内嵌一些视频播放器,比如使用 HTML5 的<video>标签。默认情况下,点击播放可能有声音没有画面,或者画面不能全屏。
如果页面需要用自带的播放器全屏播放,需要在WebChromeClient里处理onShowCustomView和onHideCustomView。这是一套比较复杂的回调体系,需要在布局外层动态加入一个全屏的 View,从当前 WebView 切换到那个 View 播放。
项目里如果不要求网页视频全屏,只说“能播就行”,可以直接在<video>标签所在的页面内播放,不必写全屏。这样可以少踩很多深坑。全屏播放牵扯到 Activity 屏幕方向切换、焦点处理、全屏 View 的移除时机,容易引来各种隐性的 bug。
8. 加载失败、白屏和兼容性排查
8.1 白屏问题排查清单
WebView 加载第三方页面,最让人崩溃的现象就是白屏。页面加载不出来,连个错误码也没有。
我总结过一套白屏排查顺序:
| 原因 | 怎么排查 |
|---|---|
| 没有加 INTERNET 权限 | 看日志有没有 UnknownHostException |
| Android 9+ 明文 HTTP 被拦截 | 检查 URL 是不是 http 开头 |
| JS 未启用 | 页面逻辑依赖 JS,但 settings 没开 |
| domStorage 未开启 | localStorage 读取失败导致前端崩溃 |
| WebView 版本与前端不兼容 | 看页面是否有特定新版 API |
| 页面发生了无条件 JS 异常 | 用 Chrome DevTools 真机调试看 console |
| 服务端响应体过大或超时 | 抓包看 response |
很多白屏不是 Android 问题,是前端脚本在运行时直接抛了异常,页面渲染进程被中断。单纯看 WebView 层查不到根本原因。为了确认这一点,我建议开发时用 Chrome 的远程调试工具chrome://inspect连接真机,直接看 WebView 里的 Console 报错,这样能快速定位到底是前端 bug 还是 WebView 环境问题。
8.2 页面加载失败与错误处理
旧版本的onReceivedError在新 API 上的行为有所变化。早期只需要调用一次就代表页面加载失败;在新版本上,子资源请求(图片、CSS、JS)也会走同一个回调,如果你在onReceivedError里弹了一个“网络错误”提示,结果可能页面主体明明加载成功,只是某个静态资源加载失败了,用户却被错误弹窗打扰了一下。
比较稳妥的做法是区分主框架错误和子资源错误:
override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { super.onReceivedError(view, request, error) if (request?.isForMainFrame == true) { // 主页面加载失败 view?.loadUrl("about:blank") showErrorView() } }旧版本 API 用onReceivedError(view, errorCode, description, failingUrl),也要判断 failingUrl 是否等于当前页。如果不判断,就很容易出现网页主框架成功,但有一个favicon.ico请求 404,错误提示却弹给了用户,体验很糟糕。
另外,出现网络错误后直接loadUrl("about:blank")是个惯用操作,目的是把当前半加载状态清干净,否则 WebView 里会残留一套破损的 DOM。
8.3 混合内容处理
HTTPS 页面如果请求了http资源,比如通过http://加载一张图片或一个接口,WebView 默认不会完成加载,控制台会报Mixed Content错误。
处理方式是按需放开:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE }MIXED_CONTENT_COMPATIBILITY_MODE模式下,WebView 会尝试加载混合内容,但与 HTTPS 兼容的资源才被允许。还有更粗暴的MIXED_CONTENT_ALWAYS_ALLOW,安全级别低,不推荐。
我遇到过的情况是套壳 App 里嵌了一个老旧的支付页面,页面用的 HTTPS,但它引用的图片服务还是 HTTP 协议。不开兼容模式,图片全裂开,支付码也显示不出来;开了之后,功能恢复了,同时我也建议前端尽早把这些资源迁到 HTTPS。毕竟混合内容本身也容易被中间人篡改,影响支付的严肃性。
8.4 缓存与速度
第三方页面首屏加载慢,通常是两个原因:资源太多、请求链路太长。WebView 能做的就是尽量把可缓存的东西留下来,第二次打开时不再重新下载。
常见的缓存模式如下:
settings.cacheMode = WebSettings.LOAD_DEFAULT settings.setAppCacheEnabled(true) settings.setAppCachePath(cacheDir.absolutePath)你可能注意到我没有用LOAD_CACHE_ELSE_NETWORK。这种模式很适合“有缓存就用缓存”的静态页面,但也会引入缓存污染。比如电商页面里用户下单以后,库存数量已经变了,结果下次打开还是缓存里的旧数据。
更好的做法是用默认的缓存策略,然后在 HTTP 响应层保证静态资源带 Cache-Control 头。前端如果做了合理的 webpack 打包,每次发版文件指纹变化,缓存问题不会太严重。相反,如果直接在 WebView 里强制使用本地缓存,倒是会让线上问题排查变得非常被动。
8.5 别忽略页面性能监控
网页加载完不代表用户能开始交互,因为有很多页面要等DOMContentLoaded之后才能响应用户点击。
做 WebView 加载第三方页面时,我建议至少统计三个时间点:
onPageStarted时间,表示开始加载onProgressChanged达到 100 的时间,表示资源基本加载完成onPageFinished时间,表示页面解析完成
某些场景下,页面在onPageFinished后还会有懒加载的内容继续加载。若要更精准的性能数据,可以尝试在onPageFinished里通过evaluateJavascript去读取performance.timing。不过这需要页面支持存取 performance API,属于锦上添花的操作,不一定每个项目都有必要。
9. 回顾一下关键避坑点
把近段时间做 WebView 加载第三方页面的经验集中列出来,算是我自己的备忘,也给大家一个速查表:
- 硬件加速必须开,不开画视频和复杂 CSS 动画会卡。
- 只加 WebViewClient 不加 WebChromeClient,很多页面会卡在 JS
alert上无反应。 - HTTP 页面默认被 Android 9+ 拦截,别跟系统安全策略硬碰硬,尽快切 HTTPS。
domStorageEnabled和javaScriptEnabled是否开启,是很多“白屏 bug”的总根源。- 页面加载失败不要无脑弹错误提示,先判断是否主框架错误。
- Cookie 登录态要给足初始化时间,否则页面会出现“先登录后跳转”。
- 文件上传绕不开 FileProvider,别再用老
file://直传。 - JS Bridge 方法是双刃剑,只暴露受控白名单域名,否则安全审计一定过不了。
- WebView 一定要参与 Activity 生命周期管理,否则内存泄漏可能比业务上线速度还要快。
- 返回键不要一上来
finish,先处理 WebView 自己的历史记录栈。
我在实际项目里发现,90%的 WebView 疑难杂症不是新需求,而是基础配置没考虑完整。第三方网页本身不可控,作为客户端要做的就是把浏览器内核该有的能力激活,同时把不该开的危险口子堵严。很多问题在前期开发阶段多花五分钟配置,后面能省下大量客服、反馈和线上 bug 的时间。如果你正打算在 App 里接入一个外部网页,建议先照着这份配置把骨架搭好,然后针对性做页面调试,比自己从头踩坑要快得多。