Flutter 基础体系 · 第 80/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 地图与定位:权限、坐标、后台定位、隐私和耗电
地图和定位经常被当成一个功能,实际上它们解决的是两个不同问题:
- 定位:由操作系统、卫星、Wi-Fi、基站、蓝牙等来源估计设备位置。
- 地图:把坐标、道路、建筑、标注等地理数据渲染成可视内容。
Flutter 只负责应用层 UI、状态和业务逻辑。真正的定位权限、地图渲染、后台运行和系统设置,仍然由 Android、iOS、桌面操作系统、浏览器以及地图服务商共同决定。
因此,一个完整的定位功能至少包含以下数据流:
flowchart LR
A[用户意图] --> B[应用说明用途]
B --> C[系统权限请求]
C --> D{权限与系统定位服务}
D -->|允许| E[操作系统定位源]
D -->|拒绝或关闭| F[错误状态与设置引导]
E --> G[原始定位数据]
G --> H[坐标校验与业务转换]
H --> I[Flutter 状态层]
I --> J[地图相机与标记]
I --> K[业务上报或本地存储]
K --> L[隐私、保留和删除策略]
后文会分别解释这些组件、状态、数据格式和失败路径。
1. 先区分地图、定位和地理编码
1.1 定位不是“从地址得到坐标”
定位(location)是从设备传感器和网络信息估计当前位置,例如:
纬度:31.2304
经度:121.4737
精度:12 米
时间:2025-01-01T10:00:00Z
地理编码(geocoding)则是把地址转换为坐标,例如:
“上海市人民广场” -> (31.2304, 121.4737)
反向地理编码(reverse geocoding)是反过来:
(31.2304, 121.4737) -> “上海市黄浦区……”
定位插件通常只能取得设备位置,不能保证提供地理编码、路线规划、地图瓦片或地点搜索。这些能力往往来自独立的地图服务商,且具有配额、授权和隐私条款。
1.2 地图不是定位数据的来源
地图 SDK 或瓦片服务负责:
- 加载和绘制地图图层;
- 根据经纬度移动相机;
- 绘制 Marker、Polyline、Polygon;
- 进行地点搜索或路线规划。
地图本身通常不会自动给 Flutter 提供可靠的设备位置。应用仍需通过系统定位 API 获取位置,再把位置交给地图组件。
这一区分会直接影响故障诊断:
- 地图空白,不一定是定位失败,可能是瓦片服务、网络或 API Key 问题;
- 蓝点不移动,不一定是地图问题,可能是权限、定位流或坐标系问题;
- 标记偏移,不一定是 GPS 精度问题,也可能是坐标基准不一致。
2. 位置数据的结构:不要只保存一个经纬度
一个可用的位置对象至少应包含:
class LocationSample {
const LocationSample({
required this.latitude,
required this.longitude,
required this.timestamp,
this.accuracyMeters,
this.altitudeMeters,
this.speedMetersPerSecond,
this.headingDegrees,
});
final double latitude;
final double longitude;
final DateTime timestamp;
final double? accuracyMeters;
final double? altitudeMeters;
final double? speedMetersPerSecond;
final double? headingDegrees;
}
这些字段含义不同:
latitude:纬度,南北方向,范围通常为[-90, 90];longitude:经度,东西方向,范围通常为[-180, 180];timestamp:采样时间,应明确是 UTC 还是本地时间;accuracyMeters:定位误差半径的估计值,不是“当前一定误差这么大”;altitudeMeters:海拔,通常比平面位置更不稳定;speedMetersPerSecond:速度估计,静止或低速时可能抖动;headingDegrees:运动方向,通常以真北为 0 度顺时针增加,静止时没有可靠意义。
accuracyMeters = 10 的含义是:系统估计真实位置大概率落在当前位置周围约 10 米的区域内。它不是精度保证,也不是每个方向都相同的圆形误差。
应用不应把 accuracyMeters 当作筛选坐标的唯一标准。例如,室内环境下系统可能返回一个时间很新的位置,但精度为几百米;这时“新”不等于“有用”。
3. 坐标系:经纬度相同,不代表位置相同
3.1 经纬度的单位和顺序
地理坐标通常写作:
(latitude, longitude)
也就是:
(纬度, 经度)
但许多 GIS、GeoJSON 和数据库接口采用:
(longitude, latitude)
例如:
{
"type": "Point",
"coordinates": [121.4737, 31.2304]
}
这里的顺序是:
[经度, 纬度]
如果把它错误地当成 [纬度, 经度],地图通常会把点绘制到完全不同的位置。由于两个值都是合法的小数,这类错误未必立刻报异常,只会表现为“点跑到了海里”或“地图跳到了别的国家”。
建议在代码中使用命名字段,而不是裸的 List<double>:
class Wgs84Point {
const Wgs84Point(this.latitude, this.longitude);
final double latitude;
final double longitude;
}
3.2 WGS84、GCJ-02 和 BD-09
常见坐标基准包括:
- WGS84:全球定位系统和许多国际地理数据常用的地理坐标基准;
- GCJ-02:中国大陆部分地图服务使用的加密偏移坐标;
- BD-09:在某些服务中基于 GCJ-02 再次转换的坐标。
同一个物理位置,在这些坐标系下的数值可能不同。典型错误是:
- 手机系统定位返回一套坐标;
- 地图底图使用另一套坐标;
- 应用不做服务商要求的转换;
- 结果是 Marker 与道路、建筑发生偏移。
转换不能随意“加一个固定经纬度”。偏移量随位置变化,且应依据地图服务商的协议和法律要求处理。工程上应先确认:
- 定位插件返回的坐标基准;
- 地图 SDK 或瓦片服务接受的坐标基准;
- 搜索、路线和逆地理编码 API 使用的坐标基准;
- 服务商是否已经在 SDK 内部完成转换。
如果所有数据都来自同一个地图生态,通常应使用该生态规定的坐标输入,不要自行重复转换。重复转换会产生二次偏移。
3.3 用 Haversine 公式计算两点距离
如果只需要计算地球表面两点的近似直线距离,可以使用 Haversine 公式。
设两点纬度、经度均以弧度表示:
- 点 1:
φ1, λ1 - 点 2:
φ2, λ2 - 地球半径:
R,常用近似值为6,371,000米
计算步骤:
Δφ = φ2 - φ1
Δλ = λ2 - λ1
a = sin²(Δφ / 2)
+ cos(φ1) × cos(φ2) × sin²(Δλ / 2)
c = 2 × atan2(√a, √(1 - a))
d = R × c
直觉上,a 衡量两点在球面上的角距离,c 把这个量转换成球心角,最后乘以地球半径得到米数。
Dart 实现如下:
import 'dart:math' as math;
double haversineMeters({
required double latitude1,
required double longitude1,
required double latitude2,
required double longitude2,
}) {
const earthRadius = 6371000.0;
double radians(double degrees) => degrees * math.pi / 180.0;
final phi1 = radians(latitude1);
final phi2 = radians(latitude2);
final deltaPhi = radians(latitude2 - latitude1);
final deltaLambda = radians(longitude2 - longitude1);
final sinLat = math.sin(deltaPhi / 2);
final sinLon = math.sin(deltaLambda / 2);
final a = sinLat * sinLat +
math.cos(phi1) * math.cos(phi2) * sinLon * sinLon;
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return earthRadius * c;
}
算例:
final distance = haversineMeters(
latitude1: 31.2304,
longitude1: 121.4737,
latitude2: 31.2244,
longitude2: 121.4787,
);
print(distance);
两点相差约:
- 纬度
0.006°,南北约 667 米; - 经度
0.005°,在北纬 31° 附近东西约 475 米; - 直线距离约为:
sqrt(667² + 475²) ≈ 819 米
这不是驾车距离,也不考虑道路、桥梁、单行道和禁行区域。路线距离必须由路线规划服务计算。
4. 权限:用户同意不等于功能一定可用
定位权限是一个状态系统,不是一个简单的布尔值。至少要区分:
- 应用是否声明了权限;
- 用户是否授予权限;
- 系统定位服务是否打开;
- 用户是否只允许大致位置;
- 当前应用生命周期是否满足后台定位条件;
- 设备、浏览器或桌面平台是否支持所需能力。
可以把前台定位的基本状态抽象为:
stateDiagram-v2
[*] --> 未检查
未检查 --> 请求中: 用户触发定位功能
请求中 --> 已授权: 用户允许
请求中 --> 已拒绝: 用户拒绝
请求中 --> 永久拒绝: 系统不再弹窗
已授权 --> 服务关闭: 系统定位服务关闭
已授权 --> 可定位: 服务开启
已拒绝 --> 请求中: 应用再次解释并请求
永久拒绝 --> 设置页: 用户主动打开系统设置
服务关闭 --> 设置页: 引导开启定位服务
可定位 --> 定位中
定位中 --> 暂时失败: 超时、无信号或数据无效
暂时失败 --> 定位中: 重试
4.1 Android 配置
Android 项目通常需要在:
android/app/src/main/AndroidManifest.xml
声明前台定位权限:
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
如果需要后台定位,还涉及:
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
如果后台定位通过前台服务持续运行,较新的 Android 版本还需要与前台服务类型相关的声明,例如定位服务类型和对应权限。具体声明必须和目标 SDK、插件实现以及 Android 版本匹配,不能只复制旧项目配置。
Android 的重要差异包括:
COARSE_LOCATION表示大致位置;FINE_LOCATION表示更精确的位置;- Android 12 及以上,用户可以在系统权限对话框中选择大致位置,即使应用请求了精确位置;
- Android 10 引入了更严格的后台位置授权;
- Android 11 及以上,后台位置通常不能和前台位置在同一个普通弹窗中一次取得,需要引导用户进入系统设置;
- Android 8 及以上对后台执行有更强限制,持续定位通常需要前台服务,并显示系统通知。
因此,应用不能仅通过一次 requestPermission() 就推断“以后一定能后台定位”。
4.2 iOS 配置
iOS 项目需要在:
ios/Runner/Info.plist
声明用途说明。常见前台定位用途键包括:
<key>NSLocationWhenInUseUsageDescription</key>
<string>用于在地图上显示你当前的位置。</string>
如果确实需要“始终允许”或后台定位,还需要按照 iOS 当前权限模型配置相应的 Always 使用说明,并在 Xcode 的 Signing & Capabilities 中启用 Location Updates 能力。
iOS 的特点是:
- 系统权限通常区分“使用 App 期间”和“始终允许”;
- 用户可以只允许精确位置,也可以关闭精确位置;
- 后台定位必须有真实、可解释的产品用途;
- 后台更新可能显示系统位置指示器;
- 用户在系统设置中修改权限后,应用不会自动收到一个可以替代全部检查的业务状态,应用恢复前台时仍应重新检查;
- 应用被用户强制结束后,后台定位能否恢复不能按普通 Dart Stream 的语义假设。
用途说明不是形式主义。系统会把它展示给用户,空泛、误导或与实际功能不符的描述会降低授权率,也可能导致审核问题。
5. Flutter 中实现前台定位
Flutter 本身提供跨平台 UI 和平台抽象,但没有把所有平台定位能力统一成一个内置插件。工程中常用第三方插件,例如 geolocator;地图显示则可使用平台地图 SDK 或 Flutter 地图组件,例如 google_maps_flutter、flutter_map 等。
这些包不是 Flutter 官方 API,版本和平台能力应以各自当前文档为准。下面用 geolocator 展示前台定位的核心流程。
5.1 添加依赖
flutter pub add geolocator
执行结果应包括:
Resolving dependencies...
Changed ... dependencies!
前置条件是项目可以正常访问 pub 仓库,并且插件版本与当前 Flutter、Android Gradle Plugin、iOS deployment target 兼容。
5.2 请求权限并取得当前位置
import 'package:geolocator/geolocator.dart';
class LocationFailure implements Exception {
const LocationFailure(this.message);
final String message;
@override
String toString() => message;
}
Future<Position> getCurrentPositionSafely() async {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
throw const LocationFailure('系统定位服务未开启');
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied) {
throw const LocationFailure('用户拒绝了定位权限');
}
if (permission == LocationPermission.deniedForever) {
throw const LocationFailure('定位权限已永久拒绝,需要到系统设置中开启');
}
return Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 20,
),
);
}
每一步的因果关系是:
isLocationServiceEnabled()检查系统总开关。权限允许但总开关关闭,仍不能定位;checkPermission()读取当前授权状态,避免每次进入页面都直接弹窗;- 只有用户触发明确功能时才请求权限,系统体验和授权率通常更合理;
deniedForever表示再次调用普通请求通常不会出现有用的系统授权弹窗;getCurrentPosition()才真正等待一次定位结果;accuracy是对系统定位质量的偏好,不是硬性保证;distanceFilter表示位置变化达到一定距离后才更倾向于产生新结果,但不同平台和实现可能有额外策略。
在真实页面中,应把异常转换成可操作的 UI:
Future<void> locate() async {
try {
final position = await getCurrentPositionSafely();
// 更新状态:
// latitude = position.latitude;
// longitude = position.longitude;
// accuracy = position.accuracy;
} on LocationFailure catch (error) {
// 展示“开启定位服务”“到设置页授权”等具体操作
print(error);
} catch (error) {
// 记录未知错误,但不要把底层异常原样展示给用户
print('定位失败: $error');
}
}
如果用户已经永久拒绝权限,可以根据插件能力打开应用设置或系统定位设置。打开设置后,页面恢复前台时必须重新检查权限,而不是假设用户一定完成了操作。
5.3 订阅连续位置流
实时导航或运动轨迹需要位置流,而不是每次点击都获取单点:
import 'dart:async';
import 'package:geolocator/geolocator.dart';
class LocationController {
StreamSubscription<Position>? _subscription;
void start() {
_subscription = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 10,
),
).listen(
(position) {
print(
'lat=${position.latitude}, '
'lon=${position.longitude}, '
'accuracy=${position.accuracy}',
);
},
onError: (Object error, StackTrace stackTrace) {
print('位置流错误: $error');
},
);
}
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
}
}
这里必须处理两个生命周期问题:
- 页面销毁时取消订阅,否则可能继续更新已不存在的状态;
- 页面进入后台时是否继续订阅,必须根据产品需求决定,而不是让 Stream 自然运行。
普通 StreamSubscription 也不等于可靠的后台服务。应用进程被系统杀死、Android 服务被终止、iOS 后台策略改变后,Dart 层的订阅对象都可能不存在。
6. 把位置显示在地图上
以 Flutter 地图组件为例,典型结构是:
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
class LocationMap extends StatelessWidget {
const LocationMap({
super.key,
required this.latitude,
required this.longitude,
});
final double latitude;
final double longitude;
@override
Widget build(BuildContext context) {
final point = LatLng(latitude, longitude);
return FlutterMap(
options: MapOptions(
initialCenter: point,
initialZoom: 15,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.app',
),
MarkerLayer(
markers: [
Marker(
point: point,
width: 48,
height: 48,
child: const Icon(
Icons.my_location,
color: Colors.blue,
size: 32,
),
),
],
),
],
);
}
}
这段代码成立的前提是:
- 已添加
flutter_map和latlong2; - 网络可用;
- 瓦片服务允许当前使用方式;
- 已遵守瓦片服务的 User-Agent、缓存、访问频率和署名要求;
latitude、longitude使用的是该地图服务要求的坐标基准;- 当前
flutter_map版本仍使用相应 API。
不能把公开瓦片地址当成生产地图服务。公开服务可能限流、禁止离线缓存或限制商业使用。生产环境应选择有明确 SLA、授权和配额策略的地图服务。
地图更新时,通常不应每个 GPS 点都重建整个页面。可以把位置放入状态管理层,只更新:
- Marker 的坐标;
- 是否自动跟随当前位置;
- 地图相机是否需要移动;
- 当前精度圆;
- 轨迹 Polyline。
当用户手动拖动地图后,常见交互是关闭“自动跟随”。否则每个位置更新都会把用户视角强行拉回当前位置,造成地图不可操作。
7. 后台定位:它是系统服务问题,不是把页面隐藏起来
7.1 前台、后台和终止不是同一个状态
需要区分:
- 前台:应用界面可见,通常可以正常使用定位 API;
- 后台:应用界面不可见,但进程或系统服务仍可能运行;
- 挂起:系统暂停应用执行;
- 终止:进程被系统或用户结束,Dart 内存状态消失。
Flutter 的 AppLifecycleState 可以帮助应用感知生命周期变化,但它不能保证:
- 后台时 Dart 代码持续执行;
- Stream 永不间断;
- 被系统杀死后自动恢复;
- iOS 和 Android 行为完全一致。
后台定位的正确抽象是:
系统定位服务
-> 平台后台组件
-> 持久化队列或数据库
-> Flutter 前台界面读取状态
-> 上传服务批量同步
而不是:
Flutter 页面隐藏
-> Timer 继续每隔几秒获取位置
7.2 Android 后台定位
Android 上持续后台定位通常需要:
- 前台或后台位置权限;
- 符合版本要求的 Manifest 声明;
- 前台服务;
- 常驻通知;
- 对后台限制、电池优化和厂商策略的处理;
- 进程重启后的恢复逻辑。
前台服务的“前台”指服务对系统和用户可见,不是 Flutter 页面必须可见。系统通知是这类能力的组成部分,不应试图隐藏。
如果应用只是记录用户主动开始的跑步轨迹,可以在用户点击“开始记录”时启动前台服务,在点击“结束”时停止。相比应用启动就永久定位,这种状态更容易解释,也更节省电量。
7.3 iOS 后台定位
iOS 需要在 Xcode 中启用 Location Updates 能力,并按照实际用途申请后台定位权限。系统会根据:
- 用户授权级别;
- 应用是否声明后台能力;
- 定位管理配置;
- 设备状态;
- 用户是否强制结束应用;
决定是否继续投递位置。
iOS 的后台定位不是“无限制高频回调”。系统可能合并、延迟或调整更新,应用必须能接受不规则采样。若需求只是到达某区域时提醒,应优先考虑区域监控等更匹配的系统能力,而不是持续高精度轨迹。
7.4 后台数据必须可恢复
后台位置不能只放在内存列表中:
final points = <Position>[];
进程重启后这个列表会丢失。更可靠的设计是:
- 收到位置;
- 校验时间、经纬度和精度;
- 写入本地持久化队列;
- 网络可用时批量上传;
- 服务端确认后删除或标记已同步;
- 失败则指数退避重试;
- 到达保留期限后自动清理。
上传应具有幂等键,例如:
deviceId + sampleId
否则网络重试可能造成同一个轨迹点重复写入。
后台定位中最常见的故障链是:
用户授予权限
-> 用户关闭系统定位服务
-> 服务未启动或被系统停止
-> 本地仍显示“正在记录”
-> 用户以为有轨迹,实际中间出现空洞
因此 UI 必须区分“记录意图”和“实际收到位置”。仅显示一个开关状态是不够的,应展示最近一次成功采样时间和异常状态。
8. 隐私:位置是高敏感的时空数据
位置数据不仅能表示“现在在哪里”,还可能推断:
- 家庭和工作地点;
- 作息规律;
- 就医、宗教、社交和出行行为;
- 与其他用户的关系;
- 特定时间段的活动轨迹。
所以隐私设计不能停留在“弹一个权限框”。
8.1 目的、范围和时长
申请定位前,应能回答三个问题:
- 目的:为什么需要定位?
- 范围:只需要当前点、前台位置,还是连续轨迹?
- 时长:只在一次操作期间使用,还是用户主动开启一段记录?
例如,天气应用通常只需要一次粗略位置;运动记录应用才可能需要持续高精度轨迹。不能因为技术上能拿到高精度,就默认长期采集。
8.2 最小化采集
如果业务只需要判断“是否进入某区域”,不一定需要保存完整轨迹。可以在设备本地计算:
位置 -> 是否在区域内 -> 只上传布尔结果
如果业务需要距离统计,也可以只上传累计距离和开始结束时间,而不是所有原始点。
如果必须保存轨迹,应明确:
- 采样频率和距离阈值;
- 是否包含海拔、速度、方向;
- 本地和服务端保留多久;
- 用户如何查看、导出和删除;
- 共享给哪些服务;
- 是否用于分析、广告或模型训练。
8.3 安全与日志
不要把完整经纬度写入普通生产日志:
print('user location: $latitude, $longitude'); // 不建议
诊断时可记录:
sampleId=abc123, accuracy=18m, age=4s, provider=system
必要时对坐标降精度或做脱敏。网络传输应使用 HTTPS;服务端应按用户授权隔离数据;本地数据库应考虑设备备份、导出和越权读取问题。
权限拒绝也应被视为正常业务状态,而不是异常崩溃。用户拒绝定位后,应用应提供不依赖位置的替代流程,而不是反复弹窗。
9. 耗电:定位成本来自采样、传感器、网络和唤醒
定位耗电不是一个固定数字,取决于:
- GPS/GNSS 是否持续工作;
- Wi-Fi、蜂窝和蓝牙扫描;
- 定位精度;
- 更新间隔和最小移动距离;
- 屏幕是否点亮;
- 网络上传次数;
- 后台唤醒频率;
- 地图瓦片和相机渲染;
- 设备型号、信号强度和系统策略。
一个简化的能耗模型可以写成:
总能耗
≈ 定位硬件能耗
+ CPU 唤醒能耗
+ 网络传输能耗
+ 地图渲染能耗
提高精度通常会增加第一项;高频回调会增加第二项;每个点立即上传会增加第三项;不断移动相机和刷新瓦片会增加第四项。
9.1 精度不是越高越好
可以根据业务模式选择策略:
| 场景 | 常见策略 |
|---|---|
| 显示当前位置 | 中等精度,较大 distanceFilter |
| 城市步行导航 | 较高精度,较小距离阈值 |
| 运动轨迹 | 高精度,但按运动状态调整采样 |
| 到达区域提醒 | 区域监控或低频定位 |
| 天气、附近内容 | 一次性定位,必要时使用粗略位置 |
这些是经验策略,不是平台保证。最终仍应在真实设备、真实网络和不同系统版本上测试。
9.2 不要固定高频 Timer 模拟定位
如下设计通常既耗电又不可靠:
Timer.periodic(const Duration(seconds: 1), (_) {
// 每秒主动请求一次定位
});
原因是:
- 系统定位本身可能返回缓存值;
- 每秒唤醒 Dart 并不代表每秒获得新位置;
- 后台 Timer 可能被系统暂停;
- 高频采样会增加 CPU、GPS 和网络开销;
- 不能替代 Android 前台服务或 iOS 后台能力。
应优先使用平台定位流,让系统根据硬件和策略调度;应用再通过精度、距离阈值和业务状态降低需求。
9.3 批量上传和动态采样
轨迹记录可以采用:
移动较快 -> 较小时间或距离间隔
移动较慢 -> 较大间隔
静止 -> 暂停或极低频采样
网络不可用 -> 本地队列
网络恢复 -> 批量上传
但速度本身可能抖动,所以不能仅凭单个速度值切换模式。可以使用最近若干采样的中位数或平均值,并设置进入、退出阈值,避免状态频繁震荡。
10. Android、iOS、桌面和 Web 的边界
Android
Android 的权限、精确/大致位置、后台位置、前台服务和厂商电池策略差异明显。真机测试至少应覆盖:
- 用户首次拒绝;
- 永久拒绝;
- 只允许大致位置;
- 关闭系统定位;
- 锁屏;
- 应用切后台;
- 系统回收进程;
- Android 不同版本;
- 电池优化开启和关闭。
iOS
iOS 重点测试:
- 使用期间授权;
- 始终允许授权;
- 用户关闭精确位置;
- 后台定位指示器;
- 锁屏;
- 用户强制结束应用;
- 系统设置修改权限后返回应用。
不能把 Android 的前台服务模型直接套到 iOS。
桌面端
Flutter 桌面端是否能提供定位,取决于:
- 操作系统;
- 桌面定位 API;
- 插件实现;
- 设备是否具备 GNSS、Wi-Fi 或其他定位来源。
桌面通常不是移动端后台定位的等价环境。应明确产品是否支持桌面定位,不能只因为 Flutter 能编译到桌面就认为功能自然可用。
Web
Web 定位依赖浏览器的 Geolocation API,通常要求:
- HTTPS 安全上下文,开发环境可能允许
localhost; - 用户授予浏览器权限;
- 浏览器和操作系统允许定位;
- 页面生命周期和浏览器后台策略允许继续工作。
浏览器一般不提供与移动原生应用完全等价的长期后台定位能力。标签页隐藏、浏览器冻结或设备休眠后,定位回调可能停止或延迟。
地图瓦片还受 CORS、域名限制、API Key、Referer 和服务商条款影响。Web 中“定位成功但地图空白”通常是两个独立问题。
11. 典型失败表现与诊断顺序
11.1 一直显示“定位中”
按以下顺序检查:
- 系统定位服务是否打开;
- 应用是否声明平台权限;
- 用户权限是否为
denied或deniedForever; - 是否只获得大致位置;
- 是否在模拟器或室内环境;
- 是否设置了过高精度和过短超时;
- 是否错误地等待一个不会结束的后台流;
- 位置流是否被提前取消;
- 页面是否在等待定位时被销毁。
日志应记录状态转换,而不是只记录“失败”:
serviceEnabled=true
permission=whileInUse
requestStarted=...
firstFixReceived=...
accuracy=35m
不要记录完整用户轨迹来排查普通权限问题。
11.2 位置明显偏移
优先检查:
- 纬度、经度顺序;
- 坐标是否为
null、NaN 或超范围; - 定位与地图底图是否使用不同坐标系;
- 是否重复做了坐标转换;
- Marker 的锚点是否正确;
- 地图缩放级别是否造成视觉误判;
- 位置时间戳是否过旧。
可以先用已知地标坐标做固定测试,再验证实时数据。这样能把“地图渲染错误”和“定位数据错误”分开。
11.3 地图空白但定位正常
检查:
- 瓦片或地图 SDK 的网络请求;
- API Key 和包名、签名、Bundle ID 配置;
- 配额和服务状态;
- HTTPS、ATS 或 Android 网络安全配置;
- User-Agent、Referer、署名和授权;
- 目标平台是否支持当前地图插件。
定位数据和地图底图是两个独立依赖,必须分别观测。
11.4 后台轨迹有缺口
缺口可能来自:
- 系统挂起或杀死进程;
- 没有正确启动 Android 前台服务;
- iOS 后台能力或权限不足;
- 用户关闭系统定位;
- 设备进入省电模式;
- 信号遮挡;
- 应用只在内存中保存数据;
- 网络上传失败但没有本地队列;
- 服务端按时间覆盖了相邻点。
因此应同时记录:
采样时间
写入本地时间
上传开始时间
服务端确认时间
定位精度
应用生命周期
权限状态
只有这样才能判断是“没有采到”“采到了但没保存”还是“保存了但没上传”。
12. 生产设计中的一个完整状态模型
应用可以把定位状态建模为不可混淆的联合状态,而不是几个互相独立的布尔变量:
sealed class LocationState {
const LocationState();
}
class LocationInitial extends LocationState {
const LocationInitial();
}
class LocationChecking extends LocationState {
const LocationChecking();
}
class LocationNeedsPermission extends LocationState {
const LocationNeedsPermission(this.permanentlyDenied);
final bool permanentlyDenied;
}
class LocationServiceDisabled extends LocationState {
const LocationServiceDisabled();
}
class LocationReady extends LocationState {
const LocationReady(this.position);
final Position position;
}
class LocationError extends LocationState {
const LocationError(this.message);
final String message;
}
这种建模避免了矛盾状态,例如:
isLoading = false
hasPermission = true
serviceEnabled = false
hasPosition = true
error = null
这组布尔值很难判断 UI 应该显示什么;而联合状态能明确表达“服务关闭”和“已有旧位置”是否同时存在。实际项目还可以增加:
tracking:用户主动开始后台记录;stale:最后位置超过业务允许的时间;permissionLimited:只有大致位置;syncing:本地轨迹正在上传;recovering:从进程重启或本地队列恢复。
状态层还应遵循一个重要边界:最后一次位置不等于当前实时位置。如果位置已经超过有效时间,应显示“最后更新于……”而不是继续显示为当前点。
13. 测试和验收不能只测模拟器正常授权
单元测试
可以测试:
- 经纬度范围校验;
- Haversine 距离;
- 坐标顺序转换;
- 采样去重;
- 轨迹点排序;
- 上传重试和幂等;
- 数据保留期限。
例如,合法范围检查:
bool isValidCoordinate(double latitude, double longitude) {
return latitude.isFinite &&
longitude.isFinite &&
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180;
}
集成测试
应覆盖:
- 首次请求允许;
- 首次请求拒绝;
- 永久拒绝后进入设置;
- 定位服务关闭;
- 精确位置切换为大致位置;
- 前后台切换;
- 锁屏;
- 网络断开和恢复;
- 进程重启;
- 数据删除后本地与服务端均不可恢复读取。
性能验证
不要只测“CPU 是否很低”。应观察:
- 单位时间定位回调数;
- GPS 或定位硬件活跃时间;
- 网络请求数和流量;
- 本地队列长度;
- 后台运行后的电量变化;
- 地图瓦片请求量;
- 页面重建和相机更新频率。
不同设备和信号环境会产生很大差异,不能编造一个适用于所有设备的固定耗电数字。
14. 取舍原则
一个定位功能是否合理,可以用以下因果链检查:
业务目标
-> 所需空间精度
-> 所需时间精度
-> 前台还是后台
-> 一次定位还是连续定位
-> 是否需要原始轨迹
-> 权限级别
-> 本地存储和上传方式
-> 电量与隐私成本
例如:
- “显示附近商店”通常不需要持续后台定位;
- “记录用户主动开始的运动轨迹”需要连续采样、持久化和后台能力;
- “进入地理围栏时提醒”更适合区域监控,而不是每秒轮询;
- “计算总行程”未必需要上传每个原始点,可以在本地聚合后上传结果。
Flutter 地图与定位的难点不在于调用一个 API,而在于正确连接权限状态、系统生命周期、坐标基准、地图服务、持久化、隐私和电量。只要把这些边界显式建模,前台显示、后台记录和跨平台降级就能分别验证,错误也不会被一个模糊的“定位失败”掩盖。
系列导航与关联阅读
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论