0%

Android-WebView笔记

概述

WebView是一个基于webkit引擎、展现web页面的控件。Android WebView 在低版本和高版本采用了不同的 webkit 版本内核,在 4.4 版本后使用 Chrome 内核。参考 WebView for Android

WebView

参考官方说明:WebView

WebView的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 激活WebView为活跃状态,能正常执行网页的响应
webView.onResume() ;
// 当页面被失去焦点被切换到后台不可见状态,需要执行onPause
// 通过onPause动作通知内核暂停所有的动作,比如DOM的解析、plugin的执行、JavaScript执行。
webView.onPause();

// 当应用程序(存在webview)被切换到后台时,这个方法不仅仅针对当前的webview而是全局的全应用程序的webview
// 它会暂停所有webview的layout,parsing,javascripttimer。降低CPU功耗。
webView.pauseTimers()
// 恢复pauseTimers状态
webView.resumeTimers();

// webview调用destory时,webview仍绑定在Activity上
// 需要先从父容器中移除webview,然后再销毁webview
rootLayout.removeView(webView);
webView.destroy();

前进/后退网页

1
2
3
4
5
6
7
8
9
10
11
12
13
// 是否可以后退
Webview.canGoBack()
// 后退网页
Webview.goBack()

// 是否可以前进
Webview.canGoForward()
// 前进网页
Webview.goForward()

// 以当前的index为起始点前进或者后退到历史记录中指定的steps
// 如果steps为负数则为后退,正数则为前进
Webview.goBackOrForward(intsteps)

在不做任何处理前提下,浏览网页时点击系统的“Back”键时,整个 Browser 会调用 finish()而结束自身,因此需要在当前Activity中处理并消费掉该 Back 事件,当按下返回键时,调用goBack方法。

清除缓存数据

1
2
3
4
5
6
7
8
9
// 清除网页访问留下的缓存
// 由于内核缓存是全局的因此这个方法不仅仅针对webview而是针对整个应用程序.
Webview.clearCache(true);

// 清除当前webview访问的历史记录
Webview.clearHistory();

// 这个api仅仅清除自动完成填充的表单数据,并不会清除WebView存储到本地的数据
Webview.clearFormData();

WebSettings

作用:对WebView进行配置和管理。可以参考官方说明:WebSettings

添加访问网络权限: android.permission.INTERNET

注意:从Android 9.0(API级别28)开始,默认情况下禁用明文支持,会显示 ERR_CLEARTEXT_NOT_PERMITTED。因此http的url均无法在webview中加载,可以在manifest中application节点添加android:usesCleartextTraffic="true"

配置 WebSettings 子类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 声明WebSettings子类
WebSettings webSettings = webView.getSettings();

// 如果访问的页面中要与Javascript交互,则webview必须设置支持Javascript
webSettings.setJavaScriptEnabled(true);

// 设置自适应屏幕,两者合用
webSettings.setUseWideViewPort(true); //将图片调整到适合webview的大小
webSettings.setLoadWithOverviewMode(true); // 缩放至屏幕的大小

// 缩放操作
webSettings.setSupportZoom(true); //支持缩放,默认为true。是下面那个的前提。
webSettings.setBuiltInZoomControls(true); //设置内置的缩放控件。若为false,则该WebView不可缩放
webSettings.setDisplayZoomControls(false); //隐藏原生的缩放控件

// 其他细节操作
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK); //关闭webview中缓存
webSettings.setAllowFileAccess(true); //设置可以访问文件
webSettings.setJavaScriptCanOpenWindowsAutomatically(true); //支持通过JS打开新窗口
webSettings.setLoadsImagesAutomatically(true); //支持自动加载图片
webSettings.setDefaultTextEncodingName("utf-8");//设置编码格式

WebViewClient

用来处理各种通知 & 请求事件,具体使用参考 官方文档

shouldOverrideUrlLoading

在网页上的所有加载都会经过这个方法,因此可以使打开网页时不调用系统浏览器,而是在本 WebView 展示。

1
2
3
4
5
6
7
8
9
10
11
webView.setWebViewClient(new WebViewClient(){
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
try {
mWebView.loadUrl(URLDecoder.decode(request.getUrl().toString(), "UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return true;
}
});

onPageStarted

开始载入页面时调用,可以在这里显示 loading 页面。

onPageFinished

在页面加载结束时调用,可以关闭 loading 条等。

onLoadResource

在加载页面资源时调用,每一个资源(如图片)的加载都会调用一次。

onReceivedError

加载出现错误时(如404)调用,可以展示错误页面。

onReceivedHttpError

加载资源时从服务器收到HTTP错误时调用。

onReceivedSslError

加载资源时发生 SSL 错误,需要调用 SslErrorHandler#cancel(默认) 或 SslErrorHandler#proceed 处理。

WebChromeClient

辅助 WebView 处理 Javascript 的对话框,网站图标,网站标题等等,具体使用参考 官方文档

onProgressChanged

获取网页的加载进度。

onReceivedTitle

获取网页标题。

对话框

处理网页中的对话框弹出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mWebView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
return super.onJsAlert(view, url, message, result);
}

@Override
public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
return super.onJsConfirm(view, url, message, result);
}

@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
return super.onJsPrompt(view, url, message, defaultValue, result);
}
});

客户端调用JS

概述

注意:JS代码要在onPageFinished回调之后才能调用,可以直接使用基础类型传递参数,也可以使用JSONObject。

WebView.loadUrl()

  1. 准备html文件,放到assets中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <title>测试</title>
    <script>
    function callJS(){
    alert("Android调用了JS的callJS方法");
    }
    </script>
    </head>

    <body>
    <h1>标题</h1>
    </body>
    </html>
  2. 加载调用。

    1
    2
    3
    4
    mWebView.loadUrl("file:///android_asset/index.html");

    // after onPageFinished
    mWebView.loadUrl("javascript:callJS()");
  3. 由于设置了alert,所以需要支持js对话框,内容的渲染需要使用webviewChromClient类去实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    mWebView.setWebChromeClient(new WebChromeClient() {
    @Override
    public boolean onJsAlert(WebView view, String url, String message, final JsResult result) {
    AlertDialog.Builder b = new AlertDialog.Builder(MainActivity.this);
    b.setTitle("Alert");
    b.setMessage(message);
    b.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
    result.confirm();
    }
    });
    b.setCancelable(false);
    b.create().show();
    return true;
    }
    });

WebView.evaluateJavascript()

优点:该方法比第一种方法效率更高、使用更简洁。

  • 因为该方法的执行不会使页面刷新,而第一种方法(loadUrl)的执行则会。
  • Android 4.4 后才可使用。

具体使用:

1
2
3
4
5
6
7
// 只需要将第一种方法的loadUrl()换成下面该方法即可
mWebView.evaluateJavascript("javascript:callJS()", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
//此处为 js 返回的结果
}
});

JS调用客户端

概述

可以直接使用基础类型传递参数,也可以使用JSONObject。

WebView.addJavascriptInterface()

  • 优点:使用简单,仅将Android对象和JS对象映射即可。

使用步骤:

  1. 定义一个与JS对象映射关系的Android类:AndroidtoJs。

    1
    2
    3
    4
    5
    6
    7
    8
    public class AndroidtoJs {

    // 被JS调用的方法必须加入@JavascriptInterface注解
    @JavascriptInterface
    public void hello(String msg) {
    Log.d(TAG, msg);
    }
    }
  2. 准备html文件,放到assets中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <title>Carson</title>
    <script>
    function callAndroid(){
    // 由于对象映射,所以调用test对象等于调用Android映射的对象
    test.hello("js调用了android中的hello方法");
    }
    </script>
    </head>

    <body>
    //点击按钮则调用callAndroid函数
    <button type="button" onclick="callAndroid()"></button>
    </body>
    </html>
  3. 定义映射,调用方法。

    1
    mWebView.addJavascriptInterface(new AndroidtoJs(), "test");

WebViewClient.shouldOverrideUrlLoading()

  • 优点:不存在方式1的漏洞。
  • 缺点:JS获取Android方法的返回值复杂。如果JS想要得到Android方法的返回值,只能通过 WebView 的 loadUrl() 去执行 JS 方法把返回值传递回去。

使用步骤:

  1. Android通过 WebViewClient 的回调方法shouldOverrideUrlLoading ()拦截 url;
  2. 解析该 url 的协议;
  3. 如果检测到是预先约定好的协议,就调用相应方法,即JS需要调用Android的方法。

缓存机制

到目前为止,H5的缓存机制一共有六种,分别是:

  1. 浏览器缓存机制
  2. Dom Storgage(Web Storage)存储机制
  3. Web SQL Database存储机制
  4. Indexed Database(IndexedDB)
  5. Application Cache(AppCache)机制
  6. File System API

