因为H5的低成本,高效率,跨平台等特性,在当下很多APP在开发时选择将某些业务使用H5技术进行开发,这种新的开发模式被业界称为:Hybrid APP。
但在开发 Hybrid 业务时,需要使用大量的客户端桥接和客户端进行通信,这使得在 Chrome 调试工具中无法直接调试该类型业务以及对 BUG 进行复现定位。
为此,Soul 借鉴市面上已有的小程序开发者工具,基于 Electron 自研了一个 Hybrid 业务调试工具,今天我们就来讲一讲 Soul 在这个工具上的实践。
首先我们要明确需要完成的目标:
• 模拟被调试的H5业务环境(屏幕尺寸,UA等)
• 模拟客户端的桥接方法,使页面流程正常流转
• 使用 Chrome Devtools 调试页面DOM结构和网络请求
目标确认后,我们来看看怎么实现这些目标。在此之前,需要介绍一下相关概念,以便大家对于后续的实操更加理解。
Chrome DevTools Protocol 协议允许使用工具对 Chromium、Chrome 和其他基于 Blink 的浏览器进行检测、检查、调试和剖析。Chrome DevTools Frontend 就使用的是该协议。
CDP协议被划分为若干领域(DOM、调试器、网络等)。每个领域都定义了一些它支持的命令和生成的事件,命令和事件都是固定结构的序列化 JSON 对象。
Chrome DevTools Protocol(简称 CDP)官网对该协议的介绍是可以对 Chrome Web 内核进行调试和查看信息,简单点说就是可以通过使用CDP调用 Chrome 浏览器的所有调试能力(检查DOM结构、网络请求调试、断点调试、移动端设备模拟等)。
Chrome 开发者工具 (Chrome DevTools) 是一套内置于 Google Chrome 中的 Web 开发和调试工具,可用来对网站进行迭代、调试和分析。(Chrome DevTools 官方文档:https://developers.google.com/web/tools/chrome-devtools?hl=zh-cn)
Chrome DevTools Frontend 相关资料:
• 官方仓库:https://github.com/ChromeDevTools/devtools-frontend
• 官方文档:https://chromedevtools.github.io/source-docs/index.html
Chrome DevTools 是辅助开发者进行 Web 开发的重要调试工具,DevTools 是 Chromium 的一部分,可以作为独立项目被 Electron 等容器集成。
DevTools 主要分为四部分:
• Frontend:调试器前端,默认由 Chromium 内核层集成
• Backend:调试器后端,Chromium、V8 或 Node.js
• Protocol:调试协议
• Message Channels:消息通道,包括:Embedder Channel、WebSocket Channel、Chrome Extensions Channel、USB/ADB Channel
Chrome DevTools Frontend 是一个 Web 应用程序,通过 WebSocket 与 Blink 的 C++ 后端通信。
Electron 分为主进程(nodejs层)和渲染进程(web层),每个渲染进程在主进程中都会有一个 WebContent 实例,Electron 官网对于 WebContent 的解释是负责渲染和控制网页,在此项目中 webContents 将是连接 webview 实现各种操作的重要工具。webContents
的 debugger
对象可以连接当前页面,并向页面发送CDP命令。
向 webContents
对应的页面发送CDP命令。
• method
- CDP命令名
• commandParams
(可选) - CDP命令参数 该方法返回一个 promise,如果发送的CDP命令有返回值则会在promise中返回。
// 初始化连接
webContente.debugger.attach();
// 发送CDP命令
// eg: 设置页面的userAgent
// webContents.debugger.sendCommand("Emulation.setUserAgentOverride", {userAgent: "xxxx"});
webContents.debugger.sendCommand(method, commandParams);
// 接收CDP消息
webContents.debugger.on("messsage", (ev, method, params, sessionId) => {
// method 是CDP事件名称 // params 是CDP事件返回的结果
});
Electron 中有三种方式可以在渲染进程中打开外部URL,分别是 iframe
、webview
以及 BrowserViews
。其中 iframe
因为调试起来较为复杂,BrowserViews
是独立渲染,无法被宿主渲染进程的组件遮盖,所以我们采用 webview
的方式打开外部页面。
默认情况下,
webview
标签在 Electron >= 5 中被禁用,需要在 Electron 的 BrowserWindow 初始化配置中将 webviewTag 属性设置为 true 即可开启webview。
// 创建主窗口
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
webviewTag: true // 开启webview标签
}
});
<body>
<style>
.device {
width: 375px;
height: 812px;
}
</style>
<webview
class="device"
src="https://m.soulapp.cn"
useragent="Mozilla/5.0 (Linux; Android 7.1.1; OPPO R9sk) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36"
></webview>
</body>
启动项目,可以看到已经在 webview 中打开了我们指定的地址。
CDP 提供了 Input.dispatchTouchEvent 方法,该方法的作用是在被调试页面上触发一次触摸事件,通过参数可以指定触摸事件的类型和触摸点的坐标,并且支持多点触摸。
向页面发送触摸事件
• type string 触摸事件的类型。TouchEnd 和 TouchCancel 必须不包含任何触摸点,而 TouchStart 和 TouchMove 必须至少包含一个触摸点,允许的值: touchStart
touchEnd
touchMove
touchCancel
• touchPoints array[TouchPoint] 触摸设备上的活动触摸点。每一个变化的点都会产生一个事件(与序列中的前一个触摸事件相比),逐一模拟按下/移动/释放点
• modifiers integer 已按下的修饰键的代码。Alt=1, Ctrl=2, Meta/Command=4, Shift=8(默认:0)
• timestamp 事件发生的时间
基于此方法,我们可以拦截 webview 标签的所有鼠标事件,然后利用 CDP 协议向 webview 发送自定义触摸事件,从而实现鼠标事件到触摸事件的转换,实现思路如下。
const webviewEle = document.querySelector("webview");
webviewEle.addEventListener("mousedown", (downEv) => {
webContents.debugger.sendCommand("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: ev.clientX, y: ev.clientY }],
});
document.addEventListener("mousemove", (moveEv) => {
webContents.debugger.sendCommand("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: moveEv.clientX, y: moveEv.clientY }],
});
});
document.addEventListener("mouseup", (upEv) => {
webContents.debugger.sendCommand("Input.dispatchTouchEvent", { type: "touchEnd" });
document.removeEventListener("mousemove");
document.removeEventListener("mouseup");
});
});
CDP提供了 Emulation.setDeviceMetricsOverride
方法,用来修改页面大小、缩放和 DPI 等信息。
覆盖设备屏幕尺寸的值(该方法会影响 CSS 媒体查询结果)。
• width integer 设备宽度值(相对于当前渲染进程的宽度值,一般设置为当前 webview element 的宽度),单位是像素(最小0,最大10000000)。0会取消覆盖。
• height integer 设备高度值(相对于当前渲染进程的高度值,一般设置为当前 webview element 的高度),单位是像素(最小0,最大10000000)。0会取消覆盖。
• deviceScaleFactor number 设备DPI值,0会取消覆盖
• mobile boolean 是否模拟移动设备。这包括 viewport 元标签、覆盖滚动条、文本自动调整等。
• screenWidth integer 设备屏幕宽度值(要模拟的屏幕像素宽度),单位是像素(最小0,最大10000000)。
• screenHeight integer 设备屏幕高度值(要模拟的屏幕像素高度),单位是像素(最小0,最大10000000)。
• positionX integer 视图在屏幕上的X坐标(相对于屏幕左上角),单位为像素(最小0,最大10000000)。
• positionY integer 视图在屏幕上的Y坐标(相对于屏幕左上角),单位为像素(最小0,最大10000000)。
例如要模拟 iPhone 11 Pro 的屏幕尺寸(375x812 dpi=3)可以按如下方式设置
const webviewEle = document.querySelector("webview");
webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 3, // 设备dpi
mobile: true // 是否为移动端设备
width: webviewEle.clientWidth,
height: webviewEle.clientHeight,
screenWidth: 375, // 设备屏幕宽度
screenHeight: 812 // 设备屏幕高度
});
客户端桥接实现方式有很多种,需要根据自己的实际情况执行。这里只以传统的客户端向 webview 注入 JS 方法这种方式为例进行讲解。
在 App 中嵌入 H5 页面在带来便利的同时也伴随着一系列安全问题。业内通用方案是js不执行敏感操作,如果有敏感操作则由客户端提供桥接方法,在原生代码中完成,例如网络请求等。具体流程如下图。
Electron webview 标签可以通过 preload
属性和 contextBridge
API 向 webview 注入包含主进程内存的全局方法,具体使用如下:
// preload.js
var { contextBridge } = require("electron");
contextBridge.exposeInMainWorld("GlobalAPI", {
request: (method, url, params) => {
// 在这里可以使用nodejs去发送请求
},
});
// 在webview中使用桥接方法
window.GlobalAPI.request("get", "xxxxxx", {});
webview 和主进程的交互如下图所示,由 Electron 主进程替代了原本客户端的行为。
上面已经介绍了Chrome的调试工具Chrome DevTools Frontend项目,该项目使用 CDP 对被调试及页面进行相关操作。
在远程调试中Chrome DevTools通过 websocket 与被调试页面进行 CDP 通信,这里我们采用自编译 Chrome DevTools Frontend项目,通过 Electron 主进程建立 websocket 连接代理 devtools 和 webContents.debugger
(CDP)的通信。
Electron 官方提供了一套内置的Chrome DevTools,通过
webContents.openDevTools()
方法可以打开对应页面的调试面板,如果要在渲染进程内打开调试面板可以通过webContents.setDevToolsWebContents()
方法,将渲染层中的另一个 webview 作为调试面板的宿主,在宿主 webview 内打开调试面板。But,由于 Electron 的bug(在使用openDevTools
方法打开调试面板后,会导致部分webContents.debugger
收到的 CDP 消息丢失,最直接的表现是Element
面板失效),所以我们采用自编译 Chrome DevTools Frontend 的方式来进 webview 调试。
Chrome DevTools Frontend 使用 google depot_tools 工具链进行构建,编译前需先安装depot_tools
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=/path/to/depot_tools:$PATH
mkdir devtools
cd devtools
fetch devtools-frontend
cd devtools-frontend
gn gen out/Defaultautoninja -C out/Default
按照上面的命令执行完后在 out/Default/gen/front_end
目录下的所有文件就是完整的 Chrome DevTools 了。将 front_end
目录移动至调试工具项目中,在渲染进程新建webview标签打开 front_end/devtools_app.html
,这时就能看到 chrome 调试工具已经显示出来了
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
/>
<title>Hello World!</title>
<style>
body {
display: flex;
padding: 10px;
}
.device {
width: 375px;
height: 812px;
}
.devtools {
flex: 1;
margin-left: 20px;
}
</style>
</head>
<body>
<webview
class="device"
src="https://www.soulapp.cn"
useragent="Mozilla/5.0 (Linux; Android 7.1.1; OPPO R9sk) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36"
></webview>
<webview class="devtools" src="./front_end/devtools_app.html"></webview>
</body>
</html>
在主进程中创建 websocket 连接,这里使用 ws 库创建 ws 服务
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 2999 });
wss.on("connection", (ws, req) => {
const webContentsId = req.url.replace(/^\//, "");
const webContent = webContents.fromId(Number(webContentsId));
if (!webContent.debugger.isAttached()) {
webContent.debugger.attach();
}
webContent.debugger.on("message", (ev, method, params) => {
ws.send(JSON.stringify({ method, params }));
});
ws.on("close", () => {
webContent.debugger.off("message");
});
ws.on("message", (message) => {
const { method, params, id } = JSON.parse(message.toString());
webContent.debugger
.sendCommand(method, params)
.then((res) => {
ws.send(JSON.stringify({ id, result: res }));
})
.catch((err) => {
ws.send(JSON.stringify({ id, error: err }));
});
});
});
修改渲染进程的 html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<title>Hello World!</title>
<style>
body {
display: flex;
padding: 10px;
}
.device {
width: 375px;
height: 812px;
}
.devtools {
flex: 1;
margin-left: 20px;
}
</style>
</head>
<body>
<webview
class="device"
src="https://www.soulapp.cn"
useragent="Mozilla/5.0 (Linux; Android 7.1.1; OPPO R9sk) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36"
></webview>
<webview class="devtools" src="./front_end/devtools_app.html"></webview>
<script>
document.querySelector(".device").addEventListener("did-attach", (ev) => {
setTimeout(() => {
document.querySelector(
".devtools"
).src = `./front_end/devtools_app.html?ws=127.0.0.1:2999/${ev.target.getWebContentsId()}`;
}, 100);
});
</script>
</body>
</html>
到这里整个调试工具就已经大功告成啦
剩下的就需要根据实际应用场景进行优化啦。