iOS成熟的夜間模式解決方案
關注倉庫,及時獲得更新: iOS-Source-Code-Analyze
從開始寫 DKNightVersion 這個框架到現在已經將近一年了,目前整個框架的設計也趨于穩定。
其實夜間模式的實現就是相當于 多主題加顏色管理 。而最新版本的 DKNightVersion 已經很好的解決了這個問題。
在正式介紹目前版本的實現之前,我會先簡單介紹一下 1.0 時代的 DKNightVersion 的實現,為各位讀者帶來一些新的思路,也確實想梳理一下這個框架是如何演變的。
我們會以對 backgroundColor 為例說明整個框架的工作原理。
方法調劑的版本
如何在不改變原有的架構,甚至不改變原有的代碼的基礎上,為應用優雅地添加夜間模式成為很多開發者不得不面對的問題。這也是 1.0 時代的 DKNightVersion 想要實現的目標。
其核心思路就是 使用方法調劑修改 backgroundColor 的存取方法 。
使用 nightBackgroundColor
在思考之后,我想到,想要在不改動原有代碼的基礎上實現夜間模式只能通過在 分類 中添加 nightBackgroundColor 屬性,并且使用方法調劑改變 backgroundColor 的 setter 方法。
- (void)hook_setBackgroundColor:(UIColor*)backgroundColor {
if ([DKNightVersionManager currentThemeVersion] == DKThemeVersionNormal) {
[self setNormalBackgroundColor:backgroundColor];
}
[self hook_setBackgroundColor:backgroundColor];
}
在當前主題為 DKThemeVersionNormal 時,將顏色保存至 normalBackgroundColor 中,然后再調用原 backgroundColor 的 setter 方法,更新視圖的顏色。
DKNightVersionManager
這里只解決了顏色設置的問題,下面會說明,如果在主題改變時,實時更新顏色,而不用重新進入當前頁面。
整個 DKNightVersion 都是由一個 DKNightVersionManager 的單例來管理的,而它的主要工作就是負責 改變應用的主題 、并在主題改變時 通知其它視圖更新顏色 :
- (void)changeColor:(id <DKNightVersionChangeColorProtocol>)object {
if ([object respondsToSelector:@selector(changeColor)]) {
[object changeColor];
}
if ([object respondsToSelector:@selector(subviews)]) {
if (![object subviews]) {
// Basic case, do nothing.
return;
} else {
for (id subview in [object subviews]) {
// recursive darken all the subviews of current view.
[self changeColor:subview];
if ([subview respondsToSelector:@selector(changeColor)]) {
[subview changeColor];
}
}
}
}
}
如果主題更新,那么就會遞歸地調用 changeColor 方法,刷新全部的視圖顏色,而這個方法的實現比較簡單:
- (void)changeColor {
if ([DKNightVersionManager currentThemeVersion] == DKThemeVersionNormal) {
self.backgroundColor = self.normalBackgroundColor;
} else {
self.backgroundColor = self.nightBackgroundColor;
}
}
上面就是整個框架在 1.0 版本時的實現思路。不過這個版本的 DKNightVersion 在實際應用中會有比較多的問題:
- 在高速滾動的 scrollView 上面來回切換夜間模式,會出現顏色錯亂的問題
- 由于對 backgroundColor 屬性進行 不合適的 方法調劑,其行為無法預測,比如:在設置顏色后,再取出,不一定與設置時傳入的顏色相同
- 無法適配第三方 UI 控件
使用色表的版本
為了解決 1.0 中的各種問題,我決定在 2.0 版本中放棄對 nightBackgroundColor 的使用,并且重新設計底層的實現,轉而使用更為 穩定 、 安全 的方法實現夜間模式,先看一下效果圖:
新的實現不僅能夠支持夜間模式,而且能夠支持多主題。
DKColorPicker
與上一個版本實現上的不同,在 2.0 中刪除了全部的 nightBackgroundColor ,使用一個名為 dk_backgroundColorPicker 的屬性取代它。
@property (nonatomic, copy) DKColorPicker dk_backgroundColorPicker;
這個屬性其實就是一個 block,它接收參數 DKThemeVersion *themeVersion ,但是會返回一個 UIColor * :
在第一次傳入 picker 或者每次主題改變時,都會將當前主題 DKThemeVersion 傳入 picker 并執行,然后,將得到的 UIColor 賦值給對應的屬性 backgroundColor 更新視圖顏色。
typedef UIColor *(^DKColorPicker)(DKThemeVersion *themeVersion);
比如下面使用 DKColorPickerWithRGB 創建一個臨時的 DKColorPicker :
- 在 DKThemeVersionNormal 時返回 0xffffff
- 在 DKThemeVersionNight 時返回 0x343434
- 在自定義的主題下返回 0xfafafa (這里的順序與色表中主題的順序有關)
cell.dk_backgroundColorPicker = DKColorPickerWithRGB(0xffffff, 0x343434, 0xfafafa);
同時,每一個對象還持有一個 pickers 數組,來存儲自己的全部 DKColorPicker :
@interface NSObject ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, DKColorPicker> *pickers;
@end
在第一次使用這個屬性時,當前對象注冊為 DKNightVersionThemeChangingNotificaiton 通知的觀察者。
在每次收到通知時,都會調用 night_update 方法,將當前主題傳入 DKColorPicker ,并再次執行,并將結果傳入對應的屬性 [self performSelector:sel withObject:result] 。
- (void)night_updateColor {
[self.pickers enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull selector, DKColorPicker _Nonnull picker, BOOL * _Nonnull stop) {
SEL sel = NSSelectorFromString(selector);
id result = picker(self.dk_manager.themeVersion);
[UIView animateWithDuration:DKNightVersionAnimationDuration
animations:^{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self performSelector:sel withObject:result];
#pragma clang diagnostic pop
}];
}];
}
也就是說,在每次改變主題的時候,都會發出通知。
DKColorTable
雖然我們在上面臨時創建了一些 DKColorPicker 。不過在 DKNightVersion 中,我更推薦使用色表,來減少相同的 DKColorPicker 的創建,并且能夠更好地管理整個應用中的顏色:
NORMAL NIGHT RED
#ffffff #343434 #fafafa BG
#aaaaaa #313131 #aaaaaa SEP
#0000ff #ffffff #fa0000 TINT
#000000 #ffffff #000000 TEXT
#ffffff #444444 #ffffff BAR
上面就是默認色表文件 DKColorTable.txt 中的內容,其中,第一行表示主題, NORMAL 主題必須存在,而且必須為第一列,而最右面的 BG 、 SEP 就是對應 DKColorPicker 的 key。
self.tableView.dk_backgroundColorPicker = DKColorPickerWithKey(BG);
在使用時,上面的代碼就相當于返回了一個在 NORMAL 時返回 #ffffff 、 NIGHT 時返回 #343434 以及 RED 時返回 #fafafa 的 DKColorPicker 。
pickerify
雖然說,我們使用色表以及 DKColorPicker 解決了,但是,到目前為止我們還沒有解決第三方框架的問題。
比如我們使用了某個第三方框架,或者自己添加了某個 color 屬性,比如說:
@interface DKView ()
@property (nonatomic, strong) UIColor *weirdColor;
@end
weirdColor 并沒有對應的 DKColorPicker ,但是,我們可以通過 pickerify 在想要使用 dk_weirdColorPicker 的地方生成這個對應的 picker:
@pickerify(DKView, weirdColor);
然后,我們就可以使用 dk_weirdColorPicker 屬性了:
view.dk_weirdColorPicker = DKColorPickerWithKey(BG);
pickerify 其實是一個宏:
#define pickerify(KLASS, PROPERTY) interface \
KLASS (Night) \
@property (nonatomic, copy, setter = dk_set ## PROPERTY ## Picker:) DKColorPicker dk_ ## PROPERTY ## Picker; \
@end \
@interface \
KLASS () \
@property (nonatomic, strong) NSMutableDictionary<NSString *, DKColorPicker> *pickers; \
@end \
@implementation \
KLASS (Night) \
- (DKColorPicker)dk_ ## PROPERTY ## Picker { \
return objc_getAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker)); \
} \
- (void)dk_set ## PROPERTY ## Picker:(DKColorPicker)picker { \
objc_setAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker), picker, OBJC_ASSOCIATION_COPY_NONATOMIC); \
[self setValue:picker(self.dk_manager.themeVersion) forKeyPath:@keypath(self, PROPERTY)];\
[self.pickers setValue:[picker copy] forKey:_DKSetterWithPROPERTYerty(@#PROPERTY)]; \
} \
@end
這個宏根據傳入的類和屬性名,為我們生成了對應 picker 的存取方法,它也可以說是一種元編程的手段。
這里生成的 setter 方法不是標準意義上的駝峰命名法 dk_setweirdColorPicker: ,因為我不知道怎么才能讓大寫首字母之后的屬性添加到這里(如果各位讀者有解決方案,歡迎提 PR 或者 issue)。
嵌入式 Ruby
由于框架中很多的代碼,都是重復的,所以在這里使用了 嵌入式 Ruby 模板 來生成對應的文件 color.m.irb :
//
// <%= klass.name %>+Night.m
// <%= klass.name %>+Night
//
// Copyright (c) 2015 Draveness. All rights reserved.
//
// These files are generated by ruby script, if you want to modify code
// in this file, you are supposed to update the ruby code, run it and
// test it. And finally open a pull request.
#import "<%= klass.name %>+Night.h"
#import "DKNightVersionManager.h"
#import <objc/runtime.h>
@interface <%= klass.name %> ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, DKColorPicker> *pickers;
@end
@implementation <%= klass.name %> (Night)
<% klass.properties.each do |property| %><%= """
- (DKColorPicker)dk_#{property.name}Picker {
return objc_getAssociatedObject(self, @selector(dk_#{property.name}Picker));
}
- (void)dk_set#{property.cap_name}Picker:(DKColorPicker)picker {
objc_setAssociatedObject(self, @selector(dk_#{property.name}Picker), picker, OBJC_ASSOCIATION_COPY_NONATOMIC);
self.#{property.name} = picker(self.dk_manager.themeVersion);
[self.pickers setValue:[picker copy] forKey:@\"#{property.setter}\"];
}
""" %><% end %>
@end
這部分的實現并不在這篇文章的討論范圍之內,如果,對這部分看興趣,可以看一下倉庫中的 generator 文件夾,其中包含了代碼生成器的全部代碼。
小結
如果你對 DKNightVersion 的使用有興趣,可以查看倉庫的 README 文件,有人會說不要在項目中 ObjC runtime,我個人覺得是沒有問題, AFNetworking 、 BlocksKit 也使用方法調劑來改變原有方法的實現,不能因為它強大就不使用它;正相反,有時候,使用 runtime 才能優雅地解決問題。
來自: http://draveness.me/cheng-shou-de-ye-jian-mo-shi-jie-jue-fang-an/