现代 Hybrid 开发与原理解析
开始之前, 咱们先来罗列一下当前市面上, 移动端的各种开发方式.
Native App
纯原生的 app 开发模式, android 或者 ios.
Java swift oc
优点: 有最好的性能, 最好的体验.
缺点: 开发和发布的成本极高, 两端需要不同的技术人员来维护, 原生开发人员非常的稀缺.
WebApp
移动端运行在浏览器上的网站, 我们一般称之为 h5 应用, 就是泛指我们经常开发的 spa, mpa.
js vue react ng jquery
优点:
- 开发和发布非常方便
- 用户看到的页面, 会随着开发人员的发布实时更新
- 可以跨平台, 因为 h5 应用的产出其实就是一个 url, 调试非常的方便. chrome safari, f12
- 类似优点2, 不存在多版本的问题, 维护成本很低. 安卓 app 1.0.0 2.0.0
缺点:
- 性能和体验一般
- 受限于浏览器, 能做的事情并不是很多, 需要兼容各种奇怪的浏览器
- 入口强依赖浏览器
React Native App / Weex App
都是为了跨平台而生的, 支持 react/vue 的语法.
Flutter
闲鱼.
dart 语言, 跨平台支持的更好.
Hybrid 基本介绍
混合开发.
h5 + native 混合开发 = hybrid
app -> webview -> url !== hybrid
最大的特点是 h5 和 native 可以双向交互.
通过微信 JSSDK 介绍 Hybrid
h5 经常分享在微信聊天/朋友圈.
公众号文章 -> ... -> 分享给好友
授权 -> 是否同意授权 xxx -> 头像昵称 -> 手机号.
微信的 JSSDK https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#1
分享、支付、位置. h5 开发者只需要关注微信 jssdk 中提供了哪些 api 即可, 其他的所有事情都由 jssdk 和微信客户端来完成.
<script>xxxxxx</script>微信分享
wx.config({
appId: 'xxxxxx'
});
wx.ready(() => {
wx.onMenuShareAppMessage({
title: '哈哈哈',
desc,
link,
imgUrl
});
});现代 Hybrid 开发与原理解析
接下来咱们开始详细介绍一下 Hybrid 开发的架构, 最后会尝试实现一个 js 端的 bridge.
Hybrid 开发架构

