rn-relates icon indicating copy to clipboard operation
rn-relates copied to clipboard

Tangram 动态布局实践

Open ljunb opened this issue 4 years ago • 0 comments

背景

小鹏 App 非车主首页涉及潜客、潜客车辆配置和预订车主三种业务场景,每种场景所展示的卡片也不尽相同。在该功能上线之初,使用的是传统原生 UITableView 的开发模式,因此当业务需求涉及一些 UI 改动时,只能通过发布新版本达到目的。Android 端之前已经接入 Tangram,并且线上表现稳定,为了提高 App 的动态下发能力,iOS 端的集成也日渐提上日程。区别于 ReactNative 跨端开发、热更修复的特性,App 动态布局更多的是强调插件化,通过在 App 内嵌一个布局坑位,然后下发不同的组件和布局信息,运营童鞋仅仅需要修改配置信息,那么就可以达到期望的布局效果。

经过一段时间的开发和测试,基于 Tangram-iOS 二次封装开发的 XTangram 已经上线,目前经过重构的非车主首页也正在灰度中,暂无发现功能和体验问题。本文会简单介绍 XTangram 的实现过程,其中涉及 VirtualView-iOSTangram-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 文件,进行压缩并上传后端,作为后续动态下发版本
  • 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 童鞋提供实际的应用场景,再做粒度更细的开发和优化。

复杂交互背景,大抵是跟组件一些状态相关,例如 pressedselectedenabled 等等。这里的背景,其实大部分是指渐变背景,另外也包括了边框线和圆角之类。这块的支持目前尚在跟进中,待后续实际上线后再做更新。

结语

从开始预研 Tangram-iOS,到 XTangram 产出,再到跟随项目上线,这其中有不少的故事。除去中间因为架构调整,小组工作计划安排上的更改,还有框架本身存在的一些小 Bug。比如之前 Android 端接入时所开发的组件,在 iOS 端就存在样式问题,在调试过程中,进而发现了框架中某些 layout 的布局问题。

App 动态化能力是每个团队都在探索的领域,其不管是对运营人员,还是开发人员,都有一定的积极意义。在 Tangram 2.0 提出 VirtualView 的概念后,App 原生端的动态布局能力成为了可能。由于共用一套布局 XML,在实际的开发过程中,也会遇到两端组件样式的适配问题,这点与 ReactNative 有点类似。好在 Tangram 提供了 virtualview_tools 工具,具备热加载功能,对研发效率还是有不少提升的。不过现在官方基本没有维护了,所以读者所在团队在考虑接入时,还是有必要做一番调研之后再做决定。

ljunb avatar Aug 04 '21 05:08 ljunb