查看原文
其他

Android爬坑之旅之WebView及源码解析

2017-02-08 香辣牛肉面 终端研发部
今日时讯

长期以来,中国应用和国外应用分裂、脱节,有人说在国外用的是 Android 手机,在国内则是安卓手机。二者的重要不同在于,中国应用没有加入  Google Mobile Service 的服务框架,所以中国应用推广到海外时候,会出现接口不一致等问题。因此,郑嘉杰也认为,Google Play 回到中国,会对创业者出海起到一定帮助作用。不过也有人认为,如果等 Google Play 入华才想出海的事情,那么这就已经晚了。假如Google真的回来了,会带来哪些变化? 我们期目以待

文正

本篇来自香辣牛肉面的投稿,这里讲了Android爬坑之旅之FileProvider的讲解。

香辣牛肉面的博客地址:

http://blog.csdn.net/cjpx00008/article/details/54582997

不知不觉,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的内容部分组成, 而WebView中的界面可能在点击后还会再跳其他Web页面(如图点击请假会在当前WebView跳转请假的Web页面)。

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

对于捕获 标签内容的方式,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 方法,在 onPageFinished 中对界面标题进行设置。 因为不管是历史记录的返回还是点击跳转都会触发页面加载, 当页面加载完成时(不包括js动态创建以及img图片加载完毕)都会触发 onPageFinished 这个方法, 此时我们去获取 的标题内容不会有任何问题,可以确保在页面返回时能够获取到正确的标题。

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页面存在标签时无法打开文件选择器

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

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

<font color=red>WebView中的web页面调用系统选择器或者相机导致app进入后台被系统释放</font>

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

    • 由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() 和 loadDataWithBaseURL() 都是直接将数据加载进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'/>
  • 2.通过媒体库查询出来的content uri地址展示

img src='content://media/external/images/media/102610'/>
  • 3.通过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())");

引用官方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的方式,不过在此基础上做了更强大的封装。


如果你觉得此文对您有所帮助,欢迎入群 QQ交流群 :232203809 微信公众号:终端研发部 


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存