mirror of
https://github.com/NohamR/knowledge-kit.git
synced 2026-05-24 20:00:37 +00:00
833 lines
32 KiB
Markdown
833 lines
32 KiB
Markdown
# 一个 Hybrid SDK 设计与实现
|
||
|
||
> 随着移动浪潮的兴起,各种 App 层出不穷,极速发展的业务拓展提升了团队对开发效率的要求,这个时候纯粹使用 Native 开发技术成本难免会更高一点。而 H5 的低成本、高效率、跨平台等特性马上被利用起来了,形成一种新的开发模式: Hybrid App
|
||
>
|
||
> 作为一种混合开发的模式,Hybrid App 底层依赖于 Native 提供的容器(Webview),上层使用各种前端技术完成业务开发(现在三足鼎立的 Vue、React、Angular),底层透明化、上层多样化。这种场景非常有利于前端介入,非常适合业务的快速迭代。于是 Hybrid 火了。
|
||
>
|
||
> 大道理谁都懂,但是按照我知道的情况,还是有非常多的人和公司在 Hybrid 这一块并没有做的很好,所以我将我的经验做一个总结,希望可以帮助广大开发者的技术选型有所帮助
|
||
|
||
|
||
|
||
|
||
## 一、Hybrid 现状
|
||
|
||
可能早期都是 PC 端的网页开发,随着移动互联网的发展,iOS、Android 智能手机的普及,非常多的业务和场景都从 PC 端转移到移动端。开始有前端开发者为移动端开发网页。这样子早期资源打包到 Native App 中会造成应用包体积的增大。越来越多的业务开始用 H5 尝试,这样子难免会需要一个需要访问 Native 功能的地方,这样子可能早期就是懂点前端技术的 Native 开发者自己封装或者暴露 Native 能力给 JS 端,等业务较多的时候者样子很明显不现实,就需要专门的 Hybrid 团队做这个事情;量大了,就需要规矩,就需要规范。
|
||
|
||
总结:
|
||
1. Hybrid 开发效率高、跨平台、低成本
|
||
2. Hybrid 从业务上讲,没有版本问题,有 Bug 可以及时修复
|
||
|
||
Hybrid 在大量应用的时候就需要一定的规范,那么本文将讨论一个 Hybrid 的设计知识。
|
||
- Hybrid 、Native、前端各自的工作是什么
|
||
- Hybrid 交互接口如何设计
|
||
- Hybrid 的 Header 如何设计
|
||
- Hybrid 的如何设计目录结构以及增量机制如何实现
|
||
- 资源缓存策略,白屏问题...
|
||
|
||
|
||
|
||
|
||
## 二、Native 与前端分工
|
||
在做 Hybird 架构设计之前我们需要分清 Native 与前端的界限。首先 Native 提供的是宿主环境,要合理利用 Native 提供的能力,要实现通用的 Hybrid 架构,站在大前端的视觉,我觉得需要考虑以下核心设计问题。
|
||
|
||
|
||
|
||
### 1. 交互设计
|
||
|
||
Hybrid 架构设计的第一要考虑的问题就是如何设计前端与 Native 的交互,如果这块设计不好会对后续的开发、前端框架的维护造成深远影响。并且这种影响是不可逆、积重难返。所以前期需要前端与 Native 好好配合、提供通用的接口。比如
|
||
|
||
1. Native UI 组件、Header 组件、消息类组件
|
||
2. 通讯录、系统、设备信息读取接口
|
||
3. H5 与 Native 的互相跳转。比如 H5 如何跳转到一个 Native 页面,H5 如何新开 Webview 并做动画跳转到另一个 H5 页面
|
||
|
||
|
||
|
||
|
||
### 2. 账号信息设计
|
||
|
||
账号系统是重要且无法避免的,Native 需要设计良好安全的身份验证机制,保证这块对业务开发者足够透明,打通账户体系
|
||
|
||
|
||
|
||
|
||
### 3. Hybrid 开发调试
|
||
|
||
功能设计、编码完并不是真正结束,Native 与前端需要商量出一套可开发调试的模型,不然很多业务开发的工作难以继续。
|
||
|
||
[iOS调试技巧](https://www.jianshu.com/p/f430caa81fa8)
|
||
|
||
Android 调试技巧:
|
||
- App 中开启 Webview 调试(WebView.setWebContentsDebuggingEnabled(true); )
|
||
- chrome 浏览器输入 chrome://inspect/#devices 访问可以调试的 webview 列表
|
||
- 需要翻墙的环境
|
||
|
||
|
||

|
||
|
||
|
||
|
||
|
||
## 三、Hybrid 交互设计
|
||
|
||
Hybrid 交互无非是 Native 调用 H5 页面JS 方法,或者 H5 页面通过 JS 调 Native 提供的接口。2者通信的桥梁是 Webview。
|
||
业界主流的通信方法:1.桥接对象(时机问题,不太主张这种方式);2.自定义 Url scheme
|
||
|
||

|
||
|
||
App 自身定义了 url scheme,将自定义的 url 注册到调度中心,例如
|
||
weixin:// 可以打开微信。
|
||
|
||
关于 Url scheme 如果不太清楚可以看看 [这篇文章](https://www.jianshu.com/p/253479ccc83a)
|
||
|
||
|
||
|
||
|
||
### 1. JS to Native
|
||
|
||
Native 在每个版本都会提供一些 Api,前端会有一个对应的框架团队对其封装,释放业务接口。举例
|
||
|
||
```
|
||
SDGHybrid.http.get() // 向业务服务器拿数据
|
||
SDGHybrid.http.post() // 向业务服务器提交数据
|
||
SDGHybrid.http.sign() // 计算签名
|
||
SDGHybrid.http.getUA() // 获取UserAgent
|
||
```
|
||
|
||
```
|
||
SDGHybridReady(function(arg){
|
||
SDGHybrid.http.post({
|
||
url: arg.baseurl + '/feedback',
|
||
params:{
|
||
title: '点菜很慢',
|
||
content: '服务差'
|
||
},
|
||
success: (data) => {
|
||
renderUI(data);
|
||
},
|
||
fail: (err) => {
|
||
console.log(err);
|
||
}
|
||
})
|
||
})
|
||
```
|
||
|
||
前端框架定义了一个全局变量 SDGHybrid 作为 Native 与前端交互的桥梁,前端可以通过这个对象获得访问 Native 的能力
|
||
|
||
|
||
|
||
|
||
### 2. Api 交互
|
||
|
||
调用 Native Api 接口的方式和使用传统的 Ajax 调用服务器,或者 Native 的网络请求提供的接口相似
|
||

|
||
|
||
所以我们需要封装的就是模拟创建一个类似 Ajax 模型的 Native 请求。
|
||
|
||

|
||
|
||
|
||
|
||
|
||
### 3. 格式约定
|
||
交互的第一步是设计数据格式。这里分为请求数据格式与响应数据格式,参考 Ajax 模型:
|
||
|
||
```
|
||
$.ajax({
|
||
type: "GET",
|
||
url: "test.json",
|
||
data: {username:$("#username").val(), content:$("#content").val()},
|
||
dataType: "json",
|
||
success: function(data){
|
||
renderUI(data);
|
||
}
|
||
});
|
||
```
|
||
```
|
||
$.ajax(options) => XMLHTTPRequest
|
||
type(默认值:GET),HTTP请求方法(GET|POST|DELETE|...)
|
||
url(默认值:当前url),请求的url地址
|
||
data(默认值:'') 请求中的数据如果是字符串则不变,如果为Object,则需要转换为String,含有中文则会encodeURI
|
||
```
|
||
|
||
所以 Hybrid 中的请求模型为:
|
||
```
|
||
requestHybrid({
|
||
// H5 请求由 Native 完成
|
||
tagname: 'NativeRequest',
|
||
// 请求参数
|
||
param: requestObject,
|
||
// 结果的回调
|
||
callback: function (data) {
|
||
renderUI(data);
|
||
}
|
||
});
|
||
```
|
||
这个方法会形成一个 URL,比如:
|
||
`SDGHybrid://NativeRequest?t=1545840397616&callback=Hybrid_1545840397616¶m=%7B%22url%22%3A%22https%3A%2F%2Fwww.datacubr.com%2FApi%2FSearchInfo%2FgetLawsInfo%22%2C%22params%22%3A%7B%22key%22%3A%22%22%2C%22page%22%3A1%2C%22encryption%22%3A1%7D%2C%22Hybrid_Request_Method%22%3A0%7D`
|
||
|
||
Native 的 webview 环境可以监控内部任何的资源请求,判断如果是 SDGHybrid 则分发事件,处理结束可能会携带参数,参数需要先 urldecode 然后将结果数据通过 Webview 获取 window 对象中的 callback(Hybrid_时间戳)
|
||
|
||
数据返回的格式和普通的接口返回格式类似
|
||
```
|
||
{
|
||
errno: 1,
|
||
message: 'App版本过低,请升级App版本',
|
||
data: {}
|
||
}
|
||
```
|
||
这里注意:真实数据在 data 节点中。如果 errno 不为0,则需要提示 message。
|
||
|
||
|
||
简易版本代码实现。
|
||
|
||
```javascript
|
||
//通用的 Hybrid call Native
|
||
window.SDGbrHybrid = window.SDGbrHybrid || {};
|
||
var loadURL = function (url) {
|
||
var iframe = document.createElement('iframe');
|
||
iframe.style.display = "none";
|
||
iframe.style.width = '1px';
|
||
iframe.style.height = '1px';
|
||
iframe.src = url;
|
||
document.body.appendChild(iframe);
|
||
setTimeout(function () {
|
||
iframe.remove();
|
||
}, 100);
|
||
};
|
||
|
||
var _getHybridUrl = function (params) {
|
||
var paramStr = '', url = 'SDGHybrid://';
|
||
url += params.tagname + "?t=" + new Date().getTime();
|
||
if (params.callback) {
|
||
url += "&callback=" + params.callback;
|
||
delete params.callback;
|
||
}
|
||
|
||
if (params.param) {
|
||
paramStr = typeof params.param == "object" ? JSON.stringify(params.param) : params.param;
|
||
url += "¶m=" + encodeURIComponent(paramStr);
|
||
}
|
||
return url;
|
||
};
|
||
|
||
|
||
var requestHybrid = function (params) {
|
||
//生成随机函数
|
||
var tt = (new Date().getTime());
|
||
var t = "Hybrid_" + tt;
|
||
var tmpFn;
|
||
|
||
if (params.callback) {
|
||
tmpFn = params.callback;
|
||
params.callback = t;
|
||
window.SDGHybrid[t] = function (data) {
|
||
tmpFn(data);
|
||
delete window.SDGHybrid[t];
|
||
}
|
||
}
|
||
loadURL(_getHybridUrl(params));
|
||
};
|
||
|
||
//获取版本信息,约定APP的navigator.userAgent版本包含版本信息:scheme/xx.xx.xx
|
||
var getHybridInfo = function () {
|
||
var platform_version = {};
|
||
var na = navigator.userAgent;
|
||
var info = na.match(/scheme\/\d\.\d\.\d/);
|
||
|
||
if (info && info[0]) {
|
||
info = info[0].split('/');
|
||
if (info && info.length == 2) {
|
||
platform_version.platform = info[0];
|
||
platform_version.version = info[1];
|
||
}
|
||
}
|
||
return platform_version;
|
||
};
|
||
```
|
||
Native 对于 H5 来说有个 Webview 容器,框架&&底层不太关心 H5 的业务实现,所以真实业务中 Native 调用 H5 场景较少。
|
||
|
||
上面的网络访问 Native 代码(iOS为例)
|
||
|
||
```objective-c
|
||
typedef NS_ENUM(NSInteger){
|
||
Hybrid_Request_Method_Post = 0,
|
||
Hybrid_Request_Method_Get = 1
|
||
} Hybrid_Request_Method;
|
||
|
||
@interface RequestModel : NSObject
|
||
|
||
@property (nonatomic, strong) NSString *url;
|
||
@property (nonatomic, assign) Hybrid_Request_Method Hybrid_Request_Method;
|
||
@property (nonatomic, strong) NSDictionary *params;
|
||
|
||
@end
|
||
|
||
|
||
@interface HybridRequest : NSObject
|
||
|
||
|
||
+ (void)requestWithNative:(RequestModel *)requestModel hybridRequestSuccess:(void (^)(id responseObject))success hybridRequestfail:(void (^)(void))fail;
|
||
|
||
+ (void)requestWithNative:(RequestModel *)requestModel hybridRequestSuccess:(void (^)(id responseObject))success hybridRequestfail:(void (^)(void))fail{
|
||
//处理请求不全的情况
|
||
NSAssert(requestModel || success || fail, @"Something goes wrong");
|
||
|
||
NSString *url = requestModel.url;
|
||
NSDictionary *params = requestModel.params;
|
||
if (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Get) {
|
||
[AFNetPackage getJSONWithUrl:url parameters:params success:^(id responseObject) {
|
||
success(responseObject);
|
||
} fail:^{
|
||
fail();
|
||
}];
|
||
}
|
||
else if (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Post) {
|
||
[AFNetPackage postJSONWithUrl:url parameters:params success:^(id responseObject) {
|
||
success(responseObject);
|
||
} fail:^{
|
||
fail();
|
||
}];
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
|
||
|
||
|
||
## 四、常用交互 Api
|
||
|
||
良好的交互设计是第一步,在真实业务开发中有一些 Api 一定会由应用场景。
|
||
|
||
|
||
|
||
### 1. 跳转
|
||
跳转是 Hybrid 必用的 Api 之一,对前端来说有以下情况:
|
||
- 页面内跳转,与 Hybrid 无关
|
||
- H5 跳转 Native 界面
|
||
- H5 新开 Webview 跳转 H5 页面,一般动画切换页面
|
||
如果使用动画,按照业务来说分为前进、后退。forward & backword,规定如下,首先是 H5 跳 Native 某个页面
|
||
|
||
```
|
||
//H5跳Native页面
|
||
//=>SDGHybrid://forward?t=1446297487682¶m=%7B%22topage%22%3A%22home%22%2C%22type%22%3A%22h2n%22%2C%22data2%22%3A2%7D
|
||
requestHybrid({
|
||
tagname: 'forward',
|
||
param: {
|
||
// 要去到的页面
|
||
topage: 'home',
|
||
// 跳转方式,H5跳Native
|
||
type: 'native',
|
||
// 其它参数
|
||
data2: 2
|
||
}
|
||
});
|
||
```
|
||
|
||
H5 页面要去 Native 某个页面
|
||
|
||
```
|
||
//=>SDGHybrid://forward?t=1446297653344¶m=%7B%22topage%22%253A%22Goods%252Fdetail%20%20%22%252C%22type%22%253A%22h2n%22%252C%22id%22%253A20151031%7D
|
||
requestHybrid({
|
||
tagname: 'forward',
|
||
param: {
|
||
// 要去到的页面
|
||
topage: 'Goods/detail',
|
||
// 跳转方式,H5跳Native
|
||
type: 'native',
|
||
// 其它参数
|
||
id: 20151031
|
||
}
|
||
});
|
||
```
|
||
|
||
H5 新开 Webview 的方式去跳转 H5
|
||
|
||
```
|
||
requestHybrid({
|
||
tagname: 'forward',
|
||
param: {
|
||
// 要去到的页面,首先找到goods频道,然后定位到detail模块
|
||
topage: 'goods/detail ',
|
||
//跳转方式,H5新开Webview跳转,最后装载H5页面
|
||
type: 'webview',
|
||
//其它参数
|
||
id: 20151031
|
||
}
|
||
});
|
||
```
|
||
|
||
back 与 forward 一致,可能会有 animatetype 参数决定页面切换的时候的动画效果。真实使用的时候可能会全局封装方法去忽略 tagname 细节。
|
||
|
||
|
||
|
||
### 2. Header 组件的设计
|
||
|
||
Native 每次改动都比较“慢”,所以类似 Header 就很需要。
|
||
1. 主流容器都是这么做的,比如微信、手机百度、携程
|
||
2. 没有 Header 一旦出现网络错误或者白屏,App 将陷入假死状态
|
||
|
||
PS: Native 打开 H5,如果 300ms 没有响应则需要 loading 组件,避免白屏
|
||
因为 H5 App 本身就有 Header 组件,站在前端框架层来说,需要确保业务代码是一致的,所有的差异需要在框架层做到透明化,简单来说 Header 的设计需要遵循:
|
||
- H5 Header 组件与 Native 提供的 Header 组件使用调用层接口一致
|
||
- 前端框架层根据环境判断选择应该使用 H5 的 Header 组件抑或 Native 的 Header 组件
|
||
|
||
一般来说 Header 组件需要完成以下功能:
|
||
|
||
1. Header 左侧与右侧可配置,显示为文字或者图标(这里要求 Header 实现主流图标,并且也可由业务控制图标),并需要控制其点击回调
|
||
|
||
2. Header 的 title 可设置为单标题或者主标题、子标题类型,并且可配置 lefticon 与 righticon(icon居中)
|
||
|
||
3. 满足一些特殊配置,比如标签类 Header
|
||
|
||
所以,站在前端业务方来说,Header 的使用方式为(其中 tagname 是不允许重复的):
|
||
|
||
```javascript
|
||
//Native以及前端框架会对特殊tagname的标识做默认回调,如果未注册callback,或者点击回调callback无返回则执行默认方法
|
||
// back前端默认执行History.back,如果不可后退则回到指定URL,Native如果检测到不可后退则返回Naive大首页
|
||
// home前端默认返回指定URL,Native默认返回大首页
|
||
this.header.set({
|
||
left: [
|
||
{
|
||
//如果出现value字段,则默认不使用icon
|
||
tagname: 'back',
|
||
value: '回退',
|
||
//如果设置了lefticon或者righticon,则显示icon
|
||
//native会提供常用图标icon映射,如果找不到,便会去当前业务频道专用目录获取图标
|
||
lefticon: 'back',
|
||
callback: function () { }
|
||
}
|
||
],
|
||
right: [
|
||
{
|
||
//默认icon为tagname,这里为icon
|
||
tagname: 'search',
|
||
callback: function () { }
|
||
},
|
||
//自定义图标
|
||
{
|
||
tagname: 'me',
|
||
//会去hotel频道存储静态header图标资源目录搜寻该图标,没有便使用默认图标
|
||
icon: 'hotel/me.png',
|
||
callback: function () { }
|
||
}
|
||
],
|
||
title: 'title',
|
||
//显示主标题,子标题的场景
|
||
title: ['title', 'subtitle'],
|
||
//定制化title
|
||
title: {
|
||
value: 'title',
|
||
//标题右边图标
|
||
righticon: 'down', //也可以设置lefticon
|
||
//标题类型,默认为空,设置的话需要特殊处理
|
||
//type: 'tabs',
|
||
//点击标题时的回调,默认为空
|
||
callback: function () { }
|
||
}
|
||
});
|
||
```
|
||
|
||
因为 Header 左边一般来说只有一个按钮,所以其对象可以使用这种形式:
|
||
|
||
```javascript
|
||
this.header.set({
|
||
back: function () { },
|
||
title: ''
|
||
});
|
||
//语法糖=>
|
||
this.header.set({
|
||
left: [{
|
||
tagname: 'back',
|
||
callback: function(){}
|
||
}],
|
||
title: '',
|
||
});
|
||
```
|
||
|
||
为完成 Native 端的实现,这里会新增两个接口,向 Native 注册事件,以及注销事件:
|
||
|
||
```javascript
|
||
var registerHybridCallback = function (ns, name, callback) {
|
||
if(!window.Hybrid[ns]) window.Hybrid[ns] = {};
|
||
window.Hybrid[ns][name] = callback;
|
||
};
|
||
|
||
var unRegisterHybridCallback = function (ns) {
|
||
if(!window.Hybrid[ns]) return;
|
||
delete window.Hybrid[ns];
|
||
};
|
||
```
|
||
|
||
Native Header 组件实现:
|
||
|
||
```javascript
|
||
define([], function () {
|
||
'use strict';
|
||
|
||
return _.inherit({
|
||
|
||
propertys: function () {
|
||
|
||
this.left = [];
|
||
this.right = [];
|
||
this.title = {};
|
||
this.view = null;
|
||
|
||
this.hybridEventFlag = 'Header_Event';
|
||
|
||
},
|
||
|
||
//全部更新
|
||
set: function (opts) {
|
||
if (!opts) return;
|
||
|
||
var left = [];
|
||
var right = [];
|
||
var title = {};
|
||
var tmp = {};
|
||
|
||
//语法糖适配
|
||
if (opts.back) {
|
||
tmp = { tagname: 'back' };
|
||
if (typeof opts.back == 'string') tmp.value = opts.back;
|
||
else if (typeof opts.back == 'function') tmp.callback = opts.back;
|
||
else if (typeof opts.back == 'object') _.extend(tmp, opts.back);
|
||
left.push(tmp);
|
||
} else {
|
||
if (opts.left) left = opts.left;
|
||
}
|
||
|
||
//右边按钮必须保持数据一致性
|
||
if (typeof opts.right == 'object' && opts.right.length) right = opts.right
|
||
|
||
if (typeof opts.title == 'string') {
|
||
title.title = opts.title;
|
||
} else if (_.isArray(opts.title) && opts.title.length > 1) {
|
||
title.title = opts.title[0];
|
||
title.subtitle = opts.title[1];
|
||
} else if (typeof opts.title == 'object') {
|
||
_.extend(title, opts.title);
|
||
}
|
||
|
||
this.left = left;
|
||
this.right = right;
|
||
this.title = title;
|
||
this.view = opts.view;
|
||
|
||
this.registerEvents();
|
||
|
||
_.requestHybrid({
|
||
tagname: 'updateheader',
|
||
param: {
|
||
left: this.left,
|
||
right: this.right,
|
||
title: this.title
|
||
}
|
||
});
|
||
|
||
},
|
||
|
||
//注册事件,将事件存于本地
|
||
registerEvents: function () {
|
||
_.unRegisterHybridCallback(this.hybridEventFlag);
|
||
this._addEvent(this.left);
|
||
this._addEvent(this.right);
|
||
this._addEvent(this.title);
|
||
},
|
||
|
||
_addEvent: function (data) {
|
||
if (!_.isArray(data)) data = [data];
|
||
var i, len, tmp, fn, tagname;
|
||
var t = 'header_' + (new Date().getTime());
|
||
|
||
for (i = 0, len = data.length; i < len; i++) {
|
||
tmp = data[i];
|
||
tagname = tmp.tagname || '';
|
||
if (tmp.callback) {
|
||
fn = $.proxy(tmp.callback, this.view);
|
||
tmp.callback = t;
|
||
_.registerHeaderCallback(this.hybridEventFlag, t + '_' + tagname, fn);
|
||
}
|
||
}
|
||
},
|
||
|
||
//显示header
|
||
show: function () {
|
||
_.requestHybrid({
|
||
tagname: 'showheader'
|
||
});
|
||
},
|
||
|
||
//隐藏header
|
||
hide: function () {
|
||
_.requestHybrid({
|
||
tagname: 'hideheader',
|
||
param: {
|
||
animate: true
|
||
}
|
||
});
|
||
},
|
||
|
||
//只更新title,不重置事件,不对header其它地方造成变化,仅仅最简单的header能如此操作
|
||
update: function (title) {
|
||
_.requestHybrid({
|
||
tagname: 'updateheadertitle',
|
||
param: {
|
||
title: 'aaaaa'
|
||
}
|
||
});
|
||
},
|
||
|
||
initialize: function () {
|
||
this.propertys();
|
||
}
|
||
});
|
||
|
||
});
|
||
```
|
||
|
||
|
||
|
||
### 3. 请求类
|
||
|
||
虽然 get 类请求可以用 jsonp 方式绕过跨域问题,但是 post 请求是一个拦路虎。为了安全性问题服务器会设置 cors 仅仅针对几个域名,Hybrid 内嵌静态资源可能是通过本地 file 的方式读取,所以 cors 就行不通了。另外一个问题是防止爬虫获取数据,由于 Native 针对网络做了安全性设置(鉴权、防抓包等),所以 H5 的网络请求由 Native 完成。可能有些人说 H5 的网络请求让 Native 走就安全了吗?我可以继续爬取你的 Dom 节点啊。这个是针对反爬虫的手段一。想知道更多的反爬虫策略可以看看我这篇文章 [Web反爬虫方案](https://github.com/FantasticLBP/Anti-WebSpider)
|
||
|
||

|
||
|
||
这个使用场景和 Header 组件一致,前端框架层必须做到对业务透明化,业务事实上不必关心这个网络请求到底是由 Native 还是浏览器发出。
|
||
|
||
```javascript
|
||
HybridGet = function (url, param, callback) {
|
||
|
||
};
|
||
HybridPost = function (url, param, callback) {
|
||
|
||
};
|
||
```
|
||
|
||
真实的业务场景,会将之封装到数据请求模块,在底层做适配,在H5站点下使用ajax请求,在Native内嵌时使用代理发出,与Native的约定为
|
||
|
||
```javascript
|
||
requestHybrid({
|
||
tagname: 'NativeRequest',
|
||
param: {
|
||
url: arg.Api + "SearchInfo/getLawsInfo",
|
||
params: requestparams,
|
||
Hybrid_Request_Method: 0,
|
||
encryption: 1
|
||
},
|
||
callback: function (data) {
|
||
renderUI(data);
|
||
}
|
||
});
|
||
```
|
||
|
||
|
||
|
||
|
||
## 五、常用 NativeUI 组件
|
||
|
||
一般情况 Native 通常会提供常用的 UI,比如 加载层loading、消息框toast
|
||
|
||
```javascript
|
||
var HybridUI = {};
|
||
HybridUI.showLoading();
|
||
//=>
|
||
requestHybrid({
|
||
tagname: 'showLoading'
|
||
});
|
||
|
||
HybridUI.showToast({
|
||
title: '111',
|
||
//几秒后自动关闭提示框,-1需要点击才会关闭
|
||
hidesec: 3,
|
||
//弹出层关闭时的回调
|
||
callback: function () { }
|
||
});
|
||
//=>
|
||
requestHybrid({
|
||
tagname: 'showToast',
|
||
param: {
|
||
title: '111',
|
||
hidesec: 3,
|
||
callback: function () { }
|
||
}
|
||
});
|
||
```
|
||
|
||
Native UI与前端UI不容易打通,所以在真实业务开发过程中,一般只会使用几个关键的Native UI。
|
||
|
||
|
||
|
||
|
||
## 六、账号系统的设计
|
||
|
||
Webview 中跑的网页,账号登录与否由是否携带密钥 cookie 决定(不能保证密钥的有效性)。因为 Native 不关注业务实现,所以每次载入都有可能是登录成功跳转回来的结果,所以每次载入都需要关注密钥 cookie 变化,以做到登录态数据的一致性。
|
||
|
||
|
||
|
||
- 使用 Native 代理做请求接口,如果没有登录则 Native 层唤起登录页
|
||
- 直连方式使用 ajax 请求接口,如果没登录则在底层唤起登录页(H5)
|
||
|
||
```javascript
|
||
/*
|
||
无论成功与否皆会关闭登录框
|
||
参数包括:
|
||
success 登录成功的回调
|
||
error 登录失败的回调
|
||
url 如果没有设置success,或者success执行后没有返回true,则默认跳往此url
|
||
*/
|
||
HybridUI.Login = function (opts) {
|
||
//...
|
||
};
|
||
//=>
|
||
requestHybrid({
|
||
tagname: 'login',
|
||
param: {
|
||
success: function () { },
|
||
error: function () { },
|
||
url: '...'
|
||
}
|
||
});
|
||
//与登录接口一致,参数一致
|
||
HybridUI.logout = function () {
|
||
//...
|
||
};
|
||
```
|
||
|
||
|
||
在设计 Hybrid 层的时候,接口要做到对于处于 Hybrid 环境中的代码乐意通过接口获取 Native 端存储的用户账号信息;对于处于传统的网页环境,可以通过接口获取线上的账号信息,然后将非敏感的信息存储到 LocalStorage 中,然后每次页面加载从 LocalStorage 读取数据到内存中(比如 Vue.js 框架中的 Vuex,React.js 中的 Redux)
|
||
|
||
|
||
|
||
|
||
## 七、Hybrid 资源管理
|
||
|
||
Hybrid 的资源需要 `增量更新` 需要拆分方便,所以一个 Hybrid 资源结构类似于下面的样子
|
||
|
||

|
||
|
||
|
||
|
||
假设有2个业务线:商城、购物车
|
||
|
||
|
||
|
||
```tex
|
||
WebApp
|
||
│- Mall
|
||
│- Cart
|
||
│ index.html //业务入口html资源,如果不是单页应用会有多个入口
|
||
│ │ main.js //业务所有js资源打包
|
||
│ │
|
||
│ └─static //静态样式资源
|
||
│ ├─css
|
||
│ ├─hybrid //存储业务定制化类Native Header图标
|
||
│ └─images
|
||
├─libs
|
||
│ libs.js //框架所有js资源打包
|
||
│
|
||
└─static
|
||
├─css
|
||
└─images
|
||
```
|
||
|
||
|
||
|
||
|
||
## 八、增量更新
|
||
|
||
每次业务开发完毕后都需要在打包分发平台进行部署上线,之后会生成一个版本号。
|
||
|
||
|
||
| Channel | Version | md5 |
|
||
| ------- | ------- | ----------- |
|
||
| Mall | 1.0.1 | 12233000ww |
|
||
| Cart | 1.1.2 | 28211122wt2 |
|
||
|
||
|
||
当 Native App 启动的时候会从服务端请求一个接口,接口的返回一个 json 串,内容是 App 所包含的各个 H5 业务线的版本号和 md5 信息。
|
||
|
||
拿到 json 后和 App 本地保存的版本信息作比较,发现变动了则去请求相应的接口,接口返回 md5 对应的文件。Native 拿到后完成解压替换。
|
||
|
||
全部替换完毕后将这次接口请求到的资源版本号信息保存替换到 Native 本地。
|
||
|
||
因为是每个资源有版本号,所以如果线上的某个版本存在问题,那么可以根据相应的稳定的版本号回滚到稳定的版本。
|
||
|
||
|
||
|
||
|
||
## 九、体验优化
|
||
|
||
### 1. 静态直出
|
||
|
||
“直出”这个概念对前端同学来说,并不陌生。为了优化首屏体验,大部分主流的页面都会在服务器端拉取首屏数据后通过 NodeJs 进行渲染,然后生成一个包含了首屏数据的 Html 文件,这样子展示首屏的时候,就可以解决内容转菊花的问题了。
|
||
当然这种页面“直出”的方式也会带来一个问题,服务器需要拉取首屏数据,意味着服务端处理耗时增加。
|
||
不过因为现在 Html 都会发布到 CDN 上,WebView 直接从 CDN 上面获取,这块耗时没有对用户造成影响。
|
||
手 Q 里面有一套自动化的构建系统 Vnues,当产品经理修改数据发布后,可以一键启动构建任务,Vnues 系统就会自动同步最新的代码和数据,然后生成新的含首屏 Html,并发布到 CDN 上面去。
|
||
|
||
我们可以做一个类似的事情,自动同步最新的代码和数据,然后生成新的含首屏 Html,并发布到 CDN 上面去
|
||
|
||
### 2. 离线预推
|
||
|
||
页面发布到 CDN 上面去后,那么 WebView 需要发起网络请求去拉取。当用户在弱网络或者网速比较差的环境下,这个加载时间会很长。于是我们通过离线预推的方式,把页面的资源提前拉取到本地,当用户加载资源的时候,相当于从本地加载,即使没有网络,也能展示首屏页面。这个也就是大家熟悉的离线包。
|
||
手 Q 使用 7Z 生成离线包, 同时离线包服务器将新的离线包跟业务对应的历史离线包进行 BsDiff 做二进制差分,生成增量包,进一步降低下载离线包时的带宽成本,下载所消耗的流量从一个完整的离线包(253KB)降低为一个增量包(3KB)。
|
||
|
||
[手Q开源Hybrid框架VasSonic介绍,极致的页面加载速度优化](https://mp.weixin.qq.com/s?__biz=MzUxMzcxMzE5Ng==&mid=2247488218&idx=1&sn=21afe07eb642162111ee210e4a040db2&chksm=f951a799ce262e8f6c1f5bb85e84c2db49ae4ca0acb6df40d9c172fc0baaba58937cf9f0afe4&scene=27#wechat_redirect)
|
||
|
||
|
||
|
||
### 3. 拦截加载
|
||
|
||
事实上,在高度定制的 wap 页面场景下,我们对于 webview 中可能出现的页面类型会进行严格控制。可以通过内容的控制,避免 wap 页中出现外部页面的跳转,也可以通过 webview 的对应代理方法,禁掉我们不希望出现的跳转类型,或者同时使用,双重保护来确保当前 webview 容器中只会出现我们定制过的内容。既然 wap 页的类型是有限的,自然想到,同类型页面大都由前端采用模板生成,页面所使用的 html、css、js 的资源很可能是同一份,或者是有限的几份,把它们直接随客户端打包在本地也就变得可行。加载对应的 url 时,直接 load 本地的资源。
|
||
对于 webview 中的网络请求,其实也可以交由客户端接管,比如在你所采用的 Hybrid 框架中,为前端注册一个发起网络请求的接口。wap 页中的所有网络请求,都通过这个接口来发送。这样客户端可以做的事情就非常多了,举个例子,NSURLProtocol 无法拦截 WKWebview 发起的网络请求,采用 Hybrid 方式交由客户端来发送,便可以实现对应的拦截。
|
||
基于上面的方案,我们的 wap 页的完整展示流程是这样:客户端在 webview 中加载某个 url,判断符合规则,load 本地的模板 html,该页面的内部实现是通过客户端提供的网络请求接口,发起获取具体页面内容的网络请求,获得填充的数据从而完成展示。
|
||
|
||
|
||
NSURLProtocol能够让你去重新定义苹果的URL加载系统(URL Loading System)的行为,URL Loading System里有许多类用于处理URL请求,比如NSURL,NSURLRequest,NSURLConnection和NSURLSession等。当URL Loading System使用NSURLRequest去获取资源的时候,它会创建一个NSURLProtocol子类的实例,你不应该直接实例化一个NSURLProtocol,NSURLProtocol看起来像是一个协议,但其实这是一个类,而且必须使用该类的子类,并且需要被注册。
|
||
|
||
|
||
|
||
### 4. WKWebView 网络请求拦截
|
||
|
||
- 方法一(Native 侧):
|
||
原生 WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此在 WKWebView 上直接使用 NSURLProtocol 是无法拦截请求的。
|
||
|
||
但是由于 mPaas 的离线包机制强依赖网络拦截,所以基于此,mPaaS 利用了 WKWebview 的隐藏 api,去注册拦截网络请求去满足离线包的业务场景需求,参考代码如下:
|
||
|
||
```Objective-c
|
||
[WKBrowsingContextController registerSchemeForCustomProtocol:@"https"]
|
||
```
|
||
|
||
但是因为出于性能的原因,WKWebView 的网络请求在给主进程传递数据的时候会把请求的 body 去掉,导致拦截后请求的 body 参数丢失。
|
||
|
||
在离线包场景,由于页面的资源不需要 body 数据,所以离线包可以正常使用不受影响。但是在 H5 页面内的其他 post 请求会丢失 data 参数。
|
||
|
||
为了解决 post 参数丢失的问题,mPaas 通过在 js 注入代码,hook 了 js 上下文里的 XMLHTTPRequest 对象解决。
|
||
|
||
通过在 JS 层把方法内容组装好,然后通过 WKWebView 的 messageHandler 机制把内容传到主进程,把对应 HTTPBody 然后存起来,随后通知 JS 端继续这个请求,网络请求到主进程后,在将 post 请求对应的 HttpBody 添加上,这样就完成了一次 post 请求的处理。整体流程可以参考如下:
|
||

|
||
通过上面的机制,既满足了离线包的资源拦截诉求,也解决了 post 请求 body 丢失的问题。但是在一些场景还是存在一些问题,需要开发者进行适配。
|
||
|
||
- 方法二(JS 侧):
|
||
通过 AJAX 请求的 hook 方式,将网络请求的信息代理到客户端本地。能拿到 WKWebView 里面的 post 请求信息,剩下的就不是问题啦。
|
||
AJAX hook 的实现可以看这个 [Repo](https://github.com/wendux/Ajax-hook).
|
||
|
||
|
||
|
||
|
||
## 十、离线包
|
||
|
||
传统的 H5 技术容易受到网络环境影响,因而降低 H5 页面的性能。通过使用离线包,可以解决该问题,同时保留 H5 的优点。
|
||
|
||
**离线包** 是将包括 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后通过客户端打开,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。
|
||
|
||
使用 H5 离线包可以给您带来以下优势:
|
||
|
||
- **提升用户体验**:通过离线包的方式把页面内静态资源嵌入到应用中并发布,当用户第一次开启应用的时候,就无需依赖网络环境下载该资源,而是马上开始使用该应用。
|
||
- **实现动态更新**:在推出新版本或是紧急发布的时候,您可以把修改的资源放入离线包,通过更新配置让应用自动下载更新。因此,您无需通过应用商店审核,就能让用户及早接收更新。
|
||
|
||

|
||
|
||
 |