在移动互联网的下半场,愈来愈多的 APP 更加注重用户体验,以期来打动用户。主题的切换就是能够加强用户体验、结合运营活动的一个点:譬如 QQ 的夜间模式,节日里电商 APP 的皮肤切换等等的这些小细节每每就是赢得用户尊重的根本。本文将从主题的动态切换出发,介绍下贝聊 iOS 客户端在实现主题动态所采用的方案,供读者参考。安全
让 APP 已有的控件能切换主题能够用子类化,swizzle 或 category 来实现,其中子类化和 category 实现起来差很少,都是让控件调特定的方法达到切换风格的效果,而 swizzle 的影响范围会比较广,使用的时候能够经过 Associated Object 添加一个标记值,让须要切换风格的控件设置这个标记值,让标记值来决定是否须要 swizzle。考虑到上述几种方案的复杂度,最后选择了 category 来实现。bash
@implementation UILabel (BLTheme)
- (void)bl_setThemeTextColor {
NSString *hexColorString = [BLThemeManager sharedInstance].styleModel.navTextColor;
UIColor *color;
if (hexColorString) {
color = [UIColor colorFromHexString:hexColorString];
}
if (color) {
[self setTextColor:color];
}
}
@end复制代码
简单说来就是经过已配置的描述文件,在 category 内部读取了下配置的样式数据(样式数据可能为默认样式或自定义样式)。ide
主题管理类的核心功能就是负责主题的更新,切换。正以下图所示,想让主题管理类通知到这么多待切换的 category 并非一件容易的事,由于以为在 category 上添加观察者并非太好的设计,你很难知道什么时机该把观察者移除了。ui
难道说得改为子类化的实现,而后 ovrride dealloc 方法,惋惜这样作感受就没那么纯粹了。atom
这也就意味着,可能须要本身动手来实现回调机制了,让切换主题相关的 category 经过主题管理类注册一个回调 block,主题类维护使用一个字典维护这些 block,待切换时由主题管理类统一回调,达到相似 Notification 的效果。spa
先来看看 categroy 使用此方案后的变化,仍然是刚才的 UILabel 类:设计
- (void)bl_setThemeTextColor {
NSString *hexColorString = [BLThemeManager sharedInstance].styleModel.navTextColor;
UIColor *color;
if (hexColorString) {
color = [UIColor colorFromHexString:hexColorString];
}
if (color) {
[self setTextColor:color];
}
@weakify(self)
SwitchThemeBlock switchThemeBlock = ^{
@strongify(self)
[self bl_setThemeTextColor];
};
[[BLThemeManager sharedInstance] addObserveKey:[self keyWithSelector:@selector(bl_setThemeTextColor)] withSwitchThemBlock:switchThemeBlock];
}复制代码
只是在方法的底部添加了注册 block 的方法,而注册 block 的方法也十分简单,只需依据 key 判断下是否须要将 block 加入代码中。指针
// BLThemeManager.m
- (void)addObserveKey:(BLThemeMapModel *)key withSwitchThemBlock:(SwitchThemeBlock)block {
if (block) {
NSArray *allKeys = [self.blockDictionary allKeys];
if (![allKeys containsObject:key]) {
self.blockDictionary[key] = block;
}
}
}复制代码
那么问题来了,到底该如何设计一个这样的 key 呢?调试
其实统筹来看,就是如何经过某个类的实例和所需定制主题的方法来肯定一个 key。code
一开始很天然的拼了一个类的地址和方法名来做为key [NSString stringWithFormat:@"%p#%@", class, NSStringFromSelector(selector)]
流程能跑起来了,可是问题也很明显,只知道一个对象的指针字符串,根据对象是否被释放而进行的字典清理将变得难以实现:
// BLThemeManager.m
- (void)updateTheme {
for (BLThemeMapModel *key in [self.blockDictionary allKeys]) {
id object = key.target;
if (object) {
if ([object respondsToSelector:NSSelectorFromString(key.selectorName)]) {
SwitchThemeBlock block = self.blockDictionary[key];
if (block) {
block();
}
}
} else {
[self.blockDictionary removeObjectForKey:key];
}
}
}复制代码
那么,应该怎样设计 block 对应的 key 呢?
respondsToSelector
;为此,实现了一个辅助的 model,用以访问须要注册的对象实例和方法名,同时做为 Dictionary 的 key,它还须要实现 NSCoping 协议:
@interface BLThemeMapModel : NSObject <NSCopying>
@property (nonatomic, weak, readonly) id target;
@property (nonatomic, copy, readonly) NSString *selectorName;
- (instancetype)initWithTarget:(id)target selctorName:(NSString *)selectorName;
@end复制代码
weak 修饰的对象实例可以在对象被释放后自动置 nil,下面附上最初的 .m
文件实现。
@implementation BLThemeMapModel
- (instancetype)initWithTarget:(id)target selctorName:(NSString *)selectorName {
if (self = [super init]) {
_target = target;
_selectorName = selectorName;
}
return self;
}
- (BOOL)isEqual:(id)object {
BLThemeMapModel *model = (BLThemeMapModel *)object;
if (model) {
if([self.target isEqualToString:model.target] &&
[self.selectorName isEqualToString:model.selectorName]){
return YES;
}
}
return NO;
}
- (NSUInteger)hash {
NSUInteger hash = [self.target hash] ^ [self.selectorName hash];
return hash;
}
- (id)copyWithZone:(NSZone *)zone {
BLThemeMapModel *themeModel = [[BLThemeMapModel allocWithZone:zone] initWithTarget:self.target selctorName:self.selectorName];
return themeModel;
}
@end复制代码
惋惜自测后发现一个挺莫名的 bug,最后调试了好一会才解决。细心的读者能够先想一想看~
由于 target 可能被置 nil,从而引发同一个 key 的 hash 值被修改了,而后在遍历字典时,就没法取到以前加入字典的对象了,即使对象是被释放了,但仍有个 dirty 的 BLThemeMapModel
对象在字典里。
定位问题后其实就很好办了,在初始化方法中添加两个用以 hash 的属性:
_pointerString = [NSString stringWithFormat:@"%p", target];
_targetTypeName = NSStringFromClass([target class]);复制代码
最后使用这两个属性完成 hash 和 -isEqual: 方法:
- (BOOL)isEqual:(id)object {
BLThemeMapModel *model = (BLThemeMapModel *)object;
if (model) {
if([self.pointerString isEqualToString:model.pointerString] &&
[self.selectorName isEqualToString:model.selectorName] &&
[self.targetTypeName isEqualToString:model.targetTypeName]){
return YES;
}
}
return NO;
}
- (NSUInteger)hash {
NSUInteger hash = [self.pointerString hash] ^ [self.selectorName hash] ^ [self.targetTypeName hash];
return hash;
}复制代码
至此,动态切换主题的功能大体就实现了,并且没使用到 OC 的任何动态方法。
本文描述了实现一个主题管理类的大体思路,但愿能对读者有所帮助。后来笔者想到既然有了 target 和 selector,能不能经过 NSInvocation 来动态调用,就不借助 block 来回调了,在尝试中笔者 NSInvocation 的效率的确会低一点,并且没有 block 灵活:
@implementation UIViewController (BLTheme)
- (UIStatusBarStyle)bl_setPreferredStatusBarStyle {
NSInteger statusValue = [BLThemeManager sharedInstance].styleModel.statusBarColor;
@weakify(self)
SwitchThemeBlock switchThemeBlock = ^{
@strongify(self)
[self setNeedsStatusBarAppearanceUpdate];
};
[[BLThemeManager sharedInstance] addObserveKey:[self keyWithSelector:@selector(bl_setPreferredStatusBarStyle)] withSwitchThemBlock:switchThemeBlock];
if (statusValue == 1) {
return UIStatusBarStyleLightContent;
} else {
return UIStatusBarStyleDefault;
}
}
@end复制代码
就像这,逻辑上并不指望再调一次 bl_setPreferredStatusBarStyle
,而是仅仅调用一下 [self setNeedsStatusBarAppearanceUpdate];
用 block 能够很灵活的指定好须要调用什么方法。
或许,也能够经过实现一个 weak proxy 的方式使用 Notification 来实现,笔者就没有尝试了,感兴趣的读者能够试试。