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 再次转换的坐标。

同一个物理位置,在这些坐标系下的数值可能不同。典型错误是:

  1. 手机系统定位返回一套坐标;
  2. 地图底图使用另一套坐标;
  3. 应用不做服务商要求的转换;
  4. 结果是 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. 权限:用户同意不等于功能一定可用

定位权限是一个状态系统,不是一个简单的布尔值。至少要区分:

  1. 应用是否声明了权限;
  2. 用户是否授予权限;
  3. 系统定位服务是否打开;
  4. 用户是否只允许大致位置;
  5. 当前应用生命周期是否满足后台定位条件;
  6. 设备、浏览器或桌面平台是否支持所需能力。

可以把前台定位的基本状态抽象为:

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_flutterflutter_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,
    ),
  );
}

每一步的因果关系是:

  1. isLocationServiceEnabled() 检查系统总开关。权限允许但总开关关闭,仍不能定位;
  2. checkPermission() 读取当前授权状态,避免每次进入页面都直接弹窗;
  3. 只有用户触发明确功能时才请求权限,系统体验和授权率通常更合理;
  4. deniedForever 表示再次调用普通请求通常不会出现有用的系统授权弹窗;
  5. getCurrentPosition() 才真正等待一次定位结果;
  6. accuracy 是对系统定位质量的偏好,不是硬性保证;
  7. 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_maplatlong2
  • 网络可用;
  • 瓦片服务允许当前使用方式;
  • 已遵守瓦片服务的 User-Agent、缓存、访问频率和署名要求;
  • latitudelongitude 使用的是该地图服务要求的坐标基准;
  • 当前 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 上持续后台定位通常需要:

  1. 前台或后台位置权限;
  2. 符合版本要求的 Manifest 声明;
  3. 前台服务;
  4. 常驻通知;
  5. 对后台限制、电池优化和厂商策略的处理;
  6. 进程重启后的恢复逻辑。

前台服务的“前台”指服务对系统和用户可见,不是 Flutter 页面必须可见。系统通知是这类能力的组成部分,不应试图隐藏。

如果应用只是记录用户主动开始的跑步轨迹,可以在用户点击“开始记录”时启动前台服务,在点击“结束”时停止。相比应用启动就永久定位,这种状态更容易解释,也更节省电量。

7.3 iOS 后台定位

iOS 需要在 Xcode 中启用 Location Updates 能力,并按照实际用途申请后台定位权限。系统会根据:

  • 用户授权级别;
  • 应用是否声明后台能力;
  • 定位管理配置;
  • 设备状态;
  • 用户是否强制结束应用;

决定是否继续投递位置。

iOS 的后台定位不是“无限制高频回调”。系统可能合并、延迟或调整更新,应用必须能接受不规则采样。若需求只是到达某区域时提醒,应优先考虑区域监控等更匹配的系统能力,而不是持续高精度轨迹。

7.4 后台数据必须可恢复

后台位置不能只放在内存列表中:

final points = <Position>[];

进程重启后这个列表会丢失。更可靠的设计是:

  1. 收到位置;
  2. 校验时间、经纬度和精度;
  3. 写入本地持久化队列;
  4. 网络可用时批量上传;
  5. 服务端确认后删除或标记已同步;
  6. 失败则指数退避重试;
  7. 到达保留期限后自动清理。

上传应具有幂等键,例如:

deviceId + sampleId

否则网络重试可能造成同一个轨迹点重复写入。

后台定位中最常见的故障链是:

用户授予权限
 -> 用户关闭系统定位服务
 -> 服务未启动或被系统停止
 -> 本地仍显示“正在记录”
 -> 用户以为有轨迹,实际中间出现空洞

因此 UI 必须区分“记录意图”和“实际收到位置”。仅显示一个开关状态是不够的,应展示最近一次成功采样时间和异常状态。


8. 隐私:位置是高敏感的时空数据

位置数据不仅能表示“现在在哪里”,还可能推断:

  • 家庭和工作地点;
  • 作息规律;
  • 就医、宗教、社交和出行行为;
  • 与其他用户的关系;
  • 特定时间段的活动轨迹。

所以隐私设计不能停留在“弹一个权限框”。

8.1 目的、范围和时长

申请定位前,应能回答三个问题:

  1. 目的:为什么需要定位?
  2. 范围:只需要当前点、前台位置,还是连续轨迹?
  3. 时长:只在一次操作期间使用,还是用户主动开启一段记录?

例如,天气应用通常只需要一次粗略位置;运动记录应用才可能需要持续高精度轨迹。不能因为技术上能拿到高精度,就默认长期采集。

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 一直显示“定位中”

按以下顺序检查:

  1. 系统定位服务是否打开;
  2. 应用是否声明平台权限;
  3. 用户权限是否为 denieddeniedForever
  4. 是否只获得大致位置;
  5. 是否在模拟器或室内环境;
  6. 是否设置了过高精度和过短超时;
  7. 是否错误地等待一个不会结束的后台流;
  8. 位置流是否被提前取消;
  9. 页面是否在等待定位时被销毁。

日志应记录状态转换,而不是只记录“失败”:

serviceEnabled=true
permission=whileInUse
requestStarted=...
firstFixReceived=...
accuracy=35m

不要记录完整用户轨迹来排查普通权限问题。

11.2 位置明显偏移

优先检查:

  1. 纬度、经度顺序;
  2. 坐标是否为 null、NaN 或超范围;
  3. 定位与地图底图是否使用不同坐标系;
  4. 是否重复做了坐标转换;
  5. Marker 的锚点是否正确;
  6. 地图缩放级别是否造成视觉误判;
  7. 位置时间戳是否过旧。

可以先用已知地标坐标做固定测试,再验证实时数据。这样能把“地图渲染错误”和“定位数据错误”分开。

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 编写。