参考: https://juejin.cn/post/6844903934004297736

WebView设置缓存

常见用法:设置WebView缓存,缓存模式如下:

  • LOAD_CACHE_ONLY: 不使用网络,只读取本地缓存数据
  • LOAD_DEFAULT: (默认)根据cache-control决定是否从网络上取数据。
  • LOAD_NO_CACHE: 不使用缓存,只从网络获取数据.
  • LOAD_CACHE_ELSE_NETWORK,只要本地有,无论是否过期,或者no-cache,都使用缓存中的数据。

当加载 html 页面时,WebView会在/data/data/包名目录下生成 database 与 cache 两个文件夹。请求的 URL记录保存在 WebViewCache.db,而 URL的内容是保存在 WebViewCache 文件夹下。设置是否启用缓存:

1
2
3
4
//优先使用缓存: 
WebView.getSettings().setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);
//不使用缓存:
WebView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE);

离线加载用法:

1
2
3
4
5
6
7
8
9
10
11
12
if (NetStatusUtil.isConnected(getApplicationContext())) {
webSettings.setCacheMode(WebSettings.LOAD_DEFAULT);// 根据cache-control决定是否从网络上取数据。
} else {
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);// 没网,则从本地获取,即离线加载
}

webSettings.setDomStorageEnabled(true); // 开启 DOM storage API 功能
webSettings.setDatabaseEnabled(true); // 开启 database storage API 功能
webSettings.setAppCacheEnabled(true); // 开启 Application Caches 功能

String cacheDirPath = getFilesDir().getAbsolutePath() + APP_CACAHE_DIRNAME;
webSettings.setAppCachePath(cacheDirPath); // 设置 Application Caches 缓存目录

注意: 每个 Application 只调用一次 WebSettings.setAppCachePath(),WebSettings.setAppCacheMaxSize()

浏览器缓存机制

浏览器缓存机制主要是根据HTTP协议头里的Cache-ControlExpiresLast-Modified以及Etag请求头控制缓存。浏览器缓存主要用于静态资源文件的存储,Webview会将访问的文件记录及文件内容存在当前app的data目录中。WebView内置自动实现,使用默认的CacheMode就可以实现。

浏览器缓存的优势在于支持Http协议层。不足之处有:

  • 需要首次加载之后才能产生缓存文件;
  • 终端设备缓存的空间有限,缓存有可能会被清除;
  • 缓存使用缺乏校验,有可能被篡改;

Application Cache缓存机制

AppCache的缓存机制类似于浏览器的缓存(Cache-Control和Last-Modified)机制,都是以文件为单位进行缓存,且文件有一定更新机制。但AppCache是对浏览器缓存机制的补充,不是替代。

AppCache有两个关键点:manifest属性和manifest文件。在头中通过manifest属性引用manifest文件。

浏览器在首次加载时,会解析manifest属性,并读取manifest文件,获取Section:CACHE MANIFEST下要缓存的文件列表,再对文件缓存。AppCache也有更新机制。被缓存的文件如果要更新,需要更新manifest文件。发现有修改,就会重新获取manifest文件,manifest文件与缓存文件的检查更新也遵守浏览器缓存机制。用于存储静态文件(如JS、CSS、字体文件)。

AppCache已经不推荐使用了,标准也不会再支持。

使用方法:

1
2
3
4
5
6
7
String path = getApplicationContext().getDir("cache", Context.MODE_PRIVATE).getPath();
//设置缓存路径
webSettings.setAppCachePath(path);
//设置缓存大小
webSettings.setAppCacheMaxSize(10*1024*1024);
//开启缓存
webSettings.setAppCacheEnabled(true);

Dom Storage存储机制

DOM Storage 是一套在 Web Applications 1.0 规范中首次引入的与存储相关的特性的总称,现在已经分离出来,单独发展成为独立的 W3C Web 存储规范。DOM存储被设计为用来提供一个更大存储量、更安全、更便捷的存储方法,从而可以代替掉将一些不需要让服务器知道的信息存储到 cookies里的这种传统方法。

Dom Storage机制类似Cookies,但有一些优势。Dom Storage是通过存储字符串的Key-Value对来提供的,Dom Storage存储的数据在本地,不像Cookies,每次请求一次页面,Cookies都会发送给服务器。

DOM Storage分为sessionStorage和localStorage,二者使用方法基本相同,区别在于作用范围不同:前者具有临时性,用来存储与页面相关的数据,它在页面关闭后无法使用,后者具备持久性,即保存的数据在页面关闭后也可以使用。

  • sessionStorage是个全局对象,它维护着在页面会话(page session)期间有效的存储空间。只要浏览器开着,页面会话周期就会一直持续。当页面重新载入(reload)或者被恢复(restores)时,页面会话也是一直存在的。每在新标签或者新窗口中打开一个新页面,都会初始化一个新的会话。
  • localStorage保存的数据是持久性的。当前PAGE关闭(Page Session结束后),保存的数据依然存在。重新打开PAGE,上次保存的数据可以获取到。另外,Local Storage 是全局性的,同时打开两个 PAGE 会共享一份存数据,在一个PAGE中修改数据,另一个 PAGE 中是可以感知到的。

Dom Storage的优势在于:存储空间(5M)大,远远大于Cookies(4KB),而且数据存储在本地无需经常和服务器进行交互,存储安全、便捷。可用于存储临时的简单数据。作用机制类似于SharedPreference。但是,如果要存储结构化的数据,可能要借助JSON了,将要存储的对象转为JSON 串。不太适合存储比较复杂或存储空间要求比较大的数据,也不适合存储静态的文件。

使用方法如下:

1
webSettings.setDomStorageEnabled(true);

Web SQL Database存储机制

Web SQL Database基于SQL的数据库存储机制,用于存储适合数据库的结构化数据,充分利用数据库的优势,存储适合数据库的结构化数据,Web SQL Database存储机制提供了一组可方便对数据进行增加、删除、修改、查询。

Web SQL Database存储机制就是通过提供一组API,借助浏览器的实现,将Native支持的数据库功能提供给了Web。

实现方法为:

1
2
3
4
String cacheDirPath = context.getFilesDir().getAbsolutePath()+"cache/";
// 设置缓存路径
webSettings.setDatabasePath(cacheDirPath);
webSettings.setDatabaseEnabled(true);

Web SQL Database存储机制官方已不再推荐使用,也已经停止了维护,取而代之的是IndexedDB缓存机制。

Indexed Database缓存机制

Indexed DB也是一种数据库的存储机制,但不同于已经不再支持 Web SQL Database缓存机制。IndexedDB不是传统的关系数据库,而是属于NoSQL数据库,通过存储字符串的Key-Value对来提供存储(类似于Dom Storage,但功能更强大,且存储空间更大)。其中Key是必需的,且唯一的,Key可以自己定义,也可由系统自动生成。Value也是必需的,但Value非常灵活,可以是任何类型的对象。一般Value通过Key来存取的。

IndexedDB提供了一组异步的API,可以进行数据存、取以及遍历。IndexedDB有个非常强大的功能:index(索引),它可对Value对象中任何属性生成索引,然后可以基于索引进行Value对象的快速查询。

IndexedDB集合了Dom Storage和Web SQL Database的优点,用于存储大块或复杂结构的数据,提供更大的存储空间,使用起来也比较简单。可以作为 Web SQL Database的替代。但是不太适合静态文件的缓存。

Android在4.4开始支持IndexedDB,只需要打开允许JS执行的开关就好了,开启方法如下:

1
webSettings.setJavaScriptEnabled(true);

File System

File System是H5新加入的存储机制。它为Web App提供了一个运行在沙盒中的虚拟的文件系统。不同WebApp的虚拟文件系统是互相隔离的,虚拟文件系统与本地文件系统也是互相隔离的。Web App在虚拟的文件系统中,通过File System API提供的一组文件与文件夹的操作接口进行文件(夹)的创建、读、写、删除、遍历等操作。

浏览器给虚拟文件系统提供了两种类型的存储空间:临时的和持久性的:

  • 临时的存储空间是由浏览器自动分配的,但可能被浏览器回收;
  • 持久性的存储空间需要显示的申请,申请时浏览器会给用户一提示,需要用户进行确认。持久性的存储空间是 WebApp 自己管理,浏览器不会回收,也不会清除内容。存储空间大小通过配额管理,首次申请时会一个初始的配额,配额用完需要再次申请。

File System的优势在于:

  • 可存储数据体积较大的二进制数据
  • 可预加载资源文件
  • 可直接编辑文件

遗憾的是:由于File System是H5新加入的缓存机制,目前Android WebView暂时还不支持。

总结

