iOS 实现快速切换主题详细教程(附上源码)| 掘金技术征文

5,258 阅读12分钟

前言

iOS 实现主题切换,相信在未来的app里也是会频繁出现的,尽管现在只是出现在主流的APP,如(QQ、新浪微博、酷狗音乐、网易云音乐等),但是现在是看颜值、追求个性的年代,所以根据用户喜好自定义/切换主题也是未来app的必备功能了。

实现思路

为了降低耦合度,决定采用的方案是使用NSObject的分类来实现主题设置,有些读者可能会想为何不使用UIView的分类而是使用NSObject的分类?建议这部分读者看一下UIBarItem父类,然后仔细思考一下,就会理解了。

设置主题色

PYThemeColor.png

  1. 创建主题色池
  2. 将需要设置主题色的控件及其对应属性/方法添加到主题色池中
  3. 调用设置主题色方法时,遍历主题色池中的控件,使用KVC设置对应属性或调用对应的方法来实现主题色的设置

代码实现

建议读者在理解思路以后先下载源码大概看一下(纵观全局)再阅读以下内容:
源码地址:github.com/iphone5solo…

1. 创建主题色池

由于是在NSObject的分类里面创建,为了方便管理,设置全局变量_themeColorPool,并通过懒加载完成_themeColorPool的实例化。数组中的对象原来采用的是NSDictionary,但是由于NSDictionary存储时,会对对象采用强引用导致对象不能被及时释放,所以最终采用的解决方案是采用NSMapTable存储,实现对象的弱引用,详情见下一步就会理解了

/** 主题颜色池 */
static NSMutableArray<NSMapTable *> *_themeColorPool;

#pragma mark - 懒加载
- (NSMutableArray *)themeColorPool
{
    if (!_themeColorPool) {
        _themeColorPool = [NSMutableArray array];
    }
    return _themeColorPool;
}
2. 添加控件到主题色池中

由于颜色设置有的可以直接通过属性设置也有的需要通过调用方法才可设置。以UIButton为例,设置背景色可通过属性button.backgroundColor设置,设置选中状态时的字体颜色则要调用setTitleColor:forState:方法才可设置,于是,就得提供两个方法供使用者调用,如下

/**
 * 添加到主题色池
 * selector : 执行方法
 * objects : 方法参数数组
 * 注意:方法参数必须按顺序一一对应,如果涉及到的主题色设置使用 PYTHEME_THEME_COLOR 宏定义代替
 * 如果数组中某个参数为nil,需包装为 [NSNull null] 对象再添加到数组中
 */
- (void)py_addToThemeColorPoolWithSelector:(SEL)selector objects:(NSArray<id> *)objects;
/** 
 * 添加到主题色池
 * propertyName : 属性名
 */
- (void)py_addToThemeColorPool:(NSString *)propertyName;

实现如下:

#pragma mark - Theme Color
/**
 * 添加到主题色池
 * selector : 执行方法
 * objects : 方法参数数组
 * 注意:方法参数必须按顺序一一对应,如果涉及到的主题色设置使用 PYTHEME_THEME_COLOR 宏定义代替
 * 如果数组中某个参数为nil,需包装为 [NSNull null] 对象再添加到数组中
 */
- (void)py_addToThemeColorPoolWithSelector:(SEL)selector objects:(NSArray *)objects
{
    // 判断参数是否为空
    if (!objects) return;
    Class appearanceClass = NSClassFromString(@"_UIAppearance");
    // 如果对象为_UIAppearance,直接返回
    if ([self isMemberOfClass:appearanceClass]) return;
    // 键:对象地址+方法名 值:对象
    NSString *pointSelectorString = [NSString stringWithFormat:@"%p%@", self, NSStringFromSelector(selector)];
    // 采用NSMapTable存储对象,使用弱引用
    NSMapTable *mapTable = [NSMapTable mapTableWithKeyOptions:NSMapTableCopyIn valueOptions:NSMapTableWeakMemory];
    [mapTable setObject:self forKey:pointSelectorString];
    [mapTable setObject:objects forKey:PYTHEME_COLOR_ARGS_KEY];
    // 判断是否已经在主题色池中
    for (NSMapTable *subMapTable in [[self themeColorPool] copy]) {
        if ([[subMapTable description] isEqualToString:[mapTable description]]) { // 存在,直接返回
            return;
        }
    }
    // 不存在,添加主题色池中
    [[self themeColorPool] addObject:mapTable];
    if (_currentThemeColor) { // 已经设置主题色,直接设置
        [self py_performSelector:selector withObjects:objects];
    }
}

