从设计一个可折叠tableview组件谈谈数据驱动UI (上篇)

阿凡达2018-06-22 10:46
可折叠的分组列表在日常的开发中并不少见,基本原理不外乎利用tableview的
- (void)reloadSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation
搭配
- (NSInteger)numberOfRowsInSection:(NSInteger)section
返回0即可让section展示收缩的效果。具体的实现可参考 iOS UITableview实现展开折叠效果
最近有一个需求正好是实现一个可折叠的tableview,但是服务端那边的接口迟迟不能确定,UI的设计稿却已经出来了,大概长这样:


点击这个sectionheader需要收起整个组的cell。设计稿中各种小图标红的绿的蓝的,文字还有各种状态,置灰态、正常态等,同时没有确定后端接口,该怎么办呢?
model层的前后分离
UI层的业务逻辑和后端接口产生依赖是一个很不好的现象。如果能分离前后端的model,代码的可拓展性将大大加强,大概的设计逻辑长下面这样。


后端接口model表示根据接口字段抽象出的model,完全根据后端的接口字段确定,大概会长这样:
@interface xxxmodel : NSObject
...
@property (nonatomic, copy) str1;
@property (nonatomic, copy) str2;
@property (nonatomic, assign) num1;
@property (nonatomic, assign) num2;
...
@end

@implementation
@end
UI层的model表示决定UI展示样式的model。中间的adapter就是一个转换器,将接口Model转换成UI层的model,同常这里是充满胶水代码的地方,不知道读者有没有听过这么一句话:什么是设计模式,设计模式就是让代码里面所有的屎都集中在一个茅坑里,让其他地方都干干净净。没错,如果你也没听过,那么这句话就是我说的- -,

在后端接口没确定的情况下我们可以先根据设计稿长的样式开始先着手UI层model的开发,注意这里的UI层model并不是严格意义上的MVVM中的viewModel。

这里可以将每一个section抽象成一个stage,将section下面的每个cell抽象成一个item。


比如这里每个cell左上角的有一个小图标,从设计稿来看它有可能是已结业、未结业、已完成或者不显示,item的model就可以这么写:
typedef NS_ENUM(NSUInteger, CompositeSubItemStatus) {
    /**已结业*/
    CompositeSubItemCompleted = 0,
    /**未结业*/
    CompositeSubItemNotSatisfied,
    /**已完成*/
    CompositeSubItemSubmit,
    /**无状态*/
    CompositeSubItemStatusNone
};

@interface CompositeSubItemModel : NSObject
...
/**是否结业、完成状态, default statusNone*/
@property (nonatomic, assign) CompositeSubItemStatus status;
...
@end
每个cell持有一个itemModel根据itemModel中的这个字段就能判断该怎么正确显示。以此类推cell中其他的UI对应不同的itemModel里面的字段,这里我想说明的是设计稿中一眼能看出有两个大类的cell,一个带body文字的,一个不带。


这里当然可以枚举一个字段去做对应的区分就像下面这样:
typedef NS_ENUM(NSUInteger, CompositeSubItemCellType) {
CompositeSubItemCellContainBody = 0,
 	CompositeSubItemCellNoBody = 0,
};
但是最佳实践应该是定义一个body的字符串,cell根据body是否有值去展示不同的cell(可以认为彻底贯彻数据驱动UI的理念)
@property (nonatomic, copy) NSString *body;

好,现在你已经根据UI的样式抽象出itemModel和stageModel了。这样子设计除了能让你在不知道后端接口的情况下提前开始写代码之外有什么好处呢?若以后有其他的场景需要这个页面但是接口又不完全一样的话,只需要新写一个adapter就OK了,这块的UI完全实现了和后端业务接口的解耦。


数据驱动UI
仔细看设计稿,发现每一个section的最后一个cell的最底下的分割线都会隐藏,right?一般的实现姿势大概是这样:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
   ...
   if (indexPath.row == [tableView numberOfRowsInSection:indexPath.section]-1) {
        cell.separateLine.hidden = YES;
    }
    return cell;
}

but,好像哪里不对劲,不是说好的数据驱动UI吗?所以直接在vc里面去改变cell的显示状态是违背这个理念的。那么正确的姿势应该是让cell根据model的状态自行去判断是否应该隐藏分割线:
//CompositeSubItemModel.h

@interface CompositeSubItemModel : NSObject
...
@property (nonatomic, weak) CompositeCourseStageModel *parentStage;
...
@end

//CompositeSubItemBaseCell.m

@implementation CompositeSubItemBaseCell

- (void)setItemModel:(JYVCompositeSubItemModel *)itemModel {
...
    if (_itemModel == _itemModel.parentStage.items.lastObject) {
        self.separateLine.hidden = YES;
    } else {
        self.separateLine.hidden = NO;
    }
...
- }

@end

这样做只需要在adapter里面生成UI层model的时候赋值好parentStage这个字段就好了,但是真的需要组件的使用者在adapter里面去赋值这个字段吗?我们回想一下cocoa里面UIView的API:
UIView *parent = [UIView new];
UIView *son = [UIView new];
[parent addSubview son];

这时候son.superview已经能指向parent了。嗯哼,所以我们要尽量保持使用的简洁性,下面才是正确的姿势:
@implementation CompositeCourseStageModel

- (void)setStageHeader:(CompositeStageHeaderModel *)stageHeader {
    _stageHeader = stageHeader;
    _stageHeader.parentStage = self;
}

- (void)setItems:(NSArray *)items {
    _items = items.copy;
    for (CompositeSubItemModel *item in items) {
        item.parentStage = self;
    }
}

