如何自己实现一个 UIAppearance

💡这是字节面试出的题目,当时连UIAppearance怎么使用的都不知道,没回答出来,感觉愧对江东父老,特反省一下,写一个 demo 大家看看对不对

代码地址可查看:https://github.com/irobbin1024/UIAppearance-demo

一. UIAppearance是什么

UIAppearance是一个可以给 App 进行定制主题的工具

传统上,如果我们要全局改变按钮的样式,例如背景色改为灰色,还是比较麻烦的,可以采取的方式,例如写一个 UIButton 的子类,在子类中进行变更。使用UIAppearance就比较简单了。看一下它的 API

1
[[UIButton appearance] setBackgroundColor:[UIColor grayColor] forState:UIControlStateNormal];

如上所示,一行代码就搞定了,我们要做的就是这个效果。

接下来看一下我的 API

1
[[MyView xy_appearance] setBackgroundColor:[UIColor redColor]];

二. 分析要点

要实现UIAppearance的效果,有两点要注意的

第一就是针对目标 API 的调用,例如setBackgroundColor,我们可以换成任意的一个属于UIButton的方法,例如setAlpha。

第二就是我们在配置好效果之后,到底怎么应用到 UI 上,这里我们不讨论系统到底是怎么实现的,根据我的思路就是hook UIView 的 addSubView:方法,在添加之后调用刚才配置的方法即可

三. 具体的实现

1. API 调用

这里我们采用欺骗的方式来实现,xy_appearance方法会返回一个 instanceType,对于编译器来说,他会认为返回的是一个MyView的实例,我们也就可以调用任何 MyView 的方法了,顺便说一下,MyView 是一个 UIView 子类

首先,我们定义一个协议XYUIAppearance,里面是xy_appearance方法,MyView 实现这个协议

在外部调用 MyView 的方法,例如setBackgroundColor的时候,我们将方法保存起来,在添加到父类的时候进行调用。

为了让这一步更简单,我使用了 NSProxy,NSProxy是一个代理类,可以实现对其方法调用的捕获,再转发给被代理的对象。我们可以在捕获之后,保存起来

接下来是具体的代码

XYUIAppearance.h 👇

1
2
3
4
5
@protocol XYUIAppearance <NSObject>

+ (instancetype)xy_appearance;

@end

MyView.h 👇

1
2
3
@interface MyView : UIView<XYUIAppearance>

@end

MyView.m👇

1
2
3
4
5
6
7
@implementation MyView

+ (instancetype)xy_appearance {
return (MyView *)[XYProxy proxyWithTarget:self];
}

@end

XYProxy.h👇

1
2
3
4
5
6
7
@interface XYProxy : NSProxy

@property (nullable, nonatomic, strong) id target;

+ (instancetype)proxyWithTarget:(id)target;

@end

XYProxy.m👇

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 XYProxy ()

@property (nonatomic, copy) NSString * className;

@end

@implementation XYProxy

- (instancetype)initWithTarget:(id)target {
_target = target;
self.className = NSStringFromClass([(NSObject *)target class]);
return self;
}

+ (instancetype)proxyWithTarget:(id)target {
return [[XYProxy alloc] initWithTarget:target];
}

- (id)forwardingTargetForSelector:(SEL)selector {
return nil;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
void *null = NULL;
// 获取方法之后,进行保存
[[XYAppearanceManager instance] appendWithClassName:self.className invocation:invocation];
// [invocation setReturnValue:&null];
}

// ... 省略了其他代码

@end

2. 应用效果

这里就比较简单了,首先 hook UIView 的 addSubView 方法,然后找到对应的 NSInvocation 进行调用。

我这里写了一个类XYAppearanceManager完成NSInvocation保存任务,一个 UIView 的分类来完成 hook 的任务

可以看一下具体的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)hook_addSubview:(UIView *)view {
// 在这里可以添加你自定义的代码,对addSubview方法进行hook操作
NSLog(@"Hooked addSubview: %@", view);

// 调用原始的addSubview方法
[self hook_addSubview:view];

NSInvocation * invocation = [[XYAppearanceManager instance].invocationMap objectForKey:NSStringFromClass(view.class)];
if (invocation) {
invocation.target = view;
[invocation invoke];
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@implementation XYAppearanceManager

+ (instancetype)instance {
static XYAppearanceManager * obj = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
obj = [[self alloc] init];
});
return obj;
}

- (NSMutableDictionary<NSString *,NSInvocation *> *)invocationMap {
if (_invocationMap == nil) {
_invocationMap = [NSMutableDictionary dictionary];
}
return _invocationMap;
}

- (void)appendWithClassName:(NSString *)calssName invocation:(NSInvocation *)invocation {
self.invocationMap[calssName] = invocation;
}

@end