四月 11, 2018

UITableView针对可变高度Cell的架构设计(下)

紧接上一篇。在上一篇中介绍了如何定义数据结构,这篇开始介绍模板设计。

模板(UITableViewCell)的设计

数据已经有了,现在需要将数据显示出来,不同的Feed对应不同的模板。这时候我们需要再捋一下思路。

  1. 如何建立Feed跟模板之间的对应关系?
  2. 在哪里计算高度?以及如何计算高度?
  3. 计算出来的Cell高度保存到哪里?
  4. 各种模板如何设计
  5. 各种UI点击的支持

下面一个一个来提出解决方案。

如何建立Feed跟模板之间的对应关系

针对第一个问题,考虑到N种Feed数据,对应N种模板。而每一种Feed都会有一个feedType的字段,那么我们大可以直接根据feedType来建立对应关系,我们需要将这个对应关系存放在一个字典中,最好是一个全局字典。因此我们可以创建一个单例来存放数据,也可以直接存放到一个静态字段中,考虑到这部分数据是固定的,不会更改,我们可以直接存放到某个类的静态变量中,然后在这个类的initialize方法中来初始化这个字典。代码如下:

static NSDictionary<NSNumber*,Class> * _registedFeedCellTypes;
+(void)initialize{
    _registedFeedCellTypes = @{
                               @(1):[Type1FeedTableViewCell class],
                               };
}

这样一来,我们可以直接通过feedType来获取对应的模板Cell,哪怕有两种type对应同一个模板也没关系。其实这样一种设计方式还有一个好处,那就是可以直接通过遍历字典,往UITableView中注册Cell(前提是我们还得自定义一个protocol来获取cellIdentifier)。

如何计算高度?

其实对于高度的计算难点主要集中在如下几点:

  1. 文本:文本的高度计算其实也分好几种
    1. 限制最大高度
    2. 限制最大行数
    3. 超过高度或者行数后还要添加诸如:“全文”等提示按钮。

    不管如何,这里计算出文本高度以后,你还需要把高度保存起来,以便在更新UI的时候,正确设置文本的高度。
    计算文本高度的时候还需要综合考虑允许的最大宽度才能正确计算出实际的高度
    有时候在计算文本高度的时候为了方便,直接在计算的时候采用NSAttributeString来计算,这时候一般还需要将整个NSAttributeString保存起来,方便在UI更新的时候直接赋值给UILable

  2. ** 图片**:图片高度计算也分好几种
    1. 固定比例计算
    2. 给定最大宽高等比压缩计算
    3. 固定一行显示N张图片后,等分宽度计算
    4. 其他

    在计算图片高度前,要是提前已经知道图片的size的话那还好计算,一般图片的size直接在api接口中随着数据一起返回给前端。但是有时候服务端并不会返回size,这时候就需要考虑事先给定一个图片默认的大小,等待客户端将图片加载出来后再重新计算高度,然后重新更新一次UI。
    同文本高度计算一样,需要将图片的高度计算结果保存到一个字段中。

  3. UI布局中的固定高度

    在UI布局中有些元素的高度是固定的,因此这部分固定的元素高度可以直接采用宏定义的方式在头文件中定义好,不建议直接写死。

  4. UI元素因为布局产生的间距。

    UI布局中,元素跟元素之间必定会有一定的间距,而这些间距一般多是固定的,因此也建议采用宏的方式来定义。而另外一些间距是有条件显示或隐藏的,这时候就为计算带来了难度,计算的时候必须考虑到这部分因素,确保隐藏的间距不要计算进去,而需要显示的间距准确计算进去。

当所有的高度计算完毕后,就把各自的高度加到一起得到Cell的整体高度。

计算出来的Cell高度保存到哪里?

其实在第二步中提到,在计算高度的时候势必会产生一些副产品,比如各种元素的实际显示Size、position,间距等一些信息。之所以会产生这些副产品,是因为这些副产品不是凭空出现的,而是严格按照UI设计稿计算得出的每个元素的属性等信息。因此这些信息必须要被保存起来,方便在更新UI的时候直接使用,而无需二次计算。另外一个是,UI元素的尺寸计算其实是一件非常消耗性能的事情,因此所有决定元素布局的数据计算完成后必须保存起来,避免二次计算带来的不必要性能损耗。

而不同的模板产生的计算副产品数据是不一样的,因此直接采用字典来保存这些数据是一个比较简单的方案。

综上,我们可以降这部分的数据存到两个字段中如下:

/**
 cell的高度
 */
@property (nonatomic,readonly)CGFloat cellHeight;

/**
 cell的额外存储数据,主要是在计算高度时候存储一些额外数据
 */
@property (nonatomic,readonly)NSMutableDictionary *cellExtData;

这样一来,我们既可以快速获取到每个cell的高度,又可以将一些副产品数据存储起来。

各种模板如何设计

因为有N种模板,那么就会有N种不同的UI布局,但即使是这样,总会有一些公共的元素的,因此,这里可以直接先设计一个BaseCell,然后采用继承的方式的来实现各个模板。将公共元素放在BaseCell中,模板的设计这块其实本身没有什么复杂的架构设计,各种模板完全是按照各自的UI设计稿来实现即可。

各种UI点击的支持

每种模板都会包含N个布局元素,在实际的需求上,每个元素都有可能被要求可以点击进而产生交互行为,而对于UI设计师来说,他们可能会要求对每一种可以被点击的元素都要添加一个点击效果,这样一来如何去管理和实现这些点击就会是一个难点。

