メインコンテンツまでスキップ

レイヤーパネルのパフォーマンス向上

Shannen WuSoftware Engineer, Figma
Peter HayesSoftware Engineer, Figma

レイヤーパネルは、Figmaファイルの中核となる設計図です。新しい計算およびキャッシュ戦略を導入して再設計した結果、最大規模かつ最も複雑なファイルの一部において、操作速度が30~50%向上しました。

レイヤーパネルのパフォーマンス向上を共有

イラスト: Chou Chia Yu

Figmaを使用したことがあるなら、デザイン要素をネストされた階層状のリストとして表すレイヤーパネルになじみがあるでしょう。

レイヤーパネルは元々、今よりもファイルサイズが小さく、Figmaの機能も少なかった約7年前に構築されました。現在では、数万ものレイヤーを含むファイルも珍しくありません。特に非常に大きなファイルでは、パネルとのインタラクションが重くなる現象が見られるようになりました。また、レイヤーパネルのパフォーマンスの問題により、ドラッグや入力といったエディター操作の速度が低下することもありました。

UIの中核となるこの部分を再び高速かつスムーズに動作させるため、私たちはレイヤーパネルのアーキテクチャを一から再構築しました。

以前のアプローチ

FigmaファイルはHTMLファイルに似た、ノードのツリー構造となっています。各ノードには一連のプロパティ(名前、可視性など)と子ノードのリストがあります。レイヤーパネルUIをレンダリングするために、各ノードに関するすべてのデータを含む大規模なJavaScriptオブジェクトを構築します。

元のアーキテクチャでは、このオブジェクトを単一のデータ収集パスで組み立て(そしてレイヤーが変わるごとに再組み立て)していました。現在のページの各最上位ノードから、そのノードに関するデータの束を集めます。そしてノードが(キャレットアイコンによって)展開された場合、その子ノードに対して再帰的に処理を行い、それらについても同様のデータを収集し、さらにそれらの子ノードに対しても再帰処理を行っていました。パネル内で多くのノードが展開されている大規模なファイルでは、レイヤーパネルのデータを繰り返し再生成することになり、これが大幅な処理速度の低下を招いていました。

結局のところ、このアーキテクチャには2つの主な弱点がありました。

  1. 計算しすぎ:ユーザーの典型的な画面には20〜30行しか表示されていないにもかかわらず、展開されたすべてのノードのデータを計算していました。
  2. 計算頻度が高すぎる: 増分計算のキャッシュをほとんど行っていませんでした。レイヤーパネルへの変更(ノードの展開など)があると、展開されたノードごとに、基本的にすべてを最初から完全に再計算する必要がありました。

これらの問題には、それぞれ明確な解決策がありました。

修正1: 2つのパスによる計算

まず、計算量が過剰になるという問題を解決するため、データ収集フェーズを2つのパスに分割しました。

1回目のパスでは、パネルを構成する行IDの順序付きリストのみを取得し、その他のデータはすべて後回しにします。このIDのリストを計算するのも一筋縄ではありません。難しい点としては、以下のようなものがあります。

  • オートレイアウトフレームの子要素は逆順で表示されます。
  • ウィジェットやFigJamの付箋などの特定のノードタイプは、パネルに子要素が表示されません。
  • プロトタイプ作成フレーム内の固定/スクロールヘッダーは子要素を2つのサブセクションに分割します。
  • 最上位のフレームとコンポーネントは付箋です。
手描きのフローチャートは、さまざまなフレーム条件と依存関係がレイヤーパネルの出力動作をどのように決定するかを示しています。手描きのフローチャートは、さまざまなフレーム条件と依存関係がレイヤーパネルの出力動作をどのように決定するかを示しています。
行IDのリストを計算するだけでも、製品依存関係の複雑なネットワークを解決する必要があります。

2回目のパスでは、ノードのデータ(名前、アイコン、ロック状態、表示、選択状態など)を計算します。1回目のパスでIDを取得しておけば、重要なノード、つまりウィンドウ内に表示されているノードのデータのみを計算することができます。

