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を維持することが大変であることを実感できるでしょう. パフォーマンスを改善するには計測して問題のある箇所を特定することを繰り返すことが重要です.

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

次回に続く…

2017/10/13

Android Performance. Dropped frame

SystemEvents, Input Events, Application, Service, Alarm, UI Drawingといった多くの処理はMain Thread(UI Thread) で実行されます.
重要なポイントは, 画面は16ミリ秒の間隔で再描画されているということです.

Why 16ms, Why 60fps?

人間は繋がりのある複数枚の絵が十分な速さで連続していると, それがあたかもアニメーションしているかのように錯覚します. パラパラ漫画やアニメGifの原理です.
アニメーションをスムーズに見せるために, どれだけ素早く画像を表示できるかという点が重要で, 滑らかで流れるようなアニメーションには必要不可欠な要素です.

人間の脳がアニメーションしているように感じるためには, 最低でも12fps程度の速度が必要です. これよりも遅いとパラパラ漫画のようなぎこちない見た目になります. 12fpsという速度はアニメーションには見えてもあまりスムーズには映りません.
24fpsは流れるようなアニメーションに見えますが, これはモーションブラーやビジュアルエフェクトの効果によるものです.
60fpsはモーションブラーやエフェクトなしでスムーズに映ります. これ以上のfpsはほぼ感知できない領域です.

注意すべきは人間の目の明敏さで, フレームレートが60fpsから24fpsに落ちると, 途端にアニメーションのスムーズさを欠いたように感じ, よくない印象を与えることになります.

VSYNC

スムーズなアニメーションを実現するためにも, Androidがどのようにして60fpsを実現しているのかを理解しておきましょう. それには2つの用語を理解しておく必要があります.

リフレッシュレート

1秒間に画面を何回リフレッシュできるかの値で, ハードウェアが定めた一定間隔で実行されます.
単位はHz(ヘルツ)で, 例えば60Hzであれば1秒間に60回のリフレッシュが可能です.

フレームレート

GPUが一秒間で幾つのフレームを描画できるかの値です.
単位はfpsで, 例えば60fpsであれば一秒間に60フレームの描画が可能です.

Synchronized

GPUが画像データを出力し, ハードウェアがそれを画面に表示します.
スクリーンの描画は, これを何度も繰り返しているので, GPUとハードウェアはできる限り一緒に働くことが望ましいのですが, リフレッシュレートとフレームレートは同じ頻度で起こることが保証されていません.

フレームレートがリフレッシュレートより早いと, ティアリングという現象が発生します.
これは, GPUが新しいフレームをメモリに上書きしている最中に, 画面がリフレッシュされてしまい, まだ更新中の画像を描画してしまうことで, 画像が崩れる(部分的に古いフレームが残る)現象です. これを解決するのがダブルバッファリングです.

ダブルバッファリングでは, GPUがバックバッファにフレームを描画し, それが終わるとフレームバッファーと呼ばれる領域にコピーします. 画面をリフレッシュするときはこのフレームバッファから取り出してリフレッシュするわけです. これによって古いフレームへの上書きが行われないので, 中途半端に上書きされた状態にはなりません.

ここで注意しないといけないことのは, 画面のリフレッシュ中にバックバッファからフレームバッファへのコピー作業が発生しないようにすることです. そうしないと, 同じ問題が起こります. ここで登場するのがVSYNC(Vertical Synchronization)です.

通常はフレームレートがリフレッシュレートよりも高いことが望ましいです. なぜなら, 画面を読み込むよりもGPUのリフレッシュの方が早くなるからです.
GPUはフレームをバックバッファに載せると, VSYNCによって次の画面リフレッシュまで処理を待つことになります.

しかし, 反対にフレームレートがリフレッシュレートよりも低い場合, 例えば30fpsに対して60Hzのディスプレイであった場合, フレームバッファのリフレッシュ作業には, 画面リフレッシュの倍の時間を要するため, 同じフレーム内容で2回ずつリフレッシュすることになります.
問題は, これが断続的に起こった場合です.

十分に早いフレームレートで動作しても, 突然フレームレートが落ちると, ユーザはスムーズなアニメーションに続いて, ぶつ切りになったものを見ることになります.
これらの事象は一般的に ラグ, ジャンク, ヒッチング, スタッター と呼ばれます.

アプリの開発者はこれらの事象を避けなければなりません.
人間の目は明敏で, フレームレートが落ちると, 途端にアニメーションのスムーズさを欠いたように感じ, よくない印象を与えてしまうことを思い出してください.

アプリ開発者が目指すところは 常に60fpsのパフォーマンスを維持すること です.

1000ms / 60frames = 16.666ms/frame

MainThreadでは16msの間隔でUI Drawingイベントが発生します. 60fpsの滑らかなアニメーションを実現するためには16ms間隔の描画が必要になります.

Main Thread(UI Thread)

単一スレッドの処理は逐次実行されるため, 順番に処理されていきます. MainThreadも例外ではありません. UI DrawingイベントもMainThreadで実行されるので, もしあなたの処理が長引くとUI Drawingイベントが遅延し, 次のリフレッシュレートのタイミングを逃してしまい, アニメーションで描画されるはずであったフレームが抜け落ちる ドロップフレーム が発生します.
あなたが書いた処理の後には, 常にUI Drawingイベントが待ち構えていることを忘れないでください.

次回に続きます…