******阅读完此文,大概需要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:

ios appdelegate 模块化 ios14模块化_后台可配

当其中一个模块(Module 1)的布局发生变化时(高度height增加或减少,bottom改变),如下:

ios appdelegate 模块化 ios14模块化_后台可配_02

我们只需要按需更新那些依赖module 1的模块(modile 1下面的modules)的布局即可,如下:

ios appdelegate 模块化 ios14模块化_UIImage_03

此时刷新模块布局的平均复杂度为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));