ModuleManager设计介绍

博客链接 ModuleManager设计介绍

NNModule-swift 用于解决Swift项目中的组件间通信问题,主要用于业务模块之间的解耦(这里并不包含一些基础模块,如网络层), 希望通过 NNModule-swift 向大家提供一种思路,对遇到类似问题的同学能有所启发。

ModuleManager 类是NNModule-swift中用于管理服务的主要类,用于在App启动加载所有的服务。

项目地址:NNModule-swift

何为服务

ModuleManger 以注册服务的方式为核心进行组件间的解耦。服务的本质就是定义了一系列行为的一套接口,ModuleManager 将所有需要跨模块通信的功能全部定义为服务。在 ModuleManager 中所有提供的服务均基于ModuleBasicSerivce,它需要一个对应的实现者,在项目中我们称为 Impl。调用者是通过定义好的服务进行接口调用,而不是通过具体的类名即具体的 Impl 类调用接口。

ModuleBasicService定义如下:

public protocol ModuleBasicService: AnyObject {

    init()
    
    static var implInstance: Self { get }
}

功能类服务

我们将需要进行跨模块通信的一些功能集抽象为一种功能类服务,所有的功能类服务均基于ModuleFunctionalService。功能类服务对应的Impl负责实现功能类服务中的所有接口,它与功能类服务为1对1关系,即使在项目中有多个类实现了同一个功能类服务(即有多个Impl),但是实际调用的Impl实例有且只有一个。每个功能类服务的Impl是分散各个模块中的,调用者只需要知道某个功能类服务能提供某个行为即可,对于具体的Impl的具体信息大可不必关心。

ModuleFunctionalService定义如下:

public protocol ModuleFunctionalService: ModuleBasicService {

    static var implPriority: Int { get }
}

implPriority用于指定impl类的优先级,当某个功能类服务存在多个Impl类时,只会选择优先级最高的类作为Impl。

根据对功能类服务的说明,得到下面这个关系图:

功能类服务类图

获取服务示例代码如下:

// 通过路由服务注册路由
Module.routeService.registerRoute("A2Page") { url, navigator in
    print(url.parameters)
    navigator.push(A2ViewController())
    
    return true
}

// 通过路由服务调用路由
Module.routeService.openRoute("xxxx", parameters: ["model": xxxx])

// 通过登录服务获取是否登录
Module.service(of: LoginService.self).isLogin

注册类服务

对于某些功能类服务(比如路由服务)来说,它自身就需要由其他模块为它注册内容,因此衍生出一系列注册类服务。注册类服务的职责便是为功能类服务注册内容,在 Modulemanager 中所有的注册类服务均基于ModuleRegisteredService,它同样需要有对应的 Impl 来实现所有的接口,但与功能服务的不同的是,注册类服务与 Impl 为1对多关系,多个 Impl 共同完成某个功能类服务的注册内容。

ModuleRegisteredService的定义如下:

public protocol ModuleRegisteredService: ModuleBasicService {
    
    static var keepaliveRegiteredImpl: Bool { get }
}

keepaliveRegiteredImpl用于保活实例,这么设计处于以下几个方面考虑:

  1. 若没有其他对象持有会导致它被调用完后会被释放,如果这些Impl涉及到数据操作的话,很容易出现数据错误和丢失的情况;
  2. 基于第1点提到的情况,虽然ModuleBasicServiceimplInstance可以指定实例比如单例对象,但是我们不能对所有的 impl 类都声明一个单例属性,这显然是不合理的;
  3. 对于某些类来说,它可能是A功能的实现者,同时它又要为B功能提供注册,那么设置keepaliveRegiteredImpl属性为 true 可以保证实例的唯一性以及防止数据分散在不同的实例上;

举个例子,假设项目有一个用于TabBar的服务ModuleTabService,对于TabBar来说,每个item对应ViewController位于不同的业务模块中,为了解耦我不能直接将这些ViewController所在的module直接引入,因此我需要一个注册TabBarItem的RegisterTabItemService服务来帮助ModuleTabService对应的Impl获取到所有的TabBarItem。

根据上面所述,可以得到下面这个类图:

tab服务类图

服务注册

通过前面的介绍相信你已经了解如何通过声明服务去进行组件解耦的了,现在的问题是我们在什么时机去注册这些服务。这里有两点是非常明确的:

  1. 注册服务的时机一定是在获取服务对应的实例之前就必须完成的,因此我们需要寻找足够早的时机。
  2. 注册服务的代码实现一定是分散在各个业务组件中,不可能全部放在一起。

在OC中我们可以通过load函数实现服务的注册,但是Swift不再支持load这样的函数,因此我们可能要自己实现一个类似的功能。

方案1.使用json配置+指定类+接口

我们可以定义出一套接口,然后让每个业务模块的指定类去实现这套接口,从而完成服务注册。我们在主工程中需要用一个module_config.json文件去保存所有的业务模块。当App初始化的时候,ModuleManager 的ModuleConfigSerivce读取这个json文件时,便会加载所有的业务模块,并调用模块中的指定类的接口完成服务的注册。

module_config.json 的格式如下:

{
    "tab_bar_class": "TabBarController.TabBarController",
    "app_service": "ApplicationModule",
    "modules": [
        "AModule",
        "BModule",
        "CModule"
    ] 
}

另外我们规定每个业务模块需要创建一个以业务模块名+Impl为规则的类,在项目初始化的时候对加载这些特殊的类来完成服务的注册。

该方案的示例代码大致如下:

// 定义注册功能类服务的接口
public protocol RegisterServiceProtocol {
    static func registerService()
}
// AwakeProtocol通常用于添加注册类服务的Impl以及一些初始化操作
public protocol AwakeProtocol {
    static func awake()
}

// AModule中的AModuleImpl类
class AModuleImpl: RegisterServiceProtocol, AwakeProtocol {
    // register service
    static func registerService() {
        Module.register(service: HomeService.self, used: HomeManager.self)
    }
    
    // invoke after finish register service
    static func awake() {
        Module.tabService.addRegister(AModuleImpl.self)
        // ... other code
    }
    
    // ... other service
}

虽然上述方法是可行的,但是在实际应用的时候我发现几个问题:

  1. json文件在没有脚本辅助的情况下容易忘记更改,导致服务不生效。
  2. 业务模块的ModuleImpl类承担着业务模块中心管理的角色,当业务模块极其复杂时可能会导致ModuleImpl类比较复杂。虽然业务模块复杂绝大部分可能因为业务拆解有问题,但我依旧希望能通过代码的方式进行一次挽救来减少这个文件的代码量。
  3. 集中式管理太多,主工程的module_config.json文件、各个业务模块下的ModuleImpl类都是集中式管理,一旦组件发生变动,会经常性的涉及到这些文件的修改。

针对上述的问题,我曾经尝试使用runtime遍历所有类的方式去加载所有的类,并从中筛选出那些遵循协议的类调用相关注册接口,但是测试结果证明,objc_getClassList函数执行的耗时比较久,此路不通。但是如果想要减少集中式管理,我们就不得不使用动态机制,一旦涉及到动态调用,我们只能去从Runtime中去找解决方案(虽然是swift项目,但也不得已为之)。

方案2. 调用指定类方法列表中的所有方法

从Runtime中我们可以知道,通过class_copyMethodList函数可以获取到类的所有方法,另外分类可以非常灵活的添加方法。如果我们能指定一个类,并在App启动时调用它的类方法列表中的所有方法,那么是不是就能实现类似于load函数的效果。

使用分类的话会存在一个问题,当分类中出现同名函数的时候,class_copyMethodList确实有两个函数地址,但是调用时只会调用编译顺序最后添加的方法,这是因为objc_msgSend的机制,但是在实际情况中我们并不能保证不出现同名函数的可能性,所以使用objc_msgSend的方式调用可能是行不通的。好在Runtime提供了method_invoke函数,它可以直接调用函数的指针而且性能比消息机制还快。

还有一点需要注意,class_copyMethodList中保存的是OC函数,对于Swift项目来说,需要在函数声明中添加@objc标记。

