Android爬坑之旅之WebView

不知不觉,Hybird App已经成了目前比较主流的一种开发方式。

对于用户体验要求较高或者与硬件交互较多的功能我们一般都会采用Native原生的方式来实现。
而用户交互少,偏展示类,活动类的功能我们则通常采用H5的方式来实现,
例如新闻类的app,详情展示页一般就是H5的页面

  • 一方面图文排版上web有着先天的优势,同时纯展示类的页面在目前的移动设备上,性能体验已经很难让用户分辨是网页还是原生了;
  • 另一方面,H5的页面跨平台,方便在原生客户端上实现分享功能,拥有较强的传播性,我们平时常见的活动页面也拥有这样的优势,所以你看到的活动页面也基本都是H5,只需轻轻一点就能分享到各个平台;
  • 同时,H5的页面开发降低了开发成本,一套代码,web,android,ios都能访问。(然而实际开发过程中,H5的适配也都是各种泪)

既然Hybird App有这么多优势,那在Android中我们通过什么样的方式在原生项目中嵌入H5页面呢?

那就不得不提到我们的WebVew了,作为官方唯一用来显示web的组件,
展示网页这样的任务也只能交给它了。

A View that displays web pages. This class is the basis upon which you can roll your own web browser or simply display some online content within your Activity. It uses the WebKit rendering engine to display web pages and includes methods to navigate forward and backward through a history, zoom in and out, perform text searches and more.

引用官方文档的一句话:
WebView是一个用来在Activity中显示我们网页的视图组件,它通过webkit渲染引擎渲染和显示我们的web页面,并且包含了web的历史导航操法,页面放大缩小,文本搜索等方法。

我们首先来看一下WebView的基本用法:

WebView的基本用法


关于WebView的基本用法,大部分人也是轻车熟路,
本来也是写了一部分,无意中发现有位博主的博客对WebView的介绍实在太过详细,像我这样的懒人,有更好的文章是不会自己去写的,
所以删了自己写的,将大牛博主的博客分享出来,感兴趣同学的可以一起看一看:

Android WebView 开发详解(一)
Android WebView 开发详解(二)
Android WebView 开发详解(三)

了解完WebView的基本用法,那就来总结下最近项目中遇到的关于WebView的坑

项目中使用WebView遇到的问题


WebView界面的原生标题设置

WebView所在界面

如图所示,
一般情况下,我们WebView所在界面由顶部带标题的原生导航栏WebView的内容部分组成,
而WebView中的界面可能在点击后还会再跳其他Web页面(如图点击请假会在当前WebView跳转请假的Web页面)。

由于点击内容的不确定性,所以通常情况下,最简单的做法就是捕获h5页面的 <title> 标签来进行标题设置。

对于捕获 <title> 标签内容的方式,WebView也很好地提供了支持,我们可以通过继承WebChromeClient的onReceivedTitle来进行获取:

 private class WebViewChromeClient extends WebChromeClient {

        @Override
        public void onReceivedTitle(WebView view, String title) {
            super.onReceivedTitle(view, title);
            mTitleText.setTitle(String.valueOf(view.getTitle()));
        }
    }

然而这样的方式在实际使用中有一个问题:

当通过 webView.goBack() 方式返回上一级Web页面的时候不会触发这个方法,因此会导致标题无法跟随历史记录返回上一级页面。

