iOS换肤功能的简单处理框架

换肤功能是在APP开发过程中遇到的比较多的场景,为了提供更好的用户体验,许多APP会为用户提供切换主题的功能。主题颜色管理涉及到的的步骤有

  • 颜色配置
  • 使用颜色
  • UI元素动态变更的能力
  • 动态修改配置
  • 主题包管理
  • 如何实施
  • 优化

效果如下:

ezgif.com-optimize

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

颜色配置

因为涉及到多种配置,所以以代码的方式定义颜色实践和维护的难度是比较高的,一种合适的方案是--颜色的配置是通过配置文件的形式进行导入的。配置文件会经过转换步骤,最终形成代码层级的配置,以全局的方式提供给各个模块使用,这里会涉及到一个颜色管理者的概念,一般地这回事一个单例对象,提供全局访问的接口。同一个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的使用

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335

推荐阅读更多精彩内容