企业级iOS应用如何面向接口编程

·  阅读 777

引言

对于面向接口编程,在很多文章中都有介绍,一般都是以一个非常简单的事物作为介绍,如交通工具和动物,来论述使用接口的好处和怎么使用接口,这些例子不仅过于简单,而且在实际的项目开发中几乎毫无用处,除了介绍理论,普及概念之外,对于实际项目开发的借鉴意义不大,基于此,本文尝试从一个企业级项目中的开发场景来探讨面向接口编程。

    接口的本质是定义和实现的分离。很多编程语言中都有描述接口的关键字,如Swift/OC中的protocol, Java、Kotlin、C#中的interface。大多数编程语言的对于接口的关键字也都是interface,但是OC中的interface关键字却是修饰类的,在头文件中定义使用interface,在实现文件中使用implementation,定义和实现也是分离的,而且方法调用其实是运行时发消息,可轻易的改变原有的实现,是一种更深的分离。在具体的编程语言中,接口并不一定某个固定的关键字声明的类型,只要符合接口的思想即可。

    在面向对象系统中,系统的功能是由许多不同的对象协作完成的,小到不同的类,大到不同的应用程序,它们之间的交互尤为重要,面向接口编程就是为了更好的实现这些交互,所以面向接口编程并不是与面向对象编程平行或先进的概念,而是践行了面向对象编程的思想,是面向对象编程的一部分。

    若干对象通过某种相关性组合成一个集合,我们通常称之为模块。关于模块化,有几个类似的术语,如组件化,插件化,他们是很相近的概念,共有的基础都是分治,模块化更关注复用,组件化更关注解耦,插件化则强调独立运行,本文中的模块化是一个宽泛的概念,只要符合其分治思想的都可以归纳为模块化。

    对于一个稍具规模的企业级应用,业务一般比较复杂,模块化开发是一个必然的选择。以下内容将以iOS开发为背景,从模块之间的交互,到模块内对象的交互这2个方面来来应用面接口编程的方法。

模块之间的交互

在概要设计阶段,我们一般会对项目结构进行设计,根据不同的因素,在纵向和横向上进行模块的划分,纵向上根据业务属性的强弱进行划分,业务属性越强层次越高,在同一个纵向层上又可以根据功能进行模块的横向划分。

纵向模块之间的交互

根据模块业务属性的强弱大致可以分为3层,基础层(无业务属性),通用(业务)层(低业务属性),业务层(完全业务属性),如下图。

纵向模块层次划分图

业务层对通用层的引用,通用层对基础层的引用,尽量不要引用具体实现类,而是引用一个接口或者抽象类。

    以一个类似钉钉的办公应用为例,业务层有消息,文档,通讯录3个主要模块,各个模块都会用到用户信息,有3个应用场景:

  • 消息模块使用用户的基本信息组合成群组;
  • 文档和工作台模块使用用户的权限进行不同的操作;
  • 通讯录使用和用户有关联的各种组织信息来归属各自的组织架构。

以用户信息模块为通用层模块,消息,文档,通讯录等为业务层模块,业务层使用用户信息模块的信息时引用接口。

protocol UserItemProtocol {  //用户基本信息
    var userId:String{get}
    var name:String{get}
    var avatarUrl:String?{get}
}

enum FilePermission { //文件权限枚举
    case read, create, write, share
}

protocol UserPermissionProtocol { //用户权限
    var userId:String{get}
    func  hasPermission(to permission:[FilePermission]) -> Bool
}
protocol  organizationLinkedListProtocol { //组织接口链表
    var  orgId:String{get}
    var name:String {get}
    var parent:  organizationLinkedProtocol?{get}
}
protocol UserOrganizationProtocol { //用户所属组织
    var userId:String{get}
    var title:String {get}
    var organization:organizationLinkedListProtocol{get}
}
复制代码

以上定义了4个Protocol,分别对应业务模块的3个应用场景:

  • 消息模块中,群包含的成员列表则可定义为如[UserItemProtocol]的数组;
  • 文档模块中,查询用户是否有读取文档的权限,可以访问UserPermissionProtocol.hasPermission(to: .read);
  • 通讯录模块中,查询某用户在当前组织中的组织架构信息,可访问UserOragnizationProtocol.organization,按链表逐个节点读取即可。

具体实现以上Protocol可能是同一个对象,如下,但是我们不可提供提供一个庞大的Protocol,满足使用即可,一个接口完成一件事情。

