ClassLab Engineering の Dev チームメンバーが執筆しました。
「不動産アプリの地図、なぜこんなに重いの?」——ユーザーからの最初のフィードバックがこれでした。
ClassLabは不動産物件検索アプリ「RIRIFE」をFlutter×Google Mapsで開発し、iOS/Android両ストアで公開しています。この記事では、地図ベースの物件検索アプリをFlutter + Google Maps SDKで構築する際に直面した技術的課題と、それぞれの設計判断を公開します。
「Flutterでネイティブ地図を使いたいが、パフォーマンスが不安」「クロスプラットフォームで地図アプリを作る設計パターンを知りたい」——そんな方に向けて書きました。
—
1. 背景 — なぜFlutter×Google Mapsを選んだか
プロダクトの要件
ClassLabはライフライン(電気・ガス)の取次事業が基盤ですが、取次で築いた不動産会社とのネットワークを活かし、不動産テック領域にも事業を拡張しています。RIRIFEはその第一弾として開発した物件検索アプリで、ユーザーが地図上で物件を探し、気になる物件の詳細を閲覧・問い合わせできます。エンジニアが新規事業の立ち上げからストア公開まで一気通貫で携われるのは、少人数チームならではの環境です。
主要な技術要件:
- 地図上に数百〜数千件の物件マーカーを表示
- 現在地周辺の物件をリアルタイム検索
- 物件画像のスムーズなスクロール表示
- iOS/Android両対応(開発リソースを考えると1コードベースが必須)
- リリースまで3ヶ月(MVP)
技術選定: なぜFlutterか
| 候補 | iOS/Android両対応 | 地図SDK | 開発速度 | チーム経験 | 選定 |
|——|—————–|———|———|———-|——|
| React Native | ○ | react-native-maps | 速 | なし | × |
| Flutter | ○ | google_maps_flutter | 速 | 1名経験あり | **採用** |
| Swift + Kotlin | ネイティブ各 | 最高品質 | 遅(2倍工数) | なし | × |
| Kotlin Multiplatform | △(UI共有限定) | ネイティブ | 中 | なし | × |
決め手は2つ。チーム内にFlutter経験者が1名いたことと、google_maps_flutterプラグインの成熟度です。React Nativeのreact-native-mapsも候補でしたが、Google Maps SDKのラッパーとしての完成度はFlutterの方が高く、カスタムマーカーやクラスタリングのサポートが充実していました。
ネイティブ(Swift + Kotlin)は品質面で最善ですが、エンジニア1〜2名で3ヶ月のMVPリリースには工数が合いませんでした。
—
2. 課題 — 地図アプリ特有の5つの壁
| # | 課題 | 影響 | 技術的な壁 |
|—|——|——|———–|
| 1 | マーカー大量表示のパフォーマンス | 高 | 数千件のマーカーで地図がフリーズ |
| 2 | Platform View のレンダリングコスト | 高 | Flutter × ネイティブ地図の描画競合 |
| 3 | 地図操作と物件リストの連動 | 中 | スクロール・ズーム時のリスト更新タイミング |
| 4 | 画像の遅延読み込み | 中 | 物件画像が多くメモリ圧迫 |
| 5 | オフライン対応 | 低 | 地下鉄・エレベーター内で地図が真っ白 |
特に課題1と2は相互に関連しています。Google Maps SDKはPlatform View(ネイティブビュー)としてFlutterに組み込まれるため、Flutter側のウィジェットツリーとは異なるレンダリングパスを通ります。マーカーが増えるとPlatform Viewの描画負荷が上がり、Flutter側のアニメーション(ボトムシートのスライド等)がカクつく原因になりました。
—
3. 設計 — アーキテクチャと主要な設計判断
全体アーキテクチャ
graph TD
subgraph Client["Flutter App"]
UI[UI Layer<br/>BLoC + Riverpod]
MAP[Google Maps<br/>Platform View]
REPO[Repository Layer<br/>Dio]
CACHE[HTTP Cache<br/>dio_cache_interceptor]
end
subgraph Backend["Backend API"]
API[REST API<br/>FastAPI]
DB[(PostgreSQL<br/>+ PostGIS)]
SEARCH[空間検索<br/>ST_DWithin]
end
UI --> MAP
UI --> REPO
REPO --> API
REPO --> CACHE
API --> DB
DB --> SEARCH
style MAP fill:#3b82f6,color:#fff
style API fill:#10b981,color:#fff
style DB fill:#8b5cf6,color:#fff
設計判断①: マーカークラスタリング
数千件のマーカーを全てレンダリングするのではなく、ズームレベルに応じて近接マーカーをクラスタ(集約)します。
採用: flutter_map_marker_clusterではなく、バックエンド側でクラスタリングする方式を選択。
| 方式 | 処理場所 | メリット | デメリット | 選定 |
|——|———|———|———-|——|
| クライアント側クラスタリング | Flutter | 通信不要 | 数千件の座標計算でUI遅延 | × |
| バックエンド側クラスタリング | API | UI負荷ゼロ | API呼び出しが増える | **採用** |
バックエンドではPostGISの空間関数を2つ使い分けています。物件の範囲検索にはST_DWithin、マーカーの集約にはST_ClusterDBSCAN。ビューポート(表示範囲)とズームレベルをパラメータとしてクラスタリング済みのマーカー座標をAPIで返します。クライアントはJSONを受け取って描画するだけ。
// マーカー取得(クラスタリング済みAPIレスポンス)
Future<List<MapMarker>> fetchMarkers({
required LatLngBounds viewport,
required double zoomLevel,
}) async {
final response = await _dio.get('/api/v1/properties/markers',
queryParameters: {
'ne_lat': viewport.northeast.latitude,
'ne_lng': viewport.northeast.longitude,
'sw_lat': viewport.southwest.latitude,
'sw_lng': viewport.southwest.longitude,
'zoom': zoomLevel.round(),
},
);
return (response.data as List)
.map((json) => MapMarker.fromJson(json))
.toList();
}
この方式により、地図上に同時表示されるマーカーは常に最大100件以下に制御でき、パフォーマンス問題を解消しました。
設計判断②: Platform View の最適化
Flutter 3.x以降、google_maps_flutterはHybrid Compositionを使用します。これはネイティブビューとFlutterビューを重ね合わせる方式で、パフォーマンスが改善されていますが、それでもオーバーレイ(物件詳細のボトムシート等)との描画競合が発生しました。
対策として以下を実施:
- 地図操作中はボトムシートのアニメーションを抑制:
onCameraMoveStartedで一時的にシートのリビルドを止める - マーカータップ時のみ物件詳細をフェッチ: 事前読み込みを避け、タップ→API呼び出し→表示の遅延を最小化(平均180ms)
RepaintBoundaryで地図と物件リストの描画領域を分離: 物件リストのスクロールが地図の再描画をトリガーしないようにする
設計判断③: 状態管理(BLoC + Riverpod併用)
地図ベースのアプリは状態が複雑です。地図のカメラ位置、表示中のマーカー、選択中の物件、検索フィルタ——これらが相互に影響します。
RIRIFEではflutter_blocとflutter_riverpodを併用しています。画面遷移に伴う複雑な状態(物件検索フィルタ、お気に入りリスト等)はBLoCで管理し、地図のカメラ位置やマーカーの非同期取得といったリアクティブなデータフローはRiverpodで管理。役割を分離することで、どちらか一方に寄せるより見通しが良くなりました。
graph LR
CAM[カメラ位置変更] --> FETCH[マーカー再取得]
FILTER[フィルタ変更] --> FETCH
FETCH --> MARKERS[マーカー更新]
MARKERS --> MAP[地図描画]
TAP[マーカータップ] --> DETAIL[物件詳細取得]
DETAIL --> SHEET[ボトムシート表示]
style FETCH fill:#f59e0b,color:#fff
style MAP fill:#3b82f6,color:#fff
style SHEET fill:#8b5cf6,color:#fff
Riverpod側ではAsyncNotifierProviderでAPIの非同期状態を管理し、カメラ位置変更時のデバウンス処理(300ms)を実装。ユーザーが地図をスクロールするたびにAPIを叩くのではなく、操作が止まって300ms後に1回だけ取得します。
// カメラ移動時のデバウンス処理(Riverpod AsyncNotifier内)
Timer? _debounceTimer;
void onCameraMoved(CameraPosition position) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
fetchMarkers(
viewport: position.bounds,
zoom: position.zoom,
);
});
}
トレードオフまとめ
| 判断ポイント | 選択肢A | 選択肢B | 採用 | 理由 |
|————-|———|———|——|——|
| クラスタリング | クライアント側 | バックエンド側 | B | UI負荷回避 |
| 状態管理 | BLoC単独 | BLoC + Riverpod併用 | B | 画面状態はBLoC、地図のリアクティブデータはRiverpod。役割分離で見通し向上 |
| 画像キャッシュ | 自前実装 | cached_network_image | B | 枯れたライブラリに委任 |
| HTTPキャッシュ | 自前実装 | dio_cache_interceptor | B | Dioのインターセプターでリクエスト層に透過的にキャッシュ |
| ナビゲーション | Navigator 2.0 | go_router | B | 宣言的ルーティング |
—
4. 実装 — 3ヶ月のMVPスプリント
このプロジェクトはモバイルエンジニア1名(Flutter)とバックエンドエンジニア1名(FastAPI + PostGIS)の2名体制です。
Month 1: 地図+マーカー基盤
- Google Maps SDK統合、マーカー描画、クラスタリングAPI
- PostGIS空間インデックス構築、
ST_DWithinによる範囲検索 - この段階でマーカー1,000件表示のベンチマーク実施 → クライアント側クラスタリングを断念
- ボトムシートでの物件詳細表示(画像カルーセル+基本情報)
- 価格帯・間取り・築年数のフィルタ機能
cached_network_imageによる画像遅延読み込み+メモリ管理dio_cache_interceptorによるHTTPレスポンスキャッシュ(直近閲覧物件をローカルに保持)- オフライン時はキャッシュ済みデータを表示(「情報が古い可能性があります」バナー付き)
- iOS App Store + Google Play Console への申請、レビュー対応
- Findyでエンジニア求人を見る
- カジュアル面談を申し込む
- 通話音声の感情分析でQA工数90%削減|本番3ヶ月の実績
- 年間数万件の申込処理をRPA×生成AIで自動化した全記録
Month 2: 物件詳細+検索フィルタ
Month 3: オフライン対応+ストア申請
失敗: 初期のマーカー描画方式
最大の失敗は、最初の2週間でカスタムマーカー(物件価格を表示するバブル)をCanvasで描画しようとしたこと。FlutterのCustomPainterでマーカーを描くと、マーカー数に比例してフレームレートが低下。100件で60fps→30fps、500件で15fpsまで落ちました。
結果としてカスタムマーカーを諦め、標準マーカー + クラスタリング + タップ時に詳細表示というシンプルな方式に切り替え。見た目のリッチさよりパフォーマンスを優先する判断でした。
—
5. 結果 — パフォーマンスと開発効率
3ヶ月のMVPリリース後、2ヶ月間の運用データです。
| 指標 | 目標 | 実績 | 判定 |
|——|——|——|——|
| 地図描画FPS(マーカー100件表示時) | 55fps以上 | 58fps (iOS) / 56fps (Android) | **達成** |
| マーカータップ→詳細表示 | 300ms以下 | 平均180ms | **達成** |
| コールドスタート時間 | 3秒以下 | 2.4秒 (iOS) / 2.8秒 (Android) | **達成** |
| クラッシュフリー率(Firebase Crashlytics) | 99%以上 | 99.6% | **達成** |
| クラスタリングAPI応答時間(P95) | 200ms以下 | 142ms | **達成** |
| コードベース共有率(iOS/Android) | 80%以上 | 94% | **達成** |
| 開発期間(MVP) | 3ヶ月 | 3ヶ月 | **達成** |
クロスプラットフォームの実効性
Flutterを選んだ最大の理由「1コードベースで両OS対応」の実効性は94%のコード共有率で証明されました。残り6%はプラットフォーム固有のコード(プッシュ通知のチャネル設定、位置情報パーミッションのUI差分、App Storeのレビュー誘導タイミング)です。
ネイティブ開発であれば2コードベースで6ヶ月以上かかる見積もりだったため、開発工数を半分以下に抑えた計算になります。
リリース後はストアレビューとユーザーインタビューをもとに改善サイクルを回しており、地図操作のUXは3回のアップデートで大幅に改善。「地図が重い」という初期フィードバックは現在ではゼロになっています。
—
6. 展望 — 次に取り組むこと
地図タイルのカスタマイズ
現在はGoogle Mapsのデフォルトスタイルを使用していますが、物件の種別(マンション/戸建/土地)に応じたカスタムマップスタイルを検討しています。住宅地エリアを強調し、商業施設を淡くするような表示。
WebView版の検討
「アプリをインストールせずに物件を見たい」というユーザーの声が多く、Flutter Webによるブラウザ版の提供を検討中です。ただしFlutter Webの地図パフォーマンスはネイティブに劣るため、地図部分のみLeaflet.jsに置き換えるハイブリッドアプローチを調査しています。
AR内見機能
将来的には、物件の位置にカメラを向けると外観と周辺情報がARオーバーレイされる機能を構想しています。Flutter用のARKitプラグインはまだ成熟していないため、中期目標として位置づけています。
—
Flutterで地図アプリを作る際の設計判断は、「Flutterの強み(高速開発・コード共有)を活かしつつ、弱み(Platform View の負荷)を設計で回避する」バランスが鍵でした。
同じようなアプリを検討している方の参考になれば幸いです。
—
採用情報
ClassLab では一緒に技術的挑戦に取り組むエンジニアを募集しています。
関連記事
—
ClassLab Engineering チームメンバーが執筆しました。
>
ClassLab.では、一緒にプロダクトを作るエンジニアを募集しています。
カジュアル面談も大歓迎です!
>