在第10章“緩沖”中,我們研究了 CAMediaTimingFunction
,它是一個通過控制 動畫緩沖來模擬物理效果例如加速或者減速來增強(qiáng)現(xiàn)實(shí)感的東西,那么如果想更加 真實(shí)地模擬物理交互或者實(shí)時(shí)根據(jù)用戶輸入修改動畫改怎么辦呢?在這一章中,我 們將繼續(xù)探索一種能夠允許我們精確地控制一幀一幀展示的基于定時(shí)器的動畫。
定時(shí)幀
動畫看起來是用來顯示一段連續(xù)的運(yùn)動過程,但實(shí)際上當(dāng)在固定位置上展示像素的時(shí)候并不能做到這一點(diǎn)。一般來說這種顯示都無法做到連續(xù)的移動,能做的僅僅是足夠快地展示一系列靜態(tài)圖片,只是看起來像是做了運(yùn)動。
我們之前提到過iOS
按照每秒60
次刷新屏幕,然后CAAnimation
計(jì)算出需要展示 的新的幀,然后在每次屏幕更新的時(shí)候同步繪制上去, CAAnimation
最機(jī)智的地方在于每次刷新需要展示的時(shí)候去計(jì)算插值和緩沖。
在第10
章中,我們解決了如何自定義緩沖函數(shù),然后根據(jù)需要展示的幀的數(shù)組來告 訴 CAKeyframeAnimation
的實(shí)例如何去繪制。所有的Core Animation
實(shí)際上都是按照一定的序列來顯示這些幀,那么我們可以自己做到這些么?
NSTimer
實(shí)際上,我們在第三章“圖層幾何學(xué)”中已經(jīng)做過類似的東西,就是時(shí)鐘那個例子, 我們用了 NSTimer
來對鐘表的指針做定時(shí)動畫,一秒鐘更新一次,但是如果我們 把頻率調(diào)整成一秒鐘更新60
次的話,原理是完全相同的。
我們來試著用 NSTimer
來修改第十章中彈性球的例子。由于現(xiàn)在我們在定時(shí)器啟 動之后連續(xù)計(jì)算動畫幀,我們需要在類中添加一些額外的屬性來存儲動畫的fromValue
,toValue
, duration
和當(dāng)前的timeOffset
。
#import "ViewController.h"
@interface ViewController ()
@property (weak, nonatomic) 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];
UIImage *ballImage = [UIImage imageNamed:@"ball"];
self.ballView = [[UIImageView alloc]initWithImage:ballImage];
[self.containerView addSubview:self.ballView];
[self animation];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[self animation];
}
float interpolate(float from, float to, float time){
return (to - from) * time + from;
}
- (id)interpolateFromeValue:(id)fromValue toVlaue:(id)toValue time:(float)time{
if ([fromValue isKindOfClass:[NSValue class]]) {
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];
}
}
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 / 1800;
}
return (54 / 5.0 * t * t) - (513/25.0 * t) + 268/25.0;
}
- (void)animation{
self.ballView.center = CGPointMake(150, 32);
self.duration = 3.0;
self.timeOffset = 0.0;
self.fromValue = [NSValue valueWithCGPoint:CGPointMake(150, 32)];
self.toValue = [NSValue valueWithCGPoint:CGPointMake(150, 268)];
[self.timer invalidate];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 / 60.0
target:self
selector:@selector(step:)
userInfo:nil repeats:YES];
}
- (void)step:(NSTimer *)step{
self.timeOffset = MIN(self.timeOffset + 1/60.0, self.duration);
float time = self.timeOffset / self.duration;
time = bounceEaseOut(time);
id position = [self interpolateFromeValue:self.fromValue toVlaue:self.toValue time:time];
self.ballView.center = [position CGPointValue];
if (self.timeOffset >= self.duration) {
[self.timer invalidate];
self.timer = nil;
}
}
@end
很贊,而且和基于關(guān)鍵幀例子的代碼一樣很多,但是如果想一次性在屏幕上對很多東西做動畫,很明顯就會有很多問題。
NSTimer
并不是最佳方案,為了理解這點(diǎn),我們需要確切地知道 NSTimer
是如 何工作的。iOS
上的每個線程都管理了一個NSRunloop
,字面上看就是通過一個 循環(huán)來完成一些任務(wù)列表。但是對主線程,這些任務(wù)包含如下幾項(xiàng):
- 處理觸摸事件
- 發(fā)送和接受網(wǎng)絡(luò)數(shù)據(jù)包
- 執(zhí)行使用gcd的代碼
- 處理計(jì)時(shí)器行為
- 屏幕重繪
當(dāng)你設(shè)置一個 NSTimer
,他會被插入到當(dāng)前任務(wù)列表中,然后直到指定時(shí)間過去 之后才會被執(zhí)行。但是何時(shí)啟動定時(shí)器并沒有一個時(shí)間上限,而且它只會在列表中 上一個任務(wù)完成之后開始執(zhí)行。這通常會導(dǎo)致有幾毫秒的延遲,但是如果上一個任 務(wù)過了很久才完成就會導(dǎo)致延遲很長一段時(shí)間。
屏幕重繪的頻率是一秒鐘六十次,但是和定時(shí)器行為一樣,如果列表中上一個執(zhí)行
了很長時(shí)間,它也會延遲。這些延遲都是一個隨機(jī)值,于是就不能保證定時(shí)器精準(zhǔn)
地一秒鐘執(zhí)行六十次。有時(shí)候發(fā)生在屏幕重繪之后,這就會使得更新屏幕會有個延
遲,看起來就是動畫卡殼了。有時(shí)候定時(shí)器會在屏幕更新的時(shí)候執(zhí)行兩次,于是動
畫看起來就跳動了。
我們可以通過一些途徑來優(yōu)化:
- 我們可以用 CADisplayLink 讓更新頻率嚴(yán)格控制在每次屏幕刷新之后。
- 基于真實(shí)幀的持續(xù)時(shí)間而不是假設(shè)的更新頻率來做動畫。
- 調(diào)整動畫計(jì)時(shí)器的 run loop 模式,這樣就不會被別的事件干擾。
CADisplayLink
CADisplayLink
是CoreAnimation
提供的另一個類似于NSTimer
的類,它總是在屏幕完成一次更新之前啟動,它的接口設(shè)計(jì)的和NSTimer
很類似,所以它實(shí)際上就是一個內(nèi)置實(shí)現(xiàn)的替代,但是和timeInterval
以秒為單位不同, CADisplayLink
有一個整型的 frameInterval
屬性,制定了間隔多少幀之后才執(zhí)行。默認(rèn)值是1
,意味著每次屏幕更新之前都會執(zhí)行一次。但是如果動畫的 代碼執(zhí)行起來超過了六十分之一秒,你可以指定frameInterval
為2
,就是說動 畫每隔一幀執(zhí)行一次(一秒鐘30
幀)或者3
,也就是一秒鐘20
次,等等。
用 CADisplayLink
而不是 NSTimer
,會保證幀率足夠連續(xù),使得動畫看起來更加平滑,但即使 CADisplayLink
也不能保證每一幀都按計(jì)劃執(zhí)行,一些失去控制的離散的任務(wù)或者事件(例如資源緊張的后臺程序)可能會導(dǎo)致動畫偶爾地丟幀。 當(dāng)使用 NSTimer
的時(shí)候,一旦有機(jī)會計(jì)時(shí)器就會開啟,但是 CADisplayLink
卻 不一樣:如果它丟失了幀,就會直接忽略它們,然后在下一次更新的時(shí)候接著運(yùn) 行。
計(jì)算幀的持續(xù)時(shí)間
無論是使用NSTimer
還是CADisplayLink
,我們?nèi)匀恍枰幚硪粠臅r(shí)間超出 了預(yù)期的六十分之一秒。由于我們不能夠計(jì)算出一幀真實(shí)的持續(xù)時(shí)間,所以需要手 動測量。我們可以在每幀開始刷新的時(shí)候用CACurrentMediaTime()
記錄當(dāng)前時(shí) 間,然后和上一幀記錄的時(shí)間去比較。
通過比較這些時(shí)間,我們就可以得到真實(shí)的每幀持續(xù)的時(shí)間,然后代替硬編碼的六 十分之一秒。
Run Loop 模式
注意到當(dāng)創(chuàng)建CADisplayLink
的時(shí)候,我們需要指定一個 run loop
和run loop mode
,對于run loop
來說,我們就使用了主線程的run loop
,因?yàn)槿魏斡脩?界面的更新都需要在主線程執(zhí)行,但是模式的選擇就并不那么清楚了,每個添加到run loop
的任務(wù)都有一個指定了優(yōu)先級的模式,為了保證用戶界面保持平滑,iOS
會 提供和用戶界面相關(guān)任務(wù)的優(yōu)先級,而且當(dāng)UI
很活躍的時(shí)候的確會暫停一些別的任 務(wù)。
一個典型的例子就是當(dāng)是用UIScrollView
滑動的時(shí)候,重繪滾動視圖的內(nèi)容會 比別的任務(wù)優(yōu)先級更高,所以標(biāo)準(zhǔn)的NSTimer
和網(wǎng)絡(luò)請求就不會啟動,一些常見 的run loop模式如下:
-
NSDefaultRunLoopMode
標(biāo)準(zhǔn)優(yōu)先級 -
NSRunLoopCommonModes
高優(yōu)先級 -
UITrackingRunLoopMode
用于UIScrollView
和別的控件的動畫
在我們的例子中,我們是用了NSDefaultRunLoopMode
,但是不能保證動畫平滑 的運(yùn)行,所以就可以用NSRunLoopCommonModes
來替代。但是要小心,因?yàn)槿绻?動畫在一個高幀率情況下運(yùn)行,你會發(fā)現(xiàn)一些別的類似于定時(shí)器的任務(wù)或者類似于 滑動的其他iOS
動畫會暫停,直到動畫結(jié)束。
同樣可以同時(shí)對 CADisplayLink
指定多個run loop
模式,于是我們可以同時(shí)加入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
模式配置,通過別的函數(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];
物理模擬
即使使用了基于定時(shí)器的動畫來復(fù)制第10
章中關(guān)鍵幀的行為,但還是會有一些本質(zhì) 上的區(qū)別:在關(guān)鍵幀的實(shí)現(xiàn)中,我們提前計(jì)算了所有幀,但是在新的解決方案中, 我們實(shí)際上實(shí)在按需要在計(jì)算。意義在于我們可以根據(jù)用戶輸入實(shí)時(shí)修改動畫的邏 輯,或者和別的實(shí)時(shí)動畫系統(tǒng)例如物理引擎進(jìn)行整合。
Chipmunk
我們來基于物理學(xué)創(chuàng)建一個真實(shí)的重力模擬效果來取代當(dāng)前基于緩沖的彈性動畫, 但即使模擬2D
的物理效果就已近極其復(fù)雜了,所以就不要嘗試去實(shí)現(xiàn)它了,直接用 開源的物理引擎庫好了。
我們將要使用的物理引擎叫做Chipmunk
。另外的2D
物理引擎也同樣可以(例如 Box2D
),但是Chipmunk
使用純C
寫的,而不是C++
,好處在于更容易和Objective-C
項(xiàng)目整合。Chipmunk
有很多版本,包括一個和Objective-C
綁定 的“indie”
版本。C
語言的版本是免費(fèi)的,所以我們就用它好了。你可以從物理引擎下載它。
Chipmunk
完整的物理引擎相當(dāng)巨大復(fù)雜,但是我們只會使用如下幾個類:
- cpSpace - 這是所有的物理結(jié)構(gòu)體的容器。它有一個大小和一個可選的重力 矢量
- cpBody - 它是一個固態(tài)無彈力的剛體。它有一個坐標(biāo),以及其他物理屬性, 例如質(zhì)量,運(yùn)動和摩擦系數(shù)等等。
- cpShape - 它是一個抽象的幾何形狀,用來檢測碰撞??梢越o結(jié)構(gòu)體添加一 個多邊形,而且
cpShape
有各種子類來代表不同形狀的類型。
在例子中,我們來對一個木箱建模,然后在重力的影響下下落。我們來創(chuàng)建一
個Crate
類,包含屏幕上的可視效果(一個UIImageView
)和一個物理模型 (一個cpBody
和一個 cpPolyShape
,一個 cpShape
的多邊形子類來代表矩形 木箱)。
用C
版本的Chipmunk
會帶來一些挑戰(zhàn),因?yàn)樗F(xiàn)在并不支持Objective-C
的引用計(jì) 數(shù)模型,所以我們需要準(zhǔn)確的創(chuàng)建和釋放對象。為了簡化,我們
把 cpShape
和cpBody
的生命周期和Crate
類進(jìn)行綁定,然后在木箱的- init
方法中創(chuàng)建,在-dealloc
中釋放。木箱物理屬性的配置很復(fù)雜,所以閱讀 了Chipmunk
文檔會很有意義。
視圖控制器用來管理 cpSpace
,還有和之前一樣的計(jì)時(shí)器邏輯。在每一步中,我 們更新 cpSpace
(用來進(jìn)行物理計(jì)算和所有結(jié)構(gòu)體的重新擺放)然后迭代對象, 然后再更新我們的木箱視圖的位置來匹配木箱的模型(在這里,實(shí)際上只有一個結(jié) 構(gòu)體,但是之后我們將要添加更多)。
Chipmunk
使用了一個和UIKit
顛倒的坐標(biāo)系(Y軸向上為正方向)。為了使得物理模 型和視圖之間的同步更簡單,我們需要通過使用 geometryFlipped
屬性翻轉(zhuǎn)容器 視圖的集合坐標(biāo)(第3章中有提到),于是模型和視圖都共享一個相同的坐標(biāo)系。
具體的代碼如下。注意到我們并沒有在任何地方釋放 cpSpace
對象。在這 個例子中,內(nèi)存空間將會在整個app
的生命周期中一直存在,所以這沒有問題。但 是在現(xiàn)實(shí)世界的場景中,我們需要像創(chuàng)建木箱結(jié)構(gòu)體和形狀一樣去管理我們的空 間,封裝在標(biāo)準(zhǔn)的Cocoa
對象中,然后來管理Chipmunk
對象的生命周期。
添加用戶交互
下一步就是在視圖周圍添加一道不可見的墻,這樣木箱就不會掉落出屏幕之外。或 許你會用另一個矩形的cpPolyShape
來實(shí)現(xiàn),就和之前創(chuàng)建木箱那樣,但是我們 需要檢測的是木箱何時(shí)離開視圖,而不是何時(shí)碰撞,所以我們需要一個空心而不是 固體矩形。
我們可以通過給cpSpace
添加四個 cpSegmentShape
對象(cpSegmentShape
代表一條直線, 所以四個拼起來就是一個矩形). 然后賦給空間的staticBody
屬性(一個不被重力影響的結(jié)構(gòu)體)而不是像木箱那樣一個新的cpBody
實(shí)例,因?yàn)槲覀儾幌胱屵@個邊框矩形滑出屏幕或者被一個下落的木箱擊中而消失.
同樣可以再添加一些木箱來做一些交互。最后再添加一個加速器,這樣可以通過傾 斜手機(jī)來調(diào)整重力矢量(為了測試需要在一臺真實(shí)的設(shè)備上運(yùn)行程序,因?yàn)槟M器 不支持加速器事件,即使旋轉(zhuǎn)屏幕)。
由于示例只支持橫屏模式,所以交換加速計(jì)矢量的x和y值。如果在豎屏下運(yùn)行程 序,請把他們換回來,不然重力方向就錯亂了。試一下就知道了,木箱會沿著橫向 移動。
模擬時(shí)間以及固定的時(shí)間步長
對于實(shí)現(xiàn)動畫的緩沖效果來說,計(jì)算每幀持續(xù)的時(shí)間是一個很好的解決方案,但是對模擬物理效果并不理想。通過一個可變的時(shí)間步長來實(shí)現(xiàn)有著兩個弊端:
如果時(shí)間步長不是固定的,精確的值,物理效果的模擬也就隨之不確定。這意
味著即使是傳入相同的輸入值,也可能在不同場合下有著不同的效果。有時(shí)候
沒多大影響,但是在基于物理引擎的游戲下,玩家就會由于相同的操作行為導(dǎo)
致不同的結(jié)果而感到困惑。同樣也會讓測試變得麻煩。由于性能故常造成的丟幀或者像電話呼入的中斷都可能會造成不正確的結(jié)果。
考慮一個像子彈那樣快速移動物體,每一幀的更新都需要移動子彈,檢測碰
撞。如果兩幀之間的時(shí)間加長了,子彈就會在這一步移動更遠(yuǎn)的距離,穿過圍
墻或者是別的障礙,這樣就丟失了碰撞。
我們想得到的理想的效果就是通過固定的時(shí)間步長來計(jì)算物理效果,但是在屏幕發(fā)生重繪的時(shí)候仍然能夠同步更新視圖(可能會由于在我們控制范圍之外造成不可預(yù)知的效果)。
幸運(yùn)的是,由于我們的模型(在這個例子中就是Chipmunk
的cpSpace
中
的 cpBody
)被視圖(就是屏幕上代表木箱的UIView
對象)分離,于是就很簡 單了。我們只需要根據(jù)屏幕刷新的時(shí)間跟蹤時(shí)間步長,然后根據(jù)每幀去計(jì)算一個或 者多個模擬出來的效果。
我們可以通過一個簡單的循環(huán)來實(shí)現(xiàn)。通過每次CADisplayLink
的啟動來通知屏 幕將要刷新,然后記錄下當(dāng)前的 CACurrentMediaTime()
。我們需要在一個小增量中提前重復(fù)物理模擬(這里用120
分之一秒)直到趕上顯示的時(shí)間。然后更新我 們的視圖,在屏幕刷新的時(shí)候匹配當(dāng)前物理結(jié)構(gòu)體的顯示位置。
避免死亡螺旋
當(dāng)使用固定的模擬時(shí)間步長時(shí)候,有一件事情一定要注意,就是用來計(jì)算物理效果的現(xiàn)實(shí)世界的時(shí)間并不會加速模擬時(shí)間步長。在我們的例子中,我們隨意選擇了 120分之一秒
來模擬物理效果。Chipmunk
很快,我們的例子也很簡單,所以 cpSpaceStep()
會完成的很好,不會延遲幀的更新。
但是如果場景很復(fù)雜,比如有上百個物體之間的交互,物理計(jì)算就會很復(fù)雜, cpSpaceStep()
的計(jì)算也可能會超出1/120秒
。我們沒有測量出物理步長的時(shí)間,因?yàn)槲覀兗僭O(shè)了相對于幀刷新來說并不重要,但是如果模擬步長更久的話, 就會延遲幀率。
如果幀刷新的時(shí)間延遲的話會變得很糟糕,我們的模擬需要執(zhí)行更多的次數(shù)來同步真實(shí)的時(shí)間。這些額外的步驟就會繼續(xù)延遲幀的更新,等等。這就是所謂的死亡螺旋,因?yàn)樽詈蟮慕Y(jié)果就是幀率變得越來越慢,直到最后應(yīng)用程序卡死了。
我們可以通過添加一些代碼在設(shè)備上來對物理步驟計(jì)算真實(shí)世界的時(shí)間,然后自動 調(diào)整固定時(shí)間步長,但是實(shí)際上它不可行。其實(shí)只要保證你給容錯留下足夠的邊 長,然后在期望支持的最慢的設(shè)備上進(jìn)行測試就可以了。如果物理計(jì)算超過了模擬 時(shí)間的50%
,就需要考慮增加模擬時(shí)間步長(或者簡化場景)。如果模擬時(shí)間步長增加到超過1/60秒
(一個完整的屏幕更新時(shí)間),你就需要減少動畫幀率到一秒30 幀
或者增加 CADisplayLink
的 frameInterval
來保證不會隨機(jī)丟幀,不然你的 動畫將會看起來不平滑。