从设计模式角度编写易读性代码之第一章单一职责(iOS篇)
背景
相信大多数人都不喜欢阅读其它人代码,因为要按照编写这段代码的程序员思路去理解。而在修改bug时, 经常不知道要去哪里修改。真的有办法马上就找到我想要修改的代码修复bug吗?
前言
本文将从设计模式的单一职责(SRP)角度,简述如何做到团队内快速读懂别人的代码。重点阐述MVC职责划分,这是笔者最近几年iOS编程经验的总结,适合所有层次的码农阅读。
文章结构:
- 整体思路
- 单一职责概念
- 为什么单一职责能提高可读性?
- 如何做到单一职责?从函数,类,模块,MVC,四个角度来阐述,重点阐述MVC
- 编程实战:单一职责在MVC中�的应用
- 结尾,奉上一个比较复杂的帖子评论功能实现代码(点击去github下载Demo),以及创建View类和Controller类时已经写好职责注释的Xcode模板(模板还在完善中)
整体思路
将所有的代码,根据某一维度进行职责分类,越细分越好,他们之间不能重叠,而且要覆盖到所有的代码,也即麦肯锡思维的MECE分析法,然后在XCode创建文件的模板中,事先写入这些职责,如下:
///// controller.m 职责划分
#pragma mark - 动态属性及重写属性
#pragma mark - M和V生命周期管理
#pragma mark - 界面跳转
#pragma mark - 视图(View)事件响应
#pragma mark - 数据模型(Model)和V的交互
在我们实现具体功能时,就将相应的代码填入相应的位置,这样,别人就容易查找了。
概念
SRP是面向对象(OO)中最重要的原则之一,那什么叫做单一职责(SRP)呢?设计模式是这样定义单一职责:“不应该有超过一个原因来改变类或者模块”。从字面理解:仅具有一种单一功能,或者说只做一件事情。
一个具体的例子就是,想象有一个用于编辑和打印报表的模块。这样的一个模块存在两个改变的原因。第一,报表的内容可以改变(编辑)。第二,报表的格式可以改变(打印)。这两方面会的改变因为完全不同的起因而发生:一个是本质的修改,一个是表面的修改。单一功能原则认为这两方面的问题事实上是两个分离的功能,因此他们应该分离在不同的类或者模块里。把有不同的改变原因的事物耦合在一起的设计是糟糕的。再举个公司组织架构的例子,比如,如果iOS组除了负责iOS原生开发外,还负责Reactive Native的开发,当一个新�测试人员来时,Reactive Native的实现到底由谁负责,就需要询问别人,这时候存在的问题是iOS部门身兼数职,应该将Reactive Native单独分为一个小组,才能符合单一职责,这样,可以减少认知的成本。
保持一个类专注于单一功能点上的一个重要的原因是,它会使得类更加的健壮。继续上面的例子,如果有一个对于报表编辑流程的修改,那么将存在极大的危险性,打印功能的代码会因此不工作。
此原则的核心就是解耦和增强内聚性。将职责划分清晰后,只有一个职责,耦合度自然下降;另一方面,和这个职责相关的代码都聚集到一个地方,自然也就提高了内聚性。简单地做个类比,家里有个储物间,原来该储物间的职责就是堆放杂物,如果没有对其进行分类存放,全部堆在一起,则自然很难找到你需要的东西。当你对其进行分类,分成玩具,修理工具,生活日用品等等,这些杂物就进行了解耦,而相关的东西也内聚到一起,而你要查找时,就更快了,只要知道类别,到相应的类别查找就可以了。
为什么单一职责能提高可读性?
回顾下可读性概念:指的是读者对于源代码的功能意图、流程控制和操作运行是否容易把握。而单一职责从功能意图层面,让读者更容易理解代码,而我们最不想读别人的代码是因为要跟着别人的思路走,如果把功能意图统一起来,也就统一了思路;单一职责可以降低类的复杂度,一个类只负责一个职责,其逻辑肯定要比负责多项职责简单的多。复杂度降低了,可读性自然就提高了。
如何做到单一职责?我们将从函数,类,模块,三个角度来阐述
函数
在《代码整洁之道》一书,提到了函数应该只做一件事,并做好这件事,本质上来讲就是单一职责。比如一个检查登陆账户的函数:
- (BOOL)checkAccountAccess{
BOOL access = false;
if (userName.isAccess
&& password.isAccess) {
access = true;
[self login];
}
return access;
}
函数名称为checkAccount,意为检查账户,而里面当账户可用是却请求登录。如果开发人员未仔细检查代码,只看函数名,很容易出现问题。检查账户的函数就应该只检查账户,而不应该在做其他事情。
类
单一职责是�认为,类应该只有一条加以修改的理由。
假设有一个矩形类,它实现了计算面积和画矩形的功能。现在有程序员A和程序员B,程序员A只需要计算矩形的面积,而程序员B只需要画矩形的功能,而这个矩形类职责就不单一了,计算面积的需求变化或者画图的需求变化都要对这个矩形进行修改,这样任何一方的修改都有可能影响到另一方。
模块
模块的单一职责和类一样,只是笔者经常看到的经常时业务模块里经常会有领域模块或者通用模块的代码,导致整个模块职责不单一。
MVC职责划分
这是本章的重点!这是本章的重点!这是本章的重点!重要的事情说3篇。😄😄😄
View的职责
引用苹果官方文档:
View Objects Present Information to the User
A view object knows how to display, and might allow users to edit, the data from the application’s model. The view should not be responsible for storing the data it is displaying. (This does not mean the view never actually stores data it’s displaying, of course. A view can cache data or do similar tricks for performance reasons).
..................................
Because model objects should not be tied to specific view objects, they need a generic way of indicating that they have changed.
大致的意思:view对象负责用户信息展示。一个view对象知道如何显示应用程序模型数据,也可以让用户进行编辑。view不负责数据存储。(当然,也不意味着它从来都不存储数据。由于性能原因或者其它类似的原因,它可以缓存数据)。
..................................
对象模型不能绑定到view对象,它需要有一个通用的方法来表示对象模型发生变化。
相应地,设计View的职责如下:
view的职责.jpg在另一章节里,将细化View的职责分工,这里不展开描述。
�Controller的职责
苹果官方文档是这样描述的:
Controller Objects Tie the Model to the View
A controller object acts as the intermediary between the application's view objects and its model objects. Controllers are often in charge of making sure the views have access to the model objects they need to display and act as the conduit through which views learn about changes to the model. Controller objects can also perform set-up and coordinating tasks for an application and manage the life cycles of other objects.
大致意思:Controller负责连接模型到view。Controller是view和model对象之间的媒介。Controller通常负责确保view能够访问model对象,以便获取要显示的数据,并�架起Model数据变化的桥梁。Controller还负责创建和协调其它对象生命周期的管理。
相应地,设计Controller的职责如下:
Controller职责.jpgModel职责
比较简单,在此不做说明。
MVC划分好职责后,我们在编写代码时,只要将相应的代码写入相应的类里就好了。
编程实战:单一职责在MVC中�的应用
需要实现下图这样一个功能:
功能视觉效果图
它需要实现加载图片,图片描述,�作者,点赞用户,用户评论信息,还需要响应点赞,评论,“...”及评论输入功能(功能需求点不做详细描述)。
首先,尽可能地�穷举所有职责,再对MVC应用单一职责,如下图:
MVC实例.jpg这里的划分还是不完美,本应该像麦肯锡思维的MECE分析法说的那样,尽可能相互独立,而且相互穷尽,后期笔者将慢慢完善,也希望有更多的读者加入进来一起讨论。
C和M相对简单一些,原来C比较复杂,但明确职责后,C相对简单了。最复杂的就是界面,因此根据可能的重用性将界面再进行分区,如下图:
界面分区
再从代码上对其分层次,如下图:
postview层次图.jpg划分职责后,可以进行设计,因为我们重点讲单一职责代码分类,所以此处省略一千字。我们直接进行代码编写阶段。
@interface PostView : UIView<ViewEventsHandler>
@property(readonly, weak)PostContentView* contentView;
@property(readonly, weak)TXEmojiKeyboardView* commentInputView;
@property(weak)id<UITableViewDataSource,UICollectionViewDataSource,TXImageViewDataSource> dataSource;
@property(weak)id<UITableViewDelegate,UICollectionViewDelegate,TXImageViewDelegate> delegate;
/**
加载所有数据
@param post post 帖子数据对象
*/
- (void)reloadAllWithPost:(id<Post>) post;
/**
只更新评论
*/
- (void)reloadComments;
/**
更新帖子相关界面
@param post post 帖子数据对象
*/
- (void)reloadPostCotentWithPost:(id<Post>) post;
@end
PostView主要职责
组合其它的View并隔离子View和Controller的耦合。在进行数据展现时,封装了contentView,commentInputView,dataSource,delegate,reloadAllWithPost:,reloadComments,reloadPostCotentWithPost:
属性和函数提供给Controller调用,保持PostView的高内聚性,方便PostView整体复用,如果将这些写到Controller里,要将PostView整体复用就需要将这些函数移动到另一个Controller里。
注意点:
刷新时需要将数据对象Post传入PostView进行刷新,因此,尽量不要将数据对象再往PostView的下一层内容区(PostCotentView)或者其它View进行传递,保持子View有一个更好的独立性,提高子View的可重用性。
@protocol ViewEventsParam<NSObject>
@property(readonly, weak) id sender;
@property(readonly, strong) id data;
@property(readonly, assign) UIControlEvents events;
@property(readonly, assign) NSUInteger tag;
@property(readonly, strong) NSIndexPath* indexPath; /* UITableView和UICollectionView要使用,或者其它 */
@end
typedef void (^ViewEventsBlock)(id<ViewEventsParam> param);
/* view的事件回调协议, 方便调用统一的事件处理出口,减少代码量,
同时所有view都这样定义,方便其它同事查找,提高可读性 */
@protocol ViewEventsHandler <NSObject>
@property(nonatomic, copy)ViewEventsBlock eventsBlock;
@end
这是PostView遵守的事件回调协议,简单地说,就是一个Blocks,当有事件需要通知Controller时,通过此Blocks回调到Controller,达到事件传递的目的。编写易读性代码下一章,我们将重点讲设计模式的接口隔离原则(也即协议)如何提高代码可读性,这里不展开阐述。
这里简单说明了View需要和Controller交互的地方,下面我们来看下�Controller的主要职责,即如何架起View和Model之间的桥梁。
PostViewController职责
-
生命周期管理,主要是V和M
#pragma mark - V生命周期管理
- (void)loadView{
PostView* view = [[PostView alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
view.delegate = self;
view.dataSource = self;
view.eventHandler = [self viewEventHandler];
self.view = self.postView = view;
}
创建PostView,并替换掉Controller的view,将所有view相关的操作封装到PostView中,使Controller更轻量。
#pragma mark - M生命周期管理
- (void)loadData{
[[[RACSignal merge:@[[PostBLLRequest()
doNext:^(id<Post> post) {
self.post = post;
[self.postView reloadPostCotentWithPost:post];
}],
[CommentBLLLoad()
doNext:^(NSArray<id<Comment>>* comments) {
self.comments = comments;
[self.postView reloadComments];
}]]]
showAllMessage]
subscribeCompleted:^{
[self.postView reloadAllWithPost:self.post];
}];
}
调用PostBLLRequest()
请求,从服务器返回数据,解析,并创建Post
,赋值给self.post
。
-
View事件响应
#pragma mark - View事件响应
- (NSDictionary<NSNumber*, ViewEventsBlock>*) viewEventHandlerTable{
return @{
@(PostViewEventHandlerTagActionViewLike):[self likeViewEventHandler],
@(PostViewEventHandlerTagEmojiInputView):[self inputEmotionViewEventHandler],
@(PostViewEventHandlerTagUserInfoProfile):[self showUserProfileEventHandler],
@(PostViewEventHandlerTagCommentTableViewCellMore):[self commentMoreEventHandler],
@(PostViewEventHandlerTagImagesView):^(id<ViewEventsParam> param){
[self showImagesBrowserViewControllerWithStartIndex:param.indexPath.item];
},
@(PostViewTableViewCellLikeUsers):^(id<ViewEventsParam> param){
[self showUserProfileViewControllerWithUser:self.post.likeUsers[param.indexPath.item]];
},
};
}
- (ViewEventsBlock)viewEventHandler{
return ^(id<ViewEventsParam> param){
ViewEventsBlock handler = [self viewEventHandlerTable][@([param.sender tag])];
if (handler) handler(param);
else PGDebugWarn(@"not matched handler for %@", @([param.sender tag]));
};
}
不熟悉Blocks,可能对这段代码无法理解,这里展开解释下。viewEventHandlerTable
返回一个字典,字典的key是PostViewEventHandlerTag
,对应的value是Blocks。根据key去查询要执行的Blocks,而这个Blocks就是具体事件的处理。例如,当点击图片区的图片时,就跳转到大图浏览模式:
@(PostViewEventHandlerTagImagesView):^(id<ViewEventsParam> param){
[self showImagesBrowserViewControllerWithStartIndex:param.indexPath.item];
},
再说下另一个例子:
当点击�帖子作者区的头像时,就会将PostViewEventHandlerTagUserInfoProfile
值存储到ViewEventsParam
对象的tag中,如下:
///// PostViewController.m
- (void)loadView{
......
view.eventsBlock = [self viewEventHandler];
......
}
//// PostView.m
- (void)setEventsBlock:(ViewEventsBlock)aEventsBlock{
eventsBlock = aEventsBlock;
self.contentView.detailView.authorInfoView.eventsBlock = aEventsBlock;
}
//// PostAuthorInfoView.m
- (void)creatSubviews{
...........
WeakSelf
[btnAvatar addTouchUpInsideActionWithBlock:^(id sender) {
StrongSelf
if (self.eventsBlock){
self.eventsBlock([ViewEventsParamPOD paramWithSender:sender
tag:PostViewEventHandlerTagUserInfoProfile]);
}
}];
}
分别讲下三个源文件里的三段代码。
在Controller里调用loadView
时,对PostView的eventsBlock
�进行赋值,以便在PostView事件发生时,可以回调eventsBlock
。
PostView重写了赋值方法,目的是让authorInfoView.eventsBlock
也同样回调Controller的[self viewEventHandler]
方法返回的Blocks。
PostAuthorInfoView创建子view时,为头像点击添加了�处理。
当用户点击作者头像时,�创建ViewEventsParam
对象并回调eventsBlock处理点击事件。
-
M和V的通讯
这个是重点中的重点,因为笔者看到太多的代码问题就是在这里,View会绑定数据对象,直接在View里面去更新数据。这就增加了View和Model的耦合度,会导致View重用性降低。我们来看下Model更新View的正确姿势。
#pragma mark - M和V的通讯
///// PostViewController.m
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{
void (^asserCell)(Class) = ^(Class cellClass){
NSAssert([cell isKindOfClass:[cellClass class]], [@"必须是" stringByAppendingString:[cellClass className]]);
};
switch (indexPath.section) {
case PostViewTableViewSectionIndexActionTag: {
asserCell([PostActionTableViewCell class]);
((PostActionTableViewCell*)cell).eventsBlock = self.viewEventHandler;
break;
}
case PostViewTableViewSectionIndexLikeUsers: {
asserCell([PostUserLikesTableViewCell class]);
((PostUserLikesTableViewCell*)cell).likesView.cvlikeAvatars.delegate = self;
((PostUserLikesTableViewCell*)cell).likesView.cvlikeAvatars.dataSource = self;
[((PostUserLikesTableViewCell*)cell).likesView.cvlikeAvatars reloadData];
break;
}
case PostViewTableViewSectionIndexComments:
{
asserCell([PostCommentTableViewCell class]);
PostCommentTableViewCell* commentCell = (PostCommentTableViewCell*)cell;
commentCell.eventsBlock = self.viewEventHandler;
[commentCell.imageView sd_setHighlightedImageWithURL:[[self.comments[indexPath.row] author] avatarURL]];
commentCell.textLabel.text = [self.comments[indexPath.row] content];
break;
}
default:
break;
}
}
这段代码是TableView要显示内容操作区,用户点赞区和评论区。具体的可以到github上下载代码,查看实现。
Model
@protocol Post <NSObject>
@property(readonly, strong)NSString* id;
@property(readonly, strong)NSString* desc; //// 作品描述
@property(readonly, strong)id<User> author; //// 作者
@property(readonly, strong)NSArray<NSURL*>* imageURLs; //// 图片地址
@property(readonly, strong)NSArray<NSString*>* tags; //// 描述内容的标签
@property(readonly, strong)NSArray<id<User>>* likeUsers; //// 点赞用户
@end
@protocol Comment <NSObject>
@property(nonatomic, readonly, strong) NSString* content;
@property(nonatomic, readonly, strong) id<User> author;
@end
///// PostViewController.m
@interface PostViewController ()<ViewEventHandlerViewController, TXImageViewDelegate, TXImageViewDataSource, UITableViewDelegate, UITableViewDataSource, UICollectionViewDelegate, UICollectionViewDataSource>
/** 替换controller.view */
@property (nonatomic, weak) PostView *postView;
/** 包含评论信息 */
@property (nonatomic, strong) NSArray<id<Comment>>* comments;
/** 包含帖子信息 */
@property (nonatomic, strong) id<Post> post;
@end
Controller只是持有了很简单的数据结构,而所有和数据模型相关的,都在相应的模型里实现了。 这样,我们能保持Controller简单,而数据模型层,可重用性更高。
RACSignal* CommentBLLLoad(), RACSignal* PostBLLRequest(),RACSignal* PostBLLLike(NSString* id),RACSignal* PostBLLObserveChange()
都是以简单函数方式实现,只有局部变量,而PostPOD,CommentPOD
都是简单对象,这里实现�尽量不用类的方式,因为面向对象有点复杂,后面,笔者将专门写一篇关于《为什么我们一定要会函数式编程》来阐述这个问题。
单一职责分类后,在查找代码时,由于都规定好,哪些代码要放哪里,因此在查找按职责直接查找就好了。