junny_map

junny_map 是供多个 Flutter 项目共用的天地图、OpenFreeMap、OSM 与设备定位基础库。

当前版本提供:

  • 天地图 WMTS 矢量、影像、地形和注记瓦片;
  • 基于 MapLibre 的 OpenFreeMap 矢量地图,默认使用彩色的 Liberty 样式;
  • OpenStreetMap 栅格地图及跨镜像备用瓦片配置;
  • flutter_map 地图组件、点击、相机事件、Marker、折线和多边形;
  • 天地图 Search V2.0 普通、视野和周边 POI 搜索;
  • 天地图正向和逆地理编码客户端;
  • geolocator 单次定位、连续定位、权限和系统服务状态;
  • 带坐标系、精度、时间戳和模拟位置标记的统一位置模型;
  • WGS84、CGCS2000、GCJ02、BD09 标识及常用转换。

使用

通过 Dart Pub 添加依赖:

dependencies:
  junny_map: ^0.1.0

库内公共地图控件使用 Flutter 原生 Localizations 机制,默认支持简体中文和英文。 宿主应用应注册库的 Delegate;语言会由 MaterialApp 根据设备 Locale 自动选择:

MaterialApp(
  localizationsDelegates: <LocalizationsDelegate<dynamic>>[
    JunnyMapLocalizations.delegate,
    ...GlobalMaterialLocalizations.delegates,
  ],
  supportedLocales: const <Locale>[Locale('zh'), Locale('en')],
)

库只负责通用地图控件、定位权限操作和底图状态文案。搜索业务状态、POI 选择流程、 天地图 Key 配置和具体页面标题应由调用方维护,并根据业务需要覆盖文案。

生产边界

本库提供地图渲染、GCJ02 坐标、定位、搜索、逆地理编码和选点能力。宿主项目负责鉴权、 业务规则、数据提交和页面流程。

Android 页面只保留一个 MapLibre 原生地图实例。JunnyOpenFreeMap 默认启用纹理模式, 保证 Flutter 的 AppBar、搜索框和地图操作按钮正常显示;底图切换时销毁旧实例,避免 多个原生地图同时占用资源。

天地图 Key 可以直连配置,也可以通过 apiBaseUrl、tileBaseUrl 接入自有代理。定位权限、 定位精度阈值、模拟位置处理和失败后的页面降级由宿主项目配置。

库内 API 的所有经纬度输入和输出统一以 GCJ02 表示;WGS84 只用于系统定位和部分底图 的内部渲染适配。其他坐标系应通过 CoordinateTransformer 显式转换,不能根据数值猜测。 定位质量阈值、模拟位置处理、权限申请时机和数据写入由宿主业务决定。

仓库内包含一个可独立运行的测试项目。配置天地图 Key 后运行:

cd example
flutter pub get
flutter run

天地图 Key 配置在 example/env/test.env,应用启动时会自动读取,该文件已加入 Git 忽略,直接编辑即可。 不配置 Key 时,示例直接使用 OpenFreeMap 底图和设备定位;天地图瓦片、POI 搜索与 逆地理编码仍需要 Key。天地图瓦片错误达到阈值时,示例会自动切换到 OpenFreeMap 兜底地图; geolocator 不需要天地图 Key。

示例页定位后使用库内统一的 GCJ02 坐标显示位置,并展示系统返回的水平精度和模拟位置 标记。外部服务如果使用其他坐标系,只在服务边界转换,不会改变定位和业务模型中的坐标。

天地图生产接入

TdtServiceOptions 将 REST 和 WMTS 分成 apiBaseUrl、tileBaseUrl 两个边界。客户端 会把 REST 路径拼成 ${apiBaseUrl}/v2/search 或 ${apiBaseUrl}/geocoder,把瓦片路径 拼成 ${tileBaseUrl}/vec_w/wmts 等 WMTS 路径;基地址不能带 query 或 fragment。

官方直连模式