/**
 * 添加到主题色池
 * propertyName : 属性名
 */
- (void)py_addToThemeColorPool:(NSString *)propertyName
{
    // 如果对象为_UIAppearance,直接返回
    Class appearanceClass = NSClassFromString(@"_UIAppearance");
    if ([self isMemberOfClass:appearanceClass]) return;
    // 键:对象地址+属性名 值:对象
    NSString *pointString = [NSString stringWithFormat:@"%p%@", self, propertyName];
    // 采用NSMapTable存储对象,使用弱引用
    NSMapTable *mapTable = [NSMapTable mapTableWithKeyOptions:NSMapTableCopyIn valueOptions:NSMapTableWeakMemory];
    [mapTable setObject:self forKey:pointString];
    // 判断是否已经在主题色池中
    for (NSMapTable *subMapTable in [[self themeColorPool] copy]) {
        if ([[subMapTable description] isEqualToString:[mapTable description]]) { // 存在,直接返回
            return;
        }
    }
    // 不存在,添加主题色池中
    [[self themeColorPool] addObject:mapTable];
    if (_currentThemeColor) { // 已经设置主题色,直接设置
        [self setValue:_currentThemeColor forKey:propertyName];
    }
}

为了满足个别需求,所以还是提供一下从主题色池中移除控件的方法

/** 
 * 从主题色池移除
 * selector : 方法选择器
 */
- (void)py_removeFromThemeColorPoolWithSelector:(SEL)selector;

/**
 * 从主题色池移除
 * propertyName : 属性名
 */
- (void)py_removeFromThemeColorPool:(NSString *)propertyName;

实现如下:


/**
 * 从主题色池移除
 * selector : 执行方法
 */
- (void)py_removeFromThemeColorPoolWithSelector:(SEL)selector
{
    // 如果对象为_UIAppearance,直接返回
    Class appearanceClass = NSClassFromString(@"_UIAppearance");
    if ([self isMemberOfClass:appearanceClass]) return;

    // 键:对象地址+方法名 值:对象
    NSString *pointSelectorString = [NSString stringWithFormat:@"%p%@", self, NSStringFromSelector(selector)];
    // 判断是否已经在主题色池中
    for (NSMapTable *subMapTable in [[self themeColorPool] copy]) {
        // 取出key
        NSString *objectKey = nil;
        // 获取mapTable中所有key
        NSEnumerator *enumerator = [subMapTable keyEnumerator];
        NSString *key;
        while (key = [enumerator nextObject]) {
            if (![key isEqualToString:PYTHEME_COLOR_ARGS_KEY]) {
                objectKey = key;
                break;
            }
        }
        if([objectKey isEqualToString:pointSelectorString]) { // 存在,移除
            [[self themeColorPool] removeObject:subMapTable];
            return;
        }
    }
}

/**
 * 从主题色池移除
 * propertyName : 属性名
 */
- (void)py_removeFromThemeColorPool:(NSString *)propertyName
{
    // 如果对象为_UIAppearance,直接返回
    Class appearanceClass = NSClassFromString(@"_UIAppearance");
    if ([self isMemberOfClass:appearanceClass]) return;

    // 键:对象地址+属性名 值:对象
    NSString *pointString = [NSString stringWithFormat:@"%p%@", self, propertyName];
    // 判断是否已经在主题色池中
    for (NSMapTable *subMapTable in [[self themeColorPool] copy]) {
        // 获取mapTable中所有key
        NSEnumerator *enumerator = [subMapTable keyEnumerator];
        if([[enumerator nextObject] isEqualToString:pointString]) { // 存在,移除
            [[self themeColorPool] removeObject:subMapTable];
            return;
        }
    }
}
3. 设置主题色
/** 
 * 设置主题色
 * color : 主题色
 */
- (void)py_setThemeColor:(UIColor *)color;

实现如下:

/**
 * 设置主题色
 * color : 主题色
 */
