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

aron1992發表於2019-04-04

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

  • 顏色配置
  • 使用顏色
  • UI元素動態變更的能力
  • 動態修改配置
  • 主題包管理
  • 如何實施
  • 優化

效果如下:

ezgif.com-optimize

DEMO程式碼:gitee.com/dhar/iosdem…

顏色配置

因為涉及到多種配置,所以以程式碼的方式定義顏色實踐和維護的難度是比較高的,一種合適的方案是--顏色的配置是通過配置檔案的形式進行匯入的。配置檔案會經過轉換步驟,最終形成程式碼層級的配置,以全域性的方式提供給各個模組使用,這裡會涉及到一個顏色管理者的概念,一般地這回事一個單例物件,提供全域性訪問的介面。同一個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",
  }
}
複製程式碼

有了以上的配置,顏色配置的工作主要就是解析該配置檔案,把配置儲存在一個單例物件中即可,這部分主要的步驟如下:

  • 配置檔案類表根據等級排序
  • 獲取每個配置檔案中的配置,進行儲存
  • 通知外部主題顏色配置發生改變

對應的程式碼如下,這裡有個需要注意的地方是,載入配置檔案的時候使用了檔案讀寫鎖進行讀寫的鎖定操作,防止讀髒資料的發生,直到配置檔案載入完成,釋放讀寫鎖,這時讀程式可以繼續。

- (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];
        // 優先順序排序
        [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;
    }
    
    // 傳送主體顏色變更通知
    [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欄位的資料都放到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) {
        // 十六進位制字串轉為UIColor
        UIColor *color = [UIColor yt_nullcolorWithHexString:obj];
        if (color) {
            [dictionary setObject:color forKey:key];
        } else {
            [dictionary setObject:obj forKey:key];
        }
    }];
}
複製程式碼

管理者處理處理配置之外,還需要暴露外部介面給客戶端使用,以用於獲取不同主題下對應的顏色色值、圖片資源、尺寸資訊等和主題相關的資訊。比如我們會提供一個colorForKey方法獲取不同主題下的同一個key對應的顏色色值,獲取色值的大致步驟如下:

  • 從當前的主題配置中獲取
  • 從預設的主題配置中獲取
  • 從預留的主題配置中獲取
  • 如果重定向的配置,遞迴處理
  • 以上步驟都完成還未找到返回預設黑色

這裡使用了讀寫鎖的寫鎖,如果同時有寫操作獲取了該鎖,讀取程式會阻塞直到寫操作的完成釋放鎖。

/**
 獲取顏色值
 */
- (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 能否轉成 color
    if (colorObj == nil) {
        colorObj = [UIColor yt_colorWithHexString:key];
    }
    
    if ([colorObj isKindOfClass:[UIColor class]]) {
        ///如果是 重定向 或者  替補 key 的color  要設定到 當前 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];
        }
    }
}
複製程式碼

使用顏色

顏色的使用也是經由管理者的,為了方便,定義一個顏色巨集提供給客戶端使用

#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為字串型別,直接使用字串常量並不是個好辦法,所以把對應的字串轉換為巨集定義是一個相對好的辦法。第一個是方便使用,可以使用程式碼提示;第二個是不容易出錯,特別是長的字串;第三個也會一定程度上的提高效率。

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";
複製程式碼

主題包管理

在實際的落地專案中,主題包管理涉及到的事項包括主題包下載和解壓動態載入主題包等內容,最後的一步是更換主題配置檔案所在的配置路徑,為了演示的方便,我們會把不同主題的資源放置在bundle中某一個特定的資料夾下,通過切換管理者中的主題路徑配置來達到切換主題的效果,和動態下載更換主題的步驟是一樣的。

管理者提供一個設定主題配置的配置路徑的方法,在該方法中改變配置路徑的同時,重新載入配置即可,程式碼如下

/**
 設定主題檔案的路徑
 @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平臺下的一個技術解決方案,真實的實踐過程中會涉及到安卓平臺、Web頁面、UI出圖的標註,這些是要進行統一處理的,才能在各個端上有一致的體驗。第一步就是制定合理的顏色規範,把規範同步給各個端的利益相關人員;第二部是UI出圖顏色是規範的顏色定義值,而不是比如#ffffff這樣的顏色,需要是比如White_A這樣規範的顏色定義值,這樣客戶端處理使用的就是White_A這個值,不用管在不同主題下不同的顏色表現形式。

優化

loadConfigDataWithColorMap方法呼叫的優化

如果模組很多,每個模組都會呼叫loadConfigWithFileName載入配置檔案,那麼loadConfigDataWithColorMap方法處理檔案的時間複雜度是O(N*N),會重複處理很多多餘的工作,理想的做法是底層儲存一份公有的顏色配置,然後在APP層載入一份定製化的配置,在模組中不用再載入主題配置檔案,這樣會提高效率。

參考資料

讀寫鎖pthread_rwlock_t的使用

相關文章