******阅读完此文,大概需要20分钟******
一、方案背景
有这么一个需求,需要做一个展示信息详情页,内容可能会比较多,超过一屏,最终还需要生成一张完整的详情页截图(UIImage), 如果我们直接基于UIScrollView(UITableView)去截屏,只会生成UIScrollView的frame的size大小的图片,不能生成contentSize大小的图片,所以,我们需要基于UIView去实施截图,有人可能会问,再UIView上再去放一个UIScrollView(UITableView),要知道截屏操作本身就是一个性耗比较大的操作,经测试验证,直接基于UIView去截屏性耗比最小。所以,需要采取UIScrollView+UIView去设计这个页面的层级。待内容页展示完成,可以直接对UIView去截屏。 内容详情页页一般设计信息较多、布局复杂、开发工作量大,所以模块化划分这个页面十分有必要。按照已有的模块化思想,创建一个ViewCtroller统一管理它要展示的子模块,管理包括统一数据刷新、统一布局等;
二、方案的设计与实现
截止这篇wiki开始,所有的代码都已经开发完成,并已经在公司的很多项目中实践,完整的组件代码地址如下:
GitHub - Leon0206/MDReactPageKit: this is my first formal project of github
读者可以自己去下载并debug,下面我来简单介绍一下这个组件的原理与使用。
1、统一的布局代码
基类:MDBaseModuleViewController,在基类VC中有个contentBgView,在这个contentBgview中,我们批量将模块化的子view进行添加,如下:
- (NSArray *)loadContentViews
{
return @[@"MDDemoHeadModuleView",
@"MDDemoBottomModuleView",
@"MDDemoMiddleModuleView",
@"MDDemoHeadModuleView",
@"MDDemoMiddleModuleView",
];
}
既然要统一管理这些模块,就需要抽象出一层模块的基类,包含一些必须的公共操作,如下:
- (void)loadModuleSubViews; //加载模块的子View,类似之前的viewdidload
- (void)loadModuleData:(id)model; //分发模块的数据model
- (void)layoutModuleWidth:(CGFloat)width; //布局模块,类似之前的viewdidlayoutsubview或者layoutSubviews
在这些公共操作中,有统一刷新布局(替代layoutsubviews)、有统一分发数据的操作,为了便于管理,还给每个模块统一标示了一个index。所以我们的模块基类MDBaseModuleView只需要实现这个protocol,并实现它的公共操作;为什么要统一封装这些操作?目的其实很简单,是因为layoutsubviews或者viewdidlayoutsubviews都会有一些不必要执行,大量的布局代码写到其中,是一种不明智的做法。当然,有了基类的另一个好处,就是我们可以将一些模块反复使用的、重复的代码封装到基类中去,精简代码。除此之外,还封装了一些来自VC的生命周期方法,需要子类继承实现,如下:
- (void)moduleWillAppear
- (void)moduleDidAppear
- (void)moduleWillDisappear
2、所有modules共享一份model数据
模块通过- (void)loadViewWithData:(id)model;方法将model带入每个模块,每个模块取自己需要的数据。如果模块需要“留存”一些value给下次操作来用,建议单独创建并赋值给property变量。
三、刷新布局的优化
当VC的model数据拿到时,所有的页面模块都会共享一份model,每个子模块从model中取自己需要的数据,计算自己的布局,如下:
- (void)loadAllSubviewsData
{
[self.contentBgView.subviews enumerateObjectsUsingBlock:^(__kindof UGCPBaseModuleView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if ([obj conformsToProtocol:@protocol(UGCPBaseModuleDelegate)]) {
[obj configViewWithIndex:idx];
[obj loadViewWithData:self.model];
}
}];
[self bindAllSubViewsHeight];
}
- (void)layoutAllSubviews
{
__block CGFloat layoutOffestY = 0.0;
@weakify(self);
[self.contentBgView.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
@strongify(self);
if ([obj conformsToProtocol:@protocol(UGCPBaseModuleDelegate)]) {
[obj layoutViewWithWidth:[self contentBgViewWidth]];
obj.top = layoutOffestY + [self spacingBetweenSubviews];
obj.left = [self leftSpacingOfSubviews];
layoutOffestY = floor(obj.bottom);
}
}];
self.contentBgView.frame = CGRectMake(0, 0, self.view.width, layoutOffestY);
}
我们在起始可以统一调用这两个方法,但是仅仅这样统一操作是不够的。如果某个模块中有异步数据(图片或者子接口请求等),需要再次刷新这个模块布局,也就是我们可以像tableview的reloadData一样,再次执行layoutAllSubviews方法,把整个页面的所有模块再次刷新一遍,此时模块复杂度为O(n)。事实上,每个模块的布局变化,并不总是需要刷新整个布局,如下:
页面的各个模块,固定的宽度、固定的x坐标、top依赖上个模块的bottom:
当其中一个模块(Module 1)的布局发生变化时(高度height增加或减少,bottom改变),如下:
我们只需要按需更新那些依赖module 1的模块(modile 1下面的modules)的布局即可,如下:
此时刷新模块布局的平均复杂度为O(n/2)。
具体实现时候,我们首先要给每个模块一个唯一的标示index(0、1、2、3),通过protocol方法初始化时带进每个模块:
- (void)setModuleIndex:(NSUInteger)index;
其次,还需要给每个模块构造一个可以监控模块height变化的信号RACSignal(RACSubject包含发送信号操作),我们只需要在
模块里面构造height变化的信号,这个信号要发送到主VC类,通知VC类哪个模块(index)的高度发生了变化,从而“定向”按需刷新页面布局,模块绑定信号如下:
- (void)setModuleIndex:(NSUInteger)moduleIndex
{
_moduleIndex = moduleIndex;
@weakify(self);
[[[[RACObserve(self, height) distinctUntilChanged] skip:1] deliverOnMainThread] subscribeNext:^(id x) {
@strongify(self);
dispatch_async(dispatch_get_main_queue(), ^{
[self.heightSignal sendNext:[NSNumber numberWithInteger:moduleIndex]];
});
}];
}
VC类负责merge这些信号,并处理:
- (void)bindAllSubViewsHeight
{
__block RACSignal *signal = [RACSubject subject];
[self.contentView.subviews enumerateObjectsUsingBlock:^(__kindof MDBaseModuleView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
RACSubject *s = obj.heightSignal;
if (idx == 0) {
signal = s;
} else {
signal = [signal merge:s];
}
}];
@weakify(self);
[signal subscribeNext:^(id x) {
@strongify(self);
[self relayoutModuleViewsWithIndex:[x integerValue]];
}];
}
拿到height变化的模块index之后,我们就可以轻松根据index布局这个页面了:
//指定模块的布局刷新
- (void)relayoutModuleViewsWithIndex:(NSUInteger)index
{
__block CGFloat layoutOffestY = [self.contentView.subviews objectAtIndex:index].bottom;
NSUInteger location = index + 1;
NSRange range = NSMakeRange(location, self.contentView.subviews.count - location);
@weakify(self);
[[self.contentView.subviews subarrayWithRange:range] enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
@strongify(self);
obj.top = layoutOffestY + [self spaceBetweenModuleViews];
layoutOffestY = obj.bottom;
}];
self.contentView.frame = CGRectMake(0, 0, self.view.width, layoutOffestY);
self.scrollView.contentSize = CGSizeMake(self.view.width, layoutOffestY);
}
四、支持Module后端动态可配
最新的代码支持模块化后台动态可配,只需要实现如下三个方法即可:
- (BOOL)isSupportDynamicConfigration
- (NSArray *)dynamicModules
- (NSDictionary *)allDynamicModules
如果你的页面模块有需要将各个模块进行动态管理(添加、删除、调整顺序等),这个将是个很好的选择。你可以从后端接口中获取模块的Identify序列,这个功能会根据此序列生成各个模块类。当然,所有的模块与identify的字典需要提前在App中注册好。
五、总结
总结一下,此组件具有以下特点:
1、统一化的模块化管理,继承基类,开发者无需投入太多时间在页面布局上,开发效率高;代码复用性高,代码比起传统开发较少;
2、大量的子view的布局代码无需写在layoutSubviews与viewdidlayoutSubviews中,提升页面布局效率;
3、按需“定向”刷新布局(平均复杂度O(n/2))性能高于传统统一刷新效率平均复杂度(O(n));