We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
小鹏 App 非车主首页涉及潜客、潜客车辆配置和预订车主三种业务场景,每种场景所展示的卡片也不尽相同。在该功能上线之初,使用的是传统原生 UITableView 的开发模式,因此当业务需求涉及一些 UI 改动时,只能通过发布新版本达到目的。Android 端之前已经接入 Tangram,并且线上表现稳定,为了提高 App 的动态下发能力,iOS 端的集成也日渐提上日程。区别于 ReactNative 跨端开发、热更修复的特性,App 动态布局更多的是强调插件化,通过在 App 内嵌一个布局坑位,然后下发不同的组件和布局信息,运营童鞋仅仅需要修改配置信息,那么就可以达到期望的布局效果。
经过一段时间的开发和测试,基于 Tangram-iOS 二次封装开发的 XTangram 已经上线,目前经过重构的非车主首页也正在灰度中,暂无发现功能和体验问题。本文会简单介绍 XTangram 的实现过程,其中涉及 VirtualView-iOS、Tangram-iOS 两个框架的一些修改,和部分自定义的功能代码。
其实 Tangram 本身不支持动态化,但是在 Tangram 2.0 之后,引入了 VirtualView 的开发方式,具体原理见 官方文档 说明。简单点讲:
.out
layout
在对 VirtualView 有个简单了解后,可以梳理下完成动态更新模板的流程,当然官方文档其实已经说得很明白,见 动态更新模板。这里罗列下在实践中的一个实际开发流程:
XTangram 的定制,涉及了框架在工程化使用中的不同节点,主要包括以下方面:
XTangramEngine 是框架向外暴露的单例,在初始化时将会读取 App 内置的配置文件,并将读取到的所有组件进行注册。这个流程与官方有所区别,官方的组件加载注册时机,是在调用 TangramView#reloadData 后,在 TangramDefaultDataSourceHelper#elementByModel:layout:tangramBus: 中去新建了对应的 element 实例,实例所对应的类,是 TMVVBaseElement:
XTangramEngine
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。有了该角色,那么就可以很方便的知道,当前该加载哪个版本的组件:
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 设置代理时,将其代理关联到分类中的属性:
load
// 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 童鞋提供实际的应用场景,再做粒度更细的开发和优化。
timingFunction
复杂交互背景,大抵是跟组件一些状态相关,例如 pressed、selected 和 enabled 等等。这里的背景,其实大部分是指渐变背景,另外也包括了边框线和圆角之类。这块的支持目前尚在跟进中,待后续实际上线后再做更新。
pressed
selected
enabled
从开始预研 Tangram-iOS,到 XTangram 产出,再到跟随项目上线,这其中有不少的故事。除去中间因为架构调整,小组工作计划安排上的更改,还有框架本身存在的一些小 Bug。比如之前 Android 端接入时所开发的组件,在 iOS 端就存在样式问题,在调试过程中,进而发现了框架中某些 layout 的布局问题。
App 动态化能力是每个团队都在探索的领域,其不管是对运营人员,还是开发人员,都有一定的积极意义。在 Tangram 2.0 提出 VirtualView 的概念后,App 原生端的动态布局能力成为了可能。由于共用一套布局 XML,在实际的开发过程中,也会遇到两端组件样式的适配问题,这点与 ReactNative 有点类似。好在 Tangram 提供了 virtualview_tools 工具,具备热加载功能,对研发效率还是有不少提升的。不过现在官方基本没有维护了,所以读者所在团队在考虑接入时,还是有必要做一番调研之后再做决定。
The text was updated successfully, but these errors were encountered:
No branches or pull requests
背景
小鹏 App 非车主首页涉及潜客、潜客车辆配置和预订车主三种业务场景,每种场景所展示的卡片也不尽相同。在该功能上线之初,使用的是传统原生 UITableView 的开发模式,因此当业务需求涉及一些 UI 改动时,只能通过发布新版本达到目的。Android 端之前已经接入 Tangram,并且线上表现稳定,为了提高 App 的动态下发能力,iOS 端的集成也日渐提上日程。区别于 ReactNative 跨端开发、热更修复的特性,App 动态布局更多的是强调插件化,通过在 App 内嵌一个布局坑位,然后下发不同的组件和布局信息,运营童鞋仅仅需要修改配置信息,那么就可以达到期望的布局效果。
经过一段时间的开发和测试,基于 Tangram-iOS 二次封装开发的 XTangram 已经上线,目前经过重构的非车主首页也正在灰度中,暂无发现功能和体验问题。本文会简单介绍 XTangram 的实现过程,其中涉及 VirtualView-iOS、Tangram-iOS 两个框架的一些修改,和部分自定义的功能代码。
开发流程
其实 Tangram 本身不支持动态化,但是在 Tangram 2.0 之后,引入了 VirtualView 的开发方式,具体原理见 官方文档 说明。简单点讲:
.out
为后缀.out
文件的能力,将编译后的 XML 布局文件,渲染成虚拟组件layout
来渲染 VirtualView 或是原生的自定义组件在对 VirtualView 有个简单了解后,可以梳理下完成动态更新模板的流程,当然官方文档其实已经说得很明白,见 动态更新模板。这里罗列下在实践中的一个实际开发流程:
.out
文件,XTangram 使用了加载 base64 的方式).out
文件,进行压缩并上传后端,作为后续动态下发版本XTangram 定制
XTangram 的定制,涉及了框架在工程化使用中的不同节点,主要包括以下方面:
引擎初始化
XTangramEngine
是框架向外暴露的单例,在初始化时将会读取 App 内置的配置文件,并将读取到的所有组件进行注册。这个流程与官方有所区别,官方的组件加载注册时机,是在调用TangramView#reloadData
后,在TangramDefaultDataSourceHelper#elementByModel:layout:tangramBus:
中去新建了对应的element
实例,实例所对应的类,是TMVVBaseElement
:XTangramEngine
对组件的处理分两步走:一是加载,二是注册。虚拟组件具备动态性,最新版本可能由服务端下发,所以 App 的沙盒目录中就可能有下载好的组件包。因此,组件的加载必须要区分版本,否则可能出现布局数据与支持的组件不一致的情况。为此,框架引入一个专门针对组件版本校验、下载的角色XTangramDownloader
。有了该角色,那么就可以很方便的知道,当前该加载哪个版本的组件:App 内置版本的组件,在官方基础上,添加了对应的 base64 编码,其格式大抵如下:
在组件加载完毕之后,按需进行内置(即base64)版本,或是沙盒版本组件的注册。这里用到的,分别对应到以下两个方法:
VVTemplateManager#sharedManager#loadTemplateData:forType:
VVTemplateManager#sharedManager#loadTemplateFileAsync:forType:completion:
调整组件初始化时机后,需要注释掉官方的初始化逻辑。后续业务方在使用的时候,可以在适当的时机进行引擎初始化。
首屏渲染处理
我们总是希望一个页面能以最快的速度,完整的展示在用户眼前。为此,XTangram 在首屏渲染阶段,提供了两个代理方法,分别用于获取缓存和网络的布局数据:
XTangram 提供了一个 XTangramView ,该类持有官方的 TangramView 实例,并暴露首屏渲染方法,由用户触发:
卡片异步加载
卡片,可理解为原生 UITableView 中的 cell 实例。异步加载有其实际存在的业务场景,比如一个卡片中的数据,依赖于手机的经纬度信息,那么这类数据必定无法通过后端进行下发。因此,有必要对列表卡片做异步加载的支持。
我们期望是在列表进行刷新的时候,如果发现某个卡片是属于异步加载类型,那么就触发对应的加载逻辑,并通知业务方去完成异步数据的请求和组装,返回卡片最终可用的信息。按照思路,可以找到官方的
TangramView#reloadData
方法,定位到其构建列表 model 数组的逻辑,添加对应判断:可以看到,我们为每个异步加载的卡片,新增了
load
字段,当检测到有该字段时,那么就触发一个对应的异步加载请求。这里为 TangramView 新增一个分类 TangramView+AsyncLoad 来处理,并在 XTangramView 设置代理时,将其代理关联到分类中的属性:XTangramView 中设置代理的处理:
然后即可在业务方实现 XTangramView 对应代理方法,根据不同的
load
值来发起对应请求:卡片局部刷新
在做异步加载支持的时候,其实已经涉及到局部刷新。因为异步数据回来,我们期望刷新的是对应的卡片实例,而不是重刷整个列表。另外一个前提是,重刷列表会重新触发卡片异步加载逻辑,这样就会出现死循环了。
这里提供一个根据 model 刷新卡片的处理:
其他
关于事件总线这里就不再赘述了,大概就是在单例中维护唯一的 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 工具,具备热加载功能,对研发效率还是有不少提升的。不过现在官方基本没有维护了,所以读者所在团队在考虑接入时,还是有必要做一番调研之后再做决定。
The text was updated successfully, but these errors were encountered: