rn-relates
rn-relates copied to clipboard
Tangram 动态布局实践
背景
小鹏 App 非车主首页涉及潜客、潜客车辆配置和预订车主三种业务场景,每种场景所展示的卡片也不尽相同。在该功能上线之初,使用的是传统原生 UITableView 的开发模式,因此当业务需求涉及一些 UI 改动时,只能通过发布新版本达到目的。Android 端之前已经接入 Tangram,并且线上表现稳定,为了提高 App 的动态下发能力,iOS 端的集成也日渐提上日程。区别于 ReactNative 跨端开发、热更修复的特性,App 动态布局更多的是强调插件化,通过在 App 内嵌一个布局坑位,然后下发不同的组件和布局信息,运营童鞋仅仅需要修改配置信息,那么就可以达到期望的布局效果。
经过一段时间的开发和测试,基于 Tangram-iOS 二次封装开发的 XTangram 已经上线,目前经过重构的非车主首页也正在灰度中,暂无发现功能和体验问题。本文会简单介绍 XTangram 的实现过程,其中涉及 VirtualView-iOS、Tangram-iOS 两个框架的一些修改,和部分自定义的功能代码。
开发流程
其实 Tangram 本身不支持动态化,但是在 Tangram 2.0 之后,引入了 VirtualView 的开发方式,具体原理见 官方文档 说明。简单点讲:
- VirtualView 使用了 XML 模板来进行布局,UI 与逻辑分离,学习成本也比较低
- XML 模板将按一定的规则被编译解析成二进制文件,输出以
.out为后缀 - VirtualView 提供加载解析
.out文件的能力,将编译后的 XML 布局文件,渲染成虚拟组件 - Tangram 提供布局坑位,提供不同的
layout来渲染 VirtualView 或是原生的自定义组件
在对 VirtualView 有个简单了解后,可以梳理下完成动态更新模板的流程,当然官方文档其实已经说得很明白,见 动态更新模板。这里罗列下在实践中的一个实际开发流程:
- 使用 virtualview_tools,开启本地文件热加载服务,并在此工程指定目录编写组件 XML
- 使用 VirtualView-iOS 的预览工程,进行组件预览调试
- 利用 virtualview_tools 打包编译组件
- 将打包产物之一,所有组件的 base64 编码添加到 Tangram-iOS 资源文件中,作为保底版本(也可添加
.out文件,XTangram 使用了加载 base64 的方式) - 将所有组件的
.out文件,进行压缩并上传后端,作为后续动态下发版本
- 将打包产物之一,所有组件的 base64 编码添加到 Tangram-iOS 资源文件中,作为保底版本(也可添加
- App 接入 Tangram-iOS,并按需内置业务方的保底布局数据,一般为 JSON
- App 启动后:
- 开始检测远程组件是否有更新,如有则下载新的组件压缩包,在解压后作为下次启动使用的版本
- 优先加载本地保底的布局数据,并同时请求远程数据,在请求响应后使用最新数据刷新页面
XTangram 定制
XTangram 的定制,涉及了框架在工程化使用中的不同节点,主要包括以下方面:
- 远程组件包的下载、版本管理
- 组件的加载、注册
- 首屏渲染处理
- 卡片异步加载、局部刷新
- 统一的事件总线处理
- 动画支持
- 复杂交互背景支持
引擎初始化
XTangramEngine 是框架向外暴露的单例,在初始化时将会读取 App 内置的配置文件,并将读取到的所有组件进行注册。这个流程与官方有所区别,官方的组件加载注册时机,是在调用 TangramView#reloadData 后,在 TangramDefaultDataSourceHelper#elementByModel:layout:tangramBus: 中去新建了对应的 element 实例,实例所对应的类,是 TMVVBaseElement:
// TMVVBaseElement.m
+ (void)initVirtualViewSystem {
// 此方法内进行了加载注册
[VVTempleteManager sharedInstance];
}
- (id)init{
self = [super init];
if (self) {
if (xmlIsLoad==NO) {
[TMVVBaseElement initVirtualViewSystem];
xmlIsLoad = YES;
}
}
return self;
}
XTangramEngine 对组件的处理分两步走:一是加载,二是注册。虚拟组件具备动态性,最新版本可能由服务端下发,所以 App 的沙盒目录中就可能有下载好的组件包。因此,组件的加载必须要区分版本,否则可能出现布局数据与支持的组件不一致的情况。为此,框架引入一个专门针对组件版本校验、下载的角色 XTangramDownloader。有了该角色,那么就可以很方便的知道,当前该加载哪个版本的组件:
// XTangramEngine.m
- (instancetype)init {
if (self = [super init]) {
_isExistsTangramComponents = [XTangramDownloader isExistsTangramComponents];
if (!self.isLoadedXML) {
[self loadAllComponentNames];
[self registerAllVVComponents];
self.loadedXML = YES;
}
}
return self;
}
- (void)loadAllComponentNames {
[self.localComponentDict removeAllObjects];
// 文件目录中有新的组件
if (self.isExistsTangramComponents) {
[self loadComponentNamesFromTangramFolder];
} else {
[self loadComponentNamesFromPlist];
}
}
App 内置版本的组件,在官方基础上,添加了对应的 base64 编码,其格式大抵如下:
<array>
<dict>
<key>element</key>
<string>XPBaseElement</string>
<key>type</key>
<string>SectionHeader</string>
<key>fileName</key>
<string>SectionHeader</string>
<key>base64</key>
<string>base64 code here...</string>
</dict>
</array>
在组件加载完毕之后,按需进行内置(即base64)版本,或是沙盒版本组件的注册。这里用到的,分别对应到以下两个方法:
VVTemplateManager#sharedManager#loadTemplateData:forType:VVTemplateManager#sharedManager#loadTemplateFileAsync:forType:completion:
调整组件初始化时机后,需要注释掉官方的初始化逻辑。后续业务方在使用的时候,可以在适当的时机进行引擎初始化。
首屏渲染处理
我们总是希望一个页面能以最快的速度,完整的展示在用户眼前。为此,XTangram 在首屏渲染阶段,提供了两个代理方法,分别用于获取缓存和网络的布局数据:
@protocol XTangramViewLoadProtocol
@required
/// 从本地缓存获取数据方法,必须实现的方法,业务方可以在此返回本地缓存或是保底的布局数据。
///
/// 如果getDataFromNetwork优先返回,将弃用该方法回调结果,不会触发结束回调;否则正常触发。
///
/// @param loadKey 页面loadKey,对应getLayoutLoadKey返回值
/// @param completion 结束回调,结果将是一个数组
- (void)getDataFromCache:(NSString *)loadKey completion:(XPLayoutResponseBlock)completion;
@required
/// 从网络获取数据方法,必须实现的方法,业务方可以在此发起网络请求,并返回对应的布局数据。
///
/// 如果getDataFromNetwork优先返回,将弃用本地结果,不会触发getDataFromNetwork结束回调。
///
/// @param loadKey 页面loadKey,对应getLayoutLoadKey返回值
/// @param completion 结束回调,结果将是一个数组
- (void)getDataFromNetwork:(NSString *)loadKey completion:(XPLayoutResponseBlock)completion;
@end
XTangram 提供了一个 XTangramView ,该类持有官方的 TangramView 实例,并暴露首屏渲染方法,由用户触发:
// XTangramView.m
- (void)render {
// start first render
if (self.loadDelegate && [self.loadDelegate respondsToSelector:@selector(getLayoutLoadKey)]) {
NSString *loadKey = [self.loadDelegate getLayoutLoadKey];
if (!loadKey || loadKey.length == 0) {
return;
}
// data from cache
if ([self.loadDelegate respondsToSelector:@selector(getDataFromCache:completion:)]) {
NSBlockOperation *cacheBlock = [[NSBlockOperation alloc] init];
__weak NSBlockOperation *weakCacheBlock = cacheBlock;
__weak typeof(self) weakSelf = self;
[cacheBlock addExecutionBlock:^{
__strong typeof(weakSelf) strongSelf = weakSelf;
[weakSelf.loadDelegate getDataFromCache:loadKey completion:^(NSArray * _Nonnull response, BOOL isSuccess) {
// return if is cancelled
if (weakCacheBlock.isCancelled) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
[strongSelf reloadData:response];
});
}];
}];
[self.asyncQueue addOperation:cacheBlock];
}
// data from network
if ([self.loadDelegate respondsToSelector:@selector(getDataFromNetwork:completion:)]) {
NSBlockOperation *netBlock = [[NSBlockOperation alloc] init];
__weak typeof(self) weakSelf = self;
[netBlock addExecutionBlock:^{
__strong typeof(weakSelf) strongSelf = weakSelf;
[weakSelf.loadDelegate getDataFromNetwork:loadKey completion:^(NSArray * _Nonnull response, BOOL isSuccess) {
// cancel cache operation
[strongSelf.asyncQueue cancelAllOperations];
dispatch_async(dispatch_get_main_queue(), ^{
[strongSelf reloadData:response];
});
}];
}];
[self.asyncQueue addOperation:netBlock];
}
}
}
- (void)reloadData:(NSArray *)dataSource {
self.privateDataSource = dataSource;
self.layoutArray = [TangramDefaultDataSourceHelper layoutsWithArray:dataSource];
[self.tangramView reloadData];
}
卡片异步加载
卡片,可理解为原生 UITableView 中的 cell 实例。异步加载有其实际存在的业务场景,比如一个卡片中的数据,依赖于手机的经纬度信息,那么这类数据必定无法通过后端进行下发。因此,有必要对列表卡片做异步加载的支持。
我们期望是在列表进行刷新的时候,如果发现某个卡片是属于异步加载类型,那么就触发对应的加载逻辑,并通知业务方去完成异步数据的请求和组装,返回卡片最终可用的信息。按照思路,可以找到官方的 TangramView#reloadData 方法,定位到其构建列表 model 数组的逻辑,添加对应判断:
// TangramView.m
- (void)reloadData {
// Get all layout
if (self.clDataSource
&& [self.clDataSource conformsToProtocol:@protocol(TangramViewDatasource)]
&& [self.clDataSource respondsToSelector:@selector(numberOfLayoutsInTangramView:)]
&& [self.clDataSource respondsToSelector:@selector(layoutInTangramView:atIndex:)]
&& [self.clDataSource respondsToSelector:@selector(itemModelInTangramView:forLayout:atIndex:)]
&& [self.clDataSource respondsToSelector:@selector(numberOfItemsInTangramView:forLayout:)]
) {
for (int i=0; i< numberOfLayouts; i++) {
...
NSMutableArray *modelArray = [[NSMutableArray alloc] init];
for (int j=0; j<numberOfItemsInLayout; j++) {
id<TangramItemModelProtocol> model = [self.clDataSource itemModelInTangramView:self forLayout:layout atIndex:j];
// check async load in layout
if ([model isKindOfClass:[TangramDefaultItemModel class]]) {
TangramDefaultItemModel *itemModel = (TangramDefaultItemModel *)model;
NSString *asyncLoadKey = [itemModel bizValueForKey:@"load"];
if (asyncLoadKey && asyncLoadKey.length > 0) {
[self startAsyncLoad:asyncLoadKey inTargetLayout:layout itemModel:itemModel];
}
}
[modelArray tm_safeAddObject:model];
}
[layout setItemModels:[NSArray arrayWithArray:modelArray]];
}
[super reloadData];
}
}
可以看到,我们为每个异步加载的卡片,新增了 load 字段,当检测到有该字段时,那么就触发一个对应的异步加载请求。这里为 TangramView 新增一个分类 TangramView+AsyncLoad 来处理,并在 XTangramView 设置代理时,将其代理关联到分类中的属性:
// XTangramView+AsyncLoad.m
- (id<XTangramViewLoadProtocol>)loadDelegate {
id<XTangramViewLoadProtocol> delegate = objc_getAssociatedObject(self, kXTangramViewLoadDelegate);
return delegate;
}
- (void)setLoadDelegate:(id<XTangramViewLoadProtocol>)loadDelegate {
objc_setAssociatedObject(self, kXTangramViewLoadDelegate, loadDelegate, OBJC_ASSOCIATION_ASSIGN);
}
- (void)startAsyncLoad:(NSString *)loadKey inTargetLayout:(UIView<TangramLayoutProtocol> *)layout itemModel:(TangramDefaultItemModel *)itemModel {
if (self.loadDelegate && [self.loadDelegate respondsToSelector:@selector(asyncLoadJSONDataWithKey:originData:completion:)]) {
__weak typeof(self) weakSelf = self;
[self.loadDelegate asyncLoadJSONDataWithKey:loadKey originData:itemModel.privateOriginalDict completion:^(NSDictionary * _Nonnull resDict) {
// 空数据
if (!resDict || resDict.count == 0) {
return;
}
// update data
for (NSString *dictKey in resDict.allKeys) {
[itemModel setBizValue:[resDict tm_safeObjectForKey:dictKey] forKey:dictKey];
}
__weak typeof(weakSelf) strongSelf = weakSelf;
dispatch_async(dispatch_get_main_queue(), ^{
// update height
Class clazz = NSClassFromString(itemModel.linkElementName);
if ([clazz conformsToProtocol:@protocol(TangramElementHeightProtocol)]) {
Class<TangramElementHeightProtocol> elementClass = NSClassFromString(itemModel.linkElementName);
if ([clazz instanceMethodForSelector:@selector(heightByModel:)]) {
itemModel.heightFromElement = [elementClass heightByModel:itemModel];
}
}
// reload target layout
[strongSelf reloadLayout:layout];
});
}];
}
}
XTangramView 中设置代理的处理:
// XTangramView.m
- (void)setLoadDataDelegate:(id<XTangramViewLoadProtocol>)loadDataDelegate {
if (_loadDataDelegate != loadDataDelegate) {
_loadDataDelegate = loadDataDelegate;
self.tangramView.loadDelegate = loadDataDelegate;
}
}
然后即可在业务方实现 XTangramView 对应代理方法,根据不同的 load 值来发起对应请求:
// MockViewController.m
#pragma mark - XPTangramAsyncItemLoadDelegate
- (void)asyncLoadJSONDataWithKey:(NSString *)loadKey originData:(NSDictionary *)originData completion:(void (^)(NSDictionary * _Nonnull))completion {
if ([loadKey isEqualToString:@"async_load_key.location"]) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[NSThread sleepForTimeInterval:2];
completion(@{@"itemTitle": @"title from mock view controller"});
});
} else if ([loadKey isEqualToString:@"async_load_key.weather"]) {
}
}
-(XTangramView *)tangramView
{
if (nil == _tangramView) {
_tangramView = [[XTangramView alloc]init];
_tangramView.frame = self.view.bounds;
_tangramView.loadDataDelegate = self;
_tangramView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1];
[self.view addSubview:_tangramView];
}
return _tangramView;
}
卡片局部刷新
在做异步加载支持的时候,其实已经涉及到局部刷新。因为异步数据回来,我们期望刷新的是对应的卡片实例,而不是重刷整个列表。另外一个前提是,重刷列表会重新触发卡片异步加载逻辑,这样就会出现死循环了。
这里提供一个根据 model 刷新卡片的处理:
// XTangramView.m
- (void)reloadLayoutWithModel:(TangramDefaultItemModel *)itemModel data:(NSDictionary *)newData {
UIView<TangramLayoutProtocol> *targetLayout = [self findLayoutByModel:itemModel];
[self updateLayout:targetLayout data:newData];
}
- (UIView<TangramLayoutProtocol> *)findLayoutByModel:(TangramDefaultItemModel *)itemModel {
if (!itemModel) { return nil; }
UIView<TangramLayoutProtocol> *targetLayout = nil;
for (UIView<TangramLayoutProtocol> *layoutItem in self.layoutArray) {
TangramDefaultItemModel *model = layoutItem.itemModels.firstObject;
// match model
if (model == itemModel) {
targetLayout = layoutItem;
break;
}
}
return targetLayout;
}
- (void)updateLayout:(UIView<TangramLayoutProtocol> *)layout data:(NSDictionary *)newData {
if (!layout || !newData || newData.count == 0) {
return;
}
TangramDefaultItemModel *model = layout.itemModels.firstObject;
// update data
for (NSString *dictKey in newData.allKeys) {
[model setBizValue:newData[dictKey] forKey:dictKey];
}
// update height
Class clazz = NSClassFromString(model.linkElementName);
if ([clazz conformsToProtocol:@protocol(TangramElementHeightProtocol)]) {
Class<TangramElementHeightProtocol> elementClass = NSClassFromString(model.linkElementName);
if ([clazz instanceMethodForSelector:@selector(heightByModel:)]) {
model.heightFromElement = [elementClass heightByModel:model];
}
}
[self.tangramView reloadLayout:layout];
}
其他
关于事件总线这里就不再赘述了,大概就是在单例中维护唯一的 TangramBus 实例,同时转发 VirtualView 中的所有点击事件到业务侧。
动画和复杂交互背景是 XTangram 正在支持的特性,目前已经支持普通动画、组合动画和多个组合动画的动态下发,也添加了几个常见 timingFunction 的支持,同时还提供了自定义 timingFunction 的功能(即通过贝塞尔控制点参数完成)。动画的支持,有另外一个方向,是增加列表滑动事件的透传,使其支持稍微复杂的手势动画。目前两端皆以支持,待 UI 童鞋提供实际的应用场景,再做粒度更细的开发和优化。
复杂交互背景,大抵是跟组件一些状态相关,例如 pressed、selected 和 enabled 等等。这里的背景,其实大部分是指渐变背景,另外也包括了边框线和圆角之类。这块的支持目前尚在跟进中,待后续实际上线后再做更新。
结语
从开始预研 Tangram-iOS,到 XTangram 产出,再到跟随项目上线,这其中有不少的故事。除去中间因为架构调整,小组工作计划安排上的更改,还有框架本身存在的一些小 Bug。比如之前 Android 端接入时所开发的组件,在 iOS 端就存在样式问题,在调试过程中,进而发现了框架中某些 layout 的布局问题。
App 动态化能力是每个团队都在探索的领域,其不管是对运营人员,还是开发人员,都有一定的积极意义。在 Tangram 2.0 提出 VirtualView 的概念后,App 原生端的动态布局能力成为了可能。由于共用一套布局 XML,在实际的开发过程中,也会遇到两端组件样式的适配问题,这点与 ReactNative 有点类似。好在 Tangram 提供了 virtualview_tools 工具,具备热加载功能,对研发效率还是有不少提升的。不过现在官方基本没有维护了,所以读者所在团队在考虑接入时,还是有必要做一番调研之后再做决定。