名称 原理 优点 适用对象 说明
浏览器缓存 使用HTTP协议头部字段进行缓存控制 支持HTTP协议层 存储静态资源 Android默认实现
AppCache 类似浏览器缓存,以文件为单位进行缓存 构建方便 离线缓存,存储静态资源 对浏览器缓存的补充
Dom Storage 通过存储键值对实现 存储空间大,数据在本地,安全便捷 类似Cookies,存储临时的简单数据 类似Android中的SP
Web SQL DataBase 基于SQL 利用数据库优势,增删改查方便 存储复杂、数据量大的结构化数据 不推荐使用,用IndexedDB替代
IndexedDB 通过存储键值对实现(NoSQL) 存储空间大、使用简单灵活 存储复杂、数据量大的结构化数据 集合Dom Storage和Web SQL DataBase的优点
File System 提供一个虚拟的文件系统 可存储二进制数据、预加载资源和之间编辑文件 通过文件系统管理数据 目前Android不支持

域控制不严格漏洞

setAllowFileAccess

设置是否允许 WebView 使用 File 协议。使用 file 域加载的 js代码能够使用进行同源策略跨域访问(对私有目录文件进行访问),从而导致隐私信息泄露。

对于不需要使用 file 协议的应用,禁用 file 协议;

1
setAllowFileAccess(false); 

对于需要使用 file 协议的应用,禁止 file 协议加载 JavaScript。

