はじめに
私たちが開発しているサービスでは、求人情報を地図上にマーカーとして表示する機能があります。求人タイトルや時給などの情報を吹き出し型の独自マーカーとして表示しています。
当初はGoogle Mapsが提供するAdvanced Markersを素直に使って実装していました。Advanced MarkersはHTMLやCSSで自由にマーカーの見た目をカスタマイズでき、開発体験も良好です。少数のマーカーならこれで十分でした。
しかし、マーカーが数千件に達したとき、致命的な問題が発生しました。地図をドラッグしても指に追従しない。スワイプしているのに描画が遅れてガクガクと動く。サービスとして成立しないレベルです。
原因は明確でした。Advanced Markersは1つ1つが独立したHTML要素です。数千件のマーカーがあるということは、数千個のHTML要素がブラウザ上に存在するということ。地図を操作するたびに、ブラウザがそれら全てのHTML要素の位置を計算し直し、画面に再配置しなければなりません。この負荷がパフォーマンスを破綻させていました。
この記事では、Advanced MarkersからCanvas描画に切り替え、さらにCanvas描画自体をいくつかの工夫で高速化した記録をまとめます。
何を検討し、何を選んだか
Advanced Markersの限界が見えた時点で、描画方式そのものを見直しました。いくつかのアプローチを検討し、実際に実装まで行ったものもあります。
まずは定番のクラスタリング。近くにあるマーカーをグループにまとめて1つの表示にする手法です。実装まで完了しましたが、様々な理由から採用は見送りました。
次にWebGL。GPUの力を借りてCanvas以上に高速な描画が可能な技術です。
不特定多数のデザインをそれぞれWebGLのシェーダーで表現するのは開発コスト・保守コストから筆頭候補ではありませんでしたが確認しておくべきだと考えdeck.glを試しました。
予想通りパフォーマンスは大幅改善しましたが、最後の手段に近い方法として採用は見送りました。
最終的に選んだのはHTML Canvas(2D Context)です。Google Maps上に独自のCanvasレイヤーを1つだけ重ね、その上にマーカーをピクセルで描く、という方法を採用しました。
HTML要素としてはCanvas要素が1つあるだけ。数千件のマーカーがあっても、ブラウザが管理するHTML要素は増えません。描画処理はプログラムで完結するため、Advanced Markersで起きていた大量のHTML要素の再配置という問題を根本から回避できます。
ただし、Canvas描画自体にもコストがあります。ここからは「Canvas描画の中身をいかに効率化するか」という話です。
工夫1: 吹き出しの形をキャッシュする
マーカーの見た目は、角丸の矩形に三角形の尻尾がついた「吹き出し」の形をしています。この吹き出しには影(ドロップシャドウ)がついています。
改善前は、マーカーを1つ描画するたびに毎回この処理を行っていました:
- 角丸矩形と三角形のパスを定義する
- 影を付けて塗りつぶす
- 影を消して再度塗りつぶす(影が二重にならないようにするため)
影の描画はCanvasの中でも特にコストが高い処理です。数千個のマーカーすべてで毎フレームこれを繰り返すと、影の計算だけで相当な時間を消費します。
改善後は、吹き出しの形を「別のCanvas」に一度だけ描いて、その結果をキャッシュするようにしました。同じサイズの吹き出しなら、2回目以降はキャッシュ済みのCanvasを画像として貼り付けるだけです。
// イメージ(擬似コード)
// 初回: 別のCanvasに吹き出しを描画してキャッシュ
const cachedBubble = renderBubbleToOffscreenCanvas(width, height);
// 2回目以降: キャッシュを貼り付けるだけ
ctx.drawImage(cachedBubble, x, y);
影の計算を含む重い描画処理が、drawImageによる画像コピーに変わりました。同じサイズのマーカーが多いほど効果は大きく、描画コストの大幅な削減に繋がっています。
工夫2: 見えないマーカーを描かない
改善前は、地図上に存在するすべてのマーカーに対して描画処理を行っていました。画面の外にあるマーカーも含めてです。
「画面外のマーカーなんて描いても意味がないのでは?」その通りです。しかし、Canvas上の座標計算(緯度経度をピクセル座標に変換する処理)を先に行ってから「画面内かどうか」を判定していたため、座標変換のコスト自体はすべてのマーカー分掛かっていました。
改善後は、座標変換の前に「そもそもこのマーカーは画面付近にあるか?」を緯度経度の範囲チェックで判定するようにしました。地図の表示範囲に少し余裕(バッファ)を持たせた上で、その範囲外のマーカーは座標変換すらせずにスキップします。
実際は見えるマーカーが多いので改善幅としては少なかったのですが、多少なりとも効果があるのは確実なので地道に対応していきました。
工夫3: Canvasのリサイズを必要なときだけ行う
HTMLのCanvas要素は、widthとheight属性を変更すると内部のビットマップが再作成されます。これは意外と重い処理です。
改善前は、描画のたびにCanvasのサイズと位置を再設定していました。地図のドラッグ中は毎フレーム呼ばれるため、毎フレームCanvasが初期化されていたことになります。
改善後は、前回の描画時のCanvas位置とサイズを記録しておき、変化がない場合はリサイズ処理をスキップするようにしました。地図をドラッグしているだけでCanvasサイズが変わらないケースでは、内部ビットマップの再作成が不要になります。
工夫4: 当たり判定の再計算を減らす
マーカーをタップしたとき、どのマーカーが押されたかを判定するために、各マーカーの矩形領域(当たり判定用の座標)を保持しています。
改善前は、描画のたびに全マーカーの矩形領域を再計算していました。改善後は、ズームレベルが変わったときだけ矩形のサイズを再計算し、地図のドラッグ(パン操作)では位置だけを更新するようにしました。
ズーム変更時はマーカーのピクセルサイズが変わるため再計算が必要ですが、パン操作ではマーカーのサイズは変わりません。位置の更新は単純な加減算だけで済むため、大幅に軽量化されています。
工夫5: ソートを毎フレーム行わない(最大の効果)
今回の改善で最も効果が大きかったのがこれです。
マーカーの描画で前後関係(Z-Order)を持たせています。手前に表示すべきマーカーを後から描くために、マーカー配列をソートする必要があります。
改善前は、描画のたびに毎回ソートしていました。地図をドラッグするだけで毎フレーム、数千件のマーカーをソートし直していたのです。
ソートは要素数が増えるほど計算量が急激に増える処理です。数千件を毎フレームソートするということは、1秒間に数十回、数千件の並べ替えを繰り返しているということ。これが操作に追従しない最大の原因でした。
しかし、マーカーの前後関係が変わるのは「マーカーの一覧が更新されたとき」だけです。地図をドラッグしてもマーカーの一覧は変わりません。
改善後は、マーカーの一覧が更新されたタイミングでのみソートし、結果をキャッシュするようにしました。毎フレームの描画ループからソート処理が完全に消えたことで、操作性が劇的に改善しました。
その他の調整
Canvasで座標に小数が含まれていると「サブピクセルレンダリング」が発生します。これを避けるために座標の計算結果を整数に丸めてからCanvasに渡すようにしました。
計測結果的には影響はなく、最近では通用しない方法かもしれませんが、すぐにできる対応だったので採用しました。
まとめ
| 工夫 | やったこと | 効果 |
|---|---|---|
| 吹き出しキャッシュ | 影つき吹き出しをオフスクリーンCanvasにキャッシュ | 影の再計算を排除 |
| 範囲フィルタリング | 画面外マーカーを緯度経度で早期スキップ | 不要な座標変換と描画を排除 |
| リサイズ最適化 | Canvas再サイズを変化時のみ実行 | ビットマップ再作成を削減 |
| 当たり判定最適化 | ズーム変更時のみ完全再計算 | パン操作時の計算量を削減 |
| ソートキャッシュ | マーカー更新時のみソート | 最大の改善効果。毎フレームの数千件ソートを排除 |
これらの改善に共通するのは「毎フレームの計算量を徹底的に減らす」という当たり前のことです。変わっていないものを再計算しない。必要ないものに触らない。地味ですが、これが最も効きました。
対策の結果として、体感では数千件のマーカーが存在するエリアでの致命的な重さは解消され、スワイプに地図がしっかり追従するようになり操作性は劇的に変わりました。
なお、要件に合わない、あるいは効果が見込めないで採用を見送った方法として、描画呼び出しのバッチ処理や、状態変更の最小化、差分再描画など様々な方法があります。
アプリごとに適用できる方法は異なるかとは思いますが、今一度基本に戻って当たり前の対策ができているか?をしっかり確認するように、レビュー観点にでも入れておくのが良さそうです。
大量のマーカーがある場合は、クラスターのように表示するもの自体を減らすことを第一選択肢として入れておいた方が良いかとは思いますが、もし大量のカスタムマーカーを扱うUIを開発する必要があれば参考になれば幸いです。