- (void)py_setThemeColor:(UIColor *)color
{
    _currentThemeColor = color;
    // 遍历缓主题池,设置统一主题色
    for (NSMapTable *mapTable in [_themeColorPool copy]) {
        // 取出key
        NSString *objectKey = nil;
        // 获取mapTable中所有key
        NSEnumerator *enumerator = [mapTable keyEnumerator];
        NSString *key;
        while (key = [enumerator nextObject]) {
            if (![key isEqualToString:PYTHEME_COLOR_ARGS_KEY]) {
                objectKey = key;
                break;
            }
        }
        if (!key) { // 如果key为空,则mapTable 为空,移除mapTable
            [_themeColorPool removeObject:mapTable];
        }
        // 取出对象
        id object = [mapTable objectForKey:objectKey];
        if ([objectKey containsString:@":"]) { // 方法
            // 取出参数
            NSArray *args = [mapTable objectForKey:PYTHEME_COLOR_ARGS_KEY];
            // 取出方法
            NSString *selectorName = [objectKey substringFromIndex:[[NSString stringWithFormat:@"%p", object] length]];
            SEL selector = NSSelectorFromString(selectorName);
            // 调用方法,设置属性
            [object py_performSelector:selector withObjects:args];
        } else { // 成员属性
            // 取出属性值
            NSString *propertyName = [objectKey substringFromIndex:[[NSString stringWithFormat:@"%p", object] length]];
            // 给对象的对应属性赋值(使用KVC)
            [object setValue:color forKeyPath:propertyName];
        }
    }
}

使用

假设有个需求:UINavigationBar背景颜色UIButton选中时的字体颜色会随着主题颜色的变化而变化,实现如下:

navigationBarbackgroundUIButtonsetTitleColor:forState:方法添加到主题池中,方法参数中如果是设置为主题色的参数则用PYTHEME_THEME_COLOR占位,如果参数为nil,则使用[NSNull null]代替

// 创建导航栏
UINavigationBar *navigationBar = [[UINavigationBar alloc] init];
// 添加到主题色池中
[navigationBar py_addToThemeColorPool:@"barTintColor"];

// 创建按钮
UIButton *button = [[UIButton alloc] init];
// 添加到主题色中
[button py_addToThemeColorPoolWithSelector:@selector(setTitleColor:forState:) objects:@[PYTHEME_THEME_COLOR, @(UIControlStateSelected)]];

设置主题色

// 设置主题色为红色
[self py_setThemeColor:[UIColor redColor]];

这里有一点注意的是[object py_performSelector:selector withObjects:args];这是自己实现的performSelector 多参调用关于这方面的网上已经有很多教程了,这里就不多介绍了。直接附上的我实现(内部方法,主要考虑到自己的使用):

