iOS關(guān)于換膚和夜間模式的一些思考

介紹

  • 好久沒(méi)寫(xiě)文章了,正好最近在研究換膚,所以將最近的心得和體會(huì)與大家分享一下。

  • iOS換膚的方式比較單一,查找了很多資料,發(fā)現(xiàn)主流的方式有如下兩種:

    • 方式一:通過(guò)給 Category 添加屬性的方式實(shí)現(xiàn)換膚,有一個(gè) Manager 用以管理顏色和圖片,當(dāng)主題改變時(shí),通過(guò)發(fā)出通知告訴 UIKit 中的相關(guān)類,該改變視圖顏色了,這時(shí)視圖就會(huì)根據(jù) Manager 中提供的不同主題的顏色來(lái)改變自己的顏色。

      • 這種方案的優(yōu)點(diǎn)在于:整體思路比較簡(jiǎn)單明了,實(shí)現(xiàn)起來(lái)也不困難。
      • 缺點(diǎn)在于:
        • 對(duì)于每種控件,都已經(jīng)將顏色固定死,沒(méi)有辦法設(shè)置比如同一個(gè)父視圖的兩個(gè)子視圖不同的顏色顯示。
        • 當(dāng)我們的項(xiàng)目已經(jīng)完成了,而且項(xiàng)目體積也比較大時(shí),這種方式的缺點(diǎn)就暴露的非常明顯了:更改界面十分麻煩,因?yàn)槲覀兊慕缑姹容^多時(shí),需要給每個(gè)界面的每個(gè)控件都添加在 Category 中增加的屬性, 這種方式工作量巨大。
    • 方式二:使用系統(tǒng)提供的 UIAppearance 來(lái)更改主題,這種方式的優(yōu)點(diǎn)在于,系統(tǒng)提供了非常簡(jiǎn)單方便的 API 供我們使用,最常用的就是 + (instancetype)appearance; 方法和 + (instancetype)appearanceWhenContainedIn:(Class<UIAppearanceContainer>)ContainerClass, …; 這兩個(gè)方法。具體用法如下: [[UINavigationBar appearance] setBarTintColor:myNavBarBackgroundColor]; 可以設(shè)置全局的 UINavigationBar 的 barTintColor。而 [[UIBarButtonItem appearanceWhenContainedIn:[UINavigationBar class], nil] setBackgroundImage:myNavBarButtonBackgroundImage forState:state barMetrics:metrics]; 表示在指定視圖中設(shè)置 color,在此示例中是設(shè)置 UINavigationBar 上的 UIBarButtonItem 的背景圖片。

    • 這種方式的原理在于:使用 UI_APPEARANCE_SELECTOR 標(biāo)記的方式會(huì)將當(dāng)前對(duì) UI 設(shè)置的外觀保存起來(lái),等到視圖在添加到 window 之前會(huì)調(diào)用這個(gè)之前保存的外觀,更新視圖外觀。所以并不是 UIKit 中所有的類的所有屬性都可以使用這個(gè)方法來(lái)設(shè)置 UI,只有當(dāng)屬性上有標(biāo)志 UI_APPEARANCE_SELECTOR 才可以用這個(gè)方法來(lái)設(shè)置。

      • 這種方式的優(yōu)點(diǎn)是可以十分便捷的設(shè)置一些全局的系統(tǒng)控件的外觀。

      • 但是缺點(diǎn)也十分明顯:

        • 當(dāng)我們想要區(qū)分同一個(gè)父視圖上方的子視圖時(shí),這種方案就會(huì)十分的不方便,與第一種方法一樣,很難達(dá)到個(gè)性化定制的目的。
        • 并且當(dāng)我們想要設(shè)置 UILabel 等控件在不同視圖上的字體顏色等時(shí),經(jīng)常會(huì)失效,通過(guò)查看系統(tǒng) API,可以發(fā)現(xiàn) UILabel 的 setTextColor: 等方法并沒(méi)有 UI_APPEARANCE_SELECTOR 標(biāo)志位,所以這也是這個(gè)換膚方式并不是萬(wàn)能的原因。Stack Overflow有一篇關(guān)于 UILabel 設(shè)置顏色失效的原因,他們說(shuō)這是蘋(píng)果系統(tǒng)的一個(gè) bug。而解決這個(gè)問(wèn)題的方法也比較簡(jiǎn)單,只要我們重寫(xiě) setTextColor: 方法,給它加上一個(gè) UI_APPEARANCE_SELECTOR 標(biāo)志位,那么就可以給它定制顏色。但是這種方式的缺點(diǎn)也十分明顯,對(duì)代碼的改動(dòng)并沒(méi)有任何減少。反而當(dāng)有很多控件都不能正確顯示顏色時(shí),還需要增加很大的工作量。
      • 總結(jié):我認(rèn)為這種設(shè)置 UIAppearance 的方式還是比較適用于當(dāng)全局的顏色已經(jīng)固定時(shí),設(shè)置主題,比如 UINavigationBar 和 UITabbar 這種控件,就比較適合使用這種方式來(lái)進(jìn)行操作。當(dāng)我們的換膚比較簡(jiǎn)單,不涉及類似夜間模式這種需要幾乎把所有的控件顏色都改變時(shí),我覺(jué)得也可以使用這種方法來(lái)進(jìn)行換膚操作。

      • 另外:這個(gè)方法需要注意的一個(gè)點(diǎn)是,當(dāng)我們改變主題顏色時(shí),需要先將控件從 window 上移除,再重新添加才會(huì)觸發(fā)這種方式。

        - (void)p_updateSystemWindow {
            NSArray *windowArray = [UIApplication sharedApplication].windows;
            for (UIWindow *window in windowArray) {
                for (UIView *subView in window.subviews) {
                    [subView removeFromSuperview];
                    [window addSubview:subView];
                }
            }
        }
        

