實務上許多應用會需要使用到地圖的功能,使用 Google 的 google_maps_flutter
可以輕鬆的讓我們的應用程式支援地圖功能。該套件可以自動存取 Google Map 伺服端,顯示地圖,基於使用者的手勢,點擊、拖拉等進行操控。還可以加上標記 Marker 到地圖上,這些物件可以為地圖上的座標提供更多其他資訊。
除了 Google Map 您還可以使用其他如
flutter_map
等套件使用不同的地圖服務。但這裡我們聚焦在 Google Map。
本篇我們將會實作:
$ flutter create google_map_demo
使用 pub.dev 安裝上面提及的 google_maps_flutter
套件,使專案支援相關功能:
$ cd google_map_demo
$ flutter pub add google_maps_flutter
pub.dev 指標
前面章節我們有提及 pub dev 會對套件提供分析指標。Likes 表示開發者點讚。 Pub Points 是新的品質指標 160 為目前的滿分,這個評分包含程式風格,支援度,維護性等,評分工具為 pana。Popularity 流行度衡量的是過去 60 天內依賴某個套件的應用程序數量。其顯示為百分比,從 100%(表示位於使用最廣泛的前 1% 套件之列)到 0%(表示最少使用的套件)。
要在 iOS 使用最新版本的 Google Map SDK 最低版本為 iOS 14。編輯 ios/Prodfile
如下
# Uncomment this line to define a global platform for your project
platform :ios, '14.0' # 打開註解,並設定為 14
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
通常的情況如果我們沒有設定,截止 2024 年中,你的 iOS 專案很大概率會使用 12 作為最低支援的版本。這個最低版本會隨著時間逐漸提高。
minSDK
同樣的 Android 也有最低版本的限制,我們須調整 minSDK
到 21。修改 android/app/build.gradle
android {
defaultConfig {
applicationId = "com.example.google_maps_in_flutter"
// Minimum Android version for Google Maps SDK
// https://developers.google.com/maps/flutter-package/config#android
minSdk = 21
targetSdk = flutter.targetSdkVersion
versionCode = flutterVersionCode.toInteger()
versionName = flutterVersionName
}
}
另外還有一種不進版控的方式就是調整 android/local.properties
flutter.minSdkVersion=21
Android minSdk 版本
一般來說,要知道 minSDK 我們需要到
android/app/build.gradle
確認android { namespace = "com.example.blue_demo" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion defaultConfig { // ... minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutterVersionCode.toInteger() versionName = flutterVersionName } }
當然你可以直接設定這邊的版本
minSdk = flutter.minSdkVersion
。不過,我們也可以進一步釐清,到底這個flutter.minSdkVersion
是多少?這個最小 SDK 版本是由 Flutter 配置決定的,要確定具體的版本號我們須查詢
flutter/packages/flutter_tools/gradle/flutter.gradle
$ which flutter [您的安裝路徑]/flutter/bin/flutter $ cd [您的安裝路徑]/flutter/packages/flutter_tools/gradle/ $ cat flutter.gradle # 接著,您應該會看到下面的設定 def pathToThisDirectory = buildscript.sourceFile.parentFile apply from: "$pathToThisDirectory/src/main/groovy/flutter.groovy" # 也就是 flutter/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy # 您應該可以看到 minSdkVersion class FlutterExtension { /** Sets the compileSdkVersion used by default in Flutter app projects. */ public final int compileSdkVersion = 34 /** Sets the minSdkVersion used by default in Flutter app projects. */ public final int minSdkVersion = 21 }
既然版本為 21 這裡我們就不做任何變更。但確實還有一些地方是可能變更這些設定的。
app/build.gradle
android/local.properties
pubspec.yaml
確認版本無誤之後,接著我們需要設定授權。
要在 Flutter 應用程式中使用 Google Map 我們須先到 Google Maps Platform 設定 API 專案
建立專案
啟用 Google Maps API 到 Maps SDK for Android 和 Maps SDK for iOS 啟動 API
「API 和服務」> 「憑證」> 分別為 Android 和 iOS「建立 API 金鑰」
取得 Android 指紋
# 使用預設密碼 “android”
$ keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
iOS 的話限制 Bundle ID 即可。
取得下面所需的 API 金鑰。有了 API 金鑰就可以進行下面的設定。
跟前面 Firebase 不同的是,目前 Google Map 還沒有提供類似 FlutterFire CLI 這類工具,我們還是得自己設定。
取得 API 金鑰之後在 android/app/src/main/AndroidManifest.xml
檔案中加入
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="google_map_demo"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<meta-data android:name="com.google.android.geo.API_KEY"
android:value="[替換你的金鑰]"/>
</application>
</manifest>
要為 iOS 應用程式加入金鑰,編輯在 ios/Runner
專案下的 AppDelegate.swift
檔案。跟 Android 不一樣的是,iOS 加入金鑰須修改原始碼。AppDelegate
是程式初始化的一個重要部分。
開啟該檔案後,首先加入 #import
匯入 Google Maps headers 然後呼叫 GMServices
單一實例的 provideAPIKey()
方法。
ios/Runner/AppDelegate.swift
import Flutter
import UIKit
import GoogleMaps // <- 新增匯入
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
GMSServices.provideAPIKey("[替換你的金鑰]"); // <- 新增此行
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late GoogleMapController _controller;
final LatLng _center = const LatLng(25.058293339123782, 121.53747830964339);
void _onMapCreated(GoogleMapController controller) {
_controller = controller;
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.green[700],
),
home: Scaffold(
appBar: AppBar(
title: const Text('Google Maps 範例'),
elevation: 2,
),
body: GoogleMap(
onMapCreated: _onMapCreated,
initialCameraPosition: CameraPosition(
target: _center,
zoom: 11.0,
),
),
),
);
}
}
當然我們可以直接固定座標 Marker
,但實務上更多情況會是需要從 API 取得座標資訊。這裡我們嘗試使用 Google API 提供的辦公室地點 API ,取得 JSON 格式的資料,並將這些辦公室座標放到地圖上。
因此我們需要額外安裝其他相依套件 http
用來發送 HTTP 請求,json_serializable
和 json_annotation
用來宣告對應 JSON 的物件結構,build_runner
用來產生支援的程式碼。
$ flutter pub add http json_annotation json_serializable dev:build_runner
你可能注意到從 API 取得的 JSON 資料具有規律的結構。如果可以自動產生一些程式碼,進而把這些資料變成開發時可以直接使用的物件,那就太方便了。
在 lib/src
目錄建立 locations.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:json_annotation/json_annotation.dart';
part 'locations.g.dart';
@JsonSerializable()
class LatLng {
LatLng({
required this.lat,
required this.lng,
});
factory LatLng.fromJson(Map<String, dynamic> json) => _$LatLngFromJson(json);
Map<String, dynamic> toJson() => _$LatLngToJson(this);
final double lat;
final double lng;
}
@JsonSerializable()
class Region {
Region({
required this.coords,
required this.id,
required this.name,
required this.zoom,
});
factory Region.fromJson(Map<String, dynamic> json) => _$RegionFromJson(json);
Map<String, dynamic> toJson() => _$RegionToJson(this);
final LatLng coords;
final String id;
final String name;
final double zoom;
}
@JsonSerializable()
class Office {
Office({
required this.address,
required this.id,
required this.image,
required this.lat,
required this.lng,
required this.name,
required this.phone,
required this.region,
});
factory Office.fromJson(Map<String, dynamic> json) => _$OfficeFromJson(json);
Map<String, dynamic> toJson() => _$OfficeToJson(this);
final String address;
final String id;
final String image;
final double lat;
final double lng;
final String name;
final String phone;
final String region;
}
@JsonSerializable()
class Locations {
Locations({
required this.offices,
required this.regions,
});
factory Locations.fromJson(Map<String, dynamic> json) =>
_$LocationsFromJson(json);
Map<String, dynamic> toJson() => _$LocationsToJson(this);
final List<Office> offices;
final List<Region> regions;
}
Future<Locations> getGoogleOffices() async {
const googleLocationsURL = 'https://about.google/static/data/locations.json';
// Retrieve the locations of Google offices
try {
final response = await http.get(Uri.parse(googleLocationsURL));
if (response.statusCode == 200) {
return Locations.fromJson(
json.decode(response.body) as Map<String, dynamic>);
}
} catch (e) {
if (kDebugMode) {
print(e);
}
}
// Fallback for when the above HTTP request fails.
return Locations.fromJson(
json.decode(
await rootBundle.loadString('assets/locations.json'),
) as Map<String, dynamic>,
);
}
如果你使用 IDE 或者 VSCode 搭配官方套件,那麼應該會看到檔案出現一些紅色波浪線的警告,原因是目前引用了一個不存在的檔案 locations.g.dart
,要處理這個問題我們須執行:
$ dart run build_runner build --delete-conflicting-outputs
完成執行後,警告應該會消失。接著我們要為 getGoogleOffices
函式提供備用的 locationis.json
檔案。這是因為函式在載入資料的時候沒有包含 CORS 所需的資訊,因此網頁版本是無法載入的。Android 和 iOS 應用則不需要這個資訊。下載 JSON
$ mkdir assets
$ cd assets
$ curl -o locations.json https://about.google/static/data/locations.json
接著在 pubspec.yaml
加入資源檔設定
flutter:
uses-material-design: true
assets:
- assets/locations.json
最後修改我們的 lib/main.dart
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'src/locations.dart' as locations;
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final Map<String, Marker> _markers = {};
Future<void> _onMapCreated(GoogleMapController controller) async {
final googleOffices = await locations.getGoogleOffices();
setState(() {
_markers.clear();
for (final office in googleOffices.offices) {
final marker = Marker(
markerId: MarkerId(office.name),
position: LatLng(office.lat, office.lng),
infoWindow: InfoWindow(
title: office.name,
snippet: office.address,
),
);
_markers[office.name] = marker;
}
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.green[700],
),
home: Scaffold(
appBar: AppBar(
title: const Text('Google Office Locations'),
elevation: 2,
),
body: GoogleMap(
onMapCreated: _onMapCreated,
initialCameraPosition: const CameraPosition(
target: LatLng(0, 0),
zoom: 2,
),
markers: _markers.values.toSet(),
),
),
);
}
}