iOS關於換膚和夜間模式的一些思考

_silhouette發表於2018-05-14

介紹

  • 好久沒寫文章了,正好最近在研究換膚,所以將最近的心得和體會與大家分享一下。

  • iOS換膚的方式比較單一,查詢了很多資料,發現主流的方式有如下兩種:

    • 方式一:通過給 Category 新增屬性的方式實現換膚,有一個 Manager 用以管理顏色和圖片,當主題改變時,通過發出通知告訴 UIKit 中的相關類,該改變檢視顏色了,這時檢視就會根據 Manager 中提供的不同主題的顏色來改變自己的顏色。

      • 這種方案的優點在於:整體思路比較簡單明瞭,實現起來也不困難。
      • 缺點在於:
        • 對於每種控制元件,都已經將顏色固定死,沒有辦法設定比如同一個父檢視的兩個子檢視不同的顏色顯示。
        • 當我們的專案已經完成了,而且專案體積也比較大時,這種方式的缺點就暴露的非常明顯了:更改介面十分麻煩,因為我們的介面比較多時,需要給每個介面的每個控制元件都新增在 Category 中增加的屬性, 這種方式工作量巨大。
    • 方式二:使用系統提供的 UIAppearance 來更改主題,這種方式的優點在於,系統提供了非常簡單方便的 API 供我們使用,最常用的就是 + (instancetype)appearance; 方法和 + (instancetype)appearanceWhenContainedIn:(Class<UIAppearanceContainer>)ContainerClass, …; 這兩個方法。具體用法如下: [[UINavigationBar appearance] setBarTintColor:myNavBarBackgroundColor]; 可以設定全域性的 UINavigationBar 的 barTintColor。而 [[UIBarButtonItem appearanceWhenContainedIn:[UINavigationBar class], nil] setBackgroundImage:myNavBarButtonBackgroundImage forState:state barMetrics:metrics]; 表示在指定檢視中設定 color,在此示例中是設定 UINavigationBar 上的 UIBarButtonItem 的背景圖片。

    • 這種方式的原理在於:使用 UI_APPEARANCE_SELECTOR 標記的方式會將當前對 UI 設定的外觀儲存起來,等到檢視在新增到 window 之前會呼叫這個之前儲存的外觀,更新檢視外觀。所以並不是 UIKit 中所有的類的所有屬性都可以使用這個方法來設定 UI,只有當屬性上有標誌 UI_APPEARANCE_SELECTOR 才可以用這個方法來設定。

      • 這種方式的優點是可以十分便捷的設定一些全域性的系統控制元件的外觀。

      • 但是缺點也十分明顯:

        • 當我們想要區分同一個父檢視上方的子檢視時,這種方案就會十分的不方便,與第一種方法一樣,很難達到個性化定製的目的。
        • 並且當我們想要設定 UILabel 等控制元件在不同檢視上的字型顏色等時,經常會失效,通過檢視系統 API,可以發現 UILabel 的 setTextColor: 等方法並沒有 UI_APPEARANCE_SELECTOR 標誌位,所以這也是這個換膚方式並不是萬能的原因。Stack Overflow有一篇關於 UILabel 設定顏色失效的原因,他們說這是蘋果系統的一個 bug。而解決這個問題的方法也比較簡單,只要我們重寫 setTextColor: 方法,給它加上一個 UI_APPEARANCE_SELECTOR 標誌位,那麼就可以給它定製顏色。但是這種方式的缺點也十分明顯,對程式碼的改動並沒有任何減少。反而當有很多控制元件都不能正確顯示顏色時,還需要增加很大的工作量。
      • 總結:我認為這種設定 UIAppearance 的方式還是比較適用於當全域性的顏色已經固定時,設定主題,比如 UINavigationBar 和 UITabbar 這種控制元件,就比較適合使用這種方式來進行操作。當我們的換膚比較簡單,不涉及類似夜間模式這種需要幾乎把所有的控制元件顏色都改變時,我覺得也可以使用這種方法來進行換膚操作。

      • 另外:這個方法需要注意的一個點是,當我們改變主題顏色時,需要先將控制元件從 window 上移除,再重新新增才會觸發這種方式。

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

