阅读视图

发现新文章,点击刷新页面。

Flutter 图纸标注功能的实现:踩坑与架构设计

写在前面

最近在做一个工程验收的项目,有个需求是要在 CAD 图纸上标注问题点。一开始觉得挺简单,不就是显示个图片,点一下加个 Marker 吗?真动手做了才发现,这里面的坑多到怀疑人生。

比如说:

  • 工地现场网络差到爆,必须完全离线
  • 图纸动辄几千像素,加载和交互都卡
  • 业务逻辑一堆,担心后面没法维护
  • 各种坐标系转来转去,脑壳疼

折腾了两周,终于把这个东西搞定了。整个过程中踩了不少坑,也积累了一些经验,所以写篇文章记录一下,顺便分享给有类似需求的朋友。

整体思路

搞这个东西之前,我先理了理需求,发现核心就是:在一张离线图纸上,支持用户点击标注,还得支持区域限制(不能乱点)

听起来简单,但要做好,必须解决几个问题:

  1. **怎么让代码不和业务绑死?**毕竟这个功能不止一个地方用
  2. **怎么管理状态?**标记点、多边形、图纸这些东西状态管理一团乱
  3. **怎么保证性能?**大图加载、高频交互都得优化

想来想去,决定按这个思路来:

CustomMapWidget (视图组件)
     ↓
CustomMapController (控制器,处理逻辑)
     ↓
CustomMapState (状态管理,响应式更新)
     ↓
MapDataSource (抽象接口,业务自己实现)

简单说就是:视图负责展示,控制器负责协调,状态负责响应式更新,业务逻辑通过接口注入

这样的好处是,核心框架和具体业务完全解耦,换个场景只需要实现不同的 DataSource 就行。

关键设计:业务抽象层

这个是整个架构的核心。我定义了一个抽象接口 MapDataSource

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 之后丝滑多了。

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:简单笛卡尔坐标系,因为图纸用的是像素坐标,不是真的经纬度
  • 多图层分离:标记点、多边形、绘制点分开管理,互不影响

二、控制器的核心逻辑

控制器主要负责协调各个部分,处理用户交互。

初始化流程

_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(); // 不管成功失败都要走后续流程
  }
}

地图渲染完成的回调

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

点击事件处理(重点)

这是最核心的逻辑,处理用户在图纸上的点击:

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 的多图层机制:

@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 用的是图层叠加的方式,从下往上渲染。顺序很重要,搞错了标记点就被图纸盖住了(别问我怎么知道的)。

底图层的实现

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),直接整图加载反而更简单。

四、工厂模式的应用

为了方便使用,封装了一个工厂类:

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

使用示例

// 创建地图组件
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(简单笛卡尔坐标系)

// CrsSimple 可以把像素坐标当成"伪经纬度"
final CrsSimple crs = const CrsSimple();

// 地图坐标 → 像素坐标(给后端用)
Point<double> pixelPoint = crs.latLngToPoint(
  latlng, 
  serverMapMaxZoom  // zoom 级别要和后端约定好
);

// 定义图纸的边界
LatLngBounds bounds = LatLngBounds(
  LatLng(0, 0),                      // 图纸左上角
  LatLng(imageHeight, imageWidth)    // 图纸右下角
);

这里有几个坑:

  1. zoom 级别必须和后端一致,不然坐标会偏移。我们约定的是 8
  2. Y 轴方向:CrsSimple 的 Y 轴是向下的,和传统坐标系相反
  3. 小数精度:坐标转换会有浮点误差,存数据库时要注意

坑二:点在多边形内判定

产品要求用户只能在房间内标注,不能标到墙外面去。这就需要判断点是否在多边形内。

我用的是射线法(Ray Casting),原理很简单:从点向右发射一条射线,数射线和多边形边界交点的个数,奇数次就在内部,偶数次就在外部。

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 都创建一个地图实例,来回滚动几次内存就爆了。

解决方案:

@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;
}

页面销毁时记得调用:

@override
void dispose() {
  CustomMapFactory.disposeController('project_${projectId}');
  super.dispose();
}

数据模型设计

配置模型

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; // 已有的标记点(用来回显)
}

配置项不算多,但每个都是实际用到的。一开始想做成超级灵活的配置系统,后来发现太复杂了,就简化成这样。

地图源模型

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,不渲染:

Widget _buildMarkerLayer() {
  return Obx(() {
    if (controller.state.markers.isEmpty) {
      return const SizedBox.shrink();  // 空图层
    }
    return MarkerLayer(markers: controller.state.markers);
  });
}

局部刷新

用 GetBuilder 的 id 参数实现精准刷新:

update(['map']);  // 只刷新地图,不影响页面其他部分

这个太重要了,之前没加 id,每次更新都全页面刷新,卡得要命。

图片缓存

FileImage 自带缓存,不需要额外处理。但如果图纸特别大(>10M),建议在加载前先压缩一下。


使用指南

第一步:实现数据源接口

根据你的业务需求,实现 MapDataSource

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': [...],  // 限制区域
    };
  }
}

第二步:创建地图组件

final mapWidget = CustomMapFactory.createDefault(
  dataSource: MyProjectDataSource(),
  config: MapDrawingConfig(
    serverMapMaxZoom: 8.0,
    singleMarker: false,  // 是否单点模式
    onTap: (pixelPoint, latlng) {
      // 用户点击了,这里保存坐标到数据库
      saveToDatabase(pixelPoint);
    },
  ),
  tag: 'project_${projectId}',  // 用唯一ID作为tag
);

第三步:在页面中使用

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();
}

几个注意事项

  1. zoom 级别要和后端对齐,不然坐标会偏
  2. tag 必须唯一,建议用项目ID或其他唯一标识
  3. 记得释放资源,不然内存泄漏
  4. 图纸路径要正确,文件不存在会报错

总结

这套架构最大的优点是解耦。核心框架不关心你的业务,只负责地图展示和交互。所有业务逻辑都通过 DataSource 接口注入,换个场景只需要写一个新的 DataSource 实现就行。

当然也有一些不足:

  • 对于特别复杂的标注需求(比如绘制曲线、多边形编辑),还需要扩展
  • 大图纸(>10M)的加载性能还有优化空间
  • 离线缓存目前还没做

不过对于大部分场景来说,已经够用了。

如果你也有类似的需求,希望这篇文章能帮到你。有问题欢迎交流!


2024年实战项目总结,代码已脱敏。

❌