iOS-Core-Animation之十一----基于定時(shí)器的動(dòng)畫

> *我可以指導(dǎo)你,但是你必須按照我說(shuō)的做。* --駭客帝國(guó)

在第10章“緩沖”中,我們研究了`CAMediaTimingFunction`,它是一個(gè)通過(guò)控制動(dòng)畫緩沖來(lái)模擬物理效果例如加速或者減速來(lái)增強(qiáng)現(xiàn)實(shí)感的東西,那么如果想更加真實(shí)地模擬物理交互或者實(shí)時(shí)根據(jù)用戶輸入修改動(dòng)畫改怎么辦呢?在這一章中,我們將繼續(xù)探索一種能夠允許我們精確地控制一幀一幀展示的基于定時(shí)器的動(dòng)畫。

##定時(shí)幀

動(dòng)畫看起來(lái)是用來(lái)顯示一段連續(xù)的運(yùn)動(dòng)過(guò)程,但實(shí)際上當(dāng)在固定位置上展示像素的時(shí)候并不能做到這一點(diǎn)。一般來(lái)說(shuō)這種顯示都無(wú)法做到連續(xù)的移動(dòng),能做的僅僅是足夠快地展示一系列靜態(tài)圖片,只是看起來(lái)像是做了運(yùn)動(dòng)。

我們之前提到過(guò)iOS按照每秒60次刷新屏幕,然后`CAAnimation`計(jì)算出需要展示的新的幀,然后在每次屏幕更新的時(shí)候同步繪制上去,`CAAnimation`最機(jī)智的地方在于每次刷新需要展示的時(shí)候去計(jì)算插值和緩沖。

在第10章中,我們解決了如何自定義緩沖函數(shù),然后根據(jù)需要展示的幀的數(shù)組來(lái)告訴`CAKeyframeAnimation`的實(shí)例如何去繪制。所有的Core Animation實(shí)際上都是按照一定的序列來(lái)顯示這些幀,那么我們可以自己做到這些么?

###`NSTimer`

實(shí)際上,我們?cè)诘谌隆皥D層幾何學(xué)”中已經(jīng)做過(guò)類似的東西,就是時(shí)鐘那個(gè)例子,我們用了`NSTimer`來(lái)對(duì)鐘表的指針做定時(shí)動(dòng)畫,一秒鐘更新一次,但是如果我們把頻率調(diào)整成一秒鐘更新60次的話,原理是完全相同的。

我們來(lái)試著用`NSTimer`來(lái)修改第十章中彈性球的例子。由于現(xiàn)在我們?cè)诙〞r(shí)器啟動(dòng)之后連續(xù)計(jì)算動(dòng)畫幀,我們需要在類中添加一些額外的屬性來(lái)存儲(chǔ)動(dòng)畫的`fromValue`,`toValue`,`duration`和當(dāng)前的`timeOffset`(見(jiàn)清單11.1)。

清單11.1使用`NSTimer`實(shí)現(xiàn)彈性球動(dòng)畫

