3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter × Google Routes APIで訪問順を組み立てる

3
Posted at

Flutter × Google Routes APIで訪問順を組み立てる

はじめに

訪問介護のように、一日に複数の場所を回る業務をアプリにするとき、地図へピンを立てるところまでは比較的素直に作れます。難しくなるのは、その次です。

「Aさん宅、Bさん宅、Cさん宅を今日はどの順番で回るか」を扱おうとすると、単なる地図表示では足りません。住所の一覧と地図を行き来しながら人が順番を決めるのか、移動時間を使ってシステムが候補を作るのか、時間指定のある訪問をどう扱うのか、といった業務側の判断が入ってきます。

中小企業向けの業務システムやスマホアプリを作ってきた中で、私はこういう機能ほど、最初から「自動で正解を決める」方向へ寄せないようにしています。今回は訪問介護を想定した架空のシナリオで、FlutterからGoogle Routes APIのcomputeRoutesを呼び、複数の訪問地点について順番の候補を取得して地図へ表示するところまでを組みます。

持ち帰れるものは、Routes APIを呼ぶDartコード、レスポンスから並び順を復元する処理、PolylineをFlutterのGoogle Mapへ描画する実装です。

結論

Google Routes APIでは、中間地点を渡してoptimizeWaypointOrderを有効にすると、並べ替えられた中間地点のインデックスを取得できます。

ただし、APIが返す順番を訪問介護の予定としてそのまま確定せず、移動経路の候補を作る層と、業務上の制約を判断する層を分ける構成にします。

環境

この記事は2026年9月27日時点の公式ドキュメントと公開パッケージ情報を確認して書いています。

検証用の構成は次のようにします。

Flutter: 3.47系
Dart: Flutter 3.47同梱版
google_maps_flutter: 2.18.1
http: 1.6.0
Google Maps Platform: Routes API v2
API: computeRoutes
対象: Android / iOS

Flutter 3.47は2026年8月に公開された安定版です。

今回はRoutes APIそのものを呼び出すためにhttpを使い、地図表示にはgoogle_maps_flutterを使います。2026年9月27日時点で、pub.dev上ではhttp 1.6.0、google_maps_flutter 2.18.1が公開されています。

依存関係は次のようにしました。

dependencies:
  flutter:
    sdk: flutter

  http: ^1.6.0
  google_maps_flutter: ^2.18.1

Routes APIにはcomputeRoutesとcomputeRouteMatrixがあります。

前者は出発地から目的地までの経路を求めるAPI、後者は複数の出発地と目的地の組み合わせについて距離や所要時間を求めるAPIです。

今回は「一つの出発地点から複数地点を経由して最後の地点まで走る」という形にするので、computeRoutesを使います。

🔧 実装

Flutter × Google Routes APIで訪問順を組み立てる

最初に、今回作る範囲を決めます。

訪問介護を想定すると、データには利用者名、住所、訪問予定時刻、サービス内容など多くの情報が入り得ます。しかし経路計算に必要のない情報まで外部APIへ送る設計にはしません。

今回Routes APIへ渡すのは位置情報だけです。

処理の流れは次のようにします。

ここで大事なのは、Routes APIを「予定を決定するサービス」ではなく「移動経路を計算するサービス」として置いていることです。

訪問地点のモデルを作る

まず、アプリ側で扱う訪問地点を小さなモデルにします。

class VisitPoint {
  const VisitPoint({
    required this.id,
    required this.latitude,
    required this.longitude,
    this.fixedOrder = false,
  });

  final String id;
  final double latitude;
  final double longitude;

  // 業務上、順番を動かしたくない地点を識別するための値
  final bool fixedOrder;
}

idにはアプリ内部の識別子を入れます。

このモデルに氏名や介護内容を詰め込まないのは意図的です。経路計算を担当するコードが、経路計算に不要な情報へ触れないようにしておくためです。