要实现点击需求无外乎两种方式。

  1. 直接把所有可以被点击的元素采用UIButton。但是用button也有问题,那就是遇到UI本身比较小,但是需要扩大点击区域的时候就没办法了。

  2. UITableViewCell添加一个UITapGestureRecognizer,然后通过点击的Point来对各种元素做整体分发。

其实在我实际的项目使用中,不会采用第一种方案,而是采用第二种方案。这两种方案之间本身并没有孰优孰劣的区分,而是我认为如果全部采用UIButton并不会带来多少代码上的便利,甚至缺少美感。:satisfied:

而采用第二种方案的话,可能遇到的最大问题就是如何分发事件。一个点击或者触摸肯定会对应一个点,那么这个点在哪里,就说明点击了哪里,只要找到这个点对应的UI元素就行了。只要使用UIView提供的convertPoint:toView:方法并且结合CGRectContainsPoint宏就能确定这个点是否处于该元素内,这样就能找到被点击的元素了,采用这种方法还有一个好处

那就是我们可以在确定点击元素的时候可以随意扩大点击范围,但这时候就需要考虑下如何避免范围重叠的问题,但这个已经小问题了。

确定点击元素以后就是添加点击效果了,其实这个问题,不管你是采用哪种方案,都需要我们自己去实现点击效果,比如点击的时候改变背景颜色、或者改变UI的透明度、改变UI大小、添加动画等一些点击效果,而这种点击效果就需要我们自己去实现了。

因此,综合来说,我个人就比较倾向于采用第二种方案了。

代码示例

通过上面的分析,我直接贴出相关的实现代码。

@interface FeedTableCellModel : NSObject

@property (nonatomic,strong)BaseFeedData *feedData;

@property (nonatomic,readonly)Class cellCalss;

/**
 cell的高度
 */
@property (nonatomic,readonly)CGFloat cellHeight;

/**
 cell的额外存储数据,主要是在计算高度时候存储一些额外数据
 */
@property (nonatomic,readonly)NSMutableDictionary *cellExtData;

-(id)initWithFeedData:(BaseFeedData *)feedData;

+(NSDictionary<NSNumber*,Class> *)registedFeedCellTypes;
@end


@implementation FeedTableCellModel
static NSDictionary<NSNumber*,Class> * _registedFeedCellTypes;
+(void)initialize{
    _registedFeedCellTypes = @{
                               @(1):[Type1FeedTableViewCell class],
                               };
}

+(NSDictionary<NSNumber*,Class> *)registedFeedCellTypes{
    return _registedFeedCellTypes;
}

-(id)init{
    self =[super init];
    _cellExtData = [NSMutableDictionary dictionary];
    return self;
}

-(id)initWithFeedData:(BaseFeedData *)feedData{
    self=[self init];
    self.feedData = feedData;
    [self initCellData];
    return self;
}

-(void)setFeedData:(BaseFeedData *)feedData{
    _feedData = feedData;
    [self initCellData];
}

-(void)initCellData{
    _cellCalss =[_registedFeedCellTypes objectForKey:@(self.feedData.feedType)];
    if(_cellCalss)
        _cellHeight = [self.cellCalss cellHeightWithModel:self];
}

@end

上面定义了一个数据类,其实这个类你可以理解为MVC中的C层,我这么说,各位应该知道这个类的具体作用了吧?在实际的开发过程中,你可以可能会遇到一些开关类的需求,比如当遇到条件A的时候,不去显示某个UI元素等等,这些开关可以直接作为字段定义到这个类中,方便在计算计算高度以及更新UI的时候使用到。

@interface BaseFeedTableViewCell : UITableViewCell
@property (nonatomic,strong)FeedTableCellModel *dataModel;


/**
 更新UI
 */
-(void)updateUI;


/**
 处理点击事件

 @param point <#point description#>
 @return <#return value description#>
 */
-(BOOL)handleClick:(CGPoint)point;

+ (NSString *)cellIdentifier;

/*
 *
 * 计算Cell的高度
 *  @param  消息model
 *
 *  @result cell高度
 */
+ (CGFloat)cellHeightWithModel:(id)model;
@end

上面这类就是一个大概的Feed模板基类,提供了一些公共的属性以及方法。

整合到UITableView

上面已经将模板和数据的定义都已经弄好了,现在需要显示到UITableView中了,这样的应用,数据一般都会采用分页的方式从服务端加载,每次加载个20条或者N条数据,然后在UI中显示。

为了提高性能、用户体验,将数据的解析、Cell的高度计算全部放在非UI线程中处理,以免阻塞UI线程发现卡顿的问题,在该线程中,将获取到的数据全部解析完毕,并且计算好对应Cell的高度,等处理完毕后,在将线程通过GCD调度到UI线程上显示出来。这时候有一点需要注意的,因为计算Cell高度我们放在非UI线程了,因此在计算高度时候不要有UI操作,这也是为什么在上面的代码中,将cellHeightWithModel这个方法定义为静态方法的一个原因。

这时候对于在UIViewController中对于Cell的代码其实已经很简单了。

  1. 注册Cell
  [[FeedTableCellModel registedFeedCellTypes].allValues enumerateObjectsUsingBlock:^(Class  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        [tableView registerClass:obj forCellReuseIdentifier:[obj cellIdentifier]];
    }];
  1. 在非UI线程加载数据并且计算Cell高度

  2. 显示

-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
    return [[self.dataArray objectAtIndex:indexPath.row] cellHeight];
}

-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
    FeedTableCellModel *object = [self.dataArray objectAtIndex:indexPath.row];
    BaseFeedTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[object.cellCalss cellIdentifier]];
    cell.dataModel = object;
    return cell;
}

发表评论

电子邮件地址不会被公开。 必填项已用*标注