关于Android移动端webview疑难问题的定位和解决方案

关于Android移动端webview疑难问题的定位和解决方案

作者: xteamer成员: 清泓

【摘要】

相信大家一点都不陌生,webview,是移动端自带的提供给用户浏览加载网页的,WebView 具有成为明星的深度和广度,并且在一大类应用中 WebView 内部加载的 Web 内容构成了整个应用用户体验。

从技术角度来看,这些仍然是原生应用。事实上,这些应用所做的唯一原生操作就是托管 WebView,而 WebView 又加载 Web 内容和用户交互的所有 UI。混合应用很受欢迎有几个原因。最大的一个是开发人员生产力。如果你有一个在浏览器中运行的响应式 Web 应用,那么在各种设备上使用相同的应用作为混合应用会非常简单。

当你对 Web 应用进行更新时,所有使用它的设备都可以立即使用该更改,因为内容来自一个集中位置,也就是你的服务器。

如果你必须使用纯原生应用,不仅需要为构建应用的每个平台更新项目,你可能必须经历耗时的应用审核过程才能使你的更新在所有的应用商店获取到。从部署和更新的角度来看,混合应用非常方便。将这种便利性与原生设备访问相结合能为你的 Web 应用提供超能力,这样你就拥有了一个成功的技术解决方案。WebView 使一切成为可能。

有些人或许觉得不就是一两行代码的事情吗,简简单单加载一个链接而已。有必要长篇大论吗?其实webview其中蕴含的技术点并不少。

在日常生活和工作中我们通常都使用 Chrome, Firefox, Safari,Edge 和 Internet Explorer等浏览器来浏览网页。但真正在一个app里面去加载一个网页,需要用到的肯定是webview了。

Webview [1] 是一个基于webkit引擎,可以解析DOM 元素,展示html页面的控件,它和浏览器展示页面的原理是相同的,所以可以把它当做浏览器看待。(chrome浏览器也是基于webkit引擎开发的,Mozilla浏览器是基于Gecko引擎开发的)

Android的Webview在低版本和高版本采用了不同的webkit版本内核,4.4后直接使用了Chrome

对于H5加壳的应用,我给他起了个名,全屏混合式应用,网上有一些一键打包app应用的,虽然说对于一些项目来说已经足够,但是无丝毫拓展性,和非开源,无法实现高度定制化。所以有了这篇文章,专攻webview的文章。相关技术实现大家可以在下面留言跟我一起共同进步。

Android篇

一.关于微信H5支付在app上的兼容

微信支付原理可移步微信开发文档

pay.weixin.qq.com/wiki/

问题表现:

微信支付在android国际系统中白屏。

当初觉得,既然是H5端已经处理微信支付的事项,我们Android端唯一要做的是,截获H5的事件,然后就可以调出支付就行了。然而后来才发现需要截获事件,并进行跳转页面url的动作,就需要有一个截获的方法,那首当其冲其冲就是 shouldOverrideUrlLoading()

这个方法到底有什么样的作用和含义

作用:用来拦截网页URL事件,这个方法的返回值

return true 表示当前url即使是重定向url也不会再执行(除了在return true之前使用webview.loadUrl(url)除外,因为这个会重新加载)

return false 表示由系统执行url,直到不再执行此方法,即加载完重定向的url(即具体的url,不再有重定向)

注意:一般情况如果想要对URL进行重定向或者相关处理,可以把返回值设为true。如果不处理,可以返回false。

在这个方法里,我微信支付进行了一个重定向,拦截URL并进行跳转操作。



实际上, shouldOverrideUrlLoading 里可以进行URL拦截,并中途加入referer

但是没什么效果,而且并没有被处理,这里就有很多疑问了,到底是为何拦截不了,而网上大多都是

靠这个方法,于是,就用到了 shouldInterceptRequest

shouldInterceptRequest 是WebViewClient的一个方法,

情形一

当用户使用WebView的loadUrl方法开启一个网页时,其中onPageStarted方法会执行,而shouldOverrideUrlLoading则不会执行

情形二

当用户继续点击网页内的链接时,onPageStarted和shouldOverrideUrlLoading均会执行,并且shouldOverrideUrlLoading要先于onPageStarted方法执行

情形三

当用户点击网页中的链接后,点击back,返回历史网页时,onPageStarted会执行,而shouldOverrideUrlLoading不会执行

