iOS 實現快速切換主題詳細教程(附上原始碼)| 掘金技術徵文

CoderKo1o發表於2019-02-22

前言

iOS 實現主題切換,相信在未來的app裡也是會頻繁出現的,儘管現在只是出現在主流的APP,如(QQ、新浪微博、酷狗音樂、網易雲音樂等),但是現在是看顏值、追求個性的年代,所以根據使用者喜好自定義/切換主題也是未來app的必備功能了。

實現思路

為了降低耦合度,決定採用的方案是使用NSObject的分類來實現主題設定,有些讀者可能會想為何不使用UIView的分類而是使用NSObject的分類?建議這部分讀者看一下UIBarItem父類,然後仔細思考一下,就會理解了。

設定主題色

iOS 實現快速切換主題詳細教程(附上原始碼)| 掘金技術徵文
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

本文參與掘金技術徵文:gold.xitu.io/post/58522d…

相關文章