自己的想法

  • 首先我們應(yīng)該明確需求背景:
    • 最基本的就是:能夠?qū)崿F(xiàn)換膚
    • 項(xiàng)目已經(jīng)完成,并且項(xiàng)目比較復(fù)雜不適合一個(gè)控制器一個(gè)控制器的去修改
    • 能夠?qū)崿F(xiàn)控件的個(gè)性化顏色定制,而并不是所有的一類控件都是同種顏色
  • 產(chǎn)生的問(wèn)題:
    • 是否可以結(jié)合上述兩種方式,產(chǎn)生自己的方式來(lái)進(jìn)行簡(jiǎn)便的換膚?
    • 如何做到盡量少改動(dòng)代碼,就能實(shí)現(xiàn)換膚的效果?
    • 如何實(shí)現(xiàn)控件的個(gè)性化顏色定制?
  • 如何解決:
    • 既然整個(gè)項(xiàng)目都已經(jīng)完成,那么如果我想盡量少改動(dòng)代碼,是否可以使用 methodSwizzling 的方式來(lái) hook 系統(tǒng)的 setXXXColor: 方法實(shí)現(xiàn)不需要或盡量少對(duì)原項(xiàng)目代碼進(jìn)行改動(dòng)。
    • 既然需要對(duì)控件進(jìn)行個(gè)性化定制,是否可以使用 tag 的方式,對(duì)需要個(gè)性化的控件添加 tag 從而根據(jù)不同的 tag 來(lái)使用不同的顏色,而不需要個(gè)性化的顏色保持原本狀態(tài)不進(jìn)行修改。

