iOSアニメーションと効率のためのチューニング

公開: 2022-03-11

優れたアプリの構築は、見た目や機能だけでなく、パフォーマンスも重要です。 モバイルデバイスのハードウェア仕様は急速に改善されていますが、パフォーマンスの低下、画面遷移のたびに途切れたり、スライドショーのようにスクロールしたりするアプリは、ユーザーエクスペリエンスを台無しにし、フラストレーションの原因となる可能性があります。 この記事では、iOSアプリのパフォーマンスを測定し、効率を上げるために調整する方法を説明します。 この記事の目的のために、画像とテキストの長いリストを備えたシンプルなアプリを作成します。

iOSアニメーションと効率のためのチューニング

パフォーマンスをテストするために、実際のデバイスの使用をお勧めします。 アプリを構築し、スムーズなiOSアニメーション用にアプリを最適化することに真剣に取り組んでいる場合、シミュレーターはそれを単純にカットしません。 シミュレーションは、現実と歩調が合わない場合があります。 たとえば、シミュレータがMacで実行されている可能性があります。これは、CPU(中央処理装置)がiPhoneのCPUよりもはるかに強力であることを意味します。 逆に、GPU(グラフィックスプロセッシングユニット)はデバイスとMacで大きく異なるため、Macは実際にデバイスのGPUをエミュレートします。 その結果、シミュレーターではCPUにバインドされた操作が高速になる傾向があり、GPUにバインドされた操作は遅くなる傾向があります。

60FPSでのアニメーション

知覚されるパフォーマンスの重要な側面の1つは、アニメーションが画面のリフレッシュレートである60 FPS(フレーム/秒)で実行されるようにすることです。 タイマーベースのアニメーションがいくつかありますが、ここでは説明しません。 一般的に、50 FPSを超える速度で実行している場合、アプリはスムーズでパフォーマンスが高く見えます。 アニメーションが20〜40 FPSで動かなくなった場合、目立ったスタッターが発生し、ユーザーはトランジションの「粗さ」を検出します。 20 FPS未満のものは、アプリのユーザビリティに深刻な影響を及ぼします。

始める前に、CPUバウンド操作とGPUバウンド操作の違いについて説明する価値があります。 GPUは、グラフィックスの描画用に最適化された専用チップです。 CPUも可能ですが、はるかに低速です。 これが、2Dまたは3DモデルからGPUに画像を生成するプロセスであるグラフィックスレンダリングの多くをオフロードしたい理由です。 ただし、GPUの処理能力が不足すると、CPUが比較的空いている場合でも、グラフィックス関連のパフォーマンスが低下するため、注意が必要です。

Core Animationは、アプリの内部と外部の両方でアニメーションを処理する強力なフレームワークです。 プロセスを6つの主要なステップに分割します。

  1. レイアウト:レイヤーを配置し、色や相対位置などのプロパティを設定する場所

  2. 表示:これは、バッキング画像がコンテキストに描画される場所です。 drawRect:またはdrawLayer:inContext:で作成したルーチンには、ここからアクセスします。

  3. 準備:この段階で、Core Animationは、描画するためにコンテキストをレンダラーに送信しようとしているため、画像の解凍などの必要なタスクを実行します。

  4. コミット:ここで、CoreAnimationはこのすべてのデータをレンダリングサーバーに送信します。

  5. デシリアライズ:前の4つの手順はすべてアプリ内で行われ、アニメーションはアプリの外部で処理され、パッケージ化されたレイヤーはレンダリングサーバーが理解できるツリーにデシリアライズされます。 すべてがOpenGLジオメトリに変換されます。

  6. 描画:形状(実際には三角形)をレンダリングします。

プロセス1〜4はCPU操作であり、5〜6はGPU操作であると推測したかもしれません。 実際には、最初の2つのステップしか制御できません。 GPUの最大のキラーは、GPUがフレームごとに同じピクセルを複数回埋める必要がある半透明のレイヤーです。 また、オフスクリーン描画(シャドウ、マスク、丸みを帯びたコーナー、レイヤーのラスタライズなどのいくつかのレイヤー効果により、Core Animationがオフスクリーンで描画するように強制されます)もパフォーマンスに影響します。 GPUで処理するには大きすぎる画像は、代わりにはるかに遅いCPUで処理されます。 シャドウは、レイヤーに直接2つのプロパティを設定することで簡単に実現できますが、画面上にシャドウのあるオブジェクトが多数ある場合は、パフォーマンスが低下する可能性があります。 これらの影を画像として追加することを検討する価値がある場合があります。

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を使用するには、「実行」から「プロファイル」に変更する必要があります。 また、実際のデバイスに接続する必要があります。シミュレータですべての機器を使用できるわけではありません(シミュレータのパフォーマンスを最適化するべきではないもう1つの理由です!)。 主に「GPUドライバー」、「Core Animation」、「TimeProfiler」のテンプレートを使用します。 あまり知られていない事実は、別の機器で停止して実行する代わりに、複数の機器をドラッグアンドドロップして同時に複数の機器を実行できることです。

機器のセットアップが完了したので、測定してみましょう。 まず、FPSに本当に問題があるかどうかを見てみましょう。

はい、ここでは18FPSを取得していると思います。 メインスレッドでバンドルから画像をロードすることは本当にそれほど高価で費用がかかりますか? レンダラーの使用率がほぼ最大になっていることに注意してください。 タイラーの使用率も同様です。 どちらも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; }

そして、もう一度測定します。

たった2行で、FPSが2倍向上しました。 現在、平均して40FPSを超えています。 しかし、画像の読み込みをバックグラウンドスレッドに移動した場合、それは役に立ちますか?

 -(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; }

測定すると、パフォーマンスは平均して約18FPSであることがわかります。

祝う価値はありません。 つまり、フレームレートは向上しませんでした。 これは、メインスレッドの詰まりが間違っていても、それがボトルネックではなく、レンダリングが原因だったためです。

平均して40FPSを超えていたより良い例に戻ると、パフォーマンスは著しくスムーズです。 しかし、実際にはもっとうまくやることができます。

Core Animation Toolで「ColorBlendLayers」を確認すると、次のように表示されます。

「カラーブレンドレイヤー」は、GPUが多くのレンダリングを行っている画面に表示されます。 緑はレンダリングアクティビティの量が最も少ないことを示し、赤は最も多いことを示します。 ただし、shouldRasterizeをYESに設定しました。 「カラーブレンドレイヤー」は「カラーヒットグリーンおよびミスレッド」と同じではないことを指摘しておく価値があります。 後者は基本的に、キャッシュが再生成されるときにラスタライズされたレイヤーを赤で強調表示します(キャッシュを適切に使用していないかどうかを確認するための優れたツール)。 shouldRasterizeをYESに設定しても、不透明でないレイヤーの初期レンダリングには影響しません。

これは重要なポイントであり、考えるために少し立ち止まる必要があります。 shouldRasterizeがYESに設定されているかどうかに関係なく、フレームワークをレンダリングするには、すべてのビューをチェックし、サブビューが透明か不透明かに基づいてブレンドする(またはしない)必要があります。 UILabelが不透明でないことは理にかなっているかもしれませんが、それは価値がなく、パフォーマンスを損なう可能性があります。 たとえば、白い背景の透明なUILabelはおそらく価値がありません。 不透明にしましょう:

これによりパフォーマンスが向上しますが、アプリのルックアンドフィールが変更されました。 これで、ラベルと画像が不透明になったため、影が画像の周りを移動しました。 この変更を気に入る人はおそらくいないでしょう。元のルックアンドフィールを一流のパフォーマンスで維持したいのであれば、希望を失うことはありません。

元の外観を維持しながら余分なFPSを絞り出すには、これまで無視してきたCoreAnimationフェーズの2つを再検討することが重要です。

  1. 準備
  2. 専念

これらは完全に私たちの手に負えないように見えるかもしれませんが、それは完全に真実ではありません。 画像を読み込むには、解凍する必要があることがわかっています。 解凍時間は画像形式によって異なります。 PNGの場合、解凍はJPEGよりもはるかに高速です(ただし、読み込みは長く、これは画像サイズにも依存します)。そのため、PNGを使用するのは正しい方向に進んでいましたが、解凍プロセスとこの解凍については何もしていません。 「描画のポイント」で起こっています! これは、メインスレッドで時間をつぶすことができる最悪の場所です。

強制的に減圧する方法があります。 すぐにUIImageViewのimageプロパティに設定できます。 ただし、それでもメインスレッドのイメージは解凍されます。 より良い方法はありますか?

ここに一つ。 CGContextに描画します。ここで、画像を描画する前に解凍する必要があります。 これは(CPUを使用して)バックグラウンドスレッドで実行でき、必要に応じて画像ビューのサイズに基づいて境界を設定します。 これにより、メインスレッドから画像描画プロセスを最適化し、メインスレッドでの不要な「準備」計算を回避できます。

その間、画像を描くときに影を追加してみませんか? 次に、画像を1つの静的で不透明な画像としてキャプチャ(およびキャッシュ)できます。 コードは次のとおりです。

 - (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を超えており、レンダリング使用率とタイラー使用率は元の半分近くになっています。

要約

1秒あたりさらに数フレームをクランクアウトするために他に何ができるか疑問に思っている場合に備えて、もう探す必要はありません。 UILabelはWebKitHTMLを使用してテキストをレンダリングします。 CATextLayerに直接アクセスして、そこでシャドウを操作することもできます。

上記の実装でお気づきかもしれませんが、バックグラウンドスレッドで画像の読み込みを行っておらず、代わりにキャッシュしていました。 画像が5つしかないため、これは非常に高速に機能し、全体的なパフォーマンスに影響を与えることはなかったようです(特に、5つの画像すべてがスクロール前に画面に読み込まれたため)。 ただし、パフォーマンスを向上させるために、このロジックをバックグラウンドスレッドに移動してみることをお勧めします。

効率の調整は、ワールドクラスのアプリとアマチュアアプリの違いです。 特にiOSアニメーションに関しては、パフォーマンスの最適化は困難な作業になる可能性があります。 しかし、Instrumentsの助けを借りれば、iOSでのアニメーションパフォーマンスのボトルネックを簡単に診断できます。