Android WebView 性能优化

离线缓存

这个比较容易,开启webView的缓存功能就可以了。

1
2
3
4
5
6
WebSettings settings = webView.getSettings();
settings.setAppCacheEnabled(true);
settings.setDatabaseEnabled(true);
settings.setDomStorageEnabled(true);//开启DOM缓存,关闭的话H5自身的一些操作是无效的
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
settings.setJavaScriptEnabled(true);

这边我们通过setCacheMode方法来设置WebView的缓存策略,WebSettings.LOAD_DEFAULT是默认的缓存策略,它在缓存可获取并且没有过期的情况下加载缓存,否则通过网络获取资源。这样的话可以减少页面的网络请求次数,那我们如何在离线的情况下也能打开页面呢,这里我们在加载页面的时候可以通过判断网络状态,在无网络的情况下更改webview的缓存策略

1
2
3
4
5
6
7
8
9
ConnectivityManager cm = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo info = cm.getActiveNetworkInfo();
if(info.isAvailable())
{
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
}else
{
settings.setCacheMode(WebSettings.LOAD_CACHE_ONLY);//不使用网络,只加载缓存
}

这样我们就可以使我们的混合应用在没有网络的情况下也能使用一部分的功能,不至于什么都显示不了了,当然如果我们将缓存做的更好一些,在网络好的时候,比如说在WIFI状态下,去后台加载一些网页缓存起来,这样处理的话,即使在无网络情况下第一次打开某些页面的时候,也能将该页面显示出来。
当然缓存资源后随之会带来一个问题,那就是资源无法及时更新,WebSettings.LOAD_DEFAULT中的页面中的缓存版本好像不是很起作用,所以我们这边可能需要自己做一个缓存版本控制。这个缓存版本控制可以放在APP版本更新中。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (upgrade.cacheControl > cacheControl)
{
webView.clearCache(true);//删除DOM缓存
VersionUtils.clearCache(mContext.getCacheDir());//删除APP缓存
try
{
mContext.deleteDatabase("webview.db");//删除数据库缓存
mContext.deleteDatabase("webviewCache.db");
}
catch (Exception e)
{
}
}

内存泄露

1.可以将 Webview 的 Activity 新起一个进程,结束的时候直接System.exit(0);退出当前进程;
启动新进程,主要代码: AndroidManifest.xml 配置文件代码如下

1
2
3
4
5
6
7
8
<activity
android:name=".ui.activity.Html5Activity"
android:process=":lyl.boon.process.web">
<intent-filter>
<action android:name="com.lyl.boon.ui.activity.htmlactivity"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>

在新进程中启动 Activity ,里面传了 一个 Url:

1
2
3
4
5
Intent intent = new Intent("com.lyl.boon.ui.activity.htmlactivity");
Bundle bundle = new Bundle();
bundle.putString("url", gankDataEntity.getUrl());
intent.putExtra("bundle",bundle);
startActivity(intent);

然后在 Html5Activity 的onDestory() 最后加上 System.exit(0); 杀死当前进程。

Android7.0系统以后,WebView相对来说是比较稳定的,无论承载WebView的容器是否在主进程,都不需要担心WebView崩溃导致应用也跟着崩溃。然后7.0以下的系统就没有这么幸运了,特别是低版本的WebView。考虑应用的稳定性,我们可以把7.0以下系统的WebView使用一个独立进程的Activity来包装,这样即使WebView崩溃了,也只是WebView所在的进程发生了崩溃,主进程还是不受影响的。

2.不在xml中定义 Webview ,而是在需要的时候在Activity中创建,并且Context使用 getApplicationgContext()

1
2
3
4
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
mWebView = new WebView(getApplicationContext());
mWebView.setLayoutParams(params);
mLayout.addView(mWebView);

3.在 Activity 销毁( WebView )的时候,先让 WebView 加载null内容,然后移除 WebView,再销毁 WebView,最后置空

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void onDestroy() {
if (mWebView != null) {
mWebView.loadDataWithBaseURL(null, "", "text/html", "utf-8", null);
mWebView.getSettings().setJavaScriptEnabled(false);
mWebView.clearFormData();
mWebView.clearHistory();
mWebView.stopLoading();

((ViewGroup) mWebView.getParent()).removeView(mWebView);
mWebView.destroy();
mWebView = null;
}
super.onDestroy();
}

