iOS组件化及架构设计

关于组件化

网上组件化的文章很多。很多文章一提到组件化,就会说解耦,一说到解耦就会说路由或者runtime。好像组件化 == 解耦 == 路由/Runtime,然而这是一个非常错误的观念。持有这一观点的人,没有搞清楚在组件化中什么是想要结果,什么是过程。

组件化和解耦

大家不妨先思考两个问题:

1、为何要进行组件化开发?

2、各个组件之间是否一定需要解耦?

采用组件化,是为了组件能单独开发,单独开发是结果。要让组件能单独开发,组件必须职责单一,职责单一需要用到重构和解耦的技术,所以重构和解耦是过程。那解耦是否是必须的过程?不一定。比如UIKit,我们用这个系统组件并没有使用任何解耦手段。问题来了,UIKit苹果可以独立开发,我们使用它为什么没用解耦手段?答案很简单,UIKit没有依赖我们的代码所以不用解耦。

PS:我这里不纠结组件、服务、模块、框架的概念,网上对这些概念的定义五花八门,实际上把简单的事说复杂了。我这里只关心一件事,这一部分代码能否独立开发,能就叫组件,不能我管你叫什么

我们之所以要解耦才能独立开发,通常是出现了循环依赖。这时候当然可以无脑的用路由把两个组件的耦合解开,也可以独立开发。然而,这样做只是把强引用改成了弱引用,代码还是烂代码。站在重构的角度来说,A、B组件循环依赖就是设计有问题,要么应该重构A、B让依赖单向;要么应该抽离一个共用组件C,让A、B组件都只依赖于C。

如果我们每个组件都只是单向依赖其他组件,各个组件之间也就没有必要解耦。再换个角度说,如果一个组件职责不单一,即使跟其他组件解耦了,组件依然不能很好的工作。如何解耦只是重构过程中可选手段,代码设计的原则如依赖倒置、接口隔离、里氏替换,都可以指导我们写出好的组件。

所以在组件化中重要的是让组件职责单一,职责单一的重要标志之一就是没有组件间的循环依赖

架构图

一般来讲,App的组件可以分为三层,上层业务组件、中层UI组件、底层SDK组件

同一层之间的组件互相独立,上层的组件耦合下层的组件。一般来讲,底层SDK组件和中层UI组件都是独立的功能,不会出现同层耦合。

架构图

业务组件解耦

上层业务组件之间的解耦,采用依赖注入的方式实现。每个模块都声明一个自己依赖的协议,在App集成方里去实现这些协议。

我之前的做法是每个模块用协议提供自己对外的能力,其他模块通过协议来访问它。这样做虽然也可以解耦,但是维护成本很高,每个模块都要去理解其他模块。同时也引入了其他模块自己用不到的功能,不符合最小依赖的原则。

使用依赖注入,APP集成方统一去管理各个模块的依赖,每个模块也能单独编译,是业务层解耦的最佳实践。

包管理

要解除循环依赖,引入包管理技术cocoapods会让我们更有效率。pod不允许组件间有循环依赖,若有pod install时就会报错。

cocoapods,提供私有pod repo,使用时把自己的组件放在私有pod repo里,然后在Podfile里直接通过pod命令集成。一个组件对应一个私有pod,每个组件依赖自己所需要的三方库。多个组件联合开发的时候,可以再一个podspec里配置子模块,这样在每个组件自己的podspec里,只需要把子模块里的pod依赖关系拷贝过去就行了。

在多个组件集成时会有版本冲突的问题。比如登录组件(L)、广告组件(A)都依赖了埋点组件(O),L依赖O的1.1版本,A依赖O的1.2版本,这时候集成就会报错。为了解决这个错误,在组件间依赖时,不写版本号,版本号只在APP集成方写。即podfile里引用所有组件,并写上版本号,.podspec里不写版本号。

这样做既可以保证APP集成方的稳定性,也可以解决组件依赖的版本冲突问题。这样做的坏处是,所有组件包括App集成方,在使用其他组件时,都必须使用其他组件最新的API,这会造成额外的升级工作量。如果不想接受组件升级最新api的成本,可以私有化一个三方库自己维护。

组件开发完毕后告诉集成方,目前的组件稳定版本是多少,引用的三方库稳定版本集成方自己去决定