struct UserInfo : UserItemProtocol, UserPermissionProtocol, UserOrganizationProtocol {
//所有的用户信息属性。
}
复制代码

纵向模块之间的交互的基本原则是:高层模块不应该直接依赖低层模块,他们都应该依赖抽象,高层模块只知道接口提供的能力,具体实现则在低层模块中,而且接口应尽量简洁,只要满足高层模块需求即可,不可添加不必要的方法或属性。

横向模块之间的交互

在纵向划分中,高层与低层模块之间的交互,可以依赖其抽象,但是在同一层次中横向划分的模块之间的交互则通常需要一个中间人来传递信息,这个中间人就是模块之间的通信系统,我们通常使用路由系统来实现。

    使用路由系统进行模块间的通信,模块之间没有耦合,应用简单,还可以兼顾客户端多端路由之间的同步,是页面跳转的常用选择。页面跳转也是一种特殊的通信,一般只需要输入参数,对返回数据的要求不强,这造成一般路由系统对于数据的交互能力较弱,我们对路由系统进行适当的改造,使其支持普通通信和页面跳转通信,强化交互能力。

    该路由系统仿照HTTP请求的方式实现模块之间的通信,通过注册路由,发送路由请求和参数,同步或者异步的获取到需要的通信数据,“页面跳转”中的”页面”也是一种通信数据,如UIViewController。

枚举该路由系统的功能

  • 可注册路由地址;
  • 可以处理某特定路由调用;
  • 可发起路由请求;
  • 同步/异步返回通信数据;

根据以上功能定义相关接口

protocol RouterHandlerProtocol {  //路由处理者协议
    func handleRouter(url:string, params:[String:Any]?, completion:((RouterResponseProtocol?) -> Void)?)
}
protocol RouterResponseProtocol { //路由响应协议
    var data:Any?{get set}
    var error:Error?{get set}
}
复制代码

实现路由处理中心

public class RouterCenter {
    func register(url:String, handler:RouterHandlerProtocol) { //注册路由处理者
    //记录路由处理者
}
func starRequest(_ request: RouterRequestProtocol) { //处理路由请求
//查询路由处理者 => RouterHandlerProtocol
//查询到的RouterHandlerProtocol.handleRouter(url:, params:, completion:);
}
}
复制代码

以上是一个路由处理器的内部处理,作为中间人的一方,接下来以一个实际的例子来实现发起请求方和接收请求方。

    如在钉钉中,通讯录页面需要显示最近联系人,通讯录页面属于通讯录模块,最近联系人属于消息模块,2个都属于业务模块,通过路由系统交互。

  1. 消息模块注册并实现路由处理协议RouterHandlerProtocol,实现该协议的类为MessageService,定义路由值:native://message/last_contacts

注册路由:

RouterCenter.register(url:"native://message/last_contacts", handler:MessageService.shared);
复制代码

实现路由处理者,返回的数据要实现RouterResponseProtocol协议:

class MessageService {
    var lastContactItem:[UserItemProtocol]?
}
extension MessageService : RouterHandlerProtocol {
    func handleRouter(url:string, params:[String:Any]?, completion:((RouterResponseProtocol?) -> Void)) {
        if url == "native://message/last_contacts" {
            var response = RouterResponse()
            response.data = self.lastContactItem //返回数据
            completion?(response)
       }
    }
}
复制代码
  1. 通讯录模块为发送路由请求方,需要实现RouterRequestProtocol协议(当然在可以在路由系统中提供一个默认的路由请求类,如RouterRequest),然后发送这个请求。
var request = RouterRequest(url:"native://message/last_contacts")
request.responseClosure = { response in
   if let lastContacts = response.data as? [UserItemProtocol] {
   //得到最近使用路由
  }
}
RouterCenter.default.starRequest(request);
复制代码

对于页面跳转这种特殊的通信方式,则是可将UIViewController当作RouterResponseProtocol中data,发起请求方可自行决定怎么打开这个新页面。

    在此例中,通讯录模块和消息模块是同一层次中的横向划分的模块,路由系统模块则是通用层模块,同层模块不可互相依赖,只能通过路由系统传递信息,通讯录和消息模块依赖路由模块中对路由请求和路由响应协议所描述的抽象,即协议RouterHandlerProtocol,RouterResponseProtocol,RouterRequestProtocol。如下图:

路由调用关系图

模块内对象的交互

