SCRAnimationChain的设计思路

1. 出现原因

自从块(Block)方式的animateWithDuration:delay:options:animations:completion:方法出现之后,大家就使用此方法替换原来笨重且不清晰的旧方法来实现动画。但是在实现动画序列的时候,问题来了,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[UIView animateWithDuration:0.25 animations:^{
self.imageView.alpha = 0.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.headline.alpha = 0.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.content.alpha = 0.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.headline.alpha = 1.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.content.alpha = 1.0;
}];
}
}];
}
}];
}
}];
}
}];

为了实现动画序列,需要在前一个动画的completion块中新建下一个动画,当动画比较多的时候,嵌套层数能把人看醉了,维护起来各种蛋疼。作为一个有重度代码洁癖的程(处)序(女)员(座),怎么能够忍受这样的代码?所以就想实现一种动画的容器,将嵌套关系转换成并列关系,利用递归来封装嵌套操作,将动画序列用简单清晰的方式管理起来。

2. 设计思路

在实现之前,按照经验,先去Github上找一找有没有别人已经做好的实现,没必要重新造轮子,而且不一定造出来的有别人的好(多么痛的领悟~)。既然是开源,把别人的看懂了不就相当于自己的嘛。而且,一般你想到的自己觉得很犀利的点子说不定人家已经做烂了(多么痛的领悟x2~)。果然,找到了下面两个库,于是我读了下他们的源码,分析如下:

  • UIKitAnimationPro:将动画抽象成Action,用类似Spirit Kit的样式提供动画序列,思路很棒,但是问题在于:
    • 思路是为某一个UIView指定动画序列,这样不是特别合理,因为动画序列很有可能包含多个UIView的动画。
    • 虽然将rotate,scale等操作封装起来方便使用,但是还是不够灵活,应该提供options的接口。
    • 只能串行,没有并行。
  • CPAnimationSequence:没有上述的缺点,已经是非常全面的实现了。但是在读源码的过程中发现里面使用了组合(Composite)模式,但是并没有用好。基类不是一个接口类(Component),而是一个具体的节点类(Leaf),容器类(Composite)继承节点类。这里不是教条主义,一定要按照设计模式要求的来,而是有以下的问题:
    • 由于Composite继承了Leaf,导致Composite中从Leaf继承下来一些跟Composite无关的数据,只好使用NSAssert来确保外部不会使用,这是不合理的。
    • 并没有将组合模式的优点发挥出来,导致代码不够清晰。

基于以上原因,我决定实现一个新的动画序列容器SCRAnimationChain,来解决上面所说的问题。为什么前缀是SCR呢?因为是我名字(尚传人)拼音首字母的大写,而且根据《Effective Objective-C》的建议,由于苹果宣称保留使用所有两字母前缀的权利,为了避免以后可能跟系统库的前缀重名,所以最好使用三个字母前缀。

思路如下:

  1. 由于动画的动作被封装在块中,所以将动画抽象成Action类(封装Block),并且包含delay、duration、options等属性,用来保持最大的灵活性。
  2. 参考GCD的思路,实现Sequence跟Concurrent两种Container类,分别用来针对串行动画跟并行动画的需求。
  3. 由于Container与Action,类似文件目录与文件的关系,所以用组合模式抽象出一个接口类(Protocol)来处理他们之间的关系。这样的好处就是只要实现了接口类,不管具体是什么都可以放置到Container中,灵活性跟可扩展性都很强。

3. 实现

项目源码在这里

SCRAnimationActionProtocol

接口类,相当于组合模式中的Component,在Objective-C中用协议描述。

  • (void)runWithCompletion:(SCRAnimationCompletionBlock)completion:动画的核心就是运行,而为了能够形成链,所以增加了completion参数用来形成链。
  • (NSTimeInterval)workTime:在并行动画中需要找到最终结束时间最长的那个动画,这样才能在并行动画中将动画链传递下去。
  • (void)addAction:(id<SCRAnimationActionProtocol>)action:用来供容器添加动画Action。

SCRAnimationAction

封装具体的动画,相当于组合模式中的Leaf,里面的属性基本上是为了在调用animateWithDuration:delay:options:animations:completion:的时候用。

这里主要说明下prepare属性的用途。由于动画形成了序列,某个动画在执行时有可能前面的动画执行的一些操作会影响到当前动画,因此需要在执行此动画之前提供一个接口供其配置各种信息。

例如,在使用AutoLayout之后不能直接frame了,而是要使用contraint,在动画的块中调用contraint的superview(注意不能是contraint依附的view)的layoutIfNeeded。在动画序列开始前更新contraint的话,有可能前面的动画提前调用了layoutIfNeeded,导致动画出问题。(在Demo的ViewController中,你可以试试将那段在prepare块中的代码移到块之外,看会出现什么情况)

SCRAnimationContainer

由于SCRAnimationSequence跟SCRAnimationConcurrence内部都有一个NSMutableArray来保存Action,所以将重复的代码抽出来,形成两者的基类。总之,DRY(Don’t Repeat Yourself)!

SCRAnimationSequence

串行动画的容器。利用递归的方式实现串行执行:在runWithCompletion:中每次从内部数组中取出一个项(由于是id<SCRAnimationActionProtocol>类型,所以可以是SCRAnimationAction,SCRAnimationSequence,SCRAnimationConcurrence或者实现SCRAnimationActionProtocol协议的任意类,这就是基于接口编程跟组合模式带来的灵活性),对其调用runWithCompletion:,在completion块中再次调用本身来实现递归。

SCRAnimationConcurrence

并行动画的容器。直接用for循环,对内部数组的每一个Action调用runWithWithCompletion:,找出并行动画中workTime(delay + duration)最大的那个动画,将SCRAnimationConcurrence的completion块交给这个动画,确保completion能够得到执行。