所以在项目中,
我们可以通过重写 WebViewClient 的 [onPageFinished](https://developer.android.google.cn/reference/android/webkit/WebViewClient.html#onPageFinished(android.webkit.WebView, java.lang.String)) 方法,在 [onPageFinished](https://developer.android.google.cn/reference/android/webkit/WebViewClient.html#onPageFinished(android.webkit.WebView, java.lang.String)) 中对界面标题进行设置。
因为不管是历史记录的返回还是点击跳转都会触发页面加载,
当页面加载完成时(不包括js动态创建以及img图片加载完毕)都会触发 [onPageFinished](https://developer.android.google.cn/reference/android/webkit/WebViewClient.html#onPageFinished(android.webkit.WebView, java.lang.String)) 这个方法,
此时我们去获取 <title> 的标题内容不会有任何问题,可以确保在页面返回时能够获取到正确的标题。

      mWebView.setWebViewClient(new WebViewClient(){

            //Web页面每次加载并完成时会触发该方法
            @Override
            public void onPageFinished(WebView view, String url) {
                super.onPageFinished(view, url);
                mToolbar.setTitle(String.valueOf(view.getTitle()));
                Log.i (LOG_TAG, "onPageFinished");
            }
        });

注: 这种做法有一个缺陷,就是返回上一个界面的时候,等页面加载完成的时候标题才会显示出来,为了更好地优化,我们可以创建一个集合用来保存我们的标题,加载url的时候把标题添加进集合,当返回上一级页面的时候,从集合中取出标题进行显示,同时从集合中移除标题。


WebView中的Web页面存在<input type='file'>标签时无法打开文件选择器

在我们的手机浏览器中,当web页面中有 <input type='file'> 按钮标签的时候点击会自动打开系统的文件选择器,
然而这个功能在主流系统的WebView中没有被默认实现,
因此,为了让 <input type='file'> 点击时能够打开系统的文件选择器,
我们必须通过重写 WebChromeClient 来实现点击<input type='file'> 打开系统文件选择器。
代码如下:

public class MainActivity extends AppCompatActivity {

    /** Android 5.0以下版本的文件选择回调 */
    protected ValueCallback<Uri> mFileUploadCallbackFirst;
    /** Android 5.0及以上版本的文件选择回调 */
    protected ValueCallback<Uri[]> mFileUploadCallbackSecond;

    protected static final int REQUEST_CODE_FILE_PICKER = 51426;


    protected String mUploadableFileTypes = "image/*";

    private WebView mWebView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initWebView();
    }

    private void initWebView() {
        mWebView = (WebView) findViewById(R.id.my_webview);

        mWebView.loadUrl("file:///android_asset/index.html");
        mWebView.setWebChromeClient(new OpenFileChromeClient());
    }

    private class OpenFileChromeClient extends WebChromeClient {

        //  Android 2.2 (API level 8)到Android 2.3 (API level 10)版本选择文件时会触发该隐藏方法
        @SuppressWarnings("unused")
        public void openFileChooser(ValueCallback<Uri> uploadMsg) {
            openFileChooser(uploadMsg, null);
        }

        // Android 3.0 (API level 11)到 Android 4.0 (API level 15))版本选择文件时会触发,该方法为隐藏方法
        public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType) {
            openFileChooser(uploadMsg, acceptType, null);
        }

        // Android 4.1 (API level 16) -- Android 4.3 (API level 18)版本选择文件时会触发,该方法为隐藏方法
        @SuppressWarnings("unused")
        public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType, String capture) {
            openFileInput(uploadMsg, null, false);
        }

        // Android 5.0 (API level 21)以上版本会触发该方法,该方法为公开方法
        @SuppressWarnings("all")
        public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) {
            if (Build.VERSION.SDK_INT >= 21) {
                final boolean allowMultiple = fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE;//是否支持多选


                openFileInput(null, filePathCallback, allowMultiple);

                return true;
            }
            else {
                return false;
            }
        }


    }

    @SuppressLint("NewApi")
    protected void openFileInput(final ValueCallback<Uri> fileUploadCallbackFirst, final ValueCallback<Uri[]> fileUploadCallbackSecond, final boolean allowMultiple) {
        //Android 5.0以下版本
        if (mFileUploadCallbackFirst != null) {
            mFileUploadCallbackFirst.onReceiveValue(null);
        }
        mFileUploadCallbackFirst = fileUploadCallbackFirst;

        //Android 5.0及以上版本
        if (mFileUploadCallbackSecond != null) {
            mFileUploadCallbackSecond.onReceiveValue(null);
        }
        mFileUploadCallbackSecond = fileUploadCallbackSecond;

        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
        i.addCategory(Intent.CATEGORY_OPENABLE);

        if (allowMultiple) {
            if (Build.VERSION.SDK_INT >= 18) {
                i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
            }
        }

        i.setType(mUploadableFileTypes);

        startActivityForResult(Intent.createChooser(i, "选择文件"), REQUEST_CODE_FILE_PICKER);

    }

    public void onActivityResult(final int requestCode, final int resultCode, final Intent intent) {
        if (requestCode == REQUEST_CODE_FILE_PICKER) {
            if (resultCode == Activity.RESULT_OK) {
                if (intent != null) {
                    //Android 5.0以下版本
                    if (mFileUploadCallbackFirst != null) {
                        mFileUploadCallbackFirst.onReceiveValue(intent.getData());
                        mFileUploadCallbackFirst = null;
                    }
                    else if (mFileUploadCallbackSecond != null) {//Android 5.0及以上版本
                        Uri[] dataUris = null;

                        try {
                            if (intent.getDataString() != null) {
                                dataUris = new Uri[] { Uri.parse(intent.getDataString()) };
                            }
                            else {
                                if (Build.VERSION.SDK_INT >= 16) {
                                    if (intent.getClipData() != null) {
                                        final int numSelectedFiles = intent.getClipData().getItemCount();

                                        dataUris = new Uri[numSelectedFiles];

                                        for (int i = 0; i < numSelectedFiles; i++) {
                                            dataUris[i] = intent.getClipData().getItemAt(i).getUri();
                                        }
                                    }
                                }
                            }
                        }
                        catch (Exception ignored) { }
                        mFileUploadCallbackSecond.onReceiveValue(dataUris);
                        mFileUploadCallbackSecond = null;
                    }
                }
            }
            else {
                //这里mFileUploadCallbackFirst跟mFileUploadCallbackSecond在不同系统版本下分别持有了
                //WebView对象,在用户取消文件选择器的情况下,需给onReceiveValue传null返回值
                //否则WebView在未收到返回值的情况下,无法进行任何操作,文件选择器会失效
                if (mFileUploadCallbackFirst != null) {
                    mFileUploadCallbackFirst.onReceiveValue(null);
                    mFileUploadCallbackFirst = null;
                }
                else if (mFileUploadCallbackSecond != null) {
                    mFileUploadCallbackSecond.onReceiveValue(null);
                    mFileUploadCallbackSecond = null;
                }
            }
        }
    }

}

注:当用户点击input file弹出文件选择器后,点击取消或者返回按钮没有执行选择时,必须在onActivityResult里给valueCallback的onReceiveValue传null,因为valueCallback持有的是WebView,在onReceiveValue没有回传值的情况下,WebView无法进行下一步操作,会导致取消选择文件后,点击input file不会再响应:

  if (mFileUploadCallbackFirst != null) {
         mFileUploadCallbackFirst.onReceiveValue(null);
         mFileUploadCallbackFirst = null;
   }
   else if (mFileUploadCallbackSecond != null) {
         mFileUploadCallbackSecond.onReceiveValue(null);
         mFileUploadCallbackSecond = null;
  }

示例demo地址:
https://github.com/cjpx00008/FileChooser4WebViewDemo


WebView中的web页面调用系统选择器或者相机导致app进入后台被系统释放

众所周知,WebView基于webkit内核来渲染web页面,因此使用起来相当于一个小型浏览器,即使页面内容不复杂,只要使用WebView也会占用大量的内存。

而Android的内存回收机制,在系统内存不足的情况下会优先释放内存占用较大的app从而回收内存资源,此时正在使用WebView的运行在后台的app肯定是首当其冲被回收的。

因此,当WebView通过input file调用系统文件选择器,或者通过文件选择器调用了相机时,我们的app就进入了后台,在部分低端Android设备(尤其红米这类手机,默认的神隐模式会在app进入后台的时候较大概率的释放app)或者系统内存资源不足的情况下,我们的app就会优先被释放掉,导致文件选择完毕后,回到上一界面时,app的界面重新走了onCreate,web页面也因此重建了。

对于部分需要填写大量表单的web页面来说,用户填写的数据会随着界面的销毁重建而丢失,而选择的文件也因为页面的重建而无法回传给input file,这对于用户的体验来说肯定是不友好的。

也许你会说,重写onSaveInstance保存数据就是啦。
这也是我一开始考虑的,
我们的WebView也提供了 saveState 以及 restoreState 来保存状态。

然而悲催的是,这两个方法并不会保存web页面内的数据,它只保存了WebView加载的页面,前进后退的历史状态等数据。

引用官方文档的描述:

Saves the state of this WebView used in onSaveInstanceState(Bundle)
. Please note that this method no longer stores the display data for this WebView. The previous behavior could potentially leak files if restoreState(Bundle)
was never called.

Please note that this method no longer stores the display data for this WebView

WebView的saveState并不会保存界面的数据。

所以,对于表单数据的恢复,我们只能自己想办法了,我们这里采用了两套方案:

  1. 通过WebView与JS交互,在onSaveInstance的时候触发界面保存数据,保存数据的方式也大体分为两种,
    一种使用H5自带的localStorage来进行数据存储,页面销毁重建的时候H5页面判断本地localStorage数据是否有值,有就将值重新填充到页面表单,提交数据后清除本地localStorage的数据。
    这种方式需要给WebView开启对localStorage的支持。
WebSettings settings = mWebView.getSettings();
settings.setDomStorageEnabled(true);

另一种则提供JS接口将数据传递给原生,通过原生代码将数据保存到本地,在页面重建渲染完成时,web页面通过JS接口调用原生方法拉取数据判断是否有值,有则填充表单,无则不做操作,提交数据后调用JS接口调用原生方法清空本地数据。

  1. 由web端自己处理,在表单页面文本输入失去焦点时自动保存数据,页面销毁重建时,自己拉取数据进行判断。
    这种方式对原生的依赖较低,个人更倾向这种方式,当然最终由于项目的特殊情况,我们还是采用了第一种方式。

以上是表单数据的恢复方案,

而对于从系统文件选择器选择的文件web页面是无法直接接收并处理了,这里我们提供了一个JS接口在web页面加载完成时,进行触发,并将数据传递给web页面。

说到这里,不得不提另外一个问题


WebView调用服务端页面如何访问本地文件

上面我们提到了通过JS接口将选择的文件数据传递给web页面,

然而由于安全原因,WebView限制了远程url页面访问本地文件,
如果我们加载的url是服务端的页面,那我们没有任何办法直接通过文件地址来访问客户端本地的文件

我们知道,WebView用来加载网页的方式主要有三种:

loadUrl(String url)
loadUrl(String url, Map<String, String> additionalHttpHeaders)
loadData(String data, String mimeType, String encoding)
loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding, String historyUrl)