理清楚思路,我们就可以实现如何方法列表中的所有方法,实现如下:

// 调用类的类方法列表
func loadAllMethods(from aClass: AnyClass) {
    guard let metaClass: AnyClass = object_getClass(aClass) else { return }
    
    var count: UInt32 = 0
    guard let methodList = class_copyMethodList(metaClass, &count) else { return }
    
    let handle = dlopen(nil, RTLD_LAZY)
    let methodInvoke = dlsym(handle, "method_invoke")
    
    for i in 0..<Int(count) {
        let method = methodList[i]
        unsafeBitCast(methodInvoke, to:(@convention(c)(Any, Method)->Void).self)(metaClass, method)
    }
    
    dlclose(handle)
    free(methodList)
}

基于这个方案,我们在业务模块中注册服务将会变得异常的简单,只需要添加分类,在分类中添加带有@objc标记的类方法即可。由于分类添加函数的灵活性,对于解决ModuleImpl类中代码臃肿也起到了比较大的帮助。

ModuleManager 中声明了Module.RegisterServiceModule.AModule这两个类用来提供服务注册的时机。

  • Module.RegisterService用于注册功能型服务,在Module.RegisterService分类中添加类方法用于功能类服务的注册。
    extension Module.RegisterService {
        @objc static func aModuleRegisterService() {
            Module.register(service: HomeService.self, used: HomeManager.self)
        }
    }
    
  • Module.Awake用于注册注册类服务和功能类服务的初始化操作,在Module.RegisterService分类中添加类方法用于注册类服务的注册。
    extension Module.Awake {
        @objc static func aModuleAwake() {
            Module.tabService.addRegister(AModuleImpl.self)
            // ... other code
        }
    }
    

ModuleManager提供的解耦方式

1. 面向协议的服务注册

通过面向协议的服务注册方案是整个ModuleManager的核心思路,它通过服务注册的方式来实现远程接口调用的。业务模块提供自己对外服务的协议声明到中间层(这个中间层指的是所有业务模块均需要依赖的某个公共模块),调用方可以通过查看中间层定义的接口来进行具体的调用。

如下图:


ModuleSerivces中定义的服务

示例代码如下:

// 注册服务
Module.register(service: LoginService.self, used: LoginManager.self)

// 获取服务的Impl实例
let loginImpl = Module.service(of: LoginService.self)

2. 基于URL的路由方案

使用路由进行跳转是最常见的页面解耦方式,关于路由的具体的使用方式可以查看NNModule-swift/URLRouter

示例代码如下:

// 注册路由
Module.routeService.register("A2Page") { url, navigator in
    print(url.parameters)
    navigator.push(A2ViewController())
    
    return true
}

// 路由跳转
Module.routeService.open("A2Page", parameters: ["model": self])

3. 基于协议的远程接口调用

基于协议的远程接口调用设计初衷是为了替代使用performSelector进行远程接口调用,performSelector函数除了自身语法比较有些隐晦以外,API只支持1对1的调用(虽然可以通过封装做到1对多的效果)。思前想后最终还是决定使用协议的方式进行远程接口调用。

NNModule-swift 提供了 EventSet 和 EventBus 类,均通过事先定义好的接口进行远程调用。EventSet可以满足1对多的要求,EventBus可以理解为是一个面向协议的通知中心。关于 EventSet 和 EventBus 的具体的使用方式可以查看NNModule-swift/EventTransfer

示例代码如下:

// 监听登录相关接口
Module.eventBusService.register(LoginEvent.self, target: self)

// 调用登录成功
Module.eventBusService.send(LoginEvent.self) { $0.didLoginSuccess() }

4. 基于NotificationCenter的通知方案

基于通知的模块间通讯方案,实现思路非常简单, 直接基于系统的 NSNotificationCenter 即可。 优势是实现简单,非常适合处理一对多的通讯场景。 劣势是仅适用于简单通讯场景。复杂数据传输,同步调用等方式都不太方便。模块化通讯方案中,更多的是把通知方案作为以上几种方案的补充。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容