ウィンドウイングは、UIフレームワークでよく使われる手法であり、スクロール可能なリストのうち、画面上に表示されている項目のみをレンダリングします。以前のアーキテクチャでもウィンドウイングを使用していましたが、すべての計算が1回のパスで行われていたため、画面上に表示されておらず、レンダリングもされないノードのデータまで、依然としてすべて取得していました。

ウィンドウイングとは、ビューポートの外側にあるものをすべて切り取り、内側にあるものだけに焦点を当てることを意味します。このイラストでは広めのウィンドウが示されていますが、仮想化の真価が最も発揮されるのは、その切り取り部分がリスト全体に比べてごくわずかである場合です。

2パス方式に移行したことで、名前、アイコン、ロック状態、表示設定、選択状態などのデータは、以前は最大で数十万行分も計算されていたのに対し、現在はわずか数十行分のみの計算で済むようになりました!

修正2: 派生データのキャッシュ

変更されたレイヤーのみを再描画し、残りを固定しておくことで、計算負荷の高い処理を頻繁に再実行することを回避できます。

計算の頻度が高すぎるという2つ目の問題を解決するため、私たちはここ数年かけて開発してきたプラットフォームレベルのプリミティブ、すなわち派生プロパティに目を向けました。

各Figmaノードは一連のフィールドを公開しており、これは変更可能なバリアブルのように機能します。つまり、アプリケーションコードから書き込みや読み取りを行うことができます。

しかし、ノードのプロパティの多くはそれ自体がフィールドではなく、フィールドや他のプロパティから計算されて得られます。例として、ノードの絶対位置を考えてみましょう。これは、キャンバスの中心にある(0, 0)点に対するオフセットです。ノードはこの値を直接保持していません。代わりに、親ノードの位置からのオフセットである相対位置を保持しています。これにより、大規模なノードツリーをまとめて移動したり回転させたりすることが容易になります。

では、レンダリングのためにノードの絶対位置をどのように計算すればよいのでしょうか?このプロパティは、次のような計算によって導き出すことができます。

Plain text
Self.AbsolutePosition = Parent.AbsolutePosition + Self.RelativePosition

派生プロパティプリミティブは、これらのプロパティを宣言し、自動的に最新の状態に保つための形式的な方法を提供します。これは、他のセルを参照するスプレッドシートの数式に似ています。このシステムを利用することで、次のような利点を自動的に得ることができます。

  • 各派生プロパティは、自身がどのフィールドや派生プロパティに依存しているかを認識しています。これが最適化された依存関係グラフ表現にコンパイルされます。
  • 複数のキャッシュポリシーがあり、速度とメモリ使用量のトレードオフがあります。
  • 派生プロパティはデフォルトで遅延評価され、読み取りが行われたときにのみ計算されます。

レイヤーパネルのツリー構造は、派生プロパティに非常に適しています。

以前は、ツリー内で何かが変更されるたびにノードデータを再計算していました。派生プロパティを使用すると、レイヤーパネルの構造とその中の依存関係を明示的にモデル化できるため、ツリーの関連するサブセクションだけを更新すれば済みます。また、サブツリーレベルのパネル計算を効率的にキャッシュすることも可能です。

したがって、行の順序付きリストを計算する最初のデータパスでは、次のように表現します(わかりやすくするために簡略化しています):

Plain text
Self.OrderedChildren = 
  If(Self.Expanded)
    Children.FlatMap(Child => Child.OrderedChildren)
  Else
    []

ノードのOrderedChildrenプロパティは、その展開状態が変わるか、いずれかの子のOrderedChildrenが変わった場合にのみ無効化されます。

例として、次のようなファイルを考えてみましょう。

以前は、フレームBを展開すると、そのページから始まるツリー全体が再計算されていました。
フレームBを展開しても、影響を受けたサブツリー(フレームAからBまで)のデータのみが再計算され、派生プロパティのおかげで、フレームCとその100個の子はスキップされます!

私たちは、派生プロパティの遅延機能を使用して、一部の計算も回避しています。例えば、OrderedChildrenは遅延なプロパティです。ノードが一度も展開されなければ、そのOrderedChildrenはリクエストされることがなく、したがって計算されることもありません。

フレームBを展開してその子要素を表示した場合を想像してください。従来のシステムでは、フレームAから処理を開始し、フレームCの100個すべての子要素に関する情報を含め、すべてを再計算することになります。しかし、派生プロパティとして表現すれば、計算が必要なのはフレームAとBに関する情報だけになります。フレームCに到達した時点で、以前の値を再利用できることがわかっているため、そこで処理を停止できます。

メモリー使用量

キャッシュを追加すると、必然的にメモリー使用量が増加します。私たちは、パフォーマンスの向上につながるのであれば、メモリー使用量が多少増加しても問題ないと考えていました。しかし、メモリーを使いすぎると「メモリー不足」エラーの発生率が増加し、これが発生すると苛立たしくもあります。

OrderedChildrenのキャッシュを単純な方法で実装すると、次のようなものになるでしょう。

Plain text
Frame A: [B, C, D]
  Frame B: [C, D]
    Frame C: [D]
      Frame D: []

メモリコストは三角数の和に従います。1 + 2 + … + nを加算するとn(n + 1)/2になります。

ツリーの各レベルは、そのすべての子孫のIDをキャッシュする必要があります。これにより三角数の和が生じ、メモリー使用量はO(n²)となります。ツリーが非常に深い場合、メモリー使用量は数十メガバイトに達する可能性があり、これは許容できません。

ロープとは、シーケンスを小さな断片に分割して格納するツリー構造のようなもので、これにより、編集や連結を行う際に、シーケンス全体を再構築することなく、既存の断片を再利用することができます。

代わりに、ロープのデータ構造に似た再帰リストを使用してOrderedChildrenのキャッシングを実装しました。これにより、コピーするのではなくポインタを介して構成することで、構造の共有を最大限に高めました。その結果、読み取り時にポインタの追加トラバーサルというわずかなコストを支払う代わりに、メモリ使用量は一意のノード数に厳密に比例する(O(n))ようになりました。これにより、元のプロトタイプと比較して、メモリ使用量を最大99%削減することができました。

ノードB、D、C、E、Fについて、「従来のアプローチ」のツリー階層と、統合されたグループ化出力を比較した図。ノードB、D、C、E、Fについて、「従来のアプローチ」のツリー階層と、統合されたグループ化出力を比較した図。
各階層が下位の階層からのデータのコピーを1つずつ追加していくにつれて、メモリー使用量は三角数の和のように増えていきました。
階層的なノードが、レイヤーパネルの展開可能なセグメント化されたグループに対応付けられる「ロープ構造」の表現を示す図。階層的なノードが、レイヤーパネルの展開可能なセグメント化されたグループに対応付けられる「ロープ構造」の表現を示す図。
ロープのような構造を使用することで、メモリー使用量の増加を再び抑制できるようになりました。

パフォーマンスへの影響

この取り組みの結果、パフォーマンスが劇的に向上しました。行の展開/折りたたみや、表示/ロック状態の切り替えといった、レイヤーパネルの重要なインタラクションが、最大かつ最も複雑なファイルの一部において、約30~50%高速化されました。

レイヤーパネル自体のパフォーマンス向上が最も顕著でしたが、その効果はFigmaのデザインエディター全体に及びました。不要な処理を削減したことで、全体的なレンダリングパフォーマンスも向上し、FPSの向上とフレームの遅延の低減につながりました。その結果、特定の複雑なファイルにおいて、文字入力、ドラッグ、色の選択など、以前はもたつきを感じていた操作も、今では常にスムーズに感じられるようになりました。

まだ道のりは長いですが、私たちはこれからもFigma全体でのパフォーマンス向上の方法を模索していきます。

エンジニア募集中!

Figmaでの働き方について詳しくは、募集中のポストをご覧ください

Eli Fitch、Amy Shan、Russell McClellan、Josh Ferrell、Connor Smith、Elynn Lee、Alex Triana、皆さんの協力に感謝します。

Figmaを使った制作とコラボレーション

無料で始める