组件化页面跳转及路由方案

前言

组件化页面路由算是个老生常谈的话题了,从蘑菇街的注册 url 到 Casa 的 target-action 方案,市面上也有了很多的优化方案。之所以出这篇文章主要是因为已有方案中的硬编码操作一直不被团队接受,所以一直在寻找更合理的方案,直到不久前从前前同事那得到的思路:注册 protocol 与 viewcontroller 的对应关系,根据 protocol 获取 VC 并调用协议方法。再加上在前公司做过的注册 url 方案,所以打算出篇文章,结合这两种思路总结一套适合当前工程的可行性路由方案。

需求

之所以文章标题叫做:页面跳转 及 路由 方案,主要是对应组件化工程中常见的两大需求:

1、组件间页面跳转解耦:PodA 中的 VC1 想要跳转到 PodB 中的 VC2,但两个组件又不能相互依赖。
2、后台可配的页面路由:页面存在跳转入口,根据后台配置不同的 url 跳往不同的落地页。

应对第一种需求,只要实现简单的解耦即可,正如前言里提到的,通过注册 protocol 与 viewcontroller 的键值对,根据 protocol 获取 VC 并调用协议方法然后 push 即可实现,剩下的就是怎么更合理的注册与取值的问题了。

应对第二种需求,其实可以通过第一种需求的解决方案来实现,只不过那需要大量的 if-else 来判断,并且需要一个中心化的转发类,不管从写法上还是可维护性上都不是最佳方案。
参照上一种解决方案,同样可以提前注册 url 与 viewcontroller 的键值对,根据 url 获取 VC 然后 push,至于传参,将 url 中的参数取出作为 NSDicitionary 传递给 VC 即可,具体实现下面会讲。相比于 if-else 的判断,这种注册 url 的方式好处是:某一 url 的路由支持交给各个组件自己支持,参数的接收也有目标 VC 来控制。

以上,总结出本方案的中心规律:谁提供、谁维护。意思是说,VC 所属于哪个 Pod,那这个 Pod 就有义务为这个 VC 支持 protocol 跳转和 url 跳转,也就是负责注册操作。如果这个 Pod 没有为 VC 进行注册操作,也就没打算将此 VC 对外提供。

预览

期望实现的用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1、protocol 方式支持页面跳转
// 提供方提前注册 protocol
[[HoloTarget sharedInstance] registTarget:ViewController.class withProtocol:@protocol(ViewControllerProtocol)];

// 调用方根据 protocol 获取 vc 跳转
UIViewController *vc = [HoloNavigator matchViewControllerWithProtocol:@protocol(ViewControllerProtocol)];
[(UIViewController<ViewControllerProtocol> *)vc viewController:@"title"];
[self.navigationController pushViewController:vc animated:YES];


// 2、url 方式支持页面路由
// 提供方提前注册 url
[[HoloTarget sharedInstance] registTarget:ViewController.class withUrl:@"holo://demo/vc?a=1&b=2"];

// 调用方根据 url 获取 vc 跳转
UIViewController *vc = [HoloNavigator matchViewControllerWithUrl:@"holo://demo/vc?a=1&b=2"];
[self.navigationController pushViewController:vc animated:YES];
// vc 内部获取入参
NSDictionary *params = [HoloNavigator matchUrlParamsWithViewController:vc];
NSLog(@"vc params:%@", params);

实现

HoloTarget

主要在 HoloTarget 这个核心类来实现,分别为 protocol 和 url 提供了注册和匹配接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// 根据 protocol 注册 target 类
- (BOOL)registTarget:(Class)target withProtocol:(Protocol *)protocol;

/// 根据 url 注册 target 类
- (BOOL)registTarget:(Class)target withUrl:(NSString *)url;


/// 根据 protocol 匹配 target 类
- (nullable Class)matchTargetWithProtocol:(Protocol *)protocol;

/// 根据 url 匹配 target 类
- (nullable Class)matchTargetWithUrl:(NSString *)url;

/// 根据 protocol 匹配 target 实例
- (nullable id)matchTargetInstanceWithProtocol:(Protocol *)protocol;

/// 根据 url 匹配 target 实例
- (nullable id)matchTargetInstanceWithUrl:(NSString *)url;

.m 的实现很简单, HoloTarget 单例维护了一个 <NSString *, Class> 类型的字典而已,核心代码很简单,大部分代码都是做安全判断的,就不贴代码了,细节在 Demo(HoloTarget.m) 里看吧。

特殊注意的一点是,注册和匹配 url 的时候,是通过 NSString+HoloTargetUrlParser 这个分类获取 path 进行注册的。

HoloNavigator

HoloNavigator 这个类是对 HoloTarget 的封装,提供了 macth viewcontroller 的功能:

1
2
3
4
5
6
7
8
/// 根据 protocol 匹配 viewController 实例
+ (nullable UIViewController *)matchViewControllerWithProtocol:(Protocol *)protocol;

/// 根据 url 匹配 viewController 实例
+ (nullable UIViewController *)matchViewControllerWithUrl:(NSString *)url;

/// 根据 viewController 匹配 url 入参
+ (nullable NSDictionary *)matchUrlParamsWithViewController:(UIViewController *)viewController;

其中在 matchViewControllerWithUrl: 方法里做了 scheme 判断,如果是 http 或者 https 的话,判断代理是否返回 web viewcontroller

并且 HoloTarget 设置过 businessScheme 的话,对 url scheme 加了强校验,只有符合 business scheme 时才继续匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+ (nullable UIViewController *)matchViewControllerWithUrl:(NSString *)url {
NSString *scheme = [url holo_targetUrlScheme];

// web viewcontroller
if ([scheme.lowercaseString isEqualToString:@"http"] || [scheme.lowercaseString isEqualToString:@"https"]) {
if ([HoloTarget sharedInstance].delegate && [[HoloTarget sharedInstance] respondsToSelector:@selector(holo_matchWebViewControllerWithUrl:)]) {
UIViewController *webVC = [[HoloTarget sharedInstance].delegate holo_matchWebViewControllerWithUrl:url];
return webVC;
}
}

// 约定必须遵守 business scheme 才可进行路由
if ([HoloTarget sharedInstance].businessScheme.length > 0 && ![scheme isEqualToString:[HoloTarget sharedInstance].businessScheme]) {
return nil;
}

......
}

使用姿势

1、 protocol pool

创建 protocol pool 组件,被所有提供方和调用方依赖,或者将 HoloTarget 集成进当前组件化工程,创建 protocol pool 文件夹管理所有的 protocol。

2、url 入参取值

通过 url 匹配到 vc 的原理是获取 url 里的 path 进行匹配,获取到 vc 后,会将 url 里的 params 与 vc 绑定存储起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ (nullable UIViewController *)matchViewControllerWithUrl:(NSString *)url {
id target = [[HoloTarget sharedInstance] matchTargetInstanceWithUrl:url];
if ([target isKindOfClass:UIViewController.class]) {
NSDictionary *params = [url holo_targetUrlParams];
if (params) {
objc_setAssociatedObject(target, &KHoloNavigatorParamsKey, params, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
return target;
}

......
}

+ (NSDictionary *)matchUrlParamsWithViewController:(UIViewController *)viewController {
return objc_getAssociatedObject(viewController, &KHoloNavigatorParamsKey);
}

所以可以在 vc 的 viewDidLoad 方法内获取 params 做入参处理:

1
2
3
4
5
6
7
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.

NSDictionary *params = [HoloNavigator matchUrlParamsWithViewController:self];
NSLog(@"params:%@", params);
}

3、Target for Pod

如果一个 protocol 对应一个 viewcontroller,而当前 Pod 对外提供的 viewcontroller 又比较多的话,可以为 Pod 创建一个 MainTarget 类,进行 MainTarget - MainTargetProtocol 一次注册,在 MainTarget 类里转发对组件内各个 vc 的调用,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1、提供 MainTarget 类
@interface MainTarget : NSObject <MainTargetProtocol>

- (UIViewController *)viewController1WithTitle:(NSString *)title;
- (UIViewController *)viewController2WithTitle:(NSString *)title;
- (UIViewController *)viewController3WithTitle:(NSString *)title;

@end


// 2、使用举例

// 提供方提前注册(protocol)
[[HoloTarget sharedInstance] registTarget:MainTarget.class withProtocol:@protocol(MainTargetProtocol)];

// 调用方获取 vc 跳转(protocol)
id mainTarget = [[HoloTarget sharedInstance] matchTargetInstanceWithProtocol:@protocol(MainTargetProtocol)];

UIViewController *vc1 = [mainTarget<MainTargetProtocol> viewController1WithTitle:@"title1"];
UIViewController *vc2 = [mainTarget<MainTargetProtocol> viewController1WithTitle:@"title2"];
UIViewController *vc3 = [mainTarget<MainTargetProtocol> viewController2WithTitle:@"title3"];

4、HoloLifecycle 提供注册时机

对 protocol 和 url 注册操作应该在哪里?这么做?根据 谁提供、谁维护 的原则,应当由各个 Pod 自己负责自己 vc 的注册操作。之前发过的一篇文章 组件化分发生命周期 介绍了 HoloLifecycle 这个工具,正好满足了当下的需求。

为每个 Pod 创建 Lifecycle 类,将主工程 AppDelegate 的生命周期分发给各个 Pod,在 Lifecycle 类里完成 target 的注册操作(protocol & url)。

5、YAML 提供注册时机

除了通过 HoloLifecycle 手动注册,HoloTarget 还提供了通过 YAML 配置文件自动注册的操作,在 Build Phases 添加 holo_target_generator.rb 脚本的执行(注意脚本路径改成脚本最终所在的位置):

该脚本的目的是收集各个 Pod 内的 holo_target.yaml 配置文件。脚本内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env ruby

require 'yaml'

product_path = "#{ENV["BUILT_PRODUCTS_DIR"]}/#{ENV["PRODUCT_NAME"]}.app"

target_files = Dir.glob("#{product_path}/**/holo_target.{yaml,yml}")

all_targets = target_files.map do |path|
YAML.load(File.open(path))
end.reduce(:merge)

File.open "#{product_path}/.HOLO_ALL_TARGETS.yaml", "w" do |file|
file.write(all_targets.to_yaml)
end

调用 HoloTarget.h 类的 registAllTargetsFromYAML 方法进行收集注册,具体实现参考 HoloTarget.m#L28-L80

1
2
3
4
5
6
7
8
- (void)registAllTargetsFromYAML {
NSString *path = [[NSBundle mainBundle] pathForResource:@".HOLO_ALL_TARGETS" ofType:@"yaml"];
NSInputStream *stream = [[NSInputStream alloc] initWithFileAtPath:path];
NSDictionary *yaml = [YAMLSerialization objectWithYAMLStream:stream
options:kYAMLReadOptionStringScalars
error:nil];
......
}

然后在各个 Pod 内部创建 holo_target.yaml 配置文件。

注意1:配置文件名称必须是 holo_target.yaml

注意2:组件内的 holo_target.yaml 配置文件不要放在 Main Bundle 内,会被互相覆盖。

holo_target.yaml 配置文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Requirement: name your file holo_target.yaml
# example

HoloViewController:
urls:
- holo/vc
protocols:
- HoloViewControllerProtocol

HoloViewController2:
urls:
- holo/vc1
- holo/vc2
protocols:
- HoloViewControllerProtocol1
- HoloViewControllerProtocol2

# etc ...

选择 YAML 格式的主要原因是因为该格式支持添加注释,而且写法也比 JSON 简洁很多,是很多配置文件的首选格式,注意 YAML 使用缩进表示层级关系,参考:YAML 语言教程

安全防护

通过 protocol 获取 vc 并调用方法时,如:

1
2
3
UIViewController *vc = [HoloNavigator matchViewControllerWithProtocol:@protocol(ViewControllerProtocol)];
[(UIViewController<ViewControllerProtocol> *)vc viewController:@"title"];
[self.navigationController pushViewController:vc animated:YES];

如果该 vc 未实现 viewController: 方法的话程序就会崩溃,虽然 HoloTarget 在注册时做了 taeget 是否遵守 protocol 的判断,如:

1
2
3
4
5
6
7
8
- (BOOL)registTarget:(Class)target withProtocol:(Protocol *)protocol {
......
} else if (![target conformsToProtocol:protocol]) {
HoloLog(@"[HoloTarget] Regist failed because the target (%@) is not conform to the protocol (%@).", target, protocolString);
isSuccess = NO;
}
......
}

但正常经常下,vc 如果遵守了协议但未实现方法的话也只有个警告而已。当然,你可以通过加 respondsToSelector: 判断这种最简单的方式解决这个 crash 隐患:

1
2
3
if ([vc respondsToSelector:@selector(viewController:)]) {
[(UIViewController<ViewControllerProtocol> *)vc viewController:@"title"];
}

HoloTarget 为了省掉这个 if 判断,最开始创建了 NSObject+HoloTargetUnrecognizedSelector 分类,想要防护所有类的 unrecognized selector 崩溃:

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
@implementation HoloTargetStubProxy

+ (BOOL)resolveInstanceMethod:(SEL)sel {
class_addMethod([self class], sel, imp_implementationWithBlock(^{
HoloLog(@"[HoloTarget] Unrecognized selector (%@)", NSStringFromSelector(sel));
}), "v@:");
return YES;
}

@end

@implementation NSObject (HoloTargetUnrecognizedSelector)

+ (void)load {
[self jr_swizzleClassMethod:@selector(forwardingTargetForSelector:) withClassMethod:@selector(_holo_forwardingTargetForSelector:) error:nil];
}

- (id)_holo_forwardingTargetForSelector:(SEL)aSelector {
// Aspects hook 会有问题:动态交换了 forwardInvocation: 方法 (aspect_swizzleForwardInvocation)
// 问题:怎么判断某个方法被 hook 过了 ?
// 判断 'forwardingTargetForSelector:'、'resolveInstanceMethod:'、'forwardInvocation:' 被处理过了的话,直接 return nil.

// 自己要处理的话, 直接返回
if ([self _holo_methodHasOverwrited:@selector(resolveInstanceMethod:) cls:self.class]) {
return nil;
}
if ([self _holo_methodHasOverwrited:@selector(forwardInvocation:) cls:self.class]) {
return nil;
}
return [HoloTargetStubProxy new];
}

// 判断 cls 是否重写了 sel 方法, 递归调用判断父类但不包括 NSObject
- (BOOL)_holo_methodHasOverwrited:(SEL)sel cls:(Class)cls {
......
}

@end

正如注释所写的,暂时没办法获取到 forwardInvocation: 方法是否被交换过,如果直接在 forwardingTargetForSelector: 方法里转发的话肯定会有问题。比如被 Aspects hook 过的方法,其实是将被 hook 的对象 isa 指向了动态创建的类,被 hook 的方法执行时进入消息转发流程,并且 Aspects 交换了 forwardInvocation: 方法在新的 forwardInvocation: 方法里执行了被 hook 方法。

所以如果在以上 _holo_forwardingTargetForSelector: 方法里无脑转发给 [HoloTargetStubProxy new] 对象的话那被 Aspects hook 的方法就是失效了。如果有方法能够判断某个方法是否被交换过就好了,这样就可以做判断:如果当前类及父类重写或交换了 resolveInstanceMethod: 方法、forwardInvocation: 方法,直接 return nil

如果你有方法能够判断某个方法是否被交换过,请告知我,谢谢!

以上方案走不通的话,那可以在工程中添加 OCLint 规则,判断当前类遵守了协议但未实现协议方法的话直接报错,编译失败。

当前 HoloTarget 也提供了备选方案,创建了 HoloBaseTargetHoloBaseTargetViewController 分别继承了 NSObjectUIViewController 的两个类,在这两个类的里重写了 resolveInstanceMethod: 方法,做了安全防护:

1
2
3
4
5
6
+ (BOOL)resolveInstanceMethod:(SEL)sel {
class_addMethod([self class], sel, imp_implementationWithBlock(^{
HoloLog(@"[HoloTarget] -[%@ %@]: unrecognized selector.", self.class, NSStringFromSelector(sel));
}), "v@:");
return YES;
}

让将要注册的 target 继承这两个类就可以了。

以上,就是 HoloTarget 的所有内容了

项目地址:https://github.com/HoloFoundation/HoloTarget

isa-swizzling (更新于 2020.7.18)

之前也考虑过这种方案,参考 KVO 的实现原理,通过 isa-swizzling 技术,动态创建一个新的类,将目标对象的 isa 指针指向这个新的类,对新的类做安全保护。但因为之前的思路一直局限于通过 isa-swizzling 保护对象,因为 HoloTarget 还提供了获取 Class 接口,所以一开始并没有选用这种方案。(HoloTarget 提供了 match Target Class 的接口,通过获取 Class 可以调用 init 的方法)

现在看来的确是之前的思路狭隘了,包括前文提到的:判断 resolveInstanceMethod: 方法、forwardInvocation: 方法等是否被处理过,其实并不需要,我们只需要关心 forwardInvocation: 方法即可,因为如果消息转发的前几步就被特殊处理过了,就不会走到 forwardInvocation: 方法了,而被特殊处理过的对象及类,HoloTarget 就不再关心了。

所以,最终的解决方案是:

1、保护对象:

1.1、根据目标对象的类动态创建一个新的子类
1.2、将目标对象的 isa 指针指向这个新的类
1.3、交换这个新的类的 forwardInvocation: 方法为自己的 holo_forwardInvocation 方法,只要这个自己的方法不再继续向下走消息转发流程,就不会崩溃了。
(注意要保证方法只被交互一次)

2、保护类:

将类的 forwardInvocation: 方法交换为自己的 holo_forwardInvocation 方法
(注意要保证方法只被交互一次)

方便的是,以上步骤就是 Aspects 里的一部分逻辑,直接将这部分逻辑单独拷贝出来应用即可:

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
static NSString *const HoloTargetSubclassSuffix = @"_holoTarget_";
static NSString *const HoloTargetForwardInvocationSelectorName = @"__holoTarget_forwardInvocation:";

@implementation NSObject (HoloTargetUnrecognizedSelector)

// 判断 cls 是否重写了 sel 方法, 递归调用判断父类但不包括 NSObject
static BOOL holo_methodHasOverwrited(Class cls, SEL sel) {
unsigned int methodCount = 0;
Method *methods = class_copyMethodList(cls, &methodCount);
for (int i = 0; i < methodCount; i++) {
Method method = methods[i];
if (method_getName(method) == sel) {
free(methods);
return YES;
}
}
free(methods);

if ([cls superclass] != [NSObject class]) {
return holo_methodHasOverwrited([cls superclass], sel);
}
return NO;
}

static Class holoTarget_hookClass(NSObject *self) {
NSCParameterAssert(self);
Class statedClass = self.class;
Class baseClass = object_getClass(self);
NSString *className = NSStringFromClass(baseClass);

// Already subclassed
if ([className hasSuffix:HoloTargetSubclassSuffix]) {
return baseClass;

// We swizzle a class object, not a single object.
} else if (class_isMetaClass(baseClass)) {
return holoTarget_swizzleClassInPlace((Class)self);
// Probably a KVO'ed class. Swizzle in place. Also swizzle meta classes in place.
} else if (statedClass != baseClass) {
return holoTarget_swizzleClassInPlace(baseClass);
}

// Default case. Create dynamic subclass.
const char *subclassName = [className stringByAppendingString:HoloTargetSubclassSuffix].UTF8String;
Class subclass = objc_getClass(subclassName);

if (subclass == nil) {
subclass = objc_allocateClassPair(baseClass, subclassName, 0);
if (subclass == nil) {
HoloLog(@"[HoloTarget] objc_allocateClassPair failed to allocate class %s.", subclassName);
return nil;
}

holoTarget_swizzleForwardInvocation(subclass);
holoTarget_hookedGetClass(subclass, statedClass);
holoTarget_hookedGetClass(object_getClass(subclass), statedClass);
objc_registerClassPair(subclass);
}

object_setClass(self, subclass);
return subclass;
}

static Class holoTarget_swizzleClassInPlace(Class klass) {
NSCParameterAssert(klass);
NSString *className = NSStringFromClass(klass);

holoTarget_modifySwizzledClasses(^(NSMutableSet *swizzledClasses) {
if (![swizzledClasses containsObject:className]) {
holoTarget_swizzleForwardInvocation(klass);
[swizzledClasses addObject:className];
}
});
return klass;
}

static void holoTarget_modifySwizzledClasses(void (^block)(NSMutableSet *swizzledClasses)) {
static NSMutableSet *swizzledClasses;
static dispatch_once_t pred;
dispatch_once(&pred, ^{
swizzledClasses = [NSMutableSet new];
});
@synchronized(swizzledClasses) {
block(swizzledClasses);
}
}

static void holoTarget_swizzleForwardInvocation(Class klass) {
NSCParameterAssert(klass);
// If there is no method, replace will act like class_addMethod.
IMP originalImplementation = class_replaceMethod(klass, @selector(forwardInvocation:), (IMP)__holoTarget_forwardInvocation__, "v@:@");
if (originalImplementation) {
class_addMethod(klass, NSSelectorFromString(HoloTargetForwardInvocationSelectorName), originalImplementation, "v@:@");
}
}

static void holoTarget_hookedGetClass(Class class, Class statedClass) {
NSCParameterAssert(class);
NSCParameterAssert(statedClass);
Method method = class_getInstanceMethod(class, @selector(class));
IMP newIMP = imp_implementationWithBlock(^(id self) {
return statedClass;
});
class_replaceMethod(class, @selector(class), newIMP, method_getTypeEncoding(method));
}

static void __holoTarget_forwardInvocation__(__unsafe_unretained NSObject *self, SEL selector, NSInvocation *invocation) {
HoloLog(@"[HoloTarget] *** Caught exception 'NSInvalidArgumentException', reason: '-[%@ %@]: unrecognized selector sent to instance %p'", [self class], NSStringFromSelector(selector), self);

if ([HoloTarget sharedInstance].delegate &&
[[HoloTarget sharedInstance].delegate respondsToSelector:@selector(holo_unrecognizedSelectorSentToTarget:selector:)]) {
[[HoloTarget sharedInstance].delegate holo_unrecognizedSelectorSentToTarget:self selector:selector];
}
}

+ (void)holo_protectUnrecognizedSelector {
if (holo_methodHasOverwrited(self, @selector(forwardInvocation:))) {
return;
}
holoTarget_hookClass((id)self);
}

- (void)holo_protectUnrecognizedSelector {
if (holo_methodHasOverwrited(self.class, @selector(forwardInvocation:))) {
return;
}
holoTarget_hookClass(self);
}

@end

其实 HoloTarget 里只保护了类,因为 matchTargetInstance 方法也是直接拿到的类执行了 new 方法。
因为加了 holo_methodHasOverwrited 方法判断,类的 forwardInvocation: 方法是否被交换过也提前判断了。