组件化-反射方案

1.前言

上一篇文章中分析了组件化的目标和好处,同时梳理了基于URL+Block的路由组件化解决方案的原理、实践和优缺点。本文将继续介绍第二种解决方案:基于反射原理的组件化方案。

2.场景

主页跳转到详情,传入详情页所需的头像;

在详情中简单的修改信息之后,返回主页时更新主页的标题。

3.思路

基于反射原理的组件化方案,其首要目标还是实现各组件之间的通信,同时减少组件间的耦合。其实现原理如下:

组件化-反射方案

3.1.反射类名

利用运行时接口将类名反射成类,如目标组件的类名:

1
Class *targetObj = NSClassFromString(NSString *aClassName);

3.2.反射方法名

将方法名反射成SEL,如目标组件提供的接口:

1
SEL sel = NSSelectorFromString(NSString *aSelectorName);

3.3.调用

通过 [targetObj performSelector:sel withObject:params] 实现与目标组件的通信:

1
- (id)performSelector:(SEL)aSelector withObject:(id)object;

3.4.优化

由于 performSelector 传递的参数个数有限,且返回值只能为id类型,所以可使用 NSInvocation 作为备用方案:

1
2
3
4
5
6
7
8
+ (NSInvocation *)invocationWithMethodSignature:(NSMethodSignature *)sig; // 创建实例

@property (nullable, assign) id target; //设置目标
@property SEL selector; // 设置目标函数

- (void)setArgument:(void *)argumentLocation atIndex:(NSInteger)idx; //设置参数
- (void)invoke; // 发起调用
- (void)getReturnValue:(void *)retLoc; // 获取返回值

4.CTMediator

反射方案利用了运行时的一些接口,如果你熟悉的话完全可以自己去尝试调用这些接口,实现组件间的通信。这里我想介绍的是一个用于实现组件间通信的三方库CTMediator,它正是基于OC的反射机制,帮我们封装了 Target-action 和 NSInvocation 操作。它提供了两种调用组件的方式:本地调用和远程调用,同时提供了其他副产品。这个库对实践基于反射原理的组件化解耦方案提供了很好的帮助,至少你可以拿来做个参考。

4.1.代码

这个库很精炼,只有两个文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// CTMediator.h

@interface CTMediator : NSObject

+ (instancetype)sharedInstance;

// 远程App调用入口
- (id)performActionWithUrl:(NSURL *)url
completion:(void(^)(NSDictionary *info))completion;

// 本地组件调用入口
- (id)performTarget:(NSString *)targetName
action:(NSString *)actionName
params:(NSDictionary *)params
shouldCacheTarget:(BOOL)shouldCacheTarget;

- (void)releaseCachedTargetWithTargetName:(NSString *)targetName;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// CTMediator.m

@implementation CTMediator

/* 远程APP调用入口
scheme://[target]/[action]?[params]
url sample:
aaa://targetA/actionB?id=1234
*/

- (id)performActionWithUrl:(NSURL *)url
completion:(void (^)(NSDictionary *))completion
{
NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
NSString *urlString = [url query];
for (NSString *param in [urlString componentsSeparatedByString:@"&"]) {
NSArray *elts = [param componentsSeparatedByString:@"="];
if([elts count] < 2) continue;
[params setObject:[elts lastObject] forKey:[elts firstObject]];
}

/* 这里这么写主要是出于安全考虑,防止黑客通过远程方式调用本地模块。
这里的做法足以应对绝大多数场景,如果要求更加严苛,也可以做更加复杂的安全逻辑。*/
NSString *actionName = [url.path stringByReplacingOccurrencesOfString:@"/" withString:@""];
if ([actionName hasPrefix:@"native"]) {
return @(NO);
}

/* 这个demo针对URL的路由处理非常简单,就只是取对应的target名字和method名字,
但这已经足以应对绝大部份需求。如果需要拓展,可以在这个方法调用之前加入完整的路由逻辑
*/
id result = [self performTarget:url.host
action:actionName
params:params
shouldCacheTarget:NO];
if (completion) {
if (result) {
completion(@{@"result":result});
} else {
completion(nil);
}
}
return result;
}

// ME:本地组件调用入口
- (id)performTarget:(NSString *)targetName
action:(NSString *)actionName
params:(NSDictionary *)params
shouldCacheTarget:(BOOL)shouldCacheTarget
{
NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];

// 反射target
NSString *targetClassString = nil;
if (swiftModuleName.length > 0) {
targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
} else {
// NOTICE: 就是这里给类名加了前缀!!!
targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
}
NSObject *target = self.cachedTarget[targetClassString];
if (target == nil) {
Class targetClass = NSClassFromString(targetClassString);
target = [[targetClass alloc] init];
}

// 反射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]) {
// NOTICE: 调用 NSInvocation 进行通信
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;
}
}
}

