WXRecycleListComponent在iOS中的实现
WXRecycleListComponent在iOS中的实现
概述
WXRecycleListComponent是阿里Weex团队在17年下半年为了对超长列表能更好地进行展示,而提供的一个新的解决方案,在Weex的0.18.0版本正式对外发布Release版本,号称在内存使用上进行了较大的优化改进,且具有较好的FPS,内存和滑动流畅度当然是每个Weex使用者最为关心的话题。我们知道之前Weex提供的WXListComponent组件透传了Native的UITableView,在内存的控制上较普通的Scroller提升明显,引入了只渲染可见区域,View内存回收复用等机制保证了内存使用率。但是WXListComponent对超长列表的显示还是存在着内存和FPS问题,这篇文章就让我们一起来看看WXRecycleListComponent这个新组件在iOS中的具体实现,如何在这些方面提出了新的解决方案。
整体架构
我们先从架构角度来对比传统的WXRecycleListComponent组件
传统WXComponent架构与WXRecycleListComponent组件有哪些区别?
WXRecycleListComponent架构最左边的业务代码角度来看,两种架构是相同的结构,都是M个模块+N条数据。而传统的组件会在JSFramework层将N条数据解析生成为N个虚拟节点Virtual DOM,再通过call native方法调用生成Native中的组件Component并渲染出具体的视图。WXListComponent中做的一个优化点就是将不可见区域的view及时的回收,需要的时候再渲染,这里要特别注意,这个操作级别是视图(View)级别。这个优化必然会比全部view都渲染好很多,但是这个流程本质并不是复用的概念,当数据量达到一定级别后,这个方案还是会存在内存上的问题,同时View的回收和渲染也同样会降低帧率(FPS)。
对比下图的WXListComponent架构中的JSFramwork部分,这里做了较大的改变。不在根据数据来生成元素dom,而是根据模板来生成模板dom,然后再通过call native方法调用生成Native中可复用的Component,再把数据当成数据源,分别对应加载复用的Component。这与iOS列表(UITableView/UICollectionView)中真正的复用原理一致,生成多个复用的cell,通过数据源来决定使用哪个cell,完成cell级别的复用。从全局方案的角度来开,这必然对整体内存和滑动流畅度都具有较大的提高。但是这打破了传统的weex组件解析方案和流程,必然会产生一定的工作量和不稳定性,我们下文具体分析其内部实现。
WXRecycleListComponent在SDK的Component目录中单独有一个RecycleList目录,目前共包含16个文件。
WXRecycleListComponent目录我们从直观上可以感受到WXRecycleListComponent整个实现方案还是具有一定的复杂性。在SDK中注册Component的是WXRecycleListComponent类,我们先看下其interface中要实现的代理和属性,大致了解下整个组件的一个框架。
@interface WXRecycleListComponent : WXScrollerComponent
@property(nonatomic, strong) WXRecycleListDataManager *dataManager;
@property(nonatomic, strong) WXRecycleListTemplateManager *templateManager;
@property(nonatomic, strong) WXRecycleListUpdateManager *updateManager;
@end
@interface WXRecycleListComponent () <WXRecycleListLayoutDelegate, WXRecycleListUpdateDelegate, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource>
@end
通过上述代码,我们看到组件内部实现了UICollectionViewDataSource和UICollectionViewDelegateFlowLayout的代理方法,那么WXRecycleListComponent的列表展示所对应的Native组件为UICollectionView。其他3个Manager对象和2个自定义delegate的具体功能,我们继续展开介绍。
3个Manager对象:DataManager、TemplateManager、UpdateManager
WXRecycleListDataManager
由于WXRecycleListComponent是基于UICollectionView的,页面的显示必然要使用到数据源,所以WXRecycleListDataManager的作用就是管理整个列表的数据源,也就是架构图中紫色的部分。列表数据的每次更新都要更新这个类中的数据源,这个类提供最基本的数据初始化、数据更新、查询数量等功能。
@interface WXRecycleListDataManager : NSObject
- (instancetype)initWithData:(NSArray *)data;
- (void)updateData:(NSArray *)data;
- (NSArray *)data;
- (NSDictionary *)dataAtIndex:(NSInteger)index;
- (NSInteger)numberOfItems;
- (NSInteger)numberOfVirtualComponent;
- (NSDictionary*)virtualComponentDataWithId:(NSString*)componentId;
- (void)updateVirtualComponentData:(NSString*)componentId data:(NSDictionary*)data;
- (NSDictionary*)virtualComponentDataWithIndexPath:(NSIndexPath*)indexPath;
- (NSString*)virtualComponentIdWithIndexPath:(NSIndexPath*)indexPath;
- (void)deleteVirtualComponentAtIndexPaths:(NSArray<NSIndexPath*>*)indexPaths;
@end
WXRecycleListTemplateManager
WXRecycleListComponent的展示、回收与恢复是基于一群模板Cell的,WXRecycleListTemplateManager的作用就是负责如何管理这些可回收的模板Cell。
- (void)addTemplate:(WXCellSlotComponent *)component;
- (WXCellSlotComponent *)dequeueCellSlotWithType:(NSString *)type forIndexPath:(NSIndexPath *)indexPath;
- (WXCellSlotComponent *)templateWithType:(NSString *)type;
WXRecycleListComponent会在_insertSubcomponent方法中,调用[addTemplate:]来添加模板类型,一个WXRecycleListComponent中的所有模板会存在一张Map表中,key为WXCellSlotComponent对象的templateCaseType属性(对应Vue中<cell-slot>
的 case
或者 default
属性),object就是具体的模板Cell组件WXCellSlotComponent。同时会为这个cell注册一个ReuseId。后续只要通过templateCaseType就可以取到对应的模板信息了。
- (void)addTemplate:(WXCellSlotComponent *)component
{
NSString *templateType = component.templateCaseType;
[_templateTypeMap setObject:component forKey:templateType];
if (_collectionView) {
[self _registerCellClassForReuseID:templateType];
}
}
WXRecycleListUpdateManager
这个类主要负责处理UICollectionView视图的更新管理。根据暴露出的方法可以清晰地看到,拿到newData、appendingData与oldData就可以去更新UICollectionView视图中的Cell。
- (void)updateWithNewData:(NSArray *)newData
oldData:(NSArray *)oldData
completion:(WXRecycleListUpdateCompletion)completion
animation:(BOOL)isAnimated;
- (void)updateWithAppendingData:(NSArray *)appendingData
oldData:(NSArray *)oldData
completion:(WXRecycleListUpdateCompletion)completion
animation:(BOOL)isAnimated;
那么,newData和appendingData有什么不同?我们具体看下这个类的实现。核心代码在方法-performBatchUpdates里,以下代码对源码做了部分精简。
- (void)performBatchUpdates
{
NSArray *newData = [self.newerData copy];
NSArray *oldData = [self.olderData copy];
NSArray *appendingData = [self.appendingData copy];
WXDiffResult *diffResult;
if (appendingData) {
newData = [oldData arrayByAddingObjectsFromArray:appendingData];
NSIndexSet *inserts = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(oldData.count, appendingData.count)];
// 对于appendingData精简一次diff操作
diffResult = [[WXDiffResult alloc] initWithInserts:inserts deletes:nil updates:nil];
} else if (newData){
diffResult = [WXDiffUtil diffWithMinimumDistance:newData oldArray:oldData];
}
// 计算UICollectionView需要delete、insert、reload的indexPahts
WXRecycleListDiffResult *recycleListDiffResult = [self recycleListUpdatesByDiffResult:diffResult];
if (![diffResult hasChanges] && self.reloadIndexPaths.count == 0) {
return;
}
void (^updates)(void) = [^{
// WXRecycleListUpdateDelegate
[self.delegate updateManager:self willUpdateData:newData];
// 具体更新UICollectionView
[self applyUpdateWithDiffResult:recycleListDiffResult];
} copy];
void (^completion)(BOOL) = [^(BOOL finished) {
// WXRecycleListUpdateDelegate
[self.delegate updateManager:self didUpdateData:newData withSuccess:finished];
} copy];
// 最后批处理
[collectionView performBatchUpdates:updates completion:completion];
}
我们可以看到WXRecycleListUpdateManager类的本质作用就是,当Vue传过来新的数据,通过这个类来diff新旧数据,然后更新视图。如果是appdening的Data,则直接insert到最后面,就不用走diff的算法了,这里对性能上做了一点优化,如果单纯从功能上来说的话,个人认为其实这2个方法没有本质上的区别。
对于diff新旧数据的处理,Weex实现中用到了莱文斯坦距离算法(Levenshtein Distance)来比较新旧数据的编辑距离,计算出如何以最少操作次数来更新UICollectionView,具体算法实现中使用了包含动态规划思想的全矩阵迭代法,这里不做具体展开,可以阅读WXDiffUtil类中的方法。
// Using the levenshtein algorithm
+ (WXDiffResult *)diffWithMinimumDistance:(NSArray<id<WXDiffable>> *)newArray oldArray:(NSArray<id<WXDiffable>> *)oldArray;
2个代理:LayoutDelegate、UpdateDelegate
WXRecycleListLayoutDelegate
WXRecycleListLayoutDelegate定义在WXRecycleListLayout类中,WXRecycleListLayout继承于UICollectionViewFlowLayout,主要是管理UICollectionView的视图布局。WXRecycleListLayoutDelegate在UICollectionViewFlowLayout中的layoutAttributesForElementsInRect方法中触发,如果实现了该代理,那么在布局该Cell时会固定(stick)在顶部。
@protocol WXRecycleListLayoutDelegate
- (BOOL)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout isNeedStickyForIndexPath:(NSIndexPath *)indexPath;
@end
@interface WXRecycleListLayout : UICollectionViewFlowLayout
@property (nonatomic, weak) id<WXRecycleListLayoutDelegate> delegate;
@end
WXRecycleListUpdateDelegate
WXRecycleListUpdateDelegate定义在WXRecycleListUpdateManager.h类中,提供2个方法。分别在UICollectionView更新视图前后时机触发回调。
@protocol WXRecycleListUpdateDelegate
// 在UICollectionView更新视图前产生回调
- (void)updateManager:(WXRecycleListUpdateManager *)manager willUpdateData:(id)newData;
// 在UICollectionView更新视图完成后产生回调
- (void)updateManager:(WXRecycleListUpdateManager *)manager didUpdateData:(id)newData withSuccess:(BOOL)finished;
@end
WXRecycleListComponent.m中的实现
经过上述Manager对象和一些Delegate的介绍,我们已经将WXRecycleListComponent分解为多个小模块,而主的WXRecycleListComponent.m类职责就是如何将上述这些小模块组合在一起使用。
在WXRecycleListComponent.m类中,除了完成一些必要的WXComponent初始化行为和Load More事件处理之外,主要处理一些组件暴露给前端的一些export method。
WX_EXPORT_METHOD(@selector(appendData:))
WX_EXPORT_METHOD(@selector(appendRange:))
WX_EXPORT_METHOD(@selector(insertData:data:))
WX_EXPORT_METHOD(@selector(updateData:data:))
WX_EXPORT_METHOD(@selector(removeData:count:))
WX_EXPORT_METHOD(@selector(moveData:toIndex:))
WX_EXPORT_METHOD(@selector(scrollTo:options:))
WX_EXPORT_METHOD(@selector(insertRange:range:))
WX_EXPORT_METHOD(@selector(setListData:))
另外的一大部分的代码是实现UICollectionViewDataSource和UICollectionViewDelegateFlowLayout。其中,比较重要的部分在[collectionView:cellForItemAtIndexPath:]方法中,如何根据data和template生成(获取)一个可以复用的cell并且绑定数据和渲染。以下对此方法做了一些精简。
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
// 1. get the data relating to the cell
id data = [_dataManager dataAtIndex:indexPath.row];
// 2. get the template type specified by data
NSString * templateType = [self templateType:indexPath];
_templateManager.collectionView = collectionView;
// 3. dequeue a cell component by template type
UICollectionViewCell *cellView = [_collectionView dequeueReusableCellWithReuseIdentifier:templateType forIndexPath:indexPath];
WXCellSlotComponent *cellComponent = (WXCellSlotComponent *)cellView.wx_component;
if (!cellComponent) {
cellComponent = [_templateManager dequeueCellSlotWithType:templateType forIndexPath:indexPath];
cellView.wx_component = cellComponent;
WXPerformBlockOnComponentThread(^{
[super _insertSubcomponent:cellComponent atIndex:self.subcomponents.count];
});
}
// 4. binding the data to the cell component
[self _updateBindingData:data forCell:cellComponent atIndexPath:indexPath];
// 5. Add cell component's view to content view.
UIView *contentView = cellComponent.view;
if (contentView.superview == cellView.contentView) {
return cellView;
}
for (UIView *view in cellView.contentView.subviews) {
[view removeFromSuperview];
}
[cellView.contentView addSubview:contentView];
[self handleAppear];
return cellView;
}
1、获取当前行Cell所对应的数据源对象,由于WXRecycleListComponent的数据管理都是由WXRecycleListDataManager来负责,所以这里很直接的根据index来拿到数据源即可。
2、获取当前行的cell所属模板类型。
3、所以这里我们可以根据模块的名字来获取到cell的对象,由于UICollectionView注册的Cell必须为UICollectionViewCell或其子类,而WXCellSlotComponent->WXComponent->NSObject,所以这里有一个动态绑定的过程。又因为Cell随着页面的滑动,是会被回收的,那么所绑定的Component也会被清理掉。所以,这里同样需要对模板类WXCellSlotComponent进行回收管理,模板的注册和获取都是通过WXRecycleListTemplateManager对象来负责处理的。
WXCellSlotComponent *cellComponent = (WXCellSlotComponent *)cellView.wx_component;
if (!cellComponent) {
cellComponent = [_templateManager dequeueCellSlotWithType:templateType forIndexPath:indexPath];
cellView.wx_component = cellComponent;
WXPerformBlockOnComponentThread(^{
//TODO: How can we avoid this?
[super _insertSubcomponent:cellComponent atIndex:self.subcomponents.count];
});
}
4、如何将数据源应用到相应的WXCellSlotComponent上,并以正确的样式进行展示,这是整个流程最为关键和精彩的部分。下一个小节会具体介绍下这个绑定流程。
5、将WXCellSlotComponent视图添加到Cell的content视图中,用于最终的显示。
由第5步我们可以看到,Cell真实的size就是Component中view的size,而由于第3步的介绍我们知道Cell与Component是可以被回收的,一旦回收后,size就需要重新计算,而计算size本身是比较费时的。所以,在UICollectionViewDelegateFlowLayout的
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath;
里,对计算出来的size根据indexPath进行cache,进一步提升整体的性能。
数据解析与绑定
我们通过以下例子来讲解数据解析和绑定的过程,也可以通过Weex中的例子来理解整个过程。recycle-list标签的模板语法我们不进行展开介绍,主要介绍整个流程在iOS中的实现。
<template>
<recycle-list for="(item, i) in labels" switch="type">
<cell-slot v-if=true case="label">
<text>{{i}}33333</text>
</cell-slot>
</recycle-list>
</template>
<script>
export default {
data () {
return {
labels: [// 数据源
{ type: 'label' },
],
}
}
}
</script>
我们回到UICollectionView数据源方法中的第4步,其方法经过精简后的实现如下,入参为数据源data、当前的Cell模板对象cellComponent以及位置索引indexPath。
- (void)_updateBindingData:(id)data forCell:(WXCellSlotComponent *)cellComponent atIndexPath:(NSIndexPath *)indexPath
{
id originalData = data;
if (!data[@"indexPath"] || !data[@"recycleListComponentRef"]) {
NSMutableDictionary * dataNew = [data mutableCopy];
dataNew[@"recycleListComponentRef"] = self.ref;
dataNew[@"indexPath"] = indexPath;
data = dataNew;
}
if ([originalData isKindOfClass:[NSDictionary class]] && _aliasKey &&!data[@"phase"]) {
data = @{_aliasKey:data,@"aliasKey":_aliasKey};
}
if (_indexKey) {
NSMutableDictionary *dataNew = [data mutableCopy];
dataNew[_indexKey] = @(indexPath.item);
data = dataNew;
}
WXPerformBlockSyncOnComponentThread(^{
[cellComponent updateCellData:[data copy]];
});
}
传入数据源后,我们会对数据源进行一些包装,附加上aliasKey、index、indexPath、recycleListComponentRef、type等值,这些值都是在渲染真正组件内容时不可或缺的一些信息。包装后的对象如下。
{
aliasKey = item;
i = 0;
item = {
indexPath = "<NSIndexPath: 0xc000000000000016> {length = 2, path = 0 - 0}";
recycleListComponentRef = "_root";
type = label;
};
}
通过这些包装完成的数据,我们就能根据已经注入的模板语法获取到这个Cell具体要显示的条件、规则和值,然后完成数据绑定,最终完成布局、渲染和显示。
- (void)updateCellData:(NSDictionary *)data
{
[self updateBindingData:data];
[self triggerLayout];
}
那么,模板语法的注入与解析转换是在什么时候完成的?
其实在WXComponentManager中通过_buildComponentForData方法生成Component的时候,会根据一些条件判断是否为模板组件,如果是,则进行数据解析与绑定的行为。
WXComponentConfig *config = [WXComponentFactory configWithComponentName:type];
BOOL isTemplate = [config.properties[@"isTemplate"] boolValue] || (supercomponent && supercomponent->_isTemplate);
if (isTemplate) {
bindingProps = [self _extractBindingProps:&attributes];
bindingStyles = [self _extractBindings:&styles];
bindingAttibutes = [self _extractBindings:&attributes];
bindingEvents = [self _extractBindingEvents:&events];
}
模板语法表达式的解析和绑定感觉是整个方案中比较难懂的部分,其主要实现在WXComponent+DataBinding这个扩展类中,主要工作就是是对前端与客户端之间通信的模板语法进行一步一步地解析转换,细节工作比较繁琐,其中一部分语法和表达式的解析代码用C++完成,主要的相关的实现在WXJSASTParser.mm文件中,有一定的阅读理解门槛。然后就是将相应表达式所要对应的行为定于在WXDataBindingBlock这个block中,然后存储在一个bingdingMap中,即完成了整个绑定操作。
总结
整个长列表复用方案在长列表的展示上具有较大的性能突破,整体方案的设计思路上与传统Weex组件架构有较大不同,充分利用了Native组件的复用机制。最后再对整个框架创新之处进行一个总结。
- 前端不对列表结构进行展开
iOS系统的Native组件实现本身就具备了很优异的交互效果和内存控制能力,以UITableView和UICollectionView为例,其本身有着非常好的滚动流畅度、内存控制能力、View复用能力,那么我们就要最大化的去利用这些Native的能力,而不是花很大的时间和精力再重新造一个轮子去模仿它,尽可能的利用Native提供的优良特性才是Weex最大的优势和价值。
- 前端与客户端之间的模板语法
复用机制的实现必然需要一套复用的模板,如何将Vue的业务代码转换成一套Native复用的模板也是一项比较大的挑战。整套语法的制定和解析过程还是有很大的工作量,有无数的细节工作需要不断的实现和完善。