[iOS] 模块化 & 组件化

感觉我去年11月的时候还不知道啥是组件化和模块化,今年这个时候就可以写这个topic了也是神奇0.0

首先说下在我看来这两者的区别吧:

  • 模块化:

如果工程很大,公司几百上千的人开发同一个app,很容易就会有冲突,那么就需要划分业务线,大家规定好对外暴露的接口,尽量只改动自己业务线的内容,例如直播就是一个业务线,也可以作为一个模块。

所以模块化更多感觉是根据业务线划分,每个业务线会有自己独立的一个或者多个git,这样的话其实主工程没有实际的代码只有一些config文件,只要引入各个业务线的git并做初始化即可。这样其实QA的压力也会小一点,毕竟业务线之间的耦合会更可控,业务线可以有自己的QA测试。

  • 组件化:

组件化一般说的是模块也是业务线自己内部的事儿了,也是为了提高复用、可插拔、减少QA测试压力等。

我经历过的两家公司的部门都是做拍摄和录制相关的,例如直播,从页面上来看,送礼就是一个小组件。

组件化的目的也很纯粹,比如在app X上你做了一个直播,然后你们又搭建了一个app B,也想要直播里面的购物车,但不要别的东西要怎么办?app B不可能为了要引入一个购物车模块把你app X的整个直播间代码引入叭。

当如模块化其实也是有这个考虑,如果新起一个app只想要直播这个模块,会很容易引用。如果大家代码都混在一起,直播里面直接引入了其他模块的.h文件,那么你无法做到只把直播的代码拷过去,就要引入整个旧app?这肯定是不行的。

其实组件化和模块化都是为了解耦,让底层不依赖上层,以及同层之间尽量不依赖,依赖也不要直接引入对方的.h文件。


模块化

首先强推:https://blog.csdn.net/u013378438/article/details/85702346

模块化前

模块化的过程比较痛苦(实际上组件化也是),模块化其实就是把业务A和业务B之间的互相依赖,转化为大家都依赖中间层:

模块化后

当改成酱紫以后,如果其他app需要复用某个模块,只要把中间层以及想要用的模块的代码搞走就好啦,非常的方便。

所以要如何做模块化呢?流程大概是酱紫的:

  1. 首先需要把每个业务拆分出单独的pod
  2. 然后梳理相互之间的依赖(分下类比如view、vc、AB test、storage)
  3. 让模块间的依赖impl替换为依赖接口
  4. 实现中间层,并替换所有依赖为中间层代码

其实感觉过程不是很复杂对不对,可能在设计的part,看起来比较复杂的是中间层如何设计才能解决以下问题:

  • Mediator如何调度不同的模块?
  • 不同模块仅和Mediator通信,那不同模块又如何知道其他模块能够提供的接口(服务)?
  • 模块知道Mediator就可以work。但是对于Mediator来说,岂不是要知道所有的模块?如何避免让Mediator成为一个巨无霸?

关于中间层的设计,业界给出了两种方式:

  1. 运用OC特有的语言机制,就是runtime反射调用。
  2. 设计注册机制,每一个模块主动向Mediator注册自己,在Mediator中统一通过抽象的Class类型来管理这些模块。用户通过模块对应的key向Mediator索要对应的模块,然后在Mediator外部自行调用模块的功能。

现在假设一个场景,有两个模块分别为A和B,A在某个场景下需要使用定义在模块B内的气泡,我们先看下最简单的方式

implement 1
实现1

因为比较简单,B的代码就不截图啦,它就是有一个createBubbleView的方法。这个时候moduleA是直接#import "ModuleBImpl.h",这种情况下如果你想把A独立出去,就要连着B一起给出去。而且这样A、B模块间可以随意访问调用,如果你负责B模块你都无法预知对方会调用你的哪些方法。

implement 2 - 中间层初实现
实现2

如果我们新建一个Mediator,让它引入各个模块,并提供方法,这样A模块只要引入这个文件即可,至少不需要直接引入模块B了,也可以限制A可以调用的方法。

