fresh digitable

めんどくさかったなってことを振り返ったり振り返らなかったりするための記録

RecyclerViewのようなものを作ろうとしたけどうまくいかなかった

仕事で作ったクラスの構造がよくなかったのでそのその反省を少しまとめてみる。

  • Adapterとは別にLayoutManager的なものも作る必要があったので、RecyclerViewの構造を参考にして似たようなものを作ることにした。RecyclerViewをなんとかして使えるようにすればよかったのかもしれないが、要件に合うLayoutManagerの作り方がわからなくてやめた。今思うとLayoutManagerは必要なかったかもしれない。
  • アニメーションについてもRecyclerViewのパターンを参考にしてItemAnimation的なものでアニメーションをやったらいいのではないかと思い立ち、えいやっと追加した。この時にViewHolderがいろんなインデックスを持っていることの意味をもう少し考えておくべきだった。
  • ただ単にデータの更新によってViewを付けたり外したりするだけならよかった。新しく増えたデータをViewにバインドした後で登場させるアニメーションや、データが減った時にViewを退場させるアニメーションのことを考慮しきれていなかった。データがなくなったらViewもなくなるというのが間違いだった。アニメーションが終わるまでremoveできない。その間にまたデータが復活するかもしれない。RecyclerViewの子Viewは取り付けられてから外されるまで、インデックスやフラグの値を更新しながらViewのライフサイクルの中でLayoutされたりデータをバインドされたりアニメーションで動かされたりしながら使われなくなったらプールの中に入る。データがなくなった時必ずしも取り外されるとは限らない。
  • ViewPoolからアタッチされていないViewを探してくるところもうまくいってなかった。
  • アニメーションが終わったのをどうやって知るのがいいのかわからない。
  • アニメーションは中断可能であり再入可能でなければならない。退場アニメーションの途中でデータが復活することがあるかもしれない。登場アニメーションの最中にデータが消えてしまうかもしれない。

lottie-androidの大雑把な処理の流れ

処理時間の性能改善をするとき、いつもはまずどこに伸びしろがありそうかあたりを付けるためにメソッドトレースのFlame Chartを見ている。LottieSampleの場合は大体setProgress()drawかそれ以外(プログレスバーとかログ出力)に分かれていて*1、これを見る限りだとsetProgress()の方が若干多いように見えたので、手始めにsetProgress()の処理時間を短縮する手立てを考えることにした。その過程で処理の流れがふんわりとわかるようになってきたので、今の段階で理解している内容を一旦まとめてみる。全体的な処理の流れは次の通り。

  • まずはJSONをパースするなどしてLottieComposeをつくる
  • LottieComposeLottieAnimationViewLottieDrawableに渡すと、LayerKeyframe, KeyframeAnimationなどのアニメーションに関係するオブジェクトが作られる
  • アニメーションが開始されたら基本的には以下の繰り返し
    • setProgress
    • draw

setProgress

レイヤーやグループといった単位に対するアニメーション可能な属性値、そしてそれらの子要素であるアニメーション部品の数だけsetProgressがよばれる。レイヤーのアニメーション可能な属性は今のところ9個ある。全部nullable。

  • 透過度
  • 開始の透過度
  • 終了の透過度
  • アンカーポイント
  • 位置
  • 倍率
  • 回転
  • 歪み
  • 歪み角度

また、マスクやマット(つや消し)の効果があるときにもsetProgressが呼ばれる。一つのsetProgressはそこまで重くないのだけど、数がとにかく多い。

このフェーズの中でDrawable.invalidateSelf()がよばれると、drawフェーズに進むことになる。逆に言うと、誰もinvalidateしないということになればdrawフェーズには行かないのでここのチェックを正しく行うことは性能に大きく寄与する。また、描画に影響しないパーツや属性値はdrawフェーズでほぼ何もせず次に処理を回すことがあるので、全体的な見かけの消費時間がdrawフェーズより多くなることもある。仕方のない部分もあるが部分的にでもカットできればよさそう。

draw

それぞれのパーツがレイヤーごとに順番に描画される。ver. 3.1.0でオフスクリーンレンダリング(Canvas.saveLayer())に対応したので、透過度がAfter Effectsの見た目通りに効くようになった模様。性能面に影響があるので、オフスクリーンレンダリングはデフォルトではoff(従来の挙動)になっている。

このフェーズの処理を大雑把に言うと、移動や変形させるために値を集めて行列を作ったり、色、透過度の値を集めてきてまとめてパスに渡すという感じ。ここの性能はサンプルアプリでもいろんな形で見ることができる。そういう事情もあってか、チューニングがかなり進んでいる印象。行列がいつも同じ値なら予め計算しておいたものを使い続けるとか、パスに掛けておくみたいな事ができると短縮できるかもしれない。

実際にはViewなりDrawableが表示される時にはonDraw()が呼ばれるので、setProgress()より先にdrawが呼ばれることになる。ただ、アニメーションの最中はsetProgress()で与えられた進捗の値を使って図形や文字の位置や大きさ、色を計算してdrawCanvasに書くというのが基本的な流れになっているものと認識している。

*1:そういう風に分かれて見えるように修正した話はまた別の機会に

lottie-androidの色の補間処理の性能を改善するパッチが取り込まれた

これです

github.com

ChangelogにはMinor performance optimizationと書かれていることからもわかる通り全然大したことない改善なんだけど、今やっているプロジェクトではこういうのも馬鹿にはできないというレベルで厳しいので取り込んでもらえたのがありがたい。

どのような修正なのか

どんな修正かをざっくりいうと、GammaEvaluator.evaluate()という色の補間を行うメソッドに対して開始にも終了にも同じ色が渡された時、補間の計算をせずに開始の色をすぐ返すようにした、というもの。

ある色を目的の色までなめらかに変化させたいとき、時間の進捗に応じた”間の色”を計算(補間)しなければならない。lottie-androidでは補間の際にガンマ補正を行っているぶん、人の見た目にはより自然にみえるようになるものの時間がかかる。

この時刻からこの時刻までの間にAをBまで変化させるということを、lottie-androidではKeyframeというクラスで表現しており、動くと動かないとにかかわらず、Canvas(正確にはLayer)に書くものすべてがKeyframe(あるいはそのサブクラス)で表現される。Keyframeによっては色が変化しないというものもあるのだが、そのような場合にもlottie-androidでは色の補間計算を行うようになっていた。これは、ボトルネックを調べるためにAndroid StudioのプロファイラでJavaメソッドレコーディングをしたところ、色を変化させるアニメーションがないにもかかわらず件のメソッドが目に付くレベルで時間を消費していたことから発覚した。その部分のコードを読んでみて、大したインパクトはないかもしれないけど書き足す量もほんの少しだし、シンプルな変更なので特に異論なく取り込まれるのではないかと考えてプルリクを出した。

KeyframeにはisStatic()という真偽値を返すメソッドがあり、真の場合はそのKeyframeは時刻にかかわらず常に同じ値を返すというものなのだが、色のKeyframeの場合はそれが呼ばれず、色の補間を毎フレーム計算しているようだった。処理的にもっと手前の段階で計算をスキップするような処理を入れるべきなのかもしれなかったのだが、影響範囲が読めなくなってしまうので今回はやらなかった。

性能改善のための修正は宝探しをやっているようで、見つけるまではつらいが、探し当てるとすごく嬉しい。有名なOSSにも僕のような者が見つけられるような宝がまだ眠っているということが分かった。そうはいってもそれなりにチューニングされているので、もう本当にちょっとずつ削りだすような感覚でやっていかなければならない。