阅读视图

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

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年实战项目总结,代码已脱敏。

flutter睡眠与冥想数据可视化神器:sleep_stage_chart插件全解析

在健康类 App 开发中,睡眠周期分析和冥想数据展示是核心功能模块。一个直观、美观且交互流畅的可视化图表,能极大提升用户对健康数据的理解和使用体验。今天给大家推荐一款专为 Flutter 开发者打造的全能型图表插件——sleep_stage_chart,它不仅能完美呈现睡眠阶段数据,还支持冥想时长可视化,跨平台兼容且高度可定制。

1. 简介

sleep_stage_chart是一款专注于睡眠阶段和冥想数据可视化的 Flutter 插件,借鉴了 Apple Health 应用的优雅设计风格,提供了平滑的过渡效果和丰富的交互能力。该插件支持 Android、iOS 和 Windows 三大平台,能够满足健康类 App 对睡眠周期分析、冥想时长统计等场景的可视化需求。

1.1. 例图

睡眠图

冥想图

1.2. 核心功能

该插件的功能覆盖了健康数据可视化的核心需求,同时提供了足够的灵活性:

  • 📊 双图表支持:同时兼容睡眠阶段图(浅睡/深睡/REM/清醒状态)和冥想时长图
  • 🎨 深度定制化:支持颜色、样式、布局、网格线等全维度自定义
  • 📱 跨平台兼容:无缝运行于 Android、iOS、Windows 平台
  • 🤏 交互体验:支持触摸拖拽指示器,查看不同时段的详细数据
  • 🕐 精准时间展示:清晰呈现时间范围、阶段时长,支持自定义时间格式化
  • 🎀 样式扩展:支持自定义底部组件、圆角、背景色等外观属性
  • 📖 完善文档:提供完整的 API 说明和可直接运行的示例代码

2. 快速集成

在项目的 pubspec.yaml 文件中添加插件依赖:

dependencies:
  sleep_stage_chart: ^1.1.2  # 建议使用最新版本

执行安装命令:

flutter pub get

2.1. 基础使用示例

插件提供了两种核心图表场景:睡眠阶段图和冥想时长图,以下是最简实现示例。

睡眠阶段图

import 'package:flutter/material.dart';
import 'package:sleep_stage_chart/sleep_stage_chart.dart';

class SleepChartDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 构造睡眠数据:包含阶段类型、起止时间、描述信息
    final sleepData = [
      SleepStageDetails(
        model: SleepStageEnum.light,  // 浅睡
        startTime: DateTime(2025, 1, 1, 22, 30),
        endTime: DateTime(2025, 1, 1, 23, 30),
        info: ['浅睡,睡眠质量良好'],
      ),
      SleepStageDetails(
        model: SleepStageEnum.deep,   // 深睡
        startTime: DateTime(2025, 1, 1, 23, 30),
        endTime: DateTime(2025, 1, 2, 1, 0),
        info: ['深睡,身体修复阶段'],
      ),
      SleepStageDetails(
        model: SleepStageEnum.rem,    // REM睡眠(快速眼动)
        startTime: DateTime(2025, 1, 2, 1, 0),
        endTime: DateTime(2025, 1, 2, 2, 15),
        info: ['REM睡眠,大脑活跃'],
      ),
      SleepStageDetails(
        model: SleepStageEnum.awake,  // 清醒
        startTime: DateTime(2025, 1, 2, 6, 0),
        endTime: DateTime(2025, 1, 2, 6, 30),
        info: ['清醒,准备起床'],
      ),
    ];

    return Container(
      height: 300,
      margin: const EdgeInsets.all(16),
      child: SleepStageChart(
        details: sleepData,  // 睡眠数据(必填)
        startTime: DateTime(2025, 1, 1, 22, 30),  // 开始时间(必填)
        endTime: DateTime(2025, 1, 2, 6, 30),     // 结束时间(必填)
        backgroundColor: Colors.white,            // 背景色(必填)
        heightUnitRatio: 1 / 8,                   // 高度比例单位
        onIndicatorMoved: (stage) {               // 指示器移动回调
          print('当前阶段:${stage.model.name},时长:${stage.duration}分钟');
        },
        bottomChild: const [Text('入睡'), Text('起床')],  // 底部自定义文本
        stageColors: {  // 自定义各阶段颜色
          SleepStageEnum.light: Colors.blue.shade300,
          SleepStageEnum.deep: Colors.blue.shade700,
          SleepStageEnum.rem: Colors.teal.shade400,
          SleepStageEnum.awake: Colors.orange.shade300,
        },
      ),
    );
  }
}

