junny_map 0.2.1
junny_map: ^0.2.1 copied to clipboard
Shared Tianditu map, geocoding, POI search, and geolocator services for Flutter applications.
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 |