```objective-c

@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ì)有很多問(wèn)題。

`NSTimer`并不是最佳方案,為了理解這點(diǎn),我們需要確切地知道`NSTimer`是如何工作的。iOS上的每個(gè)線程都管理了一個(gè)`NSRunloop`,字面上看就是通過(guò)一個(gè)循環(huán)來(lái)完成一些任務(wù)列表。但是對(duì)主線程,這些任務(wù)包含如下幾項(xiàng):

*處理觸摸事件

*發(fā)送和接受網(wǎng)絡(luò)數(shù)據(jù)包

*執(zhí)行使用gcd的代碼

*處理計(jì)時(shí)器行為

*屏幕重繪

當(dāng)你設(shè)置一個(gè)`NSTimer`,他會(huì)被插入到當(dāng)前任務(wù)列表中,然后直到指定時(shí)間過(guò)去之后才會(huì)被執(zhí)行。但是何時(shí)啟動(dòng)定時(shí)器并沒(méi)有一個(gè)時(shí)間上限,而且它只會(huì)在列表中上一個(gè)任務(wù)完成之后開始執(zhí)行。這通常會(huì)導(dǎo)致有幾毫秒的延遲,但是如果上一個(gè)任務(wù)過(guò)了很久才完成就會(huì)導(dǎo)致延遲很長(zhǎng)一段時(shí)間。

屏幕重繪的頻率是一秒鐘六十次,但是和定時(shí)器行為一樣,如果列表中上一個(gè)執(zhí)行了很長(zhǎng)時(shí)間,它也會(huì)延遲。這些延遲都是一個(gè)隨機(jī)值,于是就不能保證定時(shí)器精準(zhǔn)地一秒鐘執(zhí)行六十次。有時(shí)候發(fā)生在屏幕重繪之后,這就會(huì)使得更新屏幕會(huì)有個(gè)延遲,看起來(lái)就是動(dòng)畫卡殼了。有時(shí)候定時(shí)器會(huì)在屏幕更新的時(shí)候執(zhí)行兩次,于是動(dòng)畫看起來(lái)就跳動(dòng)了。

我們可以通過(guò)一些途徑來(lái)優(yōu)化:

*我們可以用`CADisplayLink`讓更新頻率嚴(yán)格控制在每次屏幕刷新之后。

*基于真實(shí)幀的持續(xù)時(shí)間而不是假設(shè)的更新頻率來(lái)做動(dòng)畫。

*調(diào)整動(dòng)畫計(jì)時(shí)器的`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í)行起來(lái)超過(guò)了六十分之一秒,你可以指定`frameInterval`為2,就是說(shuō)動(dòng)畫每隔一幀執(zhí)行一次(一秒鐘30幀)或者3,也就是一秒鐘20次,等等。

用`CADisplayLink`而不是`NSTimer`,會(huì)保證幀率足夠連續(xù),使得動(dòng)畫看起來(lái)更加平滑,但即使`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)行。

###計(jì)算幀的持續(xù)時(shí)間

無(wú)論是使用`NSTimer`還是`CADisplayLink`,我們?nèi)匀恍枰幚硪粠臅r(shí)間超出了預(yù)期的六十分之一秒。由于我們不能夠計(jì)算出一幀真實(shí)的持續(xù)時(shí)間,所以需要手動(dòng)測(cè)量。我們可以在每幀開始刷新的時(shí)候用`CACurrentMediaTime()`記錄當(dāng)前時(shí)間,然后和上一幀記錄的時(shí)間去比較。

通過(guò)比較這些時(shí)間,我們就可以得到真實(shí)的每幀持續(xù)的時(shí)間,然后代替硬編碼的六十分之一秒。我們來(lái)更新一下上個(gè)例子(見(jiàn)清單11.2)。

清單11.2通過(guò)測(cè)量沒(méi)幀持續(xù)的時(shí)間來(lái)使得動(dòng)畫更加平滑

```objective-c

@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模式

注意到當(dāng)創(chuàng)建`CADisplayLink`的時(shí)候,我們需要指定一個(gè)`run loop`和`run loop mode`,對(duì)于run loop來(lái)說(shuō),我們就使用了主線程的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),一些常見(jiàn)的run loop模式如下:

* `NSDefaultRunLoopMode` -標(biāo)準(zhǔn)優(yōu)先級(jí)

* `NSRunLoopCommonModes` -高優(yōu)先級(jí)

* `UITrackingRunLoopMode` -用于`UIScrollView`和別的控件的動(dòng)畫

在我們的例子中,我們是用了`NSDefaultRunLoopMode`,但是不能保證動(dòng)畫平滑的運(yùn)行,所以就可以用`NSRunLoopCommonModes`來(lái)替代。但是要小心,因?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`來(lái)保證它不會(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模式配置,通過(guò)別的函數(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í)器的動(dòng)畫來(lái)復(fù)制第10章中關(guān)鍵幀的行為,但還是會(huì)有一些本質(zhì)上的區(qū)別:在關(guān)鍵幀的實(shí)現(xiàn)中,我們提前計(jì)算了所有幀,但是在新的解決方案中,我們實(shí)際上實(shí)在按需要在計(jì)算。意義在于我們可以根據(jù)用戶輸入實(shí)時(shí)修改動(dòng)畫的邏輯,或者和別的實(shí)時(shí)動(dòng)畫系統(tǒng)例如物理引擎進(jìn)行整合。

###Chipmunk

我們來(lái)基于物理學(xué)創(chuàng)建一個(gè)真實(shí)的重力模擬效果來(lái)取代當(dāng)前基于緩沖的彈性動(dòng)畫,但即使模擬2D的物理效果就已近極其復(fù)雜了,所以就不要嘗試去實(shí)現(xiàn)它了,直接用開源的物理引擎庫(kù)好了。

我們將要使用的物理引擎叫做Chipmunk。另外的2D物理引擎也同樣可以(例如Box2D),但是Chipmunk使用純C寫的,而不是C++,好處在于更容易和Objective-C項(xiàng)目整合。Chipmunk有很多版本,包括一個(gè)和Objective-C綁定的“indie”版本。C語(yǔ)言的版本是免費(fèi)的,所以我們就用它好了。在本書寫作的時(shí)候6.1.4是最新的版本;你可以從http://chipmunk-physics.net下載它。

Chipmunk完整的物理引擎相當(dāng)巨大復(fù)雜,但是我們只會(huì)使用如下幾個(gè)類:

* `cpSpace` -這是所有的物理結(jié)構(gòu)體的容器。它有一個(gè)大小和一個(gè)可選的重力矢量

* `cpBody` -它是一個(gè)固態(tài)無(wú)彈力的剛體。它有一個(gè)坐標(biāo),以及其他物理屬性,例如質(zhì)量,運(yùn)動(dòng)和摩擦系數(shù)等等。

* `cpShape` -它是一個(gè)抽象的幾何形狀,用來(lái)檢測(cè)碰撞。可以給結(jié)構(gòu)體添加一個(gè)多邊形,而且`cpShape`有各種子類來(lái)代表不同形狀的類型。

在例子中,我們來(lái)對(duì)一個(gè)木箱建模,然后在重力的影響下下落。我們來(lái)創(chuàng)建一個(gè)`Crate`類,包含屏幕上的可視效果(一個(gè)`UIImageView`)和一個(gè)物理模型(一個(gè)`cpBody`和一個(gè)`cpPolyShape`,一個(gè)`cpShape`的多邊形子類來(lái)代表矩形木箱)。

用C版本的Chipmunk會(huì)帶來(lái)一些挑戰(zhàn),因?yàn)樗F(xiàn)在并不支持Objective-C的引用計(jì)數(shù)模型,所以我們需要準(zhǔn)確的創(chuàng)建和釋放對(duì)象。為了簡(jiǎn)化,我們把`cpShape`和`cpBody`的生命周期和`Crate`類進(jìn)行綁定,然后在木箱的`-init`方法中創(chuàng)建,在`-dealloc`中釋放。木箱物理屬性的配置很復(fù)雜,所以閱讀了Chipmunk文檔會(huì)很有意義。

視圖控制器用來(lái)管理`cpSpace`,還有和之前一樣的計(jì)時(shí)器邏輯。在每一步中,我們更新`cpSpace`(用來(lái)進(jìn)行物理計(jì)算和所有結(jié)構(gòu)體的重新擺放)然后迭代對(duì)象,然后再更新我們的木箱視圖的位置來(lái)匹配木箱的模型(在這里,實(shí)際上只有一個(gè)結(jié)構(gòu)體,但是之后我們將要添加更多)。

Chipmunk使用了一個(gè)和UIKit顛倒的坐標(biāo)系(Y軸向上為正方向)。為了使得物理模型和視圖之間的同步更簡(jiǎn)單,我們需要通過(guò)使用`geometryFlipped`屬性翻轉(zhuǎn)容器視圖的集合坐標(biāo)(第3章中有提到),于是模型和視圖都共享一個(gè)相同的坐標(biāo)系。

具體的代碼見(jiàn)清單11.3。注意到我們并沒(méi)有在任何地方釋放`cpSpace`對(duì)象。在這個(gè)例子中,內(nèi)存空間將會(huì)在整個(gè)app的生命周期中一直存在,所以這沒(méi)有問(wèn)題。但是在現(xiàn)實(shí)世界的場(chǎng)景中,我們需要像創(chuàng)建木箱結(jié)構(gòu)體和形狀一樣去管理我們的空間,封裝在標(biāo)準(zhǔn)的Cocoa對(duì)象中,然后來(lái)管理Chipmunk對(duì)象的生命周期。圖11.1展示了掉落的木箱。

清單11.3使用物理學(xué)來(lái)對(duì)掉落的木箱建模

```objective-c