预加载

有时候一个页面资源比较多,图片,CSS,js比较多,还引用了JQuery这种庞然巨兽,从加载到页面渲染完成需要比较长的时间,有一个解决方案是将这些资源打包进APK里面,然后当页面加载这些资源的时候让它从本地获取,这样可以提升加载速度也能减少服务器压力。重写WebClient类中的shouldInterceptRequest方法,再将这个类设置给WebView。

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
webView.setWebViewClient(new WebViewClient()
{
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url)
{
if (url.contains("[tag]"))
{
String localPath = url.replaceFirst("^http.*[tag]\\]", "");
try
{
InputStream is = getApplicationContext().getAssets().open(localPath);
Log.d(TAG, "shouldInterceptRequest: localPath " + localPath);
String mimeType = "text/javascript";
if (localPath.endsWith("css"))
{
mimeType = "text/css";
}
return new WebResourceResponse(mimeType, "UTF-8", is);
}
catch (Exception e)
{
e.printStackTrace();
return null;
}
}
else
{
return null;
}

}
});

这里我们队页面中带有特殊标记的请求进行过滤替换,也就是上面代码中的[tag],这个可以跟做后台开发的同事约定好来就行了。对图片资源或者其他资源进行替换也是可以的。补充一个小点可以通过settings.setLoadsImagesAutomatically(true);来设置在页面装载完成之后再去加载图片。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
webView.getSettings().setBlockNetworkImage(true);  
webView.setWebChromeClient(new WebChromeClient() {  
            @Override  
            public void onProgressChanged(WebView view, int newProgress) {  
                if (newProgress == 100) {  
                    // 网页加载完成  
                    loadDialog.dismiss();  
                    webView.getSettings().setBlockNetworkImage(false);  
                } else {  
                    // 网页加载中  
                    loadDialog.show();  
                }  
            }  
        });

白屏

SSL问题

通常情况下,通过WebView的loadUrl(String url)方法,可以顺利加载页面。但是,当load通过SSL加密的HTTPS页面时,如果这个页面的安全证书无法得到认证,WebView就会显示成空白页。

解决方式:
通过重写WebViewClient的onReceivedSslError方法来接受所有网站的证书,忽略SSL错误。

1
2
3
4
5
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
handler.proceed();
super.onReceivedSslError(view, handler, error);
}

低版本兼容问题

由于现在h5大部分都是 vue的形式打包,(可能过个一两年就变了,但是万变不离其中),这个时候要注意了, 由于是webv加载的h5,在Android老的机型上 webview 内核可能不支持 最新的h5 框架,这时候 就需要我们找h5 的同学 搞事情了, 对于 vue,想要老机器不出现白屏其实也很简单,让h5的同学 做一下老版本的兼容,具体方法:

虽然vue-cli引入了babel对js语法进行降级,但是还是有些老旧的机型会发生各种各样的问题,这里需要引入一个叫babel-polyfill的包。所以你只需只在你引入import vue之前 import babel-polyfill进来就可以了,主要是为了让es6对个别机型做到兼容。

301/302重定向问题

WebView的301/302重定向问题,绝对在踩坑排行榜里名列前茅。。。随便搜了几个解决方案,要么不能满足业务需求,要么清一色没有彻底解决问题。

https://stackoverflow.com/questions/4066438/android-webview-how-to-handle-redirects-in-app-instead-of-opening-a-browser
http://blog.csdn.net/jdsjlzx/article/details/51698250
http://www.cnblogs.com/pedro-neer/p/5318354.html
http://www.jianshu.com/p/c01769ababfa

301/302业务场景及白屏问题

先来分析一下业务场景。对于需要对url进行拦截以及在url中需要拼接特定参数的WebView来说,301和302发生的情景主要有以下几种:

  • 首次进入,有重定向,然后直接加载H5页面,如http跳转https
  • 首次进入,有重定向,然后跳转到native页面,如扫一扫短链,然后跳转到native
  • 二次加载,有重定向,跳转到native页面
  • 对于考拉业务来说,还有类似登录后跳转到某个页面的需求。如我的拼团,未登录状态下点击我的拼团跳转到登录页面,登录完成后再加载我的拼团页。