#pragma mark - performSelector 多参调用
- (id)py_performSelector:(SEL)selector withObjects:(const NSArray<id> *)objects
{
    // 1. 创建方法签名
    // 根据方法来初始化NSMethodSignature
    NSMethodSignature *methodSignate = [[self class] instanceMethodSignatureForSelector:selector];
    if (!methodSignate) { // 没有该方法
        return self;
    }
    // 2. 创建invocation对象(包装方法)
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignate];
    // 3. 设置相关属性
    // 调用者
    invocation.target = self;
    // 调用方法
    invocation.selector = selector;
    // 获取除self、_cmd的参数个数
    NSInteger paramsCount = methodSignate.numberOfArguments - 2;
    // 取最少的,防止越界
    NSInteger count = MIN(paramsCount, objects.count);
    // 用于dictionary的拷贝(用于保住objCopy,避免非法内存访问)
    NSMutableDictionary *objCopy = nil;
    // 设置参数
    for (int i = 0; i < count; i++) {
        // 取出参数对象
        id obj = objects[i];
        // 如果是主题颜色参数颜色,则设置
        if ([obj isKindOfClass:[NSString class]] && [obj isEqualToString:PYTHEME_THEME_COLOR]) {
            obj = _currentThemeColor;
        }
        // 判断需要设置的参数是否是NSNull, 如果是就设置为nil
        if ([obj isKindOfClass:[NSNull class]]) {
            obj = nil;
        }
        // 获取参数类型
        const char *argumentType = [methodSignate getArgumentTypeAtIndex:i + 2];
        // 判断参数类型 根据类型转化数据类型(如果有必要)
        NSString *argumentTypeString = [NSString stringWithUTF8String:argumentType];
        if ([argumentTypeString isEqualToString:@"@"]) { // id
            // 如果是dictionary,可能存在 PYTHEME_THEME_COLOR
            if ([obj isKindOfClass:[NSDictionary class]]) { // NSDictionary
                objCopy = [obj mutableCopy];
                // 取出所有键
                NSArray *keys = [objCopy allKeys];
                for (NSString *key in keys) {
                    // 取出值
                    id value = objCopy[key];
                    if ([value isKindOfClass:[NSString class]] && [value isEqualToString:PYTHEME_THEME_COLOR]) {
                        // 替换成颜色
                        [objCopy setValue:_currentThemeColor forKey:key];
                    }
                }
                [invocation setArgument:&objCopy atIndex:i + 2];
            } else { // 其他
                [invocation setArgument:&obj atIndex:i + 2];
            }
        }  else if ([argumentTypeString isEqualToString:@"B"]) { // bool
            bool objVaule = [obj boolValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"f"]) { // float
            float objVaule = [obj floatValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"d"]) { // double
            double objVaule = [obj doubleValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"c"]) { // char
            char objVaule = [obj charValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"i"]) { // int
            int objVaule = [obj intValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"I"]) { // unsigned int
            unsigned int objVaule = [obj unsignedIntValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"S"]) { // unsigned short
            unsigned short objVaule = [obj unsignedShortValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"L"]) { // unsigned long
            unsigned long objVaule = [obj unsignedLongValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"s"]) { // shrot
            short objVaule = [obj shortValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"l"]) { // long
            long objVaule = [obj longValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"q"]) { // long long
            long long objVaule = [obj longLongValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"C"]) { // unsigned char
            unsigned char objVaule = [obj unsignedCharValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"Q"]) { // unsigned long long
            unsigned long long objVaule = [obj unsignedLongLongValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"{CGRect={CGPoint=dd}{CGSize=dd}}"]) { // CGRect
            CGRect objVaule = [obj CGRectValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        } else if ([argumentTypeString isEqualToString:@"{UIEdgeInsets=dddd}"]) { // UIEdgeInsets
            UIEdgeInsets objVaule = [obj UIEdgeInsetsValue];
            [invocation setArgument:&objVaule atIndex:i + 2];
        }
    }
    // 4.调用方法
    [invocation invoke];
    // 5. 设置返回值
    id returnValue = nil;
    if (methodSignate.methodReturnLength != 0) { // 有返回值
        // 将返回值赋值给returnValue
        [invocation getReturnValue:&returnValue];
    }
    return returnValue;
}

细节处理

1. 设置主题色的方式
  • 通过属性直接设置主题色
  • 通过调用方法并以主题色为参数来设置主题色
  • 通过调用方法但主题色被封装后(如:NSDictionary)作为参数设置主题色
2. 自动管理内存管理

当对象应该被释放后,下一次当主题色池有新元素添加时,会遍历主题色池,根据对象的引用计数来决定是否移除对象(实现自动管理内存),因此:主题色池中最多可能会残留一个对象,这对内存几乎没有任何影响,如果要及时释放对象本人认为可以采用KVO监听对象的引用计数(未尝试),但是耗能高,不建议这么做!

3. 当对象为_UIAppearance类时,不添加到主题色池

了解UIAppearance的读者应该可以理解,而且使用UIAppearance的目的也为为了设置全局色,所以为了避免冲突,如果使用了该“技术”就不添加到主题色池

设置主题图片

观察了新浪微博、酷狗音乐等app,发现设置主题图片还是很有必要的,而且发现每套主题皮肤/图片都有对应的主题色,所以在设计接口的时候都考虑了这方面的需求。先看一下设置主题图片的基本原理,如下:

  1. 创建一个主题图片池(使用懒加载)
  2. 将相关控件对象直接添加到主题图片池中
  3. 设置主题图片时,通过block把主题图片池中的所有对象传递给用户,用户实现block,在block中获得对象,并根据需求设置相关属性完成主题图片的设置

####代码实现:

1. 创建一个主题图片池(使用懒加载)
/** 主题图片池 */
static NSMutableArray<id> *_themeImagePool;

- (NSMutableArray *)themeImagePool
{
    if (!_themeImagePool) {
        _themeImagePool = [NSMutableArray array];
    }
    return _themeImagePool;
}
2. 添加相关控件到主题图片池中