1
2
3
4
5
if (url.startsWith("file://") {
setJavaScriptEnabled(false);
} else {
setJavaScriptEnabled(true);
}

setAllowFileAccessFromFileURLs

设置是否允许通过 file url 加载的 Js代码读取其他的本地文件。

  • 在Android 4.1前默认允许
  • 在Android 4.1后默认禁止

setAllowUniversalAccessFromFileURLs

设置是否允许通过 file url 加载的 Javascript 可以访问其他的源(包括http、https等源)。

  • 在Android 4.1前默认允许
  • 在Android 4.1后默认禁止

setJavaScriptEnabled

通过此 API 可以设置是否允许 WebView 使用 JavaScript,默认是不允许,但很多应用,包括移动浏览器为了让 WebView 执行 http 协议中的 JavaScript,都会主动设置允许 WebView 执行 JavaScript,而又不会对不同的协议区别对待,比较安全的实现是如果加载的 url 是 http 或 https 协议,则启用 JavaScript,如果是其它危险协议,比如是 file 协议,则禁用 JavaScript。如果是 file 协议,禁用 javascript 可以很大程度上减小跨源漏洞对 WebView 的威胁,但是此时禁用 JavaScript 的执行并不能完全杜绝跨源文件泄露。

最终解决方案

对于不需要使用 file 协议的应用,禁用 file 协议;

1
2
3
4
// 禁用 file 协议;
setAllowFileAccess(false);
setAllowFileAccessFromFileURLs(false);
setAllowUniversalAccessFromFileURLs(false);

对于需要使用 file 协议的应用,禁止 file 协议加载 JavaScript。

1
2
3
4
5
6
7
8
9
10
11
// 需要使用 file 协议
setAllowFileAccess(true);
setAllowFileAccessFromFileURLs(false);
setAllowUniversalAccessFromFileURLs(false);

// 禁止 file 协议加载 JavaScript
if (url.startsWith("file://") {
setJavaScriptEnabled(false);
} else {
setJavaScriptEnabled(true);
}

内存泄漏

概述

一直听说 WebView 使用不当容易造成内存泄漏,网上有很多针对内存泄漏的解决方案,比较多的是在 Activity.onDestroy 的时候将 WebView 从 View 树中移除,然后再调用 WebView.destroy 方法:

1
2
3
4
5
6
7
8
override fun onDestroy() {
val parent = webView?.parent
if (parent is ViewGroup) {
parent.removeView(webView)
}
webView?.destroy()
super.onDestroy()
}

于是我写了一个简单的包含一个 WebView 的 Activity,然后在 Activity.onDestroy 中分别尝试 啥也不干只调用 WebView.destroy 方法,接着项目里面集成了 leakcanary 用来检测内存泄漏,启动 App 后,反复横屏竖屏,发现 Activity.onDestroy 有被正常调用,但是 leakcanary 并没有提示有内存泄漏,因此猜想 WebView 高版本应该把这个问题修复了。我用的测试机是 Android 9 版本的,于是想着换个低版本的机型试试,就弄了个 Android 6 的手机一跑,发现还是没有发生内存泄漏,看了下网上这些讲 WebView 内存泄漏的文章,有的还是 2019 年的,既然都 2019 年了还在谈 WebView 会造成内存泄漏,那感觉 Android 6 的机型不应该表现正常呀,一脸懵逼。。。秉着不弄明白不罢休的原则,遇到这种问题好办,Read The Fucking Source Code 就完事了。

WebView销毁时做了什么

既然网上的解决方案说先调用 removeView 移除 WebView,然后再调用 WebView.destroy 方法,那想着内存泄漏应该可以从 onDetachedFromWindow(从 Window 中 detach) 和 destroy(销毁) 这两个逻辑里找原因,看一下 WebView 中的这两个方法:

1
2
3
4
5
6
7
8
9
public void destroy() {
checkThread();
mProvider.destroy();
}

protected void onDetachedFromWindowInternal() {
mProvider.getViewDelegate().onDetachedFromWindow();
super.onDetachedFromWindowInternal();
}

一般而言 destroy 方法应该在 Activity.onDestroy 时手动调用,而 onDetachedFromWindowInternal 方法在 View detach 的时候会由系统回调。注意 onDestroy 的调用时机早于 onDetachedFromWindow,相关的源码可以参考 Android图形系统综述 中 View 系列的文章自行跟踪。

上面这两个方法都出现了一个叫 mProvider 的对象,这个对象是啥呢?在 WebView.java 中搜索了一下 mProvider = 发现只有一处赋值:

1
2
3
private WebViewProvider mProvider;

mProvider = getFactory().createWebView(this, new PrivateAccess());

它是一个 WebViewProvider 类型的实例,接着看它是怎么被赋值的,首先看一看 getFactory 返回的工厂对象是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
private static WebViewFactoryProvider getFactory() {
return WebViewFactory.getProvider();
}

// WebViewFactory
static WebViewFactoryProvider getProvider() {
if (sProviderInstance != null) return sProviderInstance;
Class<WebViewFactoryProvider> providerClass = getProviderClass();
// CHROMIUM_WEBVIEW_FACTORY_METHOD = "create"
staticFactory = providerClass.getMethod(CHROMIUM_WEBVIEW_FACTORY_METHOD, WebViewDelegate.class);
sProviderInstance = (WebViewFactoryProvider) staticFactory.invoke(null, new WebViewDelegate());
return sProviderInstance;
}

上面的 WebViewFactory.getProvider() 方法看上去是通过调用 providerClass 中的 create 方法拿到了 sProviderInstance 实例,于是得继续看 getProviderClass 方法到底是返回了一个什么类型的类:

1
2
3
4
5
6
7
8
private static Class<WebViewFactoryProvider> getProviderClass() {
// ...
return getWebViewProviderClass(clazzLoader);
}

public static Class<WebViewFactoryProvider> getWebViewProviderClass(ClassLoader clazzLoader) throws ClassNotFoundException {
return (Class<WebViewFactoryProvider>) Class.forName(CHROMIUM_WEBVIEW_FACTORY, true, clazzLoader);
}

查看源码,可以发现 CHROMIUM_WEBVIEW_FACTORY 取值为 com.android.webview.chromium.WebViewChromiumFactoryProviderForP,我查看的源码版本是 Android P 的,所以这里是 WebViewChromiumFactoryProviderForP,看了一下其它 Android 版本的源码,发现都有一个对应的 WebViewChromiumFactoryProviderForX 值。这个 WebViewChromiumFactoryProviderForP 类在 AOSP 中是没有的,那应该去哪里找呢?

参考 Chrome developer 的文档: WebView for Android,可以看到从 Android 4.4 开始,WebView 组件基于 Chromium open source project 项目,新的 Webview 与 Android 端的 Chrome 浏览器共享同样的渲染引擎,因此 WebView 和 Chrome 之间的渲染应该会更加一致。而从 Android 5.0(Lollipop) 版本开始将 WebView 迁移到了一个独立的 APK — Android System WebView,因此可以单独在 Android 平台更新。这个 APP 可以在应用管理中看到,看到这里我大概明白了之前为啥用 Android 6 的机器也没有测试出内存泄漏,猜想应该是它的 Android System WebView 应用版本已经把内存泄漏的问题解决了吧,看了一下其应用版本是 86.0.4240.198(可以在应用管理中查看 Android System WebView 应用的版本,另外也可以在浏览器中打开这个 网址 也会显示版本)。于是我们验证一下这个猜想。

关于 Chromium open source project 的源码可以在这里查看: Chromium open source project Ref,在这里可以查看目标版本的源码,我选择 86.0.4240.198 版本的源码进行解析。接着上面的 WebViewChromiumFactoryProviderForP 开始:

1
2
3
4
5
6
7
8
9
public class WebViewChromiumFactoryProviderForP extends WebViewChromiumFactoryProvider {
public static WebViewChromiumFactoryProvider create(android.webkit.WebViewDelegate delegate) {
return new WebViewChromiumFactoryProviderForP(delegate);
}

protected WebViewChromiumFactoryProviderForP(android.webkit.WebViewDelegate delegate) {
super(delegate);
}
}

可以看到返回了一个 WebViewChromiumFactoryProviderForP 实例,其 createWebView 方法在父类 WebViewChromiumFactoryProvider 中:

1
2
3
public WebViewProvider createWebView(WebView webView, WebView.PrivateAccess privateAccess) {
return new WebViewChromium(this, webView, privateAccess, mShouldDisableThreadChecking);
}

因此上面的 mProvider 是 WebViewChromium 实例,来看一下它的 onDetachedFromWindow 和 destroy 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public WebViewProvider.ViewDelegate getViewDelegate() {
return this;
}

public void onDetachedFromWindow() {
// ...
mAwContents.onDetachedFromWindow();
}

public void destroy() {
// ...
mAwContents.destroy();
}

这俩都会调用到 AwContents 中对应的方法,所以上面 WebView 销毁的时候,其 destroy 和 onDetachedFromWindowInternal 方法最后会调用到 AwContents 中对应的方法,低版本的内存泄漏就发生在这里。

AwContents中的内存泄漏

我们先看一下 mAwContents 的创建:

1
mAwContents = new AwContents(mFactory.getBrowserContextOnUiThread(), mWebView, mContext, ...);

86.0.4240.198版本

首先看看 86.0.4240.198 版本中的 AwContents 类中的几个相关方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public void destroy() {
if (isDestroyed(NO_WARN)) return;
// ...
// Remove pending messages
mContentsClient.getCallbackHelper().removeCallbacksAndMessages();
if (mIsAttachedToWindow) {
// 如果此时没有 detach 则先调用 onDetachedFromWindow 方法,然后才将 mIsDestroyed 置为 true
Log.w(TAG, "WebView.destroy() called while WebView is still attached to window.");
onDetachedFromWindow();
}
mIsDestroyed = true;
}

// onAttachedToWindow 时会调用
public void onAttachedToWindow() {
if (isDestroyed(NO_WARN)) return;
if (mIsAttachedToWindow) {
Log.w(TAG, "onAttachedToWindow called when already attached. Ignoring");
return;
}
mIsAttachedToWindow = true;
// ...
if (mComponentCallbacks != null) return;
mComponentCallbacks = new AwComponentCallbacks();
// 注册 ComponentCallbacks
mContext.registerComponentCallbacks(mComponentCallbacks);
}

// onDetachedFromWindow 时会调用
public void onDetachedFromWindow() {
if (isDestroyed(NO_WARN)) return;
if (!mIsAttachedToWindow) {
Log.w(TAG, "onDetachedFromWindow called when already detached. Ignoring");
return;
}
mIsAttachedToWindow = false;
// ...
if (mComponentCallbacks != null) {
// 将 ComponentCallbacks 解注册
mContext.unregisterComponentCallbacks(mComponentCallbacks);
mComponentCallbacks = null;
}
}

在 View attach 到 Window 中的时候会调用上面的 onAttachedToWindow 方法,在 View detach 的时候会调用到 onDetachedFromWindow 方法,这两个方法中调用了一个 registerComponentCallbacks 和 unregisterComponentCallbacks 函数分别注册和解注册了一个 Callback,低版本会发生内存泄漏的原因就在此!

所以我们再来看一下 ComponentCallbacks 相关的逻辑:

1
2
3
4
5
6
7
8
9
10
11
// Context
public void registerComponentCallbacks(ComponentCallbacks callback) {
getApplicationContext().registerComponentCallbacks(callback);
}

// Application
public void registerComponentCallbacks(ComponentCallbacks callback) {
synchronized (mComponentCallbacks) {
mComponentCallbacks.add(callback);
}
}

所以假设在 AwContents 中只调用了 registerComponentCallbacks 注册方法而没有调用 unregisterComponentCallbacks 方法来解注册,那么会出现什么情况呢?我们看一下这个 AwComponentCallbacks 类的实现,发现它是 AwContents 中的一个非静态内部类,因此它会持有外部 AwContents 实例的引用,而 AwContents 持有 WebView 的 Context 上下文,对于 xml 中的 WebView 布局而言,这个上下文就是其所在的 Activity,因此如果在 Activity 生命周期结束后没有调用 unregisterComponentCallbacks 方法解注册的话,便可能会发生内存泄漏

86.0.4240.198 版本中,如果在 Activity.onDestroy 方法中啥也不干,那么在 View detach 的时候依旧会调用 unregisterComponentCallbacks 方法解注册;而如果在 Activity.onDestroy 方法中只手动调用了 WebView.destroy 方法,那么还是会先通过调用 onDetachedFromWindow 来解注册,此时的 if (isDestroyed(NO_WARN)) return; 判断是 false,可以正常执行到解注册的逻辑,然后才会标记为已销毁。

54.0.2805.1版本

接着我们再看一个旧版本 54.0.2805.1 中的 AwContents 这几个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void destroy() {
if (isDestroyed(NO_WARN)) return;
// Remove pending messages
mContentsClient.getCallbackHelper().removeCallbacksAndMessages();
// ...
if (mIsAttachedToWindow) {
Log.w(TAG, "WebView.destroy() called while WebView is still attached to window.");
nativeOnDetachedFromWindow(mNativeAwContents);
}
mIsDestroyed = true;
}

public void onAttachedToWindow() {
if (isDestroyed(NO_WARN)) return;
// ...
if (mComponentCallbacks != null) return;
mComponentCallbacks = new AwComponentCallbacks();
mContext.registerComponentCallbacks(mComponentCallbacks);
}

public void onDetachedFromWindow() {
if (isDestroyed(NO_WARN)) return;
nativeOnDetachedFromWindow(mNativeAwContents);
// ...
if (mComponentCallbacks != null) {
mContext.unregisterComponentCallbacks(mComponentCallbacks);
mComponentCallbacks = null;
}
}

可以看到如果在 Activity.onDestroy 中只调用了 WebView.destroy 方法的话,那么此时还没有调用到 onDetachedFromWindow 方法去解注册,却已经将 mIsDestroyed 置为了 true,于是当 detach 的时候,onDetachedFromWindow 判断到 isDestroyed 为 true 则不会走接下来解注册的逻辑了,于是内存泄漏也随之而来。

而如果在 Activity.onDestroy 中不手动调用 WebView.destroy 的话,理论上在 WebView detach 的时候能调用 onDetachedFromWindow 方法解注册 Callback,那么这个内存泄漏问题应该不会发生,但是没有调用 WebView.destroy 方法的话,很可能会发生其它问题,比如说不会调用 mContentsClient.getCallbackHelper().removeCallbacksAndMessages() 去移除 pending 的消息,说不定又有新的内存泄漏之类的。。。

要测试低版本 Chromium 的内存泄漏,可以找一个低版本的 Android 手机,然后将其 Android System WebView 应用卸载到装机版本,然后查看对应版本的 AwContents 类源码,如果源码中有内存泄漏的可能的话就可以测试了。另外如果手里头有 Root 的手机,可以尝试将 Android System WebView 最新版卸载,然后在 apkmirror(要翻墙) 中下载一个低版本的 Android System WebView APK 安装到手机上;或者直接从源码中编译出一个指定版本的 Android System WebView 应用,源码编译时间有限我也没试过,可以参考 build-instructions

小结

WebView 中的内存泄漏其实与 Chromium 内核版本有关,在新版本的 Chromium 内核中内存泄漏问题已经被解决了,而且从 Android 5.0(Lollipop) 版本开始将 Chromium WebView 迁移到了一个独立的 APP – Android System WebView,随着 Android System WebView 的独立发布,低版本 Android 系统(Android 5以上)上搭载的 Chromium 内核一般来说也不会太旧,所以出现内存泄漏的概率应该是比较小的。如果仍需要兼容这很小的一部分机型,可以通过文章开头的方式销毁 WebView,即先移除 WebView 组件,确保先调用到 onDetachedFromWindow 方法解注册,然后再通过 WebView.destroy 方法处理其它销毁逻辑。