💨

Flutterの課題、Early-onset jankとは何か

2022/07/25に公開
1

Flutterはとても良いフレームワーク・エンジンですが、もちろんまだ発展途上な部分もあります。
今日は、Flutterのパフォーマンス的課題の一つである「Early-onset jank」というものについて説明したいと思います。

Early-onset Jank: 初回アニメーション時のカクツキ

Flutterのパフォーマンスの課題として大きなものに、「初回アニメーション時のカクツキ」というものがあります。これは、Android/iOS両方に見られる問題で、特にiOSの場合は顕著です。

動画を見た方が分かりやすいと思うので、Flutter公式ドキュメントのGif動画を貼ります。
早発的なJank

これは初回アニメーション時のカクツキに対する現段階の打ち手「SkSL-based Shader Warmup」という手法に関する公式ドキュメントのbefore/afterの比較動画です。(「SkSL-based Shader Warmup」が何か、という点は後述します)

左が対策を何も行っていない場合の動画で、遷移アニメーションにカクツキが見られます。
このカクツキは初回アニメーション時にのみ見られるもので、アプリを起動後二回目以降のアニメーション時はスムーズにアニメーションすることが特徴です。(なので、早発性 early-onset というワードが使われています)

何も対策をしていない一般的なFlutter製のアプリはこのカクツキが分かりやすく確認できます。特に、上のGif動画のようにCupertinoPageTransitionを使ってる箇所やその他大きなアニメーションをしている箇所を実際に触ってみると、初回だけカクツキやモタツキが感じられると思います。

原因

Early-onset jankの原因は、アニメーション時に重たい(数十ms~数百ms)のシェーダーのコンパイル処理が走ることです。(シェーダーとは、複雑な画像処理を高速に行うために、GPUで処理されるプログラムのことです)

Androidの場合は一度アニメーションを実行すれば、シェーダーのキャッシュが永続化されるため次回アプリ起動時もアニメーションがスムーズになりますが、iOSの場合はアプリ起動後、毎回、初回のアニメーションがカクツキます。

これを解決するための現時点での最善手が、「SkSL-based Shader Warmup」です。

SkSL-based Shader Warmup

カクツキの原因は、アニメーション時のシェーダーのコンパイル処理でした。
よって解決方法は、アニメーション実行よりも前にコンパイル処理を行うことです。

Skiaでは、内部的にSkSLと呼ばれるシェーダー言語を用いています。このSkSLで記述されたシェーダーがアニメーション時に各GPUドライバ向けにコンパイルされます。
このSkSLのコンパイル処理を、アニメーションが発火するよりずっと前のアプリ起動時にまとめて行う対策が、SkSL-based Shader Warmupです。

コンパイルを行うには、もちろんソースコードが必要です。ですが、既存の実装だと一体どのソースコードが実際に使われるか分かりません。
なので、実際に使われるソースコードをアプリを動かして収集する必要があります。

収集する手順としては、以下のコマンドでFlutterアプリを実行します。

$ flutter run --cache-sksl --profile
よしなにMボタンを押す
Wrote SkSL data to /.../flutter_01.sksl.json

すると、カレントディレクトリにflutter_01.sksl.jsonファイルが書き出されます。
このファイルをアプリにバンドルすることで、アプリ起動時に自動でウォームアップ処理が行われ、Early-onset jankが改善します。

バンドルの手順などは公式ドキュメントを参照ください: https://docs.flutter.dev/perf/shader

(SkSL-based Shader Warmupより前には、--dump-skp-on-shader-compilationフラグとShaderWarmUpクラスを用いたWarmup手法が使われていたようです)

sksl.jsonファイルの中身

実際に、書き出されたflutter_01.sksl.jsonを開いてみると、JSONのValueとしてbase64エンコードされたSkSLが埋め込まれています。

確認してみましょう。