冥想时长图

冥想图表通常需要展示全天或特定时段的冥想分布,可通过统一颜色和时间轴配置实现:

import 'package:flutter/material.dart';
import 'package:sleep_stage_chart/sleep_stage_chart.dart';

class MeditationChartDemo extends StatelessWidget {
  final DateTime dayStart = DateTime(2025, 1, 1);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 300,
      margin: const EdgeInsets.all(16),
      child: SleepStageChart(
        details: [
          SleepStageDetails(
            model: SleepStageEnum.light,
            startTime: dayStart.add(const Duration(minutes: 30)),
            endTime: dayStart.add(const Duration(minutes: 75)),
            info: ['晨间冥想,专注呼吸'],
          ),
          SleepStageDetails(
            model: SleepStageEnum.light,
            startTime: dayStart.add(const Duration(hours: 19)),
            endTime: dayStart.add(const Duration(hours: 20, minutes: 20)),
            info: ['睡前冥想,放松身心'],
          ),
        ],
        startTime: dayStart,
        endTime: dayStart.add(const Duration(days: 1)),
        backgroundColor: Colors.transparent,
        heightUnitRatio: 1 / 8,
        allDayModel: true,  // 开启全天模式
        minuteInterval: 360,  // 时间轴间隔:6小时
        stageColors: const {  // 统一冥想颜色
          SleepStageEnum.light: Color(0xFF43CAC4),
          SleepStageEnum.deep: Color(0xFF43CAC4),
          SleepStageEnum.rem: Color(0xFF43CAC4),
          SleepStageEnum.awake: Color(0xFF43CAC4),
        },
        bottomChild: ['00:00', '06:00', '12:00', '18:00', '00:00']
            .map((time) => Text(time, style: const TextStyle(fontSize: 12)))
            .toList(),
        showVerticalLine: true,  // 显示竖线分隔
        showHorizontalLine: false,  // 隐藏横线
        borderRadius: 12,  // 圆角优化
      ),
    );
  }
}

2.2. 高级定制指南

sleep_stage_chart提供了丰富的定制属性,以下是常见场景的定制方案。

颜色定制

通过 stageColors 属性自定义各睡眠阶段的颜色,支持所有 SleepStageEnum 类型:

stageColors: const {
  SleepStageEnum.light: Color(0xFFE3F2FD),  // 浅睡-淡蓝
  SleepStageEnum.deep: Color(0xFF90CAF9),   // 深睡-中蓝
  SleepStageEnum.rem: Color(0xFF42A5F5),    // REM-深蓝
  SleepStageEnum.awake: Color(0xFFFFE0B2),  // 清醒-淡橙
  SleepStageEnum.notWorn: Color(0xFFF5F5F5),// 未佩戴-灰色
  SleepStageEnum.unknown: Color(0xFFEEEEEE),// 未知-浅灰
},

网格线定制

控制网格线的显示/隐藏和样式:

// 横线样式
horizontalLineStyle: SleepStageChartLineStyle(
  width: 1.0,
  color: Colors.grey.shade200,
  space: 2.0,  // 虚线间隔
),
// 竖线样式
verticalLineStyle: SleepStageChartLineStyle(
  width: 1.0,
  color: Colors.grey.shade200,
),
showHorizontalLine: true,  // 显示横线
showVerticalLine: true,    // 显示竖线
horizontalLineCount: 6,    // 横线数量(分割图表高度)

时间格式化

通过 dateFormatter 自定义时间轴的显示格式:

dateFormatter: (DateTime date) {
  // 自定义格式:小时-分钟(补零)
  return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
},

交互控制

控制指示器的显示和回调:

hasIndicator: true,  // 显示触摸指示器
onIndicatorMoved: (SleepStageDetails stage) {
  // 指示器移动时回调,可用于更新详情面板
  setState(() {
    _currentStage = stage;
    _currentDuration = '${stage.duration}分钟';
    _currentInfo = stage.info.join(' ');
  });
},

3. 核心 API 参考

下面是核心的api属性列举:

SleepStageChart(主组件)

属性名 类型 默认值 描述
details List - 核心数据(必填)
startTime DateTime - 开始时间(必填)
endTime DateTime - 结束时间(必填)
backgroundColor Color - 背景色(必填)
stageColors Map<SleepStageEnum, Color>? null 阶段颜色映射
heightUnitRatio double - 高度比例单位
borderRadius double 8.0 圆角半径
showVerticalLine bool true 是否显示竖线
showHorizontalLine bool true 是否显示横线
hasIndicator bool true 是否显示触摸指示器
onIndicatorMoved void Function(SleepStageDetails)? null 指示器移动回调
allDayModel bool false 是否开启全天模式
minuteInterval int 360 全天模式时间间隔(分钟)
bottomChild List [] 底部自定义组件列表
dateFormatter String Function(DateTime)? null 时间格式化函数

SleepStageDetails(数据模型)

属性名 类型 描述
model SleepStageEnum 睡眠/冥想阶段类型
startTime DateTime 阶段开始时间
endTime DateTime 阶段结束时间
info List 阶段描述信息
duration int 时长(分钟,自动计算)

SleepStageEnum(阶段枚举)

枚举值 描述
light 浅睡/冥想
deep 深睡
rem REM睡眠
awake 清醒
unknown 未知状态

4. 示例App项目

插件提供了完整的示例项目,可直接克隆源码运行体验:

# 克隆仓库
git clone https://github.com/wp993080086/sleep_stage_chart.git

# 进入示例目录
cd sleep_stage_chart/example

# 安装依赖并运行
flutter pub get
flutter run

示例项目包含了睡眠图表、冥想图表的各种定制场景,可直接参考复用。

5. 总结

sleep_stage_chart 是一款功能全面、高度可定制的 Flutter 健康数据可视化插件,凭借其优雅的设计风格、流畅的交互体验和跨平台兼容性,能够快速满足睡眠和冥想数据的可视化需求。无论是快速集成基础图表,还是深度定制符合 App 风格的可视化效果,该插件都能提供简洁高效的解决方案。

适用场景:

  • 睡眠监测类 App:展示深睡、浅睡、REM 睡眠周期分布
  • 冥想类 App:统计每日/每周冥想时长和时段分布
  • 健康管理 App:整合睡眠与冥想数据的综合可视化
  • 智能穿戴设备配套 App:同步设备采集的睡眠数据展示

如果你的项目中需要实现睡眠周期分析或冥想数据展示,不妨试试 sleep_stage_chart,让健康数据可视化开发更高效!

相关链接:


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

《Flutter全栈开发实战指南:从零到高级》- 18 -自定义绘制与画布

引言

不知道大家是否曾有过这样的困扰:UI设计稿里出了一个特别炫酷的进度条,用现有组件怎么都拼不出来?产品经理又要求开发一个复杂的动态几何图形背景?或者需要实现一个画板功能等等。当你遇到这些情况时,别急!这些复杂效果都可以通过自定义绘制来实现,今天的内容带你深入理解这些复杂效果的背后原理。

image.png

一、绘制系统的底层架构

1.1 Flutter绘制整体架构

在深入自定义绘制之前,我们需要理解Flutter绘制系统的整体架构。这不仅仅是API调用,更是一个完整的渲染管线。

graph TB
    A[Widget Tree] --> B[RenderObject Tree]
    B --> C[Layout Phase]
    C --> D[Paint Phase]
    D --> E[Canvas Operations]
    E --> F[Skia Engine]
    F --> G[GPU]
    G --> H[Screen Display]
    
    subgraph "Flutter Framework"
        A
        B
        C
        D
        E
    end
    
    subgraph "Embedder"
        F
        G
        H
    end

1.2 渲染管线详细工作流程

下面通过一个详细的序列图来辅助理解整个绘制过程:

sequenceDiagram
    participant W as Widget
    participant R as RenderObject
    participant P as PaintingContext
    participant C as Canvas
    participant S as Skia
    participant G as GPU

    W->>R: createRenderObject()
    R->>R: layout()
    R->>P: paint()
    P->>C: save layer
    C->>S: draw calls
    S->>G: OpenGL/Vulkan
    G->>G: rasterization
    G->>G: frame buffer
    G->>Screen: display frame

二、CustomPaint与Canvas的原理

2.1 CustomPaint在渲染树中的位置

// CustomPaint的内部结构
class CustomPaint extends SingleChildRenderObjectWidget {
  final CustomPainter? painter;
  final CustomPainter? foregroundPainter;
  
  @override
  RenderCustomPaint createRenderObject(BuildContext context) {
    return RenderCustomPaint(
      painter: painter,
      foregroundPainter: foregroundPainter,
    );
  }
}

class RenderCustomPaint extends RenderProxyBox {
  CustomPainter? _painter;
  CustomPainter? _foregroundPainter;
  
  @override
  void paint(PaintingContext context, Offset offset) {
    // 1. 先绘制背景painter
    if (_painter != null) {
      _paintWithPainter(context.canvas, offset, _painter!);
    }
    
    // 2. 绘制子节点
    super.paint(context, offset);
    
    // 3. 最后绘制前景painter  
    if (_foregroundPainter != null) {
      _paintWithPainter(context.canvas, offset, _foregroundPainter!);
    }
  }
}

2.2 Canvas的底层实现机制

Canvas实际上更像一个命令录制器,它并不立即执行绘制操作,而是记录所有的绘制命令,在适当的时候批量执行。

graph LR
    A1[drawCircle] --> B[Canvas<br/>命令缓冲区]
    A2[drawPath] --> B
    A3[drawRect] --> B
    A4[drawText] --> B
    
    B --> C[SkPicture<br/>持久化存储]
    
    C --> D1[SkCanvas<br/>软件渲染]
    C --> D2[GPU<br/>硬件渲染]
    
    D1 --> E[CPU渲染结果]
    D2 --> F[GPU渲染结果]
    
    E --> G[屏幕输出]
    F --> G
    
    subgraph API_LAYER [Canvas API]
        A1
        A2
        A3
        A4
    end
    
    subgraph RECORD_LAYER [录制层]
        B
        C
    end
    
    subgraph RENDER_LAYER [渲染层]
        D1
        D2
    end

Canvas的核心数据结构:

// Canvas内部结构
class Canvas {
  final SkCanvas _skCanvas;
  final List<SaveRecord> _saveStack = [];
  
  // SkCanvas负责所有的绘制操作
  void drawCircle(Offset center, double radius, Paint paint) {
    _skCanvas.drawCircle(
      center.dx, center.dy, radius, 
      paint._toSkPaint()  // 将Dart的Paint转换为Skia的SkPaint
    );
  }
}

三、RenderObject与绘制的关系

3.1 渲染树的绘制流程

每个RenderObject都有机会参与绘制过程,理解这个过程对性能优化至关重要。

abstract class RenderObject extends AbstractNode {
  void paint(PaintingContext context, Offset offset) {
    // 默认实现:如果有子节点就绘制子节点
    // 自定义RenderObject可以重写这个方法
  }
}

class PaintingContext {
  final ContainerLayer _containerLayer;
  final Canvas _canvas;
  
  void paintChild(RenderObject child, Offset offset) {
    // 递归
    child._paintWithContext(this, offset);
  }
}

3.2 图层合成原理

Flutter使用图层合成技术来提高渲染性能。理解图层对于处理复杂绘制场景非常重要。

graph TB
    A[Root Layer] --> B[Transform Layer]
    B --> C[Opacity Layer]
    C --> D[Layer 1]
    C --> E[Layer 2]
    
    subgraph "图层树结构"
        B
        C
        D
        E
    end

图层的重要性:

  • 独立的绘制操作被记录在不同的PictureLayer中
  • 当只有部分内容变化时,只需重绘对应的图层
  • 硬件合成可以高效地组合这些图层

四、Paint对象的内部机制