たとえば画面側には次のような別モデルがあっても構いません。

class VisitSchedule {
  const VisitSchedule({
    required this.visitPointId,
    required this.displayName,
    required this.startAt,
  });

  final String visitPointId;
  final String displayName;
  final DateTime startAt;
}

経路計算用モデルと画面表示用モデルを分けておけば、「APIへ何を送っているのか」が追いやすくなります。

Routes APIを呼ぶ

Routes APIのREST版computeRoutesはPOSTで呼び出します。

今回のポイントはoptimizeWaypointOrderです。

出発地点と最終目的地の間に複数のintermediatesを設定し、このオプションを有効にすると、レスポンスのoptimizedIntermediateWaypointIndexから並べ替え結果を取得できます。

サービスクラスを作ります。

ここでは、Qiitaなどがコード中のURL文字列をリンクとして判定する場合にも、APIの呼び出し先を参考文献のリンクと誤認しにくいよう、URIをUriの各要素から組み立てています。

import 'dart:convert';

import 'package:http/http.dart' as http;

class RoutesApiException implements Exception {
  RoutesApiException(this.message);

  final String message;

  @override
  String toString() => 'RoutesApiException: $message';
}

class RoutePlan {
  const RoutePlan({
    required this.optimizedIntermediateIndexes,
    required this.encodedPolyline,
    required this.distanceMeters,
    required this.duration,
  });

  final List<int> optimizedIntermediateIndexes;
  final String encodedPolyline;
  final int distanceMeters;
  final String duration;
}

class RoutesApiClient {
  RoutesApiClient({
    required this.apiKey,
    http.Client? client,
  }) : _client = client ?? http.Client();

  final String apiKey;
  final http.Client _client;

  static final Uri _endpoint = Uri(
    scheme: 'https',
    host: 'routes.googleapis.com',
    path: 'directions/v2:computeRoutes',
  );

  Future<RoutePlan> computeOptimizedRoute({
    required VisitPoint origin,
    required VisitPoint destination,
    required List<VisitPoint> intermediates,
  }) async {
    final body = {
      'origin': _waypoint(origin),
      'destination': _waypoint(destination),
      'intermediates': intermediates.map(_waypoint).toList(),
      'travelMode': 'DRIVE',
      'routingPreference': 'TRAFFIC_AWARE',
      'optimizeWaypointOrder': true,
      'polylineQuality': 'OVERVIEW',
      'polylineEncoding': 'ENCODED_POLYLINE',
    };

    final response = await _client.post(
      _endpoint,
      headers: {
        'Content-Type': 'application/json',
        'X-Goog-Api-Key': apiKey,
        'X-Goog-FieldMask': [
          'routes.distanceMeters',
          'routes.duration',
          'routes.polyline.encodedPolyline',
          'routes.optimizedIntermediateWaypointIndex',
        ].join(','),
      },
      body: jsonEncode(body),
    );

    if (response.statusCode != 200) {
      throw RoutesApiException(
        'status=${response.statusCode}, body=${response.body}',
      );
    }

    final json = jsonDecode(response.body) as Map<String, dynamic>;
    final routes = json['routes'] as List<dynamic>?;

    if (routes == null || routes.isEmpty) {
      throw RoutesApiException('route not found');
    }

    final route = routes.first as Map<String, dynamic>;
    final polyline = route['polyline'] as Map<String, dynamic>?;

    return RoutePlan(
      optimizedIntermediateIndexes:
          (route['optimizedIntermediateWaypointIndex'] as List<dynamic>? ?? [])
              .cast<int>(),
      encodedPolyline:
          polyline?['encodedPolyline'] as String? ?? '',
      distanceMeters:
          route['distanceMeters'] as int? ?? 0,
      duration:
          route['duration'] as String? ?? '',
    );
  }

  Map<String, dynamic> _waypoint(VisitPoint point) {
    return {
      'location': {
        'latLng': {
          'latitude': point.latitude,
          'longitude': point.longitude,
        },
      },
    };
  }

  void dispose() {
    _client.close();
  }
}

X-Goog-FieldMaskも省略しません。

必要なレスポンスだけを指定して、

routes.distanceMeters
routes.duration
routes.polyline.encodedPolyline
routes.optimizedIntermediateWaypointIndex

を取得しています。

Field Maskは単なるレスポンス整形の小技ではなく、「この機能はAPIから何を必要としているのか」をコード上で明確にする役割もあります。

並び替え結果を元のデータへ戻す

ここは少し間違えやすいところです。

optimizedIntermediateWaypointIndexに返ってくるのは、訪問地点そのものではありません。リクエストで渡したintermediatesのインデックスです。

たとえば、

intermediates:
0 -> A
1 -> B
2 -> C

に対して、

[2, 0, 1]

が返れば、

C -> A -> B

という順番です。

そのため、変換処理を一つ用意します。

List<VisitPoint> reorderIntermediates(
  List<VisitPoint> source,
  List<int> optimizedIndexes,
) {
  if (optimizedIndexes.length != source.length) {
    throw StateError(
      'optimized index count does not match intermediate count',
    );
  }

  return optimizedIndexes.map((index) {
    if (index < 0 || index >= source.length) {
      throw RangeError.index(index, source);
    }

    return source[index];
  }).toList(growable: false);
}

レスポンスを受け取った画面側では、次のように扱えます。

final plan = await routesApi.computeOptimizedRoute(
  origin: office,
  destination: lastVisit,
  intermediates: visits,
);

final optimizedVisits = reorderIntermediates(
  visits,
  plan.optimizedIntermediateIndexes,
);

ここで元のvisits自体を破壊的に並べ替えないようにしています。

元の予定とAPIの候補を比較できるようにしておいた方が、業務アプリでは扱いやすいからです。

Polylineをデコードする

Routes APIからENCODED_POLYLINEで受け取った値は、そのままgoogle_maps_flutterのPolylineには渡せません。

緯度経度へ戻します。

import 'package:google_maps_flutter/google_maps_flutter.dart';

List<LatLng> decodePolyline(String encoded) {
  final points = <LatLng>[];

  var index = 0;
  var latitude = 0;
  var longitude = 0;

  while (index < encoded.length) {
    final latResult = _decodeValue(encoded, index);
    latitude += latResult.value;
    index = latResult.nextIndex;

    final lngResult = _decodeValue(encoded, index);
    longitude += lngResult.value;
    index = lngResult.nextIndex;

    points.add(
      LatLng(
        latitude / 1e5,
        longitude / 1e5,
      ),
    );
  }

  return points;
}

_DecodedValue _decodeValue(String encoded, int startIndex) {
  var index = startIndex;
  var result = 0;
  var shift = 0;
  int byte;

  do {
    byte = encoded.codeUnitAt(index++) - 63;
    result |= (byte & 0x1f) << shift;
    shift += 5;
  } while (byte >= 0x20);

  final value = (result & 1) != 0
      ? ~(result >> 1)
      : result >> 1;

  return _DecodedValue(
    value: value,
    nextIndex: index,
  );
}

class _DecodedValue {
  const _DecodedValue({
    required this.value,
    required this.nextIndex,
  });

  final int value;
  final int nextIndex;
}

本番コードなら、Polylineのデコードを担当する既存パッケージを採用する選択肢もあります。ただ、経路APIとの境界を把握する目的では、一度この変換を自分で書いておくとデータの流れが分かりやすいです。

Flutterの地図へ描画する

デコードできれば、あとは通常のPolylineとして表示できます。

class RouteMap extends StatelessWidget {
  const RouteMap({
    super.key,
    required this.points,
    required this.visits,
  });

  final List<LatLng> points;
  final List<VisitPoint> visits;

