上次文章介绍了Hybrid开发中常用到的Web和NA的通信方案:JSBridge,通过比较之后,最终推荐安卓和iOS通用的scheme协议,可以保证APP内和外都可以使用,今天开始正式介绍整个Hybrid架构内容。就像本系列「开篇语」提到的,这里的Hybrid是「狭义的Hybrid」,而不是所有的NA套个webapp就是Hybrid。
好的架构不是随波逐流,应该经得起考研
Hybrid技术体系是一套很多技术组成的完整知识架构。拿手百Hybrid方案,包括规范/约定、开发、联调、服务支撑等整个开发流程,而随着PWA之类「新」技术的产生又在考研技术架构面向未来的设计能力,所以手百Hybrid还在不断完善,注意是不断完善,不是推到重来!手百Hybrid技术整体架构如下:

接下来系列会围绕本架构图中重要的部分依次展开,全面的介绍Hybrid开发知识,本文介绍模板本地化开发方案。
所谓「模板本地化」,就是将Web页面内置到APP内,随版发布上线,然后通过云端接口实现更新,再直白一点:将H5网站的页面预先放到客户端发版,然后云端更新新版本。
这个方案好处在于:
- 本地化模板提高页面打开速度,减少用户等待时间
- 模板可更新,版本控制更方便可控,收敛快
- Web页面和NA内置代码实现一套代码,减少开发成本
- 上手成本小,开发就是实际开发Web(H5)页面,通过构建工具,生成Web页面和NA模板包不同的代码
- 标准H5代码,迁移成本小,通过Node和构建工具,可以做到H5版本前后同构,将来还可以不改代码的前提下适配PWA
本文提到的JSBridge调起协议是统一使用hybrid://
开头,只是提供思路和介绍整体Hybrid模板包架构和用到的技术,不涉及到具体代码,但是文章保证干货和诚意都满满😄。
模板本地化实现方案
本文会介绍两种模板本地化方案,分别是:依赖客户端拦截器(proxy)的方案一;完全无域名限制的方案二,两个方案的实现流程如下:

通过流程图来看,方案一比方案二多了拦截器。
方案一是「可感知」的,根据本地缓存的模板包域名(可下发、可完全根据缓存的模板域名)进行过滤,如果本地有则读取本地缓存,如果本地没有则还是访问线上(无损)。
方案二 是「预置型」的方案,只有发版时候规定的某些页面或者频道(插件)开通了Hybrid功能,才能使用Hybrid模板包缓存。
模板包设计方案
根据上面提到的两种方案,对应的模板包打包内容也不一样。具体包内容目录结构如下所示:

