iOS核心動畫高級技巧--(十一)基于定時器的動畫

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

定時幀

動畫看起來是用來顯示一段連續的運動過程,但實際上當在固定位置上展示像素的時候并不能做到這一點。一般來說這種顯示都無法做到連續的移動,能做的僅僅是足夠快地展示一系列靜態圖片,只是看起來像是做了運動。

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

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

NSTimer

實際上,我們在第三章“圖層幾何學”中已經做過類似的東西,就是時鐘那個例子, 我們用了 NSTimer 來對鐘表的指針做定時動畫,一秒鐘更新一次,但是如果我們 把頻率調整成一秒鐘更新60次的話,原理是完全相同的。

我們來試著用 NSTimer 來修改第十章中彈性球的例子。由于現在我們在定時器啟 動之后連續計算動畫幀,我們需要在類中添加一些額外的屬性來存儲動畫的fromValue ,toValue, duration和當前的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
使用 NSTimer 實現彈性球動畫效果.gif

很贊,而且和基于關鍵幀例子的代碼一樣很多,但是如果想一次性在屏幕上對很多東西做動畫,很明顯就會有很多問題。

NSTimer 并不是最佳方案,為了理解這點,我們需要確切地知道 NSTimer 是如 何工作的。iOS上的每個線程都管理了一個NSRunloop ,字面上看就是通過一個 循環來完成一些任務列表。但是對主線程,這些任務包含如下幾項:

  • 處理觸摸事件
  • 發送和接受網絡數據包
  • 執行使用gcd的代碼
  • 處理計時器行為
  • 屏幕重繪

當你設置一個 NSTimer ,他會被插入到當前任務列表中,然后直到指定時間過去 之后才會被執行。但是何時啟動定時器并沒有一個時間上限,而且它只會在列表中 上一個任務完成之后開始執行。這通常會導致有幾毫秒的延遲,但是如果上一個任 務過了很久才完成就會導致延遲很長一段時間。

屏幕重繪的頻率是一秒鐘六十次,但是和定時器行為一樣,如果列表中上一個執行
了很長時間,它也會延遲。這些延遲都是一個隨機值,于是就不能保證定時器精準
地一秒鐘執行六十次。有時候發生在屏幕重繪之后,這就會使得更新屏幕會有個延
遲,看起來就是動畫卡殼了。有時候定時器會在屏幕更新的時候執行兩次,于是動
畫看起來就跳動了。

我們可以通過一些途徑來優化:

  • 我們可以用 CADisplayLink 讓更新頻率嚴格控制在每次屏幕刷新之后。
  • 基于真實幀的持續時間而不是假設的更新頻率來做動畫。
  • 調整動畫計時器的 run loop 模式,這樣就不會被別的事件干擾。
CADisplayLink

CADisplayLinkCoreAnimation提供的另一個類似于NSTimer 的類,它總是在屏幕完成一次更新之前啟動,它的接口設計的和NSTimer 很類似,所以它實際上就是一個內置實現的替代,但是和timeInterval以秒為單位不同, CADisplayLink有一個整型的 frameInterval屬性,制定了間隔多少幀之后才執行。默認值是1,意味著每次屏幕更新之前都會執行一次。但是如果動畫的 代碼執行起來超過了六十分之一秒,你可以指定frameInterval2,就是說動 畫每隔一幀執行一次(一秒鐘30幀)或者3,也就是一秒鐘20次,等等。

CADisplayLink而不是 NSTimer ,會保證幀率足夠連續,使得動畫看起來更加平滑,但即使 CADisplayLink也不能保證每一幀都按計劃執行,一些失去控制的離散的任務或者事件(例如資源緊張的后臺程序)可能會導致動畫偶爾地丟幀。 當使用 NSTimer 的時候,一旦有機會計時器就會開啟,但是 CADisplayLink 卻 不一樣:如果它丟失了幀,就會直接忽略它們,然后在下一次更新的時候接著運 行。

計算幀的持續時間

無論是使用NSTimer還是CADisplayLink,我們仍然需要處理一幀的時間超出 了預期的六十分之一秒。由于我們不能夠計算出一幀真實的持續時間,所以需要手 動測量。我們可以在每幀開始刷新的時候用CACurrentMediaTime()記錄當前時 間,然后和上一幀記錄的時間去比較。

