千家信息网

怎么写好一个UITableView

发表于:2024-11-11 作者:千家信息网编辑
千家信息网最后更新 2024年11月11日,这篇文章主要介绍"怎么写好一个UITableView",在日常操作中,相信很多人在怎么写好一个UITableView问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答"怎么写
千家信息网最后更新 2024年11月11日怎么写好一个UITableView

这篇文章主要介绍"怎么写好一个UITableView",在日常操作中,相信很多人在怎么写好一个UITableView问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答"怎么写好一个UITableView"的疑惑有所帮助!接下来,请跟着小编一起来学习吧!

如果你觉得 `UITableViewDelegate` 和 `UITableViewDataSource` 这两个协议中有大量方法每次都是复制粘贴,实现起来大同小异;如果你觉得发起网络请求并解析数据需要一大段代码,加上刷新和加载后简直复杂度爆表,如果你想知道为什么下面的代码可以满足上述所有要求:

MVC

在讨论解耦之前,我们要弄明白 MVC 的核心:控制器(以下简称 C)负责模型(以下简称 M)和视图(以下简称 V)的交互。

这里所说的 M,通常不是一个单独的类,很多情况下它是由多个类构成的一个层。最上层的通常是以 `Model` 结尾的类,它直接被 C 持有。`Model` 类还可以持有两个对象:

1. Item:它是实际存储数据的对象。它可以理解为一个字典,和 V 中的属性一一对应

2. Cache:它可以缓存自己的 Item(如果有很多)

常见的误区:

1. 一般情况下数据的处理会放在 M 而不是 C(C 只做不能复用的事)

2. 解耦不只是把一段代码拿到外面去。而是关注是否能合并重复代码, 并且有良好的拖展性。

原始版

在 C 中,我们创建 `UITableView` 对象,然后将它的数据源和代理设置为自己。也就是自己管理着 UI 逻辑和数据存取的逻辑。在这种架构下,主要存在这些问题:

1. 违背 MVC 模式,现在是 V 持有 C 和 M。

2. C 管理了全部逻辑,耦合太严重。

3. 其实绝大多数 UI 相关都是由 Cell 而不是 `UITableView` 自身完成的。

为了解决这些问题,我们首先弄明白,数据源和代理分别做了那些事。

数据源

它有两个必须实现的代理方法:

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;

简单来说,只要实现了这个两个方法,一个简单的 `UITableView` 对象就算是完成了。

除此以外,它还负责管理 `section` 的数量,标题,某一个 `cell` 的编辑和移动等。

代理

代理主要涉及以下几个方面的内容:

1. cell、headerView 等展示前、后的回调。

2. 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,这些都是数据源不应该做的事。

这两个问题的解决方案如下:

1. 定义一个协议,父类返回基类 `Cell`,子类视情况返回合适的类型。

2. 为 `Cell` 添加一个 `setObject` 方法,用于解析 Item 并更新 UI。

优势

经过这一番折腾,好处是相当明显的:

1. 子类的数据源只需要实现 `cellClassForObject` 方法即可。原来的数据源方法已经在父类中被统一实现了。

2. 每一个 Cell 只要写好自己的 `setObject` 方法,然后坐等自己被创建,被调用这个方法即可。

3. 子类通过 `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` 完成),我们获得了以下特性:

1. C 不用关心 Cell 高度了,这个由每个 Cell 类自己负责

2. 如果数据本身存在数据源中,那么在代理协议中它可以被传给 C,免去了 C 重新访问数据源的操作。

3. 如果数据不存在于数据源,那么代理协议的方法会被正常转发(因为自定义的代理协议继承自 `UITableViewDelegate`)

对照 demo(SHA-1:ca9b261),感受一下效果。

更加 MVC,更加简洁

在上面的两次封装中,其实我们是把 `UITableView` 持有原生的代理和数据源,改成了 `KtTableView` 持有自定义的代理和数据源。并且默认实现了很多系统的方法。

到目前为止,看上去一切都已经完成了,然而实际上还是存在一些可以改进的地方:

1. 目前仍然不是 MVC 模式!

2. 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` 该怎么用:

1. 首先你需要创建一个继承自 `KtTableViewController` 的视图控制器,并且调用它的 `initWithStyle` 方法。

`objc KTMainViewController *mainVC = [[KTMainViewController alloc] initWithStyle:UITableViewStylePlain];`

2. 在子类 VC 中实现 `createDataSource` 方法,实现数据源的绑定。

```objc

* (void)createDataSource { self.dataSource = [[KtMainTableViewDataSource alloc] init]; // 这 一步创建了数据源 } ```

3. 在数据源中,需要指定 cell 的类型。

```objc

* (Class)tableView:(UITableView *)tableView cellClassForObject:(KtTableViewBaseItem *)object { return [KtMainTableViewCell class]; } ```

4. 在 Cell 中,需要通过解析数据,来更新 UI 并返回自己的高度。

objc

* (CGFloat)tableView:(UITableView *)tableView rowHeightForObject:(KtTableViewBaseItem *)object { return 60; } // Demo 中沿用了父类的 setObject 方法。 ```

还有什么要优化的

到目前为止,我们实现了对 `UITableView` 以及相关协议、方法的封装,使它更容易使用,避免了很多重复、无意义的代码。

在使用时,我们需要创建一个控制器,一个数据源,一个自定义 Cell,它们正好是基于 MVC 模式的。因此,可以说在封装与解耦方面,我们已经做的相当好了,即使再花大力气,也很难有明显的提高。

但关于 `UITableView` 的讨论远远没有结束,我列出了以下需要解决的问题

1. 在这种设计下,数据的回传不够方便,比如 cell 的给 C 发消息。

2. 下拉刷新与上拉加载如何集成

3. 网络请求的发起,与解析数据如何集成

关于第一个问题,其实是普通的 MVC 模式中 V 和 C 的交互问题,可以在 Cell(或者其他类) 中添加 weak 属性达到直接持有的目的,也可以定义协议。

问题二和三是另一大块话题,网络请求大家都会实现,但如何优雅的集成进框架,保证代码的简单和可拓展,就是一个值得深入思考,研究的问题了。接下来我们就重点讨论网络请求。

为何创建网络层

一个 iOS 的网络层框架该如何设计?这是一个非常宽泛,也超出我能力范围之外的问题。业内已有一些优秀的,成熟的思路和解决方案,由于能力,角色所限,我决定从一个普通开发者而不是架构师的角度来说说,一个普通的、简单的网络层该如何设计。我相信再复杂的架构,也是由简单的设计演化而来的。

对于绝大多数小型应用来说,集成 `AFNetworking` 这样的网络请求框架就足以应付 99% 以上的需求了。但是随着项目的扩大,或者用长远的眼光来考虑,直接在 VC 中调用具体的网络框架(下面以 `AFNetworking` 为例),至少存在以下问题:

1. 一旦日后 `AFNetworking` 停止维护,而且我们需要更换网络框架,这个成本将无法想象。所有的 VC 都要改动代码,而且绝大多数改动都是雷同的。

这样的例子真实存在,比如我们的项目中就依然使用早已停止维护的 `ASIHTTPRequest`,可以预见,这个框架迟早要被替换。

2. 现有的框架可能无法实现我们的需求。以 `ASIHTTPRequest` 为例,它的底层用 `NSOperation` 来表示每一个网络请求。众所周知,一个 `NSOperation` 的取消,并不是简单调用 `cancel` 方法就可以的。在不修改源码的前提下,一旦它被放入队列,其实是无法取消的。

3. 有时候我们的需求仅仅是进行网络请求,还会对这个请求进行各种自定义的拓展。比如我们可能要统计请求的发起和结束时间,从而计算网络请求,数据解析的步骤的耗时。有时候,我们希望设计一个通用组件,并且支持由各个业务部门去自定义具体的规则。比如可能不同的部门,会为 HTTP 请求添加不同的头部。

4. 网络请求还有可能有其他广泛需要添加的需求,比如请求失败时的弹窗,请求时的日志记录等等。

参考当前代码(SHA-1:a55ef42)感受一下没有任何网络层时的设计。

如何设计网络层

其实解决方案非常简单:

所有的计算机问题,都可以通过添加中间层来解决

读者可以自行思考,为什么添加中间层可以解决上述三个问题。

三大模块

对于一个网络框架来说,我认为主要有三个方面值得去设计:

1. 如何请求

2. 如何回调

3. 数据解析

一个完整的网络请求一般由以上三个模块组成,我们逐一分析每个模块实现时的注意事项:

### 发起请求

发起请求时,一般有两种思路,第一种是把所有要配置的参数写到同一个方法中,借用 [与时俱进,HTTP/2下的iOS网络层架构设计](http://www.jianshu.com/p/a9bca62d8dab) 一文中的代码表示:

+ (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 来完成回调。

[数据解析](https://bestswifter.com/how-to-create-an-uitableview/#)

这一部分主要是利用 runtime 将字典转换成 Item,它的实现并不算难,但是如何隐藏好实现细节,使上层业务不用过多关心,则是我们需要考虑的问题。

我们可以定义一个基类的 Item,并且为它定义一个 `parseData` 函数:

// KtBaseItem.m- (void)parseData:(NSDictionary *)data {    // 解析 data 这个字典,为自己的属性赋值    // 具体的实现请见后面的文章}

封装 API 对象

首先,我们封装一个 `KtBaseServerAPI` 对象,这个对象的主要目的有三个:

1. 隔离具体的网络库的实现细节,为上层提供一个稳定的的接口

2. 可以自定义一些属性,比如网络请求的状态,返回的数据等,方便的调用

3. 处理一些公用的逻辑,比如网络耗时统计

具体的实现请参考 Git 提交历史:SHA-1:76487f7

Model 与 Item

BaseModel

Model 主要需要负责发起网络请求,并且处理回调,来看一下基类的 Model 如何定义:

@interface KtBaseModel// 请求回调@property (nonatomic, copy) KtModelBlock completionBlock;//网络请求@property (nonatomic,retain) KtBaseServerAPI *serverApi;//网络请求参数@property (nonatomic,retain) NSDictionary *params;//请求地址 需要在子类init中初始化@property (nonatomic,copy)   NSString *address;//model缓存@property (retain,nonatomic) KtCache *ktCache;

它通过持有 API 对象完成网络请求,可以定制自己的存储逻辑,控制请求方式的选择(长、短链接,JSON或protobuf)。

Model 应该对上层暴露一个非常简单的调用接口,因为假设一个 Model 对应一个 URL,其实每次请求只需要设置好参数,就可以调用合适的方法发起请求了。

由于我们不能预知请求何时结束,所以需要设置请求完成时的回调,这也需要作为 Model 的一个属性。

BaseItem

基类的 Item 主要是负责 property name 到 json path 的映设,以及 json 数据的解析。最核心的字典转模型实现如下:

- (void)parseData:(NSDictionary *)data {    Class cls = [self class];    while (cls != [KtBaseItem class]) {        NSDictionary *propertyList = [[KtClassHelper sharedInstance] propertyList:cls];        for (NSString *key in [propertyList allKeys]) {            NSString *typeString = [propertyList objectForKey:key];            NSString* path = [self.jsonDataMap objectForKey:key];            id value = [data objectAtPath:path];            [self setfieldName:key fieldClassName:typeString value:value];        }        cls = class_getSuperclass(cls);    }}

完整代码参考 Git 提交历史:SHA-1:77c6392

如何使用

在实际使用时,首先要创建子类的 Modle 和 Item。子类的 Model 应该持有 Item 对象,并且在网络请求回调时,将 API 中携带的 JSON 数据赋值给 Item 对象。

这个 JSON 转对象的过程在基类的 Item 中实现,子类的 Item 在创建时,需要指定属性名和 JSON 路径之间的对应关系。

对于上层来说,它需要生成一个 Model 对象,设置好它的路径以及回调,这个回调一般是网络请求返回时 VC 的操作,比如调用 `reloadData` 方法。这时候的 VC 可以确定,网络请求的数据就存在 Model 持有的 Item 对象中。

具体代码参考 Git 提交历史:SHA-1:8981e28

下拉刷新

很多应用的 `UITableview` 都具有下拉刷新和上拉加载的功能,在实现这个功能时,我们主要考虑两点:

1. 隐藏底层的实现细节,对外暴露稳定易用的接口

2. Model 和 Item 如何实现

第一点已经是老生常谈,参考 SHA-1 61ba974 就可以看到如何实现一个简单的封装。

重点在于对于 Model 和 Item 的改造。

ListItem

这个 Item 没有什么别的作用,就是定义了一个属性 `pageNumber`,这是需要与服务端协商的。Model 将会根据这个属性这个属性判断有没有全部加载完。

// In .h@interface KtBaseListItem : KtBaseItem@property (nonatomic, assign) int pageNumber;@end// In .m- (id)initWithData:(NSDictionary *)data {    if (self = [super initWithData:data]) {        self.pageNumber = [[NSString stringWithFormat:@"%@", [data objectForKey:@"page_number"]] intValue];    }    return self;}

对于 Server 来说,如果每次都返回 `page_number` 无疑是非常低效的,因为每次参数都可能不同,计算总数据量是一项非常耗时的工作。因此在实际使用中,客户端可以和 Server 约定,返回的结果中带有 `isHasNext` 字段。通过这个字段,我们一样可以判断是否加载到最后一页。

ListModel

它持有一个 `ListItem` 对象, 对外暴露一组加载方法,并且定义了一个协议 `KtBaseListModelProtocol`,这个协议中的方法是请求结束后将要执行的方法。

@protocol KtBaseListModelProtocol @required- (void)refreshRequestDidSuccess;- (void)loadRequestDidSuccess;- (void)didLoadLastPage;- (void)handleAfterRequestFinish; // 请求结束后的操作,刷新tableview或关闭动画等。@optional- (void)didLoadFirstPage;@end@interface KtBaseListModel : KtBaseModel@property (nonatomic, strong) KtBaseListItem *listItem;@property (nonatomic, weak) id delegate;@property (nonatomic, assign) BOOL isRefresh; // 如果为是,表示刷新,否则为加载。- (void)loadPage:(int)pageNumber;- (void)loadNextPage;- (void)loadPreviousPage;@end

实际上,当 Server 端发生数据的增删时,只传 `nextPage` 这个参数是不能满足要求的。两次获取的页面并非完全没有交集,很有可能他们具有重复元素,所以 Model 还应该肩负起去重的任务。为了简化问题,这里就不完整实现了。

RefreshTableViewController

它实现了 `ListMode` 中定义的协议,提供了一些通用的方法,而具体的业务逻辑则由子类实现。

#pragma -mark KtBaseListModelProtocol- (void)loadRequestDidSuccess {    [self requestDidSuccess];}- (void)refreshRequestDidSuccess {    [self.dataSource clearAllItems];    [self requestDidSuccess];}- (void)handleAfterRequestFinish {    [self.tableView stopRefreshingAnimation];    [self.tableView reloadData];}- (void)didLoadLastPage {    [self.tableView.mj_footer endRefreshingWithNoMoreData];}#pragma -mark KtTableViewDelegate- (void)pullUpToRefreshAction {    [self.listModel loadNextPage];}- (void)pullDownToRefreshAction {    [self.listModel refresh];}

实际使用

在一个 VC 中,它只需要继承 `RefreshTableViewController`,然后实现 `requestDidSuccess` 方法即可。下面展示一下 VC 的完整代码,它超乎寻常的简单:

- (void)viewDidLoad {    [super viewDidLoad];    [self createModel];    // Do any additional setup after loading the view, typically from a nib.}- (void)createModel {    self.listModel = [[KtMainTableModel alloc] initWithAddress:@"/mooclist.php"];    self.listModel.delegate = self;}- (void)createDataSource {    self.dataSource = [[KtMainTableViewDataSource alloc] init]; // 这一步创建了数据源}- (void)didReceiveMemoryWarning {    [super didReceiveMemoryWarning];    // Dispose of any resources that can be recreated.}- (void)requestDidSuccess {    for (KtMainTableBookItem *book in ((KtMainTableModel *)self.listModel).tableViewItem.books) {        KtTableViewBaseItem *item = [[KtTableViewBaseItem alloc] init];        item.itemTitle = book.bookTitle;        [self.dataSource appendItem:item];    }}

其他的判断,比如请求结束时关闭动画,最后一页提示没有更多数据,下拉刷新和上拉加载触发的方法等公共逻辑已经被父类实现了。

到此,关于"怎么写好一个UITableView"的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注网站,小编会继续努力为大家带来更多实用的文章!

0