推荐的组件版本号管理方式

另一种版本管理的方式,是在podspec里写依赖组件的版本号,podfile里不写组件依赖的版本,然后通过内部沟通来解决版本冲突的问题。我认为虽然也能做,但有很多弊端。

1.作为App集成方,没办法单独控制依赖的三方库版本。三方库升级会更复杂

2.每个依赖的三方库,都应该做了完整的单元测试,才能被集成到App中。所以正确的逻辑不是组件内测试过三方库没问题就在组件内写死版本号,而是这个三方库经过我们测试后,可以在我们系统中使用XX版本。

3.在工程中就没有一个地方能完整知道所有的pod组件,而App集成方有权利知道这一点

4.沟通成本高

不推荐的方式

顺便说一句,基础组件库可以通过pod子模块单独暴露独立功能,较常用。

以上,就是组件化的所有东西。你可能会奇怪,解耦在组件化过程中有什么用。答案是解耦是为了更好的实现组件的单一职责,解耦的作用在架构设计中谈。需要再次强调,组件化 ≠ 解耦。

如果非要给组件化下一个定义,我的理解是:

组件化意味着重构,目的是让每个组件职责单一。在结构上,每个组件都最小依赖它所需要的东西。


关于架构设计

在我看来,iOS客户端架构主要为了解决两个问题,一是解决大型项目分组件开发的效率的问题,二是解决单进程App的稳定性的问题。

设计到架构设计的都是大型App,小型App主要是业务的堆叠。很多公司在业务初期都不会考虑架构,在业务发展到一定规模的时候,才会重新审视架构混乱带来的开发效率和业务稳定性瓶颈。这时候就会引入组件化的概念,我们常常面临的是对已有项目的组件化,这一过程会异常困难。

组件拆分原则

对老工程的组件拆分,我的办法是,从底层开始拆。SDK>  模块 > 业务 。如果App没有SDK可以抽离,就从模块开始拆,不要为了抽离SDK而抽离。常见的误区是,大家一拿到代码就把公共函数提出来作为共用框架,起的名字还特别接地气,如XXCommon。

事实上,这种框架型SDK,是最鸡肋的组件,原因是它实用性很小,无非就是减少了点冗余代码。而且在架构能力不强的情况下,它很容易变成“垃圾堆”,什么东西都想往里面放,后面越来越庞大。所以,开始拆分架构的时候,尽量以业务优先,比如先拆分享模块。

如果两个组件中有共同的函数,前期不要想着提出来,改个名字让它冗余是更好的办法。如果共同耦合的是一个静态库,可以利用动态库的隔离性封装静态库,具体方法可以网上找。

响应式

基础组件常常要在系统启动时初始化,或者接受App生命周期时间。这就引出了个问题,如何给appDelegate瘦身?比如我们现在有两个基础组件A、B,他们都需要监听App生命周期事件,传统的做法是,A、B两个组件都提供一些函数在appDelegate中调用。但这样做的坏处是,如果某一天我不想引入B组件了,还得去改appDelegate代码。理想的方式是,基础组件的使用不需要在appDelegate里写代码

为了实现基础组件与appDelegate分离,得对appDelegate改造。首先得提出一个观点,苹果的appDelegate设计的有问题,它在用代理模式解决观察者模式的问题。在《设计模式》中,代理模式的设计意图定义是:为其他对象提供一种代理以控制对这个对象的访问。反过来看appDelegate你会发现,它大部分代理函数都没有办法控制application,如applicationDidBecomeActive。applicationDidBecomeActive这种事件常常需要多个处理者,这种场景用观察者模式更适合。而openURL需要返回BOOL值,才需要使用代理模式。App生命周期事件虽然可以用监听通知获取,但用起来不如响应式监听信号方便。

基于响应式编程的思想,我写了一个TLAppEventBus,提供属性来监听生命周期事件。我并不喜欢庞大的ReactiveObjectC,所以我通过category实现了简单的响应式,用户只需要监听需要的信号即可。在TLAppEventBus里,我默认提供了8个系统事件用来监听,如果有其他的系统事件需要监听,可以使用扩展的方法,给TLAppEventBus添加属性(见文末Demo)。

路由