通過比較這些時間,我們就可以得到真實的每幀持續的時間,然后代替硬編碼的六 十分之一秒。

Run Loop 模式

注意到當創建CADisplayLink的時候,我們需要指定一個 run looprun loop mode ,對于run loop來說,我們就使用了主線程的run loop,因為任何用戶 界面的更新都需要在主線程執行,但是模式的選擇就并不那么清楚了,每個添加到run loop的任務都有一個指定了優先級的模式,為了保證用戶界面保持平滑,iOS會 提供和用戶界面相關任務的優先級,而且當UI很活躍的時候的確會暫停一些別的任 務。

一個典型的例子就是當是用UIScrollView 滑動的時候,重繪滾動視圖的內容會 比別的任務優先級更高,所以標準的NSTimer 和網絡請求就不會啟動,一些常見 的run loop模式如下:

  • NSDefaultRunLoopMode 標準優先級
  • NSRunLoopCommonModes 高優先級
  • UITrackingRunLoopMode 用于 UIScrollView和別的控件的動畫

在我們的例子中,我們是用了NSDefaultRunLoopMode ,但是不能保證動畫平滑 的運行,所以就可以用NSRunLoopCommonModes 來替代。但是要小心,因為如果 動畫在一個高幀率情況下運行,你會發現一些別的類似于定時器的任務或者類似于 滑動的其他iOS動畫會暫停,直到動畫結束。

同樣可以同時對 CADisplayLink指定多個run loop模式,于是我們可以同時加入NSDefaultRunLoopModeUITrackingRunLoopMode 來保證它不會被滑動打 斷,也不會被其他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];
物理模擬

即使使用了基于定時器的動畫來復制第10章中關鍵幀的行為,但還是會有一些本質 上的區別:在關鍵幀的實現中,我們提前計算了所有幀,但是在新的解決方案中, 我們實際上實在按需要在計算。意義在于我們可以根據用戶輸入實時修改動畫的邏 輯,或者和別的實時動畫系統例如物理引擎進行整合。

Chipmunk

我們來基于物理學創建一個真實的重力模擬效果來取代當前基于緩沖的彈性動畫, 但即使模擬2D的物理效果就已近極其復雜了,所以就不要嘗試去實現它了,直接用 開源的物理引擎庫好了。

我們將要使用的物理引擎叫做Chipmunk。另外的2D物理引擎也同樣可以(例如 Box2D),但是Chipmunk使用純C寫的,而不是C++,好處在于更容易和Objective-C項目整合。Chipmunk有很多版本,包括一個和Objective-C綁定 的“indie”版本。C語言的版本是免費的,所以我們就用它好了。你可以從物理引擎下載它。

Chipmunk完整的物理引擎相當巨大復雜,但是我們只會使用如下幾個類:

  • cpSpace - 這是所有的物理結構體的容器。它有一個大小和一個可選的重力 矢量
  • cpBody - 它是一個固態無彈力的剛體。它有一個坐標,以及其他物理屬性, 例如質量,運動和摩擦系數等等。
  • cpShape - 它是一個抽象的幾何形狀,用來檢測碰撞??梢越o結構體添加一 個多邊形,而且 cpShape有各種子類來代表不同形狀的類型。

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

C版本的Chipmunk會帶來一些挑戰,因為它現在并不支持Objective-C的引用計 數模型,所以我們需要準確的創建和釋放對象。為了簡化,我們
cpShapecpBody的生命周期和Crate類進行綁定,然后在木箱的- init方法中創建,在-dealloc中釋放。木箱物理屬性的配置很復雜,所以閱讀 了Chipmunk文檔會很有意義。

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

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

具體的代碼如下。注意到我們并沒有在任何地方釋放 cpSpace 對象。在這 個例子中,內存空間將會在整個app的生命周期中一直存在,所以這沒有問題。但 是在現實世界的場景中,我們需要像創建木箱結構體和形狀一樣去管理我們的空 間,封裝在標準的Cocoa對象中,然后來管理Chipmunk對象的生命周期。