#import "ViewController.h"

#import

#import "chipmunk.h"

@interface Crate : UIImageView

@property (nonatomic, assign) cpBody *body;

@property (nonatomic, assign) cpShape *shape;

@end

@implementation Crate

#define MASS 100

- (id)initWithFrame:(CGRect)frame

{

if ((self = [super initWithFrame:frame])) {

//set image

self.image = [UIImage imageNamed:@"Crate.png"];

self.contentMode = UIViewContentModeScaleAspectFill;

//create the body

self.body = cpBodyNew(MASS, cpMomentForBox(MASS, frame.size.width, frame.size.height));

//create the shape

cpVect corners[] = {

cpv(0, 0),

cpv(0, frame.size.height),

cpv(frame.size.width, frame.size.height),

cpv(frame.size.width, 0),

};

self.shape = cpPolyShapeNew(self.body, 4, corners, cpv(-frame.size.width/2, -frame.size.height/2));

//set shape friction & elasticity

cpShapeSetFriction(self.shape, 0.5);

cpShapeSetElasticity(self.shape, 0.8);

//link the crate to the shape

//so we can refer to crate from callback later on

self.shape->data = (__bridge void *)self;

//set the body position to match view

cpBodySetPos(self.body, cpv(frame.origin.x + frame.size.width/2, 300 - frame.origin.y - frame.size.height/2));

}

return self;

}

- (void)dealloc

{

//release shape and body

cpShapeFree(_shape);

cpBodyFree(_body);

}

@end

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;

@property (nonatomic, assign) cpSpace *space;

@property (nonatomic, strong) CADisplayLink *timer;

@property (nonatomic, assign) CFTimeInterval lastStep;

@end

@implementation ViewController

#define GRAVITY 1000

- (void)viewDidLoad

{

//invert view coordinate system to match physics

self.containerView.layer.geometryFlipped = YES;

//set up physics space

self.space = cpSpaceNew();

cpSpaceSetGravity(self.space, cpv(0, -GRAVITY));

//add a crate

Crate *crate = [[Crate alloc] initWithFrame:CGRectMake(100, 0, 100, 100)];

[self.containerView addSubview:crate];

cpSpaceAddBody(self.space, crate.body);

cpSpaceAddShape(self.space, crate.shape);

//start the timer

self.lastStep = CACurrentMediaTime();

self.timer = [CADisplayLink displayLinkWithTarget:self

selector:@selector(step:)];

[self.timer addToRunLoop:[NSRunLoop mainRunLoop]

forMode:NSDefaultRunLoopMode];

}

void updateShape(cpShape *shape, void *unused)

{

//get the crate object associated with the shape

Crate *crate = (__bridge Crate *)shape->data;

//update crate view position and angle to match physics shape

cpBody *body = shape->body;

crate.center = cpBodyGetPos(body);

crate.transform = CGAffineTransformMakeRotation(cpBodyGetAngle(body));

}

- (void)step:(CADisplayLink *)timer

{

//calculate step duration

CFTimeInterval thisStep = CACurrentMediaTime();

CFTimeInterval stepDuration = thisStep - self.lastStep;

self.lastStep = thisStep;

//update physics

cpSpaceStep(self.space, stepDuration);

//update all the shapes

cpSpaceEachShape(self.space, &updateShape, NULL);

}

@end

```

圖11.1一個(gè)木箱圖片,根據(jù)模擬的重力掉落

###添加用戶交互

下一步就是在視圖周圍添加一道不可見(jiàn)的墻,這樣木箱就不會(huì)掉落出屏幕之外。或許你會(huì)用另一個(gè)矩形的`cpPolyShape`來(lái)實(shí)現(xiàn),就和之前創(chuàng)建木箱那樣,但是我們需要檢測(cè)的是木箱何時(shí)離開視圖,而不是何時(shí)碰撞,所以我們需要一個(gè)空心而不是固體矩形。

我們可以通過(guò)給`cpSpace`添加四個(gè)`cpSegmentShape`對(duì)象(`cpSegmentShape`代表一條直線,所以四個(gè)拼起來(lái)就是一個(gè)矩形)。然后賦給空間的`staticBody`屬性(一個(gè)不被重力影響的結(jié)構(gòu)體)而不是像木箱那樣一個(gè)新的`cpBody`實(shí)例,因?yàn)槲覀儾幌胱屵@個(gè)邊框矩形滑出屏幕或者被一個(gè)下落的木箱擊中而消失。

同樣可以再添加一些木箱來(lái)做一些交互。最后再添加一個(gè)加速器,這樣可以通過(guò)傾斜手機(jī)來(lái)調(diào)整重力矢量(為了測(cè)試需要在一臺(tái)真實(shí)的設(shè)備上運(yùn)行程序,因?yàn)槟M器不支持加速器事件,即使旋轉(zhuǎn)屏幕)。清單11.4展示了更新后的代碼,運(yùn)行結(jié)果見(jiàn)圖11.2。

由于示例只支持橫屏模式,所以交換加速計(jì)矢量的x和y值。如果在豎屏下運(yùn)行程序,請(qǐng)把他們換回來(lái),不然重力方向就錯(cuò)亂了。試一下就知道了,木箱會(huì)沿著橫向移動(dòng)。

清單11.4使用圍墻和多個(gè)木箱的更新后的代碼

```objetive-c

- (void)addCrateWithFrame:(CGRect)frame

{

Crate *crate = [[Crate alloc] initWithFrame:frame];

[self.containerView addSubview:crate];

cpSpaceAddBody(self.space, crate.body);

cpSpaceAddShape(self.space, crate.shape);

}

- (void)addWallShapeWithStart:(cpVect)start end:(cpVect)end

{

cpShape *wall = cpSegmentShapeNew(self.space->staticBody, start, end, 1);

cpShapeSetCollisionType(wall, 2);

cpShapeSetFriction(wall, 0.5);

cpShapeSetElasticity(wall, 0.8);

cpSpaceAddStaticShape(self.space, wall);

}

- (void)viewDidLoad

{

//invert view coordinate system to match physics

self.containerView.layer.geometryFlipped = YES;

//set up physics space

self.space = cpSpaceNew();

cpSpaceSetGravity(self.space, cpv(0, -GRAVITY));

//add wall around edge of view

[self addWallShapeWithStart:cpv(0, 0) end:cpv(300, 0)];

[self addWallShapeWithStart:cpv(300, 0) end:cpv(300, 300)];

[self addWallShapeWithStart:cpv(300, 300) end:cpv(0, 300)];

[self addWallShapeWithStart:cpv(0, 300) end:cpv(0, 0)];

//add a crates

[self addCrateWithFrame:CGRectMake(0, 0, 32, 32)];

[self addCrateWithFrame:CGRectMake(32, 0, 32, 32)];

[self addCrateWithFrame:CGRectMake(64, 0, 64, 64)];

[self addCrateWithFrame:CGRectMake(128, 0, 32, 32)];

[self addCrateWithFrame:CGRectMake(0, 32, 64, 64)];

//start the timer

self.lastStep = CACurrentMediaTime();

self.timer = [CADisplayLink displayLinkWithTarget:self

selector:@selector(step:)];

[self.timer addToRunLoop:[NSRunLoop mainRunLoop]

forMode:NSDefaultRunLoopMode];

//update gravity using accelerometer

[UIAccelerometer sharedAccelerometer].delegate = self;

[UIAccelerometer sharedAccelerometer].updateInterval = 1/60.0;

}