对于Appdelegate中的openURL的事件,苹果使用代理模式并没有问题,但我们常常需要在openURL里面写if-else区分事件的处理者,这也会造成多个URL处理模块耦合在Appdelegate中。我认为appdelegate中的openURL应该用路由转发的方式来解耦。

openURL代理需要同步返回处理结果,但网上开源的路由框架能同步返回结果的。所以我这边实现了一个能同步返回结果的路由TLRouter,同时支持了注册scheme。注册scheme这一特性,在第三方分享的场景下会比较有用(见文末Demo)。

另外,网上大部分方案都搞错了场景。以蘑菇街的路由方案为例(好像iOS的路由就是他们提出来的?),蘑菇街认为路由主要有两个作用,一是发送数据让路由接收者处理,二是返回对象让路由发送者继续处理。我不禁想问,这是路由吗?不妨先回到URL的定义

URL: 统一资源标识符(Uniform Resource Locator,统一资源定位符)是一个用于标识某一互联网资源名称的字符串

openURL就是在访问资源,在浏览器中,openURL意味着打开一个网页,openURL的发起者并不关心打开的内容是什么,只关心打开的结果。所以苹果的openURL Api 就只返回了除了结果YES/NO,没有返回一个对象。所以,我对openURL这一行为定义如下

openURL:访问资源,返回是否访问成功

那把蘑菇街的路由,返回的对象改成BOOL值就可以了么?我认为还不够。对于客户端的路由,使用的实际上是通知的形式在解耦,带来的问题是路由的注册代码散落在各地,所以路由方案必须要配路由文档,要不然开发者会不知道路由在干嘛。

有没有比文档更好的方式呢?我的思路是:用schema区分路由职责

系统的openURL只干了两件事:打开App和打开网页

[[UIApplicationsharedApplication] openURL:[NSURLURLWithString:@"weixin://"]]; // 打开App

[[UIApplicationsharedApplication] openURL:[NSURLURLWithString:@"https://www.baidu.com"]];//打开网页

两者的共性是页面切换。所以我这边设计的路由openURL,只扩充了controller跳转的功能,比如打开登录页

[TLRouter openURL:@"innerJump://account/login"];

只扩充了controller跳转的功能好处是让路由的职责更单一,同时也更符合苹果对openURL的定义。工程师在看到url schema的时候就知道他的作用,避免反复查看文档。

对于数据的传递,我认为不应该用路由的方式。相比路由,通过依赖注入传入信号是更好的选择。

App配置

有时候我们需要组件的跨App复用,在App集成组件时,能够不改代码只改配置是最理想的方式。使用组件+plist配置是一个方案,具体做法是把A组件的配置放在A.plist中,在A组件内写死要读取A.plist。

以配置代替硬编码,防止对代码的侵入,是一个很好的思路。设想一下,如果我们可以通过配置在决定App是否使用组件、也可通过配置来改变组件和app所需的参数,那运维可以代替app开发来出包,这对效率和稳定性都会有提升。为了实现这一效果,我使用了OC的runtime来动态注册组件。需要在didfinishLaunch初始化的组件,可以实现代理 - (void)initializeWhenLaunch; 这样,自动初始化函数,就可以通过runtime+plist里配置的class name自动初始化。组件需要初始化的代码,可以在自己的initializeWhenLaunch里做。

由于路由只扩充了controller跳转的功能,所以路由注册这一行为也可进行一次抽象,把不同的部分放在plist配置文件,相同的放到runtime里做。这样做还有个好处是,程序内的路由跳转在一个plist里可以都可以看到

appdelegate改造后示例

iOS解耦工具Tourelle

Tourelle,是根据上面的思路写的一个开源项目 https://github.com/zhudaye12138/Tourelle,可以通过pod集成  pod 'Tourelle'。下面介绍一下他的使用方式

TLAppEventBus

TLAppEventBus通过接收系统通知来获取app生命周期事件,收到生命周期事件后改变对应属性的值。默认提供了didEnterBackground等八个属性,可以使用响应式函数来监听 

- (void)observeWithBlock:(TLObservingBlock)block; 

    [TLAppEventBus.shared.didBecomeActive observeWithBlock:^(idnewValue) {

        //do some thing

    }];

需要注意,如果在其它地方使用observeWithBlock,需要设置属性的owner,否则没有办法监听到。这里不用单独设置是因为在TLAppEventBus里已设置好

