写在前面
最近在做一个工程验收的项目,有个需求是要在 CAD 图纸上标注问题点。一开始觉得挺简单,不就是显示个图片,点一下加个 Marker 吗?真动手做了才发现,这里面的坑多到怀疑人生。
比如说:
- 工地现场网络差到爆,必须完全离线
- 图纸动辄几千像素,加载和交互都卡
- 业务逻辑一堆,担心后面没法维护
- 各种坐标系转来转去,脑壳疼
折腾了两周,终于把这个东西搞定了。整个过程中踩了不少坑,也积累了一些经验,所以写篇文章记录一下,顺便分享给有类似需求的朋友。
整体思路
搞这个东西之前,我先理了理需求,发现核心就是:在一张离线图纸上,支持用户点击标注,还得支持区域限制(不能乱点)。
听起来简单,但要做好,必须解决几个问题:
- **怎么让代码不和业务绑死?**毕竟这个功能不止一个地方用
- **怎么管理状态?**标记点、多边形、图纸这些东西状态管理一团乱
- **怎么保证性能?**大图加载、高频交互都得优化
想来想去,决定按这个思路来:
scss
CustomMapWidget (视图组件)
↓
CustomMapController (控制器,处理逻辑)
↓
CustomMapState (状态管理,响应式更新)
↓
MapDataSource (抽象接口,业务自己实现)
简单说就是:视图负责展示,控制器负责协调,状态负责响应式更新,业务逻辑通过接口注入。
这样的好处是,核心框架和具体业务完全解耦,换个场景只需要实现不同的 DataSource 就行。
关键设计:业务抽象层
这个是整个架构的核心。我定义了一个抽象接口 MapDataSource:
dart
abstract class MapDataSource {
// 加载图纸(可能从本地、可能从服务器)
Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs);
// 创建一个标记点(业务自己决定样式)
Marker addMarker(LatLng point, {String? number});
// 批量加载已有的标记点
List<Marker> loadMarkers(List<Point<double>>? latLngList, CrsSimple crs);
// 加载多边形(比如房间轮廓、限制区域等)
dynamic loadPolygons(CrsSimple crs);
}
为什么要这么设计?因为每个业务场景的需求都不一样:
- 验收系统可能需要红色图钉标记问题点
- 测量系统可能需要数字标记测量点
- 巡检系统可能需要设备图标
把这些差异抽象出来,让业务层自己实现,核心框架就不用改了。
具体实现
一、状态管理怎么搞
一开始用 Provider 写的,后来发现状态更新太频繁,性能不行。改成 GetX 之后丝滑多了。
dart
class CustomMapState {
// Flutter Map 的控制器,用来控制缩放、移动等
MapController mapController = MapController();
// 坐标系统(这个是关键,后面会讲为什么用 CrsSimple)
final CrsSimple crs = const CrsSimple();
// 配置信息(响应式的,方便动态修改)
final Rx<MapDrawingConfig> config = MapDrawingConfig().obs;
// 当前使用的图纸
final Rx<MapSourceConfig?> currentMapSource = Rx<MapSourceConfig?>(null);
// 地图边界(用来做自适应显示)
LatLngBounds? mapBounds;
// 标记点列表(Rx开头的都是响应式的,改了自动刷新UI)
final RxList<Marker> markers = <Marker>[].obs;
// 多边形列表(比如房间轮廓)
final RxList<Polygon> polygons = <Polygon>[].obs;
// 当前正在绘制的点
final RxList<LatLng> currentDrawingPoints = <LatLng>[].obs;
// 有效区域(用户只能在这个范围内标注)
List<LatLng> houseLatLngList = [];
}
这里有几个关键点:
- Rx 系列:GetX 的响应式类型,状态改了UI自动更新,不用手动 setState
- CrsSimple:简单笛卡尔坐标系,因为图纸用的是像素坐标,不是真的经纬度
- 多图层分离:标记点、多边形、绘制点分开管理,互不影响
二、控制器的核心逻辑
控制器主要负责协调各个部分,处理用户交互。
初始化流程
dart
_initData() async {
state.config.value = config;
try {
// 调用业务层加载图纸
var result = await dataSource.loadMapDrawingResource(state.crs);
state.currentMapSource.value = result;
state.mapBounds = result.defaultSource.bounds;
} catch (e) {
// 这里可能失败,比如文件不存在、网络问题等
logDebug('加载图纸失败: $e');
} finally {
onMapReady(); // 不管成功失败都要走后续流程
}
}
地图渲染完成的回调
dart
void onMapReady() {
if (state.isMapReady) return; // 防止重复调用(之前遇到过bug,这里加个保险)
state.isMapReady = true;
// 加载多边形(比如房间轮廓、限制区域等)
var parameter = dataSource.loadPolygons(state.crs);
if (parameter['polygonList'] != null) {
state.polygons.value = parameter['polygonList'];
}
// 如果有历史标记点,也一起加载进来
if (config.latLngList.isNotEmpty) {
state.markers.value = dataSource.loadMarkers(config.latLngList, state.crs);
}
// 自适应显示整个图纸(不然可能只看到一个角)
if (state.mapBounds != null) {
state.mapController.fitCamera(
CameraFit.bounds(bounds: state.mapBounds)
);
}
}
点击事件处理(重点)
这是最核心的逻辑,处理用户在图纸上的点击:
dart
void addDrawingPoint(TapPosition tapPosition, LatLng latlng) {
// 第一步:坐标转换(从地图坐标转成像素坐标)
// 为什么要转?因为后端存的是像素坐标,前端显示用的是地图坐标
Point<double> cp = state.crs.latLngToPoint(
latlng,
state.config.value.serverMapMaxZoom
);
// 第二步:检查是否超出图纸范围
// 之前没加这个判断,用户点到图纸外面就报错,体验很差
if (cp.x < 0 || cp.y < 0 ||
cp.x > currentMapSource.width ||
cp.y > currentMapSource.height) {
showSnackBar('超出图纸范围');
return;
}
// 第三步:检查是否在有效区域内
// 比如验收系统要求只能在房间内标注,不能标到墙外面去
if (state.houseLatLngList.isNotEmpty &&
!MapUtils.isPointInPolygon(latlng, state.houseLatLngList)) {
showSnackBar('请将位置打在画区内');
return;
}
// 第四步:通知业务层(让业务层保存数据)
config.onTap?.call(cp, latlng);
// 第五步:在地图上显示标记点
addMarker(position: latlng);
}
这个函数看起来简单,但每一步都是踩坑踩出来的:
- 坐标转换那里,之前 zoom 值没对齐,导致标记点位置偏移
- 边界检查是测试提的bug,用户点外面会崩
- 区域约束是产品后来加的需求,还好架构预留了扩展性
三、视图层的设计
视图层就是负责显示,用 Flutter Map 的多图层机制:
dart
@override
Widget build(BuildContext context) {
return GetBuilder<CustomMapController>(
tag: tag, // 用tag支持多实例,不然多个地图会冲突
id: 'map', // 局部刷新用的,只刷新地图部分
builder: (controller) {
return FlutterMap(
mapController: controller.state.mapController,
options: _buildMapOptions(),
children: [
_buildTileLayer(), // 底图层(图纸)
_buildPolygonLayer(), // 多边形层(房间轮廓)
_buildMarkerLayer(), // 标记点层
...?children, // 预留扩展位,可以加自定义图层
],
);
},
);
}
Flutter Map 用的是图层叠加的方式,从下往上渲染。顺序很重要,搞错了标记点就被图纸盖住了(别问我怎么知道的)。
底图层的实现
dart
Widget _buildTileLayer() {
return Obx(() { // Obx 会监听里面用到的响应式变量
final currentSource = controller.state.currentMapSource.value;
// 图纸还没加载完,显示loading
if (currentSource?.defaultSource.localPath?.isEmpty ?? true) {
return const Center(child: CircularProgressIndicator());
}
// 加载本地图纸文件
return OverlayImageLayer(
overlayImages: [
OverlayImage(
imageProvider: FileImage(File(currentSource.defaultSource.localPath)),
bounds: currentSource.defaultSource.bounds // 图纸的边界
)
]
);
});
}
这里用 OverlayImageLayer 把本地图片当成地图底图,bounds 定义了图片的坐标范围。一开始我还尝试用瓦片图的方式切片加载,后来发现图纸不大(2-3M),直接整图加载反而更简单。
四、工厂模式的应用
为了方便使用,封装了一个工厂类:
dart
class CustomMapFactory {
static CustomMapWidget createDefault({
required MapDataSource dataSource,
required MapDrawingConfig config,
String? tag,
}) {
late CustomMapController controller;
// 检查是否已经创建过(避免重复创建导致内存泄漏)
if (Get.isRegistered<CustomMapController>(tag: tag)) {
controller = Get.find<CustomMapController>(tag: tag);
} else {
controller = CustomMapController(
dataSource: dataSource,
config: config,
);
Get.lazyPut(() => controller, tag: tag); // 懒加载,用的时候才创建
}
return CustomMapWidget(
controller: controller,
tag: tag,
);
}
// 页面销毁时记得调用,不然内存泄漏
static void disposeController(String tag) {
if (Get.isRegistered<CustomMapController>(tag: tag)) {
Get.delete<CustomMapController>(tag: tag);
}
}
}
使用示例:
dart
// 创建地图组件
final mapWidget = CustomMapFactory.createDefault(
dataSource: MyDataSourceImpl(), // 你自己的业务实现
config: MapDrawingConfig(
serverMapMaxZoom: 8.0,
onTap: (pixelPoint, latlng) {
print('点击了坐标: $pixelPoint');
},
),
tag: 'project_01', // 用唯一标识,支持多个地图实例
);
踩坑记录
坑一:坐标系统的选择
一开始我用的是常规的地理坐标系(EPSG:3857),结果发现图纸上的坐标根本对不上。后来才明白,CAD 图纸用的是像素坐标,不是经纬度。
后端存的坐标是这样的:{x: 1234, y: 5678},单位是像素。而 Flutter Map 默认用的是经纬度坐标。
解决办法是用 CrsSimple(简单笛卡尔坐标系):
dart
// CrsSimple 可以把像素坐标当成"伪经纬度"
final CrsSimple crs = const CrsSimple();
// 地图坐标 → 像素坐标(给后端用)
Point<double> pixelPoint = crs.latLngToPoint(
latlng,
serverMapMaxZoom // zoom 级别要和后端约定好
);
// 定义图纸的边界
LatLngBounds bounds = LatLngBounds(
LatLng(0, 0), // 图纸左上角
LatLng(imageHeight, imageWidth) // 图纸右下角
);
这里有几个坑:
- zoom 级别必须和后端一致,不然坐标会偏移。我们约定的是 8
- Y 轴方向:CrsSimple 的 Y 轴是向下的,和传统坐标系相反
- 小数精度:坐标转换会有浮点误差,存数据库时要注意
坑二:点在多边形内判定
产品要求用户只能在房间内标注,不能标到墙外面去。这就需要判断点是否在多边形内。
我用的是射线法(Ray Casting),原理很简单:从点向右发射一条射线,数射线和多边形边界交点的个数,奇数次就在内部,偶数次就在外部。
dart
static bool isPointInPolygon(LatLng point, List<LatLng> polygon) {
int intersectCount = 0;
// 遍历多边形的每条边
for (int i = 0; i < polygon.length; i++) {
// 取当前点和下一个点(首尾相连)
final LatLng vertB =
i == polygon.length - 1 ? polygon[0] : polygon[i + 1];
// 检查射线是否和这条边相交
if (_rayCastIntersect(point, polygon[i], vertB)) {
intersectCount++;
}
}
// 奇数次相交说明在内部
return (intersectCount % 2) == 1;
}
static bool _rayCastIntersect(LatLng point, LatLng vertA, LatLng vertB) {
final double aY = vertA.latitude;
final double bY = vertB.latitude;
final double aX = vertA.longitude;
final double bX = vertB.longitude;
final double pY = point.latitude;
final double pX = point.longitude;
// 优化:快速排除明显不相交的情况
// 如果AB两个点都在P的上方/下方/左侧,肯定不相交
if ((aY > pY && bY > pY) ||
(aY < pY && bY < pY) ||
(aX < pX && bX < pX)) {
return false;
}
// 特殊情况:垂直的边
if (aX == bX) return true;
// 计算射线与边的交点X坐标(直线方程 y = mx + b)
final double m = (aY - bY) / (aX - bX); // 斜率
final double b = ((aX * -1) * m) + aY; // 截距
final double x = (pY - b) / m; // 交点的X坐标
// 如果交点在P的右侧,说明射线和这条边相交了
return x > pX;
}
这个算法看起来复杂,其实就是初中的直线方程 y = mx + b。第一次写的时候没考虑垂直边的情况,结果遇到矩形房间就挂了。
坑三:内存泄漏
GetX 虽然好用,但不注意的话很容易内存泄漏。尤其是在列表页,每个 item 都创建一个地图实例,来回滚动几次内存就爆了。
解决方案:
dart
@override
void onClose() {
if (_isDisposed) return; // 防止重复释放
super.onClose();
// 释放地图控制器
state.mapController.dispose();
// 清空所有列表
state.markers.clear();
state.polygons.clear();
state.currentDrawingPoints.clear();
// 重置状态
state.config.value = MapDrawingConfig();
state.currentMapSource.value = null;
state.isMapReady = false;
_isDisposed = true;
}
页面销毁时记得调用:
dart
@override
void dispose() {
CustomMapFactory.disposeController('project_${projectId}');
super.dispose();
}
数据模型设计
配置模型
dart
class MapDrawingConfig {
// 样式相关
final Color defaultMarkerColor; // 标记点颜色
final double defaultMarkerSize; // 标记点大小
// 缩放相关(这几个参数很重要)
final double serverMapMaxZoom; // 后端用的zoom级别(要对齐)
final double realMapMaxZoom; // 前端实际最大zoom(影响流畅度)
final double minZoom; // 最小zoom(防止缩太小)
// 交互相关
final bool singleMarker; // 是否单点模式(有些场景只能选一个点)
Function(Point<double>, LatLng)? onTap; // 点击回调
// 数据相关
List<Point<double>> latLngList; // 已有的标记点(用来回显)
}
配置项不算多,但每个都是实际用到的。一开始想做成超级灵活的配置系统,后来发现太复杂了,就简化成这样。
地图源模型
dart
class MapSource {
final String localPath; // 图纸的本地路径
final LatLngBounds bounds; // 图纸的边界
final double height; // 图纸高度(像素)
final double width; // 图纸宽度(像素)
}
class MapSourceConfig {
final MapSource defaultSource; // 默认使用的图纸
// 工厂方法:快速创建本地图纸配置
factory MapSourceConfig.customLocal({
required String customPath,
required double height,
required double width,
}) { ... }
}
这个模型设计得比较简单,因为我们的需求就是加载一张本地图纸。如果你的场景需要多个图纸切换,可以扩展 availableSources 列表。
性能优化
图层懒加载
没有数据的图层直接返回空 Widget,不渲染:
dart
Widget _buildMarkerLayer() {
return Obx(() {
if (controller.state.markers.isEmpty) {
return const SizedBox.shrink(); // 空图层
}
return MarkerLayer(markers: controller.state.markers);
});
}
局部刷新
用 GetBuilder 的 id 参数实现精准刷新:
dart
update(['map']); // 只刷新地图,不影响页面其他部分
这个太重要了,之前没加 id,每次更新都全页面刷新,卡得要命。
图片缓存
FileImage 自带缓存,不需要额外处理。但如果图纸特别大(>10M),建议在加载前先压缩一下。
使用指南
第一步:实现数据源接口
根据你的业务需求,实现 MapDataSource:
dart
class MyProjectDataSource implements MapDataSource {
@override
Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs) async {
// 从服务器下载或本地读取图纸
String localPath = await getDrawingPath(); // 你的业务逻辑
return MapSourceConfig.customLocal(
customPath: localPath,
height: 1080, // 图纸高度
width: 1920, // 图纸宽度
);
}
@override
Marker addMarker(LatLng point, {String? number}) {
// 创建一个标记点(自定义样式)
return Marker(
point: point,
width: 40,
height: 40,
child: Icon(Icons.location_pin, color: Colors.red),
);
}
@override
List<Marker> loadMarkers(List<Point<double>>? points, CrsSimple crs) {
// 加载已有的标记点(比如从数据库读取)
return points?.map((point) {
LatLng latlng = crs.pointToLatLng(point, 8.0);
return addMarker(latlng);
}).toList() ?? [];
}
@override
dynamic loadPolygons(CrsSimple crs) {
// 加载多边形(房间轮廓、限制区域等)
return {
'polygonList': [...], // 你的多边形数据
'houseLatLngList': [...], // 限制区域
};
}
}
第二步:创建地图组件
dart
final mapWidget = CustomMapFactory.createDefault(
dataSource: MyProjectDataSource(),
config: MapDrawingConfig(
serverMapMaxZoom: 8.0,
singleMarker: false, // 是否单点模式
onTap: (pixelPoint, latlng) {
// 用户点击了,这里保存坐标到数据库
saveToDatabase(pixelPoint);
},
),
tag: 'project_${projectId}', // 用唯一ID作为tag
);
第三步:在页面中使用
dart
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('图纸标注')),
body: mapWidget,
);
}
}
// 页面销毁时记得释放资源
@override
void dispose() {
CustomMapFactory.disposeController('project_${projectId}');
super.dispose();
}
几个注意事项
- zoom 级别要和后端对齐,不然坐标会偏
- tag 必须唯一,建议用项目ID或其他唯一标识
- 记得释放资源,不然内存泄漏
- 图纸路径要正确,文件不存在会报错
总结
这套架构最大的优点是解耦 。核心框架不关心你的业务,只负责地图展示和交互。所有业务逻辑都通过 DataSource 接口注入,换个场景只需要写一个新的 DataSource 实现就行。
当然也有一些不足:
- 对于特别复杂的标注需求(比如绘制曲线、多边形编辑),还需要扩展
- 大图纸(>10M)的加载性能还有优化空间
- 离线缓存目前还没做
不过对于大部分场景来说,已经够用了。
如果你也有类似的需求,希望这篇文章能帮到你。有问题欢迎交流!
2024年实战项目总结,代码已脱敏。