iOS 動畫和優化效率
已發表: 2022-03-11構建一個偉大的應用程序不僅僅關乎外觀或功能,還關乎它的性能。 儘管移動設備的硬件規格正在快速改進,但性能不佳、每次屏幕轉換時卡頓或像幻燈片放映一樣滾動的應用程序可能會破壞用戶的體驗並成為令人沮喪的原因。 在本文中,我們將了解如何衡量 iOS 應用程序的性能並對其進行優化以提高效率。 出於本文的目的,我們將構建一個包含一長串圖像和文本的簡單應用程序。
出於測試性能的目的,我建議使用真實設備。 如果您認真地構建應用程序並優化它們以實現流暢的 iOS 動畫,那麼模擬器根本不會削減它。 模擬有時可能與現實脫節。 例如,模擬器可能在您的 Mac 上運行,這可能意味著 CPU(中央處理單元)比 iPhone 上的 CPU 強大得多。 相反,您的設備和 Mac 之間的 GPU(圖形處理單元)是如此不同,以至於您的 Mac 實際上模擬了設備的 GPU。 因此,受 CPU 限制的操作在您的模擬器上往往更快,而受 GPU 限制的操作往往更慢。
以 60 FPS 製作動畫
感知性能的一個關鍵方面是確保您的動畫以 60 FPS(每秒幀數)運行,即屏幕的刷新率。 有一些基於計時器的動畫,我們不會在這裡討論。 一般來說,如果您以超過 50 FPS 的速度運行,您的應用程序看起來會很流暢且性能良好。 如果您的動畫卡在 20 到 40 FPS 之間,則會出現明顯的卡頓,並且用戶會在過渡中檢測到“粗糙度”。 任何低於 20 FPS 的速度都會嚴重影響您應用的可用性。
在我們開始之前,可能有必要討論一下 CPU 綁定和 GPU 綁定操作之間的區別。 GPU 是專門為繪製圖形而優化的芯片。 雖然 CPU 也可以,但速度要慢得多。 這就是為什麼我們希望將盡可能多的圖形渲染(從 2D 或 3D 模型生成圖像的過程)轉移到 GPU 上。 但我們需要小心,因為當 GPU 的處理能力耗盡時,即使 CPU 相對空閒,圖形相關的性能也會下降。
Core Animation 是一個強大的框架,可以在應用程序內部和外部處理動畫。 它將過程分解為 6 個關鍵步驟:
佈局:您在其中排列圖層並設置其屬性,例如顏色及其相對位置
顯示:這是將背景圖像繪製到上下文的位置。 您在
drawRect:
或drawLayer:inContext:
中編寫的任何例程都可以在此處訪問。準備:在這個階段,Core Animation 即將發送上下文到渲染器進行繪製,執行一些必要的任務,例如解壓縮圖像。
提交:Core Animation 將所有這些數據發送到渲染服務器。
反序列化:前面的 4 個步驟都在你的應用程序內,現在動畫在你的應用程序外處理,打包的層被反序列化成渲染服務器可以理解的樹。 一切都轉換為 OpenGL 幾何圖形。
繪製:渲染形狀(實際上是三角形)。
您可能已經猜到,進程 1-4 是 CPU 操作,而 5-6 是 GPU 操作。 實際上,您只能控制前兩個步驟。 GPU 的最大殺手是半透明層,其中 GPU 必須每幀多次填充相同的像素。 此外,任何離屏繪製(陰影、遮罩、圓角或圖層光柵化等多個圖層效果都會強制 Core Animation 離屏繪製)也會影響性能。 太大而無法由 GPU 處理的圖像將由速度慢得多的 CPU 處理。 雖然可以通過直接在圖層上設置兩個屬性來輕鬆實現陰影,但如果屏幕上有許多帶有陰影的對象,它們很容易降低性能。 有時值得考慮將這些陰影添加為圖像。
測量 iOS 動畫性能
我們將從一個包含 5 個 PNG 圖像和一個表格視圖的簡單應用程序開始。 在這個應用程序中,我們將加載 5 張圖像,但會重複超過 10,000 行。 我們將為圖像和圖像旁邊的標籤添加陰影:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; return cell; }
圖像被簡單地回收,而標籤總是不同的。 結果是:
垂直滑動時,您很可能會在視圖滾動時注意到卡頓。 此時您可能會認為我們在主線程中加載圖像的事實是問題所在。 可能如果我們把它移到後台線程,我們所有的問題都會得到解決。
與其盲目猜測,不如嘗試一下並衡量性能。 是時候使用儀器了。
要使用 Instruments,您需要將“Run”更改為“Profile”。 而且您還應該連接到您的真實設備,並非所有儀器都可以在模擬器上使用(這是您不應該在模擬器上優化性能的另一個原因!)。 我們將主要使用“GPU Driver”、“Core Animation”和“Time Profiler”模板。 一個鮮為人知的事實是,您可以拖放多個儀器並同時運行多個,而不是在不同的儀器上停止和運行。
現在我們已經設置好了儀器,讓我們測量一下。 首先讓我們看看我們的 FPS 是否真的有問題。
哎呀,我認為我們在這裡獲得了 18 FPS。 在主線程上從包中加載圖像真的那麼昂貴和昂貴嗎? 請注意,我們的渲染器利用率幾乎已達到極限。 我們的瓷磚利用率也是如此。 兩者都在 95% 以上。 這與在主線程上從包中加載圖像無關,所以我們不要在這裡尋找解決方案。
調整效率
有一個名為 shouldRasterize 的屬性,人們可能會建議您在這裡使用它。 shouldRasterize 到底是做什麼的? 它將您的圖層緩存為平面圖像。 所有這些昂貴的圖層繪圖都需要進行一次。 如果您的幀經常更改,則緩存沒有用處,因為無論如何每次都需要重新生成它。
快速修改我們的代碼,我們得到:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
我們再次測量:
僅用兩條線,我們就將 FPS 提高了 2 倍。 我們現在的平均速度超過 40 FPS。 但是,如果我們將圖像加載移到後台線程會有幫助嗎?
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; cell.tag = indexPath.row; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; dispatch_async(dispatch_get_main_queue(), ^{ if (indexPath.row == cell.tag) { cell.customCellImageView.image = image; } }); }); cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; // cell.layer.shouldRasterize = YES; // cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
經過測量,我們看到性能平均在 18 FPS 左右:

沒有什麼值得慶祝的。 換句話說,它沒有提高我們的幀速率。 那是因為即使阻塞主線程是錯誤的,這不是我們的瓶頸,渲染是。
回到更好的例子,我們平均高於 40FPS,性能明顯更流暢。 但我們實際上可以做得更好。
在 Core Animation Tool 上檢查“Color Blended Layers”,我們看到:
“顏色混合層”顯示在屏幕上,您的 GPU 正在執行大量渲染。 綠色表示渲染活動最少,而紅色表示最多。 但是我們確實將 shouldRasterize 設置為YES
。 值得指出的是,“顏色混合圖層”與“顏色命中綠色並錯過紅色”不同。 後者基本上在重新生成緩存時以紅色突出顯示柵格化圖層(一個很好的工具,可以查看您是否沒有正確使用緩存)。 將 shouldRasterize 設置為YES
對非透明圖層的初始渲染沒有影響。
這是很重要的一點,我們需要停下來思考一下。 無論 shouldRasterize 是否設置為YES
,渲染框架都需要檢查所有視圖,並根據子視圖是透明還是不透明進行混合(或不混合)。 雖然你的 UILabel 不透明可能是有意義的,但它可能毫無價值並且會扼殺你的表現。 例如,白色背景上的透明 UILabel 可能毫無價值。 讓我們讓它不透明:
這會產生更好的性能,但我們對應用程序的外觀和感覺已經改變。 現在,因為我們的標籤和圖像是不透明的,陰影已經在我們的圖像周圍移動。 可能沒有人會喜歡這種變化,如果我們想以一流的性能保留原始外觀和感覺,我們不會失去希望。
為了在保留原始外觀的同時擠出一些額外的 FPS,重要的是重新審視我們迄今為止忽略的兩個核心動畫階段。
- 準備
- 犯罪
這些似乎完全不受我們控制,但事實並非如此。 我們知道要加載的圖像需要解壓縮。 解壓縮時間根據圖像格式而變化。 對於 PNG 的解壓縮比 JPEG 快得多(儘管加載時間更長,這也取決於圖像大小),所以我們在使用 PNG 的道路上是正確的,但是我們對解壓縮過程沒有做任何事情,而且這個解壓縮正在“繪圖點”發生! 這是我們可以消磨時間的最糟糕的地方——在主線程上。
有辦法強制解壓。 我們可以立即將其設置為 UIImageView 的圖像屬性。 但這仍然會解壓縮主線程上的圖像。 有沒有更好的辦法?
有一個。 將其繪製到CGContext中,圖像需要在其中解壓縮才能繪製。 我們可以在後台線程中執行此操作(使用 CPU),並根據圖像視圖的大小對其進行必要的限制。 這將通過在主線程之外完成它來優化我們的圖像繪製過程,並使我們免於在主線程上進行不必要的“準備”計算。
當我們這樣做的時候,為什麼不在我們繪製圖像時添加陰影呢? 然後,我們可以將圖像捕獲(並將其緩存)為一張靜態的、不透明的圖像。 代碼如下:
- (UIImage*)generateImageFromName:(NSString*)imageName { //define a boudns for drawing CGRect imgVwBounds = CGRectMake(0, 0, 48, 48); //get the image NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; //draw in the context UIGraphicsBeginImageContextWithOptions(imgVwBounds.size, NO, 0); { //get context CGContextRef context = UIGraphicsGetCurrentContext(); //shadow CGContextSetShadowWithColor(context, CGSizeMake(0, 3.0f), 3.0f, [UIColor blackColor].CGColor); CGContextBeginTransparencyLayer (context, NULL); [image drawInRect:imgVwBounds blendMode:kCGBlendModeNormal alpha:1.0f]; CGContextSetRGBStrokeColor(context, 1.0, 1.0, 1.0, 1.0); CGContextEndTransparencyLayer(context); } image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; }
最後:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; // NSInteger index = (indexPath.row % [self.images count]); // NSString *imageName = [self.images objectAtIndex:index]; // // cell.tag = indexPath.row; // // dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; // UIImage *image = [UIImage imageWithContentsOfFile:filePath]; // // dispatch_async(dispatch_get_main_queue(), ^{ // if (indexPath.row == cell.tag) { // cell.customCellImageView.image = image; // } // }); // }); cell.customCellImageView.image = [self getImageByIndexPath:indexPath]; cell.customCellImageView.clipsToBounds = YES; // cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); // cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
結果是:
我們現在的平均速度超過 55 FPS,我們的渲染利用率和平鋪利用率幾乎是原來的一半。
包起來
萬一您想知道我們還能做些什麼來每秒增加幾幀,請不要再猶豫了。 UILabel 使用 WebKit HTML 來呈現文本。 我們可以直接進入 CATextLayer,也許也可以在那裡玩弄陰影。
您可能已經註意到,在我們上面的實現中,我們沒有在後台線程中加載圖像,而是緩存它。 由於只有 5 張圖像,因此運行速度非常快,並且似乎不會影響整體性能(尤其是因為所有 5 張圖像在滾動之前都已加載到屏幕上)。 但是您可能想嘗試將此邏輯移動到後台線程以獲得額外的性能。
優化效率是世界級應用程序和業餘應用程序之間的區別。 性能優化,尤其是在 iOS 動畫方面,可能是一項艱鉅的任務。 但是在 Instruments 的幫助下,可以很容易地診斷出 iOS 上動畫性能的瓶頸。