[loadData()](https://developer.android.google.cn/reference/android/webkit/WebView.html#loadData(java.lang.String, java.lang.String, java.lang.String)) 和 [loadDataWithBaseURL()](https://developer.android.google.cn/reference/android/webkit/WebView.html#loadDataWithBaseURL(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String)) 都是直接将数据加载进WebView中,相当于显示的一个本地Web

loadUrl也可以通过访问本地的文件地址(例如本地asset目录下的存放了index.html页面,可以通过loadUrl("file:///android_asset/index.html")来显示web页面)

对于这样的三种加载本地内容的方式,我们可以使用多种方式来传递路径供web页面传递,这里以图片为例(相册目录下test/IMG_20170105_093405.jpg):

  1. 直接通过文件的绝对地址来提供给页面显示:
<img src = 'file:///storage/emulated/0/dcim/test/IMG_20170105_093405.jpg' />
  1. 通过媒体库查询出来的content uri地址展示
<img src = 'content://media/external/images/media/102610' />
  1. 通过FileProvider转换的content uri地址展示
<img src = 'content://com.test.myfileprovider/dcim/test/IMG_20170105_093405.jpg'/>

可当你使用loadUrl(String url)加载服务端的http地址时,以上三种方法将均无法使用,经过各种尝试,目前找到两种方案来提供给web端进行图片显示:

  1. 由原生代码处理,将文件流转换为Base64之后通过JS接口回传给web;

  2. 重写WebViewClient里的shouldInterceptRequest方法,每当页面发生资源请求的时候就会触发这个方法,我们可以过滤请求,判断请求是否为本地文件,通过拦截请求转换为二进制流回传回去,
    示例代码如下:

mWebView.setWebViewClient(new WebViewClient(){

     @Override
     public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
          if (url.startsWith("http://")&&url.endWith(".jpg") {
               return getWebResourceResponse("/storage/emulated/0/dcim/trinaic/IMG_20170105_093405.jpg", "image/jpeg", ".jpg");
          }
          return super.shouldInterceptRequest(view, url);
     }

}

    private WebResourceResponse getWebResourceResponse(String url, String mime, String style) {
        WebResourceResponse response = null;
        try {
            response = new WebResourceResponse(mime, "UTF-8", new FileInputStream(new File(url)));
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        return response;
    }

WebView JS注入漏洞

要想让原生跟JS进行交互,按照官方提供的方法就得使用addJavaScriptInterface

class JsObject {
    @JavascriptInterface
    public String toString() { return "injectedObject"; }
 }
 webView.addJavascriptInterface(new JsObject(), "injectedObject");
 webView.loadData("", "text/html", null);
 webView.loadUrl("javascript:alert(injectedObject.toString())");

Injects the supplied Java object into this WebView. The object is injected into the JavaScript context of the main frame, using the supplied name. This allows the Java object's methods to be accessed from JavaScript. For applications targeted to API level JELLY_BEAN_MR1
and above, only public methods that are annotated with JavascriptInterface
can be accessed from JavaScript. For applications targeted to API level JELLY_BEAN
or below, all public methods (including the inherited ones) can be accessed, see the important security note below for implications.

引用官方api的说明,在Android 4.2以下,会有被注入的风险,4.2以上版本可以通过@JavascriptInterface的注解来处理这个问题。
具体的注入方式,我找了篇博客,如果有不清楚的同学可以了解下:
Android WebView的Js对象注入漏洞解决方案

在之前乌云平台报出的漏洞中,
android/webkit/webview中默认内置的一个searchBoxJavaBridge_ 接口同时存在远程代码执行漏洞

在于android/webkit/AccessibilityInjector.java中,调用了此组件的应用在开启辅助功能选项中第三方服务的安卓系统中会造成远程代码执行漏洞。这两个接口分别是"accessibility" 和"accessibilityTraversal" ,此漏洞原理与searchBoxJavaBridge_接口远程代码执行相似,均为未移除不安全的默认接口,不过此漏洞需要用户启动系统设置中的第三方辅助服务,利用条件较复杂。

因此,一般情况下我们通过removeJavaScripteInterface来移除这几个接口

        if (Build.VERSION.SDK_INT < 17) {
            mAdvanceWebView.removeJavascriptInterface("searchBoxJavaBridge_");
            mAdvanceWebView.removeJavascriptInterface("accessibility");
            mAdvanceWebView.removeJavascriptInterface("accessibilityTraversal");
        }

除此之外也有通过onJsPrompt的方式来实现WebView原生跟JS交互功能的,github上的开源项目JSBridge就是采用这种方法:
https://github.com/lzyzsd/JsBridge

之前拜读过大名鼎鼎的cordova的源码,它内部的原生JS交互也是采用onJsPrompt的方式,不过在此基础上做了更强大的封装。


WebView后台耗电问题

当我们的WebView的web页面在解析或者播放视频再或者有js定时器在执行的时,
如果我们把应用退到后台,不做任何处理的情况下,以上的操作还会在后台继续执行,导致WebView在后台持续耗电,因此一般我们会做以下处理

    @Override
    protected void onPause() {
        super.onPause();
        mWebView.onPause();//暂停部分可安全处理的操作,如动画,定位,视频播放等
        mWebView.pauseTimers();//暂停所有WebView的页面布局、解析以及JavaScript的定时器操作  
    }

    @Override
    protected void onResume() {
        super.onResume();
        mWebView.onResume();
        mWebView.resumeTimers();    
    }

对于WebView的使用,在处理问题的过程中发现一个不错的开源库:
https://github.com/delight-im/Android-AdvancedWebView

基本上上面我提到的或者没提到的问题它都做了一定的封装处理,并且考虑了一些版本适配的问题,可以直接拿来使用,也可以拿来参考学习。

如果你觉得问题还是太多的话也可以考虑使用腾讯浏览服务,基于QQ浏览器X5内核,适配了Android全部主流平台,可以在所有Android手机上使用Blink的技术能力,具有更好的H5/CSS3支持和性能,目前微信、qq都在使用它。

唯一的缺陷就是它不提供打包内核版的SDK,第一次使用时,它会自动到腾讯服务端去下载内核,下载完毕后会弹窗提示用户是否重启app,重启之后就能正常使用x5浏览服务了,如果你不介意这样的用户体验,可以考虑直接使用腾讯浏览服务。


(补充)

WebView混淆问题

如果app打包混淆之后发现提供给web页面的JS接口失效了,记得检查是否添加了JavaScriptInterface的混淆配置:

-keepclassmembers class * {
    @android.webkit.JavascriptInterface <methods>;
}

红米WebView内部Web页面的div自身滚动条问题

红米上WebView内部的Web页面的div由于内容高度大于div,产生了基于div的滚动条(WebView滚动条已禁用的情况下),通过设置div的css样式来禁用div滚动条

Html dom元素ID或class:: -webkit-scrollbar {display:none}

WebView内部web页面px跟dp的关系

经测试发现,WebView内部web页面的px值会在内部自动转换为dp,且1px=1dp,跟ppi值无关,这点跟原生开发中的1dp = 设备ppi/160 * px换算关系nveou

推荐阅读更多精彩内容