- (id)safePerformAction:(SEL)action
target:(NSObject *)target
params:(NSDictionary *)params
{
NSMethodSignature* methodSig = [target methodSignatureForSelector:action];
const char* retType = [methodSig methodReturnType];

//...
// ME: 对目标组件返回值为非对象类型的情况进行特殊处理

if (strcmp(retType, @encode(NSUInteger)) == 0) {
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
[invocation setArgument:&params atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
NSUInteger result = 0;
[invocation getReturnValue:&result];
return @(result);
}

// ME: 返回值是对象类型则直接走此方法
return [target performSelector:action withObject:params];
}
@end

ps:库的作者提供了详细的注释,// ME:标注的注释是我添加,用来说明其中的业务逻辑。

4.2.本地调用

CTMediator实现了本地调用功能,即“performTarget:action:params:shouldCacheTarget:”接口。

所谓本地调用,就是我们的应用内部组件之间的服务调用,比如主页组件调用详情组件的跳转服务,组件间主要是通过performSelector的方式进行通信,对于返回值为非对象类型的情况则使用NSInvocation进行通信。

[target performSelector:action withObject:params]需要三个主要参数:

  • 当前对象

即示例中的target,在组件化实践中则对应着目标组件服务类的一个实例。它是通过NSClassFromString(@“targetClassString”)接口将字符串类型的类名反射成真实的类,再调用实例化方法创建实例。

  • SEL

即示例中的action,对应的是目标组件服务类对外暴露的服务接口。它是通过NSSelectorFromString(actionString)接口将字符串类型的接口名反射成SEL,供运行时调用。

  • 方法参数

即示例中的params,对应的是目标组件服务类接口中的参数。由于performSelector能传递的参数有限,此库要求外部调用者将多个参数包装在一个字典容器中,再由库内部将此参数传递给SEL。

4.3.远程调用

CTMediator还提供了远程调用接口,即库中提供的“-performActionWithUrl:completion:”接口。

所谓远程调用,就是别的应用向我们的应用发起的服务调用,大致过程如下:

  • A应用通过[[UIApplication sharedApplication] openURL:xxx]传递一个指定格式的URL到B应用;
  • B应用在-application:openURL:回调中接收URL,再调用库中的performActionWithUrl开始处理此URL;
  • performActionWithUrl中对URL中的参数进行解析之后,再通过performTarget调用本地组件提供服务。

远程调用时传递的URL一定要按照约定的格式拼接,开发者应按照自己的规范自定义库文件中远程调用的相关逻辑;

基于URL的远程调用有个缺点,即URL中参数的类型将会受到限制,如传递图片、数据、block等类型时将很不方便。

5.示例

着手实现章节开始时提出的场景需求:从主页组件跳转到详情组件;返回时更新主页组件的标题。

5.1.目标组件

详情组件视图控制器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@interface DetailViewController : UIViewController
@property (nonatomic, strong) NSDictionary *params; // 接收外部组件传进来的参数
@end

#import "DetailViewController.h"

@interface DetailViewController ()
@property (weak, nonatomic) IBOutlet UIImageView *mIconImv;
@property (nonatomic, strong) UIImage *image;
@property (nonatomic, copy) DetailCallback callBack; // 回调block
@end

@implementation DetailViewController

- (void)viewDidLoad {
[super viewDidLoad];

// 获取参数
self.mIconImv.image = self.params[k_Key_Image];
self.callBack = self.params[k_Key_Block];

UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
[btn setTitle:@"返回" forState:UIControlStateNormal];
[btn addTarget:self action:@selector(onBackWithBlock) forControlEvents:UIControlEventTouchUpInside];
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:btn];
}

- (void)onBackWithBlock{
self.callBack(); //回传信息
[self.navigationController popViewControllerAnimated:YES];
}
@end

这是详情所在的视图控制器,其中定义了用于接收外部图片的属性image和处理回调的callBack闭包。视图加载时显示外部传进来的图片;退出详情页时回调callBack闭包以通知上层调用者处理自己的业务。

5.2.暴露接口

详情组件对外提供服务的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface Target_DetailViewController : NSObject
- (UIViewController*)Action_showDetail:(NSDictionary*)paramDic;
@end

#import "Target_DetailViewController.h"
// 导入详情原类
#import "DetailViewController.h" // 服务类可依赖组件原类

@implementation Target_DetailViewController

- (UIViewController *)Action_showDetail:(NSDictionary *)paramDic{

// 当前为业务实现层 属于组件的一部分,可以引用目标VC
DetailViewController *controller = [[UIStoryboard storyboardWithName:@"Main" bundle:nil]
instantiateViewControllerWithIdentifier:@"DaDetailViewController"];
// 给目标传值
controller.params = paramDic;

return controller;
}
@end

一些细节:

  • 详情组件对外提供的所有服务,都在这里定义和实现;
  • 在此服务类的头文件中将服务接口暴露给外界;
  • 此类是详情组件的一部分,可以导入和依赖详情原类;
  • 注意上面的命名方式,类名前缀为Target_,而方法名前缀为Action_

