Skip to content
New issue

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

Tangram 动态布局实践 #9

Open
ljunb opened this issue Aug 4, 2021 · 0 comments
Open

Tangram 动态布局实践 #9

ljunb opened this issue Aug 4, 2021 · 0 comments

Comments

@ljunb
Copy link
Owner

ljunb commented Aug 4, 2021

背景

小鹏 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 ljunb changed the title XTangram 实践 Tangram 动态布局实践 Aug 21, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant