胡琪

为今天工作,为明天投资,为未来孵化一些东西!

安卓WebView使用与安全开发指南

在Android中WebView是一个非常重要的控件,虽然在很多情况下绝大部分开发者不会用到该控件,但个人觉得其重要程度丝毫不亚于TextView和RecycleView等我们常用控件,尤其是在hybrid开发中,作为高级开发工程师掌握WebView的使用以及安全问题是最基本的要求之一。

WebView介绍与基本用法

定义

首先来看下WebView类的定义:

从上面的定义可以看到WebView继承自AbsoluteLayout,而AbsoluteLayout是一个ViewGroup,所以WebView不仅仅只是一个功能非常强大的控件(View),其本身也是一个容器(ViewGroup),也就是说我们可以像在LinearLayout中放置子View那样在WebView中放置子View。

注:上面的源码是以安卓7.0系统API24作为分析,AbsoluteLayout绝对布局在很早的时候就被安卓官方废弃掉,现在在安卓UI布局中基本不会使用到该布局,安卓系统设计者之所以仍然让WebView继承自AbsoluteLayout是为了向后兼容的考虑。WebView extends {@link AbsoluteLayout} for backward compatibility reasons.

WebView的作用主要是为了让用户能够在我们的App内浏览网页,在大多数情况下如果我们界面内包含一个url,当用户点击后我们通常的处理是通过一个intent打开手机自带的浏览器去访问该url,像下面这样:

但是在某些情况下我们希望用户能够在我们的App内访问html页面,或者说在某些情况下本身需要在我们自己的App(或SDK)内展示某个在线内容去和用户交互,这个时候WebView就派上用场了。

基本使用

WebView的基本使用很简单,在布局文件中放置WebView控件,然后在需要使用该控件的组件的某个地方,比如Activity的onCreate中调用其loadUrl方法即可:

需要注意的是因为loadUrl方法会更新UI,因此不能在子线程中调用。但是上述代码虽然能够加载url指定的网址的内容,但是是通过启动一个浏览器app来完成的,也就是不是在自己的App内部浏览网页,如果要在自己的App内部打开网页需要借助WebViewClient这个类来完成,后面在讲到该类时会提到。

WebView的一些重要接口及其功能

  • onPause()/onResume()/destroy():和Activity等组件一样,可以理解为是WebView的生命周期接口,不过需要开发者主动调用,而不是回调接口。比如当前WebView页面切换到后台不可见时可以主动调用onPause()方法来暂停WebView的操作
  • pauseTimers()/resumeTimers():和前面的onPause()类似,也是用来暂停WebView的操作,不过需要注意的是onPause()不会暂停JavaScript的执行,但是pauseTimers()会,而且该方法的作用对象是整个应用程序的webview,它会暂停所有webview的操作。而不仅仅只是调用该方法的WebView实例。
  • getContentHeight()
  • reload():重新加载当前url,类似浏览器的刷新功能

 

当然还包括很多其他类似浏览器的前进后退一页功能的goForward()/goBack()接口等,不过一般而言我们使用WebView不是像手Q这种App内置一个浏览器内核,因此这方面的接口不做过多介绍。如果你想了解更多,可以参看官方文档:https://developer.android.com/guide/webapps/webview#java

使用pauseTimers()/resumeTimers()需要注意的坑

前面提到过pauseTimers()/resumeTimers()的作用对象是整个App内的所有WebView,所以很容易出现这样一个坑,以我负责的易盾验证码SDK为例,假设某个客户他们的App内使用了WebView,然后在他们自身WebView控件对应的Activity的onDestroy()回调中使用了pauseTimers()方法。然后在某个页面中使用了我们易盾验证码SDK,验证码SDK会使用一个CaptchaWebView去请求验证码,那么这个时候因为用户已经调用过pauseTimers(),而该方法作用对象是整个App内的WebView,所以导致验证码SDK一直初始化超时。解决方法看似很简单,就是在易盾验证码SDK加载验证码的组件的onStart()/onDestroy()中分别调用resumeTimers()/pauseTimers()即可,但是这样做如果某个用户是先使用易盾验证码SDK,然后使用他们自己App内的某个WebView,但是它他们自己却没有使用resumeTimers(),那么可能会导致用户自己的WebView加载功能异常。事实上这种情况是会出现的,上面2个例子在实际情况中都遇到过。出现这种情况的原因很显然就是2个使用WebView控件的创建者开发理念不一致导致的,有些开发者处于性能的考虑,习惯使用pauseTimers()/resumeTimers(),而某些开发者可能不习惯使用这2个方法,他们可能比较习惯使用onPause()/onResume()。只要出现某个开发者创建的WebView调用了pauseTimers,而另一个开发者创建的WebView未调用resumeTimers就会导致该问题出现。而一旦出现这种情况,排查起原因来会非常困难,因为这种情况往往出现在我们使用包含WebView的一些三方服务的时候(因为如果是一个团队内的多个开发者,即使一个App内需要使用到多个WebView,肯定都会统一使用pauseTimers()/resumeTimers()或onPause()/onResume()),而三方SDK不一定是开源的,根本不能确定对方是否调用了这2个方法,意味着需要和三方SDK开发者进行沟通,需要跨团队沟通也就意味着更大的排错成本。另外如果你之前根本没遇到过这种情况,根本就想不到会是这个原因导致的WebView加载某些网址异常。

所以如果你开发的是SDK,为了避免上述2种情况的出现,除了在包含WebView控件的某个组件,如Activity或自定义的Dialog的onStart()/onDestroy()中分别调用resumeTimers()/pauseTimers()外,还需要提供一个是否允许调用pauseTimers接口,一旦某个用户出现集成我们SDK后导致他们自己的WebView加载某些网址异常,就能够通过该接口设置不调用resumeTimers()/pauseTimers()方法。

NOTE:WebView的所有方法中只有pauseTimers()/resumeTimers()这2个方法作用对象是整个App内的所有WebView,所以如果你的App内集成了其他包含WebView控件的三方SDK后导致出现某些异常的超时现象的时候,那么很可能就是三方SDK中某些地方使用这2个方法不当导致的。

WebView自定义行为

我们再来看下谷歌官方文档中关于WebView的自定义行为的介绍:

A WebView has several customization points where you can add your own behavior. These are:

  • Creating and setting a WebChromeClient subclass. This class is called when something that might impact a browser UI happens, for instance, progress updates and JavaScript alerts are sent here (see Debugging Tasks).
  • Creating and setting a WebViewClient subclass. It will be called when things happen that impact the rendering of the content, eg, errors or form submissions. You can also intercept URL loading here (via shouldOverrideUrlLoading()).
  • Modifying the WebSettings, such as enabling JavaScript with setJavaScriptEnabled().
  • Injecting Java objects into the WebView using the addJavascriptInterface(Object, String) method. This method allows you to inject Java objects into a page’s JavaScript context, so that they can be accessed by JavaScript in the page.

从上面可以看到WebView的一些自定义高阶用法主要涉及到WebChromeClient和WebViewClient以及WebSettings。其中WebChromeClient主要是用来影响浏览器UI,例如,在这里发送进度更新和JavaScript警报。WebViewClient主要是用来影响内容呈现的情况时将调用它,例如,错误或表单提交。这也是这2个类的重要区别,前者主要负责和界面UI相关的功能以及和用户交互,后者主要负责页面加载相关的功能。这一点在后面讲解这2个类的核心API的时候也可以感受到。WebSettings主要用来设置一些配置信息。

所以接下来重点讲解下这3个类的用法。

和WebView相关的重要类及其用法

WebSettings

顾名思义,就是WebView的设置类,用来配置WebView的一些参数,使用WebView的getSettings()方法即可得到一个WebSettings对象:

在绝大多数情况下我们都会针对自己使用的WebView的场景进行一些相关参数配置,因此下面介绍下WebSettings的一些重要方法:

  • setJavaScriptEnabled(boolean flag):设置是否允许运行JavaScript脚本,默认是不允许如果需要进行js端和java端的相互通信,需要设置该接口未true。
  • setAllowFileAccess(boolean allow):是否允许WebView使用File协议,默认是允许。通常情况下我们使用WebView是为了访问远端网页,也就是http/https协议,但是实际上WebView功能非常强大,还支持file协议,也就是访问本地的文件,但是这可能会导致WebView的一个严重的安全漏洞:跨源攻击。包括支付宝在内的很多知名应用就曾存在过这漏洞。这个在后面安全优化会详细讲解。所以如果自己的App不需要访问本地的一些html文件,推荐禁用file协议,即将该选项设置为false。
  • setAllowUniversalAccessFromFileURLs(boolean allow):设置是否允许通过file协议的url加载的javascript访问其他的本地文件,该值默认设置在安卓4.0.3及其以前系统默认为true,在4.1及其以上系统默认为false。同样为了更安全,推荐不需要使用该功能的App将其设置为false。
  • setDatabaseEnabled(boolean flag):设置是否启用数据库存储缓存,默认为false
  • setDomStorageEnabled(boolean flag):设置是否启用DOM存储缓存,默认为false。
  • setCacheMode(int mode):设置缓存模式,共有5中缓存模式,这5种模式请参看官网介绍:

WebViewClient

shouldOverrideUrlLoading返回值的真正含义

在前面说过默认情况下直接使用WebViw加载一个url不会在自己App内的WebView中加载网页,而是会询问Activity Manager去寻找一个合适的处理者去加载(一般是跳到手机上的浏览器App进行加载显示)如果要达到这个目的,就需要用到WebViewClient这个类。

这里纠正一个网上广泛流传的错误说法,网上绝大多数说法是重写其boolean shouldOverrideUrlLoading(WebView view, String url)方法返回true则能达到在App内加载网页的效果,返回false则不行。一般做类似如下处理:

但是从谷歌官方给的说明来看,返回true表示宿主App处理了url,返回false意味着让当前的WebView处理该url。所以即使返回false同样也能达到在自己的App内浏览网页的效果。事实经过实践发现返回false确实也能达到在自己App内部处理该url的目的。只要设置了WebViewClient对象就意味着在该App内加载url,而不会跳转到系浏览器App。

那么既然如此这里就有一个问题值得思考,为何谷歌官方会让其返回一个boolean类型呢?这是因为这个boolean返回值不是用来设置是否让该url跳转到系统浏览器加载还是在自己App内加载(前面说过只要设置了WebViewClient就会在自己的App内加载而不会跳转),而是用来控制是否需要使用WebView来加载某些url的。首先看下谷歌官方对该方法的完整注释:

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. This method is not called for requests using the POST “method”.

从注释上可以很清楚的看到,该方法的作用是:在当前WebView中加载新url时,给应用程序一个接管控制权的机会。也就是说开发者可以在这个函数中对某些特殊的url做一些拦截判断是否需要去请求加载,返回true就表示由应用程序自己处理(也就是开发者拦截处理),返回fasle就表示交给该WebView去加载所以这里的关键就是理解shouldOverrideUrlLoading方法的调用时机以及该方法的返回值对其他关键函数执行流程的影响。就像事件分发机制中的onTouchEvent函数的返回值对onClick等函数的影响一样。

 

另外需要注意该方法在API24也就是7.0系统被废弃,此时官方推荐使用boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request)。

WebViewClient的其他重要函数

下面的on开头的函数都是WebViw在加载html页面时的回调函数。

  • onLoadResource(WebView view, String url):WebView在加载html页面的资源时会回调,被加载的资源的路径由url表示
  • onPageStarted(WebView view, String url, Bitmap favicon):当页面已经开始加载时回调。此方法在每次主框架加载时调用一次,因此具有iframes或framesets的页面将为主框架调用onPageStarted一次。这也意味着当嵌入帧的内容发生变化时,onPageStarted将不会被调用
  • onPageFinished(WebView view, String url):当页面加载完成时回调,此方法同样仅对主框架调用
  • onReceivedError(WebView view, WebResourceRequest request, WebResourceError error):当加载web资源出现错误时回调,注意这些错误通常表示无法连接到服务器。这也是该方法和下面的http error回调方法的区别。另外需要注意的是这个方法是在API23时新增的,旧版方法是
    onReceivedError(WebView view, int errorCode, String description, String failingUrl)。关于新旧方法的区别和注意事项会在后面单独详细说明
  • onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse):当加载web资源出现http错误的时候回调。此时http返回的错误码通常>=400。注意这和前面提到的onReceivedError出现错误回调不同,前面的回调通常是和服务端连接失败,也就是网络不通,而该回调错误往往是http连接失败。同样的当web页面局部加载错误时也会回调该方法,所以谷歌推荐开发者在这个方法中做尽可能少的操作。
  • onReceivedSslError(WebView view, SslErrorHandler handler, SslError error):在加载Web资源发生SSL错误时回调。此时应用程序必须调用handler.cancel()或handler.proceed()。使用cancel()表示当出现SSL错误时取消加载页面,而proceed()表示忽略SSL证书错误,继续加载页面。因此个人不推荐使用handler.proceed(),这存在一定的安全隐患,推荐使用handler.cancel()。
  • WebResourceResponse shouldInterceptRequest(WebView view, String url):顾名思义,该方法是用来进行资源拦截的,该方法可以将资源请求通知应用程序,并允许应用程序返回数据。如果返回值为null, WebView将像往常一样继续加载资源。否则,将使用返回的响应和数据。注意:此方法是在UI线程之外的线程上调用的,因此客户端在访问私有数据或视图系统时应谨慎

