2017/10/16

Android Performance. UI Rendering

レイアウトXMLはどのようなプロセスを経てピクセル情報に変換され, 画面に描画されるのでしょうか?
Androidのパフォーマンスを改善するには, UIレンダリングの仕組みを理解しておく必要があります.

Android Performance. Dropped frameでは画面のアップデートが16ms毎に行われ, これが遅延するとユーザ体験を悪くしてしまうことについて触れました.

アプリが60fpsを維持するためにはMainThreadでの処理を軽くし, 16msごとのリフレッシュレートを逃さないようにしなければなりません.
60fpsを維持できなくする理由はたくさんありますが, 今回はViewの更新とレンダリングパイプラインについて見ていきます.

Layout & Draw

レイアウトXMLがパースされるとレイアウトツリー(ビューヒエラルキー)が作成されます. 描画はルートノードから始まり, ツリーを渡り歩きながらレイアウトと描画が行われます.
複数のビューを持つ親ビュー(ビューグループ)の場合は, 子ビューにいくつかの制約や制限をつけて描画を要求します. 描画の順序は親ビューが先で子ビューが後になるので, 親が子より奥に描画され, 子ビューが親に重なる形で描画されることになります.

ビューのレイアウトにはメジャーとレイアウトのプロセスがあります. 親ビューは子ビューのサイズに依存するので, まずは子ビューのサイズを計測します. 計測が終わると親ビューが全ての子ビューを計算されたサイズで配置していきます. これはビューツリーからトップダウントラバーサルで処理されるため, ビュー階層が浅いほどパフォーマンスが良くなります. ビューのレイアウトが終わるとこれを描画します.

Rasterization

Viewをディスプレイに描画するには, ボタンやテキストをピクセルに変換する必要があります. 例えば, ラスタ形式(ビットマップ, etc.)ではない文字列やボタン, ベクタードロワブルのようなオブジェクトはラスタライズと呼ばれるプロセスでピクセル形式に変換されてから画面に出力されます.
Android3.0以降, レンダリングパイプラインはハードウェアアクセラレーションをサポートしました. ラスタライズはとても時間のかかるプロセスなので, 専用にデザインされたハードウェアユニット(アクセラレータ)で高速に処理されます. これがGPU(Graphics Processing Unit)です.

GPUはポリゴンやテクスチャといったいわゆる画像などのために設計されたハードウェアユニットです. CPUはそういった画像をGPUに供給する役割を果たします. この操作には OpenGL ES のAPIを使って行われています.

ボタンなどのUIオブジェクトを描画したい場合, まずはCPUでポリゴンやテクスチャ情報に変換し, これをGPUに送ってラスタライズします. CPUでポリゴンやテクスチャ情報に変換したり, GPUにこれを入力する処理は高速ではありません.

パフォーマンスのために, これらのオブジェクトに変換する回数を減らすことは効果があります. OpenGL ES のAPIはGPUに入力したオブジェクトをGPU上にそのままキャッシュさせることが可能です. 同じボタンやUIコンポーネントを使う場合は, 単にGPU上に残ったキャッシュを参照すればよいので, 余計なオーバーヘッドが起こりません. レンダリングの性能を最適化するには, GPU上にあるキャッシュを可能な限り長時間保持して, これを再利用するようにすることです.

Display list

標準UIコンポーネントのドロワブルなどはあらかじめGPUに入力されており, これらの描画は効率的に動きます.
しかし, 実際のUIは複雑で, 例えば背景画像といったビットマップはCPUが画像をメモリにロードしてGPUに転送されます. また, ベクタードロワブルはパスを繋げてポリゴンを描画する必要があります.
テキストにいたってはCPUで文字グリフをテクスチャにラスタライズしたあとGPUにこれを入力し, GPUメモリにグリフを参照する領域を描画します.
アニメーションリソースはもっと複雑で, ビジュアルが変わればGPUリソースを1コマ, 1コマ何度も更新しなければなりません.

ハードウェアアクセラレーションが有効である場合, ディスプレイリストを使った新しい描画モデルで描画されます. ディスプレイリストにはGPUレンダリングに必要な情報アセットとOpenGLコマンドリストが格納されていて, 無駄なオーバーヘッドを抑えて効率的に描画することができます.

Draw Phase

ビューが実際にレンダリングされる前に, まずGPUに適した形式に変換するDrawフェーズがあります. これはJavaによるonDrawコマンドで行われますが, Canvasを使ってテッセレートされた複雑なオブジェクトかもしれません.
この変換が終わると, システムによって結果がディスプレイリストとしてキャッシュされます.

Androidではその都度画面全体を再描画することはせず, 更新が必要な領域に絞って描画します. しかし, 多数のビューが無効化(invalidate())されるとDrawフェーズに多くの時間を費やします. あるいはonDrawで非常に複雑なロジックを抱えているかもしれません.

Execute Phase

作成されたディスプレイリストは2Dレンダラーによって実行されます. ディスプレイリストはOpenGL ES APIを使ってドローされます. これによってGPUにデータが送られ, 最終的にピクセルを画面に送ります.
複雑な描画をするカスタムビューでは, OpenGLが描画できるようにコマンドも複雑になる必要があります. 複雑なビューを描画することは2DレンダラーのExecuteフェーズに多くの時間を費やす原因になります.

画面上でUIオブジェクトの位置が変わった場合は, 同じディスプレイリストをもう1度Executeフェーズを実行するだけです. しかし, 画像のビジュアルが変化すると過去のディスプレイリストが無効になるかもしれません. その場合はDrawフェーズでディスプレイリストを再作成して, 再び実行する必要があります. 画像の描画内容が変わるたびにこのプロセスが繰り返されます. このパフォーマンスは画像の複雑さによって変わるため不正確です.

Process

DrawフェーズとExecuteフェーズが終わるとCPUはフレームのレンダリングが完了したことをGPU/グラフィックドライバーに伝えます. このアクションはブロッキングコールであるため, GPUがコマンドを受け付けたことの応答をCPUは待つことになります.
GPUからのコマンド応答が長くなると, このプロセスも長くなります. プロセスが長くなるのは大抵GPUが多くの仕事をしていることが多いです. 多数の複雑なビューの結果, 多くのOpenGLレンダリングコマンドが必要になりGPUの仕事が増えるのです.

16ms / Frame

16msの間に起こるレンダリングパイプラインは次の通りです.

  1. Input(ユーザからの入力)
  2. Animation(アニメーション)
  3. Measure&Layout
  4. Drawing(Draw Phase)
  5. Sync/Upload
  6. Issuing Commands(Execute Phase)
  7. Processing(Process)
  8. Misc

これらの時間はProfile GPU Renderingツールで見ることができます. 下図はフレームごとのレンダリングに要した時間を並べたもので, 緑色の水平線が16msを示すラインです. これを超えるとDropped Frameが発生します.


実際にアプリケーションを作成すると, 16ms/フレーム・60fpsを維持することが大変であることを実感できるでしょう. パフォーマンスを改善するには計測して問題のある箇所を特定することを繰り返すことが重要です.

前回と合わせて, 最低限必要な知識は揃いましたので, アプリのパフォーマンスを悪くしている箇所を特定し, それを改善するアプローチについて次回以降に書きたいと思います.

次回に続く…