整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

WebView的坑别嫌多

WebView的坑别嫌多

个蛋(codeegg)第 723 次推文

作者: 骑着蜗牛闯红灯

原文: https://juejin.im/post/5cff8c27f265da1bae38f1c1

先简单介绍一下,Android在4.4之后采用了Chrome内核,所以我们在开发web页面的时候,es6的语法,css3的样式等大可放心使用。

我将分下面几个模块去介绍Android上面WebView。

WebView自身的一些方法


//方式1. 加载一个网页:

webView.loadUrl("http://www.google.com/");

//方式2:加载apk包中的html页面
webView.loadUrl("file:///android_asset/test.html");

//方式3:加载手机本地的html页面
webView.loadUrl("content://com.android.htmlfileprovider/sdcard/test.html");



正常情况下,在WebView界面,用户点击返回键是直接退出该页面的,着当然不是我们想要的,我们想要的是网页自己的前进和后退,所以下面介绍网页前进和后退的一些API

//判断是否可以后退
Webview.canGoBack
//后退网页
Webview.goBack

//判断是否可以前进
Webview.canGoForward
//前进网页
Webview.goForward

// 参数传负的话表示后退,传正值的话表示的是前进
Webview.goBackOrForward(int steps)
对返回键的监听,来实现网页的后退
public boolean onKeyDown(int keyCode, KeyEvent event) {
if ((keyCode==KEYCODE_BACK) && mWebView.canGoBack) {
mWebView.goBack;
return true;
}
return super.onKeyDown(keyCode, event);
}

如何防止WebView内存泄漏

防止内存泄漏的一个原则就是:生命周期长的不要跟生命周期短的玩。

为了防止WebView不造成内存泄漏,

  • 不要在xml里面定义WebView,而是在Activity选中使用代码去构建,并且Context使用ApplicationContext

  • 在Activity销毁的时候,先让WebView加载空内容,然后重rootView中移除WebView,再销毁WebView,最后置空

override fun onDestroy {
if (webView !=) {
webView!!.loadDataWithBaseURL(, "", "text/html", "utf-8", )
webView!!.clearHistory
(webView!!.parent as ViewGroup).removeView(webView)
webView!!.destroy
webView=
}
super.onDestroy

}

WebSetting和WebViewClient,WebChromeClien

  • WebSetting


作用:对WebView进行配置和管理

WebSettings webSettings=webView.getSettings;
// 设置可以与js交互,为了防止资源浪费,我们可以在Activity
// 的onResume中设置为true,在onStop中设置为false
webSettings.setJavaScriptEnabled(true);

//设置自适应屏幕,两者合用
//将图片调整到适合webview的大小
webSettings.setUseWideViewPort(true);
// 缩放至屏幕的大小
webSettings.setLoadWithOverviewMode(true);

//设置编码格式
webSettings.setDefaultTextEncodingName("utf-8");

// 设置允许JS弹窗
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);

//设置缓存的模式
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);

关于缓存的设置

当加载 html 页面时,WebView会在/data/data/包名目录下生成 database 与 cache 两个文件夹,请求的 URL记录保存在 WebViewCache.db,而 URL的内容是保存在 WebViewCache 文件夹下

缓存模式如下:

//LOAD_CACHE_ONLY: 不使用网络,只读取本地缓存数据
//LOAD_DEFAULT: (默认)根据cache-control决定是否从网络上取据。
//LOAD_NO_CACHE: 不使用缓存,只从网络获取数据.
//LOAD_CACHE_ELSE_NETWORK,只要本地有,无论是否过期,或no-cache,都使用缓存中的数据。

离线加载

if (NetStatusUtil.isConnected(getApplicationContext)) {
webSettings.setCacheMode(WebSettings.LOAD_DEFAULT);//根据cache-control决定是否从网络上取数据。
} else {
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);//没网,则从本地获取,即离线加载
}

webSettings.setDomStorageEnabled(true); // 开启 DOM storage API 功能
webSettings.setDatabaseEnabled(true); //开启 database storage API 功能
webSettings.setAppCacheEnabled(true);//开启 Application Caches 功能

String cacheDirPath=getFilesDir.getAbsolutePath + APP_CACAHE_DIRNAME;
webSettings.setAppCachePath(cacheDirPath); //设置 Application Caches 缓存目录
  • WebViewClient 作用

  • 处理各种通知,请求事件,主要有,网页开始加载,记载结束,加载错误(如404),处理https请求,具体使用请看下面代码,注释清晰。


webView!!.webViewClient=object : WebViewClient {
// 启用WebView,而不是系统自带的浏览器
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
view.loadUrl(url)
return true
}

// 页面开始加载,我们可以在这里设置loading
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
tv_start.text="开始加载了..."
}
// 页面加载结束,关闭loading
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
tv_end.text="加载结束了..."
}

// 只要加载html,js,css的资源,每次都会回调到这里
override fun onLoadResource(view: WebView?, url: String?) {
loge("onLoadResource invoked")
}

// 在这里我们可以加载我们自己的404页面
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
loge("加载错误:${error.toString}")
}

// webview默认设计是不开启https的,下面的设置是允许使用https
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.proceed
}

// js调用Android的方法,在这里可以,该方法不存在通过注解的方式的内存泄漏,但是想拿到Android的返回值的话很难,
// 可以通过Android调用js的代码的形式来传递返回值,例如下面的方式
// Android:MainActivity.java
// mWebView.loadUrl("javascript:returnResult(" + result + ")");
// JS:javascript.html
// function returnResult(result){
// alert("result is" + result);
// }

override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val uri=Uri.parse(request?.url.toString)
// 一般根据scheme(协议格式) & authority(协议名)判断(前两个参数)
//假定传入进来的 url="js://webview?arg1=111&arg2=222"(同时也是约定好的需要拦截的)
if (uri.scheme=="js") {
if (uri.authority=="webview") {
toast_custom("js调用了Android的方法")
val queryParameterNames=uri.queryParameterNames
queryParameterNames.forEach {
loge(it + ":" + uri.getQueryParameter(it))
}
}
return true
}
return super.shouldOverrideUrlLoading(view, request)
}

// 拦截资源 通常用于h5的首页页面,将常用的一些资源,放到本地
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if(request?.url.toString.contains("logo.gif")){
var inputStream: InputStream?=
inputStream=applicationContext.assets.open("images/test.png")
return WebResourceResponse("image/png","utf-8", inputStream)
}
return super.shouldInterceptRequest(view, request)
}
}

注意:

5.1 以上默认禁止了https和http的混用下面的设置是开启:

if (Build.VERSION.SDK_INT >=Build.VERSION_CODES.LOLLIPOP) {
webView.getSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}
  • WebChromeClient 作用

    辅助webview的一下回调方法,可以得到网页加载的进度,网页的标题,网页的icon,js的一些弹框,直接看代码,注释清晰。