$ echo 'CAAAA....=' | base64 -d
uniform float4 sk_RTAdjust;
in float2 position;
in half4 color;
out half4 vcolor_S0;
void main()
{
        // Primitive Processor QuadPerEdgeAAGeometryProcessor
        vcolor_S0 = color;
        sk_Position = position.xy01;
}
uniform float4 uinnerRect_S1;
uniform half2 uradiusPlusHalf_S1;
in half4 vcolor_S0;
out half4 sk_FragColor;
half4 CircularRRect_S1(half4 _input)
{
        float2 dxy0 = uinnerRect_S1.LT - sk_FragCoord.xy;
        float2 dxy1 = sk_FragCoord.xy - uinnerRect_S1.RB;
        float2 dxy = max(max(dxy0, dxy1), 0.0);
        half alpha = half(saturate(uradiusPlusHalf_S1.x - length(dxy)));
        return _input * alpha;
}
void main()
{
        // Stage 0, QuadPerEdgeAAGeometryProcessor
        half4 outputColor_S0;
        outputColor_S0 = vcolor_S0;
        const half4 outputCoverage_S0 = half4(1);
        half4 output_S1;
        output_S1 = CircularRRect_S1(outputCoverage_S0);
        {
                // Xfer Processor: Porter Duff
                sk_FragColor = outputColor_S0 * output_S1;
        }
}
...

これらの収集されたSkSLが、アプリ起動時にコンパイルされ、アニメーション時にキャッシュが再利用されることでカクツキが改善します。

SkSL-based Shader Warmupの課題: 開発体験の悪さ

現時点でSkSL-based Shader Warmupが最善手ではありますが、まだ課題も残っています。

SkSLを収集する手順を説明しましたが、この作業を開発者がUIの変更の度に行うのは非効率的です。
また、このSkSLはEngineのバージョンが上がるたびに無効になるため、Flutter更新の度に開発者がアプリ全体を網羅的に操作して、使われるSkSLを集め直す必要があります。
(integrate_testを使って収集を自動化もできますが、これも比較的コストが高めです)

これが主な課題の、アプリを実際に動かしてキャッシュを作る開発体験の悪さ、です。

(これ以外にも、起動時間がコンパイル処理によって長くなること、バンドルサイズがものに寄りますがだいたい数百KBぐらい増えること、などが課題としてあります)

根本解決: Impeller

SkSL-based Shader Warmupには、開発体験の悪さという課題がありました。
この課題は、将来的なFlutterの改善により解消に向かっています。

Flutterは3.0.0のリリースで、そもそもランタイムのシェーダーコンパイルを消し去ったImpellerという仕組みを実験的にサポートしました。
現時点でiOSのみの対応ですが、—-enable-impellerというフラグを付けて実行するか、Info.plistFLTEnableImpellerという値をtrueとして設定することで有効化できます。
(あくまで実験的で、まだまだ実用段階ではありません。前述の「SkSL-based Shader Warmup」が現時点の最善手です)

まだまだ実装されていない機能も多く、見た目はかなりバギーですが、カクツキがかなり減っているように見えます。

このImpellerは、今まで使われていた描画エンジンのSkiaを代替し、Engineのビルド時にシェーダーのコンパイルを行うことで、アプリ実行時のシェーダーコンパイルを取り去った次世代描画エンジンです。
FlutterのサンプルアプリのGalleryの最も重たいフレームが、20倍ほど高速化したと説明されています。

Impeller
The team has been hard at work on a solution to address early-onset jank on iOS and other platforms. In the Flutter 3 release, you can preview an experimental rendering backend called Impeller on iOS. Impeller precompiles a smaller, simpler set of shaders at engine build time so that they won’t compile while an app is running; this has been a major source of jank in Flutter. Impeller is not production ready and is far from finished. Not all Flutter features are implemented yet, but we’re pleased enough with its fidelity and performance in the flutter/gallery app that we are sharing our progress. In particular, the worst frame in the transition animation of the Gallery app is around 20x faster.
-- What’s new in Flutter 3, https://medium.com/flutter/whats-new-in-flutter-3-8c74a5bc32d0

次世代描画エンジンのImpellerが一体どのように動いているのか、詳しい仕組みについては記事にしようと思っています。(可能であれば…)

その他のEarly-onset jankに対するFlutterの対応を知りたい人は、ぜひ Early-onset jank - flutter/flutter に一覧がありますので見てみてください。

参考文献

Discussion

mjhdmjhd

Impellerはまだまだ実験的サポート、プレビュー版といった位置付けです。今後の開発に期待しましょう!(文章を「実験的」であることを強調するよう修正しています)