重庆分公司,新征程启航
为企业提供网站建设、域名注册、服务器等服务
如果你觉得 UITableViewDelegate 和 UITableViewDataSource 这两个协议中有大量方法每次都是复制粘贴,实现起来大同小异;如果你觉得发起网络请求并解析数据需要一大段代码,加上刷新和加载后简直复杂度爆表,如果你想知道为什么下面的代码可以满足上述所有要求:
10年积累的成都网站设计、成都网站制作、外贸网站建设经验,可以快速应对客户对网站的新想法和需求。提供各种问题对应的解决方案。让选择我们的客户得到更好、更有力的网络服务。我虽然不认识你,你也不认识我。但先网站设计后付款的网站建设流程,更有姑苏免费网站建设让你可以放心的选择与我们合作。
解耦后的VC
在讨论解耦之前,我们要弄明白 MVC 的核心:控制器(以下简称 C)负责模型(以下简称 M)和视图(以下简称 V)的交互。
这里所说的 M,通常不是一个单独的类,很多情况下它是由多个类构成的一个层。最上层的通常是以 Model 结尾的类,它直接被 C 持有。Model 类还可以持有两个对象:
在 C 中,我们创建 UITableView 对象,然后将它的数据源和代理设置为自己。也就是自己管理着 UI 逻辑和数据存取的逻辑。在这种架构下,主要存在这些问题:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;
简单来说,只要实现了这个两个方法,一个简单的 UITableView 对象就算是完成了。
除此以外,它还负责管理 section 的数量,标题,某一个 cell 的编辑和移动等。
代理
代理主要涉及以下几个方面的内容:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;
提醒:绝大多数代理方法都有一个 indexPath 参数
最简单的思路是单独把数据源拿出来作为一个对象。
这种写法有一定的解耦作用,同时可以有效减少 C 中的代码量。然而总代码量会上升。我们的目标是减少不必要的代码。
比如获取每一个 section 的行数,它的实现逻辑总是高度类似。然而由于数据源的具体实现方式不统一,所以每个数据源都要重新实现一遍。
首先我们来思考一个问题,数据源作为 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
其中的 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,这些都是数据源不应该做的事。
这两个问题的解决方案如下:
经过这一番折腾,好处是相当明显的:
我们以之前所说的,代理协议中常用的两个方法为例,看看怎么进行优化与解耦。
首先是计算高度,这个逻辑并不一定在 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 完成),我们获得了以下特性:
在上面的两次封装中,其实我们是把 UITableView 持有原生的代理和数据源,改成了 KtTableView 持有自定义的代理和数据源。并且默认实现了很多系统的方法。
到目前为止,看上去一切都已经完成了,然而实际上还是存在一些可以改进的地方:
@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 该怎么用:
* (void)createDataSource { self.dataSource = [[KtMainTableViewDataSource alloc] init]; // 这 一步创建了数据源 } ```
1.在数据源中,需要指定 cell 的类型。
* (Class)tableView:(UITableView *)tableView cellClassForObject:(KtTableViewBaseItem *)object { return [KtMainTableViewCell class]; }
1.在 Cell 中,需要通过解析数据,来更新 UI 并返回自己的高度。
* (CGFloat)tableView:(UITableView *)tableView rowHeightForObject:(KtTableViewBaseItem *)object { return 60; } // Demo 中沿用了父类的 setObject 方法。
还有什么要优化的
到目前为止,我们实现了对 UITableView 以及相关协议、方法的封装,使它更容易使用,避免了很多重复、无意义的代码。
在使用时,我们需要创建一个控制器,一个数据源,一个自定义 Cell,它们正好是基于 MVC 模式的。因此,可以说在封装与解耦方面,我们已经做的相当好了,即使再花大力气,也很难有明显的提高。
但关于 UITableView 的讨论远远没有结束,我列出了以下需要解决的问题
一个 iOS 的网络层框架该如何设计?这是一个非常宽泛,也超出我能力范围之外的问题。业内已有一些优秀的,成熟的思路和解决方案,由于能力,角色所限,我决定从一个普通开发者而不是架构师的角度来说说,一个普通的、简单的网络层该如何设计。我相信再复杂的架构,也是由简单的设计演化而来的。
对于绝大多数小型应用来说,集成 AFNetworking 这样的网络请求框架就足以应付 99% 以上的需求了。但是随着项目的扩大,或者用长远的眼光来考虑,直接在 VC 中调用具体的网络框架(下面以 AFNetworking 为例),至少存在以下问题:
其实解决方案非常简单:
所有的计算机问题,都可以通过添加中间层来解决
读者可以自行思考,为什么添加中间层可以解决上述三个问题。
对于一个网络框架来说,我认为主要有三个方面值得去设计:
一个完整的网络请求一般由以上三个模块组成,我们逐一分析每个模块实现时的注意事项:
发起请求时,一般有两种思路,第一种是把所有要配置的参数写到同一个方法中,借用与时俱进,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 这个字典,为自己的属性赋值
// 具体的实现请见后面的文章
}
首先,我们封装一个 KtBaseServerAPI 对象,这个对象的主要目的有三个:
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 的一个属性。
基类的 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 的改造。
这个 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 字段。通过这个字段,我们一样可以判断是否加载到最后一页。
它持有一个 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 还应该肩负起去重的任务。为了简化问题,这里就不完整实现了。
它实现了 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];
}
}
其他的判断,比如请求结束时关闭动画,最后一页提示没有更多数据,下拉刷新和上拉加载触发的方法等公共逻辑已经被父类实现了。
具体代码见 Git 提交历史:SHA-1:0555db2
写在结尾
网络请求的设计架构到此就全部结束了,它还有很多值的拓展的地方。还是那句老话,没有通用的架构,只有最适合业务的架构。
我的 Demo 为了方便演示和阅读,通常都是先实现底层的类和方法,然后再由上层调用。但实际上这种做法在实际开发中是不现实的。我们总是在发现大量冗余,无意义的代码后,才开始设计架构。
因此在我看来,真正的架构过程是当业务发生变更(通常是变复杂了)时,我们开始应该思考当前哪些操作是可以省略的(由父类或代理实现),最上层应该以何种方式调用底层的服务。一旦设计好了最上层的调用方式,就可以逐步向底层实现了。
由于本人水平也有限,本文的架构并不优秀,希望在深入理解设计模式,积累更多经验后,再与大家分享收获。