webView!!.webChromeClient=object : WebChromeClient {

// 网页加载的进度
override fun onProgressChanged(view: WebView?, newProgress: Int) {
tv_progress.text="$newProgress%"
}

// 获得网页的标题
override fun onReceivedTitle(view: WebView?, title: String?) {
tv_title.text=title
}

//js Alert
override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {

AlertDialog.Builder(this@WebActivity)
.setTitle("JsAlert")
.setMessage(message)
.setPositiveButton("OK") { _, _ -> result?.confirm }
.setCancelable(false)
.show
return true
}

// js Confirm
override fun onJsConfirm(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {
return super.onJsConfirm(view, url, message, result)
}

//js Prompt
override fun onJsPrompt(
view: WebView?,
url: String?,
message: String?,
defaultValue: String?,
result: JsPromptResult?
): Boolean {
return super.onJsPrompt(view, url, message, defaultValue, result)
}

}

Android和js的交互

Android调用js

1. 通过webview的loadUrl

注意:该方式必须在webview加载完毕之后才能调用,也就是webviewClient的onPageFinished方法回调之后,而且该方法的执行 会刷新界面,效率较低

js代码:
function callJs{
alert("Android 调用了 js代码)
}
kotlin代码:
webView?.loadUrl("javascript:callJs")

2. 通过webview的evaluateJavaScript

比起第一种方法,效率更高,但是要在4.4之后才能使用


js代码:

function callJs{
// alert("Android 调用了 js代码)
return {name:'wfq',age:25}
}

kotlin代码:
webView?.evaluateJavascript("javascript:callJs") {
// 这里直接拿到的是js代码的返回值
toast(it) // {name:'wfq',age:25}
}

js调用Android

1. 通过webview的addJavaScriptInterface进行对象映射

我们可以单独定义一个类,所有需要交互的方法可以全部写在这个类里面,当然也可以直接写在Activity里面,下面以直接定义在Activity里面为例,优点:使用方便,缺点:存在漏洞(4.2之前),请看下面的“WebView的一些漏洞以及如何防止”

kotlin中定义被js调用的方法
@JavascriptInterface
fun hello(name: String) {
toast("你好,我是来自js的消息:$msg")
}
js代码
function callAndroid{
android.hello("我是js的,我来调用你了")
}
kotlin中们在webview里面设置Android与js的代码的映射
webView?.addJavascriptInterface(this, "android")

2. 通过webviewClient的shouldOverrideUrlLoading的回调来拦截url

具体使用:解析该url的协议,如果监测到是预先约定好的协议,那么就调用相应的方法。比较安全,但是使用麻烦,js获取Android的返回值的话很麻烦,只能通过上面介绍的通过loadurl去执行js代码把返回值通过参数传递回去

首先在js中约定号协议
function callAndroid{
// 约定的url协议为:js://webview?name=wfq&age=24
document.location="js://webview?name=wfq&age=24"
}

在kotlin里面,当loadurl的时候就会回调到shouldOverrideUrlLoading里面

override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val uri=Uri.parse(request?.url.toString)
// 一般根据scheme(协议格式) & authority(协议名)判断(前两个参数)
//假定传入进来的 js://webview?name=wfq&age=24
if (uri.scheme=="js") {
if (uri.authority=="webview") {
toast_custom("js调用了Android的方法")
val queryParameterNames=uri.queryParameterNames
queryParameterNames.forEach {
loge(it + ":" + uri.getQueryParameter(it))
}
}
return true
}
return super.shouldOverrideUrlLoading(view, request)
}

3.通过webChromeClient的onJsAlert,onJsConfirm,onJsPrompt回调来拦截对话框

通过拦截js对话框,得到他们的消息,然后解析即可,为了安全,建议内容采用上面介绍的url协议, 常用的拦截的话就是拦截prompt,因为它可以返回任意值,alert没有返回值,confirm只能返回两种类型,确定和取消

js代码
function clickprompt{
var result=prompt("wfq://demo?arg1=111&arg2=222");
alert("demo " + result);
}

kotlin代码
override fun onJsPrompt(
view: WebView?,
url: String?,
message: String?,
defaultValue: String?,
result: JsPromptResult?
): Boolean {
val uri=Uri.parse(message)
if (uri.scheme=="wfq") {
if (uri.authority=="demo") {
toast_custom("js调用了Android的方法")
val queryParameterNames=uri.queryParameterNames
queryParameterNames.forEach {
loge(it + ":" + uri.getQueryParameter(it))
}
// 将需要返回的值通过该方式返回
result?.confirm("js调用了Android的方法成功啦啦啦啦啦")
}
return true
}
return super.onJsPrompt(view, url, message, defaultValue, result)
}

由于拦截了弹框,所以js代码的alert需要处理 这里的message便是上面代码的返回值通过alert显示出来的信息
override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {
AlertDialog.Builder(this@WebActivity)
.setTitle("JsAlert")
.setMessage(message)
.setPositiveButton("OK") { _, _ -> result?.confirm }
.setCancelable(false)
.show
return true
}

上面三种方式的区别:

addJavascriptInterface 方便简洁,4.0以下存在漏洞,4.0以上通过@JavascriptInterface注解修复漏洞。

WebViewClient.shouldOverrideUrlLoading回调,不存在漏洞,使用复杂,需要定义协议的约束,但是返回值的话有些麻烦,在不需要返回值的情况下可以使用这个方式。

通过WebChromeClient的onJsAlerta,onJsConfirm,onJsPrompt,不存在漏洞问题,使用复杂,需要进行协议的约束,可以返回值,能满足大多数情况下的互调通信。

WebView的一些漏洞以及如何防止

密码明文存储漏洞

webview默认开启了密码保存功能,在用户输入密码后会弹出提示框询问用户是否保存密码,保存后密码会被明文保存在 /data/data/com.package.name/databases/webview.db 下面,手机root后可以查看,那么如何解决?

WebSettings.setSavePassword(false) // 关闭密码保存提醒功能

WebView 任意代码执行漏洞

addJavascriptInterface漏洞,首先先明白一点,js调用Android代码的时候,我们经常使用的是addJavascriptInterface, JS调用Android的其中一个方式是通过addJavascriptInterface接口进行对象映射,那么Android4.2之前,既然拿到了这个对象,那么这个对象中的所有方法都是可以调用的,4.2之后,需要被js调用的函数加上@JavascriptInterface注解后来避免该漏洞

所以怎么解决

对于Android 4.2以前,需要采用拦截prompt 方式进行漏洞修复

对于Android 4.2以后,则只需要对被调用的函数以 @JavascriptInterface进行注解

域控制不严格漏洞

  • 原因分析 当我们在Applilcation里面,android:exported="true"的时候,A 应用可以通过 B 应用导出的 Activity 让 B 应用加载一个恶意的 file 协议的 url,从而可以获取 B 应用的内部私有文件,从而带来数据泄露威胁,

下面来看下WebView中getSettings类的方法对 WebView 安全性的影响 setAllowFileAccess

// 设置是否允许 WebView 使用 File 协议

// 默认设置为true,即允许在 File 域下执行任意 JavaScript 代码

webView.getSettings.setAllowFileAccess(true);
如果设置为false的话,便不会存在威胁,但是,webview也无法使用本地的html文件

setAllowFileAccessFromFileURLs

// 设置是否允许通过 file url 加载的 Js代码读取其他的本地文件
// 在Android 4.1前默认允许
// 在Android 4.1后默认禁止
webView.getSettings.setAllowFileAccessFromFileURLs(true);

我们应该明确的设置为false,禁止读取其他文件

setAllowUniversalAccessFromFileURLs

// 设置是否允许通过 file url 加载的 Javascript 可以访问其他的源(包括http、https等源)
// 在Android 4.1前默认允许(setAllowFileAccessFromFileURLs不起作用)
// 在Android 4.1后默认禁止
webView.getSettings.setAllowUniversalAccessFromFileURLs(true);

WebView预加载以及资源预加载

为什么需要预加载

h5页面加载慢,慢的原因:页面渲染慢,资源加载慢

如何优化?

h5的缓存,资源预加载,资源拦截

h5的缓存 Android WebView自带的缓存

1. 浏览器缓存

根据 HTTP 协议头里的 Cache-Control(或 Expires)和 Last-Modified(或Etag)

等字段来控制文件缓存的机制浏览器自己实现,我需我们处理


2. App Cache


方便构建Web App的缓存,存储静态文件(如JS、CSS、字体文件)


WebSettings settings=getSettings;
String cacheDirPath=context.getFilesDir.getAbsolutePath+"cache/";
settings.setAppCachePath(cacheDirPath);
settings.setAppCacheMaxSize(20*1024*1024);
settings.setAppCacheEnabled(true);

3. Dom Storage

WebSettings settings=getSettings;
settings.setDomStorageEnabled(true);

4. Indexed Database

// 只需设置支持JS就自动打开IndexedDB存储机制
// Android 在4.4开始加入对 IndexedDB 的支持,只需打开允许 JS 执行的开关就好了。
WebSettings settings=getSettings;
settings.setJavaScriptEnabled(true);
  • 资源预加载 预加载webview对象,首次初始化WebView会比第二次慢很多的原因:初始化后,即使webview已经释放,但是WebView的一些共享的对象依然是存在的,我们可以在Application里面提前初始化一个Webview的对象,然后可以直接loadurl加载资源

  • 资源拦截 可以将跟新频率低的一些资源静态文件放在本地,拦截h5的资源网络请求并进行检测,如果检测到,就直接拿本地的资源进行替换即可


// 拦截资源 通常用于h5的首页页面,将常用的一些资源,放到本地
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {

if(request?.url.toString.contains("logo.jpg")){
var inputStream: InputStream?=
inputStream=applicationContext.assets.open("images/test.jpg")
return WebResourceResponse("image/png","utf-8", inputStream)
}

return super.shouldInterceptRequest(view, request)
}

常见的使用注意事项

1. Android9.0 已经禁止了webview使用http,怎么解决?

在manifest Application标签下面使用:

android:usesCleartextTraffic="true"

2. 开启混淆之后,Android无法与h5交互?

#保留annotation, 例如 @JavascriptInterface 等 annotation
-keepattributes *Annotation*

#保留跟 javascript相关的属性
-keepattributes JavascriptInterface

#保留JavascriptInterface中的方法
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
#这个类是用来与js交互,所以这个类中的 字段 ,方法, 不能被混淆、全路径名称.类名
-keepclassmembers public class com.youpackgename.xxx.H5CallBackAndroid{
<fields>;
<methods>;
public *;
private *;
}

3. 如何调试?

3.1 在WebViewActivity里面,开启调试

// 开启调试
WebView.setWebContentsDebuggingEnabled(true)

3.2 chrome浏览器地址栏输入 chrome://inspect

3.3 手机打开USB调试,打开webview页面,点击chrome页面的最下面的inspect,这样,便可以进入了web开发,看控制台,网络请求等

日问题:

看到WebView是不是很头疼?

系 大淘宝技术 2024-06-28 17:25 浙江




从零基础接手DX扩展开发维护,到完成DX扩展从O2平台迁移到 VSCode 平台,现在也积累了一些经验,本文将对这一过程中的学习经历做一个简单小结,也希望可以通过本文帮助想要开发 VSCode 扩展的同学可以更快速的上手。

VSCode (Visual Studio Code) 是微软开发的一款免费、开源的代码编辑器。它基于 Electron 框架构建,提供了丰富的开发者工具,支持多种编程语言,可以进行代码调试、版本控制、智能提示等功能,是很多开发者日常使用的工具。


Electron


理解 vscode,我们首先要谈的是 Electron。


Electron 的核心技术主要包括以下几个方面:

  • Chromium: Electron 使用了 Chromium 浏览器作为其渲染引擎。Chromium是 Google Chrome 的开源版本,负责处理和渲染应用程序的用户界面,包括 HTML、CSS 和 JavaScript。这使得开发者可以利用Web开发技术来构建应用的界面。
  • Node.js: Electron 集成了 Node.js,使得开发者可以在应用程序的主进程(后台)中运行 JavaScript 代码。Node.js 提供了对文件系统、网络、进程等系统级 API 的访问,增强了应用程序的功能和交互性。
  • Native API: Electron 提供了一套 API,允许主进程和渲染进程之间进行通信,以及调用操作系统级别的功能。这些 API 包括 ipcRenderer 和 ipcMain(用于进程间通信)、webContents(用于控制页面内容)等。



Electron 还有一个很大特点就是多进程。主要的有以下两个进程:

  • 主进程
    • Electron 中运行 package.json 中的 main 脚本的进程被称为主进程,即 main.js 就是运行在主进程。
    • 一个 electron 应用有且只有一个主进程。
    • 只有主进程可以直接进行 GUI 相关的原生 API 操作。


  • 渲染进程
    • 运行在 Chromium 的 web 页面姑且叫渲染进程,即运行 index.html 的环境就是渲染进程。
    • 一个 electron 应用可以有多个渲染进程。
    • 渲染进程在引入 Node.js 模块的前提下,可以在页面中和操作系统进行一些底层交互(如 fs 模块)。


综上来看:在 Electron 应用中,web 页面可以通过渲染进程将消息转发到主进程中,进而调用操作系统的 native api。相比普通 web 应用,可开发扩展的能力更加灵活、丰富。


了解了 vscode 的底层设计,下面我们就以真实的需求(创建模板)来一步步探索 vscode 扩展开发。



需求分析


在 vscode 活动栏提供视图容器,透出创建模板入口,点击后打开可视化界面,进行简单配置后完成模板创建(注册模板信息到模板平台并生成对应的模板文件)。


要实现以上功能,需要先提炼出几个和 vscode 相关功能:

  • 通过 vscode 指令系统,注册一个命令到菜单栏。
  • 创建一个用于配置的 web 页面。
  • 完成配置后上传配置信息并创建文件。
  • 完成配置后关闭 web 页面。


逻辑实现


?注册指令



初始化一个插件项目后,暴露在最外面的文件中包含 activate deactvate 两个方法,这俩方法属于 vscode 插件的生命周期,最终会被 export 出去给 vscode 主动调用。而 onXXX 等事件是声明在插件 package.json 文件中的 Activation Events。声明这些 Activation Events 后,vscode 就会在适当的时机回调插件中的 activate函数。vscode 之所以这么设计,是为了节省资源开销,只在必要的时候才激活你的插件。


  // package.json
  "activationEvents": [
    "onCommand:dinamicx.createTemplate",
    ...
  ],
 "commands": [
      {
        "command": "dinamicx.createTemplate",
        "title": "DX: 创建模板"
      },
      ...
  ],
    "menus": {
      "view/title": [
        {
          "command": "dinamicx.createTemplate",
          "group": "navigation@0",
          "when": "view==dinamicx.views.main"
        }
        ...
      ]
    }


也可以在插件激活时注册命令:


import { createTemplate } from './commands/createTemplate';


export function activate(context: vscode.ExtensionContext) {
  // 注册命令
  vscode.commands.registerCommand('dinamicx.createTemplate', (info: any)=> {
    createTemplate(context, info.path);
  })
    ...
}


上面这段代码的含义是将dinamicx.createTemplate这个命令和函数绑定,具体的逻辑部分应该在createTemplate这个方法中实现。


?创建WebView


如果要创建一个页面,可以使用 vscode 提供的

api——vscode.window.createWebviewPanel:


export function createTemplate(
  context: vscode.ExtensionContext,
  dirPath: string,
) {
  const panel=vscode.window.createWebviewPanel(
    'createTemplate', // viewType
    '创建模板页面', // 视图标题
    vscode.ViewColumn.One, // 显示在编辑器的哪个部位
    // 启用JS,默认禁用 // webview被隐藏时保持状态,避免被重置
    { enableScripts: true, retainContextWhenHidden: true },
  );
  ...
  const htmlContent=this.getHtmlContent(panel.webview, htmlPath);
  panel.webview.html=htmlContent;
  panel.reveal();
  return panel;
}


具体渲染的页面可以通过 html 属性指定,但是 html 属性接收的参数是字符串!那么我们无法使用 vue/react 进行编码,只能写模板字符串了吗?


当然不是!我们可以先编写 react 代码,再打包成 js,套在 index.html 模板中 return 出来,问题就迎刃而解。处理这件事情的就是getHtmlContent:


function getHtmlContent(webview, htmlPath) {
    /*
    各种资源的绝对路径
    const getHTMLDependencies=()=> (`
    <!-- Dependencies -->
    <script src="${highlightJs}"></script>
    <script src="${reactJs}"></script>
    <script src="${reactDomJs}"></script>
    <script src="${antdJs}"></script>
  `);
   */
    const { getHTMLLinks, getHTMLDependencies }=useWebviewBasic(context);
    return `
  <!DOCTYPE html>
  <html>
      <head>
          <meta charset="UTF-8" />
          ${getHTMLLinks()}
      </head>
      <style>
        body {
          background-color: transparent !important;
        }
</style>
      <body>
          <div id="root"></div>
          ${getHTMLDependencies()}
          <!-- Main -->
          <script src="vscode-resource:${htmlPath}"></script>
          #{endOfBody}
      </body>
  </html>
  `;
}


vscode-resource: 出于安全考虑,Webview 默认无法直接访问本地资源,它在一个孤立的上下文中运行。它只允许通过绝对路径访问特定的本地文件。


由上面的代码可见,针对一个命令/函数,如果涉及到 webview,只关注渲染代码(即 SPA 的 js 文件),不关心具体页面实现,所以可以将编写 UI 相关的逻辑,提炼到 node 主进程之外。


?React 和 Webpack


对于 vscode 插件来讲,UI 是独立的,所以我们可以像创建 react 项目一样来完成页面部分的代码。


const Template: React.FC=()=> {
  const [loading, setLoading]=useState(false);
  ...
  return (
    <Spin spinning={loading} tip={loadingText}>
      <div className="template">
           ...
      </div>
    </Spin>
  );
};


ReactDOM.render(<Template />, document.getElementById('root'));


在打包方面,刚才提到了我们要根据不同命令加载不同的页面组件,即不同的 js,所以打包的 entry 是多入口的;为了不重复引入公共库,将 react、antd 等库 external,选择通过 cdn 的方式引入。


const config={
    mode: env.production ? 'production' : 'development',
    entry: {
      template: createPageEntry('page-template'),
      layout: createPageEntry('page-layout'),
      view: createPageEntry('view-idl'),
      ...
    },
    output: {
      filename: '[name].js',
      path: path.resolve(__dirname, '../dist/webview'),
    },
    ...
    externals: {
        'react': 'root React',
        'react-dom': 'root ReactDOM',
        'antd': 'antd',
    },
  };


?进程通信


当我们实现 Webview 后,下一步是拉取数据,然后渲染到本地项目对应的路径中,可见这一步需要操作系统 api 的支持,我们需要使用 node 进程来做这件事。


那么问题来了,UI 是通过 html 字符串传给 vscode 进程的,他们之间是如何通信的呢。


开发 vscode 扩展最核心(恶心)的事情就是通信,单向的数据流导致不仅是 webview 和插件 node 进程通信复杂,即使在同一个 react 项目中的两个不同页面(webview)也是不能直接进行数据交互的。


流程如图:


vscode 在通信这里,只为我们提供了最简单粗糙的通信方法 —— acquirevscodeApi,这个对象里面有且仅有以下几个可以和插件通信的 API。


插件发送消息:

panel.webview.postMessage; // 支持发送任意被JSON化的数据


WebView 接收消息:

window.addEventListener('message', (event)=> {
    const message=event.data;
    console.log(message);
});


WebView 给插件发消息:

export const vscode=acquirevscodeApi();
vscode.postMessage('xxx');


插件接收消息:

panel.webview.onDidReceiveMessage(
    (message)=> {
        console.log('插件收到的消息:', message);
    },
    undefined,
    context.subscriptions
);


通信封装


基于以上的进程通信方式,如果所有通信逻辑都通过 message 事件监听,那怎么知道某一处该接收哪些消息,该如何发送一个具有唯一标识的消息?


vscode 本身没有提供类似的功能,不过可以自己封装。

流程如图:


Webview端:

export abstract class App<> {
    //    private readonly _api: vscodeApi;


    // 单向通信
    protected sendCommand<TCommand extends IpcCommandType<any>>(
        command: TCommand,
        params: IpcMessageParams<TCommand>
    ): void {
        const id=nextIpcId();
        this.postMessage({ id: id, method: command.method, params: params });
    }


    // 双向通信
    protected async sendCommandWithCompletion<
        TCommand extends IpcCommandType<any>,
        TCompletion extends IpcNotificationType<any>
    >(
        command: TCommand,
        params: IpcMessageParams<TCommand>,
        completion: TCompletion
    ): Promise<IpcMessageParams<TCompletion>> {
        const id=nextIpcId();


        const promise=new Promise<IpcMessageParams<TCompletion>>(
            (resolve, reject)=> {
                let timeout: ReturnType<typeof setTimeout> | undefined;


                const disposables=[
                    DOM.on(window, 'message', (e: MessageEvent<IpcMessage>)=> {
                        onIpc(completion, e.data, (params)=> {
                            if (e.data.completionId===id) {
                                disposables.forEach((d)=> d.dispose());
                                queueMicrotask(()=> resolve(params));
                            }
                        });
                    }),
                    {
                        dispose: function () {
                            if (timeout !=null) {
                                clearTimeout(timeout);
                                timeout=undefined;
                            }
                        },
                    },
                ];


                timeout=setTimeout(()=> {
                    timeout=undefined;
                    disposables.forEach((d)=> d.dispose());
                    debugger;
                    reject(
                        new Error(
                            `Timed out waiting for completion of ${completion.method}`
                        )
                    );
                }, 600000);
            }
        );


        this.postMessage({
            id: id,
            method: command.method,
            params: params,
            completionId: id,
        });
        return promise;
    }


    private postMessage(e: IpcMessage) {
        this._api.postMessage(e);
    }
}


Node端:

parent.webview.onDidReceiveMessage(this.onMessageReceivedCore, this),


onMessageReceivedCore(e: IpcMessage) {
        if (e==null) return;


        switch (e.method) {
            case ExecuteCommandType.method:
                onIpc(ExecuteCommandType, e, params=> {
                    if (params.args !=null) {
                        void executeCommand(params.command as Commands, ...params.args);
                    } else {
                        void executeCommand(params.command as Commands);
                    }
                });
                break;
            default:
                this.provider.onMessageReceived?.(e);
                break;
        }
    }


// commands.ts
import { commands } from 'vscode';


export function executeCommand<U=any>(command: Commands): Thenable<U>;
export function executeCommand<T=unknown, U=any>(command: Commands, arg: T): Thenable<U>;
export function executeCommand<T extends [...unknown[]]=[], U=any>(command: Commands, ...args: T): Thenable<U>;
export function executeCommand<T extends [...unknown[]]=[], U=any>(command: Commands, ...args: T): Thenable<U> {
    return commands.executeCommand<U>(command, ...args);
}


需求实现


基于以上,视图层、逻辑层、通信层的框架就大致完成了,接下来就是基于需求本身实现视图(react)和逻辑(node)的实现了。


希望此文能帮助大家快速对 vscode 插件开发有一定了解。后续会再介绍基于 vscode 的 DX 插件和使用建议、以及提高 vscode 开发效率的配置分享~


参考资料


  • Introduction | Electron (electronjs.org):
  • https://www.electronjs.org/docs/latest/?spm=ata.21736010.0.0.317e4797PUtlD0
  • Webview API | Visual Studio Code Extension API:
  • https://code.visualstudio.com/api/extension-guides/webview?spm=ata.21736010.0.0.317e4797PUtlD0


团队介绍


我们是淘天集团 - 终端体验平台团队,立足于淘宝体验平台及集团移动中台定位,致力于无线端到端前沿技术探索,深入终端厂商到原生系统技术挖掘,打造集团先进且行业领先的终端基础设施及配套服务,涵盖多端性能体验、终端技术服务、原生技术研发、用户增长触达等关键领域的工作,为阿里巴巴数百款活跃App提供研发与性能支撑,即是集团终端技术生态的基石团队之一,也是淘天双11核心支撑团队之一!

ebview加载H5优化小记

行到水穷处,坐看云起时

原文链接

一、概述

1、背景

  • 鉴于H5的优势,客户端的很多业务都由H5来实现,Webview成了App中H5业务的唯一载体。
  • WebView组件是iOS组件体系中非常重要的一个,之前的UIWebView 存在严重的性能和内存消耗问题,iOS 8之后推出WKWebView,旨在代替UIWebView;
  • WKWebView在性能、稳定性、内存占用上有很大的提升,支持更多的HTML5特性,高达60fps的滚动刷新率以及内置手势;可以通过KVO监控网络加载的进度,获取网页title;
  • 实践中,大部分App的H5业务将由WKWebview承载。

2、H5页面的体验问题

从用户角度,相比Native页面,H5页面的体验问题主要有两点:

  • 页面打开时间慢:打开一个 H5 页面需要做一系列处理,会有一段白屏时间,体验糟糕。
  • 响应流畅度较差:由于 WebKit 的渲染机制,单线程,历史包袱等原因,页面刷新/交互的性能体验不如原生。

这里讨论的是:第一点,怎样减少白屏时间。

二、Webview打开H5

通过Webview打开H5页面,请求并得到 HTML、CSS 和 JavaScript 等资源并对其进行处理从而渲染出 Web 页面。

1、加载流程

  • 初始化Webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 ->DOM 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片-> 页面完整展示

  • DOM渲染之前耗时主要在两部分:初始化Webview数据请求,一般Webview首次初始化在400ms这个量级,二次加载能少一个量级。
  • 数据请求依赖网络,网络请求一般经过:DNS查询、TCP 连接、HTTP 请求和响应。数据包括HTML、JS和CSS资源,这些都是在webview在loadRequest:之后做的,这一阶段,用户所见到的都是白屏。(虽然4G已经成为主流,但是4G延迟明显高于Wifi)。

2、H5页面渲染

对H5页面的渲染,主要包括:渲染树构建、布局及绘制,具体可分为:

  • 处理 HTML 标记并构建 DOM 树。
  • 处理 CSS 标记并构建 CSSOM(CSS Object Model) 树。
  • 将 DOM 与 CSSOM 合并成一个渲染树。
  • 根据渲染树来布局,以计算每个节点的几何信息。
  • 将各个节点绘制到屏幕上。

说明:这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM。具体参考:DOM渲染机制与常见性能优化

3、总结

  • 分析Webview打开H5打开的过程,我们发现,在H5优化中,前端重任在肩;
降低请求量:合并资源,减少 HTTP 请求数,minify / gzip 压缩,webP,lazyLoad。
加快请求速度:预解析DNS,减少域名数,并行加载,CDN 分发。
缓存:HTTP 协议缓存请求,离线缓存 manifest,离线数据缓存localStorage。
渲染:JS/CSS优化,加载顺序,服务端渲染,pipeline。
复制代码
  • 但是客户端也很重要,主要优化DOM渲染之前这些事情,可以做有:减少DNS时间预初始化WebView 以及 HTML、JS、CSS等资源离线下载
  • 列举在某业务中笔者实践过的比较trick的优化方案,然后再引出笔者认为理想的方案。

二、WebView的客户端优化(trick版)

由于是接入第三方的H5页面,接入离线包方案,需要比较繁杂的商务沟通和技术挑战(业务逻辑和代码超级诡异),临时采用如下优化方案

1、预加载资源

  • 将首页面需要的JS文件和CSS文件等资源放在一个URL地址(和业务url同域名);
  • 启动App后,间隔X秒去加载;加载的策略是,检查当前和上一次间隔时间,超时则加载,有效期忽略预加载请求。

2、预初始化Webview

  • 首次初始化Webview,需要初始化浏览器内核,需要的时间在400ms这个量级;二次初始化时间在几十ms这个量级;
  • 根据此特征:选择在APP 启动后X秒,预创建(初始化)一个 Webview 然后释放,这样等使用到 H5 模块,再加载 Webview时,加载时间也少了不少。
  • 结合步骤一中预加载公共资源,也需要Webview,所以选择在加载公共资源包时候,首次初始化Webview,加载资源,然后释放。

3、最终方案(迫不得已)

?由于第三方业务H5很多问题,和人力上不足;不得不需要客户端强行配合优化,在产品的要求下,不得不采用如下方案,方案的前提是:业务H5尽可能少修改,甚至不修改,客户端还要保证首屏加载快;

  • 预加载资源
  • 预创建Webview并加载首页H5,驻留在内存中,需要的时候,立刻显示。

4、方案的后遗症

  • 我不建议这种trick做法,因为自从开了这个口子,后续很多H5需求不走之前既定的离线包方案,在内存中预创建多个Webview (最多4个),加载H5时候不用新建Webview,从Webview池中获取;
  • 此种Webview池方案带来诸多隐患:内存压力、诡异的白屏、JS造成的内存泄露,页面的清空等等问题(填坑填到掉头发)。

三、离线包方案

1、概述

  • 离线包方案才是业务主流的H5加载优化方案,非常建议在客户端团队和前端团队推广,类似预创建Webview加载H5不应该成为主流。
  • 将每个独立的H5功能模块,相关HTML、Javascript、CSS 等页面内静态资源打包到一个压缩包内,客户端可以下载该离线包到本地,然后打开Webview,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。
  • 离线包可以提升用户体验(页面加载更快),还可以实现动态更新(在推出新版本或是紧急发布的时候,可以把修改的资源放入离线包,通过更新配置让应用自动下载更新)

2、方案描述

引用bang的离线包方案,简单描述如下

  • 后端使用构建工具把同一个业务模块相关的页面和资源打包成一个文件,同时对文件加密/签名。
  • 客户端根据配置表,在自定义时机去把离线包拉下来,做解压/解密/校验等工作。
  • 根据配置表,打开某个业务时转接到打开离线包的入口页面。
  • 拦截网络请求,对于离线包已经有的文件,直接读取离线包数据返回,否则走 HTTP 协议缓存逻辑。
  • 离线包更新时,根据版本号后台下发两个版本间的 diff 数据,客户端合并,增量更新。

说明:目前WKWebView已经能成为主流,但是WKWebView在实现离线包方案时,拦截网络请求有坑。

3、WKWebView拦截网络请求的坑

  • 虽然NSURLProtocol可以拦截监听每一个URL Loading System中发出request请求,记住是URL Loading System中那些类发出的请求,也支持AFNetwoking,UIWebView发出的request,NSURLProtocol都可以拦截和监听。
  • 因为WKWebView 在独立进程里执行网络请求。一旦注册 http(s) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。
  • 但是在 WebKit2 的设计里使用 MessageQueue 进行进程之间的通信,Network Process 会将请求 encode 成一个 Message,然后通过 IPC(进程间通信) 发送给 App Process。出于性能的原因,encode 的时候 将HTTPBody 和 HTTPBodyStream 这两个字段丢弃掉()
  • 因此,如果通过 registerSchemeForCustomProtocol 注册了 http(s) scheme, 那么由 WKWebView 发起的所有 http(s)请求都会通过 IPC 传给主进程 NSURLProtocol 处理,导致 post 请求 body 被清空
//苹果开源的 WebKit2 源码暴露了私有API:
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]
//通过注册 http(s) scheme 后 WKWebView 将可以使用 NSURLProtocol 拦截 http(s) 请求:
Class cls=NSClassFromString(@"WKBrowsingContextController”); 
SEL sel=NSSelectorFromString(@"registerSchemeForCustomProtocol:"); 
if ([(id)cls respondsToSelector:sel]) { 
 // 注册http(s) scheme, 把 http和https请求交给 NSURLProtocol处理 
 [(id)cls performSelector:sel withObject:@"http"]; 
 [(id)cls performSelector:sel withObject:@"https"]; 
}
复制代码

**说明1:**名目张胆使用私有API,是过不了AppStore审核的,具体使用什么办法,想来你也懂(hun xiao)。

说明2:一旦打开ATS开关:Allow Arbitrary Loads 选项设置为NO,通过 registerSchemeForCustomProtocol 注册了 http(s) scheme,WKWebView 发起的所有 http(s) 网络请求将被阻塞(即便将Allow Arbitrary Loads in Web Content 选项设置为YES);

说明3:iOS11之后可以通过WKURLSchemeHandler去完成对WKWebView的请求拦截,不需要再调用私有API解决上述问题了。

4、WKWebView自定义资源scheme

  • 向WKWebView 注册 customScheme, 比如 dynamic://, 而不是https或http,避免对https或http请求的影响
  • 保证使用离线包功能的请求,没有post方式,遇到customScheme请求,比如dynamic://www.dynamicalbumlocalimage.com/,通过 NSURLProtocol 拦截这个请求并加载离线数据。
  • iOS 11上, WebKit 提供的WKURLSchemeHandler可实现拦截,需要注意的只允许开发者拦截自定义 Scheme 的请求,不允许拦截 “http”、“https”、“ftp”、“file” 等的请求,否则会crash。

四、其他

1、LocalWebServer

  • 离线包方案中,除了拦截请求加载资源的方式,还有种在项目中搭建local web server,用以获得本地资源。市面有比较完善的框架
CocoaHttpServer (支持iOS、macOS及多种网络场景)
GCDWebServer (基于iOS,不支持 https 及 webSocket)
Telegraph (Swift实现,功能较上面两类更完善)
复制代码
  • 具体可参考 基于 LocalWebServer 实现 WKWebView 离线资源加载, 之前团队有过实践,采用的是GCDWebServer

2、WKWebView loadRequest 问题

  • 在 WKWebView 上通过 loadRequest 发起的 post 请求 body 数据会丢失:
//同样是由于进程间通信性能问题,HTTPBody字段被丢弃
[request setHTTPMethod:@"POST"];
[request setHTTPBody:[@"bodyData" dataUsingEncoding:NSUTF8StringEncoding]];
[wkwebview loadRequest: request];
复制代码

解决:假如想通过-[WKWebView loadRequest:]加载 post 请求 (原始请求)request1: h5.nanhua.com/order/list,可以通过以下步骤实现:

  • 替换请求 scheme,生成新的 post 请求 request2: post://h5.nanhua.com/order/list, 同时将 request1 的 body 字段复制到 request2 的 header 中(WebKit 不会丢弃 header 字段);
  • 通过-[WKWebView loadRequest:] 加载新的 post 请求 request2;
  • 并且通过 +[WKBrowsingContextController registerSchemeForCustomProtocol:]注册 scheme: post://;
  • 注册 NSURLProtocol 拦截请求 post://h5.nanhua.com/order/list ,替换请求 scheme, 生成新的请求 request3: h5.nanhua.com/order/list,将 request2 header的body 字段复制到 request3 的 body 中,并使用 NSURLSession 加载 request3,最后将加载结果返回 WKWebView;

3、推荐资料

  • 移动端本地 H5 秒开方案探索与实现
  • 使用 PageSpeed Insights 进行移动版分析
  • WebView性能、体验分析与优化
  • iOS app秒开H5优化总结
  • 赋予H5以Native的生命 ——《WebView优化》