我的實(shí)踐

  • 首先需要提供一個(gè) Manager 來(lái)進(jìn)行主題的控制,在我的項(xiàng)目中,它叫做 LYThemeManager , 這個(gè) Manager 的作用是控制切換不同的主題,當(dāng)主題進(jìn)行改變時(shí),可以發(fā)出通知,告知 UI 控件該改變自己的顏色了。并且它所提供的 (UIColor *)colorWithReceiver:(id)receiver selString:(NSString *)selector;(UIColor *)colorWithReceiver:(id)receiver withTag:(NSInteger)tag selString:(NSString *)selector; 分別是實(shí)現(xiàn)全局控件 UI 的設(shè)置以及 個(gè)性化控件 UI 的設(shè)置。

    • LYThemeManager 內(nèi)部有兩個(gè)字典,分別是讀取不同的 plist , colorInfoDic 用于讀取全局 UI 的顏色設(shè)置,而 specialColorInfoDic 用于讀取個(gè)性化控件的顏色設(shè)置,具體的 plist 中的內(nèi)容如下:

      image
      image

      在 specialPlist 中前面的數(shù)字表示 tag 值,后面表示設(shè)置的屬性意義。

    • 以 UIView 的 category 為例,首先在這個(gè)類中,使用了 methodSwizzle 來(lái)實(shí)現(xiàn) hook 系統(tǒng)方法,在這里我 hook 了系統(tǒng)的 setBackgroundColor: 方法和 setTintColor: 方法。

      + (void)load {
          [self swizzleViewColor];
      }
      
      #pragma mark - MethodSwizzling
      
      + (void)swizzleViewColor {
          static dispatch_once_t onceToken;
          dispatch_once(&onceToken, ^{
              [LYMethodSwizzleUtils swizzleInstanceMethodWithClass:[self class] OriginMethod:@selector(setBackgroundColor:) swappedMethod:@selector(ly_setBackgroundColor:)];
              [LYMethodSwizzleUtils swizzleInstanceMethodWithClass:[self class] OriginMethod:@selector(setTintColor:) swappedMethod:@selector(ly_setTintColor:)];
          });
      }
      
    • setBackgroundColor: 方法為例:

      - (void)ly_setBackgroundColor:(UIColor *)color {
          
      // 利用 selector 來(lái)選方法,注意子類和父類不要使用同名方法,否則會(huì)導(dǎo)致符號(hào)混亂產(chǎn)生循環(huán)引用。
          UIColor *bgColor = [[LYThemeManager shareManager] colorWithReceiver:self withTag:self.tag selString:[NSString stringWithFormat:@"%ld:viewBackgroundColor", self.tag]];
          if (bgColor) {
              [self.pickers setObject:bgColor forKey:@"setBackgroundColor:"];
              [self ly_setBackgroundColor:bgColor];
          } else {
              [self ly_setBackgroundColor:color];
          }
      }
      

      在這里為什么我要使用個(gè)性化顏色設(shè)置的方法:(UIColor *)colorWithReceiver:(id)receiver withTag:(NSInteger)tag selString:(NSString *)selector; ,這是因?yàn)閹缀跛?UIKit 中的控件都繼承自 UIView,當(dāng)我們直接將所有的 setBackgroundColor: 方法都設(shè)置為同一顏色時(shí),達(dá)到的效果是災(zāi)難性的所有控件都是同一顏色。無(wú)法進(jìn)行區(qū)分。所以這里使用個(gè)性化的,只對(duì) controller 中的 view 改變顏色。

    • 添加了一個(gè)字典屬性 pickers, 這個(gè)屬性用以將我們 hook 的方法添加進(jìn)來(lái),它的 key 是方法名, value是它應(yīng)該被設(shè)置的 color,當(dāng)收到改變顏色的通知時(shí),需要遍歷這個(gè)屬性中所有的數(shù)據(jù),來(lái)實(shí)現(xiàn)顏色更新。

      @interface UIView ()
      @property (nonatomic, strong) NSMutableDictionary <NSString *, UIColor *> *pickers;
      @end
      
      #pragma mark - Add Property
      
      - (NSMutableDictionary<NSString *,UIColor *> *)pickers {
          NSMutableDictionary <NSString *, UIColor *> *pickers = objc_getAssociatedObject(self, @selector(pickers));
          if (!pickers) {
              pickers = @{}.mutableCopy;
              objc_setAssociatedObject(self, @selector(pickers), pickers, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
              
              [[NSNotificationCenter defaultCenter] removeObserver:self];
              [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateTheme) name:LYThemeChangeNotification object:nil];
          }
          return pickers;
      }
      
    • 最后就是對(duì)通知的響應(yīng):

      #pragma mark - Response Notification
      
      - (void)updateTheme {
          
          [self.pickers enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, UIColor * _Nonnull obj, BOOL * _Nonnull stop) {
              SEL selector = NSSelectorFromString(key);
              [UIView animateWithDuration:0.3 animations:^{
      #pragma clang diagnostic push
      #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
                  [self performSelector:selector withObject:obj];
      #pragma clang diagnostic pop
              }];
          }];
      }
      
    • 由于幾乎所有的 UIKit 中的控件都繼承自 UIView,并且響應(yīng)方式都同于 UIView ,所以在其他的 category 中省去了對(duì)屬性 picker 的 Add Property 步驟以及對(duì)通知的響應(yīng)。

    • 在 UILabel 中的 setTextColor: 方法也使用了個(gè)性化的設(shè)置,對(duì)于不需要特殊設(shè)置的 UILabel 的 textColor 則原本默認(rèn)是什么顏色,就是什么顏色。

    • 所有的 tag 值,我都以宏定義的方式存儲(chǔ)在 ThemeConfig.pch 中了,當(dāng)需要個(gè)性定義的控件比較多時(shí),通過(guò) tag 管理也是一個(gè)缺點(diǎn)。

    • 整體上思路就是如此,這個(gè)方案只是一個(gè)初步方案,還有很多很多不足之處。

      • 缺點(diǎn)在于:
        • 比如說(shuō)通過(guò) tag 來(lái)管理顏色,實(shí)際上也會(huì)修改原項(xiàng)目的代碼,因?yàn)槲覀冃枰O(shè)置不同控件的 tag 值。
        • hook 系統(tǒng)的方法或許會(huì)帶來(lái)意想不到的bug。不過(guò)在我 hook 的這種方式下,當(dāng)在顏色匹配表中找不到對(duì)應(yīng)字段時(shí),會(huì)直接使用原來(lái)的顏色進(jìn)行設(shè)置,感覺(jué)也沒(méi)有什么特別大的問(wèn)題。
      • 這種方式的優(yōu)勢(shì)在于:
        • 可以盡可能減少對(duì)原項(xiàng)目的改動(dòng)
        • 并且可以實(shí)現(xiàn)對(duì)不同要求的控件進(jìn)行個(gè)性化定制。基本上完成了對(duì)一開(kāi)始提出的問(wèn)題的解決。

總結(jié)

  • 這種方案還是一種比較不成熟的方案,沒(méi)有經(jīng)過(guò)真正項(xiàng)目的認(rèn)證,當(dāng)項(xiàng)目比較大時(shí),這種方案可能還是不能夠很好的解決問(wèn)題。不過(guò)這也是一次新的嘗試。以后我會(huì)就這方面繼續(xù)進(jìn)行修改和嘗試。也歡迎有想法的大家來(lái)與我進(jìn)行討論,希望能不吝賜教!
  • 項(xiàng)目的代碼在:這個(gè)地址
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容