所以说, hybrid 最核心的部分, 就是 native 和 h5 的双向通讯. 通讯是完全依赖于 webview 容器.
- 具体的通讯形式又是什么样子的呢?
- webview 凭什么可以支撑起 native 和 h5 的双向通讯.
双向通讯市面上目前有两种方式:
- URL schema, 客户端通过拦截 webview 中的请求来完成通讯
- native 向 webview 的 js 执行环境中, 给 window 对象挂载 api, 以此来完成通讯
一、URL Schema, 客户端拦截 webview 请求
原理
在 webview 中发出的网络请求, 都会被客户端给监听到, 给拦截到.
这就是 URL Schema 这种模式实现的最基本的基石.
定义自己的私有协议
h5 里面可能有无数的请求, https://www.baidu.com. // http 协议
native 可以定义自己的私有协议, qiuku://
随后我们在 webview 中如果要去调用 native 的一些端能力, 就需要在请求前面拼上这个协议头, setTitle
qiuku://setTitle?params1=xxx¶m2=xxx协议的名称是自定义的, 没什么特别硬性的要求, 只要和 native 协商好就可以
如果一家公司下有多个 app, 今日头条 抖音 西瓜视频, 比如有好多可以共用的逻辑, commonToutiao://
请求的发送
iframe 的方式
jsconst doc = window.document; const body = document.body; const iframe = doc.createElement('iframe'); iframe.style.display = 'none'; iframe.src = 'qiuku://setTitle?params1=xxx¶m2=xxx'; // 此时还没有开始请求 body.appendChild(iframe); setTimeout(() => { body.removeChild(iframe); }, 200);客户端要考虑的还有安全方面, 会设置一些域名的白名单.
比如咱们 h5 现在的 url 是, www.qiuku.com, baidu.com
location.href
不适用于并行的请求
客户端拦截协议请求
当拦截到的请求是
qiuku://, 会解析参数, 解析方法, 进行相关的 native 操作.请求处理完成后的回调
webview 请求本质上还是一个异步请求, 我们需要有一个回调来告诉我们请求的结果.
window.addEventListener和window.dispatchEvent这两个 api.业务中:
jswindow.setTitle({title: '哈哈哈哈', (err, response) => { if (err) { console.log(err); return; } // 执行成功, 执行业务逻辑 }});JSBridge中:
jslet handlerId = 1; const eventName = `setTitle_${handlerId}`; // 每一个 eventName 唯一 handlerId++; const event = new Event(eventName, (res) => { if (res.data.errcode) { // 执行失败 return; } // 执行成功 }); JSBridge.send('qiuku://setTitile?title=哈哈哈哈'); // 相当于调用上面的 iframe event.data = { errcode: 0 }; window.dispatchEvent(event);
二、注入 API
通过 iframe 来发送请求, 参数很容易过长而被截断. // iframe 为了兼容 iOS 6
向 native 传递信息
前提是: native 已经向 window 变量注入了各种 api, 咱们已经可以直接调用它们了.
比如
window.QiukuWebview = { setTitle: xxx };jswindow.QiukuWebview.setTitle(params);准备接收 native 的回调
jswindow['setTitle_callback_1'] = (errcode, response) => { console.log(errcode); }有可能有的公司为了安全性, 会对参数进行加密或者编码
native 调用回调函数
native 执行完之后, 应该怎么告诉 h5 我执行完了呢? 我应该调用哪个函数告诉 h5 呢?
jswindow.QiukuWebview.setTitle(params);jsconst callbackName = 'setTitle_callback_1'; window.QiukuWebview.setTitle({ trigger: callbackName, ...params }); window['setTitle_callback_1'] = (errcode, response) => { console.log(errcode); }为了保证 callback 的唯一性, 一般会加入各种的
Date.now() + idiOS
window.webkit.messageHandler.postMessage()
declare var require: any;
const Buffer = require('buffer').Buffer;
interface WebviewParams {
callback?: Function;
[key: string]: any;
}
const UID_PREFIX = Date.now().toString();
const isNotApp = !/Qiuku/.test(window.navigator.userAgent);
let uid = 1;
// webkit.messageHandler
// postMessage
class Webview {
public exec(name: string, params: WebviewParams) {
this.addApi(name)[name](params);
}
/**
* 一般用于处理一些后期的更新或者变动
* 比如最初内置了好多 api, 但是后期客户端增加了好多 api
* @param name
*/
public addApi(name: string) {
if (!this[name]) {
this[name] = (params) => {
if (isNotApp) {
return this;
}
return this.run(name, params);
}
}
}
private getUid(name: string) {
return name + UID_PREFIX + (++uid);
}
private run(apiName: string, params: WebviewParams = {}) {
const callback = params.callback;
if (typeof callback === 'function') {
// @ts-ignore
const callbackName = this.getUid(callback.name);
// 在 window 上注册的回调函数, 需要是接收 base64String 的函数
window[callbackName] = this.convertToReceiveBase64(callback);
params.trigger = callback;
}
let messageHandler = window['QiukuWebview'] as any;
if (!messageHandler[apiName]) {
console.error(`without ${apiName}, warning!!!`);
return;
}
const encodeParams = new Buffer(JSON.stringify(params)).toString('base64');
messageHandler[apiName](encodeParams);
// 为了链式调用
return this;
}
/**
* 使用和客户端约定好的规则来解析 base64
* @param base64Str
* @private
*/
private base64ToString(base64Str: string): string {
const newStr = base64Str.replace(/[-_]/g, function (m0) {
return m0 === '-' ? '+' : '/';
}).replace(/[^A-Za-z0-9+/]/g, '');
return (new Buffer(base64Str, 'base64')).toString();
}
/**
* 解析 base64 的数据
* @param callback
* @private
*/
private convertToReceiveBase64(callback: Function) {
return (base64Str) => {
let data = {};
if (base64Str) {
try {
data = JSON.parse(this.base64ToString(base64Str));
} catch (e) {
const msg = e.message || 'webview parse error';
data = {msg};
}
}
// apply 的第一个参数如果是 null, 代表将是执行环境的全局变量来执行 callback
// window.callback(data);
callback.apply(null, data);
}
}
}
const webview = new Webview();
webview.exec('setTitle', {
title: '这是标题',
callback: (errcode) => {
console.log(errcode);
}
});h5 在 app 内的运行方式
app 的 webview 直接加载一个 h5 链接
缺点: 没有太好的体验, 除了能用一些 native 的能力之外, 和普通浏览器打开 h5 没什么区别
因为加载的还是网络资源
优点: 灵活, 易用
app 内置 h5 资源
优点:
首屏加载速度特别快, 体验接近原生
可以不依赖网络, 离线运行
缺点:
- 会增大 app 的体积
- 需要多方合作去完成方案
要解决的最核心的问题是: 如何更新内置的 h5 资源
project-config.json
项目名、版本号、全量/增量更新、cdn 地址
qiuku 1.2.0 全量 https://qiuku.cdn.com/1.2.0.js
开发中的常见问题
iOS webview 中滑动不流畅
如果有一个滚动容器 scroll-container, overflow: scroll
css-webkit-overflow-scrolling: touch; /* 当手指从触摸屏上移开,会保持一段时间的滚动 */ -webkit-overflow-scrolling: auto; /* 当手指从触摸屏上移开,滚动会立即停止 */滚动穿透
背景页面有滚动的时候, 此时有个弹窗出现了.
2.1 弹窗内无滚动, 背景页面有滚动
直接在弹窗容器元素上加一个监听事件就可以了
js/* 原生 */ document.addEventListener('touchmove', function (e) { // 阻止默认事件 e.preventDefault(); }); /* vue */ @touchmove.prevent2.2 弹窗内有滚动, 背景页面有滚动
弹窗展示活动规则
要实现的是:
弹窗出现时, 背景禁止滚动
弹窗隐藏时, 背景恢复滚动
js/* vue 自定义指令, 仅适用于 v-if 组件, v-show 不适用 */ const inserted = () => { // 弹窗出现时的行为 const scrollTop = document.body.scrollTop || document.documentElement.scrollTop; document.body.style.cssText += `postion: fixed; width: 100%; top: -${scrollTop}px`; }; const unbind = () => { const body = document.body || document.documentElement; body.style.position = ''; const top = body.style.top; document.body.scrollTop = document.documentElement.scrollTop = -parseInt(top, 10); body.style.top = ''; }; export const vScroll = { inserted, unbind } Vue.directive('scroll', vScroll); div(v-scroll)刘海屏的安全区域留白
设置 viewport-fit cover
html<meta name="viewport" content="viewport-fix=over" /> safe area insetcss.bottom { position: fixed; bottom: 1rem; /* constant 写在 env 前面, 兼容性的问题 */ bottom: calc(constant(safe-area-inset-bottom) + 1rem); bottom: calc(env(safe-area-inset-bottom) + 1rem); }