如何写好一个UITableView
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何写好一个UITableView相关的知识,希望对你有一定的参考价值。
如果你觉得UITableViewDelegate和UITableViewDataSource这两个协议中有大量方法每次都是复制粘贴,实现起来大同小异;如果你觉得发起网络请求并解析数据需要一大段代码,加上刷新和加载后简直复杂度爆表,如果你想知道为什么下面的代码可以满足上述所有要求:
MVC
系好安全带,上车!
在讨论解耦之前,我们要弄明白 MVC 的核心:控制器(以下简称 C)负责模型(以下简称 M)和视图(以下简称 V)的交互。
这里所说的 M,通常不是一个单独的类,很多情况下它是由多个类构成的一个层。最上层的通常是以Model结尾的类,它直接被 C 持有。Model类还可以持有两个对象:
Item:它是实际存储数据的对象。它可以理解为一个字典,和 V 中的属性一一对应
Cache:它可以缓存自己的 Item(如果有很多)
常见的误区:
一般情况下数据的处理会放在 M 而不是 C(C 只做不能复用的事)
解耦不只是把一段代码拿到外面去。而是关注是否能合并重复代码, 并且有良好的拖展性。
原始版
在 C 中,我们创建UITableView对象,然后将它的数据源和代理设置为自己。也就是自己管理着 UI 逻辑和数据存取的逻辑。在这种架构下,主要存在这些问题:
违背 MVC 模式,现在是 V 持有 C 和 M。
C 管理了全部逻辑,耦合太严重。
其实绝大多数 UI 相关都是由 Cell 而不是UITableView自身完成的。
为了解决这些问题,我们首先弄明白,数据源和代理分别做了那些事。
数据源
它有两个必须实现的代理方法:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;
简单来说,只要实现了这个两个方法,一个简单的 UITableView 对象就算是完成了。
除此以外,它还负责管理 section 的数量,标题,某一个 cell 的编辑和移动等。
代理
代理主要涉及以下几个方面的内容:
cell、headerView 等展示前、后的回调。
cell、headerView 等的高度,点击事件。
最常用的也是两个方法:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;
提醒:绝大多数代理方法都有一个 indexPath 参数
优化数据源
最简单的思路是单独把数据源拿出来作为一个对象。
这种写法有一定的解耦作用,同时可以有效减少 C 中的代码量。然而总代码量会上升。我们的目标是减少不必要的代码。
比如获取每一个 section 的行数,它的实现逻辑总是高度类似。然而由于数据源的具体实现方式不统一,所以每个数据源都要重新实现一遍。
SectionObject
首先我们来思考一个问题,数据源作为 M,它持有的 Item 长什么样?答案是一个二维数组,每个元素保存了一个 section
所需要的全部信息。因此除了有自己的数组(给cell用)外,还有 section 的标题等,我们把这样的元素命名为 SectionObject:
@interface KtTableViewSectionObject : NSObject
@property (nonatomic, copy) NSString *headerTitle; // UITableDataSource 协议中的 titleForHeaderInSection 方法可能会用到
@property (nonatomic, copy) NSString *footerTitle; // UITableDataSource 协议中的 titleForFooterInSection 方法可能会用到
@property (nonatomic, retain) NSMutableArray *items;
- (instancetype)initWithItemArray:(NSMutableArray *)items;
@end
Item
其中的 items 数组,应该存储了每个 cell 所需要的 Item,考虑到 Cell 的特点,基类的 BaseItem 可以设计成这样:
@interface KtTableViewBaseItem : NSObject
@property (nonatomic, retain) NSString *itemIdentifier;
@property (nonatomic, retain) UIImage *itemImage;
@property (nonatomic, retain) NSString *itemTitle;
@property (nonatomic, retain) NSString *itemSubtitle;
@property (nonatomic, retain) UIImage *itemAccessoryImage;
- (instancetype)initWithImage:(UIImage *)image Title:(NSString *)title SubTitle:(NSString *)subTitle AccessoryImage:(UIImage *)accessoryImage;
@end
父类实现代码
规定好了统一的数据存储格式以后,我们就可以考虑在基类中完成某些方法了。以 -
(NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section 方法为例,它可以这样实现:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
if (self.sections.count > section)
KtTableViewSectionObject *sectionObject = [self.sections objectAtIndex:section];
return sectionObject.items.count;
return 0;
比较困难的是创建 cell,因为我们不知道 cell 的类型,自然也就无法调用 alloc 方法。除此以外,cell 除了创建,还需要设置 UI,这些都是数据源不应该做的事。
这两个问题的解决方案如下:
定义一个协议,父类返回基类 Cell,子类视情况返回合适的类型。
为 Cell 添加一个 setObject 方法,用于解析 Item 并更新 UI。
优势
经过这一番折腾,好处是相当明显的:
子类的数据源只需要实现 cellClassForObject 方法即可。原来的数据源方法已经在父类中被统一实现了。
每一个 Cell 只要写好自己的 setObject 方法,然后坐等自己被创建,被调用这个方法即可。
子类通过 objectForRowAtIndexPath 方法可以快速获取 item,不用重写。
对照 demo(SHA-1:6475496),感受一下效果。
优化代理
我们以之前所说的,代理协议中常用的两个方法为例,看看怎么进行优化与解耦。
首先是计算高度,这个逻辑并不一定在 C 完成,由于涉及到 UI,所以由 Cell 负责实现即可。而计算高度的依据就是 Object,所以我们给基类的 Cell 加上一个类方法:
+ (CGFloat)tableView:(UITableView*)tableView rowHeightForObject:(KtTableViewBaseItem *)object;
另外一类问题是以处理点击事件为代表的代理方法, 它们的主要特点是都有 indexPath 参数用来表示位置。然而实际在处理过程中,我们并不关系位置,关心的是这个位置上的数据。
因此,我们对代理方法做一层封装,使得 C 调用的方法中都是带有数据参数的。因为这个数据对象可以从数据源拿到,所以我们需要能够在代理方法中获取到数据源对象。
为了实现这一点, 最好的办法就是继承 UITableView:
@protocol KtTableViewDelegate@optional
- (void)didSelectObject:(id)object atIndexPath:(NSIndexPath*)indexPath;
- (UIView *)headerViewForSectionObject:(KtTableViewSectionObject *)sectionObject atSection:(NSInteger)section;
// 将来可以有 cell 的编辑,交换,左滑等回调
// 这个协议继承了UITableViewDelegate ,所以自己做一层中转,VC 依然需要实现某
@end
@interface KtBaseTableView : UITableView@property (nonatomic, assign) id ktDataSource;
@property (nonatomic, assign) id ktDelegate;
@end
cell 高度的实现如下,调用数据源的方法获取到数据:
- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath
id dataSource = (id)tableView.dataSource;
KtTableViewBaseItem *object = [dataSource tableView:tableView objectForRowAtIndexPath:indexPath];
Class cls = [dataSource tableView:tableView cellClassForObject:object];
return [cls tableView:tableView rowHeightForObject:object];
优势
通过对 UITableViewDelegate 的封装(其实主要是通过 UITableView 完成),我们获得了以下特性:
C 不用关心 Cell 高度了,这个由每个 Cell 类自己负责
如果数据本身存在数据源中,那么在代理协议中它可以被传给 C,免去了 C 重新访问数据源的操作。
如果数据不存在于数据源,那么代理协议的方法会被正常转发(因为自定义的代理协议继承自 UITableViewDelegate)
对照 demo(SHA-1:ca9b261),感受一下效果。
更加 MVC,更加简洁
在上面的两次封装中,其实我们是把 UITableView 持有原生的代理和数据源,改成了 KtTableView 持有自定义的代理和数据源。并且默认实现了很多系统的方法。
到目前为止,看上去一切都已经完成了,然而实际上还是存在一些可以改进的地方:
目前仍然不是 MVC 模式!
C 的逻辑和实现依然可以进一步简化
基于以上考虑, 我们实现一个 UIViewController 的子类,并且把数据源和代理封装到 C 中。
@interface KtTableViewController : UIViewController@property (nonatomic, strong) KtBaseTableView *tableView;
@property (nonatomic, strong) KtTableViewDataSource *dataSource;
@property (nonatomic, assign) UITableViewStyle tableViewStyle; // 用来创建 tableView
- (instancetype)initWithStyle:(UITableViewStyle)style;
@end
为了确保子类创建了数据源,我们把这个方法定义到协议里,并且定义为 required。
成果与目标
现在我们梳理一下经过改造的 TableView 该怎么用:
首先你需要创建一个继承自 KtTableViewController 的视图控制器,并且调用它的 initWithStyle 方法。
KTMainViewController *mainVC = [[KTMainViewController alloc] initWithStyle:UITableViewStylePlain];
在子类 VC 中实现 createDataSource 方法,实现数据源的绑定。
- (void)createDataSource
self.dataSource = [[KtMainTableViewDataSource alloc] init]; // 这 一步创建了数据源
在数据源中,需要指定 cell 的类型。
- (Class)tableView:(UITableView *)tableView cellClassForObject:(KtTableViewBaseItem *)object
return [KtMainTableViewCell class];
在 Cell 中,需要通过解析数据,来更新 UI 并返回自己的高度。
+ (CGFloat)tableView:(UITableView *)tableView rowHeightForObject:(KtTableViewBaseItem *)object
return 60;
// Demo 中沿用了父类的 setObject 方法。
还有什么要优化的
到目前为止,我们实现了对 UITableView 以及相关协议、方法的封装,使它更容易使用,避免了很多重复、无意义的代码。
在使用时,我们需要创建一个控制器,一个数据源,一个自定义 Cell,它们正好是基于 MVC 模式的。因此,可以说在封装与解耦方面,我们已经做的相当好了,即使再花大力气,也很难有明显的提高。
但关于 UITableView 的讨论远远没有结束,我列出了以下需要解决的问题
在这种设计下,数据的回传不够方便,比如 cell 的给 C 发消息。
下拉刷新与上拉加载如何集成
网络请求的发起,与解析数据如何集成
关于第一个问题,其实是普通的 MVC 模式中 V 和 C 的交互问题,可以在 Cell(或者其他类) 中添加 weak 属性达到直接持有的目的,也可以定义协议。
问题二和三是另一大块话题,网络请求大家都会实现,但如何优雅的集成进框架,保证代码的简单和可拓展,就是一个值得深入思考,研究的问题了。接下来我们就重点讨论网络请求。
为何创建网络层
一个 ios
的网络层框架该如何设计?这是一个非常宽泛,也超出我能力范围之外的问题。业内已有一些优秀的,成熟的思路和解决方案,由于能力,角色所限,我决定从一个
普通开发者而不是架构师的角度来说说,一个普通的、简单的网络层该如何设计。我相信再复杂的架构,也是由简单的设计演化而来的。
对于绝大多数小型应用来说,集成 AFNetworking 这样的网络请求框架就足以应付 99% 以上的需求了。但是随着项目的扩大,或者用长远的眼光来考虑,直接在 VC 中调用具体的网络框架(下面以 AFNetworking 为例),至少存在以下问题:
一旦日后 AFNetworking 停止维护,而且我们需要更换网络框架,这个成本将无法想象。所有的 VC 都要改动代码,而且绝大多数改动都是雷同的。
这样的例子真实存在,比如我们的项目中就依然使用早已停止维护的 ASIHTTPRequest,可以预见,这个框架迟早要被替换。
现有的框架可能无法实现我们的需求。以 ASIHTTPRequest 为例,它的底层用 NSOperation
来表示每一个网络请求。众所周知,一个 NSOperation 的取消,并不是简单调用 cancel
方法就可以的。在不修改源码的前提下,一旦它被放入队列,其实是无法取消的。
有时候我们的需求仅仅是进行网络请求,还会对这个请求进行各种自定义的拓展。比如我们可能要统计请求的发起和结束时间,从而计算网络请求,数据解
析的步骤的耗时。有时候,我们希望设计一个通用组件,并且支持由各个业务部门去自定义具体的规则。比如可能不同的部门,会为 HTTP
请求添加不同的头部。
网络请求还有可能有其他广泛需要添加的需求,比如请求失败时的弹窗,请求时的日志记录等等。
参考当前代码(SHA-1:a55ef42)感受一下没有任何网络层时的设计。
如何设计网络层
其实解决方案非常简单:
所有的计算机问题,都可以通过添加中间层来解决
读者可以自行思考,为什么添加中间层可以解决上述三个问题。
三大模块
对于一个网络框架来说,我认为主要有三个方面值得去设计:
如何请求
如何回调
数据解析
一个完整的网络请求一般由以上三个模块组成,我们逐一分析每个模块实现时的注意事项:
发起请求
发起请求时,一般有两种思路,第一种是把所有要配置的参数写到同一个方法中,借用 与时俱进,HTTP/2下的iOS网络层架构设计 一文中的代码表示:
+ (void)networkTransferWithURLString:(NSString *)urlString
andParameters:(NSDictionary *)parameters
isPOST:(BOOL)isPost
transferType:(NETWORK_TRANSFER_TYPE)transferType
andSuccessHandler:(void (^)(id responseObject))successHandler
andFailureHandler:(void (^)(NSError *error))failureHandler
// 封装AFN
这种写法的好处在于所有参数一目了然,而且简单易用,每次都调用这个方法即可。但是缺点也很明显,随着参数和调用次数的增多,网络请求的代码很快多到爆炸。
另一组方法则是将 API 设置成一个对象,把要传入的参数作为这个对象的属性。在发起请求时,只要设置好对象的相关属性,然后调用一个简单的方法即可。
@interface DRDBaseAPI : NSObject
@property (nonatomic, copy, nullable) NSString *baseUrl;
@property (nonatomic, copy, nullable) void (^apiCompletionHandler)(_Nonnull id responseObject, NSError * _Nullable error);
- (void)start;
- (void)cancel;
...
@end
根据前文提到的 Model 和 Item 的概念,那么应该可以想到:这个用于访问网络的 API 对象,其实是作为 Model 的一个属性。
Model 负责对外暴露必要的属性和方法,而具体的网络请求则由 API 对象完成,同时 Model 也应该持有真正用来存储数据的 Item。
如何回调
一次网络请求的返回结果应该是一个 JSON 格式的字符串,通过系统的或者一些开源框架可以将它转换成字典。
接下来我们需要使用 runtime 相关的方法,将字典转换成 Item 对象。
最后,Model 需要将这个 Item 赋值给自己的属性,从而完成整个网络请求。
如果从全局角度来说,我们还需要一个 Model 请求完成的回调,这样 VC 才能有机会做相应的处理。
考虑到 Block 和 Delegate 的优缺点,我们选择用 Block 来完成回调。
数据解析
这一部分主要是利用 runtime 将字典转换成 Item,它的实现并不算难,但是如何隐藏好实现细节,使上层业务不用过多关心,则是我们需要考虑的问题。
我们可以定义一个基类的 Item,并且为它定义一个 parseData 函数:
// KtBaseItem.m
- (void)parseData:(NSDictionary *)data
// 解析 data 这个字典,为自己的属性赋值
// 具体的实现请见后面的文章
参考技术A 1实现tableview的下拉刷新23tableview滑动就会触发这个方法?4*/5-(void)tableView:(UITableView*)tableViewwillDisplayCell:(UITableViewCell*)cellforRowAtIndexPath:(NSIndexPath*)indexPath67//当tableview下拉到最后一行的时候才触发8if(indexPath.row==self.m_data.count-1)910//定义一个UIView11UIView*footSpinnerView=[[UIViewalloc]initWithFrame:CGRectMake(0.0f,0.0f,320.0f,60.0f)];1213//顶一个有刷新图标的view14UIActivityIndicatorView*activity=[[UIActivityIndicatorViewalloc]initWithFrame:CGRectMake(130.0f,0.0f,60.0f,60.0f)];15activity.color=[UIColorredColor];16[activitystartAnimating];//启动有刷新图标的view1718footSpinnerView.backgroundColor=[UIColorgrayColor];19[footSpinnerViewaddSubview:activity];2021//设置footerview22self.myTableView.tableFooterView=footSpinnerView;2324//self.myTableView.tableHeaderView=footSpinnerView;2526dispatch_queue_tqueue=dispatch_queue_create("myqueue",nil);2728//在后台线程添加数据29dispatch_async(queue,^(void)3031[self.m_dataaddObject:@"1000"];32[self.m_dataaddObject:@"1001"];33[self.m_dataaddObject:@"1002"];34[self.m_dataaddObject:@"1003"];35[self.m_dataaddObject:@"1004"];3637);3839//添加完数据就重新加载数据40dispatch_async(queue,^(void)4142sleep(2);43dispatch_sync(dispatch_get_main_queue(),^(void)4445[self.myTableViewreloadData];46);47);4849//[self.myTableViewreloadData];50dispatch_release(queue);51[footSpinnerViewrelease];52[activityrelease];5354//elseif(indexPath.row==0)55//56//UIView*footSpinnerView=[[UIViewalloc]initWithFrame:CGRectMake(0.0f,0.0f,320.0f,60.0f)];57//UIActivityIndicatorView*activity=[[UIActivityIndicatorViewalloc]initWithFrame:CGRectMake(130.0f,0.0f,60.0f,60.0f)];58//activity.color=[UIColorredColor];59//[activitystartAnimating];60//61//footSpinnerView.backgroundColor=[UIColorgrayColor];62//[footSpinnerViewaddSubview:activity];63//64////self.myTableView.tableFooterView=footSpinnerView;65//66//self.myTableView.tableHeaderView=footSpinnerView;67//68//dispatch_queue_tqueue=dispatch_queue_create("myqueue",nil);69//70//dispatch_async(queue,^(void)71//72//[self.m_datainsertObject:@"1000"atIndex:0];73////[self.m_dataaddObject:@"1001"];74////[self.m_dataaddObject:@"1002"];75////[self.m_dataaddObject:@"1003"];76////[self.m_dataaddObject:@"1004"];77////[self.m_datainsertObject:[NSArrayarrayWithObject:@"01"]atIndex:0];78//79//);80//81//dispatch_async(queue,^(void)82//83//sleep(2);84//dispatch_sync(dispatch_get_main_queue(),^(void)85//86//[self.myTableViewreloadData];87//[self.myTableViewscrollToRowAtIndexPath:[indexPathinitWithIndex:3]atScrollPosition:UITableViewScrollPositionNoneanimated:YES];88//);89//);90//91//92//dispatch_release(queue);93//[footSpinnerViewrelease];94//[activityrelease];95//9697//如果不是最后一行,则把footerview和headerview都设为nil98else99100self.myTableView.tableFooterView=nil;101self.myTableView.tableHeaderView=nil;102103
如何与重叠显示的 UITableView 交互另一个 UITableView
【中文标题】如何与重叠显示的 UITableView 交互另一个 UITableView【英文标题】:How to interact with a UITableView displayed overlapping another UITableView 【发布时间】:2011-08-25 07:24:47 【问题描述】:我有一个 UITableView 显示一些数据,当用户开始在 UITableView 上方的 UITextField 中输入时,另一个 UITableView 会显示在 UITextField 下方以显示建议。这个“建议” UITableView 与 UITextField 下面的 UITableView 重叠。点击其中一个建议将在文本框中填充被点击的项目。
建议 UITableView 显示在另一个 UITableView 的前面,但问题是,如果建议 UITableView 超出了包含它的视图的边界,则不会注册任何对建议 UITableView 的点击。
一般来说,这可能是一个非常简单的问题,更多地处理 UIViews,但我遇到了麻烦。感谢您的帮助!
【问题讨论】:
【参考方案1】:您必须调整您的第二个表格视图,它会在测试字段下方为您提供建议,这将解决您的大部分问题
希望对你有帮助
【讨论】:
这使我朝着正确的方向前进-我最终根据建议 UITableView 的大小调整了包含 UITextField 和建议 UITableView 的 UIView 框架的大小,然后将框架设置回原始关闭建议视图时的大小。谢谢!【参考方案2】:您可以在当前视图上方显示的另一个视图中显示第二个 UITableView,例如弹出框,使用
[self.view addSubview:secondView].
做出选择后,您可以使用
删除此视图[secondView removeFromSuperview];
【讨论】:
很好的建议,但似乎无法解决我看到的问题。我仍然需要调整包含两个控件的 UIView 的框架,以便与建议 UITableView 进行交互。我想知道隐藏和显示建议或添加其视图然后按照您的建议将其从超级视图中删除是否更好(性能或内存使用)?以上是关于如何写好一个UITableView的主要内容,如果未能解决你的问题,请参考以下文章