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. 封装细节

参考

关于使用CALayer中mask的一些技巧

CALayer拥有mask属性,Apple的官方解释如下:

An optional layer whose alpha channel is used to mask the layer’s content. The layer’s alpha channel determines how much of the layer’s content and background shows through. Fully or partially opaque pixels allow the underlying content to show through but fully transparent pixels block that content.

mask同样也是一个CALayer。假设将CALayer本身称为ContentLayer,将mask称为MaskLayer,蒙版(Masking)的工作原理是通过MaskLayer的alpha值定义ContentLayer的显示区域:对于ContentLayer上每一个Point,计算公式为ResultLayer = ContentLayer * MaskLayer_Alpha。所以当alpha为1时Content显示,alpha为0时Content不显示,其他处于0与1之间的值导致Content半透明。

需要注意的是:

  1. MaskLayer的color不重要,主要使用opacity(CALayer中的alpha),但是注意[UIColor clearColor]其实就是alpha为0的color。
  2. ContentLayer超出MaskLayer以外的部分不会被显示出来。
  3. MaskLayer必须是个“单身汉”,不能有sublayers,否则蒙版(Masking)的结果就是未知(Undefined)。

由于mask是一个CALayer,可以通过组合产生很多非常棒的效果。例如可以将MaskLayer指定为CAGradientLayer类型实现Gradient效果,可以给MaskLayer添加动画,下面两个例子就是这种用法的经典实例:

理解anchorPoint,position,frame的关系

对于UIView的frameboundscenter都比较熟悉:

  • bounds指定UIView自身坐标系。
  • center指定UIView在superview中的位置坐标。
  • frame由互不影响(正交)的boundscenter生成。

但是我一直有这样的疑问:为什么要使用UIView中心位置的center来指定坐标,为什么不用其他位置?

后来学习了CALayer,发现UIView的主要任务其实是响应事件(这就是为什么从UIResponer继承的原因),而将显示委托给CALayer处理。一个成功的UIView背后总有一个默默贡献的CALayer,CALayer作为GPU/OpenGL纹理的一种高层封装,处理显示的方方面面,UIView则将CALayer一些功能再次封装,提供简洁好用的接口。像UIView中的framebounds就是直接使用CALayer中的framebounds

但是对于center,CALayer对应项是position,为什么不同名了呢?因为position更灵活,谁规定“指定UIView在superview中的位置坐标”一定要在UIView的中心位置呢?!而position默认在中心位置的目的我觉得是为了方便Rotation,因为一般Rotation都是绕着中心旋转,UIView为了简化使用,满足大部分情况即可,所以就将默认在中心的position封装成了center

由于CALayer的position并没有限制一定要在bounds的中心位置,所以就需要一个属性来描述positionbounds中的位置,这样才能推算出frame的origin点位置。于是anchorPoint出现了,CALayer用anchorPoint指定positionbounds中的位置,有如下特点:

  • 为了可以任意指定位置,因此anchorPoint就不使用绝对值,而是比例值。
  • 由于Rotation总是围绕position旋转,因此指定position在Layer中位置的anchorPoint就被命名为锚点(像船的锚一样固定位置)。

总之,frame是由positionboundsanchorPoint共同生成(其实还有transform,这就需要了解Current Transform Matrix的概念,为了减少复杂性,这里就不涉及了),公式如下:

  • frame.origin.x = position.x - bounds.size.width * anchorPoint.x
  • frame.origin.y = position.y - bounds.size.height * anchorPoint.y

这就解释了:

  1. 为什么anchorPoint改变会导致frame改变,而position却没变?
  2. 为什么anchorPoint要使用比例而不是具体值?

豁然开朗。

PS. 多思考,保持好奇心,多从设计者角度想想为什么这样设计,即使是简单的概念,也会有收获。Stay Hungry,Stay Foolish。

UIView中hidden、alpha、clear color与opaque的区别

关于UIView的透视度,有四个属性(下图中红框中的项)都与之相关,作为一个喜欢刨根问底的程(处)序(女)员(座),一定要搞清楚它们各自的用处跟区别是什么呢?

hidden

此属性为BOOL值,用来表示UIView是否隐藏。关于隐藏大家都知道就是让UIView不显示而已,但是需要注意的是:

  • 当前UIView的所有subview也会被隐藏,忽略subview的hidden属性。UIView中的subview就相当于UIView的死忠小弟,老大干什么我们就跟着老大,同进同退,生死与共!
  • 当前UIView也会从响应链中移除。你想你都不显示了,就不用在响应链中接受事件了。

alpha

此属性为浮点类型的值,取值范围从0到1.0,表示从完全透明到完全不透明,其特性有:

  • 当前UIView的alpha值会被其所有subview继承。因此,alpha值会影响到UIView跟其所有subview。
  • alpha具有动画效果。当alpha为0时,跟hidden为YES时效果一样,但是alpha主要用于实现隐藏的动画效果,在动画块中将hidden设置为YES没有动画效果。

backgroundColor的alpha(Clear Color)

此属性为UIColor值,而UIColor可以设置alpha的值,其特性有:

  • 设置backgroundColor的alpha值只影响当前UIView的背景,并不会影响其所有subview。这点是同alpha的区别,Clear Color就是backgroundColor的alpha为1.0。
  • alpha值会影响backgroundColor最终的alpha。假设UIView的alpha为0.5,backgroundColor的alpha为0.5,那么backgroundColor最终的alpha为0.25(0.5乘以0.5)。

opaque

此属性为BOOL值。要搞清楚这个属性的作用,就要先了解绘图系统的一些原理:屏幕上的每个像素点都是通过RGBA值(Red、Green、Blue三原色再配上Alpha透明度)表示的,当纹理(UIView在绘图系统中对应的表示项)出现重叠时,GPU会按照下面的公式计算重叠部分的像素(这就是所谓的“合成”):

Result = Source + Destination * (1 - SourceAlpha)

Result是结果RGB值,Source为处在重叠顶部纹理的RGB值,Destination为处在重叠底部纹理的RGB值。通过公式发现:当SourceAlpha为1时,绘图系统认为下面的纹理全部被遮盖住了,Result等于Source,直接省去了计算!尤其在重叠的层数比较多的时候,完全不同考虑底下有多少层,直接用当前层的数据显示即可,这样大大节省了GPU的工作量,提高了效率。(多像现在一些“美化墙”,不管后面的环境多破烂,“美化墙”直接遮盖住了,什么都看不到,不用整治改进,省心省力)。更详细的可以读下objc.io中<绘制像素到屏幕上>这篇文章。

那什么时候SourceAlpha为1呢?这时候就是opaque上场的时候啦!当opaque为YES时,SourceAlpha为1。opaque就是绘图系统向UIView开放的一个性能开关,开发者根据当前UIView的情况(这些是绘图系统不知道的,所以绘图系统也无法优化),将opaque设置为YES,绘图系统会根据此值进行优化。所以,如果在开发时某UIView是不透明的,就将opaque设置为YES,能优化显示效率。

需要注意的是:

  1. 当UIView的opaque为YES时,其alpha必须为1.0,这样才符合opaque为YES的场景。如果alpha不为1.0,最终的结果将是不可预料的(unpredictable)。
  2. opaque只对UIView及其subclass生效,对系统提供的类(像UIButton,UILabel)是没有效果的。

参考

UIImage图片拉伸

为什么进行图片拉伸

  1. 灵活:对于一些带边框的背景图而言,如果不进行图片拉伸,每当改变frame时就需要一份新size的背景图
  2. 节约资源:对于一些比较规则的图片,没必须使用frame大小,而是使用一个满足拉伸条件的较小的图片进行拉伸,这样加载图片变快,而且图片大小变小

使用方法

使用- resizeableImageWithCapInsets:(UIEdgeInsets)capInsets,iOS官方解释如下:

You use this method to add cap insets to an image or to change the existing cap insets of an image. In both cases, you get back a new image and the original image remains untouched.
During scaling or resizing of the image, areas covered by a cap are not scaled or resized. Instead, the pixel area not covered by the cap in each direction is tiled, left-to-right and top-to-bottom, to resize the image. This technique is often used to create variable-width buttons, which retain the same rounded corners but whose center region grows or shrinks as needed. For best performance, use a tiled area that is a 1x1 pixel area in size.

原理就是对UIImage指定了Insets后,在Insets中的区域不进行拉伸,只对区域外的部分进行拉伸。需要注意的有两点:

  1. UIEdgeInsetsMake的四个参数从前到后依次是top、left、bottom、right,不是成对的,别搞错了
  2. 拉伸的方式不是全方向一起拉伸!而是先左右拉伸,再上下拉伸,最后的结果是两次拉伸的叠加!不然会出现这样的问题

参考