TLAppEventBus使用前需要调用 - (void)start; 如果需要监听更多的事件,可以调用

- (void)startWithNotificationMap:(NSDictionary *)map; 

  NSMutableDictionary *defaultMap = [NSMutableDictionary dictionaryWithDictionary:[TLAppEventBus defaultNotificationMap]]; //获取默认map

    [defaultMapsetObject:KDidChangeStatusBarOrientation forKey:UIApplicationWillChangeStatusBarOrientationNotification]; //添加新的事件

    [TLAppEventBus.shared startWithNotificationMap:defaultMap];//开启EventBus

添加新事件需要用分类添加TLAppEventBus的属性,添加后就可正常使用了

-(void)setDidChangeStatusBarOrientation:(NSNotification*)didChangeStatusBarOrientation {

    objc_setAssociatedObject(self, (__bridge const void *)KDidChangeStatusBarOrientation , didChangeStatusBarOrientation, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

}

-(NSNotification*)didChangeStatusBarOrientation {

    returnobjc_getAssociatedObject(self, (__bridge const void *)KDidBecomeActive);

}

TLRouter

路由支持两种注册方式,一种只写schema,一种写url路径

 [TLRouter registerURL:@"wx1234567://" hander:^(TLRouterURL *routeURL, void (^callback)(BOOL result)) {       

        //do something     

}]//注册schema

[TLRouter registerURL:@"InnerJump://account/login" hander:^(TLRouterURL *routeURL, void (^callback)(BOOL result)) {

                //do something

 }]//注册url路径

支持同步 & 异步获取返回值,其中异步转同步内部通过semaphore实现

+(void)openURL:(NSString*)url callback:(void(^)(BOOLresult))callback;

+(BOOL)openURL:(NSString*)url;

另外openURL除了支持url中带参数,也支持参数放在字典中

+(BOOL)openURL:(NSString*)url param:(NSDictionary *)param;

TLAppLaunchHelper

TLAppLaunchHelper有两个函数,一个用来初始化组件。该函数会读取AutoInitialize.plist中的classes,通过runtime + 自动初始化协议完成初始化

-(void)autoInitialize;

AutoInitialize.plist

另一个函数用来自动注册路由,该函数会读取AutoRegistURL.plist完成路由注册。其中controller代表类名,params代表默认参数,如果openURL传的参数与默认参数不符合,路由会报错

-(void)autoRegistURL;


 AutoRegistURL.plist


路由注册时,并不决定controller跳转的方式。注册者只是调用presentingSelf方法,跳转方式由controller中presentingSelf方法决定。

-(BOOL)presentingSelf {

    UINavigationController *rootVC = (UINavigationController *) APPWINDOW.rootViewController;

    if(rootVC) {

        [rootVCpushViewController:self animated:YES];

        returnYES;

   }

    return NO;

}


耦合检测工具

针对既有代码的组件化重构,我这边开发了一个耦合检测工具,目前只支持OC。

耦合检测工具的原理是这样:工具认为工程中一级文件夹由组件构成,比如A工程下面有aa、bb、cc三个文件夹,aa、bb、cc就是三个待检测的组件。耦合检测分三步,第一步通过正则找到组件内.h文件中所有关键字(包括函数、宏定义和类)。第二步通过找到的组件内关键字,再通过正则去其它组件的.m中找是否使用了该组件的关键字,如果使用了,两个组件就有耦合关系。第三步,输出耦合检测报告

代码:开源中....

总结

本文给出了组件化的定义:组件化意味着重构,目的是让每个组件职责单一以提升集成效率。包管理技术Pod是组件化常用的工具,iOS组件依赖及组件版本号确定,都可以用pod实现。整个iOS工程的组件通常分为3层,业务组件、模块组件和SDK组件。在老工程重构时,优先抽离SDK组件,切记不要写XXCommon让它变成垃圾堆。

关于解耦的技术,appldegate适合用观察者模式替换代理模式,路由只用来做controller之间的跳转,上层业务组件的解耦靠依赖注入而不是全用路由。工程的组件和路由都可通过runtime + 配置的形式自动注册,这样做维护和集成都会很方便。

Demo地址:https://github.com/zhudaye12138/Tourelle

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

推荐阅读更多精彩内容