- (void)accelerometer:(UIAccelerometer *)accelerometer didAccelerate:(UIAcceleration *)acceleration

{

//update gravity

cpSpaceSetGravity(self.space, cpv(acceleration.y * GRAVITY, -acceleration.x * GRAVITY));

}

```

圖11.1真實(shí)引力場(chǎng)下的木箱交互

###模擬時(shí)間以及固定的時(shí)間步長(zhǎng)

對(duì)于實(shí)現(xiàn)動(dòng)畫的緩沖效果來(lái)說(shuō),計(jì)算每幀持續(xù)的時(shí)間是一個(gè)很好的解決方案,但是對(duì)模擬物理效果并不理想。通過(guò)一個(gè)可變的時(shí)間步長(zhǎng)來(lái)實(shí)現(xiàn)有著兩個(gè)弊端:

*如果時(shí)間步長(zhǎng)不是固定的,精確的值,物理效果的模擬也就隨之不確定。這意味著即使是傳入相同的輸入值,也可能在不同場(chǎng)合下有著不同的效果。有時(shí)候沒(méi)多大影響,但是在基于物理引擎的游戲下,玩家就會(huì)由于相同的操作行為導(dǎo)致不同的結(jié)果而感到困惑。同樣也會(huì)讓測(cè)試變得麻煩。

*由于性能故常造成的丟幀或者像電話呼入的中斷都可能會(huì)造成不正確的結(jié)果。考慮一個(gè)像子彈那樣快速移動(dòng)物體,每一幀的更新都需要移動(dòng)子彈,檢測(cè)碰撞。如果兩幀之間的時(shí)間加長(zhǎng)了,子彈就會(huì)在這一步移動(dòng)更遠(yuǎn)的距離,穿過(guò)圍墻或者是別的障礙,這樣就丟失了碰撞。

我們想得到的理想的效果就是通過(guò)固定的時(shí)間步長(zhǎng)來(lái)計(jì)算物理效果,但是在屏幕發(fā)生重繪的時(shí)候仍然能夠同步更新視圖(可能會(huì)由于在我們控制范圍之外造成不可預(yù)知的效果)。

幸運(yùn)的是,由于我們的模型(在這個(gè)例子中就是Chipmunk的`cpSpace`中的`cpBody`)被視圖(就是屏幕上代表木箱的`UIView`對(duì)象)分離,于是就很簡(jiǎn)單了。我們只需要根據(jù)屏幕刷新的時(shí)間跟蹤時(shí)間步長(zhǎng),然后根據(jù)每幀去計(jì)算一個(gè)或者多個(gè)模擬出來(lái)的效果。

我們可以通過(guò)一個(gè)簡(jiǎn)單的循環(huán)來(lái)實(shí)現(xiàn)。通過(guò)每次`CADisplayLink`的啟動(dòng)來(lái)通知屏幕將要刷新,然后記錄下當(dāng)前的`CACurrentMediaTime()`。我們需要在一個(gè)小增量中提前重復(fù)物理模擬(這里用120分之一秒)直到趕上顯示的時(shí)間。然后更新我們的視圖,在屏幕刷新的時(shí)候匹配當(dāng)前物理結(jié)構(gòu)體的顯示位置。

清單11.5展示了固定時(shí)間步長(zhǎng)版本的代碼

清單11.5固定時(shí)間步長(zhǎng)的木箱模擬

```objective-c

#define SIMULATION_STEP (1/120.0)

- (void)step:(CADisplayLink *)timer

{

//calculate frame step duration

CFTimeInterval frameTime = CACurrentMediaTime();

//update simulation

while (self.lastStep < frameTime) {

cpSpaceStep(self.space, SIMULATION_STEP);

self.lastStep += SIMULATION_STEP;

}

//update all the shapes

cpSpaceEachShape(self.space, &updateShape, NULL);

}

```

###避免死亡螺旋

當(dāng)使用固定的模擬時(shí)間步長(zhǎng)時(shí)候,有一件事情一定要注意,就是用來(lái)計(jì)算物理效果的現(xiàn)實(shí)世界的時(shí)間并不會(huì)加速模擬時(shí)間步長(zhǎng)。在我們的例子中,我們隨意選擇了120分之一秒來(lái)模擬物理效果。Chipmunk很快,我們的例子也很簡(jiǎn)單,所以`cpSpaceStep()`會(huì)完成的很好,不會(huì)延遲幀的更新。

但是如果場(chǎng)景很復(fù)雜,比如有上百個(gè)物體之間的交互,物理計(jì)算就會(huì)很復(fù)雜,`cpSpaceStep()`的計(jì)算也可能會(huì)超出1/120秒。我們沒(méi)有測(cè)量出物理步長(zhǎng)的時(shí)間,因?yàn)槲覀兗僭O(shè)了相對(duì)于幀刷新來(lái)說(shuō)并不重要,但是如果模擬步長(zhǎng)更久的話,就會(huì)延遲幀率。

如果幀刷新的時(shí)間延遲的話會(huì)變得很糟糕,我們的模擬需要執(zhí)行更多的次數(shù)來(lái)同步真實(shí)的時(shí)間。這些額外的步驟就會(huì)繼續(xù)延遲幀的更新,等等。這就是所謂的死亡螺旋,因?yàn)樽詈蟮慕Y(jié)果就是幀率變得越來(lái)越慢,直到最后應(yīng)用程序卡死了。

我們可以通過(guò)添加一些代碼在設(shè)備上來(lái)對(duì)物理步驟計(jì)算真實(shí)世界的時(shí)間,然后自動(dòng)調(diào)整固定時(shí)間步長(zhǎng),但是實(shí)際上它不可行。其實(shí)只要保證你給容錯(cuò)留下足夠的邊長(zhǎng),然后在期望支持的最慢的設(shè)備上進(jìn)行測(cè)試就可以了。如果物理計(jì)算超過(guò)了模擬時(shí)間的50%,就需要考慮增加模擬時(shí)間步長(zhǎng)(或者簡(jiǎn)化場(chǎng)景)。如果模擬時(shí)間步長(zhǎng)增加到超過(guò)1/60秒(一個(gè)完整的屏幕更新時(shí)間),你就需要減少動(dòng)畫幀率到一秒30幀或者增加`CADisplayLink`的`frameInterval`來(lái)保證不會(huì)隨機(jī)丟幀,不然你的動(dòng)畫將會(huì)看起來(lái)不平滑。

##總結(jié)

在這一章中,我們了解了如何通過(guò)一個(gè)計(jì)時(shí)器創(chuàng)建一幀幀的實(shí)時(shí)動(dòng)畫,包括緩沖,物理模擬等等一系列動(dòng)畫技術(shù),以及用戶輸入(通過(guò)加速計(jì))。

在第三部分中,我們將研究動(dòng)畫性能是如何被被設(shè)備限制所影響的,以及如何調(diào)整我們的代碼來(lái)活的足夠好的幀率。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評(píng)論 6 543
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,559評(píng)論 3 429
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評(píng)論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評(píng)論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,581評(píng)論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,922評(píng)論 1 328
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評(píng)論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,096評(píng)論 0 290
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,639評(píng)論 1 336
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,374評(píng)論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,591評(píng)論 1 374
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評(píng)論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,789評(píng)論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評(píng)論 1 295
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,322評(píng)論 3 400
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,554評(píng)論 2 379

推薦閱讀更多精彩內(nèi)容