一、前言
以前看各种绚丽的UI特效动画代码,采用的方法是会先运行一篇,然后直接去看实现代码。初学时抱着瞻仰的态度去接触,去认识,是没有错的。但是在了解了像素、动画渲染机制,CoreAnimation API,推导过二维、三维的仿射矩阵之后,我们可以改变阅读UI动画博文或者是源码的方式了。
Talk is cheap, show me the code——Linus Torvalds。
大量的仿写;一定一定要多写——叶孤城__ 在CodeReview线下大会上的发言。
最近安居客、猿题库、蘑菇街、滴滴都有在谈iOS客户端的架构设计,很多童鞋在说看不懂或者根本就是viper之类的话,是不是举重若轻不敢轻易评论。但只有经历过多人合作,没有统一架构规范,不断填充ViewController, 使得VC从几十行增长到千余行再拆分至几百行;经历过近百个VC类的各种产品跳转需求创(瞎)新(搞),才能了解Massive ViewController的痛和页面跳转逻辑cyclomatic complexity超量的难以承受吧。
二、仿写的UI动画结果比较
原文链接:http://www.henishuo.com/clock-animation/
标哥博文提供的工程运行截图 | 笔者的工程运行截图 |
从呈现效果的直观认识来看,质量是相近的;
从UI美观上来看,标哥集中在核心功能编码,我有些注重无谓的美学外观,因此对指针和钟心的指针盖冒都做了路径绘制,看起来会漂亮一点么^^
从运行性能上来看,CPU的消耗都是0,内存、动画流畅性等方面是差不多的
从组件可用性来看,标哥当然不该浪费精力做这么个简单的组件,所以我提供的组件API还是比较多的,提供了代码xib兼容初始化,钟表时间的设置,暂停,运行等,钟表时间值的手动KVO,表盘背景图的设置等,基本上有虚拟钟表的需求时,我的这个组件是可以直接拿来用的。
从编码思路上看,标哥将现实世界问题直接转换到机器实质,比如直接指定指针动画的duration;而我的组件开发思路一直是搭建现实世界到机器世界的中间桥梁,这样任何现实世界的规律都能通过中间桥梁转换到工程方法和UI显示。任何运行状态都能通过中间桥梁映射到现实世界,被人类逻辑所理解。标哥的思路定然是高效的,但我的思路更贴近人类思维。还是那句话吧,编程之路法无定法,但由你自己选择。
三、UI与技术需求分析
所有的需求分析和编码工作是在阅读标哥提供的源码Demo之前的,以锻炼个人独立分析问题、解决问题的能力。
UI实现上,因为不提供交互,所以选择轻量级的CALayer,用到的OC类主要是UIView、CAShapeLayer、UIBezierPath。另外在中心盖帽的绘制上,我用了CAGradientLayer。
逻辑实现上,我的思路是周期一秒钟后,人为去驱动钟表时间属性变化和UI更新,因此用到了NSTimer。这里NSTimer有retain cycle的问题,常用的解决方案有弱引用,中间代理,GCD Timer等。标哥选择了第一种,我的看法是我需要强执有我要用的东西,当然这也是从哲学思辨来考虑。因此,我用了中间代理这种方法,以前有写过,就直接拿来用了。在KVO的实现上,我使用了手动KVO,因为time属性提供给使用方用setter方法来设置更改,接入方肯定不想观察到自己设置时的KVO,还得先移除,再添加。因此,我编码时setter方法时不发布变化信息,而是在钟表自动运行时time的改变提供手动KVO.
其它需要注意的是,NSTimer的创建与提交需要消耗CPU,因此不要频繁的创建销毁,只在接入方设置更改当前时间时,更换Timer。
四、类设计与编码
在其它语言中,有接口的概念但OC没有。那么如何面向接口编程呢,我想Protocol是一种可取的方法。在写一个类之前,如果有时间还是要做一下接口设计比较好。示例如下:
@protocol HSClockViewProtocol <NSObject> /** * 一个时钟与外界的通信,就是它的时间。 * 要有setter/getter, KVO-compliance */ @property (nonatomic, assign) NSTimeInterval time; /** * 暂停时钟运行 */ - (void) pause; /** * 继续或者开始时钟运行 */ - (void) work; /** * 设置表盘背景图 * * @param image 表盘背景图,UIImage对象 */ - (void) setDialBackgroundImage:(UIImage *) image; @end
五、现实世界与机器世界的转换关系
在虚拟时钟这个问题上还是比较简单的,主要在于时间字符串或者Unix时间戳到三个指针的弧度角行向量的转换,代码如下:
/** * 时针、分针、秒针的弧度角(左手二维坐标系下,与X轴正方向的夹角。从屏幕外看,顺时针为增长方向) */ typedef struct HSClockHandRadian { double hourRadian; double minuteRadian; double secondRadian; } HSClockHandRadian; HSClockHandRadian HSRadianFromTimeInterval(NSTimeInterval time) { time += 8 * 60 * 60; //北京时间 +8 NSInteger offsetIn12Hour = (NSInteger)time % (12 * 60 * 60); // 以12小时为周期时,偏移的秒数,时针 NSInteger offsetIn1Hour = (NSInteger)time % (1 * 60 * 60); // 以1小时为周期时,偏移的秒数,分针 NSInteger offsetIn1Minute = (NSInteger)time % (1 * 60); // 以1分钟为周期时,偏移的秒数,秒针 HSClockHandRadian handRadian; handRadian.hourRadian = offsetIn12Hour * 1.0 / (12 * 60 * 60) * M_PI * 2- M_PI_2; handRadian.minuteRadian = offsetIn1Hour * 1.0 / (1 * 60 * 60) * M_PI * 2 - M_PI_2; handRadian.secondRadian = offsetIn1Minute * 1.0 / (1 * 60) * M_PI * 2 - M_PI_2; return handRadian; } HSClockHandRadian HSTimeFromTimeStr(NSString *timeStr) { NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; dateFormatter.dateFormat = @"yyyy-MM-dd hh:mm:ss"; NSString *dateStr = [NSString stringWithFormat:@"1970-01-01 %@", timeStr]; NSDate *date = [dateFormatter dateFromString:dateStr]; NSTimeInterval timeStamp = [date timeIntervalSince1970]; return HSRadianFromTimeInterval(timeStamp); } HSClockHandRadian HSTimeFromDate(NSDate *date) { NSTimeInterval timeStamp = [date timeIntervalSince1970]; return HSRadianFromTimeInterval(timeStamp); }
六、指针弧度角到仿射矩阵的变换
二维中的平移、缩换、平面原点为圆心旋转、平面任何点为圆心旋转,三维中的平移、缩换、绕坐标轴旋转、绕任意轴旋转、透视等,都在于仿射矩阵的变化。笔者建议,还是自己去把转换关系推导出来,因此不打算提供转换矩阵^^
在这里提供几点思路和注意点:
1.cor_new = cor_old * M,其中cor_new、cor_old均为行向量,一个是原值,一个是期望值,这两个我们知道后,可以把仿射矩阵M推导出来。
2.iOS在CA中采用与UIKit相同的左手坐标系,三维坐标系时Z轴向外。二维时从屏幕外看,顺时针为旋转角增长方向。三维时看向旋转轴的负方向,顺时针为旋转角的增长方向。实际上,二维时绕原点的旋转即绕Z轴旋转。
3.绕任意轴旋转时,先将坐标系转换,使得旋转轴与一坐标轴重合,在此坐标系完成旋转后,再做坐标系逆转换。
4.三维视效主要体现在透视点的设置上。一般设定下,人眼从屏幕外看动画,即透视点在z轴上变化。
5.推导过程涉及到矩阵运算,相乘,求逆等;涉及到三角函数和差化积等。
七、工程中声明的私有属性、成员变量和私有方法
关于在Extension里写私有属性还是在implement后的花括号里写成员变量,唐巧大神有过论述,有兴趣的可以去看下唐巧的技术博客。私有方法是否在Extension里声明呢,我的看法是尽量写一下,别人看你代码的时候能够迅速的知道你实现了哪些私有方法。代码示例如下:
@interface HSClockView()
/**
* 内部标识时钟是否在运行中
*/
@property (nonatomic, assign, getter=isWorking) BOOL working;
/**
* 初始化当前时间,背景,指针, 供代码创建与xib创建共用
*/
- (void) p_initClockView;
/**
* 初始化指针并返回
*
* @param width 指针宽度
* @param height 指针高度
* @param tailLength 指针尾部长度
* @param tickLength 指针尖部长度
*
* @return 初始化好path的ShapeLayer
*/
- (CAShapeLayer *) p_handLayerWithWidth:(CGFloat)width height:(CGFloat)height tailLength:(CGFloat)tailLength tickLength:(CGFloat)tickLength;
/**
* 不含时钟运行标识判断与修改的私有方法,动画执行与UI更新主方法
*
* @param time 要设置的时间戳
*/
- (void) p_setTime:(NSTimeInterval)time;
/**
* 定时器的触发处理,更新钟表时间
*/
- (void) p_handleTimeSource;
@end
@implementation HSClockView {
CAShapeLayer *_hourLayer;
CAShapeLayer *_minuteLayer;
CAShapeLayer *_secondLayer;
NSTimer *_timer;
}
八、结语
写这个工程Demo差不多用了5个小时,编码速度还有待提高;在编码思路上,再思考是搭建现实世界桥梁,还是直接转换成机器思维,或者是将两者良好的综合运用。
另外,要真正做好三维特效的动画,要对光源、材质,光线跟踪等方面有些了解,比如聚光灯、泛光灯、平行光;金属材质、塑料材质、玻璃材质;阴影反射变化等。笔者以前做过3DMax建模与动画,欢迎童鞋一起讨论。
本文的工程源码:https://github.com/1962449521/OCDemos/tree/master/ClockDemo