在基础层,一般是纯逻辑运算模块,或者纯UI模块,每个类甚至每个方法都可以单独使用。如系统库UIKit.framework, Foundation.framework等,其内部各对象之间一般不存在交互。

    在通用层,业务属性较弱,如上一节中的路由系统模块,功能比较单一,内部对象之间一般直接依赖即可。

    在业务层,则要复杂许多,可能既包含UI也包含逻辑运算,其内部对象的交互就不能直接依赖了,否则可能会使的耦合性急剧升高,不利于后续功能的扩展,这时候我们就可以引入架构模式了,实现架构模式中角色之间的交互就是模块内对象的交互。

    在iOS应用中,常见的架构模式有MVC系列的MVC, MVP, MVVM 和来源于清洁架构的VIPER。VIPER模式角色定位分明,遵循单一职责原则,其角色之间的交互多通过接口实现,所以本文以该模式来举例介绍。

    VIPER全称为View-Interactor-Presenter-Entity-Router。

    在VIPER中,ViewController归类为View角色,只负责显示逻辑。Interactor负责业务逻辑,包含网络请求,文件的存取。Presenter负责连接View和Interactor。Entity为原始数据,没有业务逻辑,只有Interactor能够访问它,Router为路由跳转,关注模块之间的交互。

VIPER结构图

以钉钉通讯录模块中的联系人列表为例,有2个需求场景:通讯录人员列表展示,创建群聊添加成员; 根据需求,枚举出具体功能:

  1. 人员列表中,每行中显示联系人的基本信息,包含姓名,所属部门,职位;
  2. 人员有2个状态,普通状态(通讯录人员列表展示场景),选择状态(创建群聊添加多个成员场景);
  3. 普通状态,点击跳转到人员详情页;
  4. 选择状态,点击选择该人员信息或者取消选择该人员信息,最后通过一个确定按钮将已选择的人员信息返回给创建群聊服务。
  • Interactor角色实现
protocol ContactItemProtocol { // 联系人信息
	var userId:String {get}
	var displayName:String {get}
	var avatar:String {get}
}
protocol ContactListInteractorProtocol { //item显示处理接口
	var items:[ContactListItemProtocol]? {get}
	func requestContacts()
}
protocol ContactListInteractorOutputProtocol {
	func contactDidReceived(_  items:[ContactItemProtocol]?, error:Error?)
}
复制代码

实现主要类:ContactListInteractor,遵从ContactListInteractorProtocol,持有一个外部代理output,用于通知外部数据已经更新,通常这个output由presenter实现,这点在定义presenter时也会有说到。

class ContactListInteractor : ContactListInteractorProtocol { 
 	weak var output : ContactListInteractorOutputProtocol? 
   	var items:[ContactItemProtocol]?
 	func requestContacts()
 		//同步/异步请求搜索数据
 		self.output.contactDidReceived(users, error:nil)
 	}
}
复制代码

此处我们暂时只实现数据的列表显示,对于选择状态留待后面处理。另外,Entity角色为实现了ContactItemProtocol协议的结构体或类,此处略过实现。

  • View角色实现
protocol ContactCellModelProtocol{ //每行的显示数据接口
    var imageUrl:String{get}
    var nameString:String{get}
    var titleString:String?{get}
}
protocol ContactListViewDataSource : class { //整体数据源接口
    func numberOfRows() -> Int
    func cellModel(at row:Int) -> ContactCellModelProtocol
}
protocol ContactListViewEventHandler : class{ //点击事件接口
    func selectRow(at index:Int)
}
protocol ContactListViewProtocol {  //UI页面显示接口
    func reloadData()
}
复制代码

实现View主要类:ContactListViewController,遵从ContactListViewProtocol,持有dataSource用于获取显示数据,持有eventHandler用于点击事件的处理,一般dataSource和eventHandler都由presenter实现。

class ContactListViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, ContactListViewProtocol {
    //实现ContactListViewProtocol协议
    var dataSource:ContactListViewDataSource?
    var eventHandler:ContactListViewEventHandler?
}
复制代码
  • Presenter角色实现

Presenter用于连接View和Interactor,实现View角色的回调ContactListViewDataSource和ContactListViewEventHandler,然后再持有一个ContactViewProtocol;实现Interactor角色的回调ContactListInteractorOutput,然后再持有一个ContactListInteractorProtocol。

class ContactPresenter:  ContactListViewDataSource, ContactListViewEventHandler, ContactListInteractorOutput {
     var view : ContactViewProtocol!
     var interactor : ContactListInteractorProtocol!

