iOS关于换肤和夜间模式的一些思考

介绍

  • 好久没写文章了,正好最近在研究换肤,所以将最近的心得和体会与大家分享一下。

  • 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 中的内容如下:

      image
      image

      在 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 的这种方式下,当在颜色匹配表中找不到对应字段时,会直接使用原来的颜色进行设置,感觉也没有什么特别大的问题。
      • 这种方式的优势在于:
        • 可以尽可能减少对原项目的改动
        • 并且可以实现对不同要求的控件进行个性化定制。基本上完成了对一开始提出的问题的解决。

总结

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

推荐阅读更多精彩内容