第一种情况属于正常情况,暂时没遇到什么坑。

第二种情况,会遇到WebView空白页问题,属于原始url不能拦截到native页面,但301/302后的url拦截到native页面的情况,当遇到这种情况时,需要把WebView对应的Activity结束,否则当用户从拦截后的页面返回上一个页面时,是一个WebView空白页。

第三种情况,也会遇到WebView空白页问题,原因在于加载的第一个页面发生了重定向到了第二个页面,第二个页面被客户端拦截跳转到native页面,那么WebView就停留在第一个页面的状态了,第一个页面显然是空白页。

第四种情况,会遇到无限加载登录页面的问题。考拉的登录链接是类似下面这种格式:

1
https://m.kaola.com/login.html?target=登录后跳转的url

如果登录成功后还重新加载这个url,那么就会循环跳转到登录页面。第四点解决起来比较简单,登录成功以后拿到target后的跳转url再重新加载即可。

301/302回退栈问题

无论是哪种重定向场景,都不可避免地会遇到回退栈的处理问题,如果处理不当,用户按返回键的时候不一定能回到重定向之前的那个页面。很多开发者在覆写WebViewClient.shouldOverrideUrlLoading()方法时,会简单地使用以下方式粗暴处理:

1
2
3
4
5
6
7
8
WebView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
view.loadUrl(url);
return true;
}
...
)

这种方法最致命的弱点就是如果不经过特殊处理,那么按返回键是没有效果的,还会停留在302之前的页面。现有的解决方案无非就几种:

  1. 手动管理回退栈,遇到重定向时回退两次^6
  2. 通过HitTestResult判断是否是重定向,从而决定是否自己加载url^7
  3. 通过设置标记位,在onPageStarted和onPageFinished分别标记变量避免重定向^9

可以说,这几种解决方案都不是完美的,都有缺陷。以下给出301/302较优解决方案:

解决301/302回退栈问题

能否结合上面的几种方案,来更加准确地判断301/302的情况呢?下面说一下本文的解决思路。在提供解决方案之前,我们需要了解一下shouldOverrideUrlLoading方法的返回值代表什么意思。

Give the host application a chance to take over the control when a new url is about to be loaded in the current WebView. If WebViewClient is not provided, by default WebView will ask Activity Manager to choose the proper handler for the url. If WebViewClient is provided, return true means the host application handles the url, while return false means the current WebView handles the url.

简单地说,就是返回true,那么url就已经由客户端处理了,WebView就不管了,如果返回false,那么当前的WebView实现就会去处理这个url。

WebView能否知道某个url是不是301/302呢?当然知道,WebView能够拿到url的请求信息和响应信息,根据header里的code很轻松就可以实现,事实正是如此,交给WebView来处理重定向(return false),这时候按返回键,是可以正常地回到重定向之前的那个页面的。(PS:WebView在5.0以后是一个独立的apk,可以单独升级,新版本的WebView实现肯定处理了重定向问题)