@end

在设计组件或者框架的时候,一定要参考cocoa的API设计理念,经常问一句:如果你是设计cocoa的工程师,你会怎么设计这个接口?下面一起来实现这个展开收起的功能,section展开收起的状态我们用一个字段fold来保存。因为section的header在展开收起时候的UI是有不同的,所以stageModel和headerModel都得持有这个状态:
@interface CompositeCourseStageModel: NSObject
@property (nonatomic, assign) BOOL fold;
@end

@interface CompositeStageHeaderModel: NSObject
@property (nonatomic, assign) BOOL fold;
@end

oh no,明明是表示的是一个同一个stage的展开或者收起状态,为什么需要用两个字段,真的一点都不优雅。这个fold的状态站在数据层的角度来看就是最细粒度的一个标志位,若我们在实现的model里面用了两个或者多个字段来表示,则我们必须维护这两个字段的一致性,这在以后的维护上是很麻烦一件事情。所以干脆不要headerModel里面的这个字段吧,毕竟headerModel有一个parentStage的指针
header.parentStage.fold

这样就解决了多个标志位带来的麻烦,嗯哼,不错,这是一种解决方案,但这仍旧不是最优雅的实现。我还是希望用header.fold怎么办,这样子代码的可读性是最高的。
@interface CompositeStageHeaderModel : NSObject
@property (nonatomic, assign) BOOL fold;
@end

@implementation CompositeStageHeaderModel
- (BOOL)fold {
    return self.parentStage.fold;
}

- (void)setFold:(BOOL)fold {
    self.parentStage.fold = fold;
}
@end


本文来自网易实践者社区,经作者朱建峰授权发布。
相关阅读:

从设计一个可折叠tableview组件谈谈数据驱动UI (下篇)


header并没有持有一个_fold的实例变量,而是将fold briding到parentStage上去,这样底层仍旧只有stageModel持有一个_fold这个实例变量,但是对于使用者来说,既可以访问stage.fold也可以访问header.fold同时不同解决多个标志位带来的一致性问题,so elegant!
说了这么多,是时候抽象出一张数据驱动UI的脑图了:
核心是数据,数据只接受网络请求和用户操作的改变,然后去改变UI,UI的改动只依赖于数据。遵照这种设计,还有一个额外的好处就是可测试性大大加强,输入不同的数据测试UI是否正确显示。这一点和函数式编程中"纯函数式"的函数的概念不谋而合,一个函数只接收一个确定的输入产生一个输出,输入确定的话输出就一定是可预测的。
可拓展性
突然有一天,策划跑过来找你,开发大大我这里希望有一种cell能不展开收缩诶,比如最底下能有一个播放视频的cell。
好好思考下我们抽象出的stage和item的模型,不能展开收缩的cell其实就是一个fold==NO的stage里面只有一个item,而且这个stage的header不能改变fold的状态,剩下的工作只要根据视觉稿自定义下这种类型的stage的sectionheader就好了。
突然有一天,策划跑过来找你,开发大大我这里希望有一个三级联动的section收缩展开诶。
对于界面UI来说,不管几级的展开收起都可以最终转化成section和下面的cell的个数的变化,二级的联动的展开收起表现为一次只有一个section的有变化,多级联动的展开收起是一次有多个section的变化,仔细思考下这句话,right?下面图示以三级的联动为例:
我们上面的数据结构仍旧可以保留,只需要在最外面新建一个segment的model持有一个数组的stage。在tableview生成sectionheader的时候需要判断生成的是哪种model的header,是segment的还是stage的。每当用户点击发生的时候去改变对应的fold标志位的状态,然后reload。在tableview的代理:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
//通过section计算出对应的segment和stage
...
    if(isSegment) return 0;//恰好是segment最上层,只显示一个section header
    //正常的stage
    if (_dataSource[segment].fold || _dataSource[segment][stage].fold) { return 0; }
    return _dataSource[segment][stage].items.count;
}
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
    //通过section计算出对应的segment和stage
...
    if(isSegment) return [SegmentHeaderView new];//segment必然需要展示header
    if(!isSegment && !_datasource[segment].fold) return [Stageheader new];//stage的header在segment展开的状态才展示
    return nil;
}

上面贴出了伪代码,有兴趣的读者可以自行去实现。
结语
1. 日常的工作不可避免的要遇到各种写界面的需求,数据驱动UI的思想能使写出的代码更具可拓展性和可读性,同时易于接入单元测试。数据驱动UI的思路能将主要工作集中在对model层的抽象上,对我们以后写其他的框架也是很有好处的。
2. model层的抽象要注意控制model的粒度,尽量减少不同字段的重复表意,重读表意的字段将为以后的维护带来很大的麻烦。
3. 接口的设计要尽量遵循cocoa的设计风格。这里面接口的设计又是有大文章可以研究,即要控制好接口的功能粒度,粒度太大的接口一次性完成了很多工作但是是去了灵活性,粒度太小的接口会让使用者失去便利性(如实现一个功能需要组合调用几个接口)。同时接口设计要为以后的拓展预留出空间又要避免过度设计。设计是永远需要钻研的一门艺术,建议读者多研究一些出名开源库的源码。
4. 最后,数据驱动UI换成另外一种表达就是"UI只是数据的一种表现形式" (这个逼就要装不下去了,逃。。)
5. 数据驱动UI再往深处探究下就是以ReactiveCocoa为代表的函数式编程了,有兴趣的读者可以参考王巍大神的这篇 单向数据流动的函数式 View Controller

本文来自网易实践者社区,经作者朱建锋授权发布。