     var eventHandler : ContactListEventHandlerPresenter?
     func selectRow(at row: Int) { 
         let user = self.interactor.results[row];
         ContactRouter.gotoUser(user.userId);
     }
}
复制代码
  • Router角色实现

应用上一节中的路由系统,跳转到另一个模块中的页面

struct ContactRouter {
    func gotoUser(_ userId:String) {
         var request = RouterRequest(url:"native://contact/user/\(userId)")
         request.responseClosure = { response in
              if let vc = response.data as? UIViewController {
                 currentVC.pushViewController(vc, animated:true);
               }
           RouterCenter.default.starRequest(request);
      }
}
复制代码

以上即完成了普通状态下通讯录人员的展示,是一个标准的VIPER模式实现的模块,类关系图如下:

联系人列表类关系图

然后实现选择状态,在选择状态时,显示的时候需要表示某个联系人是否被选择,点击后的逻辑也不一致,为此主要指需要修改Interactor,Presenter和View这3个角色。

  • Interactor角色,添加2个接口:
protocol ContactPickerInteractorProtocol {
    func pickStatusDidChanged(at index:Int)
}
protocol ContactPickerOutputProtocol {
    var results:[ContactItemProtocol]?{get} 
    func pick(result:ContactItemProtocol)
}
class ContactPickerInteractor : ContactPickerOutputProtocol {
    weak var output: ContactPickerOutputProtocol?
}
复制代码
  • Presenter角色,修改点击代理接口
class ContactPresenter:  ContactListViewDataSource, ContactListViewEventHandler, ContactListInteractorOutput {
    var view : ContactViewProtocol!
    var interactor : ContactListInteractorProtocol!

     var eventHandler : ContactListEventHandlerPresenter?
     func selectRow(at row: Int) { 
         let user = self.interactor.results[row];
         self.eventHandler.selectUser(user)
     }
}
protocol ContactListEventHandlerPresenter {
      func selectUser(_ user:ContactItemProtocol)
}
class ContactNormalEventHandlerPresenter : ContactListEventHandlerPresenter{ //普通点击
    func selectUser(_ user:ContactItemProtocol)
       ContactRouter..gotoUse(user.userId)
   }
}
class ContactPickerEventHandlerPresenter : ContactListEventHandlerPresenter{//选择联系人
var pickInteractor:ContactPickerInteractor!
    func selectUser(_ user:ContactItemProtocol)
         pickInteractor.pick(user)
     }
}
复制代码
  • View角色,修改ContactCellModelProtocol接口
protocol ContactCellModelProtocol{ //每行的显示数据接口
    var imageUrl:String{get}
    var nameString:String{get}
    var titleString:String?{get}
    var picked:Bool? {get} 
}
复制代码

最后将VIPER各个角色连接起来,还需要一个第一推动力,对此我们可以再创建一个角色ModuleFactory,如下:

struct ContactModuleFactory  :RouterHandlerProtocol{
   static func handleRouter(url:string, params:[String:Any]?, completion:((RouterResponseProtocol?) -> Void)) -> UIViewController?{
     if //普通列表展示
       return ContactModuleFactory.buildNormalContact()
     else if //选择显示
        return ContactModuleFactory.buildMultiplePickerContact()
   }
   static func buildNormalContact() -> UIViewController{
     let vc = ContactListViewController()
     let interactor = ContactListInteractor()
     let presenter = ContactPresenter()
     vc.dataSource = presenter
     vc.eventHandler = presenter
     interactor.output = presenter;
     let eventHandler = ContactNormalEventHandlerPresenter()
     presenter.eventHandler = eventHandler
   }
   static func buildMultiplePickerContact() -> UIViewController{
          //类比buildNormalContact实现     
   }
}
复制代码

通过VIPER架构模式,分别使用接口连接模式下的各个角色,大大降低了各对象之间的耦合性,各角色对象复用度也非常高,可任意组合成一个新的模块。

总结

以上从模块内外两个方面描述了如何在一个企业级iOS应用中采用模块化思维来实现面向接口编程,实现一个模块或者一个对象的时候,首先要从定义接口开始,确定他们功能的边界,制定交互的规则。但是,并不需要为每个类都定义一个接口,只有在对类有扩展性或者解耦要求的时候才需要定义接口。

微信公众号同步文章地址

分类:
iOS
标签: