# 定時幀
動畫看起來是用來顯示一段連續的運動過程,但實際上當在固定位置上展示像素的時候并不能做到這一點。一般來說這種顯示都無法做到連續的移動,能做的僅僅是足夠快地展示一系列靜態圖片,只是看起來像是做了運動。
我們之前提到過iOS按照每秒60次刷新屏幕,然后`CAAnimation`計算出需要展示的新的幀,然后在每次屏幕更新的時候同步繪制上去,`CAAnimation`最機智的地方在于每次刷新需要展示的時候去計算插值和緩沖。
在第10章中,我們解決了如何自定義緩沖函數,然后根據需要展示的幀的數組來告訴`CAKeyframeAnimation`的實例如何去繪制。所有的Core Animation實際上都是按照一定的序列來顯示這些幀,那么我們可以自己做到這些么?
### `NSTimer`
實際上,我們在第三章“圖層幾何學”中已經做過類似的東西,就是時鐘那個例子,我們用了`NSTimer`來對鐘表的指針做定時動畫,一秒鐘更新一次,但是如果我們把頻率調整成一秒鐘更新60次的話,原理是完全相同的。
我們來試著用`NSTimer`來修改第十章中彈性球的例子。由于現在我們在定時器啟動之后連續計算動畫幀,我們需要在類中添加一些額外的屬性來存儲動畫的`fromValue`,`toValue`,`duration`和當前的`timeOffset`(見清單11.1)。
清單11.1 使用`NSTimer`實現彈性球動畫
~~~
@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
~~~
很贊,而且和基于關鍵幀例子的代碼一樣很多,但是如果想一次性在屏幕上對很多東西做動畫,很明顯就會有很多問題。
`NSTimer`并不是最佳方案,為了理解這點,我們需要確切地知道`NSTimer`是如何工作的。iOS上的每個線程都管理了一個`NSRunloop`,字面上看就是通過一個循環來完成一些任務列表。但是對主線程,這些任務包含如下幾項:
* 處理觸摸事件
* 發送和接受網絡數據包
* 執行使用gcd的代碼
* 處理計時器行為
* 屏幕重繪
當你設置一個`NSTimer`,他會被插入到當前任務列表中,然后直到指定時間過去之后才會被執行。但是何時啟動定時器并沒有一個時間上限,而且它只會在列表中上一個任務完成之后開始執行。這通常會導致有幾毫秒的延遲,但是如果上一個任務過了很久才完成就會導致延遲很長一段時間。
屏幕重繪的頻率是一秒鐘六十次,但是和定時器行為一樣,如果列表中上一個執行了很長時間,它也會延遲。這些延遲都是一個隨機值,于是就不能保證定時器精準地一秒鐘執行六十次。有時候發生在屏幕重繪之后,這就會使得更新屏幕會有個延遲,看起來就是動畫卡殼了。有時候定時器會在屏幕更新的時候執行兩次,于是動畫看起來就跳動了。
我們可以通過一些途徑來優化:
* 我們可以用`CADisplayLink`讓更新頻率嚴格控制在每次屏幕刷新之后。
* 基于真實幀的持續時間而不是假設的更新頻率來做動畫。
* 調整動畫計時器的`run loop`模式,這樣就不會被別的事件干擾。
### `CADisplayLink`
`CADisplayLink`是CoreAnimation提供的另一個類似于`NSTimer`的類,它總是在屏幕完成一次更新之前啟動,它的接口設計的和`NSTimer`很類似,所以它實際上就是一個內置實現的替代,但是和`timeInterval`以秒為單位不同,`CADisplayLink`有一個整型的`frameInterval`屬性,指定了間隔多少幀之后才執行。默認值是1,意味著每次屏幕更新之前都會執行一次。但是如果動畫的代碼執行起來超過了六十分之一秒,你可以指定`frameInterval`為2,就是說動畫每隔一幀執行一次(一秒鐘30幀)或者3,也就是一秒鐘20次,等等。
用`CADisplayLink`而不是`NSTimer`,會保證幀率足夠連續,使得動畫看起來更加平滑,但即使`CADisplayLink`也不能*保證*每一幀都按計劃執行,一些失去控制的離散的任務或者事件(例如資源緊張的后臺程序)可能會導致動畫偶爾地丟幀。當使用`NSTimer`的時候,一旦有機會計時器就會開啟,但是`CADisplayLink`卻不一樣:如果它丟失了幀,就會直接忽略它們,然后在下一次更新的時候接著運行。
### 計算幀的持續時間
無論是使用`NSTimer`還是`CADisplayLink`,我們仍然需要處理一幀的時間超出了預期的六十分之一秒。由于我們不能夠計算出一幀真實的持續時間,所以需要手動測量。我們可以在每幀開始刷新的時候用`CACurrentMediaTime()`記錄當前時間,然后和上一幀記錄的時間去比較。
通過比較這些時間,我們就可以得到真實的每幀持續的時間,然后代替硬編碼的六十分之一秒。我們來更新一下上個例子(見清單11.2)。
清單11.2 通過測量沒幀持續的時間來使得動畫更加平滑
~~~
@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
~~~
### Run Loop 模式
注意到當創建`CADisplayLink`的時候,我們需要指定一個`run loop`和`run loop mode`,對于run loop來說,我們就使用了主線程的run loop,因為任何用戶界面的更新都需要在主線程執行,但是模式的選擇就并不那么清楚了,每個添加到run loop的任務都有一個指定了優先級的模式,為了保證用戶界面保持平滑,iOS會提供和用戶界面相關任務的優先級,而且當UI很活躍的時候的確會暫停一些別的任務。
一個典型的例子就是當是用`UIScrollview`滑動的時候,重繪滾動視圖的內容會比別的任務優先級更高,所以標準的`NSTimer`和網絡請求就不會啟動,一些常見的run loop模式如下:
* `NSDefaultRunLoopMode`?- 標準優先級
* `NSRunLoopCommonModes`?- 高優先級
* `UITrackingRunLoopMode`?- 用于`UIScrollView`和別的控件的動畫
在我們的例子中,我們是用了`NSDefaultRunLoopMode`,但是不能保證動畫平滑的運行,所以就可以用`NSRunLoopCommonModes`來替代。但是要小心,因為如果動畫在一個高幀率情況下運行,你會發現一些別的類似于定時器的任務或者類似于滑動的其他iOS動畫會暫停,直到動畫結束。
同樣可以同時對`CADisplayLink`指定多個run loop模式,于是我們可以同時加入`NSDefaultRunLoopMode`和`UITrackingRunLoopMode`來保證它不會被滑動打斷,也不會被其他UIKit控件動畫影響性能,像這樣:
~~~
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模式配置,通過別的函數,而不是`+scheduledTimerWithTimeInterval:`構造器
~~~
self.timer = [NSTimer timerWithTimeInterval:1/60.0
target:self
selector:@selector(step:)
userInfo:nil
repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer
forMode:NSRunLoopCommonModes];
~~~
- Introduction
- 1. 圖層樹
- 1.1 圖層與視圖
- 1.2 圖層的能力
- 1.3 使用圖層
- 1.4 總結
- 2. 寄宿圖
- 2.1 contents屬性
- 2.2 Custom Drawing
- 2.3 總結
- 3. 圖層幾何學
- 3.1 布局
- 3.2 錨點
- 3.3 坐標系
- 3.4 Hit Testing
- 3.5 自動布局
- 3.6 總結
- 4. 視覺效果
- 4.1 圓角
- 4.2 圖層邊框
- 4.3 陰影
- 4.4 圖層蒙板
- 4.5 拉伸過濾
- 4.6 組透明
- 4.7 總結
- 5. 變換
- 5.1 仿射變換
- 5.2 3D變換
- 5.3 固體對象
- 5.4 總結
- 6. 專用圖層
- 6.1 CAShapeLayer
- 6.2 CATextLayer
- 6.3 CATransformLayer
- 6.4 CAGradientLayer
- 6.5 CAReplicatorLayer
- 6.6 CAScrollLayer
- 6.7 CATiledLayer
- 6.8 CAEmitterLayer
- 6.9 CAEAGLLayer
- 6.10 AVPlayerLayer
- 6.11 總結
- 7. 隱式動畫
- 7.1 事務
- 7.2 完成塊
- 7.3 圖層行為
- 7.4 呈現與模型
- 7.5 總結
- 8. 顯式動畫
- 8.1 屬性動畫
- 8.2 動畫組
- 8.3 過渡
- 8.4 在動畫過程中取消動畫
- 8.5 總結
- 9. 圖層時間
- 9.1 CAMediaTiming協議
- 9.2 層級關系時間
- 9.3 手動動畫
- 9.4 總結
- 10. 緩沖
- 10.1 動畫速度
- 10.2 自定義緩沖函數
- 10.3 總結
- 11. 基于定時器的動畫
- 11.1 定時幀
- 11.2 物理模擬
- 12. 性能調優
- 12.1. CPU VS GPU
- 12.2 測量,而不是猜測
- 12.3 Instruments
- 12.4 總結
- 13. 高效繪圖
- 13.1 軟件繪圖
- 13.2 矢量圖形
- 13.3 臟矩形
- 13.4 異步繪制
- 13.5 總結
- 14. 圖像IO
- 14.1 加載和潛伏
- 14.2 緩存
- 14.3 文件格式
- 14.4 總結
- 15. 圖層性能
- 15.1 隱式繪制
- 15.2 離屏渲染
- 15.3 混合和過度繪制
- 15.4 減少圖層數量
- 15.5 總結