iOS核心動畫高級技巧 - 6

来源:https://www.cnblogs.com/Julday/archive/2019/12/05/11988879.html

11. 基於定時器的動畫 基於定時器的動畫 我可以指導你,但是你必須按照我說的做。 -- 駭客帝國 在第10章“緩衝”中,我們研究了CAMediaTimingFunction,它是一個通過控制動畫緩衝來模擬物理效果例如加速或者減速來增強現實感的東西,那麼如果想更加真實地模擬物理交互或者實時根據用戶輸 ...


11. 基於定時器的動畫

基於定時器的動畫

我可以指導你,但是你必須按照我說的做。 -- 駭客帝國

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

11.1 定時幀

定時幀

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

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

一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:1012951431, 分享BAT,阿裡面試題、面試經驗,討論技術, 大家一起交流學習成長!希望幫助開發者少走彎路。

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

NSTimer

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

我們來試著用NSTimer來修改第十章中彈性球的例子。由於現在我們在定時器啟動之後連續計算動畫幀,我們需要在類中添加一些額外的屬性來存儲動畫的fromValuetoValueduration和當前的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 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];

 

11.2 物理模擬

物理模擬

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

Chipmunk

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

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

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

  • cpSpace - 這是所有的物理結構體的容器。它有一個大小和一個可選的重力矢量

  • cpBody - 它是一個固態無彈力的剛體。它有一個坐標,以及其他物理屬性,例如質量,運動和摩擦繫數等等。

  • cpShape - 它是一個抽象的幾何形狀,用來檢測碰撞。可以給結構體添加一個多邊形,而且cpShape有各種子類來代表不同形狀的類型。

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

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

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

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

具體的代碼見清單11.3。註意到我們並沒有在任何地方釋放cpSpace對象。在這個例子中,記憶體空間將會在整個app的生命周期中一直存在,所以這沒有問題。但是在現實世界的場景中,我們需要像創建木箱結構體和形狀一樣去管理我們的空間,封裝在標準的Cocoa對象中,然後來管理Chipmunk對象的生命周期。圖11.1展示了掉落的木箱。

清單11.3 使用物理學來對掉落的木箱建模

#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 真實引力場下的木箱交互

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

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

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

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

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

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

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

清單11.5展示了固定時間步長版本的代碼

清單11.5 固定時間步長的木箱模擬

#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);
}

 

避免死亡螺旋

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

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

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

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

物理模擬

12. 性能調優

性能調優

代碼應該運行的儘量快,而不是更快 - 理查德

在第一和第二部分,我們瞭解了Core Animation提供的關於繪製和動畫的一些特性。Core Animation功能和性能都非常強大,但如果你對背後的原理不清楚的話也會降低效率。讓它達到最優的狀態是一門藝術。在這章中,我們將探究一些動畫運行慢的原因,以及如何去修複這些問題。

12.1. CPU VS GPU

CPU VS GPU

關於繪圖和動畫有兩種處理的方式:CPU(中央處理器)和GPU(圖形處理器)。在現代iOS設備中,都有可以運行不同軟體的可編程晶元,但是由於歷史原因,我們可以說CPU所做的工作都在軟體層面,而GPU在硬體層面。

總的來說,我們可以用軟體(使用CPU)做任何事情,但是對於圖像處理,通常用硬體會更快,因為GPU使用圖像對高度並行浮點運算做了優化。由於某些原因,我們想儘可能把屏幕渲染的工作交給硬體去處理。問題在於GPU並沒有無限制處理性能,而且一旦資源用完的話,性能就會開始下降了(即使CPU並沒有完全占用)

大多數動畫性能優化都是關於智能利用GPU和CPU,使得它們都不會超出負荷。於是我們首先需要知道Core Animation是如何在這兩個處理器之間分配工作的。

動畫的舞臺

Core Animation處在iOS的核心地位:應用內和應用間都會用到它。一個簡單的動畫可能同步顯示多個app的內容,例如當在iPad上多個程式之間使用手勢切換,會使得多個程式同時顯示在屏幕上。在一個特定的應用中用代碼實現它是沒有意義的,因為在iOS中不可能實現這種效果(App都是被沙箱管理,不能訪問別的視圖)。

動畫和屏幕上組合的圖層實際上被一個單獨的進程管理,而不是你的應用程式。這個進程就是所謂的渲染服務。在iOS5和之前的版本是SpringBoard進程(同時管理著iOS的主屏)。在iOS6之後的版本中叫做BackBoard

