关于 iOS 应用主题管理与动态切换的一些想法与实现

今天突然想写篇博客,看了上一篇的发表时间,已经快接近三年半了,着实惭愧啊~。这篇文章主要介绍一下前段时间突发奇想写的一个管理 iOS 主题的开源小库:ThemeManager

为何要实现一个管理主题的库

其实做一个方便管理和切换 iOS 主题的库的想法是好几年前的事了,在这期间脑补过很多实现方案,当想开始做的时候又由于想到了方案的不足再加上我对切换主题没有什么需求,导致一直搁浅。前段时间突发奇想,来了灵感,先在脑袋里打了个草稿,几周后着手开始做,加上一两天空闲时间做的实验,最终花了我一晚上的时间(下班后,包括代码 + Demo + README + 适配各种依赖管理工具(Swift Package, Carthage, CocoaPods,包括本地测试) + 传到 CocoaPods 上,最终两点多完成)终于把这个库做出来了。所以做这玩意纯粹是个人兴趣加上突发奇想。

主题管理库的实现方案

一般的思路

依照常规的想法以及大部分现有开源库的思路,基本上都是先实现一个自定义的主题类,比如:MyTheme,在其中实现 UI 配置相关的属性,比如背景色、前景色、字体、背景图片等。然后实现一套自己的配置 UI 方面的方法,比如:my_backgroundColor,然后有一个叫 ThemeManager 的东西通过相应的方法负责切换主题,切换主题一般是 ThemeManager 发个通知,然后 UI 控件监听了此通知,然后做出相应的修改。我在 GitHub 上看到一个别人写的基于 Swift 的管理主题的库,也不知为何这么多 star,大致看了一下代码,虽然实现了切换主题的功能,但是设置 UI 属性时是将所有主题的相应属性值赋给 UI 属性,比如 label.theme_textColor = ["#000", "#FFF"],切换主题时通过 ThemeManager.setTheme(index: isNight ? 1 : 0) 这种方式,这种实现方式怎么说呢。。。我觉得只能用“呵呵”来代表我的想法吧。

类似这种思路的缺点

优点我就不说了,可能也就是能切换主题。但是缺点却是一大堆的,我可以罗列一些:

  • 实现臃肿、繁琐,需要对每个 UI 控件实现自己的 UI 配置方法,具体实现可能让人觉得会诡异;
  • 对系统控件有侵入性,有些方法或属性等需要通过 Method Swizzling 特性来实现,可能会带来一些未知的问题;
  • 扩展性极差,你只能使用开源库所实现的一些方法,如果有一些需要跟随主题动态改变的东西,你就无法实现了。
  • 可能需要注册过多的通知,由于可能是通过通知进行更换主题的,所以每个 UI 控件的实例或者相应的辅助类都需要监听主题改变的通知,我相信大家也都知道通知过多带来的坏处;
  • 占用更多的内存,首先是因为注册了过多的通知,其次是可能保存了各种主题配置,再次可能是由于实现主题切换功能,可能会需要很多的辅助类等。
  • 如果 UIKit 有更新,还需要实现相应的方法,可能导致库的更新不及时,也就导致你无法及时使用新的 UI 特性。
  • ……

我的思路

我最初的思路是有个 ThemeManager 用来管理和切换主题(这个肯定都一样),然后初始注册一个主题到 ThemeManager,但最主要的是怎么实现主题的切换。最一开始我想到的是扩展 UI 控件的方法,比如 -[UIView theme_setBackgroundColor:], 内部通过 -[ThemeManger setViewObject:forUIKeyPath:toValueOfThemeKeyPath:] 的方法将这些数据记录到 NSMapTable 数据类型的属性中,切换主题时只需要遍历这个属性,执行相应的方法即可实现主题的切换。

初看上去很不错哦,特别是 Swift 4 之后加入了 KeyPath 特性,都是后面一想还是不行的。因为有些是通过方法来设置 UI 属性的,比如说:-[UIButton setTitleColor:forState:],这种通过这样的方法就不好实现(当然也能实现,只是感觉比较丑陋、不够优雅,不是我想要的结果)。

就算以上的想法都实现了,但是还有一个问题我无法接受,就是内存占用问题。虽然用了 NSMapTable,但是 UIView 释放掉之后只是其 Key 自动变成了 nil,但是保存的 Value 仍然在内存中没有释放。何时以及怎么释放这些 Value 是个问题。

就是因为这些问题,困扰了我好几年的时间。前段时间突然开窍了,想到一个绝妙的方法来实现 UI 属性设置的问题,那就是通过闭包将设置属性的设置放进去,然后通过 ThemeItem 的结构体将 UI 和设置属性的闭包进行关联和存储。

这个是实现了,但是怎么做到 UI 释放掉之后自动清空其闭包呢?这个也是费了我几天的时间来思考,开始的想法是通过 Method Swizzling 来交换 dealloc 方法,然后在交换方法中移除相应的 ThemeItem。但是代码写完了之后发现编译器报错了,是不允许交换 dealloc 方法的,网上搜了一下发现可以通过 NSSelectorFromString 的函数进行交换,试了一下结果并没有任何卵用,交换是失败的,没有走。。。最后突然来了灵光闪现,我可以通过 objc runtime 的关联对象特性来解决这个问题呀!

我创建了一个 DeallocObserver 的类,里面放了两个属性,一个是记录需要观察被释放的对象的内存地址,这样我不需要对被观察对象进行引用,不会影响被观察者的生命周期;另一个是被观察对象被释放后需要执行的闭包,闭包有个参数值,就是存储的已释放对象之前的内存地址。我将 DeallocObserver 关联到被观察对象上,当被观察对象释放的时候,由于已经没人在引用 DeallocObserver 了,所以 DeallocObserver 也会紧接着被释放,此时就会执行到 DeallocObserver 的闭包,告诉外面做一些清理工作。比如我用来存储 ThemeItem 的变量 private var managedItems = [Int: [ThemeItem]](),因为 managedItems 是个字典,Key 值为被观察对象的内存地址,所以只需要执行 managedItems.removeValue(forKey:#memory address#) 即可。

Perfect! 所有的问题都解决了,实现方式超乎寻常的简单,最终代码只有不到 90 行,具体实现可以看一下 ThemeManager.swift

后续的改进(0.2)

由于第一版仅支持基于 UIView 的对象,发出去之后没几天想到一个想到一个了一个问题,有一些非继承自 UIView 的类就无法实现主题的切换了,比如一些继承自 UIBarItem 的类。那何必将其限定死为 UIView 呢?于是我将 ThemeItem 的类型限定开放为 AnyObject 了。

这样做有什么好处?好处显而易见,我不经过可以动态设置 UIView 的属性,我还可以动态设置其它类型的属性,比如:UIBarButtonItemtitleimage 等。另外我还改造了 ThemeManagersetup 方法,参数变为 Optional 参数,如果为 nil 就什么都不做,极大的方便了编码效率,举个例子,比如需要设置 navigationController?.navigationBar 的属性,我只需要通过如下代码即可轻松实现:

1
2
3
4
themeManager.setup(navigationController?.navigationBar) { (bar, theme) in
bar.tintColor = theme.mainColor
bar.barTintColor = theme.backgroundColor
}

除了 UI 的样式之外,后来我也想到,我的 ThemeManager 不仅仅可以做主题的切换,还可以做多语言的切换等所有你能想得到的它可以实现的东西,而所有这些没有动到 UIKit 的任何一点东西,比如想做一个多语言的切换,可以简单的实现如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 需要实现 Theme 协议
struct Language: Theme {
var greeting: String = "Hello"
}

// 初始化 ThemeManager 并使用默认的语言
let languageManager = ThemeManager(Language())

// 设置 label 的标题
languageManager.setup(label) { (label, language) in
label.text = language.greeting
}

// 切换语言,不管你用任何方式加载语言文件或初始化 Language,比如
var zhLanguage = Language();
zhLanguage.greeting = "你好"

// 变更语言
languageManager.apply(zhLanguage)

将来可以新增的特性

这些功能虽然足够用了,但是我觉得还可以做得更多。~~iOS 13 马上就要发布了,大家都知道 iOS 13 加入了黑色主题的支持,下一步我想做的就是监听系统黑白主题的的变化,通过回调告诉外面,然后你自己做决定是否根据系统主题的切换而切换当前主题。~~虽然系统提供了自动切换题的方法,但是系统将你的主题限定死了,只能使用两种主题,想要实现更多的主题,通过我这个库来实现,没错的~。除了这个之外,我还想在下一版支持自定义动画切换功能。


0.3.0 新功能

  • 增加主题变更时的通知
  • 可以自定义动画
1
2
3
4
5
6
7
// register notification
NotificationCenter.default.addObserver(self, selector: #selector(themeChanged(_:)), name: .ThemeDidChange, object: nil)

// custom animation theme change animation
themeManager.animationBlock = {
UIView.animate(withDuration: 0.3, animations: $0)
}

优点总结

  • 说到优点,首先一点就是对系统没有任何的入侵性,没有对系统库做任何修改;
  • 比较好的内存管理,相应的对象被释放后,其对应的相应的配置也被释放,而且当前仅保存一个主题实例;
  • 自由度很大,我不管你的 Theme 如何实现,不管你是怎么加载和保存主题的,你要做的只是实现我的 Theme 这个空的协议;
  • 系统发布新版本、新特性时,你不需要管 ThemeManager,它一样可以运行,并实现你想要的所有新特性;
  • 〇 通知注册,没有注册任何的通知;
  • 体积小、使用简单,这个库总共代码行数才 89 行(0.2 版本,包括空行和注释),虽然代码少、实现简单,但是可以满足几乎所有主题和语言切换的所有功能;
  • 支持主题切换动画,是主题切换起来过渡自然(过渡动画仅对可见视图有效,不可见视图使用动画是无任何意义的)。

最后,如果你觉得这个库还可以的话,那么欢迎您使用并 star,欢迎提出宝贵意见或者 PR。GitHub 地址:https://github.com/azone/ThemeManager。由于英文不够好,README 又全是用英文写的(真是胆大,献丑了😆),如果有什么不对的地方还望批评指正!

刚才发现搜狐的畅言竟然给我加了一些恶心的广告,果断弃用,还是改用 disqus 吧,disqus 也是支持广告的,是可配置的,我准备开启一下试试。