#import "ModuleAImpl.h"
#import "Mediator.h"
@import UIKit;

@implementation ModuleAImpl

- (void)showBubble {
    UIView *bubble = [Mediator createBModuleBubbleView];
    // add到view上面
}

@end

这么做仍旧有一点问题,Mediator直接依赖了B的实现,那么如果想独立出A,就要把Mediator带上,但是如果不带B的实现的话就会编译不过。

也就是说,Mediator 必须知道每一个模块的存在,这就在模块和Mediator间产生了强耦合。这样做会有很多缺点:

  • Mediator必须知道每一个模块以及模块所能够提供的所有接口,会使得Mediator变得十分臃肿甚至难以维护。(试想一下,如果你负责维护Mediator,你需要知道每个业务部门的业务接口逻辑)
  • 由于Mediator与模块的强耦合性,导致每当模块添加或修改接口,都需要Mediator跟着变动,而Mediator又是所有模块都会引用到的一个中介,这么一个三天两头就会变化的Mediator很难搞。

所以这里做一点修改,让Mediator依赖B的接口,但是如果依赖接口,Mediator要如何生成一个B呢?

implement 3 - 注册实现中间层

我们可以让各个模块自己向Mediator里面注册自己,这样Mediator就可以拿到他们啦:

#import <Foundation/Foundation.h>
@import UIKit;

NS_ASSUME_NONNULL_BEGIN

@interface Mediator : NSObject

+ (Mediator *)sharedInstance;

- (void)registerService:(Protocol *)proto forService:(Class)serviceClass;

- (Class)fetchService:(Protocol *)proto;


@end

NS_ASSUME_NONNULL_END

===============================

#import "Mediator.h"

@interface Mediator()

@property(nonatomic, strong) NSMutableDictionary *serviceDict;

@end

@implementation Mediator

+ (Mediator *)sharedInstance {
    static Mediator *mediator;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        mediator = [[Mediator alloc] init];
    });
    return mediator;
}

- (void)registerService:(Protocol *)proto forService:(Class)serviceClass {
    [self.serviceDict setObject:serviceClass forKey:NSStringFromProtocol(proto)];
}

- (Class)fetchService:(Protocol *)proto {
    return self.serviceDict[NSStringFromProtocol(proto)];
}

@end

然后模块B需要在初始化的时候注册自己,为了方便就在load的时候做啦,但是实际上最好还是init的时候去做:

#import "ModuleBImpl.h"
#import "Mediator.h"

@implementation ModuleBImpl

+ (void)load {
    [[Mediator sharedInstance] registerService:@protocol(ModuleBInterface) forService:[self class]];
}

+ (UIView *)createBubbleView {
    return [[UIView alloc] init];
}

@end

这里的ModuleBInterface就是模块B对外提供的接口功能:

#ifndef ModuleBInterface_h
#define ModuleBInterface_h

@class UIView;

@protocol ModuleBInterface <NSObject>

+ (UIView *)createBubbleView;

@end


#endif /* ModuleBInterface_h */

然后A就可以通过Mediator拿到B的抽象啦~


A使用B

现在如果某个app需要引用A模块,就只需要把Mediator和A的目录下的文件拿走就可以啦,只是注意从Mediator拿东西的时候,要有意识可能是空,当然你也可以自己写一个新的B的impl注册到中间层里面~

我们之前项目还做了一个放在自己模块里面的configuration类,类似让你自己返回protocol也就是key对应的class~ 但其实本质还是没有变的,只是可配置性更强一点:

@implementation xxxConfiguration

+ (NSString *)name {
    return @"xxx";
}

+ (nullable NSArray<Class> *)moduleServiceClasses {
    return @[
        [xxx class],
    ];
}

+ (nullable NSArray<Protocol *> *)moduleInterfaceProtocols {
    return @[
        @protocol(xxx),
    ];
}