主题更换的设计思路

现在App类似桌面软件的趋势,在功能完善之后,渐渐追求个性化,以满足不同用户的审美,主题更换就是其中一项,像Weico微博客户端,UC浏览器。所谓主题,可以看成相同功能不同展现可视资源的集合,例如,按钮无论在什么主题下都需要背景图片这个资源,只是在不同主题下是不同的背景图片而已。

如何在iOS中实现主题更换的核心思路为:

  1. 资源按主题放置:相同功能的资源名称相同,放在不同的主题路径或者前缀使用主题名。
  2. 增加中间层,隔离不同主题相同功能资源使用的变化。

1. 主题管理

主题的特性导致代码不关心资源的表现是什么,只关心资源的功能,而主题是易变化的,因此需要将易变化的部分抽离出来,整合到一个管理者中,主题的变化在管理者中完成,而不影响资源使用的地方。而且这个管理者是全局唯一的,因此使用单例。

1
2
3
4
5
6
7
8
9
+ (ThemeManager *)sharedInstance
{
static ThemeManager *sharedInstance = nil;
if (sharedInstance == nil)
{
sharedInstance = [[ThemeManager alloc] init];
}
return sharedInstance;
}

主题中的资源使用plist进行存储,颜色的RGBA值跟字体的信息可以直接存入plist,而图片则可以存入图片的位置。按主题命名plist文件,ThemeManager的初始化跟主题更换就从main bundle中按主题名字读取对应的plist文件。

1
2
3
4
5
6
7
8
9
10
11
12
- (id)init
{
if (self = [super init])
{
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSString *themeName = [defaults objectForKey:@"theme"] ?: @"default";
NSString *path = [[NSBundle mainBundle] pathForResource:themeName ofType:@"plist"];
self.theme = [NSDictionary dictionaryWithContentsOfFile:path];
}
return self;
}

代码中不再是直接使用主题相关的资源,而是通过ThemeManager得到对应主题下的资源。

1
2
3
4
5
6
7
// 直接使用资源:
UIImage *image = [UIImage imageNamed:@"xxx_btn_background"];
// 通过主题管理器使用资源:
NSDictionary *theme = [ThemeManager sharedInstance].theme;
NSString *imageName = [theme objectForKey:@"xxx_btn_background"];
UIImage *image = [UIImage imageNamed:imageName];

上面的代码在使用时还是有些复杂,代码只关心资源的功能,不关系也不应该关心取资源的细节,因此应在ThemeManager对取资源进行如下封装:

1
- (UIImage *)imageForKey:(NSString *)key;

在使用主题中的资源时,代码就变成了:

1
UIImage *image = [[ThemeManager sharedInstance] imageForKey:@"xxx_btn_background"];

2. 资源的放置

当系统将主题相关的资源文件部署到ios设备中时,在默认情况下,系统会将所有的资源plat平铺拷贝到mainBundle目录下,即使你的资源是按文件夹来组织的。(我们可以在模拟器中查看Bundle的情况,模拟器的路径是:~/Library/Application Support/iPhone Simulator

因此,在将资源文件加入到工程时,不要选默认的”Recursively create groups for any add folders”,要选择“Create Folder Reference for any add folders”,这样才能保证资源文件按照原有文件夹的组织格式被拷贝到mainBundle中。

关于上述的两个选项,就涉及到Xcode的Group(黄色)跟Folder Reference(蓝色)的概念了,参见从别处摘抄来的理解:

XCode项目中的文件夹分成两类: group 和 directory reference, 分别是虚结构和实结构. 黄颜色的 group 是默认的格式, 它的结构和磁盘上的文件夹毫无关系, 仅仅表示资源的逻辑组织结构, 这在管理源文件是非常方便. 同一段代码可以被很多项目使用, 也可能只使用一个目录的部分文件, 它不需要被拷贝到当前项目中, 但可以在当前项目中保持一个清晰的逻辑结构. 而且引用头文件时不需要指明复杂的层次结构, 因为这些文件在XCode看来是 flat 的, 即它们处在同一层文件夹里.
但是 group 带来便利的同时也导致更加棘手的麻烦, 文件重名冲突问题; 尤其当你要使用上千个资源文件时, 这种问题已经极难避免; 而且, 资源文件一般是要拷贝到目标程序中的, 虽然它们在项目中可以有结构的组织, 但是复制到程序中时将会 flat 地输出到程序的根目录中, 这将是怎样的一个灾难! 同时, 如果你在外部向文件夹中加入了上百幅图片, 你不得不把它们再向xcode中加入一遍. 归根结底, 还要求助于我们传统的蓝色的 directory reference。

3. 主题更换通知

对于没有显示的界面,更换主题是不需要通知的,因为在取资源时是根据当前主题取的,但是对于正在显示的界面,更换主题时就需要进行通知,让界面重新取资源后再重绘。由于这类通知是全局性的,因此应该使用NSNotification实现通知机制。

在ThemeManager的changeTheme中调用[NSNotificaitonCenter defaultCenter]postNotificationName:object:发出通知,而在各个涉及到主题更换的ViewController中使用addObserver:selector:name:object:监听通知事件。

4. 总结

其实主题的设计思路跟类簇很像,例如对于NSNumber,不同类型的数据其实真正返回的是NSNumber相对于此类型的子类,但是对于NSNumber的使用者而言,其并不关心NSNumber返回的具体子类是什么,只要满足NSNumber定义的接口就行。设计总是类似的,针对易变化的部分,增加一个中间层(接口)将易变化的部分封装起来,提供给使用者稳定不易变的服务。

总之,OOP跟DesignPattern在我看来主要做了两件事:

  1. 隔离变化
  2. 封装细节

参考