WebView 在现在的项目中使用的频率应该还是非常高的,WebView 主要用来加载一些容易改变的频繁交互的应用App。目前 HTML5 是一种趋势。在开发中会遇到一些开发问题及优化问题,如下所记。
一、开发问题
1、WebView 硬件加速导致页面渲染闪烁
4.0以上的系统我们开启硬件加速后,WebView渲染页面更加快速,拖动也更加顺滑。但有个副作用就是,当WebView视图被整体遮住一块,然后突然恢复时,这个过渡期会出现白块同时界面闪烁。解决这个问题的方法是在过渡期前将WebView的硬件加速临时关闭,过渡期后再开启,关闭代码如下:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { webview.setLayerType(View.LAYER_TYPE_SOFTWARE, null); }
2、ViewPager里非首屏WebView点击事件不响应
如果你的多个WebView是放在ViewPager里一个个加载出来的,那么就会遇到这样的问题。ViewPager首屏WebView的创建是在前台,点击时没有问题;而其他非首屏的WebView是在后台创建,滑动到它后点击页面会出现如下错误日志:
20955-20968/xx.xxx.xxx E/webcoreglue﹕ Should not happen: no rect-based-test nodes found
解决这个问题的办法是继承WebView类,在子类覆盖onTouchEvent方法,加入如下代码:
@Override public boolean onTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { onScrollChanged(getScrollX(), getScrollY(), getScrollX(), getScrollY()); } return super.onTouchEvent(ev); }
3、WebView与上层父元素的TouchMove事件冲突
在开发过程中你可能会遇到这样一种情况。里面使用ViewPager嵌套了多个WebView页面,同时某一个WebView中的页面元素需要响应TouchMove事件。
这时会发现上层(ViewPager)阻断了下层(WebView)接收TouchMove事件,即使你的WebView在TouchDown时返回true也无效,因为上层直接使用了onInterceptTouchEvent对后续的TouchMove进行了拦截。针对这个问题的解决,简单做法是在重写WebView onTouchEvent方法,如下:
@Override public boolean onTouchEvent(MotionEvent ev) { boolean ret = super.onTouchEvent(ev); if (mPreventParentTouch) { switch (ev.getAction()) { case MotionEvent.ACTION_MOVE: requestDisallowInterceptTouchEvent(true); ret = true; break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: requestDisallowInterceptTouchEvent(false); mPreventParentTouch = false; break; } } return ret; } public void preventParentTouchEvent () { mPreventParentTouch = true; }
代码控制的关键在于mPreventParentTouch这个变量,mPreventParentTouch默认为false,当用户touchdown页面元素时通知该WebView将mPreventParentTouch设置为true。示意代码如下:
<script type="text/javascript"> document.getElementById("targetEle").addEventListener("touchstart", function(ev) { HostApp.preventParentTouchEvent(); // 通知WebView阻止祖先对其Touch事件的拦截 } ); document.getElementById("targetEle").addEventListener("touchmove", function(ev) { // todo something on this page } ); </script>
关于web页面如何通知WebView(即调用Java方法)请参看第8条。
刚提到了上面是一种简单的做法,并不能很好的解决手指滑动过快带来的误操作问题,即当用户快速地滑动时,还是有一定机率会出现ViewPager拦截TouchMove事件而发生了Tab切换而非页面元素做出了响应。要完美解决此问题,就要用到稍微复杂一点的方法(仅是整体消息传递流程复杂一点)。
首先假设在ViewPager之上还有一个父元素叫做ParentViewOnViewPager,当我们接收到页面preventParentTouchEvent通知时就先于ViewPager而进行拦截。ParentViewOnViewPager.java如下:
public class ParentViewOnViewPager extends FrameLayout { private MineWebView mDispatchWebView; public void preventParentTouchEvent (WebView view) { mDispatchWebView = (MineWebView)view; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_MOVE && mDispatchWebView != null) { mDispatchWebView.ignoreTouchCancel(true); return true; } return false; } @Override public boolean onTouchEvent(MotionEvent ev) { if (mDispatchWebView != null){ switch (ev.getAction()) { case MotionEvent.ACTION_MOVE: mDispatchWebView.onTouchEvent(ev); break; default: mDispatchWebView.ignoreTouchCancel(false); mDispatchWebView.onTouchEvent(ev); mDispatchWebView = null; break; } return true; } return super.onTouchEvent(ev); } }
即当ParentViewOnViewPager接收到通知时,发起TouchEvent拦截,将拦截到的Touch事件转嫁到装载页面的mDispatchWebView进行事件派发。这样就直接跳过了ViewPager这一层。这里需要注意的是当ParentViewOnViewPager发起拦截时,WebView会接收到一个TouchCancel事件,WebView应该忽略这个事件,以避免页面接收到这个事件而打断整个处理流程。如下代码 MineWebView.java 所示:
public class MineWebView extends WebView { boolean mIgnoreTouchCancel; public void ignoreTouchCancel (boolean val) { mIgnoreTouchCancel = val; } @Override public boolean onTouchEvent(MotionEvent ev) { return ev.getAction() == MotionEvent.ACTION_CANCEL && mIgnoreTouchCancel || super.onTouchEvent(ev); } }
另外针对这种解决方案,页面端的JS脚本不用做任何变动。
二、优化方案
1、启用 WebView 缓存
开启 WebView 的缓存功能可以减少对服务器资源的请求,一般使用默认缓存策略就可以。
//设置 缓存模式 webView.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT); // 开启 DOM storage API 功能 webView.getSettings().setDomStorageEnabled(true);
2、资源文件本地存储
资源等文件(不需要更新)本地存储,在需要的时候直接从本地获取。哪些资源需要我们去存储在本地呢,当然是一些不会被更新的资源,例如图片文件,js文件,css文件,替换的方法也很简单,重写WebView的方法即可。
@Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { // TODO Auto-generated method stub if (url.endsWith("jquery-3.2.1.min.js")) { return ConvertLocalResponse(); } return super.shouldInterceptRequest(view, url); } private WebResourceResponse ConvertLocalResponse() { try { InputStream localStream = getResources().openRawResource(R.raw.jquery_min_js); WebResourceResponse localResponse = new WebResourceResponse("text/javascript","utf-8", localStream); return localResponse; } catch (Exception e) { e.printStackTrace(); Log.i("result", "加载本地js错误:"+e.toString()); return null; } }
3、减少耗时操作
为了达到此目的,我们应减少同步操作的操作时间,尽量使用异步操作替代同步操作。如果服务端存在存取数据等耗时的操作,尽量使用异步(ajax)进行操作,把原本的时间花在异步操作上。
4、客户端UI优化
如何解决 WebView 加载前的白色页面呢?我们需要 WebView 预加载页面,下面介绍两种方法:
(1) ViewPager,将欢迎页面与"包含WebView页面"一起放进ViewPager中,设置预加载页面个数,使WebView所在页面可以预加载,在加载完毕的时候切换到WebView所在页面。
(2) FrameLayout,将欢迎页面与WebView页面的布局合在一起,显示在一个页面内,起始隐藏WebView布局,待WebView加载完毕,隐藏欢迎布局,显示WebView布局。
使用 FrameLayout 要简单一些,两种方法都是需要对 WebChromeClient 的 onProgressChanged 进行监听,加载完毕进行页面切换,示例如下:
webView.setWebChromeClient(new WebChromeClient() { @Override public void onProgressChanged(WebView view, int newProgress) { super.onProgressChanged(view, newProgress); if (newProgress >= 100) { // 切换页面 } } });
5、加快HTML网页装载完成的速度
默认情况html代码下载到WebView后,webkit开始解析网页各个节点,发现有外部样式文件或者外部脚本文件时,会异步发起网络请求下载文件,但如果在这之前也有解析到image 节点,那势必也会发起网络请求下载相应的图片。在网络情况较差的情况下,过多的网络请求就会造成带宽紧张,影响到css或js文件加载完成的时间,造成页面空白loading过久。解决的方法就是告诉WebView先不要自动加载图片,等页面finish后再发起图片加载。
在 WebView 初始化时设置如下代码:
public void int () { if(Build.VERSION.SDK_INT >= 19) { webView.getSettings().setLoadsImagesAutomatically(true); } else { webView.getSettings().setLoadsImagesAutomatically(false); } }
同时在 WebView 的 WebViewClient 实例中的 onPageFinished() 方法添加如下代码:
@Override public void onPageFinished(WebView view, String url) { if(!webView.getSettings().getLoadsImagesAutomatically()) { webView.getSettings().setLoadsImagesAutomatically(true); } }
上面的代码中我们对系统API在19以上的版本作了兼容。因为4.4以上系统在onPageFinished时再恢复图片加载时,如果存在多张图片引用的是相同的src时,会只有一个image标签得到加载,因而对于这样的系统我们就先直接加载。
6、自定义出错界面
当WebView加载页面出错时(一般为404 NOT FOUND),安卓WebView会默认显示一个卖萌的出错界面。这样用户发现我们嵌入的是网页,我们期望的是用户在网页上得到如原生般应用的体验。解决方案为在WebViewClient实例中的重写 onReceivedError() 方法:
@Override public void onReceivedError (WebView view, int errorCode, String description, String failingUrl) { super.onReceivedError(view, errorCode, description, failingUrl); loadDataWithBaseURL(null, "", "text/html", "utf-8", null); mErrorFrame.setVisibility(View.VISIBLE); }
我们先使用 loadDataWithBaseURL() 方法清除掉默认错误页内容,再让我们自定义的View得到显示(mErrorFrame为蒙在WebView之上的一个LinearLayout布局,默认为View.GONE)。
7、是否存在滚动条
当我们做类似上拉加载下一页这样的功能的时候,页面初始的时候需要知道当前WebView是否存在纵向滚动条,如果有则不加载下一页,如果没有则加载下一页直到其出现纵向滚动条。首先继承WebView类,在子类添加下面的代码:
public boolean existVerticalScrollbar () { return computeVerticalScrollRange() > computeVerticalScrollExtent(); }
computeVerticalScrollRange() 得到的是可滑动的最大高度,computeVerticalScrollExtent() 得到的是滚动把手自身的高,当不存在滚动条时,两者的值是相等的。当有滚动条时前者一定是大于后者的。
8、是否已滚动到页面底部
同样我们在做上拉加载下一页这样的功能时,也需要知道当前页面滚动条所处的状态,如果快到底部,则要发起网络请求数据更新网页。同样继承WebView类,在子类覆盖onScrollChanged方法,具体如下:
@Override protected void onScrollChanged(int newX, int newY, int oldX, int oldY) { super.onScrollChanged(newX, newY, oldX, oldY); if (newY != oldY) { float contentHeight = getContentHeight() * getScale(); // 当前内容高度下从未触发过, 浏览器存在滚动条且滑动到将抵底部位置 if (mCurrContentHeight != contentHeight && newY > 0 && contentHeight <= newY + getHeight() + mThreshold) { // TODO Something... mCurrContentHeight = contentHeight; } } }
上面mCurrContentHeight用于记录上次触发时的网页高度,用来防止在网页总高度未发生变化而目标区域发生连续滚动时会多次触发TODO,mThreshold是一个阈值,当页面底部距离滚动条底部的高度差<=这个值时会触发TODO。
9、远程网页需访问本地资源
当我们在WebView中加载出从web服务器上拿取的内容时,是无法访问本地资源的,如assets目录下的图片资源,因为这样的行为属于跨域行为(Cross-Domain),而WebView是禁止的。解决这个问题的方案是把html内容先下载到本地,然后使用loadDataWithBaseURL加载html。这样就可以在html中用 file:///android_asset/xxx.png 的链接来引用包里面assets下的资源了。示例如下:
private void loadWithAccessLocal(final String htmlUrl) { new Thread(new Runnable() { public void run() { try { final String htmlStr = NetService.fetchHtml(htmlUrl); if (htmlStr != null) { TaskExecutor.runTaskOnUiThread(new Runnable() { @Override public void run() { loadDataWithBaseURL(htmlUrl, htmlStr, "text/html", "UTF-8", ""); } }); return; } } catch (Exception e) { Log.e("Exception:" + e.getMessage()); } TaskExecutor.runTaskOnUiThread(new Runnable() { @Override public void run() { onPageLoadedError(-1, "fetch html failed"); } }); } }).start(); }
需要注意:
- 从网络上下载html的过程应放在工作线程中
- html下载成功后渲染出html的步骤应放在UI主线程,不然WebView会报错
- html下载失败则可以使用我们前面讲述的方法来显示自定义错误界面
10、避免 addJavaScriptInterface 带来的安全问题
使用开源项目Safe Java-JS WebView Bridge可以很好替代addJavaScriptInterface方法,同时增加了异步回调等支持,并且不存在了安全风险。