iOS換膚功能的簡單處理框架

換膚功能是在APP開發(fā)過程中遇到的比較多的場景,為了提供更好的用戶體驗,許多APP會為用戶提供切換主題的功能。主題顏色管理涉及到的的步驟有

  • 顏色配置
  • 使用顏色
  • UI元素動態(tài)變更的能力
  • 動態(tài)修改配置
  • 主題包管理
  • 如何實施
  • 優(yōu)化

效果如下:

ezgif.com-optimize

DEMO代碼:https://gitee.com/dhar/iosdemos/tree/master/YTThemeManagerDemo

顏色配置

因為涉及到多種配置,所以以代碼的方式定義顏色實踐和維護的難度是比較高的,一種合適的方案是--顏色的配置是通過配置文件的形式進行導入的。配置文件會經(jīng)過轉(zhuǎn)換步驟,最終形成代碼層級的配置,以全局的方式提供給各個模塊使用,這里會涉及到一個顏色管理者的概念,一般地這回事一個單例對象,提供全局訪問的接口。同一個APP中在不同的模塊中保存不同的主題顏色配置,在不同的層級中也可以存在不同的主題顏色配置,因為涉及到層級間的配置差異,所以顏色的配置需要引入一個等級的概念,一般地較高層級顏色的配置等級是高于較低層級的,存在相同的配置較高層級的配置會覆蓋較低層級的配置。

我們采用的顏色配置的文件形如下面所示,為什么是在一個json文件的colorkey下面呢,是為了考慮到未來的擴展性,如果不同的主題會涉及到一些尺寸值的差異化,我們可以添加dimensionskey進行擴展配置。

{
  "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層加載一份定制化的配置,在模塊中不用再加載主題配置文件,這樣會提高效率。

參考資料

讀寫鎖pthread_rwlock_t的使用

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容