添加用戶交互

下一步就是在視圖周圍添加一道不可見的墻,這樣木箱就不會掉落出屏幕之外?;?許你會用另一個矩形的cpPolyShape 來實現,就和之前創建木箱那樣,但是我們 需要檢測的是木箱何時離開視圖,而不是何時碰撞,所以我們需要一個空心而不是 固體矩形。

我們可以通過給cpSpace添加四個 cpSegmentShape 對象(cpSegmentShape代表一條直線, 所以四個拼起來就是一個矩形). 然后賦給空間的staticBody屬性(一個不被重力影響的結構體)而不是像木箱那樣一個新的cpBody實例,因為我們不想讓這個邊框矩形滑出屏幕或者被一個下落的木箱擊中而消失.
同樣可以再添加一些木箱來做一些交互。最后再添加一個加速器,這樣可以通過傾 斜手機來調整重力矢量(為了測試需要在一臺真實的設備上運行程序,因為模擬器 不支持加速器事件,即使旋轉屏幕)。

由于示例只支持橫屏模式,所以交換加速計矢量的x和y值。如果在豎屏下運行程 序,請把他們換回來,不然重力方向就錯亂了。試一下就知道了,木箱會沿著橫向 移動。

模擬時間以及固定的時間步長

對于實現動畫的緩沖效果來說,計算每幀持續的時間是一個很好的解決方案,但是對模擬物理效果并不理想。通過一個可變的時間步長來實現有著兩個弊端:

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

  • 由于性能故常造成的丟幀或者像電話呼入的中斷都可能會造成不正確的結果。
    考慮一個像子彈那樣快速移動物體,每一幀的更新都需要移動子彈,檢測碰
    撞。如果兩幀之間的時間加長了,子彈就會在這一步移動更遠的距離,穿過圍
    墻或者是別的障礙,這樣就丟失了碰撞。

我們想得到的理想的效果就是通過固定的時間步長來計算物理效果,但是在屏幕發生重繪的時候仍然能夠同步更新視圖(可能會由于在我們控制范圍之外造成不可預知的效果)。

幸運的是,由于我們的模型(在這個例子中就是ChipmunkcpSpace
cpBody )被視圖(就是屏幕上代表木箱的UIView 對象)分離,于是就很簡 單了。我們只需要根據屏幕刷新的時間跟蹤時間步長,然后根據每幀去計算一個或 者多個模擬出來的效果。

我們可以通過一個簡單的循環來實現。通過每次CADisplayLink 的啟動來通知屏 幕將要刷新,然后記錄下當前的 CACurrentMediaTime()。我們需要在一個小增量中提前重復物理模擬(這里用120分之一秒)直到趕上顯示的時間。然后更新我 們的視圖,在屏幕刷新的時候匹配當前物理結構體的顯示位置。

避免死亡螺旋

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

但是如果場景很復雜,比如有上百個物體之間的交互,物理計算就會很復雜, cpSpaceStep()的計算也可能會超出1/120秒。我們沒有測量出物理步長的時間,因為我們假設了相對于幀刷新來說并不重要,但是如果模擬步長更久的話, 就會延遲幀率。

如果幀刷新的時間延遲的話會變得很糟糕,我們的模擬需要執行更多的次數來同步真實的時間。這些額外的步驟就會繼續延遲幀的更新,等等。這就是所謂的死亡螺旋,因為最后的結果就是幀率變得越來越慢,直到最后應用程序卡死了。

我們可以通過添加一些代碼在設備上來對物理步驟計算真實世界的時間,然后自動 調整固定時間步長,但是實際上它不可行。其實只要保證你給容錯留下足夠的邊 長,然后在期望支持的最慢的設備上進行測試就可以了。如果物理計算超過了模擬 時間的50%,就需要考慮增加模擬時間步長(或者簡化場景)。如果模擬時間步長增加到超過1/60秒(一個完整的屏幕更新時間),你就需要減少動畫幀率到一秒30 幀或者增加 CADisplayLinkframeInterval來保證不會隨機丟幀,不然你的 動畫將會看起來不平滑。

iOS核心動畫高級技巧--目錄

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372

推薦閱讀更多精彩內容