然后你可以AppDelegate里面在app初始化的时候,调用这个config文件来进行注册配置~ 这样的话,只要你引入了这个模块,就可以注册这个模块内的接口,然后其他模块就可以用了。

implement 4 - runtime实现中间层

首先先看下不通过中间层,仅仅通过反射如何做:

#import "ModuleAImpl.h"
@import UIKit;

@implementation ModuleAImpl

- (void)showBubble {
    Class cls = NSClassFromString(@"ModuleBImpl");
    UIView *bubble = [cls performSelector:@selector(createBubbleView) withObject:nil];
}

@end

讲真模块之间要是这么使用,感觉会被打死。。其他模块要是改个类名,那我们妥妥跪了,所以这肯定是不行的,那么如果放进中间层呢?

有一个现成的库可以帮我们做到中间层:CTMediator

这个库的使用方法是,你可以给CTMediator加category作为对外的方法,这样其他模块使用的时候就会直接可以调用你的方法啦。然后CTMediator要如何把方法转发给你呢?

你在category里面只要写[self performTarget:目标类名 action:目标方法名 params:@{@"key":@"value"} shouldCacheTarget:NO];CTMediator会去找Target_目标名的class,然后找这个类的Action_动作名的方法来调用:

CTMediator

可以看一下performTarget的代码是酱紫的:

{
    NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
    
    // generate target
    NSString *targetClassString = nil;
    if (swiftModuleName.length > 0) {
        targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
    } else {
        targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
    }
    NSObject *target = self.cachedTarget[targetClassString];
    if (target == nil) {
        Class targetClass = NSClassFromString(targetClassString);
        target = [[targetClass alloc] init];
    }

    // generate action
    NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
    SEL action = NSSelectorFromString(actionString);
    
    if (target == nil) {
        // 这里是处理无响应请求的地方之一,这个demo做得比较简单,如果没有可以响应的target,就直接return了。实际开发过程中是可以事先给一个固定的target专门用于在这个时候顶上,然后处理这种请求的
        [self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
        return nil;
    }
    
    if (shouldCacheTarget) {
        self.cachedTarget[targetClassString] = target;
    }

    if ([target respondsToSelector:action]) {
        return [self safePerformAction:action target:target params:params];
    } else {
        // 这里是处理无响应请求的地方,如果无响应,则尝试调用对应target的notFound方法统一处理
        SEL action = NSSelectorFromString(@"notFound:");
        if ([target respondsToSelector:action]) {
            return [self safePerformAction:action target:target params:params];
        } else {
            // 这里也是处理无响应请求的地方,在notFound都没有的时候,这个demo是直接return了。实际开发过程中,可以用前面提到的固定的target顶上的。
            [self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
            [self.cachedTarget removeObjectForKey:targetClassString];
            return nil;
        }
    }
}

这里通过这个库改写一下上面的例子:


A使用B的样子

CTMediator的分类可以放在底层:

#import <CTMediator/CTMediator.h>
@import UIKit;

NS_ASSUME_NONNULL_BEGIN

@interface CTMediator (moduleb)

- (UIView *)createModuleBBubble;

@end

NS_ASSUME_NONNULL_END

==============================

#import "CTMediator+moduleb.h"

@implementation CTMediator (moduleb)

- (UIView *)createModuleBBubble {
    UIView *bubble = [self performTarget:@"B" action:@"createBubbleView" params:@{@"key" : @"value"} shouldCacheTarget:NO];
    return bubble;
}

@end

新建一个target B的文件,为了让CTMediator在performTarget的时候可以找到它:

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface Target_B : NSObject

@end

NS_ASSUME_NONNULL_END
================================

#import "Target_B.h"
#import "ModuleBImpl.h"
@import UIKit;

@implementation Target_B

- (UIView *)Action_createBubbleView {
    return [ModuleBImpl createBubbleView];
}

@end

这个文件可以放在B模块里面,功能其实类似替代了接口文件,所以这么做其实B就不用再实现接口的protocol,也就没有定义相关的interface protocol了,完全通过target文件来控制有哪些action。


Summary for 模块化

中间层的实现分两种,注册(可以参考阿里的BeeHive)以及反射(可以参考CTMediator)。

反射其实是你需要遵守它的规则,包括类名和方法名,这样它通过runtime找到你的对应关系;而注册就是自己需要控制注册时机之类的,但是更清晰,更透明。你不需要了解CTMediator的命名要求就可以做到。

其实两者区别不大,都是解决了如何通过抽象找到一个类的实现,如果你的App整体是由 DI 做的,还可以通过依赖注入来解耦(但其实很多对外的都是类方法,只要拿到class就可以了,在DI的场景可能不是很有用,DI适用于拿对象)。模块化的重点其实还是如何划分模块以及具体拆分的时候怎么一步一步的拆开,具体中间层的实现有很多方式的。


组件化

强推一篇:https://juejin.im/post/6844903873023311886#heading-1

可插拔、复用、减少QA测试,不要改动一点要把所有核心功能测一遍(业务的价值点)

在业务内,就像开篇举的直播例子,页面里面会有很多独立的单元,当然除了直播也会有这样的,比如抖音快手的拍视频。所以如果其他app要引用我们的直播模块,但是它只希望页面里有一个送礼组件,如果为了实现这个它需要引入所有和送礼组件耦合的组件,是非常不合理and头秃的。

所以其实组件化是为了实现我们业务线内,更小力度的组件可配置,可插拔。也就是你可以选择,你要放入哪些组件,并可以不引入其他不要的组件的文件。

类似模块化,如果你想实现只要组件A,那么即使组件A使用了组件B,也是不可以直接在A里面import B的头文件哒。比较重要的是,组件化的实现仍旧是依赖接口interface而非impl

1)组件原则
组件通常分为两种类型的组件:基础组件,业务组件。

  • 业务组件依赖基础组件
  • 基础组件不可依赖业务组件。
  • 业务组件间不可相互依赖。

2)组件间的通信方式:
组件间通信方式,业内主要有两种实现方式:
1. 协议式框架,比如蘑菇街的这种方案。蘑菇街 App 的组件化之路
2. 中间者架构,比如casatwy的方案。casatwy关于蘑菇街组件化的讨论