4.1 Paint的Skia底层对应

class Paint {
  Color? color;
  PaintingStyle? style;
  double? strokeWidth;
  BlendMode? blendMode;
  Shader? shader;
  MaskFilter? maskFilter;
  ColorFilter? colorFilter;
  ImageFilter? imageFilter;
  
  // 将Dart的Paint转换为Skia的SkPaint
  SkPaint _toSkPaint() {
    final SkPaint skPaint = SkPaint();
    if (color != null) {
      skPaint.color = color!.value;
    }
    if (style == PaintingStyle.stroke) {
      skPaint.style = SkPaintStyle.stroke;
    }
    skPaint.strokeWidth = strokeWidth ?? 1.0;
    return skPaint;
  }
}

4.2 Shader的工作原理

Shader是Paint中最强大的功能之一,理解其工作原理可以写出更炫酷的视觉效果。

// 线性渐变的底层实现原理
class LinearGradient extends Shader {
  final Offset start;
  final Offset end;
  final List<Color> colors;
  
  @override
  SkShader _createShader() {
    final List<SkColor> skColors = colors.map((color) => color.value).toList();
    return SkShader.linearGradient(
      start.dx, start.dy, end.dx, end.dy,
      skColors,
      _computeColorStops(),
      SkTileMode.clamp,
    );
  }
}

Shader的GPU执行流程:

  1. CPU准备Shader参数;
  2. 上传到GPU的纹理内存;;
  3. 片段着色器执行插值计算;
  4. 输出到帧缓冲区;

五、Path的原理与实现

5.1 贝塞尔曲线

贝塞尔曲线是计算机图形学的基础,理解其数学原理有助于创建更复杂的图形。

// 贝塞尔曲线
Path _flattenCubicBezier(Offset p0, Offset p1, Offset p2, Offset p3, double tolerance) {
  final Path path = Path();
  path.moveTo(p0.dx, p0.dy);
  
  // 将曲线离散化为多个线段
  for (double t = 0.0; t <= 1.0; t += 0.01) {
    final double x = _cubicBezierPoint(p0.dx, p1.dx, p2.dx, p3.dx, t);
    final double y = _cubicBezierPoint(p0.dy, p1.dy, p2.dy, p3.dy, t);
    path.lineTo(x, y);
  }
  
  return path;
}

5.2 Path的底层数据结构

Path在底层使用路径段的链表结构来存储:

// Path段类型
enum PathSegmentType {
  moveTo,
  lineTo, 
  quadraticTo,
  cubicTo,
  close,
}

class PathSegment {
  final PathSegmentType type;
  final List<Offset> points;
  final PathSegment? next;
}

六、性能优化的底层原理

6.1 RepaintBoundary的工作原理

RepaintBoundary是Flutter性能优化的关键,它创建了独立的图层。

class RepaintBoundary extends SingleChildRenderObjectWidget {
  @override
  RenderRepaintBoundary createRenderObject(BuildContext context) {
    return RenderRepaintBoundary();
  }
}

class RenderRepaintBoundary extends RenderProxyBox {
  @override
  bool get isRepaintBoundary => true; // 关键属性
  
  @override
  void paint(PaintingContext context, Offset offset) {
    // 如果内容没有变化,可以复用之前的绘制结果
    if (_needsPaint) {
      _layer = context.pushLayer(
        PictureLayer(Offset.zero),
        super.paint,
        offset,
        childPaintBounds: paintBounds,
      );
    } else {
      context.addLayer(_layer!);
    }
  }
}

6.2 图层复用机制

sequenceDiagram
    participant A as Frame N
    participant B as RepaintBoundary
    participant C as PictureLayer
    participant D as Frame N+1
    
    A->>B: paint()
    B->>C: 录制绘制命令
    C->>C: 生成SkPicture
    
    D->>B: paint() 检查脏区域
    B->>B: 判断是否需要重绘
    alt 需要重绘
        B->>C: 重新录制
    else 不需要重绘
        B->>C: 复用之前的SkPicture
    end

七、实现一个粒子系统

让我们用所学的底层知识实现一个高性能的粒子系统。

7.1 架构设计

class ParticleSystem extends CustomPainter {
  final List<Particle> _particles = [];
  final Stopwatch _stopwatch = Stopwatch();
  
  @override
  void paint(Canvas canvas, Size size) {
    final double deltaTime = _stopwatch.elapsedMilliseconds / 1000.0;
    _stopwatch.reset();
    _stopwatch.start();
    
    _updateParticles(deltaTime);
    _renderParticles(canvas);
  }
  
  void _updateParticles(double deltaTime) {
    for (final particle in _particles) {
      // 模拟位置、速度、加速度
      particle.velocity += particle.acceleration * deltaTime;
      particle.position += particle.velocity * deltaTime;
      particle.lifeTime -= deltaTime;
    }
    
    _particles.removeWhere((particle) => particle.lifeTime <= 0);
  }
  
  void _renderParticles(Canvas canvas) {
    // 使用saveLayer实现粒子混合效果
    canvas.saveLayer(null, Paint()..blendMode = BlendMode.srcOver);
    
    for (final particle in _particles) {
      _renderParticle(canvas, particle);
    }
    
    canvas.restore();
  }
  
  void _renderParticle(Canvas canvas, Particle particle) {
    final Paint paint = Paint()
      ..color = particle.color.withOpacity(particle.alpha)
      ..maskFilter = MaskFilter.blur(BlurStyle.normal, particle.radius);
    
    canvas.drawCircle(particle.position, particle.radius, paint);
  }
  
  @override
  bool shouldRepaint(ParticleSystem oldDelegate) => true;
}

7.2 性能优化技巧

对象池模式:

class ParticlePool {
  final List<Particle> _pool = [];
  int _index = 0;
  
  Particle getParticle() {
    if (_index >= _pool.length) {
      _pool.add(Particle());
    }
    return _pool[_index++];
  }
  
  void reset() => _index = 0;
}

批量绘制优化:

void _renderParticlesOptimized(Canvas canvas) {
  // 使用drawVertices进行批量绘制
  final List<SkPoint> positions = [];
  final List<SkColor> colors = [];
  
  for (final particle in _particles) {
    positions.add(SkPoint(particle.position.dx, particle.position.dy));
    colors.add(particle.color.value);
  }
  
  final SkVertices vertices = SkVertices(
    SkVerticesVertexMode.triangles,
    positions,
    colors: colors,
  );
  
  canvas.drawVertices(vertices, BlendMode.srcOver, Paint());
}

八、自定义渲染管线

对于性能要求非常高的场景,我们可以绕过CustomPaint,直接操作渲染管线。

8.1 自定义RenderObject

class CustomCircleRenderer extends RenderBox {
  Color _color;
  
  CustomCircleRenderer({required Color color}) : _color = color;
  
  @override
  void performLayout() {
    size = constraints.biggest;
  }
  
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    final Paint paint = Paint()..color = _color;
    
    // 操作Canvas控制绘制过程
    canvas.save();
    canvas.translate(offset.dx, offset.dy);
    canvas.drawCircle(size.center(Offset.zero), size.width / 2, paint);
    canvas.restore();
  }
}

8.2 与平台通道集成

对于特别复杂的图形,可以考虑使用平台通道调用原生图形API:

class NativeRenderer extends CustomPainter {
  static const MethodChannel _channel = MethodChannel('native_renderer');
  
  @override
  void paint(Canvas canvas, Size size) async {
    final ByteData? imageData = await _channel.invokeMethod('render', {
      'width': size.width,
      'height': size.height,
    });
    
    if (imageData != null) {
      final Uint8List bytes = imageData.buffer.asUint8List();
      final Image image = await decodeImageFromList(bytes);
      canvas.drawImage(image, Offset.zero, Paint());
    }
  }
}

总结

通过深度剖析Flutter绘制系统的底层原理,我们不仅学会了如何使用CustomPaint和Canvas,更重要的是理解了:渲染管线图层架构 、Skia集成性能优化 ,掌握了这些底层原理,你就能在遇到复杂绘制需求时游刃有余。

如果觉得这篇文章对你有帮助,别忘了一键三连(点赞、关注、收藏)!有任何问题,欢迎评论区留言!!

❌