onReceivedError注意事项

前面说过新方法onReceivedError(WebView view, WebResourceRequest request, WebResourceError error)和旧方法onReceivedError(WebView view, int errorCode, String description, String failingUrl)的一个很大区别就是新方法在页面局部加载发生错误时也会被调用(Note that unlike the deprecated version of the callback, the new version will be called for any resource (iframe, image, etc), not just for the main page. Thus, it is recommended to perform minimum required work in this callback.)

比如若某些Web页面无图标favicon资源,那么该方法将会被回调。如果我们的页面本身就是无网站图标资源的,那么这种情况是不希望发生的,因为会干扰我们对其他正常情况的判断。所以需要对这种情况进行过滤处理,下面是一个简单示例:

另外也正因为新版本的该方法页面局部加载错误时也会被回调,所以谷歌推荐开发者在这个方法中做尽可能少的操作。

WebChromeClient

前面讲解了WebViewClient,聪明的同学可能已经注意到WebViewClient提供的API接口主要是关于和WebView加载相关的,那如果Web页面加载完成了,用户需要和界面进行交互,这个时候肯定会存在js端代码和java端代码的相互通信,比如可能界面上有个按钮,点击按钮之后会在js端弹出一个js的对话框,这些功能就是由WebChromeClient来提供的。这也是WebChromeClient与WebViewClient的区别。

看下该类提供的一些重要函数:

  • onProgressChanged(WebView view, int newProgress):当页面的加载进行发生改变时回调,用来告诉应用程序加载页面的当前进度
  • onReceivedIcon(WebView view, Bitmap icon):当接受到页面的图标时回调,用来通知应用程序当前页面的新图标(Notify the host application of a new favicon for the current page),类似的API还包括onReceivedTitle(WebView view, String title)等
  • boolean onJsAlert(WebView view, String url, String message, JsResult result):当web页面中要弹出js对话框时会回调该方法,告知客户端将显示一个javascript警告对话框。类似的API还包括boolean onJsPrompt (WebView view, String url, String message, String defaultValue, JsPromptResult result)等
  • boolean onShowFileChooser (WebView webView, ValueCallback<Uri[]> filePathCallback, WebChromeClient.FileChooserParams fileChooserParams):告诉客户端显示一个文件选择器。这是用来处理输入类型为“file”的HTML表单,以响应用户按下“Select file”按钮。若要取消请求,请调用filePathCallback.onReceiveValue(null)并返回true。

通常情况下如果不是要进行复杂的WebView交互,一般只需要使用WebViewClient就可以了,无需使用WebChromeClient。上面只例举了该类一些常用的API接口,关于该类其他的一些API使用及其作用请参看官网:

js端与java端相互通信

在绝大多数情况下,WebView不会仅仅只是用来进行UI展示,还会和用户进行UI交互同时响应用户的操作。这就涉及到web端的js代码和App端的java代码的相关通信了。

java代码调用js端代码

java代码调用js代码主要是通过WebView的loadUrl方法和evaluateJavascript方法。前者无返回值,而后者可以接受返回值。比较简单这里只举2个简单的例子:

使用loadUrl调用js函数

通过上面可以看到如果js端函数本身是无参函数直接将调用的函数名拼接到javascript:后面然后传给loadUrl作为参数即可,如果js端函数是包含参数的,则将参数以字符串的形式拼接到函数的形参中。

使用evaluateJavascript调用js函数

同样以前面的popupCaptcha()函数为例,假设该函数返回一个字符串对象。