关于最后一条,这是因为接下来使用到的通信类三方库CTMediator中做了特殊处理,所以我们需要按照通信层的要求进行命名,当然,你也可以按照自己的规范在原库中将Target_Action_修改为你自己喜欢的前缀。

5.3.通信类

中间通信层,这是我们在三方库CTMediator基础上自定义的一个分类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#import "CTMediator.h"

@interface CTMediator (Detail)
/**
详情中间通信类对外提供的接口
@param paramDic 参数
@return 返回值
*/
- (UIViewController*)CTDetail:(NSDictionary*)paramDic;
@end

#import "CTMediator+Detail.h"

@implementation CTMediator (Detail)

- (UIViewController *)CTDetail:(NSDictionary *)paramDic{

/* 调用CTMediator库的“performTarget”方法;
使用字符串,结合反射机制,从而避免直接引用目标类,减少依赖
*/
return [self performTarget:@"DetailViewController"
action:@"showDetail"
params:paramDic
shouldCacheTarget:NO];
}
@end

-CTDetail:是详情中间通信类暴露的一个接口,其他组件想调用详情组件的服务时从这里找即可~

单独提供这么一层,而非在调用详情组件服务的地方直接执行perform方法,主要是因为这里的类名和方法名都是明文,在没有代码补全功能的情况下,让别的组件的开发者每次调用时都自己写一遍极有可能会出错,也很不方便。这一层对这些操作进行了封装,外部调用者就不用再为此挠头。

另外这里的明文类名和方法名并没带前一小节提到的前缀,三方库CTMediator中会自动追加。

5.4.调用服务

在主页组件中调用详情组件的服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#import "CTMediator+Detail.h"

@implementation HomeViewController

- (IBAction)onShowDetail:(id)sender {
UIButton *btn = sender;

// 声明回调 从详情页面返回当前页面时更新标题
DetailCallback block = ^(){
self.title = @"Info Checked";
};

// 通过中间层调用别的组件,传递参数
NSDictionary *params = @{k_Key_Image : btn.currentBackgroundImage, k_Key_Block : block};
UIViewController *controller = [[CTMediator sharedInstance] CTDetail:params];
if (controller) {
[self.navigationController pushViewController:controller animated:YES];
}
}
@end

在本次调用中,主页组件中并未直接引用详情组件的头文件,只是引用了详情组件的中间通信层CTMediator+Detail.h。主页组件最终通过详情中间通信层提供的接口实现了对详情组件的通信或者说服务的调用。

6.目录结构

整个工程的目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
.
├── AppDelegate.h
├── AppDelegate.m
├── Assets.xcassets
│   ├── AppIcon.appiconset
│   │   └── Contents.json
│   └── Contents.json
├── Base.lproj
│   ├── LaunchScreen.storyboard
│   └── Main.storyboard
├── Defines.h
├── Detail
│   ├── Controllers
│   │   ├── DetailViewController.h
│   │   └── DetailViewController.m
│   ├── Mediator
│   │   ├── CTMediator+Detail.h
│   │   └── CTMediator+Detail.m
│   └── Module+API
│   ├── Target_DetailViewController.h
│   └── Target_DetailViewController.m
├── Home
│   ├── HomeViewController.h
│   └── HomeViewController.m
├── Icons
│   ├── 1@2x.png
│   └── 1@3x.png
├── Info.plist
└── main.m

7.流程总结

  • 定义目标组件;
  • 定义目标组件的对外服务类和接口;
  • 定义目标组件的中间通信层;
  • 发起者与目标组件的中间通信层交互,调用目标组件的接口;
  • 中间通信层调用CTMediator,通过运行时接口实现通信;

8.方案评价

优点:

  • 巧妙的利用了运行时接口,通过反射机制完成组件间的通信和解耦;
  • 组件之间不再引用对方头文件,而是通过中间通信层进行通信;
  • 调用方组件只单向依赖了像CTMediator+Detail分类这样的中间通信层;
  • 各大组件之间没有耦合,只以中间层为中心与其他组件进行通信,这种思想值得肯定。

缺点:

  • 中间通信层中需要明文书写类名和方法名,这种硬编码没有代码补全的辅助,极可能出错且编译时不易察觉;
  • 当调用的服务接口中需要传递实体类作为参数时,还需导入对方组件中实体类的头文件,还是有依赖;
  • 受反射机制的限制,本地调用时通信层中接收的参数个数有限,所以只能以字典等容器的方式传值,不够灵活方便;
  • 远程调用时,URL中参数的类型也会受到限制,只适合用来进行应用内部页面跳转或别的App调起我们本地的页面;

9.结尾

整体来看,基于反射原理的组件化解决方案还是不够完美,后续会继续介绍面向协议的服务注册组件化方案,待续~


相关参考:

#©反射方案demo

#©URL+Block方案demo

#©服务协议注册方案demo

#©有赞Bifrost


组件化-反射方案
https://davidlii.cn/2020/11/12/modules-runtime.html
作者
Davidli
发布于
2020年11月12日
许可协议