私、Ian Kilpatrick は、Blink レイアウト チームのエンジニアリング リードです。Ishii Koji と共に、Blink チームに加わる前は、Google ドキュメント、ドライブ、Gmail の機能の開発を担当するフロントエンド エンジニアでした(Google に「フロントエンド エンジニア」という役職が存在する前です)。その職務に約 5 年間携わってから、Blink チームに大金を移し、C++ を効果的に学習し、非常に複雑な Blink コードベースでスキルアップしようとしました。現在でも、その内容のごく一部しか理解できません。 こんな時間に貴重なお時間を割いていただき、ありがとうございました。 多くの「フロントエンド エンジニアの回復」によって、私よりも早く「ブラウザ エンジニア」になったという事実に安心しました。
これまでの経験が、Blink チームでの私自身の指針となっています。フロントエンド エンジニアとして、ブラウザの不整合、パフォーマンスの問題、レンダリング バグ、機能の欠落に常に直面していました。LayoutNG は、Blink のレイアウト システム内でこれらの問題を体系的に修正する機会でした。これは、長年にわたる多くのエンジニアの努力の集大成です。
この記事では、このような大規模なアーキテクチャ変更によって、さまざまな種類のバグやパフォーマンスの問題を減らし、軽減する方法について説明します。
レイアウト エンジン アーキテクチャの概要
これまで、Blink のレイアウト ツリーは「変更可能なツリー」でした。
レイアウト ツリー内の各オブジェクトには、親によって適用される使用可能なサイズ、フロートの位置などの入力情報と、オブジェクトの最終的な幅と高さ、x と y の位置などの出力情報が含まれていました。
これらのオブジェクトはレンダリング間で保持されていました。スタイルが変更されると、そのオブジェクトをダーティとしてマークし、同様にツリー内のすべての親をダーティとしてマークしました。レンダリング パイプラインのレイアウト フェーズが実行されたとき、ツリーをクリーンアップし、ダーティなオブジェクトを検査してから、レイアウトを実行してクリーンな状態にします。
このアーキテクチャでは、多くのクラスの問題が発生することが判明しました。以下で説明します。では、まずレイアウトの入力と出力について考えてみましょう。
このツリーのノードでレイアウトを実行すると、コンセプト的には「スタイルと DOM」と、親レイアウト システム(グリッド、ブロック、フレックス)の親の制約が取得され、レイアウト制約アルゴリズムが実行されて結果が生成されます。
新しいアーキテクチャでは、このコンセプト モデルを形式化しています。レイアウト ツリーはまだ残っていますが、主にレイアウトの入力と出力を保持するために使用します。 出力については、フラグメント ツリーと呼ばれる、まったく新しい不変オブジェクトを生成します。
不変のフラグメント ツリーについては、以前の記事で説明しました。この記事では、増分レイアウトに以前のツリーの大部分を再利用する方法について説明します。
また、そのフラグメントを生成する親の制約オブジェクトも保存します。これはキャッシュキーとして使用されます。これについては後ほど詳しく説明します。
インライン(テキスト)レイアウト アルゴリズムも、新しい不変のアーキテクチャに合わせて書き換えられています。インライン レイアウト用の不変のフラットなリスト表現を生成するだけでなく、再レイアウトを高速化するための段落レベルのキャッシュ、要素や単語全体にフォント機能を適用するための段落ごとのシェイプ、ICU を使用した新しい Unicode 双方向アルゴリズム、多くの正確性修正などを備えています。
レイアウト バグの種類
レイアウトのバグは、大きく分けて 4 つのカテゴリに分類され、それぞれに根本原因が異なります。
正確性
レンダリング システムのバグを考える場合、通常は正確性について考えます。たとえば、「ブラウザ A は X の動作をしますが、ブラウザ B は Y の動作をします」や「ブラウザ A と B の両方が機能しません」などです。以前はこの作業に多くの時間を費やし、その過程では常にシステムとの闘いがありました。 一般的な障害モードは、1 つのバグに対して非常にターゲットを絞った修正を適用し、数週間後にシステムの別の(関連性がないと思われる)部分で回帰が発生したことに気付くというものでした。
前回の記事で説明したように、これはシステムが非常に脆弱であることを示しています。特にレイアウトについては、クラス間に明確な契約がないため、ブラウザ エンジニアは、依存すべきでない状態に依存したり、システムの別の部分の値を誤って解釈したりしていました。
たとえば、ある時点で、1 年以上にわたって、Flex レイアウトに関連する約 10 個のバグが連続して発生しました。修正のたびに、システムの一部で正確性またはパフォーマンスの問題が発生し、さらに別のバグが発生しました。
LayoutNG では、レイアウト システム内のすべてのコンポーネント間のコントラクトが明確に定義されているため、変更をより確実に適用できるようになりました。また、優れた Web Platform Tests(WPT)プロジェクトも大きなメリットです。このプロジェクトでは、複数の参加者が共通のウェブテスト スイートに貢献できます。
現在、安定版チャネルで実際の回帰をリリースした場合、通常は WPT リポジトリに関連するテストがなく、コンポーネント コントラクトの誤解が原因で発生するものではありません。また、バグ修正ポリシーの一環として、常に新しい WPT テストを追加し、ブラウザが同じ間違いを繰り返さないようにしています。
無効化の不足
ブラウザ ウィンドウのサイズ変更や CSS プロパティの切り替えによってバグが消えるという不思議なバグに遭遇したことがある場合は、無効化不足の問題が発生しています。変更可能なツリーの一部はクリーンと見なされましたが、親の制約が変更されたため、正しい出力を表現しませんでした。
これは、後述する 2 パス(レイアウト ツリーを 2 回走査して最終的なレイアウト状態を決定する)レイアウト モードで非常に一般的です。以前のコードは次のようになります。
if (/* some very complicated statement */) {
child->ForceLayout();
}
通常、このタイプのバグの修正は次のようになります。
if (/* some very complicated statement */ ||
/* another very complicated statement */) {
child->ForceLayout();
}
通常、この種の問題を修正すると、パフォーマンスが大幅に低下します(下記のオーバー無効化を参照)。また、修正は非常にデリケートです。
現在(上記のように)親レイアウトから子へのすべての入力を記述する不変の親制約オブジェクトがあります。生成された不変フラグメントとともに保存します。そのため、これら 2 つの入力値の差分を 1 か所にまとめ、子に別のレイアウトパスを実行してもらう必要があるかどうかを判断しています。この差分ロジックは複雑ですが、適切に制御されています。このクラスの無効化不足の問題をデバッグする場合は、通常、2 つの入力を手動で検査し、入力の何が変更されたかを確認して、別のレイアウト パスが必要かどうかを判断します。
通常、この差分コードの修正は簡単で、これらの独立したオブジェクトの作成が簡単なため、単体テストが容易です。
上記の例の差分コードは次のとおりです。
if (width.IsPercent()) {
if (old_constraints.WidthPercentageSize()
!= new_constraints.WidthPercentageSize())
return kNeedsLayout;
}
if (height.IsPercent()) {
if (old_constraints.HeightPercentageSize()
!= new_constraints.HeightPercentageSize())
return kNeedsLayout;
}
ヒステリシス
この種類のバグは無効化の不足に似ています。 基本的に、以前のシステムでは、レイアウトがべき等であることを確認することが非常に困難でした。つまり、同じ入力でレイアウトを再実行しても、同じ出力になるようにすることは困難でした。
次の例では、CSS プロパティを 2 つの値間で切り替えるだけです。ただし、この場合、長方形は「無限に拡大」します。
以前の変更可能なツリーでは、このようなバグを導入するのは非常に簡単でした。コードが間違ったタイミングやステージでオブジェクトのサイズや位置を読み取った場合(たとえば、前のサイズや位置を「クリア」しなかった場合)、すぐに微妙なヒステリシス バグが追加されます。通常、テストではこれらのバグは発生しません。ほとんどのテストは 1 つのレイアウトとレンダリングに焦点を当てているためです。さらに厄介なことに、一部のレイアウト モードを正しく機能させるには、このヒステリシスが必要になることもわかっていました。 レイアウトパスを削除する最適化を行うバグがありましたが、正しい出力を得るためにレイアウト モードで 2 つのパスが必要だったため、「バグ」が発生しました。
LayoutNG では、明示的な入力データ構造と出力データ構造があり、以前の状態にアクセスできないため、このクラスのバグをレイアウト システムから広範囲に軽減しています。
過剰な無効化とパフォーマンス
これは、無効化不足クラスのバグとは正反対です。多くの場合、無効化不足のバグを修正すると、パフォーマンスの急降下が発生します。
多くの場合、パフォーマンスよりも正確性を優先する難しい選択を迫られました。次のセクションでは、このようなパフォーマンスの問題を軽減する方法について詳しく説明します。
2 パス構成のレイアウトとパフォーマンスの壁の高まり
Flex レイアウトとグリッド レイアウトにより、ウェブ上のレイアウトの表現力は変化しました。 しかし、これらのアルゴリズムは、これまでのブロック レイアウト アルゴリズムとは根本的に異なっていました。
ブロック レイアウトでは(ほとんどの場合)、エンジンがすべての子に対してレイアウトを実行するのは 1 回だけです。これはパフォーマンスには優れていますが、ウェブ デベロッパーが望むほど表現力豊かではありません。
たとえば、多くの場合、すべての子のサイズを最大サイズに拡張します。これをサポートするために、親レイアウト(フレックスまたはグリッド)は測定パスを実行して各子のサイズを決定し、レイアウトパスを実行してすべての子をこのサイズに伸ばします。この動作は、Flex レイアウトとグリッド レイアウトの両方でデフォルトになっています。
これらの 2 パス レイアウトは、通常は深くネストしないため、パフォーマンス的には許容範囲内でした。しかし、より複雑なコンテンツが登場し始めると、パフォーマンスに関する重大な問題が発生し始めました。測定フェーズの結果をキャッシュに保存しないと、レイアウト ツリーの測定状態と最終的なレイアウト状態の間でスラッシングされます。
以前は、このタイプのパフォーマンスの急降下に対処するために、フレックス レイアウトとグリッド レイアウトに非常に具体的なキャッシュを追加していました。これは機能しましたが(Flex でかなりの進歩を遂げました)、無効化の不足と過剰のバグに常に悩まされていました。
LayoutNG を使用すると、レイアウトの入力と出力の両方に明示的なデータ構造を作成できます。さらに、測定パスとレイアウト パスのキャッシュを構築しています。これにより複雑さが O(n) に戻り、ウェブ デベロッパーは予測可能な線形パフォーマンスを得ることができます。レイアウトが 3 パス レイアウトを行っている場合は、そのパスもキャッシュします。 これにより、将来的には、より高度なレイアウト モードを安全に導入する機会が広がる可能性があります。たとえば、RenderingNG が基本的に全般的に拡張性を確保する様子をご覧ください。 グリッド レイアウトでは 3 パス レイアウトが必要になる場合がありますが、現時点では非常にまれです。
デベロッパーがレイアウトに関するパフォーマンスの問題に直面した場合、通常はパイプラインのレイアウト ステージの元のスループットではなく、レイアウト時間の指数関数的なバグが原因です。小さな増分変更(1 つの要素が 1 つの CSS プロパティを変更)で 50 ~ 100 ミリ秒のレイアウトが発生する場合は、指数関数的なレイアウト バグである可能性があります。
まとめ
レイアウトは非常に複雑な領域であり、インライン レイアウトの最適化(インラインとテキスト サブシステム全体の仕組み)など、興味深い詳細は網羅していません。ここで取り上げたコンセプトもほんの一部にすぎません。しかし、システムのアーキテクチャを体系的に改善することで、長期的には大がかりな利益が得られることがわかっていれば幸いです。
とはいえ、まだ多くの課題が残されています。 Google は、解決に向けて取り組んでいる問題のクラス(パフォーマンスと正確性の両方)を認識しており、CSS に導入される新しいレイアウト機能に期待しています。LayoutNG のアーキテクチャでは、これらの問題を安全かつ扱いやすく解決できると考えています。
Una Kravets による 1 枚の画像(どれかわかりますよね)。