この記事は、クロスプラットフォームの地図アプリ開発、地図SDKの選定に悩むモバイルエンジニア向けです。
「不動産アプリの地図、なぜこんなに重いの?」——ユーザーからの最初のフィードバックがこれでした。
ClassLabは不動産物件検索アプリ「RIRIFE」を Flutter で開発し、iOS/Androidの両ストアで公開しています。当初はGoogle Maps(google_maps_flutter)で実装を進めていましたが、料金とレンダリング性能の二つの壁に直面し、開発途中で Mapbox(mapbox_maps_flutter)へピボットしました。
この記事では、その選定→ピボット→実装定着までの設計判断を公開します。「Flutterで地図アプリを作る際にどのSDKを選ぶか」「Google Maps Platform の料金や描画制約で困っている」——そんな方に向けて、実際の判断基準とコード上のポイントを共有します。
1. 背景 — 初期選定としてGoogle Mapsを選んだ理由
プロダクトの要件
ClassLabはライフライン(電気・ガス)の取次事業が基盤ですが、取次で築いた不動産会社とのネットワークを活かし、不動産テック領域にも事業を拡張しています。RIRIFEはその第一弾として開発した物件検索アプリで、ユーザーが地図上で物件・クーポン対象店舗を探し、気になる物件の詳細を確認するUXが中核です。
要件のエッセンス:
- 地図が主要インタラクション: ピン×クラスタ表示、ズーム、パン、現在地表示が常時操作される
- iOS/Android 両ストア公開: 開発リソースの都合でクロスプラットフォーム必須
- 3ヶ月でMVPリリース: 不動産会社との連携スケジュールに合わせる
- オフライン耐性: 内見中に電波が悪い環境で閲覧できる必要
開発体制の制約条件:
- エンジニア: Flutter 1名 + バックエンド 1名(計2名)
- 既存ドメイン知識: 不動産業界は新規、モバイル開発は社内蓄積なし
- 期限: 不動産会社との連携開始スケジュールが固定、延長不可
技術選定: なぜ初手はFlutter×Google Mapsだったのか
クロスプラットフォーム前提で、以下の選択肢を検討しました。
| 候補 | 地図SDK | MVP速度 | 懸念 |
|---|---|---|---|
| ネイティブ (Swift + Kotlin) | 各プラットフォームの純正 | 低(iOS/Android 同時開発) | 品質は高いが工数2倍 |
| React Native | react-native-maps | 中 | 地図操作のラッパー成熟度が当時は不安 |
| Flutter | google_maps_flutter | 高 | Platform View のオーバーヘッド |
Flutterを選定した主因は、「UIの表現力が高いこと」 と 「既存Dartエンジニアがチームに在籍していたこと」でした。Flutter側の地図SDKはgoogle_maps_flutterがもっともメジャーで、ドキュメントもサンプルも豊富だったため、初手としてこちらを採用しました。
2. 課題 — 地図アプリ特有の5つの壁
開発を進めるなかで直面した課題を整理します。
| 課題 | 影響度 | ユーザー体感 |
|---|---|---|
| マーカー描画のパフォーマンス | 高 | 地図がカクつき、ピン選択が遅い |
| Platform View とFlutterウィジェットの統合 | 高 | ボトムシートのスライドが重い |
| 地図API の料金モデル | 中〜高 | ユーザー数増加で運用コスト急増 |
| オフライン対応 | 中 | 地下鉄・エレベーター内で地図が真っ白 |
| 状態管理の複雑性 | 中 | フィルタと地図とリストの同期ズレ |
特に課題1と2は相互に関連しています。Google Maps SDK は Platform View(ネイティブビュー)として Flutter に組み込まれるため、Flutter 側のウィジェットツリーとは異なるレンダリングパスを通ります。マーカーが増えると Platform View の描画負荷が上がり、Flutter 側のアニメーション(ボトムシートのスライド等)がカクつく原因になりました。
そして課題3の「料金」が、私たちの意思決定を決定づけました。
3. ピボット — Google Maps から Mapbox へ
MVPの開発が半ばまで進んだタイミングで、Google Maps Platform から Mapbox へ地図SDKを切り替える判断をしました。理由は次の3点に集約されます。
理由①: 料金モデル
Google Maps Platform と Mapbox は、そもそも課金単位が違うため、そのままでは直接比較できません。
| 提供元 | 課金対象 | 典型的な増減要因 |
|---|---|---|
| Google Maps Platform(Mobile Native Maps SKU) | Maps ロード数(地図を画面に表示した回数) | セッション中の地図の開閉回数に比例 |
| Mapbox Maps SDK | 月間アクティブユーザー数(MAU) | ユニークユーザー数に比例、セッション内の地図開閉では増えない |
比較するため、「1 MAU あたり何回の Maps Load が発生するか」を自社のアクセスログから試算しました。RIRIFE は地図が主要画面のため、1ユーザーが1セッション中に地図を数回〜10回程度開閉する想定です。これを同一 MAU 帯に正規化して年間コストを試算すると、Google Maps Platform の方が数倍オーダーで高くなる見込みでした。
※具体的な月額・MAU値・倍率の正確な数字は公開を控えます。重要なのは、地図ロード回数の多いUXでは Mapbox の MAU 課金の方が予測しやすく、スケール時に負担が急増しにくいという構造的な違いです。
理由②: レンダリング性能
Google Maps Flutter は Platform View を通じて OS ネイティブの地図を描画します。これは「ネイティブと同じ描画品質」というメリットがある反面、Flutter のウィジェットツリーと別レンダリングパイプを経由するため、次の事象が再現性高く発生しました。
シナリオA: ボトムシート重ね合わせ時のちらつき
- Given: Google Maps が全画面で描画された状態
- When: 検索結果ボトムシートをスワイプで引き上げる(半モーダル)
- Then: Platform View のレイヤー境界で白フラッシュが一瞬発生、地図タイルが再描画される
シナリオB: 多数マーカー時のフレームレート低下
- Given: 数百件のマーカーがズームレベル15で描画中
- When: ユーザーがピンチズームまたはパンを連続操作
- Then: Platform View の描画負荷でフレーム落ち(60fps を維持できない)、ジェスチャ追従が遅延
シナリオC: Flutter ウィジェットと地図の同期アニメーション
- Given: ピン選択時にボトムシートをスライドアップしつつ、地図上の該当ピンを中央にアニメーション移動したい
- When: 2系統のアニメーション(Flutter側 / Platform View側)を同時開始
- Then: 描画パイプが別のため、終了タイミングが揃わずカクつきが出る
Mapbox Maps SDK for Flutter は GL ベースでタイルとレイヤーを Flutter 側のレンダリング内で描画するため、上記シナリオA〜Cはいずれも体感レベルで改善しました。具体数値は環境依存のため掲載を控えますが、「ユーザーから地図が重いと言われなくなった」が目安として最大の成果でした。
理由③: 開発体験(DX)
開発のしやすさでも Mapbox が優位でした。
- クラスタリングが純正機能:
GeoJsonSourceにcluster: trueを設定し、クラスタ用レイヤーと個別ピン用レイヤーをスタイルJSONで制御できる。外部プラグインに頼らない - スタイルJSONでの見た目制御: ピンの色・サイズ、クラスタの半径、テキストフォントなどを JSON で宣言的に記述できる
- カメラやジェスチャーの制御APIがFlutterらしい:
CameraOptions,CameraBoundsOptions,GesturesSettingsなど、Flutter の DSL に馴染む設計
ピボットコストは決して小さくありませんでしたが、運用コスト・パフォーマンス・開発速度の3点すべてで改善が見込めるため、早期に切り替えるほど回収が早いという判断でした。
4. 設計 — Mapboxベースのアーキテクチャ
全体アーキテクチャ
graph TD
subgraph Client["Flutter App"]
UI[UI Layer<br/>BLoC + Riverpod]
CONTROLLER[MapboxMapController<br/>Riverpod Provider]
MAP_WIDGET[Mapbox MapWidget<br/>GL Rendering]
CLUSTER_HELPER[ClusterHelper<br/>Source/Layer管理]
SYNC_QUEUE[MapboxSyncQueue<br/>操作順序保証]
REPO[Repository Layer<br/>Dio]
CACHE[HTTP Cache<br/>dio_cache_interceptor]
end
subgraph Backend["Backend API"]
API[REST API<br/>EC2]
DB[(RDS)]
end
subgraph Tile["Mapbox"]
TILES[Vector Tiles<br/>GeoJsonSource]
end
UI --> CONTROLLER
CONTROLLER --> MAP_WIDGET
UI --> REPO
MAP_WIDGET --> CLUSTER_HELPER
CLUSTER_HELPER --> SYNC_QUEUE
SYNC_QUEUE --> TILES
REPO --> API
REPO --> CACHE
API --> DB
style MAP_WIDGET fill:#3b82f6,color:#fff
style CONTROLLER fill:#3b82f6,color:#fff
style SYNC_QUEUE fill:#3b82f6,color:#fff
style API fill:#10b981,color:#fff
style DB fill:#8b5cf6,color:#fff
図中の MapboxMapController と MapboxSyncQueue は、後述の設計判断②・③で実装例を示します。
設計判断①: マーカー×クラスタリングは Mapbox 純正で
数千件のピンを全て個別に描画するのではなく、ズームレベルに応じて近接ピンをクラスタ(集約)表示します。Mapbox は GeoJsonSource に clusterOptions を与えるだけでこの挙動が得られます。
| 方式 | 処理場所 | 特徴 | 採否 |
|---|---|---|---|
| クライアント側で自前計算 | Flutter | 通信不要だが計算コストが UI に乗る | × |
| バックエンド側でクラスタ生成 | API | UIは軽いがAPI回数が増える | △ |
| Mapbox純正クラスタ | Mapbox SDK | GeoJson Source + スタイルJSONで完結 | 採用 |
実装では、クラスタ用のサークルレイヤーと件数表示用のシンボルレイヤー、個別ピン用のシンボルレイヤーを組み合わせます。アーキ図中の ClusterHelper がこの責務を担います。
設計判断②: レイヤー・ソース操作の順序保証(MapboxSyncQueue)
Mapbox の style layer や source は、追加・削除の順序を間違えるとエラーになります(例: レイヤーを削除する前にソースを削除すると参照エラー)。フィルタ変更でピンを更新する際、非同期で複数の操作が走ると、順序が崩れてレンダリングが壊れることがありました。
対処として、レイヤー・ソース操作を順次実行する同期キューを自前で持ちました。Dart での簡易実装は次のような形です(アーキ図中の MapboxSyncQueue)。
/// Mapboxレイヤー・ソース操作の順序を保証するキュー
class MapboxSyncQueue {
final _taskQueue = Queue<Future<void> Function()>();
bool _isRunning = false;
void enqueue(Future<void> Function() task) {
_taskQueue.add(task);
_process();
}
void _process() async {
if (_isRunning) return;
_isRunning = true;
while (_taskQueue.isNotEmpty) {
final task = _taskQueue.removeFirst();
try {
await task();
await Future.delayed(const Duration(milliseconds: 1)); // 内部反映猶予
} catch (_) {
// 続行
}
}
_isRunning = false;
}
}
「エラーを握りつぶすのでは?」と思われそうですが、ピン再描画は毎秒複数回トリガーされうるため、次の操作で上書きされる前提での握りつぶしです。ここは現実的な設計トレードオフでした。
本番では Firebase Crashlytics でキュー内エラーの発生頻度をサンプリング監視しており、頻発するエラー種別があればハンドリングを追加する運用にしています。
設計判断③: 状態管理(BLoC + Riverpod 併用、MapboxMapController)
RIRIFE は BLoC(イベント駆動)と Riverpod(宣言的DI)を併用しています。
- BLoC: ユーザーアクション(検索実行、フィルタ変更、ログイン)などのフロー型ロジック
- Riverpod: 地図状態・API結果キャッシュ・カメラ位置などの宣言的な参照
MapboxMap コントローラは Riverpod の Provider で保持し、Widget や Helper 側から ref.read/ref.watch で参照できるようにしています。これにより、Widget Tree から離れたヘルパー層からも地図操作を呼び出せます(アーキ図中の MapboxMapController)。
@riverpod
class MapboxMapController extends _$MapboxMapController {
@override
MapboxMap? build() => null;
void set(MapboxMap map) {
state = map;
}
}
設計判断④: 本番運用で遭遇した Gotchas
ドキュメントでは見えにくい、運用してから分かった注意点も共有します。
| 状況 | 事象 | 対処 |
|---|---|---|
| Vector Tile の CDN タイムアウト | タイルがロードされず灰色背景のまま | ローディング shimmer を3秒で fallback message に切替、再試行ボタン表示 |
| Mapbox アクセストークンのローテーション | 古いトークンでアプリを開くと地図が読み込めない | Firebase Remote Config でトークンを配信、アプリ起動時に取得 |
| iOS の地図初期化タイミング | 画面遷移直後に onMapCreated が呼ばれない稀なケース | Provider 初期値を null にして、`ref.watch` で遅延構築 |
| Android の位置権限 | Android 13+ で ACCESS_COARSE_LOCATION と ACCESS_FINE_LOCATION の両方が必要 | permission_handler で両方を順次要求 |
| Mapbox 側の style バージョン変更 | 一部のレイヤープロパティ名が旧仕様から変わる | スタイルJSONをアプリ内で版管理、フィールド名の互換変換層を通す |
トレードオフまとめ
- Mapbox 固有のスタイルJSON記法を学ぶコストはある(初学1〜2日)
- レイヤー・ソースの追加順序バグは一度踏むと厄介なので、早期に同期キューを導入するのが正解
- ピン再描画の UX を詰めると、キャッシュ戦略(画像合成・アイコン合成)も必要になる
- 本番で遭遇する Gotchas(上表)は一度ドキュメント化しておくと後続メンバーが楽
5. 実装 — 3ヶ月のMVPスプリント
実装は次のようなリズムで進めました(Flutter 1名 + バックエンド 1名、計2名)。
Month 1: 地図+マーカー基盤(Google Maps ベース)
- Flutter +
google_maps_flutterで基本UI - 現在地表示、検索結果ピン、ボトムシートのスライド
- BLoC + Riverpod の配線
この時点で、マーカー数が増えたときの描画負荷と、Google Maps Platform の料金試算が気になり始めます。
Month 1 後半 〜 Month 2: ピボット決断と移行
- Mapbox で同一UI を作り直し、描画パフォーマンスと運用コストを比較
- クラスタリング、ピン選択ハイライト、現在地フォロー、カメラ境界を Mapbox 実装に移植
- レイヤー・ソース操作の順序問題に遭遇 → 同期キューを導入
Google Maps 版は画面とビジネスロジックのうちUI層(ピン描画・カメラ制御・ボトムシート連携)は廃棄、BLoC・Repository・APIモデルはほぼ再利用できました。工数損失はチーム肌感覚で2〜3週間相当でしたが、再利用率が高かったためスプリントの致命傷にはなりませんでした。
Month 2 後半 〜 Month 3: 機能拡充とストア申請
- 検索フィルタ、物件詳細画面、ハザードマップ表示(Mapbox の別Style活用)
- オフライン時のフォールバック(キャッシュとローディング表現)
- iOS/Android のストア申請・審査対応
失敗: ピボット判断を Month 1 の途中まで引き延ばしたこと
料金とパフォーマンスの懸念は Month 0〜1 の段階でも見えていましたが、「Google Maps の成熟度なら何とかなる」という前提で走り続けたため、ピボット作業がスプリントの山場と重なりました。
教訓: 地図SDKの選定は、MVPのごく初期に最低限の比較検証をしておく。一度ユーザー向け機能を実装してからの差し替えはコストが跳ね上がります。
6. 結果 — パフォーマンスと開発効率
Before/After 定性サマリー
| 観点 | Google Maps 期 | Mapbox 期 | 差分(体感/実測) |
|---|---|---|---|
| 多数ピン(数百件)でのズーム/パンフレームレート | 60fps を維持できず、操作遅延が目立つ | 60fps に張り付き、操作追従が滑らか | 体感で明確に改善、ユーザーからの「重い」FBが消失 |
| ボトムシート重ね合わせ時のちらつき | レイヤー境界で白フラッシュが再現 | ちらつき再現せず | 再現性ベースで解消 |
| 同一 MAU 帯の運用コスト(試算) | Maps Load 数に比例して膨張 | MAU に比例、予測しやすい | Mapbox の方が数倍オーダーで安い試算 |
| クラスタリング実装工数 | 外部プラグイン(flutter_map_marker_cluster)選定・導入に時間 | GeoJsonSource + スタイルJSONで完結 | 約1/3 の工数で到達 |
| スタイル差し替え(ハザードマップ追加等) | SDK差し替え等で影響大 | スタイルJSON切替で1日以内 | 別機能追加の速度が段違い |
| 学習曲線 | Google Maps Flutter は即使い始め可 | Mapbox スタイルJSONに1〜2日の初学必要 | 初学コストあり、ただし習得後の表現力は優位 |
※正確な数値は環境・MAU・ズーム挙動に依存するため公開を控えます。表は「定性的にどちらが優位か」の目安としてご覧ください。
クロスプラットフォームの実効性
iOS/Android 両対応を Flutter + Mapbox で実装した結果、コードベースの90%超を共通化できました。プラットフォーム固有の調整は、地図の初期化タイミングや権限まわりに限定されました。
7. 展望 — 次に取り組むこと
地図タイルのカスタマイズ
Mapbox Studio でタイルをカスタマイズすれば、物件検索アプリらしい「駅」「ランドマーク」を強調したマップを作れます。ブランド体験として深掘りしたい領域です。
ハザード情報の重ね合わせ強化
RIRIFE には既にハザードマップ画面があり、Mapboxのレイヤー機能を使って浸水・土砂・津波などの情報を重ねています。レイヤー制御を自由に操れる Mapbox の強みが最も出た部分で、今後は情報源の拡充と凡例UIの改善を進める予定です。
AR内見機能
地図で物件を絞り込んだあとに AR でその場に立ったときの眺望を確認する、という体験を試したいと考えています。Flutter から ARKit/ARCore を呼ぶ構成の技術検証から着手します。
8. あなたのケースでMapboxを検討すべきか
この記事の内容があなたのプロジェクトに適用できるかの簡易チェックリストです。
- ☐ 地図がアプリの主要インタラクションである(常時開閉される)
- ☐ 月間 Maps Load 数が万〜10万オーダー以上になりそう、またはスケール想定
- ☐ Flutter ウィジェットと地図の重ね合わせ UX が多い(ボトムシート・フィルタ・同期アニメーション)
- ☐ 数百件以上のマーカー/ピン表示でフレームレートが気になっている
- ☐ スタイル(色・ラベル・レイヤー)をブランド寄りにカスタマイズしたい
- ☐ 今後、別用途のスタイル(ハザードマップ等)を重ね合わせて使う計画がある
3つ以上該当するなら Mapbox の検討価値が十分にあります。0〜1つ程度(地図は補助的、単一スタイルで十分)なら Google Maps Flutter の成熟度を活かす判断が合理的です。
9. まとめ
- Flutter で地図アプリを作るとき、SDK選定はMVP初期の比較検証が必須。一度作ってから差し替えるとピボットコストが跳ね上がる(今回は2〜3週間相当)
- Google Maps Flutter は成熟しているが、料金モデル(Maps Load従量)とPlatform View 経由の描画制約で地図主導のアプリには厳しい場面がある
- Mapbox Maps SDK for Flutter は クラスタリング・スタイルJSON・GL描画が強みで、地図を主要インタラクションとする UX に適合する
- レイヤー・ソース操作の順序には気をつける(同期キュー
MapboxSyncQueueを用意する) - 本番運用前に Tile タイムアウト/トークン ローテーション/初期化順序 など Gotchas のハンドリングを設計に入れる
同じようなアプリを検討している方の参考になれば幸いです。
関連記事
- 10名未満で7プロダクトを回すAIネイティブ開発の仕組み — RIRIFE を含む7プロダクトを少人数で運用する仕組み
- Claude Code と GitHub Copilot を全員が使う開発チームの実態 — 本記事の実装も AI 活用前提で進めました
採用情報
ClassLab では、地図を中核UXとするモバイルアプリを一緒に設計・実装してくれる Flutter エンジニアを募集しています。Mapbox・地図タイル・クロスプラットフォーム設計に興味がある方、ぜひお話しさせてください。
ClassLab Engineering チームメンバーが執筆しました。
ClassLab.では、一緒にプロダクトを作るエンジニアを募集しています。
カジュアル面談も大歓迎です!