前言
我们在一般实现一个系统的时候,通常是将定义与实现合为一体,不加分离的,但是有时候最为理想的系统设计规范应是所有的定义与实现分离,尽管这可能对系统中的某些情况有点麻烦。
在一个面向对象的系统中,系统的各种功能是由许许多多的不同对象协作完成的。在这种情况下,各个对象内部是如何实现自己的,对系统设计人员来讲就不那么重要了;而各个对象之间的协作关系则成为系统设计的关键。小到不同类之间的通信,大到各模块之间的交互,在系统设计之初都是要着重考虑的,这也是系统设计的主要工作内容。面向接口编程就是指按照这种思想来编程。
关于接口的理解
接口从更深层次的理解,应是定义(规范,约束)与实现(名实分离的原则)的分离。
接口的本身反映了系统设计人员对系统的抽象理解。
接口应有两类:
第一类是对一个体的抽象,它可对应为一个抽象体(abstract class);
第二类是对一个体某一方面的抽象,即形成一个抽象面(interface);
依赖注入(Dependency Injection)
说到面向接口编程,我觉得还是有必要说下依赖注入这个概念,下面举下网上很多文章都在举的例子来说明下:
我们有一个公交车类(Bus),每天早上6点钟需要发车(work),为其分配对应的司机(Driver),看代码
@implementation Bus
- (void)work {
Driver *driver = [[Driver alloc] initWithName:@"张三"];
//dosomething
}
@end
在上面这段代码中,Bus对象的运作需要用到Driver对象,因而创建了一个Driver对象,我们称Bus对Driver有一个依赖。这样的强耦合关系会因为日后的变化而给我们带来很多麻烦,不久将来张三师傅辞职了,我们需要修改Bus-work()的代码,也就是说在开发过程中非常不便于单元测试(一是不能方便地更换各种Driver对象,二是如果Driver这个职位创建是耗时操作或者高成本操作,我们并不能使用准备好的Driver实现快速重复测试)。 我们继续:
@implementation Bus
@property (strong, nonatomic) Driver *driver;
- (instancetype)initWithDriver:(Driver *)driver {
self = [super init];
if (self) {
self.dirver = driver;
}
return self;
}
- (void)work {
//dosomething
}
@end
以上这段代码我们通过init方法,为Bus对象传入了一个Driver对象,像这种非自己主动初始化依赖,而从外部通过注入点注入依赖的方式,我们就称为依赖注入,而例子中的这种注入的方法称之为构造器注入。明显的,这个场景中Bus和Driver的耦合因此轻了一层。说到解耦,并不是说Bus和Driver之间的依赖关系就不存在了,在Bus的范围内看来,只是将依赖建立从编译期间推迟到了运行期间,毕竟Bus无论如何也是需要Driver提供服务的。对此,这篇文章有一个非常形象的比喻,“依赖就像是系统中的 plugin (插件),主系统并不强依赖于任何一个插件,但一旦插件被加载,主系统就应该可以准确调用适当插件的功能”。
类似这样的注入方式还有
* 属性注入
* 方法注入
* 环境上下文注入
* 子类重写方法注入等
不同的只是注入的手段,思想还是一样的。
实际开发中的实例展示
下面文中用到这个例子和代码来着这篇文章,我觉得文章中的这个例子很有启发性,所以加上了一些自己的理解进去,想看原文的可以点进进入阅读原著。
MVP模式虽然能解决许多MVC模式下存在的问题,但对于比较复杂的需求,
还是会存在逻辑过于复杂,Presenter层也出现难以维护的问题。下面我们就通过一个实际的例子,
来看看面对复杂的业务逻辑,我们应该如何去设计和实现。
很多复杂的需求,在最初都是从一个简单的场景,一步步往上增加功能。
在这个过程中,如果不持续的进行优化和重构,到最后就成了所谓的"只有上帝能看懂的代码"。说了这么多,进入正题,来看这个需求。
V1.0 单文件上传
实现一个简单的单文件上传,文件的索引存储在数据库中,文件存储在App的沙箱里面。这个应该对于有经验的客户端开发者来说是小菜一碟,比较简单也容易实现。我们可以把这个需求大致拆分成以下几个子需求
- 初始化上传View
- 更新上传View
- 点击上传按钮事件
- 数据库中获取上传模型
- 发起HTTP请求上传文件
- 检查网络状态
以上几项如果使用传统的MVC模式,实现起来如下图所示
我们可以看到上述需求基本都直接在UploadViewController
中实现,目前需求还是比较简单的情形下面,还是勉强能够接受,也不需要更多的思考。如果使用MVP的模式进行优化,如下图所示
现在UploadPresenter
负责处理上传逻辑了,而UploadViewController
专注于UI更新和事件传递,整体的结构更加清晰,以后维护代码也会比较方便。
V2.0 多文件上传
需求来了!需要在原来的基础上支持多文件上传,意味着我们多了一个子需求
- 维护上传模型队列
很显然,我们需要在UploadPresenter
中增加一个维护上传队列的功能,最初我也确实是这样实现的,但是由于文件上传需要监听的事件比较多,回调也比较频繁,直接在Presenter中继续写这样的逻辑代码,已经成倍增加了代码的复杂性。
所以经过一番思考,我考虑把文件上传这部分的逻辑单独提取出一层FileUploader
,而UploadPresenter
只负责维护FileUploader
的队列以及检查网络状态。具体的实现如下所示。
我们可以看到,分层之后的结构又更加清晰了,每一层的职责都比较单一,目前看起来一切OK!
V3.0 多来源上传
原来我们的上传文件的来源是存在于App沙箱里的,我们通过数据库查询可以找到这个文件的索引和路径,进而获取到这个文件进行上传。现在万恶的需求又来了,需要支持上传系统相册中获取的图片/视频。
- 支持系统相册和App沙箱中获取文件
到这里可能有些读者已经有点头大了,如果没有仔细思考,很可能从这里就走向了代码质量崩溃的道路。
这个时候,我们就要思考,他们是多来源,但是对于FileUploader来说,它其实不关心模型的来源,它只需要获取到模型的二进制流。于是,我们可以抽象出一个BaseModel
,提供一个stream
只读属性,两种来源分别继承BaseModel
,各自重载stream
只读属性,实现自己的构造文件stream
的方法。对于FileUploader
来说,它只持有BaseModel
即可,这就是继承和多态的一个典型的使用场景。
如果后续还有更多来源的文件,比如网络文件(先下载再上传?),也只需要继续继承BaseModel
,重载stream
即可,对于FileUploader
和它的所有上层来说,一切都是透明的,无需进行修改。经过这样的设计,我们的代码的可维护性和可扩展性又好了。下面是架构图。
V4.0 多方式上传
在HTTP文件上传中,我们可以直接上传文件的二进制流,这种就需要服务端做特定的支持。但更为常用和支持广泛的做法是使用HTTP表单文件传输,即组装HTTP请求的body时采用multipart/form-data
的标准组装,传输数据。于是,我们又多了一个需求:
- 支持表单传输和流传输
思路和刚才的多来源上传差不多,我们把上面的两种来源的模型,即FSBaseM
和ABaseM
抽象为父类,父类含有各自的文件二进制数据的抽象,子类分别实现二进制直接组装流,和按multipart/form-data
格式组装流,实现如下图。
V5.0 支持FTP/Socket上传
刚才我们的文件上传,底层的协议是基于Http,此时我们需要支持FTP/Socket协议的传输,应该怎么办?
- 支持HTTP/FTP/Socket
经过上面的思考,相信你一定知道该怎么做了。这里留个思考,答案:
对比
最后,我们把目前的需求全都整理一下
- 初始化上传View
- 更新上传View
- 点击上传按钮事件
- 数据库中获取上传模型
- 发起HTTP请求上传文件
- 检查网络状态
- 维护上传模型队列
- 支持系统相册和App沙箱中获取文件
- 支持表单传输和流传输
- 支持HTTP/FTP/Socket
我们看看,如果分别采用MVC、MVP_V1、MVP_V2、MVP_V3、MVP_V4、MVP_V5,来实现目前的十个需求,我们的代码大致会分布在哪几层。
孰优孰劣一目了然。如果采用最原始的MVC模式的话,保守估计ViewController
代码量至少3K行以上。
总结
- 运用MVP的设计模式,逻辑和UI操作解耦
- 分层模式,上层拥有下层,下层通过接口与上层通信,达到解耦。
- 利用继承和多态,屏蔽底层实现的细节,达到职责分离和高扩展性
代码优化和重构的技巧
在这次的项目重构中,我也总结了一些重构方面的技巧和贴士,希望能帮助到想开始进行代码重构的同学
事不过三
大段重复的代码出现了三次或以上——提取成一个公共的方法,这一点是最常见也最容易做到的,只要在平时的编码过程中养成这种习惯,对于出现过三次以上重复代码段,提取成一个公共方法。
一个类的职责有三种或以上——通过合理分层的方式,减少职责,这一点在上面的例子中已经阐述地比较清楚了,通过职责的分层,上层持有下层,下层通过接口与上层通讯。其实这也是MVP模式的本质。
同类的if/else出现了三次或以上——考虑使用抽象类和多态代替if/else,如果相同的if/else判断在你的代码中出现了很多次的话,则应该考虑设计一个抽象类去替代这些判断。这里可能有点难以理解,举个例子就好懂很多,比如,现在我们有一个水果类,有三种水果,水果有颜色、价钱和品种
class Fruit {
var name:String = ""
func getColor() -> UIColor? {
if name == "apple" {
return UIColor.red
} else if name == "banana" {
return UIColor.yellow
} else if name == "orange" {
return UIColor.orange
}
return nil
}
func getPrice() -> Float? {
if name == "apple" {
return 10
} else if name == "banana" {
return 20
} else if name == "orange" {
return 30
}
return nil
}
func getType() -> String? {
if name == "apple" {
return "红富士"
} else if name == "banana" {
return "芭蕉"
} else if name == "orange" {
return "皇帝"
}
return nil
}
}
这里的对名称name的相同的if/else判断出现了三次,如果此时我们多了一种水果梨,我们得修改上述所有的if/else判断,这样就会非常难维护。
这种场景我们可以考虑抽象出一个Fruit的抽象类/接口/协议,通过实现水果类/接口/协议的方式,此时如果多了一种水果,让这种水果继续实现Fruit协议就行,这样我们就通过新增的方式替代修改,提高了代码的可维护性。
protocol Fruit {
func getPrice() -> Float?
func getType() -> String?
func getColor() -> UIColor?
var name:String { get }
}
class Apple:Fruit {
var name:String = "apple"
func getColor() -> UIColor? {
return UIColor.red
}
func getPrice() -> Float? {
return 10
}
func getType() -> String? {
return "红富士"
}
}
class Banana:Fruit {
var name:String = "banana"
func getColor() -> UIColor? {
return UIColor.yellow
}
func getPrice() -> Float? {
return 20
}
func getType() -> String? {
return "芭蕉"
}
}
class Orange:Fruit {
var name:String = "orange"
func getColor() -> UIColor? {
return UIColor.orange
}
func getPrice() -> Float? {
return 30
}
func getType() -> String? {
return "皇帝柑"
}
}
这个例子跟我之前提的一篇文章的例子是极其相似的:使用state pattern替代if else,都是面向接口编程的典型实例。而且这个例子还不如我之前提到的那篇文章中的实例更能说明面向接口编程的必要性,因为那坨逻辑到最后会发展为不得不解决的痛了。
合理分层
纵向分层——层级之间有关联
上层持有下层,下层通过接口与上层通信。这里为什么不让下层也持有上层呢?主要还是为了能够解耦,下层设计的目的是为上层服务的,它不应该依赖上层。这种设计模式在计算机科学中是很常见的,比如计算机网络中的网络分层设计。横向分层——层级之间无关联
适用于功能相对独立的模块,简单划分即可。我们的iOS项目的首页就是由好几个部分组成,这个部分之间无太多的关联,我们简单划分成几个模块就行。如果出现了少数需要通讯的场景,使用Notification即可。
避免过度设计
越简单的越是有效的,复杂的架构设计往往在客户端高速迭代开发中意义不大(相比服务端)
没有银弹!软件开发是工程化的,没有完美的架构模式,很多时候需要具体问题具体分析,灵活运用设计模式,得到局部的最优解。比如前面提到的MVP模式,如果生搬硬套,同样无法解决Presenter层复杂的问题。
如何判断过度设计?胶水代码过多,大量文件的行数小于100,想了一天,没写出代码,也没写出架构方案
重构的时机和对象
- 时机,单文件代码行数开始超过500行的时候
Code Review是重构的好帮手 - 对象,需求经常变化或增加的功能,一定要注意设计,避免走向质量不可控
稳定且不变的功能,不重构
总结
上文中我引用的是一篇别人文章中的内容,我从中看到的是面向接口的思想,并没有多突出 MVP的思想。面向接口的编程思想对程序的扩展性和维护性是极友好的,是大型系统中复杂逻辑的一个极其好的解决方案,本文只是一个例子,一个引子,引导我们从这个思想层面来思考程序的设计,希望大家有所获。
参考文章:
- iOS MVP模式重构实践