仿写及比较标哥的iOS时钟动画

一、前言

  以前看各种绚丽的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 

原文地址:https://www.cnblogs.com/hushuai-ios/p/5295542.html