但是,业务对url拦截有需求,肯定不能把所有的情况都交给系统WebView处理。为了解决url拦截问题,本文引入了另一种思想——通过用户的touch事件来判断重定向。下面通过代码来说明。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
/**
* WebView基础类,处理一些基础的公有操作
*
* @author xingli
* @time 2017-12-06
*/
public class BaseWebView extends WebView {
private boolean mTouchByUser;
public BaseWebView(Context context) {
super(context);
}
public BaseWebView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BaseWebView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public final void loadUrl(String url, Map<String, String> additionalHttpHeaders) {
super.loadUrl(url, additionalHttpHeaders);
resetAllStateInternal(url);
}
@Override
public void loadUrl(String url) {
super.loadUrl(url);
resetAllStateInternal(url);
}
@Override
public final void postUrl(String url, byte[] postData) {
super.postUrl(url, postData);
resetAllStateInternal(url);
}
@Override
public final void loadData(String data, String mimeType, String encoding) {
super.loadData(data, mimeType, encoding);
resetAllStateInternal(getUrl());
}
@Override
public final void loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding,
String historyUrl) {
super.loadDataWithBaseURL(baseUrl, data, mimeType, encoding, historyUrl);
resetAllStateInternal(getUrl());
}
@Override
public void reload() {
super.reload();
resetAllStateInternal(getUrl());
}
public boolean isTouchByUser() {
return mTouchByUser;
}
private void resetAllStateInternal(String url) {
if (!TextUtils.isEmpty(url) && url.startsWith("javascript:")) {
return;
}
resetAllState();
}
// 加载url时重置touch状态
protected void resetAllState() {
mTouchByUser = false;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//用户按下到下一个链接加载之前,置为true
mTouchByUser = true;
break;
}
return super.onTouchEvent(event);
}
@Override
public void setWebViewClient(final WebViewClient client) {
super.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
boolean handleByChild = null != client && client.shouldOverrideUrlLoading(view, url);
if (handleByChild) {
// 开放client接口给上层业务调用,如果返回true,表示业务已处理。
return true;
} else if (!isTouchByUser()) {
// 如果业务没有处理,并且在加载过程中用户没有再次触摸屏幕,认为是301/302事件,直接交由系统处理。
return super.shouldOverrideUrlLoading(view, url);
} else {
//否则,属于二次加载某个链接的情况,为了解决拼接参数丢失问题,重新调用loadUrl方法添加固有参数。
loadUrl(url);
return true;
}
}
@RequiresApi(api = Build.VERSION_CODES.N)
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
boolean handleByChild = null != client && client.shouldOverrideUrlLoading(view, request);
if (handleByChild) {
return true;
} else if (!isTouchByUser()) {
return super.shouldOverrideUrlLoading(view, request);
} else {
loadUrl(request.getUrl().toString());
return true;
}
}
});
}
}

上述代码解决了正常情况下的回退栈问题。

解决业务白屏问题

为了解决白屏问题,考拉目前的解决思路和上面的回退栈问题思路有些类似,通过监听touch事件分发以及onPageFinished事件来判断是否产生白屏,代码如下:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
public class KaolaWebview extends BaseWebView implements DownloadListener, Lifeful, OnActivityResultListener {
private boolean mIsBlankPageRedirect; //是否因重定向导致的空白页面。
public KaolaWebview(Context context) {
super(context);
init();
}
public KaolaWebview(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public KaolaWebview(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
protected void back() {
if (mBackStep < 1) {
mJsApi.trigger2("kaolaGoback");
} else {
realBack();
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_UP) {
mIsBlankPageRedirect = true;
}
return super.dispatchTouchEvent(ev);
}
private WebViewClient mWebViewClient = new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
url = WebViewUtils.removeBlank(url);
//允许启动第三方应用客户端
if (WebViewUtils.canHandleUrl(url)) {
boolean handleByCaller = false;
// 如果不是用户触发的操作,就没有必要交给上层处理了,直接走url拦截规则。
if (null != mIWebViewClient && isTouchByUser()) {
handleByCaller = mIWebViewClient.shouldOverrideUrlLoading(view, url);
}
if (!handleByCaller) {
handleByCaller = handleOverrideUrl(url);
}
return handleByCaller || super.shouldOverrideUrlLoading(view, url);
} else {
try {
notifyBeforeLoadUrl(url);
Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
mContext.startActivity(intent);
if (!mIsBlankPageRedirect) {
// 如果遇到白屏问题,手动后退
back();
}
} catch (Exception e) {
ExceptionUtils.printExceptionTrace(e);
}
return true;
}
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
return shouldOverrideUrlLoading(view, request.getUrl().toString());
}

private boolean handleOverrideUrl(final String url) {
RouterResult result = WebActivityRouter.startFromWeb(
new IntentBuilder(mContext, url).setRouterActivityResult(new RouterActivityResult() {
@Override
public void onActivityFound() {
if (!mIsBlankPageRedirect) {
// 路由已经拦截到跳转到native页面,但此时可能发生了
// 301/302跳转,那么执行后退动作,防止白屏。
back();
}
}
@Override
public void onActivityNotFound() {
if (mIWebViewClient != null) {
mIWebViewClient.onActivityNotFound();
}
}
}));
return result.isSuccess();
}
};
@Override
public void onPageFinished(WebView view, String url) {
mIsBlankPageRedirect = true;
if (null != mIWebViewClient) {
mIWebViewClient.onPageReallyFinish(view, url);
}
super.onPageFinished(view, url);
}
}