综上所述,当需要对访问的网页进行策略控制时,需要在onPageStarted方法中进行拦截,如下示例代码:

@Override public void onPageStarted(WebView view, String url, Bitmap favicon) { Log.d(TAG, "onPageStarted url is " + url); boolean res = checkUrl(url); //根据对URL的检查结果,进行不同的处理, //例如,当检查的URL不符合要求时, //可以加载本地安全页面,提示用户退出 if (!res) { //停止加载原页面 view.stopLoading(); //加载安全页面 view.loadUrl(LOCAL_SAFE_URL); } }

然后,来分析一下shouldInterceptRequest(WebView view, String url),此方法从Android API 11(3.0)开始提供,位于WebViewClient 内,当用户使用WebView的loadUrl方法打开网页、点击网页中的链接、返回历史网页时,所有资源的加载均会调用shouldInterceptRequest方法,测试访问“ firefly.cmbc.com.cn/app ”如下图:

可以看到网页中加载的html、js、css、gif、png、webp等资源,全部会调用此方法。因此,开发者可以在此方法中对网页中加载的资源进行拦截,构造WebResourceResponse对象返回,以此来加载本地缓存的资源。WebResourceResponse的构造函数如下:

1.

参数说明mimeTypeString类型,资源的MIME类型,例如text/html,image/png,text/cssencodingString类型,资源Response的编码格式,例如UTF-8dataInputStream类型,资源Response的输入流,不能是StringBufferInputStream

2.

参数说明mimeTypeString类型,资源的MIME类型,例如text/html,image/png,text/cssencodingString类型,资源Response的编码格式,例如UTF-8statusCodeint类型,状态码,必须在[100,299][400,599]之间,3XX的不支持reasonPhraseString类型,描述状态码的语言,例如“确定”,必须是非空的responseHeadersMap类型,资源Response的头部dataInputStream类型,资源Response的输入流,不能是StringBufferInputStream

官方的说明 [2]

/**

* Notify the host application of a resource request and allow the

* application to return the data. If the return value is null, the WebView

* will continue to load the resource as usual. Otherwise, the return

* response and data will be used. NOTE: This method is called on a thread

* other than the UI thread so clients should exercise caution

* when accessing private data or the view system.

*

* @param view The {@link android.webkit.WebView} that is requesting the

* resource.

* @param request Object containing the details of the request.

* @return A {@link android.webkit.WebResourceResponse} containing the

* response information or null if the WebView should load the

* resource itself.

*/

public WebResourceResponse shouldInterceptRequest(WebView view,

WebResourceRequest request) {

return shouldInterceptRequest(view, request.getUrl().toString());

}

留目此文的应该都是大神了,就不百度翻译了

在网上罗列资料 [3] ,发现这个确是把这个方法原理和部分用法给写清楚了,看好了



需求案例:

需求背景

接到这样一个需求,需要在 WebView 的所有网络请求中,在请求的url中,加上一个xxx=1的标志位。

例如 baidu.com 加上标志位就变成了 baidu.com ?xxx=1

寻找解决方案

从 Android API 11 (3.0) 开始,WebView 开始在 WebViewClient 内提供了这样一条 API ,如下:

public WebResourceResponse shouldInterceptRequest(WebView view, String url)

就是说只要实现 WebViewClient 的 shouldInterceptRequest 方法,然后调用 WebView 的 setWebViewClient 就可以了。

但是,在 API21 以上又弃用了上述 API,使用了一条新的 API,如下:

public WebResourceResponse shouldInterceptRequest(WebView view, final WebResourceRequest request)

好吧,为了支持尽量多的版本,看来两个都需要实现了,发现一看就非常好用的 String url 变成了一个 WebResourceRequest request 。 WebResourceRequest 这个东西是一个接口,并且是这样定义的:

public interface WebResourceRequest { Uri getUrl (); boolean isForMainFrame (); boolean hasGesture (); String getMethod (); Map<String, String> getRequestHeaders (); }

在其中没有发现任何可以直接替换请求的方法。

然后搜索了一下 Android 代码中对他的引用, 点我搜索 。然后发现 private static class WebResourceRequestImpl implements WebResourceRequest 它的内部实现仅仅是一个单纯的实体。那这个东西要替换就非常好办了,三个方法都可以做:

  1. 动态代理
  2. 反射
  3. 重新实现

实现

方案确定了,剩下的就简单了。直接上代码。

首先是往URL字符串加那个标志位的方法

public static String injectIsParams(String url) { if (url != null && !url.contains("xxx=") { if (url.contains("?")) { return url + "&xxx=1"; } else { return url + "?xxx=1"; } } else { return url; } }

然后要拦截所有请求了

webView.setWebViewClient( new WebViewClient() { @SuppressLint("NewApi") @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { if (request != null && request.getUrl() != null && request.getMethod().equalsIgnoreCase("get")) { String scheme = request.getUrl().getScheme().trim(); if (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) { try { URL url = new URL(injectIsParams(request.getUrl().toString())); URLConnection connection = url.openConnection(); return new WebResourceResponse(connection.getContentType(), connection.getHeaderField("encoding"), connection.getInputStream()); } catch (MalformedURLException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } } return null ; } @Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { if (!TextUtils.isEmpty(url) && Uri.parse(url).getScheme() != null ) { String scheme = Uri.parse(url).getScheme().trim(); if (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) { try { URL url = new URL(injectIsParams(request.getUrl().toString())); URLConnection connection = url.openConnection(); return new WebResourceResponse(connection.getContentType(), connection.getHeaderField("encoding"), connection.getInputStream()); } catch (MalformedURLException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } } return null ; } });

大功告成。

欢迎指出代码中的问题~~一起学习进步

注意:注意保护 URL 的 Scheme,在代码中特地过滤了 http 和 https。

引申

上边的 API 中发现还能有更多的玩法,比如:

  • 替换 WebResourceResponse ,构造一个自己的 WebResourceResponse 。比如下列代码,用一个包里的本地文件替换掉要请求的网络图片。

WebResourceResponse response = null; if (url.contains("logo")) { try { InputStream is = getAssets(). open ("test.png"); response = new WebResourceResponse("image/png", "UTF-8", is ); } catch (IOException e) { e.printStackTrace(); } } return response;

  • 在 API 21 (5.0) 以上的版本使用了 WebResourceRequest 接口,这个接口能修改发出请求的 Header

@Override public Map<String, String> getRequestHeaders() { return request.getRequestHeaders(); }

  • 在 API 21 (5.0) 以上的版本中可以区分 GET 请求和 POST 请求,在某些情况下,需要区分 AJAX 的不同种类请求的时候可以用到。

参考资料

tuicool.com/articles/VF

-------------------------------分割线-------------------------------------------------------

别急,好戏才刚刚开始,分析如下:

由此可见,当webview页面有资源请求的时候通知宿主应用,允许应用自己返回数据给webview。如果返回值是null,就正常加载返回的数据,否则就加载应用自己return的response给webview。注意,这个方法回调在子线程而不是UI线程所以在操作私有数据或者view视图的时候要小心。

通常这个方法是用来监控所有的页面请求的,可以用它来监控黑名单以防止页面劫持,当怀疑域名被劫持时,可以通过本地http请求代理,然后将结果返回给webview。

因为我们用的是h5微信支付,所以要在app使用微信h5支付调用的时候,回传一个Referer的参数来证明我们是微信支付app端在用。

细节问题要注意一下:

其一:在什么时候拦截处理: wx.tenpay.com/cgi-bin/m ,一定是这个时候。

只有加载到这一步的时候才进行行相关处理。

其二: 对于有些需要进行appH5壳开发的同志来说,这个referer肯定不是固定的 ,每个app都是不固定的,那么,这个肯定要动态获取的,

至于如何获取,就应该直接在加载的URL上面下功夫。

在拦截到URL的时候,通过裁剪网址的方法,对网址进行裁切和修剪。referer是微信需要的参数,详情可见微信开发者文档。

上传参数的方法可以用get,也可以用post表单提交。基础的都做完了,这些方法都是成功的,

在打印log的时候是成功上传参数了的,微信支付的成功html页的地址也在log日志中有显示

正常版本日志如下:

2019-09-24 10:36:17.231 18061-18061/com.wholefarm D/EgretLoader: EgretLoader(Context context)

2019-09-24 10:36:17.232 18061-18061/com.wholefarm D/EgretLoader: The context is not activity

2019-09-24 10:36:17.251 18061-18258/com.wholefarm I/HomeActivity: shouldInterceptRequest1: https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=xxxxxxc8893c71944551600&package=164xxxxxx6&redirect_url=https://www.ixxxxxx.com/004_mobile_web_site_xxxxxxou/4024_pay_success.html?id=3xxxxxx

2019-09-24 10:36:17.251 18061-18258/com.wholefarm I/HomeActivity: shouldInterceptRequest2: www.xxxxxx.com

2019-09-24 10:36:17.261 18061-18061/com.wholefarm I/HomeActivity: --------------------------onPageStarted--------------------------------

2019-09-24 10:36:17.489 18061-18258/com.wholefarm I/HomeActivity: shouldInterceptRequest3: Request{method=GET, url= wx.tenpay.com/cgi-bin/m , tags={}}

2019-09-24 10:36:17.573 18061-18061/com.wholefarm I/Timeline: Timeline: Activity_launch_request time:39353376 intent:Intent { act=android.intent.action.VIEW dat=weixin://wap/pay?prepayid=wx2410361890029759cc88xxxxxx51600&package=164xxxx766&noncestr=15xxxxxxx9&sign=6d55xxxxxxxxxxxafed94c }

2019-09-24 10:36:17.584 18061-18061/com.wholefarm I/HomeActivity: --------------------onPageFinished:------------------

错误版本日志如下:

2019-09-24 10:39:46.012 4370-4532/com.wholefarm I/HomeActivity: shouldInterceptRequest1: https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=xxxxxxc8893c71944551600&package=164xxxxxx6&redirect_url=https://www.ixxxxxx.com/004_mobile_web_site_xxxxxxou/4024_pay_success.html?id=3xxxxxx

2019-09-24 10:39:46.012 4370-4532/com.wholefarm I/HomeActivity: shouldInterceptRequest2: www.ixxxxxxh.com

2019-09-24 10:39:46.536 4370-4532/com.wholefarm W/com.wholefarm: Accessing hidden method Lcom/android/org/conscrypt/OpenSSLSocketImpl;->setAlpnProtocols([B)V (light greylist, reflection)

2019-09-24 10:39:47.030 4370-4532/com.wholefarm W/com.wholefarm: Accessing hidden method Lcom/android/org/conscrypt/OpenSSLSocketImpl;->getAlpnSelectedProtocol()[B (light greylist, reflection)

2019-09-24 10:39:47.434 4370-4532/com.wholefarm I/HomeActivity: shouldInterceptRequest3: Request{method=GET, url= https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=xxxxxxc8893c71944551600&package=164xxxxxx6&redirect_url=https://www.ixxxxxx.com/004_mobile_web_site_xxxxxxou/4024_pay_success.html?id=3xxxxxx

后来把get方法改成了post方法,也是没什么效果

https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_4

[4]

我们到了的这一步已经成功获取到URL,所以问题出在第三步到第5 步,为何在正常系统版本下能够正常支付,

而在国际版系统不能支付,是不是安全证书的问题,于是,我在安全证书方法中,是忽略了所有不安全。

还有异常捕获操作如下:


但是日志和实际结果表明,这个证书问题并没有爆出异常错误,所以不上日志了。并没有报错。

微信h5支付文档中,对所有异常情况作出了说明,

以下是长图:

常见错误 [5]

序号问题错误描述解决方法 1

网络环境未能通过安全验证,请稍后再试1. 商户侧统一下单传的终端IP(spbill_create_ip)与用户实际调起支付时微信侧检测到的终端IP不一致导致的,这个问题一般是商户在统一下单时没有传递正确的终端IP到spbill_create_ip导致,详细可参见 客户端ip获取指引 2. 统一下单与调起支付时的网络有变动,如统一下单时是WIFI网络,下单成功后切换成4G网络再调起支付,这样可能会引发我们的正常拦截,请保持网络环境一致的情况下重新发起支付流程 2

商家参数格式有误,请联系商家解决1. 当前调起H5支付的referer为空导致,一般是因为直接访问页面调起H5支付,请按正常流程进行页面跳转后发起支付,或自行抓包确认referer值是否为空 2. 如果是APP里调起H5支付,需要在webview中手动设置referer,如( Map extraHeaders = new HashMap(); extraHeaders.put("Referer", "商户申请H5时提交的授权域名");//例如 baidu.com )) 3

商家存在未配置的参数,请联系商家解决1,当前调起H5支付的域名(微信侧从referer中获取)与申请H5支付时提交的授权域名不一致,如需添加或修改授权域名,请登陆商户号对应的商户平台--"产品中心"--"开发配置"自行配置 2,如果设置了回跳地址redirect_url,请确认设置的回跳地址的域名与申请H5支付时提交的授权域名是否一致 4

支付请求已失效,请重新发起支付统一下单返回的MWEB_URL生成后,有效期为5分钟,如超时请重新生成MWEB_URL后再发起支付 6

请在微信外打开订单,进行支付H5支付不能直接在微信客户端内调起,请在外部浏览器调起 7

IOS:签名验证失败 安卓:系统繁忙,请稍后再试1,请确认同一个MWEB_URL只被一个微信号调起,如果不同微信号调起请重新下单生成新的MWEB_URL 2,如MWEB_URL有添加redirect_url,请确认参数拼接格式是否有误,是否有对redirect_url的值做urlencode,可对比以下例子格式: wx.tenpay.com/cgi-bin/m


但是成功拦截URL,成功替换URL头,没有报错,就是白屏,未跳转,为何。

微信开发者社区 [6] 提问:


==微信官方选择漠视。没办法。


后来由一位经常刷机的玩机大咖的同事指点,找到了一个中和此bug的方法,

但不是长久之计,以后还是需要通过程序代码的方式对系统进行兼容

造成原因:

后来发现手机开发者模式中有一个webview实现。

正常系统的话使用的Android System Webview选项,但是国际版系统的话,在Chrome浏览器安装了之后,

系统会默认使用Chrome最新版本的内核来实现webview。

Android国际版操作系统,默认的WebView实现方式是Chrome,此时使用H5调起微信支付会出现白屏情况。

解决问题方法:

将WebView实现方式切换至Android System WebView后,使用H5可以成功调起微信支付。

切换方式如下:

1. 点击Android系统设置图标

2. 进入开发者选项

3. 选择WebView实现,切换至Android System WebView即可。

注:国际版系统一般内置谷歌服务套件及Chrome浏览器,此时WebView实现不可选择。若需要切换,则必须要先停用Chrome浏览器,停用后,系统会默认使用Android System WebView。


把这个关了之后,白屏现在不再出现,至于解决方案,暂时只能选择关闭Chrome功能,造成了极大的不便,

暂时没有找到其他通过代码实现解决问题的方案,希望有同行者多行讨论,共同进步,本文将持续更新,

谢谢大家过目。

相关解决方案正在进一步研究之中,对这个有建议,意见和看法的,欢迎关注留言。

联系我微信1500227467,请注明来源。


欢迎关注 技术团队的知乎账号 我们凭团队实例运作以下专栏, 必须干货!

互联网创业专栏 (我们小伙伴的创业历程)

与您一起聊技术 (APP、微信公众号、小程序、H5 技术总结)

互联网产品研发管理 (我们公司对产品结构的管理思路)


我们是不一样的技术团队:

(我们认为:所有的企业行为,都解读为交易行为,无论是摩拜单车、外卖平台、自动售货机、招聘社区、家政服务,都用交易的语言来表达,我们专栏里面有 很多实际案例 和开发过程和交付流程)

(类似于元素周期表,我们把交易拆解成元素级别,根据业务定制组装,完全复原个性化需求, 我们专栏里面有很学术也很实际的介绍 )

(每个项目设置: 导师成长基金、参与人员的奖励,全员股权池,创业氛围浓郁,我们专栏公开 分享了我们的一些经验 )

(专治各种复杂的业务场景, 我们通过简洁的元素和分层组合,来完成复杂场景的业务定制,我们在这一块有非常多的案例,在 互联网创业专栏 里面有详细描述)

参考

  1. ^ Android WebView、Webkit内核深入讲解 https://wenku.baidu.com/view/8510b018b52acfc789ebc9ea.html
  2. ^ 记一次webview 中使用shouldInterceptRequest的踩坑 https://blog.csdn.net/y1962475006/article/details/84895173
  3. ^ Android中WebView中拦截所有请求并替换URL https://blog.csdn.net/cnzx219/article/details/46574073?utm_source=blogxgwz6
  4. ^ 微信支付根据应用场景选择实现模式 https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_4
  5. ^ H5微信支付常见问题 https://pay.weixin.qq.com/wiki/doc/api/H5.php?chapter=15_4
  6. ^ 微信开发者社区 https://developers.weixin.qq.com/community/pay
编辑于 2019-12-22 23:41

文章被以下专栏收录