官方直连时,includeRestApiKey 和 includeTileApiKey 保持默认的 true,REST 与瓦片 请求都会携带 tk。Android/iOS 必须使用天地图控制台允许原生服务请求的 Key(通常是 服务端或移动端 Key);浏览器/JS API Key 不能直接替代原生 Key。Key 即使通过 --dart-define 注入,仍会随应用发布到客户端;需要集中管理时建议使用自有代理。

final TdtServiceOptions services = TdtServiceOptions(
  apiKey: const String.fromEnvironment('TIANDITU_KEY'),
  apiBaseUrl: 'https://api.tianditu.gov.cn',
  tileBaseUrl: 'https://{s}.tianditu.gov.cn',
  serviceCoordinateSystem: CoordinateSystem.gcj02,
);

构建时通过 --dart-define=TIANDITU_KEY=... 提供值,不要把真实 Key 提交到仓库。

纯代理模式

不希望 REST 或瓦片 Key 出现在客户端请求中时,REST 和瓦片都指向宿主的 HTTPS 代理, 并同时关闭两个 Key 参数。下面配置会生成以下代理请求:

  • REST:https://maps.example.com/tianditu/rest/v2/search 和 https://maps.example.com/tianditu/rest/geocoder;
  • 瓦片:https://maps.example.com/tianditu/tiles/vec_w/wmts,以及影像、地形或注记 对应的同构路径。
final TdtServiceOptions services = TdtServiceOptions(
  // 两个 include*ApiKey 都为 false 时可以使用空字符串。
  apiKey: '',
  apiBaseUrl: 'https://maps.example.com/tianditu/rest',
  tileBaseUrl: 'https://maps.example.com/tianditu/tiles',
  includeRestApiKey: false,
  includeTileApiKey: false,
  serviceCoordinateSystem: CoordinateSystem.gcj02,
);

代理应在服务端保存天地图 Key,校验宿主自己的身份和权限,按需限流,并转发原库需要 的 REST JSON 或 WMTS 图片响应;不要要求客户端再次传入 tk,也不要把官方 Key 放进 代理 URL 的 query。代理返回的 Search V2.0 和地理编码响应结构需要与天地图兼容,或由 代理完成等价转换。若代理使用子域名负载均衡,可将 tileBaseUrl 写为 https://maps.example.com/tianditu/tiles/{s},实际的 t0 路径会位于 .../tiles/t0/vec_w/wmts。

搜索和逆地理客户端的请求都接受可选的 Dio CancelToken;JunnyMapPickerController 会取消自己创建的旧 REST 请求,并用请求代次忽略迟到响应。宿主直接使用客户端时, 也应在页面销毁、查询替换或用户取消时管理 Token 和客户端生命周期。

创建服务配置:

final TdtServiceOptions services = TdtServiceOptions(
  apiKey: const String.fromEnvironment('TIANDITU_KEY'),
  // 仅用于天地图 REST 服务边界;客户端返回值仍统一为 GCJ02。
  serviceCoordinateSystem: CoordinateSystem.gcj02,
);

显示地图:

JunnyTdtMap(
  services: services,
  initialCenter: const LatLng(30.5928, 114.3055),
  initialZoom: 13,
  markers: <Marker>[
    Marker(
      point: const LatLng(30.5928, 114.3055),
      width: 40,
      height: 40,
      child: const Icon(Icons.location_on),
    ),
  ],
)

地图定位由库内 JunnyMapLocationController 管理,Demo 或业务页面无需自行订阅 LocationProvider.watchLocation。控制器也可以脱离地图单独使用;传入地图后,地图会自动 渲染当前位置 marker,并在 isFollowing 为 true 时跟随最新位置移动相机:

final JunnyMapLocationController locationController =
    JunnyMapLocationController(provider: const GeolocatorLocationProvider());

JunnyTdtMap(
  services: services,
  initialCenter: const LatLng(30.5928, 114.3055),
  locationController: locationController,
)

await locationController.locate(requestPermission: true);

项目内部定位模型统一使用 GCJ02。设备定位和 OpenFreeMap 的 WGS84 适配只在对应消费边界 执行一次;地图控制器负责连续定位、跟随状态和订阅生命周期。

使用 OpenFreeMap 兜底地图:

JunnyOpenFreeMap(
  initialCenter: const GeoCoordinate(
    latitude: 30.5928,
    longitude: 114.3055,
    system: CoordinateSystem.gcj02,
  ),
  style: JunnyOpenFreeMapStyle.liberty,
)

OpenFreeMap 不需要 Key,适合无天地图 Key 时作为示例和备用底图。需要固定样式或稳定 访问时,使用 styleUrl 配置自有的 MapLibre Style JSON。

需要稳定容量时,宿主可以自托管 MapLibre Style JSON,并把其中的矢量瓦片 source、字体 glyph、sprite 等资源一起部署或代理,再通过 styleUrl 指向设备可访问的 HTTPS 地址:

JunnyOpenFreeMap(
  initialCenter: const GeoCoordinate(
    latitude: 30.5928,
    longitude: 114.3055,
    system: CoordinateSystem.gcj02,
  ),
  styleUrl: 'https://maps.example.com/styles/production.json',
  showAttribution: true,
  androidTextureMode: true,
)

传入 styleUrl 后会覆盖内置样式地址。

如果部署网络可以访问 CARTO,可使用 Voyager 样式并自动回退到 OSM:

JunnyOsmMap(
  initialCenter: const LatLng(30.5928, 114.3055),
  tileLayerOptions: const JunnyOsmTileLayerOptions.carto(
    userAgentPackageName: 'com.example.app',
  ),
)

在部署网络无法直连 CARTO 时,将 urlTemplate 改为自有瓦片代理地址。

OSM 栅格服务边界

JunnyOsmMap 默认显示归属标识。接入宿主时将 JunnyOsmTileLayerOptions.userAgentPackageName 改为实际应用包名;如请求经过代理, 在代理侧统一处理 User-Agent。

JunnyOsmMap(
  initialCenter: const LatLng(30.5928, 114.3055),
  tileLayerOptions: const JunnyOsmTileLayerOptions(
    userAgentPackageName: 'com.example.attendance',
  ),
  showAttribution: true,
  attributionText: '© OpenStreetMap contributors',
)

默认主源是 tile.openstreetmap.de,备用源是 tile.openstreetmap.jp。宿主可以通过 urlTemplate、fallbackUrl 和 requestTimeout 配置代理、备用地址和单瓦片超时。

搜索和逆地理编码:

final TdtSearchClient searchClient = TdtSearchClient(options: services);
final TdtSearchResult result = await searchClient.searchNearby(
  keyword: '医院',
  center: const GeoCoordinate(
    latitude: 30.5928,
    longitude: 114.3055,
    system: CoordinateSystem.gcj02,
  ),
);

final TdtGeocodingClient geocodingClient =
    TdtGeocodingClient(options: services);
final TdtReverseGeocodeResult address = await geocodingClient.reverseGeocode(
  const GeoCoordinate(
    latitude: 30.5928,
    longitude: 114.3055,
    system: CoordinateSystem.gcj02,
  ),
);

设备定位:

final LocationProvider locationProvider =
    const GeolocatorLocationProvider();
final LocationSnapshot snapshot =
  await locationProvider.getCurrentLocation(
      requestPermission: true,
      settings: const LocationRequestSettings(
        timeLimit: Duration(seconds: 15),
      ),
    );

timeLimit 建议由调用方为单次定位设置有限值;超时只表示设备没有在期限内返回新的 位置,不会自动使用过期的 last-known location。部分 Android 设备如果 fused provider 不可用,库会自动回退到系统 NETWORK_PROVIDER,继续尝试 Wi-Fi/基站定位。可以将 forceAndroidLocationManager 设为 true 进行兼容性测试;网络定位回退的等待时间和 最大缓存年龄也可以通过 networkLocationTimeLimit、maximumLocationAge 配置。

生产定位或轨迹流应显式配置位置质量策略。maximumAccuracyMeters 同时约束单次定位 和连续定位更新;rejectMockedLocation 会拒绝平台标记为模拟位置的样本,并以 LocationProviderErrorCode.insufficientAccuracy 或 mockedLocation 暴露稳定错误。默认 值分别是不限制精度和不拒绝模拟位置,以保持兼容;平台的模拟位置标记不是完整的反作弊 能力,是否允许、如何提示和如何在服务端复核由宿主决定。

const LocationRequestSettings attendanceLocationSettings =
    LocationRequestSettings(
      accuracy: LocationAccuracyPreset.high,
      timeLimit: Duration(seconds: 15),
      maximumLocationAge: Duration(minutes: 2),
      maximumAccuracyMeters: 100,
      rejectMockedLocation: true,
    );

final LocationSnapshot punchLocation =
    await locationProvider.getCurrentLocation(
      requestPermission: true,
      settings: attendanceLocationSettings,
    );
final Stream<LocationSnapshot> trackingLocations =
    locationProvider.watchLocation(settings: attendanceLocationSettings);

宿主配置

geolocator 不能替宿主项目声明系统权限。Android 至少需要:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

后台定位还需要按 Android 版本配置 ACCESS_BACKGROUND_LOCATION、定位类型的前台服务 和用户可见通知,并符合应用商店政策;只做前台定位不应为了复用配置而默认申请后台权限。 iOS 至少需要 NSLocationWhenInUseUsageDescription;后台定位还需要 Background Modes 的 Location updates 和对应授权。权限弹窗的展示时机由宿主控制:picker 的 locate 默认 不申请权限,用户明确触发定位时再传 requestPermission: true。

天地图 Key、服务代理、权限声明、定位失败提示和请求重试由宿主项目负责。需要隐藏 Key 时使用上面的纯代理模式。

坐标约定

库内业务、定位、搜索、逆地理编码和地图交互统一使用 GCJ02。 EPSG:900913 只是天地图 Web Mercator 瓦片矩阵,不是业务坐标。 TdtServiceOptions.serviceCoordinateSystem 只描述 REST 服务边界坐标,发送请求前转换, 解析响应后转换回 GCJ02。

选点控制器

JunnyMapPickerController 只编排状态,不创建页面或地图 Widget。宿主把输入框、地图 点击、地图视野和按钮事件转发给它,并监听 state、searchResults、selection 和 error 构建自己的 UI。控制器支持防抖搜索、视野搜索、POI 选择、点击逆地理编码、单次 定位、cancel、clear、clearSearch 和 clearSelection;旧请求完成后不会覆盖新请求 的状态。

final TdtSearchClient searchClient = TdtSearchClient(options: services);
final TdtGeocodingClient geocodingClient = TdtGeocodingClient(options: services);
final JunnyMapPickerController picker = JunnyMapPickerController(
  searchClient: searchClient,
  geocodingClient: geocodingClient,
  locationProvider: const GeolocatorLocationProvider(),
);

await picker.searchInView(
  '医院',
  bounds: const GeoBounds(
    southwest: GeoCoordinate(
      latitude: 30.3,
      longitude: 114.0,
      system: CoordinateSystem.gcj02,
    ),
    northeast: GeoCoordinate(
      latitude: 30.9,
      longitude: 114.6,
      system: CoordinateSystem.gcj02,
    ),
  ),
);
if (picker.searchResults.isNotEmpty) {
  await picker.selectPoi(picker.searchResults.first);
}
await picker.selectCoordinate(
  const GeoCoordinate(
    latitude: 30.5928,
    longitude: 114.3055,
    system: CoordinateSystem.gcj02,
  ),
);
await picker.locate(
  requestPermission: true,
  settings: attendanceLocationSettings,
);

final JunnyMapPickerSelection? selection = picker.selection;
final Map<String, Object?>? selectionPayload = selection?.toJson();

JunnyMapPickerSelection 构造和控制器结果都把坐标归一化为 GCJ02。 toJson(outputSystem: ...) 可以按需要输出指定坐标系:

字段 含义
latitude / longitude 按 outputSystem 输出
coordinateSystem 经纬度使用的坐标系
address 地址文本;没有值时为 ''
poi POI 名称;没有值时为 ''
originalLatitude / originalLongitude 内部 GCJ02 坐标
originalCoordinateSystem 内部坐标系,固定为 GCJ02

Libraries

junny_map