两种方案对比
方案一:拦截器 | 方案二:无拦截器 | |
---|---|---|
cookie | 带域名符合http规范 | 需要native支持 |
入口开放 | 方便,无需发版 | 需要发版 |
NA开发成本 | 拦截器开发 | 浏览器能力对齐 |
开发联调成本 | 较低 | 较高 |
webapp改造成本 | 方便 | 不方便 |
安全性 | 域名限制 | 需要独立控制 |
打包方案 | 按照网站目录打包 | 按照插件/频道打包 |
扩展性 | 依照PWA模式扩展能力 | - |
方案一相对来说客户端开发量较大,而且拦截域名太多会有性能问题,但是优点也相对较多一些,具体需要自己权衡,本系列本意是扫盲和普及Hybrid开发知识,尽量全面的介绍Hybrid技术,不针对某种方案深入展开,后续讨论内容也是如此。
浏览器能力对齐
模板本地化之后,客户端实际是通过file://
协议访问本地模板,这样会导致跟域名相关的操作和方法需要客户端提供端能力支持,目前最重要的是一下几个:
- http请求:如果在file协议下直接请求http[s]会引起跨域(Tips:跨域的几种情况有哪些?
- cookie操作:域名没了,cookie怎么办?session也是cookie的实现
- localstorage/sessionstorage:这俩是根据域名划分存储空间和读写的
这些浏览器的基本能力需要依靠客户端为页面提供端能力来补齐。浏览器根据域名做了限制的端能力,是遵守「同源策略」,所以在补齐端能力的时候,应该考虑进去「同源策略」,不能无节制的开通端能力
同源策略
考虑到同源策略,每个模板包会对应一个域名限制(系列文章中上一篇提到过JSBridge的安全鉴权问题),两种方案分别对应的同源策略解决方案是:
- 方案一拦截器:代理的域名就是要限制的域名范围
- 方案二无拦截器:在package.json/manifest.json中添加域名权限申请,比如
domain:xxx.baidu.com
http请求
根据本系列上篇JSBridge的最佳实践结合常用的请求方式,设计http请求的接口如下:
- GET:hybrid://http/get?url=http://baidu.com&query={key1:value1,key2:value2}
- POST:hybrid://http/post?url=http://baidu.com&data={key1:value1,key2:value2}
从协议上来看,post和get并没有差异,但是hybrid的JSBridge协议设计是基于URL schema协议的,所以会有长度限制,对于简单的post操作使用这个协议就可以,但是对于post的data太大的情况下(不考虑上传文件等多媒体类POST操作,这方面可以通过后面文章提到的device API增强解决),需要提供
cookie操作
为了防止网站cookie被恶意修改,很多网站已经使用httponly的方式来操作cookie,这种情况下cookie其实是没有必要通过前端使用js操作的,而且在file://
协议下发送http[s]://
API请求,http协议也会带上域名和路径下的cookie,所以cookie的操作我是不建议开端能力的。但是凡事都有但是。。。cookie操作还是设计上吧:
hybrid://cookie/[get/set/delete]?name=value,expire=xxx;name2=value2,path=/
浏览器缓存
浏览器缓存的设计参考web storage的key-value存储设计,然后增加过期时间,类似memcached这类内存缓存,因为value是string类型,所以不像redis那样支持多种存储类型格式。
hybrid://cache/save?key=xxx&value=xxx&callback=xxx[&expire=xxx]
hybrid://cache/get?key=xxx&callback=xxx
hybrid://cache/delete?key=xxx&callback=xxx
构建工具
为了保证后续一套代码,在不同的构建流程打出不一样的模板包(比如H5版本和hybrid版本),构建过程不再是简单的压缩资源,而是根据打包类型对组件进行有选择性的pick,这里我们使用 FIS3
,根据不同的 media
打出不一样的包。例如:
fis.media('hybrid').match(…)
fis.media('webapp').match(…)
详细用法请查看fis.baidu.com ,这里不再展开。
为什么要用pick?
我们的H5代码和Hybrid的代码都在一起,完全组件化之后,除了Hybrid的差异化的功能性组件,其他组件都是可以通用的。这些差异化的组件包括:
- 上一章节提到的Http、cache和cookie这类跟域名相关的代码,我们分别封装了Hybrid内和H5版本,比如Http都叫Fetch,但是内部实现:hybrid是调用NA接口,H5是Fetch+ajax
- 浏览和导航类:比如跳转/打开新页面,NA内是打开一个新的webview,H5是
location
跳转或直接A标签 - 端能力和增强类组件:同样是大图浏览器,为了更好的交互,NA上做了增强,H5是纯web实现
这些差异性的代码都封装在不同的组件内,通过设计模式中的「接口模式」对外暴漏一样的接口和使用方法。
这种设计,可以更好的提高业务同事学习和编写程序的成本,同时统一的组件化管理方便单元测试和联调,(划重点:等本系列联调部分会再重提这种设计的好处,那时候会发现这设计太赞了!)
代码pick怎么实现?
这里安利集鹄大叔的jdists(有对应的fis插件):https://github.com/zswang/jdists
通过jdists可以通过注释的方式对代码进行区域化裁剪和联调功能,比如下面代码可以通过fis3的media来触发trigger,从而在不同的条件下暴漏不同的代码区域:
/*<debug>*/
console.log(debug);
/*</debug>*/
/*<jdists trigger="hybrid">
var fetch = require('na/fetch')
</jdists>*/
/*<jdists trigger="webapp">
var fetch = require('web/fetch')
</jdists>*/
构建模板包
除了代码pick之外,构建工具还要和模板包管理平台配合,增加额外的文件生成(比如生成版本号、签名、diff包)做到完全自动化,然后包管理平台去拉取构建工具生成的zip包,上传到CDN平台,在数据库创建一条记录,关于模板包管理平台的内容,在下一篇文章详细介绍。
模板更新和容错策略
模板包设计好了之后,就需要考虑下发和更新的流程。除了考虑模板的更新,还需要考虑到容错机制,保证模板包升级不断出现问题或者某些极端情况下,服务可用。
模板更新
模板更新时机是很讲究的,不同的APP产品可以利用的时机可能不一样,目前针对手百产品合适的时机有:
- APP冷热启动
- 接口检测
- 频道异步检查,当打开某个Hybrid频道后台开始更新
- 页面主动检测
- 推送
综合来说,不管怎样的更新时机,都逃不出两种更新的途径:
- 通过专门的NA更新接口上传本地版本号,server下发最新版本信息,完全有NA完成模板包更新
- 通过页面JS接口主动检测进行更新
第一种途径,比如冷热启动、接口和频道异步都是通过上行请求将APP内单个或者多个频道的当前模板版本号上传给server,server根据模板维护cms推送过来的最新版本挨个频道依次比较版本号,如果版本号有更新,就下发对应的最新流量包地址和校验信息。
而页面主动检测包括两种:
- 在页面发起请求的API接口中带有当前的版本号,如果接口判断有更新,则下发对应的更新包信息(下载地址和校验信息),然后页面js通过NA接口通知客户端更新
- 页面js直接调用NA接口强制进行一次单独的检测,这样客户端会带着版本号走专门的接口只进行模板版本的更新检测,然后走NA的更新流程
容错机制
当得知某个版本号存在不可修复的bug,需要紧急容错,可以通过server下发对应的command(app调起协议,也是一种schema)来指定在某个版本(模板版本、客户端版本)或者版本号范围内不调用Hybrid,而直接访问H5页面,达到快速止损。
举例说明:新闻列表是NA实现的,新闻的详情页是Hybrid的做的,所以Hybrid的上游是NA做的list,Hybrid详情页是通过NA的列表调起展现的。NA的list数据和调起协议(command)是由server下发的,如果server下发的是调起Hybrid页面,那么用户点击list打开Hybrid详情页;当Hybrid版本在某些情况下有问题(比如在某些客户端版本上有bug或者直接Hybrid某版本包就有bug),那么Server下发给NA调起详情页的Command就变成直接打开H5 webapp,而不是打开Hybrid,从而将有问题的Hybrid切换到线上H5,达到容错止损。
版本包回滚
除了server端止损之外,我们还设计了回滚机制,在包管理平台,可以将任意一个已经上线的版本包作为回滚包,然后生成新的版本号进行下发回滚。
客户端回滚
客户端在安装更新包之前,先将老的版本包进行备份,然后开始安装,如果安装过程遇见问题,比如覆盖失败,或者空间不够等各种问题,就需要删除之前的安装操作,将备份包重新解压,保证整个安装过程是完整的,而不是安装一半。
提高模板包的更新成功率
针对模板包的收敛率率,除了更新时机之外,还应该从链路优化和包体积方面进行优化。
具体策略如下所列:
- 针对模板包做专门的通信线路和协议优化
- 使用CDN就近存储
- 断点续传
- 分块下载
- 重试策略
- 减小包体积
- 增量更新包设计
增量包设计
增量包的设计一般有两种方式,可以根据不同的场景和开发周期进行选择:
- 按文件diff进行增量下发
- 按二进制包进行增量下发
按文件diff进行增量
这种方式对于打包工具来说很简单,而且客户端没有开发工作量。
客户端拿到模板更新包是整体覆盖安装的,覆盖安装的意思是:遇见新的文件就直接新增,遇见重名的文件就直接覆盖安装。这就有点像前端静态资源管理,可以打md5进行增量,可以同名覆盖。
我们做法是当发版的时候,编译工具打出一个全量包,然后从版本库中找到最近的3次版本包,对文件进行遍历,找出diff,然后形成Vn-Vn+1
的diff包,而这一切都是自动维护的。
我什么是最近3个版本包做diff,而不是全部?
因为如果全部随着发版越来越多,diff包会呈现指数增长,增加维护成本;而且模板收敛好了,两个之前老版本的量就很小了,没有必要为这些版本做增量包;再说全量包也不会大到哪里去嘛~
按二进制包进行增量下发
这种方式是将模板zip包,根据bsdiff差分算法打出不同的patch,然后将下发给客户端,客户端再加上上一个版本的包生成新的全量包。
两种方式各有利弊,实际应用要权衡成本和收益。
模板包安全
模板包在下发的过程中,会遇见被篡改的问题,而且包如果不完整,也会对Hybrid模板包的完整性和功能构成威胁。
首先整个模板包的更新接口和模板包zip的CDN都是使用HTTPS。另外在包安全性方面我们分别做了两个校验:
- 模板完整性检测
- 模板合法性校验
结合整个更新流程(客户端和server交互部分)来讲下:

在server升级接口会返回两个最重要的字段:md5
和signature
。
- md5用于校验zip的完整性,保证zip是完整的,可解压的;
- signature是签名,签名算法是结合私钥、请求参数、版本号、客户端特征值和模板包的md5等组合计算的,签名算法是保证了包的安全性;这样即使包被劫持替换,不知道签名算法也不会通过校验。
写在最后
知识是相通的,要学会举一反三,文章为了通俗易懂,很多技术介绍根据前端常见的场景做了类比。只要有心,涉猎范围够广,你会发现:
- 写过chrome扩展的对于模板本地化方案是不是似曾相识?
- 如果觉得和开发chrome扩展类似,能不能利用chrome的调试功能进行调试?
- 而模板本地化方案又跟PWA是不是可以通用?
- PWA解决的痛点是什么?和本文的模板本地化方案怎么快速切换?
这些问题都值得深入思考,且听后续文章慢慢道来~