在移動網際網路的下半場,越來越多的 APP 更加註重使用者體驗,以期來打動使用者。主題的切換就是可以增強使用者體驗、結合運營活動的一個點:譬如 QQ 的夜間模式,節日裡電商 APP 的皮膚切換等等的這些小細節往往就是贏得使用者尊重的根本。本文將從主題的動態切換出發,介紹下貝聊 iOS 客戶端在實現主題動態所採用的方案,供讀者參考。
從切換方案說起
讓 APP 已有的控制元件能切換主題可以用子類化,swizzle 或 category 來實現,其中子類化和 category 實現起來差不多,都是讓控制元件調特定的方法達到切換風格的效果,而 swizzle 的影響範圍會比較廣,使用的時候可以通過 Associated Object 新增一個標記值,讓需要切換風格的控制元件設定這個標記值,讓標記值來決定是否需要 swizzle。考慮到上述幾種方案的複雜度,最後選擇了 category 來實現。
@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 內部讀取了下配置的樣式資料(樣式資料可能為預設樣式或自定義樣式)。
怎樣實現一個主題管理類
主題管理類的核心功能就是負責主題的更新,切換。正如下圖所示,想讓主題管理類通知到這麼多待切換的 category 並不是一件容易的事,因為覺得在 category 上新增觀察者並不是太好的設計,你很難知道什麼時機該把觀察者移除了。
難道說得改成子類化的實現,然後 ovrride dealloc 方法,可惜這樣做感覺就沒那麼純粹了。
這也就意味著,可能需要自己動手來實現回撥機制了,讓切換主題相關的 category 通過主題管理類註冊一個回撥 block,主題類維護使用一個字典維護這些 block,待切換時由主題管理類統一回撥,達到類似 Notification 的效果。
先來看看 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 呢?
- 同一個控制元件的主題 category 有多個需要切換主題的方法(例如 UIButton 有setTitleColor:forState: 和 setImage:forState:);
- 多個控制元件都是通過同一個 categroy 來切換主題(例如有多個 UIButton 需要切換主題);
其實統籌來看,就是如何通過某個類的例項和所需定製主題的方法來確定一個 key。
一開始很自然的拼了一個類的地址和方法名來作為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 呢?
- 能從 key 中獲取到註冊的類;
- key 中也存有方法做 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 來實現,筆者就沒有嘗試了,感興趣的讀者可以試試。