當運行一段動畫時候,這個過程會被四個分離的階段被打破:

  • 佈局 - 這是準備你的視圖/圖層的層級關係,以及設置圖層屬性(位置,背景色,邊框等等)的階段。

  • 顯示 - 這是圖層的寄宿圖片被繪製的階段。繪製有可能涉及你的-drawRect:-drawLayer:inContext:方法的調用路徑。

  • 準備 - 這是Core Animation準備發送動畫數據到渲染服務的階段。這同時也是Core Animation將要執行一些別的事務例如解碼動畫過程中將要顯示的圖片的時間點。

  • 提交 - 這是最後的階段,Core Animation打包所有圖層和動畫屬性,然後通過IPC(內部處理通信)發送到渲染服務進行顯示。

但是這些僅僅階段僅僅發生在你的應用程式之內,在動畫在屏幕上顯示之前仍然有更多的工作。一旦打包的圖層和動畫到達渲染服務進程,他們會被反序列化來形成另一個叫做渲染樹的圖層樹(在第一章“圖層樹”中提到過)。使用這個樹狀結構,渲染服務對動畫的每一幀做出如下工作:

  • 對所有的圖層屬性計算中間值,設置OpenGL幾何形狀(紋理化的三角形)來執行渲染

  • 在屏幕上渲染可見的三角形

所以一共有六個階段;最後兩個階段在動畫過程中不停地重覆。前五個階段都在軟體層面處理(通過CPU),只有最後一個被GPU執行。而且,你真正只能控制前兩個階段:佈局和顯示。Core Animation框架在內部處理剩下的事務,你也控制不了它。

這並不是個問題,因為在佈局和顯示階段,你可以決定哪些由CPU執行,哪些交給GPU去做。那麼改如何判斷呢?

GPU相關的操作

GPU為一個具體的任務做了優化:它用來採集圖片和形狀(三角形),運行變換,應用紋理和混合然後把它們輸送到屏幕上。現代iOS設備上可編程的GPU在這些操作的執行上又很大的靈活性,但是Core Animation並沒有暴露出直接的介面。除非你想繞開Core Animation並編寫你自己的OpenGL著色器,從根本上解決硬體加速的問題,那麼剩下的所有都還是需要在CPU的軟體層面上完成。

寬泛的說,大多數CALayer的屬性都是用GPU來繪製。比如如果你設置圖層背景或者邊框的顏色,那麼這些可以通過著色的三角板實時繪製出來。如果對一個contents屬性設置一張圖片,然後裁剪它 - 它就會被紋理的三角形繪製出來,而不需要軟體層面做任何繪製。

但是有一些事情會降低(基於GPU)圖層繪製,比如:

  • 太多的幾何結構 - 這發生在需要太多的三角板來做變換,以應對處理器的柵格化的時候。現代iOS設備的圖形晶元可以處理幾百萬個三角板,所以在Core Animation中幾何結構並不是GPU的瓶頸所在。但由於圖層在顯示之前通過IPC發送到渲染伺服器的時候(圖層實際上是由很多小物體組成的特別重量級的對象),太多的圖層就會引起CPU的瓶頸。這就限制了一次展示的圖層個數(見本章後續“CPU相關操作”)。

  • 重繪 - 主要由重疊的半透明圖層引起。GPU的填充比率(用顏色填充像素的比率)是有限的,所以需要避免重繪(每一幀用相同的像素填充多次)的發生。在現代iOS設備上,GPU都會應對重繪;即使是iPhone 3GS都可以處理高達2.5的重繪比率,並任然保持60幀率的渲染(這意味著你可以繪製一個半的整屏的冗餘信息,而不影響性能),並且新設備可以處理更多。

  • 離屏繪製 - 這發生在當不能直接在屏幕上繪製,並且必須繪製到離屏圖片的上下文中的時候。離屏繪製發生在基於CPU或者是GPU的渲染,或者是為離屏圖片分配額外記憶體,以及切換繪製上下文,這些都會降低GPU性能。對於特定圖層效果的使用,比如圓角,圖層遮罩,陰影或者是圖層光柵化都會強制Core Animation提前渲染圖層的離屏繪製。但這不意味著你需要避免使用這些效果,只是要明白這會帶來性能的負面影響。

  • 過大的圖片 - 如果視圖繪製超出GPU支持的2048x2048或者4096x4096尺寸的紋理,就必須要用CPU在圖層每次顯示之前對圖片預處理,同樣也會降低性能。

CPU相關的操作

大多數工作在Core Animation的CPU都發生在動畫開始之前。這意味著它不會影響到幀率,所以很好,但是他會延遲動畫開始的時間,讓你的界面看起來會比較遲鈍。

以下CPU的操作都會延遲動畫的開始時間:

  • 佈局計算 - 如果你的視圖層級過於複雜,當視圖呈現或者修改的時候,計算圖層幀率就會消耗一部分時間。特別是使用iOS6的自動佈局機制尤為明顯,它應該是比老版的自動調整邏輯加強了CPU的工作。

  • 視圖懶載入 - iOS只會當視圖控制器的視圖顯示到屏幕上時才會載入它。這對記憶體使用和程式啟動時間很有好處,但是當呈現到屏幕上之前,按下按鈕導致的許多工作都會不能被及時響應。比如控制器從資料庫中獲取數據,或者視圖從一個nib文件中載入,或者涉及IO的圖片顯示(見後續“IO相關操作”),都會比CPU正常操作慢得多。

  • Core Graphics繪製 - 如果對視圖實現了-drawRect:方法,或者CALayerDelegate-drawLayer:inContext:方法,那麼在繪製任何東西之前都會產生一個巨大的性能開銷。為了支持對圖層內容的任意繪製,Core Animation必須創建一個記憶體中等大小的寄宿圖片。然後一旦繪製結束之後,必須把圖片數據通過IPC傳到渲染伺服器。在此基礎上,Core Graphics繪製就會變得十分緩慢,所以在一個對性能十分挑剔的場景下這樣做十分不好。

  • 解壓圖片 - PNG或者JPEG壓縮之後的圖片文件會比同質量的點陣圖小得多。但是在圖片繪製到屏幕上之前,必須把它擴展成完整的未解壓的尺寸(通常等同於圖片寬 x 長 x 4個位元組)。為了節省記憶體,iOS通常直到真正繪製的時候才去解碼圖片(14章“圖片IO”會更詳細討論)。根據你載入圖片的方式,第一次對圖層內容賦值的時候(直接或者間接使用UIImageView)或者把它繪製到Core Graphics中,都需要對它解壓,這樣的話,對於一個較大的圖片,都會占用一定的時間。

當圖層被成功打包,發送到渲染伺服器之後,CPU仍然要做如下工作:為了顯示屏幕上的圖層,Core Animation必須對渲染樹種的每個可見圖層通過OpenGL迴圈轉換成紋理三角板。由於GPU並不知曉Core Animation圖層的任何結構,所以必須要由CPU做這些事情。這裡CPU涉及的工作和圖層個數成正比,所以如果在你的層級關係中有太多的圖層,就會導致CPU沒一幀的渲染,即使這些事情不是你的應用程式可控的。

IO相關操作

還有一項沒涉及的就是IO相關工作。上下文中的IO(輸入/輸出)指的是例如快閃記憶體或者網路介面的硬體訪問。一些動畫可能需要從山村(甚至是遠程URL)來載入。一個典型的例子就是兩個視圖控制器之間的過渡效果,這就需要從一個nib文件或者是它的內容中懶載入,或者一個旋轉的圖片,可能在記憶體中尺寸太大,需要動態滾動來載入。

IO比記憶體訪問更慢,所以如果動畫涉及到IO,就是一個大問題。總的來說,這就需要使用聰敏但尷尬的技術,也就是多線程,緩存和投機載入(提前載入當前不需要的資源,但是之後可能需要用到)。這些技術將會在第14章中討論。

12.2 測量,而不是猜測

測量,而不是猜測

於是現在你知道有哪些點可能會影響動畫性能,那該如何修複呢?好吧,其實不需要。有很多種詭計來優化動畫,但如果盲目使用的話,可能會造成更多性能上的問題,而不是修複。

如何正確的測量而不是猜測這點很重要。根據性能相關的知識寫出代碼不同於倉促的優化。前者很好,後者實際上就是在浪費時間。

那該如何測量呢?第一步就是確保在真實環境下測試你的程式。

真機測試,而不是模擬器

當你開始做一些性能方面的工作時,一定要在真機上測試,而不是模擬器。模擬器雖然是加快開發效率的一把利器,但它不能提供準確的真機性能參數。

模擬器運行在你的Mac上,然而Mac上的CPU往往比iOS設備要快。相反,Mac上的GPU和iOS設備的完全不一樣,模擬器不得已要在軟體層面(CPU)模擬設備的GPU,這意味著GPU相關的操作在模擬器上運行的更慢,尤其是使用CAEAGLLayer來寫一些OpenGL的代碼時候。

這就是說在模擬器上的測試出的性能會高度失真。如果動畫在模擬器上運行流暢,可能在真機上十分糟糕。如果在模擬器上運行的很卡,也可能在真機上很平滑。你無法確定。

另一件重要的事情就是性能測試一定要用發佈配置,而不是調試模式。因為當用發佈環境打包的時候,編譯器會引入一系列提高性能的優化,例如去掉調試符號或者移除並重新組織代碼。你也可以自己做到這些,例如在發佈環境禁用NSLog語句。你只關心發佈性能,那才是你需要測試的點。

最後,最好在你支持的設備中性能最差的設備上測試:如果基於iOS6開發,這意味著最好在iPhone 3GS或者iPad2上測試。如果可能的話,測試不同的設備和iOS版本,因為蘋果在不同的iOS版本和設備中做了一些改變,這也可能影響到一些性能。例如iPad3明顯要在動畫渲染上比iPad2慢很多,因為渲染4倍多的像素點(為了支持視網膜顯示)。

保持一致的幀率

為了做到動畫的平滑,你需要以60FPS(幀每秒)的速度運行,以同步屏幕刷新速率。通過基於NSTimer或者CADisplayLink的動畫你可以降低到30FPS,而且效果還不錯,但是沒辦法通過Core Animation做到這點。如果不保持60FPS的速率,就可能隨機丟幀,影響到體驗。

你可以在使用的過程中明顯感到有沒有丟幀,但沒辦法通過肉眼來得到具體的數據,也沒法知道你的做法有沒有真的提高性能。你需要的是一系列精確的數據。

你可以在程式中用CADisplayLink來測量幀率(就像11章“基於定時器的動畫”中那樣),然後在屏幕上顯示出來,但應用內的FPS顯示並不能夠完全真實測量出Core Animation性能,因為它僅僅測出應用內的幀率。我們知道很多動畫都在應用之外發生(在渲染伺服器進程中處理),但同時應用內FPS計數的確可以對某些性能問題提供參考,一旦找出一個問題的地方,你就需要得到更多精確詳細的數據來定位到問題所在。蘋果提供了一個強大的Instruments工具集來幫我們做到這些。

12.4 總結

總結

在這章中,我們學習了Core Animation是如何渲染,以及我們可能出現的瓶頸所在。你同樣學習瞭如何使用Instruments來檢測和修複性能問題。

在下三章中,我們將對每個普通程式的性能陷阱進行詳細討論,然後學習如何修複。


您的分享是我們最大的動力!

更多相關文章
  • Navicat Keygen - 註冊機是怎麼工作的? 1. 關鍵詞解釋. Navicat激活公鑰 這是一個2048位的RSA公鑰,Navicat使用這個公鑰來完成相關激活信息的加密和解密。 這個公鑰被作為 RCData 類型的資源儲存在 navicat.exe 當中。資源名為"ACTIVATION ...
  • ElasticSearch 用Scroll(對應資料庫的游標) 一次查出全部數據 ...
  • SQLite 的 DELETE 語句用於刪除表中已有的記錄。可以使用帶有 WHERE 子句的 DELETE 查詢來刪除選定行,否則所有的記錄都會被刪除。 SQLite 要清空表記錄,只能使用Delete來刪除全部表數據。但是與別的資料庫不同,SQlite在為表創建自增列後,會將表自增列的當前序號保存 ...
  • 參考51CTO博客 問題描述:使用scn號恢復誤刪數據 1.查詢系統閃回的scn值以及當前日誌的scn值,因為我這個是測試,創建的表是在在後邊,所以scn值要大於下邊這兩個scn值,所以對我恢複數據沒有用,如果我創建的數據是在下邊這兩個SCN值之前,也就是比這兩個時間點SCN值小,就可以用這兩個sc ...
  • #!/bin/bash env echo "Download msyql5.7 rpm..." sudo yum install wget wget -i -c http://dev.mysql.com/get/mysql57-community-release-el7-10.noarch.rpm ... ...
  • 使用CameraLibrary項目,在部分手機或平板上不能正常使用,要報“打開相機失敗”查看debug日誌顯示“setParameters failed”。 找到CameraView.java中的setCameraParameters方法,註釋掉 //自動聚焦模式 //parameters.setF ...
  • 在安卓開發當中,一個十分重要的佈局則是底部標題欄了,擁有了底部標題欄,我們就擁有了整個軟體UI開發的框架,一般而言,整個軟體的佈局首先就是從底部標題欄開始構建,然後再開始其他模塊的編寫,組成一個完善的軟體,那麼如何才能夠編寫一個底部標題欄呢,我這裡使用了碎片來實現,當然是碎片的動態載入的方式,靜態加 ...
  • 問題 Android 設置頁面的啟動模式為 singletask 之後,當按Home 退出時,再重新打開應用,還會進入首啟動頁。就會造成一些應用需要重新登錄,當前頁數據丟失等問題 解決 去除啟動頁的 singletask 的啟動模式(AndroidManifest.xml) 在啟動頁activity ...
一周排行
  • " 返回《C 併發編程》" "1. 概念介紹" "2. 非同步編程" "2.1. async運行過程" "2.2. async運行中同步上下文簡介" "2.3. 創建Task實例" "2.4. 捕獲非同步異常類型" "3. 並行編程" "3.1. Parallel" "3.2. 異常處理" "3.3. ...
  • 我們先看看兩個特效,感受一下,有沒有學習的動力? 核心API:Texture2D.SetPixel(int x, int y, Color color),Texture2D.Apply() 實現原理:對象池 思路: 第一幀繪製前:遍歷瓦片上所有活著的粒子對象並且進行數據操作(或運動,死亡),發生運動 ...
  • 原來的導出方式比較適用於比較簡單的導出,每一條數據在一行,數據列雖然自定義程度比較高,如果要一條數據對應多行就做不到了,於是就想支持根據模板導出,在 1.8.0 版本中引入了根據模板導出的功能 ...
  • 創建一個bat腳本, 裡面寫上: reg delete HKEY_CURRENT_USER\Software\JetBrains\dotMemory /freg delete HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Ex ...
  • Linux下有vsyscall來優化一些例如time(NULL), gettimeofday這種調用的消耗; 但是Windows下, 沒有類似的東西, 但是思路還是有的 1. 程式啟動的時候, 獲取一下準確的時間戳 2. 然後每次需要獲取時間的時候, 獲取一下流逝的時間, 可以通過獲取CPU的tic ...
  • 當用戶按下鍵盤上的一個鍵時,就會發生一系列事件。下表根據他們的發生順序列出了這些事件: 表 所有元素的鍵盤事件(按順序) 鍵盤處理永遠不會像上面看到的這麼簡單。一些控制項可能會掛起這些事件中的某些事件,從而可執行自己更特殊的鍵盤處理。最明顯的例子是TextBox控制項,它掛起了TextInput事件。對 ...
  • static void LocalMethod() { Cube(100); void Cube(int x) => Console.WriteLine($"The cube of {x} is {x * x * x}"); } static void GoToDemo() { int i = 1; ...
  • 滑鼠事件執行幾個關聯的任務。當滑鼠移到某個元素上時,可通過最基本的滑鼠事件進行響應。這些事件是MouseEnter(當滑鼠指針移到元素上時引發該事件)和MouseLeave(當滑鼠指針離開元素時引發該事件)。這兩個事件都是直接事件,這意味著他們不使用冒泡和隧道過程,而是源自一個元素並且只被該元素引發 ...
  • 反射這個詞聽起來就很牛逼是吧? 嗯的確,反射是比較高級的特性,只有語言基礎很扎實的Dev們才應該使用它。 搞點反射,可以提高程式的靈活性、可擴展性、耦合度。 反射這東西,是為了動態地運行時載入,相比於靜態代碼。編譯的時候就是板上釘釘了。 就是說,如果你的程式需要在運行時搞一些晚綁定,動態載入或檢查對 ...
  • 眾所周知,微服務架構是由一眾微服務組成,項目中調用其他微服務介面更是常見的操作。為了便於調用外部介面,我們的常用思路一般都是封裝一個外部介面的客戶端,使用時候直接調用相應的方法。webservice或WCF的做法就是引用服務,自動生成客戶端。在webapi2.0里,我們都會手動封裝一個靜態類。那麼在 ...
x