关于 Hybrid 模式开发 app 的好处, 网络上已有很多文章阐述了, 这里不展开
本文将从以下几个方面阐述 Hybrid app 架构设计的一些经验和思考
原文及讨论请到 github issue
通讯
作为一种跨语言开发模式, 通讯层是 Hybrid 架构首先应该考虑和设计的, 往后所有的逻辑都是基于通讯层展开
Native(以 Android 为例)和 H5 通讯, 基本原理:
Android 调用 H5: 通过 webview 类的 loadUrl 方法可以直接执行 js 代码, 类似浏览器地址栏输入一段 js 一样的效果
webview.loadUrl("javascript: alert('hello world')");
H5 调用 Android:webview 可以拦截 H5 发起的任意 url 请求, webview 通过约定的规则对拦截到的 url 进行处理(消费), 即可实现 H5 调用 Android
- var ifm = document.createElement('iframe');
- ifm.src = 'jsbridge://namespace.method?[...args]';
JSBridge 即我们通常说的桥协议, 基本的通讯原理很简单, 接下来就是桥协议具体实现
P.S: 注册私有协议的做法很常见, 我们经常遇到的在网页里拉起一个系统 app 就是采用私有协议实现的 app 在安装完成之后会注册私有协议到 OS, 浏览器发现自身不能识别的协议 (httphttpsfile 等) 时, 会将链接抛给 OS,OS 会寻找可识别此协议的 app 并用该 app 处理链接比如在网页里以 itunes:// 开头的链接是 Apple Store 的私有协议, 点击后可以启动 Apple Store 并且跳转到相应的界面国内软件开发商也经常这么做, 比如支付宝的私有协议 alipay://, 腾讯的 tencent:// 等等
桥协议的具体实现
由于 JavaScript 语言自身的特殊性(单进程), 为了不阻塞主进程并且保证 H5 调用的有序性, 与 Native 通讯时对于需要获取结果的接口(GET 类), 采用类似于 JSONP 的设计理念:
类比 HTTP 的 request 和 response 对象, 调用方会将调用的 api 参数以及请求签名 (由调用方生成) 带上传给被调用方, 被调用方处理完之后会吧结果以及请求签名回传调用方, 调用方再根据请求签名找到本次请求对应的回调函数并执行, 至此完成了一次通讯闭环
H5 调用 Native(以 Android 为例)示意图:
Native(以 Android 为例)调用 H5 示意图:
基于桥协议的 api 设计(HybridApi)
jsbridge 作为一种通用私有协议, 一般会在团队级或者公司级产品进行共享, 所以需要和业务层进行解耦, 将 jsbridge 的内部细节进行封装, 对外暴露平台级的 API
以下是笔者剥离公司业务代码后抽象出的一份 HybridApi js 部分的实现, 项目地址:
hybrid-js
另外, 对于 Native 提供的各种接口, 也可以简单封装下, 使之更贴近前端工程师的使用习惯:
- /$lib/jsbridge/core.js
- function assignAPI(name, callback) {
- var names = name.split(/\./);
- var ns = names.shift();
- var fnName = names.pop();
- var root = createNamespace(JSBridge[ns], names);
- if(fnName) root[fnName] = callback || function() {};
- }
增加 api:
- /$lib/jsbridge/api.js
- var assign = require('./core.js').assignAPI;
- ...
- assign('util.compassImage', function(path, callback, quality, width, height) {
- JSBridge.invokeApp('os.getInfo', {
- path: path,
- quality: quality || 80,
- width: width || 'auto',
- height: height || 'auto',
- callback: callback
- });
- });
H5 上层应用调用:
- // h5/music/index.js
- JSBridge.util.compassImage('http://cdn.foo.com/images/bar.png', function(r) {
- console.log(r.value); // => base64 data
- });
界面与交互(Native 与 H5 职责划分)
本质上, Native 和 H5 都能完成界面开发几乎所有 hybrid 的开发模式都会碰到同样的一个问题: 哪些由 Native 负责哪些由 H5 负责?
这个回到原始的问题上来: 我们为什么要采用 hybrid 模式开发? 简而言之就是同时利用 H5 的跨平台快速迭代能力以及 Native 的流畅性系统 API 调用能力
根据这个原则, 为了充分利用二者的优势, 应该尽可能地将 app 内容使用 H5 来呈现, 而对于 js 语言本身的缺陷, 应该使用 Native 语言来弥补, 如转场动画多线程作业(密集型任务)IO 性能等即总的原则是 H5 提供内容, Native 提供容器, 在有可能的条件下对 Android 原生 webview 进行优化和改造(参考阿里 Hybrid 容器的 JSM), 提升 H5 的渲染效率
但是, 在实际的项目中, 将整个 app 所有界面都使用 H5 来开发也有不妥之处, 根据经验, 以下情形还是使用 Native 界面为好:
关键界面交互性强的的界面使用 Native
因 H5 比较容易被恶意攻击, 对于安全性要求比较高的界面, 如注册界面登陆支付等界面, 会采用 Native 来取代 H5 开发, 保证数据的安全性, 这些页面通常 UI 变更的频率也不高
对于这些界面, 降级的方案也有, 就是 HTTPS 但是想说的是在国内的若网络环境下, HTTPS 的体验实在是不咋地(主要是慢), 而且只能走现网不能走离线通道
另外, H5 本身的动画开发成本比较高, 在低端机器上可能有些绕不过的性能坎, 原生 js 对于手势的支持也比较弱, 因此对于这些类型的界面, 可以选择使用 Native 来实现, 这也是 Native 本身的优势不是比如要实现下面这个音乐播放界面, 用 H5 开发门槛不小吧, 留意下中间的波浪线背景, 手指左右滑动可以切换动画
导航组件采用 Native
导航组件, 就是页面的头组件, 左上角一般都是一个 back 键, 中间一般都是界面的标题, 右边的话有时是一个隐藏的悬浮菜单触发按钮有时则什么也没有
移动端有一个特性就是界面下拉有个回弹效果, 头不动 body 部分跟着滑动, 这种效果 H5 比较难实现
再者, 也是最重要的一点, 如果整个界面都是 H5 的, 在 H5 加载过程中界面将是白屏, 在弱网络下用户可能会很疑惑
所以基于这两点, 打开的界面都是 Native 的导航组件 + webview 来组成, 这样即使 H5 加载失败或者太慢用户可以选择直接关闭
在 API 层面, 会相应的有一个接口来实现这一逻辑(例如叫
JSBridge.layout.setHeader
), 下面代码演示定制一个只有 back 键和标题的导航组件:
- /$h5/pages/index.js
- JSBridge.layout.setHeader({
- background: {
- color: '#00FF00',
- opacity: 0.8
- },
- buttons: [
- // 默认只有 back 键, 并且 back 键的默认点击处理函数就是 back()
- {
- icon: '../images/back.png',
- width: 16,
- height: 16,
- onClick: function() {
- // todo...
- JSBridge.back();
- }
- },
- {
- text: '音乐首页',
- color: '#00FF00',
- fontSize: 14,
- left: 10
- }
- ]
- });
上面的接口, 可以满足绝大多数的需求, 但是还有一些特殊的界面, 通过 H5 代码控制生成导航组件这种方式达不到需求:
如上图所示, 界面含有 tab, 且可以左右滑动切换, tab 标题的下划线会跟着手势左右滑动大多见于 app 的首页 (mainActivity) 或者分频道首页, 这种界面一般采用定制 webview 的做法: 定制的导航组件和内容框架(为了支持左右滑动手势),H5 打开此类界面一般也是开特殊的 API:
- /$h5/pages/index.js
- // 开打音乐频道下我的音乐 tab
- JSBridge.view.openMusic({'tab': 'personal'});
这种打开特殊的界面的 API 之所以特殊, 是因为它内部要么是纯 Native 实现, 要么是和某个约定的 html 文件绑定, 调用时打开指定的 html 假设这个例子中, tab 内容是 H5 的, 如果 H5 是 SPA 架构的那么
openMusic({'tab': 'personal'})
则对应
/music.html#personal
这个 url, 反之多页面的则可能对应
/mucic-personal.html
至于一般的打开新界面, 则有两种可能:
app 内 H5 界面
指的是由 app 开发者开发的 H5 页面, 也即是 app 的功能界面, 一般互相跳转需要转场动画, 打开方式是采用 Native 提供的接口打开, 例如:
- JSBridge.view.openUrl({
- url: '/music-list.html',
- title: '音乐列表'
- });
再配合下面即将提到的离线访问方式, 基本可以做到模拟 Native 界面的效果
第三方 H5 页面
指的是 app 内嵌的第三方页面, 一般由 `a` 标签直接打开, 没有转场动画, 但是要求打开 webview 默认的历史列表, 以免打开多个链接后点回退直接回到 Native 主界面
系统级 UI 组件采用 Native
基于以下原因, 一些通用的 UI 组件, 如 alerttoast 等将采用 Native 来实现:
H5 本身有这些组件, 但是通常比较简陋, 不能和 APP UI 风格统一, 需要再定制, 比如 alert 组件背景增加遮罩层
H5 来实现这些组件有时会存在坐标尺寸计算误差, 比如笔者之前遇到的是页面 load 异常需要调用对话框组件提示, 但是这时候页面高度为 0, 所以会出现弹窗消失的现象
这些组件通常功能单一但是通用, 适合做成公用组件整合到 HybridApi 里边
下面代码演示 H5 调用 Native 提供的 UI 组件:
JSBridge.ui.toast('Hello world!');
默认界面采用 Native
由于 H5 是在 H5 容器里进行加载和渲染, 所以 Native 很容易对 H5 页面的行为进行监控, 包括进度条 loading 动画 404 监控 5xx 监控网络诊断等, 并且在 H5 加载异常时提供默认界面供用户操作, 防止 APP 假死
下面是微信的 5xx 界面示意:
设计 H5 容器
Native 除了负责部分界面开发和公共 UI 组件设计之外, 作为 H5 的 runtime,H5 容器是 hybrid 架构的核心部分, 为了让 H5 运行更快速稳定和健壮, 还应当提供并但不局限于下面几方面
H5 离线访问
之所以选择 hybrid 方式来开发, 其中一个原因就是要解决 webapp 访问慢的问题即使我们的 H5 性能优化做的再好服务器在牛逼, 碰到蜗牛一样的运营商网络你也没辙, 有时候还会碰到流氓运营商再给 webapp 插点广告哎说多了都是泪
离线访问, 顾名思义就是将 H5 预先放到用户手机, 这样访问时就不会再走网络从而做到看起来和 Native APP 一样的快了
但是离线机制绝不是把 H5 打包解压到手机 sd 卡这么简单粗暴, 应该解决以下几个问题:
H5 应该有线上版本
作为访问离线资源的降级方案, 当本地资源不存在的时候应该走现网去拉取对应资源, 保证 H5 可用另外就是, 对于 H5, 我们不会把所有页面都使用离线访问, 例如活动页面, 这类快速上线又快速下线的页面, 设计离线访问方式开发周期比较高, 也有可能是页面完全是动态的, 不同的用户在不同的时间看到的页面不一样, 没法落地成静态页面, 还有一类就是一些说明类的静态页面, 更新频率很小的, 也没必要做成离线占用手机存储空间
开发调试 & 抓包
我们知道, 基于 file 协议开发是完全基于开发机的, 代码必须存放于物理机器, 这意味着修改代码需要 push 到 sd 卡再看效果, 虽然可以通过假链接访问开发机本地 server 发布时移除的方式, 但是个人觉得还是太麻烦易出错
为了实现同一资源的线上和离线访问, Native 需要对 H5 的静态资源请求进行拦截判断, 将静态资源映射到 sd 卡资源, 即实现一个处理 H5 资源的本地路由, 实现这一逻辑的模块暂且称之为 Local Url Router, 具体实现细节在文章后面
H5 离线动态更新机制
将 H5 资源放置到本地离线访问, 最大的挑战就是本地资源的动态更新如何设计, 这部分可以说是最复杂的了, 因为这同时涉及到 H5Native 和服务器三方, 覆盖式离线更新示意图如下:
解释下上图, 开发阶段 H5 代码可以通过手机设置 HTTP 代理方式直接访问开发机完成开发之后, 将 H5 代码推送到管理平台进行构建打包, 然后管理平台再通过事先设计好的长连接通道将 H5 新版本信息推送给客户端, 客户端收到更新指令后开始下载新包对包进行完整性校验 merge 回本地对应的包, 更新结束
其中, 管理平台推送给客户端的信息主要包括项目名 (包名) 版本号更新策略 (增量 or 全量) 包 CDN 地址 MD5 等
通常来说, H5 资源分为两种, 经常更新的业务代码和不经常更新的框架库代码和公用组件代码, 为了实现离线资源的共享, 在 H5 打包时可以采用分包的策略, 将公用部分单独打包, 在本地也是单独存放, 分包及合并示意图:
Local Url Router
离线资源更新的问题解决了, 剩下的就是如何使用离线资源了
上面已经提到, 对于 H5 的请求, 线上和离线采用相同的 url 访问, 这就需要 H5 容器对 H5 的资源请求进行拦截映射到本地, 即 Local Url Router
Local Url Router 主要负责 H5 静态资源请求的分发(线上资源到 sd 卡资源的映射), 但是不管是白名单还是过滤静态文件类型, Native 拦截规则和映射规则将变得比较复杂这里, 阿里去啊 app 的思路就比较赞, 我们借鉴一下, 将映射规则交给 H5 去生成: H5 开发完成之后会扫描 H5 项目然后生成一份线上资源和离线资源路径的映射表(souce-router.json),H5 容器只需负责解析这个映射表即可
H5 资源包解压之后在本地的目录结构类似:
- $ cd h5 && tree
- .
- js/
- CSS/
- img/
- pages
- index.html
- list.html
- souce-router.json
souce-router.json 的数据结构类似:
- {
- "protocol": "http",
- "host": "o2o.xx.com",
- "localRoot": "[/storage/0/data/h5/o2o/]",
- "localFolder": "o2o.xx.com",
- "rules": {
- "/index.html": "pages/index.html",
- "/js/": "js/"
- }
- }
H5 容器拦截到静态资源请求时, 如果本地有对应的文件则直接读取本地文件返回, 否则发起 HTTP 请求获取线上资源, 如果设计完整一点还可以考虑同时开启新线程去下载这个资源到本地, 下次就走离线了
下图演示资源在 app 内部的访问流程图:
其中 proxy 指的是开发时手机设置代理 http 代理到开发机
数据通道
上报
由于界面由 H5 和 Native 共同完成, 界面上的用户交互埋点数据最好由 H5 容器统一采集上报, 还有, 由页面跳转产生的浏览轨迹(转化漏斗), 也由 H5 容器记录和上报
ajax 代理
因 ajax 受同源策略限制, 可以在 hybridApi 层对 ajax 进行统一封装, 同时兼容 H5 容器和浏览器 runtime, 采用更高效的通讯通道加速 H5 的数据传输
Native 对 H5 的扩展
主要指扩展 H5 的硬件接口调用能力, 比如屏幕旋转摄像头麦克风位置服务等等, 将 Native 的能力通过接口的形式提供给 H5
综述
最后来张图总结下, hybrid 客户端整体架构图:
其中的
Synchronize Service
模块表示和服务器的长连接通信模块, 用于接受服务器端各种推送, 包括离线包等
Source Merge Service
模块表示对解压后的 H5 资源进行更新, 包括增加文件以旧换新以及删除过期文件等
可以看到, hybrid 模式的 app 架构, 最核心和最难的部分都是 H5 容器的设计
来源: https://juejin.im/entry/5ab34a595188255579189056