  @override
  Widget build(BuildContext context) {
    final markers = <Marker>{
      for (var i = 0; i < visits.length; i++)
        Marker(
          markerId: MarkerId(visits[i].id),
          position: LatLng(
            visits[i].latitude,
            visits[i].longitude,
          ),
          infoWindow: InfoWindow(
            title: '訪問 ${i + 1}',
          ),
        ),
    };

    return GoogleMap(
      initialCameraPosition: CameraPosition(
        target: points.isNotEmpty
            ? points.first
            : const LatLng(35.6812, 139.7671),
        zoom: 12,
      ),
      markers: markers,
      polylines: {
        if (points.isNotEmpty)
          Polyline(
            polylineId: const PolylineId('route'),
            points: points,
            width: 5,
          ),
      },
    );
  }
}

ここではサンプルの初期表示座標として東京駅付近を使っています。訪問介護の実データではありません。

ルート取得から表示までをつなぐと、

final plan = await routesApi.computeOptimizedRoute(
  origin: office,
  destination: lastVisit,
  intermediates: visits,
);

final orderedVisits = reorderIntermediates(
  visits,
  plan.optimizedIntermediateIndexes,
);

final routePoints = decodePolyline(
  plan.encodedPolyline,
);

という流れになります。

orderedVisitsは訪問一覧へ、routePointsは地図へ渡します。

「最適化できる地点」だけを渡す

ここからが、介護系の業務アプリでは重要だと考えています。

仮に次の予定があったとします。

09:00 Aさん
時間自由 Bさん
時間自由 Cさん
11:00 Dさん

これを全部まとめて「最適な順番にしてください」と渡す設計にはしません。

AとDには時刻という業務条件があります。移動距離だけを見れば違う順番の方が都合よくても、訪問予定として成立するとは限らないからです。

私はまず、固定された予定の間にある「動かしてよい部分」を取り出す形で考えます。

09:00 A
  ↓
[ B / C を並べ替えてよい ]
  ↓
11:00 D

コードにするなら、Routes APIの前に業務ルールを置きます。

class RouteOptimizationBlock {
  const RouteOptimizationBlock({
    required this.origin,
    required this.destination,
    required this.movableVisits,
  });

  final VisitPoint origin;
  final VisitPoint destination;
  final List<VisitPoint> movableVisits;
}

Routes APIへ渡す単位は、一日全体ではなくこのRouteOptimizationBlockです。

この分離をしておくと、

業務ルール
    ↓
動かせる区間を決める
    ↓
Routes API
    ↓
移動順の候補

となります。

APIへ業務判断まで背負わせない構造です。

🧭 「最短」と「回れる」は同じではない

経路最適化という言葉を見ると、「APIへ全部渡せば、一日の予定表が完成する」と考えたくなります。

しかし訪問業務には、地図に現れない条件があります。

たとえば、こんな場面を想像してみてください。

ある訪問は午前中でなければならない。別の訪問は家族の在宅時間に合わせたい。同じ建物への訪問をまとめたい日もある。職員ごとに担当できる業務が違うかもしれません。

これらを経路APIだけで判断することはできません。

だから画面にも「最適化済み」と断定的に表示するより、

移動順の候補

くらいの意味で扱う方が、仕組みの実態に合っています。

私がAIを使った業務自動化でも大切にしている「人が決め、AIが下ごしらえする」という考え方は、経路計算にもそのまま当てはまります。

計算が得意なところはシステムへ渡す。一方で、訪問してよい時刻や担当者の事情まで自動判断したことにはしません。

最終的な予定は人が確認できる状態にします。

🔐 APIキーをアプリへ直書きしない

ここまでのサンプルでは説明を短くするため、

RoutesApiClient(
  apiKey: apiKey,
);

としてFlutterから直接Routes APIを呼んでいます。

ただし、実運用ではこの構成をそのまま採用するとは限りません。

モバイルアプリへ含めた秘密情報は、完全に秘匿できるものとして扱わない方が安全です。特に、訪問予定の取得や権限確認など、自前のバックエンドがすでに存在するなら、Routes APIの呼び出しもバックエンド側へ寄せる構成を検討します。

この形ならFlutter側がGoogle Maps Platform用のサーバー向け認証情報を直接管理する必要がありません。

さらに、アプリから緯度経度を自由に指定してRoutes APIを呼べる構造ではなく、

visitPointIdを送る
↓
サーバーが権限を確認する
↓
サーバー側で位置情報へ変換する
↓
Routes APIへ渡す

という境界も作れます。

医療・介護に限りませんが、機微な業務データを扱うアプリでは、「APIキーを隠す」だけでなく「クライアントに何を自由入力させるか」まで考えた方がよいです。

なお、Google Maps PlatformにはAPIごとの利用条件や表示時の帰属表示要件があります。Routes APIの結果を地図等へ表示する場合は、実装時点の公式ポリシーを確認する必要があります。

⚠️ ハマりどころ

optimizedIntermediateWaypointIndexを訪問番号だと思わない

最初に確認したいのがインデックスの意味です。

たとえば、

"optimizedIntermediateWaypointIndex": [2, 0, 1]

は、「訪問番号2、0、1」という業務IDではありません。

リクエストしたintermediates配列に対する0始まりのインデックスです。

そのため、私はAPI呼び出し時の配列を保持し、レスポンスをその配列へ適用する形にしています。

final requestedIntermediates = List<VisitPoint>.unmodifiable(
  visits,
);

final plan = await routesApi.computeOptimizedRoute(
  origin: office,
  destination: lastVisit,
  intermediates: requestedIntermediates,
);

final optimized = reorderIntermediates(
  requestedIntermediates,
  plan.optimizedIntermediateIndexes,
);

API送信後に元配列を書き換えると対応関係が崩れるので、変更不可のリストにしておくのも一つの方法です。

出発地と最終地点は中間地点ではない

optimizeWaypointOrderが並べ替える対象はintermediatesです。

したがって、

事業所
↓
A
↓
B
↓
C

という予定で、事業所をorigin、Cをdestination、AとBをintermediatesにすれば、並べ替え対象はAとBです。

「最後は必ず事業所へ戻る」という運用なら、

origin        = 事業所
intermediates = A, B, C
destination   = 事業所

という組み立ても考えられます。

このあたりはAPIの仕様というより、先に業務側で「何を固定し、何を動かすのか」を決めておく部分です。

Field Maskへ必要な値を入れ忘れる

レスポンスで使いたいフィールドはX-Goog-FieldMaskへ指定します。

訪問順だけを実装してから地図表示を追加すると、

routes.polyline.encodedPolyline

を追加し忘れる、といったことが起こりやすいです。

私はField Maskをサービスクラスの近くへまとめています。

static const _routeFieldMask = [
  'routes.distanceMeters',
  'routes.duration',
  'routes.polyline.encodedPolyline',
  'routes.optimizedIntermediateWaypointIndex',
];

そして、

'X-Goog-FieldMask': _routeFieldMask.join(','),

とします。

何を取得しているのかレビューもしやすくなります。

住所をそのまま業務データの主キーにしない

地図を扱い始めると、住所文字列をあちこちで使いたくなります。

しかしアプリ内部では、

visitPointId

のような自前の識別子を中心にした方が扱いやすいです。

画面表示用の住所、位置情報、訪問予定をIDで関連付け、Routes API用のモデルへ変換する直前に緯度経度を取り出します。

そうすると経路計算のコードが利用者情報の構造へ依存しにくくなります。

APIの結果を長期保存する前に利用条件を確認する

経路結果を取得できるようになると、「毎朝計算してDBへ保存しておこう」と考えたくなります。

ここは実装だけで決めない方がよい部分です。

Google Maps Platformのコンテンツには保存やキャッシュに関する利用条件があります。Routes APIの公式ポリシーでもキャッシュについて制限が示されているため、経路や地図由来データを保存する設計では、最新の規約を確認してから保存対象と期間を決めます。

自前データである訪問予定と、外部サービスから得た経路情報を同じ感覚で永続化しないようにします。

「交通状況を使う」と「時間指定を守る」を混同しない

今回のコードでは、

"routingPreference": "TRAFFIC_AWARE"

を指定しています。

これは経路計算で交通状況を考慮するための設定です。

しかし、「10時までにこの家へ到着する必要がある」という業務制約を自動的に保証してくれる、という意味ではありません。

経路計算の条件と訪問業務の条件は分けます。

Routes API
  移動経路
  距離
  所要時間
  中間地点の順序候補

業務ロジック
  訪問可能時間
  担当者
  サービス内容
  固定予定
  休憩

この境界が曖昧になると、APIの高機能化に合わせて業務ルールまで経路計算側へ流れ込みやすくなります。

🧪 最初にテストしたいところ

地図UIより先に、並び順の復元処理をテストしておくと安心です。

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('optimized indexesで訪問地点を並べ替える', () {
    const points = [
      VisitPoint(
        id: 'A',
        latitude: 35.0,
        longitude: 139.0,
      ),
      VisitPoint(
        id: 'B',
        latitude: 35.1,
        longitude: 139.1,
      ),
      VisitPoint(
        id: 'C',
        latitude: 35.2,
        longitude: 139.2,
      ),
    ];

    final result = reorderIntermediates(
      points,
      [2, 0, 1],
    );

    expect(
      result.map((e) => e.id).toList(),
      ['C', 'A', 'B'],
    );
  });

  test('インデックス数が一致しなければ例外にする', () {
    const points = [
      VisitPoint(
        id: 'A',
        latitude: 35.0,
        longitude: 139.0,
      ),
      VisitPoint(
        id: 'B',
        latitude: 35.1,
        longitude: 139.1,
      ),
    ];

    expect(
      () => reorderIntermediates(points, [0]),
      throwsStateError,
    );
  });
}

Routes APIのレスポンスを正しく受け取れても、アプリ側でインデックスを間違って解釈すれば表示順が壊れます。

さらに実務で組むなら、少なくとも次のケースは確認します。

  • 中間地点が空
  • 中間地点が1件
  • APIから経路が返らない
  • HTTPエラー
  • 不正なインデックスが返った場合
  • Polylineが空
  • 固定予定しかない
  • 最適化対象の区間が複数ある

ネットワーク部分と業務ロジックを分離しておけば、地図を起動しなくてもこのあたりをテストできます。

まとめ

FlutterとGoogle Routes APIを組み合わせれば、複数の訪問地点について、経路と中間地点の順番候補をアプリへ取り込めます。

実装そのものは、地点をintermediatesへ入れ、optimizeWaypointOrderを有効にし、optimizedIntermediateWaypointIndexを元データへ対応させるのが中心です。Polylineまで取得すれば、google_maps_flutterを使った経路表示にもつなげられます。

ただ、訪問介護の「回る順番」を作る機能として見ると、経路APIを呼ぶところより前が重要です。

誰の予定を動かしてよいのか。時刻固定の訪問はどれか。経路計算へ渡してよい情報は何か。APIが返した候補を誰が確認するのか。

私はこうした条件を先に分けてから、計算できる部分だけを外部サービスへ任せる形が扱いやすいと考えています。

最初から一日の予定を完全自動で決めるのではなく、まずは読み取り専用に近い「現在の予定に対して移動順の候補を表示する」機能から始める方法もあります。

地図の線を描くところからではなく、「どこまでをシステムに決めさせるか」を決めるところから始める。それが、現場で使う経路機能を作るときの設計の軸になります。

参考文献

以下は2026年9月27日時点で確認した公式ドキュメントです。

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?