自己的想法

  • 首先我們應該明確需求背景:
    • 最基本的就是:能夠實現換膚
    • 專案已經完成,並且專案比較複雜不適合一個控制器一個控制器的去修改
    • 能夠實現控制元件的個性化顏色定製,而並不是所有的一類控制元件都是同種顏色
  • 產生的問題:
    • 是否可以結合上述兩種方式,產生自己的方式來進行簡便的換膚?
    • 如何做到儘量少改動程式碼,就能實現換膚的效果?
    • 如何實現控制元件的個性化顏色定製?
  • 如何解決:
    • 既然整個專案都已經完成,那麼如果我想盡量少改動程式碼,是否可以使用 methodSwizzling 的方式來 hook 系統的 setXXXColor: 方法實現不需要或儘量少對原專案程式碼進行改動。
    • 既然需要對控制元件進行個性化定製,是否可以使用 tag 的方式,對需要個性化的控制元件新增 tag 從而根據不同的 tag 來使用不同的顏色,而不需要個性化的顏色保持原本狀態不進行修改。

我的實踐

  • 首先需要提供一個 Manager 來進行主題的控制,在我的專案中,它叫做 LYThemeManager , 這個 Manager 的作用是控制切換不同的主題,當主題進行改變時,可以發出通知,告知 UI 控制元件該改變自己的顏色了。並且它所提供的 (UIColor *)colorWithReceiver:(id)receiver selString:(NSString *)selector;(UIColor *)colorWithReceiver:(id)receiver withTag:(NSInteger)tag selString:(NSString *)selector; 分別是實現全域性控制元件 UI 的設定以及 個性化控制元件 UI 的設定。

    • LYThemeManager 內部有兩個字典,分別是讀取不同的 plist , colorInfoDic 用於讀取全域性 UI 的顏色設定,而 specialColorInfoDic 用於讀取個性化控制元件的顏色設定,具體的 plist 中的內容如下:

      iOS關於換膚和夜間模式的一些思考

      iOS關於換膚和夜間模式的一些思考
      在 specialPlist 中前面的數字表示 tag 值,後面表示設定的屬性意義。

    • 以 UIView 的 category 為例,首先在這個類中,使用了 methodSwizzle 來實現 hook 系統方法,在這裡我 hook 了系統的 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 來選方法,注意子類和父類不要使用同名方法,否則會導致符號混亂產生迴圈引用。
          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];
          }
      }
      複製程式碼

      在這裡為什麼我要使用個性化顏色設定的方法:(UIColor *)colorWithReceiver:(id)receiver withTag:(NSInteger)tag selString:(NSString *)selector; ,這是因為幾乎所有 UIKit 中的控制元件都繼承自 UIView,當我們直接將所有的 setBackgroundColor: 方法都設定為同一顏色時,達到的效果是災難性的所有控制元件都是同一顏色。無法進行區分。所以這裡使用個性化的,只對 controller 中的 view 改變顏色。

    • 新增了一個字典屬性 pickers, 這個屬性用以將我們 hook 的方法新增進來,它的 key 是方法名, value是它應該被設定的 color,當收到改變顏色的通知時,需要遍歷這個屬性中所有的資料,來實現顏色更新。

      @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;
      }
      複製程式碼
    • 最後就是對通知的響應:

      #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,並且響應方式都同於 UIView ,所以在其他的 category 中省去了對屬性 picker 的 Add Property 步驟以及對通知的響應。

    • 在 UILabel 中的 setTextColor: 方法也使用了個性化的設定,對於不需要特殊設定的 UILabel 的 textColor 則原本預設是什麼顏色,就是什麼顏色。

    • 所有的 tag 值,我都以巨集定義的方式儲存在 ThemeConfig.pch 中了,當需要個性定義的控制元件比較多時,通過 tag 管理也是一個缺點。

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

      • 缺點在於:
        • 比如說通過 tag 來管理顏色,實際上也會修改原專案的程式碼,因為我們需要設定不同控制元件的 tag 值。
        • hook 系統的方法或許會帶來意想不到的bug。不過在我 hook 的這種方式下,當在顏色匹配表中找不到對應欄位時,會直接使用原來的顏色進行設定,感覺也沒有什麼特別大的問題。
      • 這種方式的優勢在於:
        • 可以儘可能減少對原專案的改動
        • 並且可以實現對不同要求的控制元件進行個性化定製。基本上完成了對一開始提出的問題的解決。

總結

  • 這種方案還是一種比較不成熟的方案,沒有經過真正專案的認證,當專案比較大時,這種方案可能還是不能夠很好的解決問題。不過這也是一次新的嘗試。以後我會就這方面繼續進行修改和嘗試。也歡迎有想法的大家來與我進行討論,希望能不吝賜教!
  • 專案的程式碼在:這個地址

相關文章