動(dòng)畫看起來是用來顯示一段連續(xù)的運(yùn)動(dòng)過程,但實(shí)際上當(dāng)在固定位置上展示像素的時(shí)候并不能做到這一點(diǎn)。一般來說這種顯示都無法做到連續(xù)的移動(dòng),能做的僅僅是足夠快地展示一系列靜態(tài)圖片,只是看起來像是做了運(yùn)動(dòng)。
我們之前提到過iOS按照每秒60次刷新屏幕,然后CAAnimation
計(jì)算出需要展示的新的幀,然后在每次屏幕更新的時(shí)候同步繪制上去,CAAnimation
最機(jī)智的地方在于每次刷新需要展示的時(shí)候去計(jì)算插值和緩沖。
在第10章中,我們解決了如何自定義緩沖函數(shù),然后根據(jù)需要展示的幀的數(shù)組來告訴CAKeyframeAnimation
的實(shí)例如何去繪制。所有的Core Animation實(shí)際上都是按照一定的序列來顯示這些幀,那么我們可以自己做到這些么?
NSTimer
實(shí)際上,我們?cè)诘谌隆皥D層幾何學(xué)”中已經(jīng)做過類似的東西,就是時(shí)鐘那個(gè)例子,我們用了NSTimer
來對(duì)鐘表的指針做定時(shí)動(dòng)畫,一秒鐘更新一次,但是如果我們把頻率調(diào)整成一秒鐘更新60次的話,原理是完全相同的。
我們來試著用NSTimer
來修改第十章中彈性球的例子。由于現(xiàn)在我們?cè)诙〞r(shí)器啟動(dòng)之后連續(xù)計(jì)算動(dòng)畫幀,我們需要在類中添加一些額外的屬性來存儲(chǔ)動(dòng)畫的fromValue
,toValue
,duration
和當(dāng)前的timeOffset
(見清單11.1)。
清單11.1 使用NSTimer
實(shí)現(xiàn)彈性球動(dòng)畫
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, strong) UIImageView *ballView;
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, assign) NSTimeInterval duration;
@property (nonatomic, assign) NSTimeInterval timeOffset;
@property (nonatomic, strong) id fromValue;
@property (nonatomic, strong) id toValue;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//add ball image view
UIImage *ballImage = [UIImage imageNamed:@"Ball.png"];
self.ballView = [[UIImageView alloc] initWithImage:ballImage];
[self.containerView addSubview:self.ballView];
//animate
[self animate];
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//replay animation on tap
[self animate];
}
float interpolate(float from, float to, float time)
{
return (to - from) * time + from;
}
- (id)interpolateFromValue:(id)fromValue toValue:(id)toValue time:(float)time
{
if ([fromValue isKindOfClass:[NSValue class]]) {
//get type
const char *type = [(NSValue *)fromValue objCType];
if (strcmp(type, @encode(CGPoint)) == 0) {
CGPoint from = [fromValue CGPointValue];
CGPoint to = [toValue CGPointValue];
CGPoint result = CGPointMake(interpolate(from.x, to.x, time), interpolate(from.y, to.y, time));
return [NSValue valueWithCGPoint:result];
}
}
//provide safe default implementation
return (time < 0.5)? fromValue: toValue;
}
float bounceEaseOut(float t)
{
if (t < 4/11.0) {
return (121 * t * t)/16.0;
} else if (t < 8/11.0) {
return (363/40.0 * t * t) - (99/10.0 * t) + 17/5.0;
} else if (t < 9/10.0) {
return (4356/361.0 * t * t) - (35442/1805.0 * t) + 16061/1805.0;
}
return (54/5.0 * t * t) - (513/25.0 * t) + 268/25.0;
}
- (void)animate
{
//reset ball to top of screen
self.ballView.center = CGPointMake(150, 32);
//configure the animation
self.duration = 1.0;
self.timeOffset = 0.0;
self.fromValue = [NSValue valueWithCGPoint:CGPointMake(150, 32)];
self.toValue = [NSValue valueWithCGPoint:CGPointMake(150, 268)];
//stop the timer if it's already running
[self.timer invalidate];
//start the timer
self.timer = [NSTimer scheduledTimerWithTimeInterval:1/60.0
target:self
selector:@selector(step:)
userInfo:nil
repeats:YES];
}
- (void)step:(NSTimer *)step
{
//update time offset
self.timeOffset = MIN(self.timeOffset + 1/60.0, self.duration);
//get normalized time offset (in range 0 - 1)
float time = self.timeOffset / self.duration;
//apply easing
time = bounceEaseOut(time);
//interpolate position
id position = [self interpolateFromValue:self.fromValue
toValue:self.toValue
time:time];
//move ball view to new position
self.ballView.center = [position CGPointValue];
//stop the timer if we've reached the end of the animation
if (self.timeOffset >= self.duration) {
[self.timer invalidate];
self.timer = nil;
}
}
@end
很贊,而且和基于關(guān)鍵幀例子的代碼一樣很多,但是如果想一次性在屏幕上對(duì)很多東西做動(dòng)畫,很明顯就會(huì)有很多問題。
NSTimer
并不是最佳方案,為了理解這點(diǎn),我們需要確切地知道NSTimer
是如何工作的。iOS上的每個(gè)線程都管理了一個(gè)NSRunloop
,字面上看就是通過一個(gè)循環(huán)來完成一些任務(wù)列表。但是對(duì)主線程,這些任務(wù)包含如下幾項(xiàng):
當(dāng)你設(shè)置一個(gè)NSTimer
,他會(huì)被插入到當(dāng)前任務(wù)列表中,然后直到指定時(shí)間過去之后才會(huì)被執(zhí)行。但是何時(shí)啟動(dòng)定時(shí)器并沒有一個(gè)時(shí)間上限,而且它只會(huì)在列表中上一個(gè)任務(wù)完成之后開始執(zhí)行。這通常會(huì)導(dǎo)致有幾毫秒的延遲,但是如果上一個(gè)任務(wù)過了很久才完成就會(huì)導(dǎo)致延遲很長一段時(shí)間。
屏幕重繪的頻率是一秒鐘六十次,但是和定時(shí)器行為一樣,如果列表中上一個(gè)執(zhí)行了很長時(shí)間,它也會(huì)延遲。這些延遲都是一個(gè)隨機(jī)值,于是就不能保證定時(shí)器精準(zhǔn)地一秒鐘執(zhí)行六十次。有時(shí)候發(fā)生在屏幕重繪之后,這就會(huì)使得更新屏幕會(huì)有個(gè)延遲,看起來就是動(dòng)畫卡殼了。有時(shí)候定時(shí)器會(huì)在屏幕更新的時(shí)候執(zhí)行兩次,于是動(dòng)畫看起來就跳動(dòng)了。
我們可以通過一些途徑來優(yōu)化:
CADisplayLink
讓更新頻率嚴(yán)格控制在每次屏幕刷新之后。run loop
模式,這樣就不會(huì)被別的事件干擾。CADisplayLink
CADisplayLink
是CoreAnimation提供的另一個(gè)類似于NSTimer
的類,它總是在屏幕完成一次更新之前啟動(dòng),它的接口設(shè)計(jì)的和NSTimer
很類似,所以它實(shí)際上就是一個(gè)內(nèi)置實(shí)現(xiàn)的替代,但是和timeInterval
以秒為單位不同,CADisplayLink
有一個(gè)整型的frameInterval
屬性,指定了間隔多少幀之后才執(zhí)行。默認(rèn)值是1,意味著每次屏幕更新之前都會(huì)執(zhí)行一次。但是如果動(dòng)畫的代碼執(zhí)行起來超過了六十分之一秒,你可以指定frameInterval
為2,就是說動(dòng)畫每隔一幀執(zhí)行一次(一秒鐘30幀)或者3,也就是一秒鐘20次,等等。
用CADisplayLink
而不是NSTimer
,會(huì)保證幀率足夠連續(xù),使得動(dòng)畫看起來更加平滑,但即使CADisplayLink
也不能保證每一幀都按計(jì)劃執(zhí)行,一些失去控制的離散的任務(wù)或者事件(例如資源緊張的后臺(tái)程序)可能會(huì)導(dǎo)致動(dòng)畫偶爾地丟幀。當(dāng)使用NSTimer
的時(shí)候,一旦有機(jī)會(huì)計(jì)時(shí)器就會(huì)開啟,但是CADisplayLink
卻不一樣:如果它丟失了幀,就會(huì)直接忽略它們,然后在下一次更新的時(shí)候接著運(yùn)行。
無論是使用NSTimer
還是CADisplayLink
,我們?nèi)匀恍枰幚硪粠臅r(shí)間超出了預(yù)期的六十分之一秒。由于我們不能夠計(jì)算出一幀真實(shí)的持續(xù)時(shí)間,所以需要手動(dòng)測量。我們可以在每幀開始刷新的時(shí)候用CACurrentMediaTime()
記錄當(dāng)前時(shí)間,然后和上一幀記錄的時(shí)間去比較。
通過比較這些時(shí)間,我們就可以得到真實(shí)的每幀持續(xù)的時(shí)間,然后代替硬編碼的六十分之一秒。我們來更新一下上個(gè)例子(見清單11.2)。
清單11.2 通過測量沒幀持續(xù)的時(shí)間來使得動(dòng)畫更加平滑
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, strong) UIImageView *ballView;
@property (nonatomic, strong) CADisplayLink *timer;
@property (nonatomic, assign) CFTimeInterval duration;
@property (nonatomic, assign) CFTimeInterval timeOffset;
@property (nonatomic, assign) CFTimeInterval lastStep;
@property (nonatomic, strong) id fromValue;
@property (nonatomic, strong) id toValue;
@end
@implementation ViewController
...
- (void)animate
{
//reset ball to top of screen
self.ballView.center = CGPointMake(150, 32);
//configure the animation
self.duration = 1.0;
self.timeOffset = 0.0;
self.fromValue = [NSValue valueWithCGPoint:CGPointMake(150, 32)];
self.toValue = [NSValue valueWithCGPoint:CGPointMake(150, 268)];
//stop the timer if it's already running
[self.timer invalidate];
//start the timer
self.lastStep = CACurrentMediaTime();
self.timer = [CADisplayLink displayLinkWithTarget:self
selector:@selector(step:)];
[self.timer addToRunLoop:[NSRunLoop mainRunLoop]
forMode:NSDefaultRunLoopMode];
}
- (void)step:(CADisplayLink *)timer
{
//calculate time delta
CFTimeInterval thisStep = CACurrentMediaTime();
CFTimeInterval stepDuration = thisStep - self.lastStep;
self.lastStep = thisStep;
//update time offset
self.timeOffset = MIN(self.timeOffset + stepDuration, self.duration);
//get normalized time offset (in range 0 - 1)
float time = self.timeOffset / self.duration;
//apply easing
time = bounceEaseOut(time);
//interpolate position
id position = [self interpolateFromValue:self.fromValue toValue:self.toValue
time:time];
//move ball view to new position
self.ballView.center = [position CGPointValue];
//stop the timer if we've reached the end of the animation
if (self.timeOffset >= self.duration) {
[self.timer invalidate];
self.timer = nil;
}
}
@end
注意到當(dāng)創(chuàng)建CADisplayLink
的時(shí)候,我們需要指定一個(gè)run loop
和run loop mode
,對(duì)于run loop來說,我們就使用了主線程的run loop,因?yàn)槿魏斡脩艚缑娴母露夹枰谥骶€程執(zhí)行,但是模式的選擇就并不那么清楚了,每個(gè)添加到run loop的任務(wù)都有一個(gè)指定了優(yōu)先級(jí)的模式,為了保證用戶界面保持平滑,iOS會(huì)提供和用戶界面相關(guān)任務(wù)的優(yōu)先級(jí),而且當(dāng)UI很活躍的時(shí)候的確會(huì)暫停一些別的任務(wù)。
一個(gè)典型的例子就是當(dāng)是用UIScrollview
滑動(dòng)的時(shí)候,重繪滾動(dòng)視圖的內(nèi)容會(huì)比別的任務(wù)優(yōu)先級(jí)更高,所以標(biāo)準(zhǔn)的NSTimer
和網(wǎng)絡(luò)請(qǐng)求就不會(huì)啟動(dòng),一些常見的run loop模式如下:
NSDefaultRunLoopMode
?- 標(biāo)準(zhǔn)優(yōu)先級(jí)NSRunLoopCommonModes
?- 高優(yōu)先級(jí)UITrackingRunLoopMode
?- 用于UIScrollView
和別的控件的動(dòng)畫在我們的例子中,我們是用了NSDefaultRunLoopMode
,但是不能保證動(dòng)畫平滑的運(yùn)行,所以就可以用NSRunLoopCommonModes
來替代。但是要小心,因?yàn)槿绻麆?dòng)畫在一個(gè)高幀率情況下運(yùn)行,你會(huì)發(fā)現(xiàn)一些別的類似于定時(shí)器的任務(wù)或者類似于滑動(dòng)的其他iOS動(dòng)畫會(huì)暫停,直到動(dòng)畫結(jié)束。
同樣可以同時(shí)對(duì)CADisplayLink
指定多個(gè)run loop模式,于是我們可以同時(shí)加入NSDefaultRunLoopMode
和UITrackingRunLoopMode
來保證它不會(huì)被滑動(dòng)打斷,也不會(huì)被其他UIKit控件動(dòng)畫影響性能,像這樣:
self.timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(step:)];
[self.timer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
[self.timer addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode];
和CADisplayLink
類似,NSTimer
同樣也可以使用不同的run loop模式配置,通過別的函數(shù),而不是+scheduledTimerWithTimeInterval:
構(gòu)造器
self.timer = [NSTimer timerWithTimeInterval:1/60.0
target:self
selector:@selector(step:)
userInfo:nil
repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer
forMode:NSRunLoopCommonModes];
更多建議: