iT邦幫忙

2024 iThome 鐵人賽

DAY 24
1

實務上許多應用會需要使用到地圖的功能,使用 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 平台

要在 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 作為最低支援的版本。這個最低版本會隨著時間逐漸提高。

設定 Android 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 這裡我們就不做任何變更。但確實還有一些地方是可能變更這些設定的。

  1. app/build.gradle
  2. android/local.properties
  3. pubspec.yaml

確認版本無誤之後,接著我們需要設定授權。

加入 Google Map

要在 Flutter 應用程式中使用 Google Map 我們須先到 Google Maps Platform 設定 API 專案

  1. 建立專案

  2. 啟用 Google Maps API 到 Maps SDK for Android 和 Maps SDK for iOS 啟動 API

  3. 「API 和服務」> 「憑證」> 分別為 Android 和 iOS「建立 API 金鑰」

  4. 取得 Android 指紋

    # 使用預設密碼 “android”
    $ keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
    
  5. iOS 的話限制 Bundle ID 即可。

取得下面所需的 API 金鑰。有了 API 金鑰就可以進行下面的設定。

跟前面 Firebase 不同的是,目前 Google Map 還沒有提供類似 FlutterFire CLI 這類工具,我們還是得自己設定。

為 Android 應用程式加入 API 金鑰

取得 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 應用程式加入 API 金鑰

要為 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_serializablejson_annotation 用來宣告對應 JSON 的物件結構,build_runner 用來產生支援的程式碼。

$ flutter pub add http json_annotation json_serializable dev:build_runner

使用程式碼產生器解析 JSON

你可能注意到從 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(),
        ),
      ),
    );
  }
}

參考資源


上一篇
Day 23 處理 API 請求
下一篇
Day 25 推播通知
系列文
Flutter 開發實戰 - 30 天逃離新手村38
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言