終於要開始實作功能了,這個 Project 最重要也最基礎的功能就是定位,如果無法獲取定位或者定位不穩定,那後面的路線紀錄、距離計算、速度分析與背景定位都無法繼續實作。
今天只會先完成在打開 App 的情況下持續取得位置,並讓地圖跟著使用者移動。
React Native 本身沒有提供完整的定位追蹤流程,因此通常會透過第三方定位套件,為了避免定位邏輯散落在各個 UI 中,我會建立一個 LocationService,統一負責:
如果 UI 直接依賴某個定位套件,之後要更換套件、加入背景定位,畫面邏輯也會一起受到影響。
目前簡易的定位流程如下:
透過 LocationService 分離邏輯和UI後,畫面只需要知道兩件事:
至於底層使用哪個套件、Android 與 iOS 如何取得位置,都由 Service 負責。
介面大致如下:
export interface LocationService {
startTracking(
onLocation: (location: LocationPoint) => void,
onError: (error: LocationError) => void,
): Promise<void>;
stopTracking(): void;
}
畫面只需要呼叫這個介面:
await locationService.startTracking(
location => {
updateCurrentLocation(location);
},
error => {
handleLocationError(error);
},
);
這樣之後更換底層定位實作時,不需要跟著修改UI。
實作定位流程時,很容易把「系統權限」和「定位狀態」混在一起,但這兩者其實是不同的狀態。
系統權限代表 App 是否有資格讀取位置,Android 和 iOS 都可以大概抽象成:
Not Determined
↓
Requesting
↓
Granted / Denied / Blocked
權限一旦允許,不會因為停止定位就回到未授權狀態。
定位狀態則代表 App 目前是否正在取得位置,由APP管理
例如:
Idle
│
▼
Locating
│
▼
Tracking
│
▼
Stopping
│
▼
Idle
每次開始與停止定位時,這段流程都會重新執行。
完整流程大致如下:
[ 初始狀態 / Idle ]
│
▼
Not_Determined
│
▼
Requesting_Permission ──( 使用者拒絕 )──> [ Permission_Denied ]
│ │
( 使用者同意 ) ▼
│ ( 提示並引導至設定頁 )
▼
Granted
│
▼
Locating ───────( GPS 訊號失敗 )─────> [ Location_Error ]
│
▼
Tracking ───────( 訊號中斷/關閉 )───> [ Location_Error ]
│
▼
Stopping
│
▼
[ 回到 Idle ]
如果使用者只是第一次拒絕權限,App 還可以再次請求。
但如果權限狀態已經變成 blocked,系統通常不會再顯示權限視窗,只能引導使用者前往設定頁修改。
if (permissionStatus === 'blocked') {
await Linking.openSettings();
}
因此,畫面不能只顯示「定位失敗」,還需要讓使用者知道要去哪裡重新開啟權限。
Android 需要使用定位功能需要宣告以下權限:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
ACCESS_FINE_LOCATION 可以取得較精確的位置。
ACCESS_COARSE_LOCATION 則是較粗略的位置權限。新版 Android 允許使用者只提供大概位置,因此兩個權限都需要處理。
背景定位需要額外的權限與 Foreground Service,這部分明天再說。
iOS 需要在 Info.plist 中說明為什麼 App 要取得位置:
<key>NSLocationWhenInUseUsageDescription</key>
<string>散步期間需要使用您的位置來顯示目前位置。</string>
NSLocationWhenInUseUsageDescription 描述文字必須明確說明「為什麼要用」以及「對使用者有什麼好處」,否則上架時容易被退回。
目前使用的是 When In Use 權限,只允許 App 位於前景時取得位置。
等到實作背景定位時,會需要加入 Always Authorization 與 Background Modes。
地圖使用 Google Maps,因此 Android 與 iOS 都需要在 Google Cloud Console 啟用對應的 SDK:
兩個平台的 API Key 分開建立,並限制各自允許使用的 App。
Android Key 可以限制:
iOS Key 可以限制:
這些設定在我之前的鐵人賽有提到過,可以看一下,這裡只處理 Key 如何放進專案。
Android 的 Google Maps API Key 放在 apps/mobile/android/local.properties
例如:
GOOGLE_MAPS_API_KEY=xxxxxxxxxxxxxxx
接著在 android/app/build.gradle 讀取 local.properties 內的值:
defaultConfig {
applicationId "com.tracewalk"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
manifestPlaceholders.googleMapsApiKey = localProperties.getProperty("GOOGLE_MAPS_API_KEY", "")
}
最後在 AndroidManifest.xml 中引用:
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${googleMapsApiKey}" />
</application>
API Key 沒有直接寫進 AndroidManifest.xml,是為了避免將不同環境的 Key 寫死在專案裡面。
iOS 透過 Xcode 建立 GOOGLE_MAPS_API_KEY User-Defined Setting,避免直接將 Key 寫死在程式碼內。
使用 Xcode 開啟 xcworkspace,選擇左側的 TARGETS,切換到 Build Settings,點擊左上角的 +,選擇 Add User-Defined Setting,並建立一個名稱為 GOOGLE_MAPS_API_KEY 的設定。

接著在 iOS 初始化 Google Maps SDK 時讀取這個值。
Debug 與 Release 可以使用不同的 key,只要在左側展開GOOGLE_MAPS_API_KEY就能看到 Debug 和 Release:

成功取得位置後,就可以在地圖上顯示目前位置。
如果使用 react-native-maps,可以開啟顯示使用者位置:
<MapView
ref={mapRef}
style={styles.map}
showsUserLocation
showsMyLocationButton={false}
/>
也可以使用 Marker 顯示位置:
{currentLocation && (
<Marker
coordinate={{
latitude: currentLocation.latitude,
longitude: currentLocation.longitude,
}}
/>
)}

定位功能初始後地圖中心會保持在當前位置,但隨著散步一段時間後,可能會走出當前地圖可視的範圍,地圖卻不會自動重新調整地圖中心。
所以我採取了以下作法:
mapRef.current?.animateCamera(
{
center: {
latitude: location.latitude,
longitude: location.longitude,
},
},
{
duration: 500,
},
);
這樣既能讓地圖持續跟著使用者,也不會阻止使用者自行操作地圖。
最基本的定位以及地圖顯示功能已經完成,明天會開始說明背景定位(Background Location)的實作方法。