本来上面的两个问题可以用同一个变量控制解决的,但由于历史代码遗留问题,目前还没有时间优化测试,这也是代码暂不公布的原因之一。

loadUrl(url,map)方法加载带hash(带#号)的url导致刷新问题或请求头缓存问题

1.如果调用loadUrl(url,map)方法去加载资源,那么在此调用loadUrl(ur),reload,loadUrl(url,map)造成无法刷新的问题。这个现象主要出现在Android 8.0的系统中。
可尝试调用如下url尝试:

https://baike.baidu.com/item/%E9%83%8E%E5%B9%B3/58857#/
https://baike.baidu.com/item/%E9%83%8E%E5%B9%B3/58857#/?a=123
https://baike.baidu.com/item/%E9%83%8E%E5%B9%B3/58857#3

2.loadUrl(url,map) 第二个参数map中传入的数据用于请求头,此外这个请求头数据会被webview缓存下来,刷新时,请求头中的数据还是原来的,因此,不适用传入需要进程变化的“状态”信息。

解决方法:不要使用loadUrl(url,map),推荐使用loadUrl(url),如果非要传输参数,还不如在url中添加参数。

是否应该开启硬件加速

由于碎片化问题太多,建议保持默认状态【默认表示由系统决定,不要手动设置】,否则可能产生问题。

Cookie同步导致的内存泄漏

使用CookieSyncManager同步时,会永久引用第一个acitivity的的Context,为了避免此种情况,请使用ApplicationContext

1
2
3
 if (Build.VERSION.SDK_INT < 21) {
android.webkit.CookieSyncManager.createInstance(context.getApplicationContext());
}

Android5.0 WebView中Http和Https混合问题

在Android 5.0上 Webview 默认不允许加载 Http 与 Https 混合内容:

解决办法:

1
2
3
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
webView.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}

参数类型说明:
MIXED_CONTENT_ALWAYS_ALLOW:允许从任何来源加载内容,即使起源是不安全的;
MIXED_CONTENT_NEVER_ALLOW:不允许Https加载Http的内容,即不允许从安全的起源去加载一个不安全的资源;
MIXED_CONTENT_COMPATIBILITY_MODE:当涉及到混合式内容时,WebView 会尝试去兼容最新Web浏览器的风格。

在5.0以下 Android 默认是 全允许,但是到了5.0以上,就是不允许,实际情况下很我们很难确定所有的网页都是https的,所以就需要这一步的操作。

onPageFinished被调用多次

使用onPageProgressChanged代替

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void  handleProgress(WebView view, int newProgress){
if(progressPending.get()!=newProgress){
progressPending.set(newProgress);
onProgressChanged(newProgress);

}
}
@Override
public final void onProgressChanged(WebView view, int newProgress) {
super.onProgressChanged(view, newProgress);
handleProgress(view,newProgress);
}

public void onProgressChanged(int newProgress){
Log.i("WebChromeClient","progress="+newProgress+"%");
if(newProgress==100){
Log.i("WebChromeClient","加载完成");
}
}

H5优化

Android的OnPageFinished事件会在Javascript脚本执行完成之后才会触发。如果在页面中使 用JQuery,会在处理完DOM对象,执行完$(document).ready(function() {});事件自会后才会渲染并显示页面。而同样的页面在iPhone上却是载入相当的快,因为iPhone是显示完页面才会触发脚本的执行。所以我们这边的解决方案延迟JS脚本的载入,这个方面的问题是需要Web前端工程师帮忙优化的,网上应该有比较多LazyLoad插件,这里放一个比较老的链接Painless JavaScript lazy loading with LazyLoad,同样也放上一小段前端代码,仅供参考。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script src="/css/j/lazyload-min.js" type="text/javascript"></script>
<script type="text/javascript" charset="utf-8">
loadComplete() {
//instead of document.read();
}
function loadscript() {
LazyLoad.loadOnce([
'/css/j/jquery-1.6.2.min.js',
'/css/j/flow/jquery.flow.1.1.min.js',
'/css/j/min.js?v=2011100852'
], loadComplete);
}
setTimeout(loadscript,10);
</script>

参考资料

https://www.jianshu.com/p/427600ca2107
https://my.oschina.net/ososchina/blog/1799575
https://iluhcm.com/2017/12/10/design-an-elegant-and-powerful-android-webview-part-one/