換膚功能是在APP開發(fā)過程中遇到的比較多的場景,為了提供更好的用戶體驗,許多APP會為用戶提供切換主題的功能。主題顏色管理涉及到的的步驟有
- 顏色配置
- 使用顏色
- UI元素動態(tài)變更的能力
- 動態(tài)修改配置
- 主題包管理
- 如何實施
- 優(yōu)化
效果如下:
DEMO代碼:https://gitee.com/dhar/iosdemos/tree/master/YTThemeManagerDemo
顏色配置
因為涉及到多種配置,所以以代碼的方式定義顏色實踐和維護的難度是比較高的,一種合適的方案是--顏色的配置是通過配置文件的形式進行導入的。配置文件會經(jīng)過轉(zhuǎn)換步驟,最終形成代碼層級的配置,以全局的方式提供給各個模塊使用,這里會涉及到一個顏色管理者的概念,一般地這回事一個單例對象,提供全局訪問的接口。同一個APP中在不同的模塊中保存不同的主題顏色配置,在不同的層級中也可以存在不同的主題顏色配置,因為涉及到層級間的配置差異,所以顏色的配置需要引入一個等級的概念,一般地較高層級顏色的配置等級是高于較低層級的,存在相同的配置較高層級的配置會覆蓋較低層級的配置。
我們采用的顏色配置的文件形如下面所示,為什么是在一個json文件的color
key下面呢,是為了考慮到未來的擴展性,如果不同的主題會涉及到一些尺寸值的差異化,我們可以添加dimensions
key進行擴展配置。
{
"color": {
"Black_A":"323232",
"Black_AT":"323232",
"Black_B":"888888",
"Black_BT":"888888",
"White_A":"ffffff",
"White_AT":"ffffff",
"White_AN":"ffffff",
"Red_A":"ff87a0",
"Red_AT":"ff87a0",
"Red_B":"ff5073",
"Red_BT":"ff5073",
"Colour_A":"377ce4",
"Colour_B":"6aaafa",
"Colour_C":"ff8c55",
"Colour_D":"ffa200",
"Colour_E":"c4a27a",
}
}
有了以上的配置,顏色配置的工作主要就是解析該配置文件,把配置保存在一個單例對象中即可,這部分主要的步驟如下:
- 配置文件類表根據(jù)等級排序
- 獲取每個配置文件中的配置,進行保存
- 通知外部主題顏色配置發(fā)生改變
對應(yīng)的代碼如下,這里有個需要注意的地方是,加載配置文件的時候使用了文件讀寫鎖進行讀寫的鎖定操作,防止讀臟數(shù)據(jù)的發(fā)生,直到配置文件加載完成,釋放讀寫鎖,這時讀進程可以繼續(xù)。
- (void)loadConfigWithFileName:(NSString *)fileName level:(NSInteger)level {
if (fileName.length == 0) {
return;
}
pthread_rwlock_wrlock(&_rwlock);
__block BOOL finded = NO;
[self.configFileQueue enumerateObjectsUsingBlock:^(YTThemeConfigFile *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
if ([obj.fileName isEqualToString:fileName]) {
finded = YES;
*stop = YES;
}
}];
if (!finded) {
// 新增配置文件
YTThemeConfigFile *file = [[YTThemeConfigFile alloc] init];
file.fileName = fileName;
file.level = level;
[self.configFileQueue addObject:file];
// 優(yōu)先級排序
[self.configFileQueue sortUsingComparator:^NSComparisonResult(YTThemeConfigFile *_Nonnull obj1, YTThemeConfigFile *_Nonnull obj2) {
if (obj1.level > obj2.level) {
return NSOrderedDescending;
}
return NSOrderedAscending;
}];
[self setupConfigFilesContainDefault:YES];
}
pthread_rwlock_unlock(&_rwlock);
}
- (void)setupConfigFilesContainDefault:(BOOL)containDefault {
NSMutableDictionary *defaultColorDict = nil, *currentColorDict = nil;
// 加載默認配置
if (containDefault) {
defaultColorDict = [NSMutableDictionary dictionary];
[self loadConfigDataWithColorMap:defaultColorDict valueMap:nil isDefault:YES];
self.defaultColorMap = defaultColorDict;
}
// 加載主題配置
if (_themePath.length > 0) {
currentColorDict = [NSMutableDictionary dictionary];
[self loadConfigDataWithColorMap:currentColorDict valueMap:nil isDefault:NO];
self.currentColorMap = currentColorDict;
}
// 發(fā)送主體顏色變更通知
[self notifyThemeDidChange];
}
- (void)notifyThemeDidChange {
NSArray *allActionObjects = self.actionMap.objectEnumerator.allObjects;
for (YTThemeAction *action in allActionObjects) {
[action notifyThemeDidChange];
}
}
- (void)loadConfigDataWithColorMap:(NSMutableDictionary *)colorMap valueMap:(NSMutableDictionary *)valueMap isDefault:(BOOL)isDefault {
// 每一次新增一個配置文件,所有配置文件都得重新計算一次,這里有很多重復多余的工作
[self.configFileQueue enumerateObjectsUsingBlock:^(YTThemeConfigFile *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
NSDictionary *dict = nil;
if (isDefault) {
dict = obj.defaultDict;
} else {
dict = obj.currentDict;
}
if (dict.count > 0) {
[self loadThemeColorTo:colorMap from:dict]; // 將所有配置表中的color字段的數(shù)據(jù)都放到colorMap中
}
}];
}
- (void)loadThemeColorTo:(NSMutableDictionary *)dictionary from:(NSDictionary *)from {
NSDictionary<NSString *, NSString *> *colors = from[@"color"];
[colors enumerateKeysAndObjectsUsingBlock:^(NSString *_Nonnull key, NSString *_Nonnull obj, BOOL *_Nonnull stop) {
// 十六進制字符串轉(zhuǎn)為UIColor
UIColor *color = [UIColor yt_nullcolorWithHexString:obj];
if (color) {
[dictionary setObject:color forKey:key];
} else {
[dictionary setObject:obj forKey:key];
}
}];
}
管理者處理處理配置之外,還需要暴露外部接口給客戶端使用,以用于獲取不同主題下對應(yīng)的顏色色值、圖片資源、尺寸信息等和主題相關(guān)的信息。比如我們會提供一個colorForKey
方法獲取不同主題下的同一個key對應(yīng)的顏色色值,獲取色值的大致步驟如下:
- 從當前的主題配置中獲取
- 從默認的主題配置中獲取
- 從預留的主題配置中獲取
- 如果重定向的配置,遞歸處理
- 以上步驟都完成還未找到返回默認黑色
這里使用了讀寫鎖的寫鎖,如果同時有寫操作獲取了該鎖,讀取進程會阻塞直到寫操作的完成釋放鎖。
/**
獲取顏色值
*/
- (UIColor *)colorForKey:(NSString *)key {
pthread_rwlock_rdlock(&_rwlock);
UIColor *color = [self colorForKey:key isReserveKey:NO redirectCount:0];
pthread_rwlock_unlock(&_rwlock);
return color;
}
- (UIColor *)colorForKey:(NSString *)key isReserveKey:(BOOL)isReserveKey redirectCount:(NSInteger)redirectCount {
if (key == nil) {
return nil;
}
///正常獲取色值
id colorObj = [_currentColorMap objectForKey:key];
if (colorObj == nil) {
colorObj = [_defaultColorMap objectForKey:key];
}
if (isReserveKey && colorObj == nil) {
return nil;
}
///看看是否有替補key
if (colorObj == nil) {
NSString *reserveKey = [_reserveKeyMap objectForKey:key];
if (reserveKey) {
colorObj = [self colorForKey:reserveKey isReserveKey:YES redirectCount:redirectCount];
}
}
///查看當前key 能否轉(zhuǎn)成 color
if (colorObj == nil) {
colorObj = [UIColor yt_colorWithHexString:key];
}
if ([colorObj isKindOfClass:[UIColor class]]) {
///如果是 重定向 或者 替補 key 的color 要設(shè)置到 當前 colorDict 里面
// 重定向的配置形如:"Red_A":"Red_B",
if (redirectCount > 0 || isReserveKey) {
[_currentColorMap ?: _defaultColorMap setObject:colorObj forKey:key];
}
return colorObj;
} else {
if (redirectCount < 3) { // 重定向遞歸
return [self colorForKey:colorObj isReserveKey:NO redirectCount:redirectCount + 1];
} else {
return [UIColor blackColor];
}
}
}
使用顏色
顏色的使用也是經(jīng)由管理者的,為了方便,定義一個顏色宏提供給客戶端使用
#define YTThemeColor(key) ([[YTThemeManager sharedInstance] colorForKey:key])
客戶端使用的代碼如下:
UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(0, 20, 200, 40)];
label.text = @"Text";
label.textColor = YTThemeColor(kCK_Red_A);
label.backgroundColor = YTThemeColor(kCK_Black_H);
[self.view addSubview:label];
另外,因為顏色配置的key為字符串類型,直接使用字符串常量并不是個好辦法,所以把對應(yīng)的字符串轉(zhuǎn)換為宏定義是一個相對好的辦法。第一個是方便使用,可以使用代碼提示;第二個是不容易出錯,特別是長的字符串;第三個也會一定程度上的提高效率。
YTColorDefine
類的宏定義
// .h 中的聲明
///Black
FOUNDATION_EXTERN NSString *kCK_Black_A;
FOUNDATION_EXTERN NSString *kCK_Black_AT;
FOUNDATION_EXTERN NSString *kCK_Black_B;
FOUNDATION_EXTERN NSString *kCK_Black_BT;
// .m 中的定義
NSString *kCK_Black_A = @"Black_A";
NSString *kCK_Black_AT = @"Black_AT";
NSString *kCK_Black_B = @"Black_B";
NSString *kCK_Black_BT = @"Black_BT";
主題包管理
在實際的落地項目中,主題包管理涉及到的事項包括主題包下載和解壓和動態(tài)加載主題包等內(nèi)容,最后的一步是更換主題配置文件所在的配置路徑,為了演示的方便,我們會把不同主題的資源放置在bundle中某一個特定的文件夾下,通過切換管理者中的主題路徑配置來達到切換主題的效果,和動態(tài)下載更換主題的步驟是一樣的。
管理者提供一個設(shè)置主題配置的配置路徑的方法,在該方法中改變配置路徑的同時,重新加載配置即可,代碼如下
/**
設(shè)置主題文件的路徑
@param themePath 文件的路徑
*/
- (void)setupThemePath:(NSString *)themePath {
pthread_rwlock_wrlock(&_rwlock);
_themePath = [themePath copy];
self.currentColorMap = nil;
if ([_themePath.lowercaseString isEqualToString:[[NSBundle mainBundle] resourcePath].lowercaseString]) {
_themePath = nil;
}
self.currentThemePath = _themePath;
for (int i = 0; i < self.configFileQueue.count; i++) {
YTThemeConfigFile *obj = [self.configFileQueue objectAtIndex:i];
[obj resetCurrentDict];
}
[self setupConfigFilesContainDefault:NO];
pthread_rwlock_unlock(&_rwlock);
}
如何實施
以上的流程涉及到的只是iOS平臺下的一個技術(shù)解決方案,真實的實踐過程中會涉及到安卓平臺、Web頁面、UI出圖的標注,這些是要進行統(tǒng)一處理的,才能在各個端上有一致的體驗。第一步就是制定合理的顏色規(guī)范,把規(guī)范同步給各個端的利益相關(guān)人員;第二部是UI出圖顏色是規(guī)范的顏色定義值,而不是比如#ffffff這樣的顏色,需要是比如White_A這樣規(guī)范的顏色定義值,這樣客戶端處理使用的就是White_A這個值,不用管在不同主題下不同的顏色表現(xiàn)形式。
優(yōu)化
loadConfigDataWithColorMap方法調(diào)用的優(yōu)化
如果模塊很多,每個模塊都會調(diào)用loadConfigWithFileName
加載配置文件,那么loadConfigDataWithColorMap方法處理文件的時間復雜度是O(N*N),會重復處理很多多余的工作,理想的做法是底層保存一份公有的顏色配置,然后在APP層加載一份定制化的配置,在模塊中不用再加載主題配置文件,這樣會提高效率。