使用evaluateJavascript方法可以获得js端有返回值的函数的返回值。在该函数ValueCallback参数对象的onReceiveValue方法回调中可以获取到js端函数执行完成之后的返回值。

js端代码调用java代码

js端代码调用java代码相对复杂一些。首先使用WebView的addJavascriptInterface方法,该方法将Java对象注入页面的JavaScript上下文,这样页面中的JavaScript就可以访问它们。该函数定义如下:

第一个参数是要注入到此WebView的JavaScript上下文中的Java对象,第二个参数用来在JavaScript中公开对象的名称,也就是和js端的一个名称约定。下面是一个小示例:

这个示例表示将JsCallback这个java对象注入到js上下文中,约定的js对象名称为JSInterface。

然后在前面创建的java对象类中定义需要被js端调用的方法,且给该方法加上@JavaInterface注解:

完成这2步后java端代码就ok了,这样js端就能够调用java端的onLoad方法了,下面是个简单示例:

当然一般情况下js端代码是前端同学负责的,所以作为客户端开发同学只需要和前端同学协商好需要注入到js的对象的字符串名称,以及需要回调的一些方法即可。然后大家就可以各司其职,负责各自的一部分就行。

使用避坑指南

使用WebView有很多需要注意的一些细节,这些在前面讲解时已经提到过了,这里只简单的总结下,具体原因及其解决方法请参看前面讲解WebView及其相关类使用是的详细讲解

  • pauseTimers()/resumeTimers()需要注意的坑
  • WebViewClient#shouldOverrideUrlLoading返回值对其他函数执行的影响
  • WebViewClient#onReceivedHttpError在某些情况下需要对局部资源加载失败进行过滤
  • WebView#onReceivedSslError回调中不要使用handler.proceed(),存在安全隐患,推荐使用handler.cancel()
  • WebView#addJavascriptInterface方法注意安全漏洞

相关优化

加载优化

WebView的加载优化是一个非常复杂的话题,后面再专门写一篇博客进行讲解,这里只简单提下具体思路:

  • 将某些不会频繁变化的js资源打包到本地,供WebView直接使用。具体做法就是通过shouldInterceptRequest来拦截判断是否需要从服务的请求资源。
  • 合理使用缓存:根据实际业务情况,使用缓存
  • 预先加载,延迟显示:这一条从原理上严格来说不属于加载优化,而是从用户感知角度来做的。但是我们加载优化的目的就是为了让用户使用更流畅吗?所以我们可以通过预加载的形式将某些页面提前加载。然后等用户要看的时候显示出来。这样用户感知上会觉得加载很快。很多浏览器实际上都具备预加载功能。

安全优化

  • 【强制】如果覆写了WebView#onReceivedSslError方法,一定不能在该方法中使用handler.proceed()
  • 在使用WebView#addJavascriptInterface方法时要注意在API16安卓4.1系统(含)及其以前会存在远程代码执行漏洞,关于此漏洞感兴趣的同学可以自行谷之百之。所以在使用该方法时可以考虑根据系统版本进行区分是否调用,如果是4.2以前的系统则不调用,此时可以通过js反射注入的形式来达到目的。4.2(含)及其以后可以放心使用该接口。
  • 在Android 3.0以下系统,谷歌默认注入了一个searchBoxJavaBridge_接口,同样的该接口也是通过addJavascriptInterface添加的,所以如果你的App不能确保只运行在4.2及其以上系统上,为了安全在4.2以前系统我们也需要把这个接口删除,调用removeJavascriptInterface方法即可。

上面提到的addJavascriptInterface在4.1及其以前系统上导致的远程代码执行漏洞可以按照如下方式解决:

但是上面的代码只是解决了在4.1及其以下存在的安全漏洞,但是没解决js端该如何调用java端代码了,这个比较麻烦。谷歌给的思路是通过反射来达到目的。但是这里存在一个问题就是因为是回调,所以发起方是js端,所以我们需要在某个地方能够接受到js端发送的消息。js端将需要调用的java端的方法以字符串的形式告知客户端,然后客户端根据js端发送的消息分离出方法名称和签名,然后通过反射或创建对象来调用对应的方法。因为不能使用addJavascriptInterface接口了,所以只能依靠WebView及其相关类的回调方法来接收信息。具体选择哪个回调方法可以视自己业务的实际情况来判断。

打赏

点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注