一种组件化的 Table View 的实现

背景

最近在做一个项目时,需要实现一些列表界面,总体上是上下滚动的,中间的部分段有可以横滚的,有一个个小标签式的,也有可循环滚动的焦点图的……且类似的界面大量出现,并随机组合。可以参照网易云音乐,早期版本的蘑菇街,小红书等等。

按以往的想法是,继承 UITableViewController 然后分多个 section,所有的数据与点击都在一个 VC 中完成。如果全是占满行的 Cell,勉强可以接受,但很快你会发现,你的代码变得庞大而臃肿,且不可维护。更要命的是,代码无法复用,且一旦有需求变动,留下 Bug 的几率很大。网上关于 UITableView 瘦身的优化方法已经很多了,基本上也就是增加一个 ViewModel 层,将代码换了个地方,没什么很大的意思。但是在这次遇到的项目背景下,我想应该可以用更好的组件化的方式来实现。

组件定义

我们定义 UITableView 中的一个 section 为一个组件(component),它需要管理自己的标头(header)、行高、Cell 数量等:

@protocol RTTableComponent <NSObject>
@required

- (NSString *)cellIdentifier;
- (NSString *)headerIdentifier;

- (NSInteger)numberOfItems;
- (CGFloat)heightForComponentHeader;
- (CGFloat)heightForComponentItemAtIndex:(NSUInteger)index;

- (__kindof UITableViewCell *)cellForTableView:(UITableView *)tableView
                                   atIndexPath:(NSIndexPath *)indexPath;
- (__kindof UIView *)headerForTableView:(UITableView *)tableView;

- (void)reloadDataWithTableView:(UITableView *)tableView
                      inSection:(NSInteger)section;
- (void)registerWithTableView:(UITableView *)tableView;
@optional

- (void)willDisplayHeader:(__kindof UIView *)header;
- (void)willDisplayCell:(__kindof UITableViewCell *)cell forIndexPath:(NSIndexPath *)indexPath;

- (void)didSelectItemAtIndex:(NSUInteger)index;

@end

上面代码中:- (void)registerWithTableView:(UITableView *)tableView 提供了一个入口供组件注册自定义的 UITableViewCell

继承自 UIViewController——这里不用 UITableViewController 是为了灵活性,比如有时候 TableView 不需要占满屏——实现一个 RTComponentController,它维护一个成员为 id<RTTableComponent> 类型的数组:

@interface RTComponentController : UIViewController <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, readonly, strong) UITableView *tableView;
@property (nonatomic, strong) NSArray <id<RTTableComponent> > *components;
- (CGRect)tableViewRectForBounds:(CGRect)bounds;
@end

然后在具体的实现中,将大部分 DatasourceDelegate 的方法转发到 components 上:

- (CGRect)tableViewRectForBounds:(CGRect)bounds
{
    return bounds;
}

#pragma mark - UITableView Datasource & Delegate

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    return self.components.count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return self.components[section].numberOfItems;
}

- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section
{
    return self.components[section].heightForComponentHeader;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return [self.components[indexPath.section] heightForComponentItemAtIndex:indexPath.row];
}

- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section
{
    return [self.components[section] headerForTableView:tableView];
}

- (UITableViewCell *)tableView:(UITableView *)tableView
         cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return [self.components[indexPath.section] cellForTableView:tableView atIndexPath:indexPath];
}

- (void)tableView:(UITableView *)tableView
willDisplayHeaderView:(UIView *)view  
       forSection:(NSInteger)section
{
    if ([self.components[section] respondsToSelector:@selector(willDisplayHeader:)]) {
        [self.components[section] willDisplayHeader:view];
    }
}

- (void)tableView:(UITableView *)tableView
  willDisplayCell:(UITableViewCell *)cell
forRowAtIndexPath:(NSIndexPath *)indexPath  
{
    if ([self.components[indexPath.section] respondsToSelector:@selector(willDisplayCell:forIndexPath:)]) {
        [self.components[indexPath.section] willDisplayCell:cell
                                               forIndexPath:indexPath];
    }
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    if ([self.components[indexPath.section] respondsToSelector:@selector(didSelectItemAtIndex:)]) {
        [self.components[indexPath.section] didSelectItemAtIndex:indexPath.row];
    }
}

给定一个基础实现 RTBaseComponent,没有标头,0 个 Cell:

@interface RTBaseComponent : NSObject <RTTableComponent>
@property (nonatomic, weak) id<RTTableComponentDelegate> delegate;

@property (nonatomic, strong) NSString *cellIdentifier;
@property (nonatomic, strong) NSString *headerIdentifier;

+ (instancetype)componentWithTableView:(UITableView *)tableView;
+ (instancetype)componentWithTableView:(UITableView *)tableView delegate:(id<RTTableComponentDelegate>)delegate;

- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithTableView:(UITableView *)tableView;
- (instancetype)initWithTableView:(UITableView *)tableView delegate:(id<RTTableComponentDelegate>)delegate NS_DESIGNATED_INITIALIZER;

- (void)registerWithTableView:(UITableView *)tableView NS_REQUIRES_SUPER;
- (void)setNeedUpdateHeightForSection:(NSInteger)section;

@end


@interface RTBaseComponent ()
@property (nonatomic, weak) UITableView *tableView;
@end


@implementation RTBaseComponent

