--- tags: Flutter --- # Flutter 結合 Google Maps 建構收藏景點 APP ## 簡述 建立一個可以收藏私房地點的應用程式 可以使用手機本身的相機功能進行實地拍攝,或從相簿中選取相片當成景點的主要圖片 再結合 Google Maps 功能選取地點,或通過 Location 套件獲取用戶當前位置資訊 並透過 path 、 path_provider 與 sqflite 將所有數據資料存儲到手機的檔案系統中 ## 使用 `sqflite` 套件將資料存儲在手機本身的空間裡 首先需要安裝 `sqflite` 套件 接著在專案中新增一個 `helper` 資料夾,於裡面建立 `db_helper.dart` 檔案 引入 `sqflite` 與 `path` 套件,並建立存儲資料相關的方法: ```dart= import 'package:sqflite/sqflite.dart' as sql; // 引入 sqflite import 'package:path/path.dart' as path; // 引入 path import 'package:sqflite/sqlite_api.dart'; // 引入 sqlite_api class DBHelper { static Future<Database> database() async { // 建立方法 database final dbPath = await sql.getDatabasesPath(); // 獲取資料庫存儲的路徑 return sql.openDatabase( // 開啟資料庫(如果資料庫不存在就會建立一個資料庫) path.join(dbPath, 'places.db'), // 要開啟的路徑(這邊需要用 path 提供的 join 方法,因為要開啟的不是資料夾而是資料夾中的文件,places.db 就是要開啟的文件名稱) onCreate: (db, version) { // 傳入建立資料庫的方法,假設資料庫不存在就會執行此方法建立一個資料庫 return db.execute( // 使用 db.execute 執行 sql 語句 'CREATE TABLE user_places(id TEXT PRIMARY KEY, title TEXT, description TEXT, image TEXT, loc_lat REAL, loc_lng REAL, address TEXT)'); }, version: 1, // 設置當前版本,假設有更動數據結構則該版本號應該跟著變動 ); } static Future<void> insert(String table, Map<String, Object> data) async { // 建立方法 insert 以插入數據資料 final db = await DBHelper.database(); // 獲取資料庫 db.insert(table, data, conflictAlgorithm: ConflictAlgorithm.replace); // 使用 db.insert 往資料庫中插入數據(第三個參數來自 sqlite_api,當我們往已存在的 id 中插入數據則通過 ConflictAlgorithm.replace 告知資料庫覆蓋原本的數據) } static Future<List<Map<String, dynamic>>> getTableData(String table) async { // 建立方法 getTableData 以獲取數據資料 final db = await DBHelper.database(); return db.query(table); // 使用 db.query 查詢整個 TABLE 的資料 } } ``` >在 `db.insert` 與 `db.query` 中傳入的 `table` 應為 CREATE TABLE 時設置的 TABLE 名稱(即 `user_places`) 上方 sql 語句解析: ``` CREATE TABLE user_places( // 建立 TABLE,TABLE 名稱為 user_places id TEXT PRIMARY KEY, // id 為 TEXT PRIMARY KEY title TEXT, // title 為 TEXT description TEXT, // description 為 TEXT image TEXT, // image 為 TEXT loc_lat REAL, // loc_lat 為 REAL loc_lng REAL, // loc_lng 為 REAL address TEXT // address 為 TEXT ) ``` ## 使用手機的相機與照片庫 使用 `image_picker` 套件開啟相機拍照或相簿選照片, 並通過 `path_provider` 與 `path` 套件獲取圖片路徑,將照片存到檔案系統中 ### 安裝與設置 `image_picker` 套件 安裝 `image_picker` 套件以通過相機或照片庫獲取圖片, 安裝後請先根據說明設置好 `/ios/Runner/Info.plist` 檔案內容([參考](https://pub.dev/packages/image_picker#ios)) 主要設置內容如下: ```plist= <key>NSCameraUsageDescription</key> <string>Places App need to use Camera.</string> // 輸入為什麼要存取相機 <key>NSPhotoLibraryUsageDescription</key> <string>Places App need to use PhotoLibrary.</string> // 輸入為什麼要存取照片庫 ``` >通常都是一個 key配一個 value,在新增上方四行時, >需注意不要將其放置在某組 key 與 value 的中間。 ### 安裝 `path_provider` 與 `path` 套件以獲取圖片路徑 直接在終端機中輸入 `flutter pub add path` 以及 `flutter pub add path_provider` 即可安裝 接著於要使用的 .dart 檔案中引入: ```dard= import 'dart:io'; // 使用 File 類型須引入 dart:io import 'package:image_picker/image_picker.dart'; // 使用 ImagePicker import 'package:path/path.dart' as path; // 用於各種 path 相關的方法 import 'package:path_provider/path_provider.dart' as syspaths; // 獲取常用的檔案系統路徑(這邊用於獲取應用程式的文檔目錄) ``` 創建一個獲取圖片的函數: ```dart= Future<void> _takePicture([isCamera = false]) async { // 創建一個 Future 類型的函數 final imageFile = await ImagePicker().pickImage( // picker.pickImage 為 Future 類型,需加上 await source: ImageSource.camera, // 開啟相機拍攝照片 maxWidth: 500, // 設置照片最大寬度 ); if (imageFile == null) { // 假設點進去又反悔,沒拍照,則 return return; } final appDir = await syspaths.getApplicationDocumentsDirectory(); // 獲取應用程式的文檔目錄路徑 final fileName = path.basename(imageFile.path); // 獲取圖片的名稱 final savedImage = await _storedImage.copy('${appDir.path}/$fileName'); // 將圖片拷貝到應用程式的文檔目錄中 } ``` 並於用來當拍攝照片的按鈕小部件中的 onTap() 設置為剛才創建一個獲取圖片的函數 >假設不是拍攝照片,而是從照片庫中選擇照片, >則將 `ImageSource.camera` 改為 `ImageSource.gallery` 即可 ## 使用手機的地圖 ### 安裝與設置 `location` 套件 安裝 `location` 套件以通過地圖獲取經緯度 安裝後請先根據說明設置好 `/ios/Runner/Info.plist` 檔案內容([參考](https://pub.dev/packages/location#ios)) 主要設置內容如下: ```plist= <key>NSLocationWhenInUseUsageDescription<key> <string>Places App need to use Location.</string> // 輸入為什麼要存取位置 <key>NSLocationAlwaysAndWhenInUseUsageDescription<key> <string>Places App need to use Location.</string> // 輸入為什麼要存取位置 ``` >通常都是一個 key配一個 value,在新增上方四行時, >需注意不要將其放置在某組 key 與 value 的中間。 接著針對 Android 也須設置好 `/android/app/src/main/AndroidManifest.xml` 檔案內容([參考](https://pub.dev/packages/location#android)) 主要設置內容如下: ```xml= <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> ``` >`<uses-permission>` 標籤必須放在 `<manifest>` 標籤裡面,且位在 `<application>` 標籤之上 ### 使用 `Location` 套件獲取用戶當前位置 建立一個函數 `_getUserLocation`,通過 `Location().getLocation()` 獲取用戶當前位置: ```javascript= Future<void> _getUserLocation() async { try { final locData = await Location().getLocation(); } catch (err) { // 處理錯誤 ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('無法取得當前位置。')), ); return; } } ``` ### 安裝與設置 `google_maps_flutter` 套件 安裝 `google_maps_flutter` 套件以使用 Google Map 這邊需要先獲取 GOOGLE MAPS API KEY: 1. 請進入[此官網](https://mapsplatform.google.com/)點擊 `Get started` 2. 登入你的 google 帳號,按照提示建立好信用卡資料開啟免費試用 完成後會看到 `API 金鑰`,請先將其複製到專案中存放 接著回到專案中,按照說明文件設置好 `android/app/build.gradle` 檔案內容([參考](https://pub.dev/packages/google_maps_flutter#android)) 並在 `android/app/src/main/AndroidManifest.xml` 檔案內容中放置你的 API 金鑰 ```xml= <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR KEY HERE"/> ``` >`<meta-data>` 標籤必須放在 `<application>` 標籤裡面 針對 IOS 也按照說明文件設置好 `ios/Runner/AppDelegate.m` 或 `ios/Runner/AppDelegate.swift` 檔案 >這邊可以看自己的專案中有無 `.m` 檔,如無就設置 `.swift` 檔即可 ## 使用 `.env` 保存 `API KEY` 首先安裝 `flutter_config` 套件 接著在專案的根目錄中新增 `.env` 檔案 將 `GOOGLE_API_KEY` 等機密資訊存放到 `.env` 檔案中(EX: `GOOGLE_API_KEY=balabalabalabala-balabala-balabala`) ### 在 .dart 中取用 .env 參數 首先需於 main.dart 檔案中進行配置: ```dart= // 引入 flutter_config 套件 import 'package:flutter_config/flutter_config.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await FlutterConfig.loadEnvVariables(); runApp(MyApp()); } ``` 接著在任一要取用 .env 的 .dart 檔案中: ```dart= // 引入 flutter_config 套件 import 'package:flutter_config/flutter_config.dart'; // 使用 FlutterConfig.get(這裡傳入要取用的 env 名) final googleApiKey = FlutterConfig.get('GOOGLE_API_KEY'); ``` ### 在 .xml 檔案中取用 .env 參數 以 Android 設置為例: 1. 開啟 `android/app/build.gradle` 檔案 2. 找到: `apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"` 於下方新增一行: `apply from: project(':flutter_config').projectDir.getPath() + "/dotenv.gradle"` 3. 拷貝 `defaultConfig` 中的 `applicationId` 內容 4. 在 `/android/app` 中新增檔案 `proguard-rules.pro` 裡面放入: `-keep 拷貝的applicationId內容.BuildConfig { *; }` (EX:`-keep class com.example.places_app.BuildConfig { *; }`) 5. 在: `android/app/src/main/AndroidManifest.xml` 檔案中 將之前新增的 `meta-data` 標籤中的 `API KEY` 改寫為: `<meta-data android:value="@string/GOOGLE_API_KEY" ... /> ` >以上參考 [Android 設置說明](https://github.com/ByneappLLC/flutter_config/blob/master/doc/ANDROID.md) >(感謝估狗大神) ### 使用 `Maps Static API` 獲取地圖快照 這邊需要使用 `Maps Static API` 生成地圖預覽(可參考[官方說明文件](https://developers.google.com/maps/documentation/maps-static/start?hl=zh-tw)) 首先建立一個函數 `locationPreviewImg` ,可傳入參數 `lat` 與 `long`(經緯度) 接著從官方說明文件中拷貝一個範例進行修改,作為函數要回傳的值: ```htmlembedded= static String locationPreviewImg({double long, double lat}) { return 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$long&scale=2&zoom=15&size=400x300&key=$googleApiKey&markers=color:red%7Clabel:%7C$lat%2C$long'; } ``` ### 使用 `Geocoding API` 獲取完整地址 這邊需要使用 `Geocoding API` 的『反向地理編碼』利用經緯度取得完整地址(可參考[官方說明文件](https://developers.google.com/maps/documentation/geocoding/start?hl=zh-tw#reverse)) 首先安裝 `http` 套件以用來發送 HTTP Request 接著建立一個函數 `getAddressByLatLng` ,可傳入參數 `lat` 與 `long`(經緯度): ```javascript= static Future<String> getAddressByLatLng(double lat, double lng) async { final url = Uri.https('maps.googleapis.com', '/maps/api/geocode/json', { "latlng": "$lat,$lng", // 傳入經緯度 "key": googleApiKey, // 傳入 API KEY "language": "zh-TW", // 設置語系 }); final res = await http.get(url); // 發送請求 return json.decode(res.body)['results'][0]['formatted_address']; // 獲取完整地址 } ``` ### 使用 `google_maps_flutter` 套件開啟地圖 `google_maps_flutter` 套件提供了 `GoogleMap` 小部件用以顯示地圖 使用方式如下: ```dart= Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('地圖'), actions: [ if (widget.isSelecting && _markerLocation != null) IconButton( onPressed: () { Navigator.of(context).pop(_markerLocation); }, icon: const Icon(Icons.check), ) ], ), body: GoogleMap( // 使用 GoogleMap 小部件 initialCameraPosition: CameraPosition( // 設置地圖預設位置 target: LatLng(widget.initLat, widget.initLng), zoom: 16, // 設置縮放程度 ), onTap: widget.isSelecting ? _selectedLocation : null, // 點擊地圖上某個位置後要執行的動作 markers: (_markerLocation == null && widget.isSelecting) // 在地圖上標記一個位置 ? {} // 如果沒選取位置則回傳空物件 {} 給 markers : { Marker( markerId: const MarkerId('m1'), // 設置 ID position: _markerLocation ?? LatLng(widget.initLat, widget.initLng), ), }, ), ); } ``` ## 使用真實設備進行測試 1. 將 iPhone 使用 USB 的方式連結電腦,並依序點擊信任此裝置、輸入手機密碼等等 2. 進入 iPhone 的“設定>隱私權與安全性>開發者模式”將開發者模式打開,此時手機會要求你重新開機 3. 開啟電腦的 Xcode 應用程式,點擊 “Open a project or file” ,選擇想開啟的 Flutter 專案底下的 ios 資料夾 4. 在 Xcode 上方選單列中的 Device 中選擇你的 iPhone 5. 在 Xcode 左側選單中點選 Runner ,檢查 Signing 是否有選擇 Team (沒有的話就用自己的 apple ID 申請一個) 6. 在 Xcode 中點擊左上角的播放鍵進行 build a. 假設出現 “build failed” ,請開啟終端機 cd 到想開啟的 Flutter 專案目錄中,執行 `flutter clean` 再執行 `flutter build ios` b. 假設出現錯誤 “Error (Xcode): No profiles for 'com.example.xxxxApp' were found: Xcode couldn't find any iOS App Development” 則將 Team 下方的 Bundle Identifier 更新成唯一的值(比如加上自己的名字之類的),再重新執行一次 `flutter build ios` 6. 完成後手機會出現 Flutter APP 的 icon,但開啟失敗,並顯示提示 “開發者不受裝置信任的通知”,此時請到手機的 “設定>一般>VPN與裝置管理” 將自己的開發者帳號設為信任 7. 再次回到 Xcode 中點擊一次播放鍵,即可成功開啟 APP 進行實測