热更新是一个非常方便的方案。在应对大量用户和深度定制的时候一定不能使用开源的方案。
一般第三方的这种方案,服务器带宽较小,或者不够灵活,不能满足自己的想法。
这里推荐自己实现对应的热更新方案。只需要少量代码即可支持。
下面推荐一种灵活的热更新方案。包括客户端的改造、接口设计、界面开发,同时是开源的!可以自由改造。
体验地址:demo 用户名密码都是:admin
基础数据的准备和实现
首先第一点,一个 APP 如果要支持热更新,需要在打开 APP(或者其他进入 RN 页面之前)就要判断是否需要更新 bundle 文件。这里就是我们实现热更新的节点。一旦需要热更新就开始下载文件,而判断的接口就是我们这次文章的核心内容。这里简单贴出安卓和 ios 两端的下载逻辑。
请求之前需要在 head 中附带上客户端的几个重要信息。客户端版本号 version、客户端唯一 id:clientid、客户端类型 platform、客户端品牌 brand。
ios 下载的例子
-(void)doCheckUpdate
{
self.upView.viewButtonStart.hidden = YES;
if ([XCUploadManager isFileExist:[XCUploadManager bundlePathUrl].path])
{// 沙盒里已经有了下载好的 jsbundle,以沙盒文件优先
self.oldSign = [FileHash md5HashOfFileAtPath:[XCUploadManager bundlePathUrl].path];
}else
{// 真机计算出的包内 bundlemd5 有变化,可能是压缩了,所以这里写死初始化的 md5
// NSString *ipPath = [[NSBundle mainBundle] pathForResource:@"main" ofType:@"jsbundle"];
// self.oldSign = [FileHash md5HashOfFileAtPath:ipPath];
self.oldSign = projectBundleMd5;
}
AFHTTPSessionManager *_sharedClient = [[AFHTTPSessionManager alloc] initWithBaseURL:[NSURL URLWithString:@"http://test.com"]];
[self initAFNetClient:_sharedClient];
[_sharedClient GET:@"api/check" parameters:nil progress:nil success:^(NSURLSessionDataTask * __unused task, id JSON) {NSDictionary *dic = [JSON valueForKeyPath:@"data"];
BOOL isNeedLoadBundle = YES;
if ([dic isKindOfClass:[NSDictionary class]])
{self.updateSign = [dic stringForKey:@"sign"];
self.downLoadUrl = [dic stringForKey:@"downloadUrl"];
if(self.updateSign.length && self.oldSign.length && (![self.updateSign isEqualToString:self.oldSign]))
{
// 需要更新 bundle 文件了
self.upView.viewUpdate.hidden = NO;
[self updateBundleNow];
isNeedLoadBundle = NO;
}else
{
// 不需要更新 bundle 文件,再处理跳过按钮显示逻辑
[self.upView showSkipButtonOrNot];
}
}
if (isNeedLoadBundle) {[self loadBundle];
}
} failure:^(NSURLSessionDataTask *__unused task, NSError *error) {[self loadBundle];
}];
}
安卓下载的例子
private void requestData() {
subscribe = DalingNetwork
.getDalingApi()
.getBundleVersion()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new BaseSubscriber<BundleVersionResponse>() {
@Override
public void onError(Throwable e) {startMainActivity();
e.printStackTrace();}
@Override
public void onNext(final BundleVersionResponse response) {
isJSNeedUpdate = false;
if (response.status == 0) {if (response.data != null) {if (MainApplication.getApplication().getBundleMD5().equalsIgnoreCase(response.data.sign)) {
// 和本地版本相同,直接进入主页
isJSNeedUpdate = false;
tv_skip.setVisibility(View.VISIBLE);
startMainActivity();} else {
// 下载升级
isJSNeedUpdate = true;
downloadSign = response.data.sign;
downloadUrl = response.data.downloadUrl;
downLoad(response.data.downloadUrl, response.data.sign);
}
}
} else {startMainActivity();
}
}
});
}
系统设计方案
首先来看一下我们是怎样设计客户端获取更新逻辑的。
- 客户端请求的时候会带上版本号、平台 2 个重要信息。
- 接口拿到请求之后查询对应的本地缓存,没有则去数据库查询。
- 从查询结果中筛查对应的 3 段数据:白名单、灰度、全量,判断顺序从左到右。
- 返回查询之后对应的结果。
数据库等设计
上面的设计是基础的逻辑,下面我们继续细化逻辑。其中为了支持更好的性能和分布式做了一些其他的方案设计。
根据逻辑自行设计是完全可以的????
数据库设计
我们选择 MySQL 作为基础数据库,负责存储每次发布之后的数据保存。fe_bundle
表存储的是每次发布的 bundle 信息,主要分 3 个部分:
- 表本身需要的数据。id、状态、操作人、发布说明。
- 判断是否更新的依据字段。版本号、平台、客户端 id、bundle 的签名、地址、压缩包的地址。
- 作为附加数据在接口返回的。标签 id、标签内容。
fe_labels
表就是作为附加数据存储的。如果想要在接口上返回一些复杂的操作,比如显示隐藏某个界面、是否加载某个 bundle、是否强制更新等,都可以在这里设置。这个表本身只支持添加和是否启用,不支持删除,防止误操作。
根据实际情况减少字段的长度可以优化数据库的查询性能。比如昵称的长度不会超过 10 个字符。
大量数据的情况下添加索引也会提高数据库性能。查询的时候只查询需要的字段也可以减少查询的时间。
发布订阅设计
使用发布订阅模式主要是为了同步每次发布的结果。这样做可以解耦发布和本地缓存更新,多个服务器支持也不会出现资源争夺或者更新不及时的情况。
这里使用的是 redis 的发布订阅模式,可以选的其他方案有 MQ 的消息队列等方式。在收到消息的时候主动更新本地缓存。
本地缓存设计
接口响应速度快不快的关键就是在本地缓存这里了。毕竟在用户大量访问的情况下,一个数据库是非常难支撑的。这里利用本地缓存减少数据库的查询,不管是面对多少用户,实际在工作的就只有接口所在的服务器线程。而且这里利用了 nodejs 的高并发优势,只要机器抗的住,我们的服务就不会卡顿或者挂掉。服务能支持的并发数几乎等于机器支持的并发数。
- 本地缓存的优点就是查询速度快,没有网络请求的消耗。
- 在遇到缓存没有的情况下,去数据库读取数据并缓存在本地。
- 使用双缓存,避免多个请求来临的情况下并发打垮数据库。
- 双缓存只是应对特殊情况,比如本地缓存失效、服务器重启等情况下的大量请求。正常情况下发布订阅已经解决了本地缓存的问题。
前台界面开发
前台界面使用 React+Mobx+ElementUI 实现。这里选择这个技术栈主要是为了方便,毕竟会 RN 的开发者大概率是可以很快上手 React 的。
- React 作为基础框架,利用框架的优势快速开发。
- Mobx 作为状态管理,这次项目中只利用到了用户信息的全局管理。
- ElementUI 的几个 UI 还不错,这里利用现成的 UI 开发,剩下大量的设计精力。
登录界面
登录只需要简单的一个背景 + 登录信息输入框即可。有兴趣的可以优化一下,让界面更好看。
这里利用 Mobx 将用户的登录信息保存在全局缓存中。这个设计比较简陋,在公司内部用一下还可以了。如果是开发给更多人用一定要完善一下,把用户鉴权做的更安全一些。
bundle 管理界面
列表管理只需要显示关键信息即可。列出查询的几个参数,方便查询。在点击删除的时候要弹出是否删除的提示,点击发布的时候也需要弹出提示。
编辑的时候给出几个固定选项。如果是灰度的时候还能够选择不同的手机品牌、灰度的比例。如果是白名单模式,需要填入白名单对应的 clientid。
标签管理
标签的核心就是添加和使用。在添加的时候定义好添加的字段和值类型。只需要一次添加即可完成。客户端兼容????️值情况下的兼容就好了。
后端接口开发
接口分 2 个部分,一部分是应对后台的编辑列表等接口,另外一个部分是应对大量用户的查询接口。
编辑查询接口
接口开发其实非常简单,如果对数据库使用不熟练的可以看看相应的文档或者教程。
sequelize 简单教程
接口开发 3 个步骤:
- 获取请求的参数。这里最好添加默认值处理,异常校验。
- 查询数据库。处理正常返回和 catch 报错的 2 种情况。
- 按照约定的规范返回具体的内容。
这里约定,返回
status=0
是查询成功,所有数据放在data
字段里。
返回status=1
代表查询失败,错误信息放在msg
字段里。
查询接口
查询接口分 2 个线程,一个线程是网络请求线程,管理来访的网络请求和筛选返回。另外一个线程管理本地更新,通过 redis 的订阅模式触发对应的数据更新。
缓存更新
当 redis 通知到需要更新的时候会带上版本号、平台的数据库。我们本地缓存也是由这 2 个字段作为 key 缓存的。searchFromService
这个方法主要是从数据库拿对应的数据列表,并且在拿到数据之后手工把数据分为 3 个部分,分别用来处理白名单、灰度、全量的数据。他们对应的返回也是 N 个白名单、N 个灰度、1 个全量数据。
网络请求
网络请求逻辑较复杂,需要首先从缓存中拿数据,同时可能触发数据库拿数据并处理到缓存中,备份缓存拿数据并返回。
数据来源确定之后就开始分阶段筛选。
- 筛选是否存在合适的白名单数据。
- 筛选是否存在合适的灰度数据
- 判断对应的全量数据是否存在。
以上判断全部完成之后就可以知道本次请求是否有合适的 bundle 了。没有的话客户端也不需要更新。用户可以正常打开并浏览。
判断灰度的时候 clientid 中可能会带字母。这情况下需要将字母转为数据再判断。
这里的转化是简单的字母数字对应,具体表现就是百分比前移。前 60% 的用户量会大于后 40% 的用户量。如果对这个有要求的可以按照 26 进制转 10 进制的方式转化数据。拿到的就是真实的百分比了。
源代码地址
前台页面地址:前台代码
后台接口地址:后台代码
数据库地址:数据库代码