这里说一种实现叭,在直播的时候,所有组件都会对应一个protocol,也就是这个组件对外提供的能力,然后组件们会被注入到DI,组件间的互相调用,是通过从DI拿实现了某个protocol的对象实现的。也就是这些protocol和DI是作为中间层common的部分,如果你只想引入组件A,可以只引入A和common part。

当然还有很多种实现,但重要的是,都不要直接引入别的组件的内容。

我们目前用的是基于分层结构的组件化,会抽出基础组件放在底层,而不是每个组件都提供一个proto,主要是为了:

  • 有些组件其实并没有必要一定要有一个proto,就会比较冗余。通过底层service来实现组件化,可能几个组件对应一个service
  • 如果每个组件都对外提供一个proto,并且将自己注册进入DI也可以实现解耦,但是这样的话,还是会不可避免的一个init会带起一堆组件的init
  • 另外,有些组件频繁的被依赖,其实是因为它提供了某个很基础的功能,这种功能可能每个组件都需要,所以可以抽出来作为基础的service,放在最底层对外暴露,这样避免了频繁的互相依赖,以及可以更完善对外暴露的功能。

比较基础的一些组件,如果要是以之前DI的方式,不抽出成为基础service放在底层库,就会出现比如music模块提供了一个很基础的方法每个组件都有用到,那么每个都要持有一个musiccomponent,如果其他app引用我们的库,他们如果不用这个music组件,必须自己实现一个实现了这个protocol的对象,就但实际上他们只是用它的一个方法,这就很不合理以及麻烦,别的应用引入我们库的时候也很痛苦


Reference:

https://www.jianshu.com/p/921ee8916569
https://www.cnblogs.com/wujy/p/5919105.html

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