因为在设置图片是,比较复杂,如UITabBar上面的UIBarItem的图片、字体颜色等,所以为了满足大部分用户的需求,决定采用的是直接存储控件对象

/** 添加到主题图片池 */
- (void)py_addToThemeImagePool;

/** 从主题图片池中移除 */
- (void)py_removeFromThemeImagePoo

实现如下:

#pragma mark - Theme Image
/** 添加到主题图片池 */
- (void)py_addToThemeImagePool
{
    // 如果对象为_UIAppearance,直接返回
    Class appearanceClass = NSClassFromString(@"_UIAppearance");
    if ([self isMemberOfClass:appearanceClass]) return;

    if ([self isKindOfClass:[UITabBarItem class]]) { // 如果是UITabBarItem,判断是否有设置图片
        UITabBarItem *item = (UITabBarItem *)self;
        if (!item.image) { // 没有设置图片
            item.image = [[UIImage alloc] init];
        }
        if (!item.selectedImage) { // 没有设置图片
            item.selectedImage = [[UIImage alloc] init];
        }
    }
    // 判断是否已经在主题图片池中
    if (![[self themeImagePool] containsObject:self]) { // 不在主题图片池中
        [[self themeImagePool] addObject:self];
    }
    // 遍历主题图片池(移除应该被回收的对象)
    for (id object in [self themeImagePool]) {
        NSInteger retainCount = [[object valueForKey:@"retainCount"] integerValue];
        if (retainCount == 2) { // 对象应该被回收了
            [[self themeImagePool] removeObject:self];
        }
    }
}

/** 从主题图片池中移除 */
- (void)py_removeFromThemeImagePool
{
    // 如果对象为_UIAppearance,直接返回
    Class appearanceClass = NSClassFromString(@"_UIAppearance");
    if ([self isMemberOfClass:appearanceClass]) return;

    // 判断是否已经在图片池中
    if ([[self themeImagePool] containsObject:self]) { // 在主题图片池中
        [[self themeImagePool] removeObject:self];
    }
}
3. 设置主题图片和相关配色

当设置图片时,会通过block将主题图片池里面的所有控件传递给用户,用户根据需求进行相关设置,如果提供了配色,就会采用上面设置主题色功能来设置主题色

/** 
 * 重新加载主题图片
 * themeColor : 主题色
 * block : 设置主题图片时调用的block
 */
- (void)py_reloadThemeImageWithThemeColor:(UIColor *)themeColor setting:(PYThemeImageSettingBlock)block;

实现如下:

/** 重新加载主题图片 */
- (void)py_reloadThemeImageWithThemeColor:(UIColor *)themeColor setting:(PYThemeImageSettingBlock)block
{
    if (themeColor) { // 有主题色,设置主题色
        [self py_setThemeColor:themeColor];
    }

    if (block) { // 存在block,直接调用
        block([self themeImagePool]);
    }
}
使用

假设现在有这么一个需求:更换主题图片时,更换UITabBarItem的图片

  1. 将UITabBarItem添加到图片池
    // UITabBarItem
    [childController.tabBarItem py_addToThemeImagePool];
  2. 切换主题图片并设置配色为红色
    // 重新加载主题图片,并设置主题色为红色
    [self py_reloadThemeImageWithThemeColor:[UIColor redColor] setting:^(const NSArray<id> *objects) {
        // 根据控件类型完成相关设置
    }

总结

篇幅可能有点大,能耐心读到这里的读者相信会有不少收获的,希望读者在阅读此教程的时候,千万不要学习代码实现,而是要多思考:为什么要这样实现?那样实现有什么不好?多学学接口为什么要这样设计,那样设计是不是更合理?当你带着这些问题再回过头来去看看源码时,希望你会有更多的收货!当然,这里只是提供了一种思路,你也可以在此基础上实现夜间模式的切换等。期待你们的实现!

期望

当然如果您有更多的想法想表达或者交流的话,欢迎到留言/评论!因为本人比较喜欢活跃在GitHub社区,所以,如果您有什么想反馈的也可以issuse me,在这也鼓励大家去多多发现优秀源码,并且共享给大家。毕竟分享是双方获利的,何乐而不为?

源码地址:github.com/iphone5solo…
源码作者:CoderKo1o

本文参与掘金技术征文:juejin.cn/post/684490…