- (instancetype)initWithTableView:(UITableView *)tableView delegate:(id<RTTableComponentDelegate>)delegate
{
    self = [super init];
    if (self) {
        self.cellIdentifier = [NSString stringWithFormat:@"%@-Cell", NSStringFromClass(self.class)];
        self.headerIdentifier = [NSString stringWithFormat:@"%@-Header", NSStringFromClass(self.class)];
        self.tableView = tableView;
        self.delegate = delegate;

        [self registerWithTableView:tableView];
    }
    return self;
}

- (void)registerWithTableView:(UITableView *)tableView
{
    [tableView registerClass:[UITableViewCell class]
      forCellReuseIdentifier:self.cellIdentifier];
}

- (NSInteger)numberOfItems
{
    return 0;
}

- (CGFloat)heightForComponentHeader
{
    return 0.f;
}

- (CGFloat)heightForComponentItemAtIndex:(NSUInteger)index
{
    return 0.f;
}

......

@end

然后继承自 RTBaseComponent,实现一个有标头的组件:

@interface RTHeaderComponent : RTBaseComponent
@property (nonatomic, copy) NSString *title;
@property (nonatomic, strong) UIFont *titleFont;
@property (nonatomic, strong) UIColor *titleColor;
@property (nonatomic, strong) UIView *accessoryView;

- (CGRect)accessoryRectForBounds:(CGRect)bounds;

@end

@implementation RTHeaderComponent

- (void)registerWithTableView:(UITableView *)tableView
{
    [super registerWithTableView:tableView];
    [tableView registerClass:[UITableViewHeaderFooterView class]
forHeaderFooterViewReuseIdentifier:self.headerIdentifier];  
}

- (CGFloat)heightForComponentHeader
{
    return 36.f;
}

- (__kindof UIView *)headerForTableView:(UITableView *)tableView
{
    UITableViewHeaderFooterView *header = [tableView dequeueReusableHeaderFooterViewWithIdentifier:self.headerIdentifier];
    header.textLabel.text = self.title;
    header.textLabel.textColor = self.titleColor ?: [UIColor darkGrayColor];
    self.accessoryView.frame = [self accessoryRectForBounds:header.bounds];
    [header.contentView addSubview:self.accessoryView];
    return header;
}

- (void)willDisplayHeader:(__kindof UIView *)header
{
    UITableViewHeaderFooterView *headerView = (UITableViewHeaderFooterView *)header;
    headerView.textLabel.font = self.titleFont ?: [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline];
    self.accessoryView.frame = [self accessoryRectForBounds:header.bounds];
}

......

注意,上面需要在 willDisplayHeader: 中设置 textLabel 的字体(可能是苹果的 bug)

同时为了满足横滚等需求,实现一个 RTCollectionComponent,它管理一个 UICollectionView 实例,实现它的 DatasourceDelegate,提供一个入口供子类注册自定义的 UICollectionViewCell,并最终将它添加到 cell.contentView 上:

@interface RTCollectionComponent : RTActionHeaderComponent <UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>
@property (nonatomic, readonly, strong) UICollectionView *collectionView;

- (void)configureCollectionView:(UICollectionView *)collectionView NS_REQUIRES_SUPER;

- (CGRect)collectionViewRectForBounds:(CGRect)bounds;

@end

结果

在 Demo 中项目自定义了四种 Component

  • RTDemoTagsComponent
  • RTDemoBannerComponent
  • RTDemoImageItemComponent
  • RTDemoItemComponent

最终实现的界面效果类似如下:

而整个 VC 的代码只是挂载了四个 Component,在其他 VC 中这些组件也可以选择性地复用,且有较高的配置灵活性:

- (void)viewDidLoad {
    [super viewDidLoad];

    RTDemoTagsComponent *tags = [RTDemoTagsComponent componentWithTableView:self.tableView
                                                                   delegate:self];
    self.components = @[tags,
                        [RTDemoImageItemComponent componentWithTableView:self.tableView
                                                                delegate:self],
                        [RTDemoBannerComponent componentWithTableView:self.tableView
                                                             delegate:self],
                        [RTDemoImageItemComponent componentWithTableView:self.tableView
                                                                delegate:self],
                        [RTDemoItemComponent componentWithTableView:self.tableView
                                                           delegate:self]];

    [tags reloadDataWithTableView:self.tableView
                        inSection:0];
}

单个 Component 的数据可以由 VC 发起请求后一起塞回,或者每个 Component 自己在 - (void)reloadDataWithTableView:inSection: 方法中请求,而 VC 负责触发一次请求,取决于具体实现与需求。

总结

一个程序员的日常无非就是在处理产品经理的各种合理非理的需求,在真正动手之前多停下来思考一下,磨刀不误砍柴功,以不变应对万变的需求。在上面这种实现中,无论临时增加或减少一个展示段,无非就是增加、减少一个 Component,修改起来没有痛苦。而如果像以前一样用 switch (indexPath.section) 的办法,不仅改起来不方便,还容易 Crash

以上所有代码匀可以在 Github 上找到,并会在整理后发布到 Cocoapods

本文只针对 UITableView 做了简单的组件化,同样的操作可以应用到 UICollectionView 上,且更多实用,并且现在已经有开源实现:https://github.com/Instagram/IGListKit。如何更全面的、完全的组件化?参考以下两个实现:HubFrameworkComponentKit