在这里插入图片描述

前言

数据可视化是运动健康应用中帮助用户理解运动数据的关键功能。通过直观的图表展示,用户可以快速了解自己的运动趋势、发现规律、制定改进计划。本文将详细介绍如何在Flutter与OpenHarmony平台上实现专业的运动数据图表组件,包括折线图、柱状图、环形图、雷达图等多种图表类型的完整实现方案。

图表设计需要在信息密度和可读性之间取得平衡。过于复杂的图表会让用户感到困惑,过于简单的图表又无法传达足够的信息。我们需要根据不同的数据类型和使用场景,选择最合适的图表形式,并通过颜色、动画等视觉元素增强用户体验。

Flutter图表数据模型

class ChartDataPoint {
  final DateTime date;
  final double value;
  final String? label;
  
  ChartDataPoint({
    required this.date,
    required this.value,
    this.label,
  });
}

class ChartDataSet {
  final String name;
  final List<ChartDataPoint> points;
  final Color color;
  
  ChartDataSet({
    required this.name,
    required this.points,
    required this.color,
  });
  
  double get maxValue => points.map((p) => p.value).reduce((a, b) => a > b ? a : b);
  double get minValue => points.map((p) => p.value).reduce((a, b) => a < b ? a : b);
  double get average => points.map((p) => p.value).reduce((a, b) => a + b) / points.length;
}

图表数据模型是所有图表组件的基础。ChartDataPoint表示单个数据点,包含日期、数值和可选的标签。ChartDataSet表示一个数据系列,包含系列名称、数据点列表和显示颜色。我们在数据集类中提供了maxValue、minValue和average三个计算属性,用于图表的坐标轴计算和统计显示。这种设计将数据结构与图表渲染分离,同一套数据可以用不同类型的图表展示,提高了代码的复用性和灵活性。

OpenHarmony图表数据查询服务

import relationalStore from '@ohos.data.relationalStore';

class ChartDataService {
  private rdbStore: relationalStore.RdbStore | null = null;
  
  async getWeeklySteps(): Promise<Array<object>> {
    let data: Array<object> = [];
    if (!this.rdbStore) return data;
    
    let endDate = new Date();
    let startDate = new Date();
    startDate.setDate(startDate.getDate() - 6);
    
    let predicates = new relationalStore.RdbPredicates('daily_stats');
    predicates.between('date', startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0]);
    
    let resultSet = await this.rdbStore.query(predicates, ['date', 'steps']);
    while (resultSet.goToNextRow()) {
      data.push({
        date: resultSet.getString(resultSet.getColumnIndex('date')),
        value: resultSet.getDouble(resultSet.getColumnIndex('steps')),
      });
    }
    resultSet.close();
    return data;
  }
  
  async getMonthlyCalories(): Promise<Array<object>> {
    // 查询月度卡路里数据
    return [];
  }
}

图表数据查询服务从数据库获取图表所需的数据。getWeeklySteps方法查询最近7天的步数数据,使用between条件筛选日期范围。查询结果包含日期和步数两个字段,封装为对象数组返回。这种服务层设计将数据获取逻辑与UI展示分离,便于数据源的切换和测试。不同的图表可能需要不同时间范围和聚合方式的数据,服务层可以提供多个查询方法满足各种需求。

Flutter折线图组件

class LineChartWidget extends StatelessWidget {
  final ChartDataSet dataSet;
  final double height;
  
  const LineChartWidget({
    Key? key,
    required this.dataSet,
    this.height = 200,
  }) : super(key: key);
  
  
  Widget build(BuildContext context) {
    return Container(
      height: height,
      padding: EdgeInsets.all(16),
      child: CustomPaint(
        size: Size(double.infinity, height - 32),
        painter: LineChartPainter(dataSet: dataSet),
      ),
    );
  }
}

class LineChartPainter extends CustomPainter {
  final ChartDataSet dataSet;
  
  LineChartPainter({required this.dataSet});
  
  
  void paint(Canvas canvas, Size size) {
    if (dataSet.points.isEmpty) return;
    
    Paint linePaint = Paint()
      ..color = dataSet.color
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;
    
    Paint dotPaint = Paint()
      ..color = dataSet.color
      ..style = PaintingStyle.fill;
    
    Path path = Path();
    double xStep = size.width / (dataSet.points.length - 1);
    double yRange = dataSet.maxValue - dataSet.minValue;
    
    for (int i = 0; i < dataSet.points.length; i++) {
      double x = i * xStep;
      double y = size.height - ((dataSet.points[i].value - dataSet.minValue) / yRange) * size.height;
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
      canvas.drawCircle(Offset(x, y), 4, dotPaint);
    }
    canvas.drawPath(path, linePaint);
  }
  
  
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

折线图是展示数据趋势的经典图表类型。我们使用CustomPaint实现自定义绑制,LineChartPainter负责具体的绑制逻辑。首先计算x轴步长和y轴范围,然后遍历数据点,将数值映射到画布坐标。Path对象连接所有数据点形成折线,同时在每个数据点位置绘制圆点标记。y轴的映射需要注意坐标系转换,画布的y轴向下增长,而数据的y轴向上增长,所以使用size.height减去计算值。这种折线图适合展示步数、心率等随时间变化的数据。

Flutter柱状图组件

class BarChartWidget extends StatelessWidget {
  final List<ChartDataPoint> data;
  final Color barColor;
  final double height;
  
  const BarChartWidget({
    Key? key,
    required this.data,
    this.barColor = Colors.blue,
    this.height = 200,
  }) : super(key: key);
  
  
  Widget build(BuildContext context) {
    double maxValue = data.map((d) => d.value).reduce((a, b) => a > b ? a : b);
    
    return Container(
      height: height,
      padding: EdgeInsets.symmetric(horizontal: 16),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: data.map((point) {
          double barHeight = maxValue > 0 ? (point.value / maxValue) * (height - 40) : 0;
          return Expanded(
            child: Padding(
              padding: EdgeInsets.symmetric(horizontal: 4),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.end,
                children: [
                  Text('${point.value.toInt()}', style: TextStyle(fontSize: 10)),
                  SizedBox(height: 4),
                  Container(
                    height: barHeight,
                    decoration: BoxDecoration(
                      color: barColor,
                      borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
                    ),
                  ),
                  SizedBox(height: 4),
                  Text(point.label ?? '', style: TextStyle(fontSize: 10)),
                ],
              ),
            ),
          );
        }).toList(),
      ),
    );
  }
}

柱状图适合展示离散的分类数据比较。我们使用Row和Expanded组件实现柱子的均匀分布,每根柱子的高度按数据值与最大值的比例计算。柱子顶部显示具体数值,底部显示标签(如星期几)。圆角设计使柱子看起来更加柔和。这种实现方式利用Flutter的布局系统,无需手动计算位置,代码简洁且易于维护。柱状图常用于展示每日步数、每周运动次数等数据,让用户直观比较不同时间段的运动量。

OpenHarmony数据聚合服务

class DataAggregationService {
  aggregateByDay(records: Array<object>): Map<string, number> {
    let dailyData = new Map<string, number>();
    
    for (let record of records) {
      let date = record['date'] as string;
      let value = record['value'] as number;
      
      if (dailyData.has(date)) {
        dailyData.set(date, dailyData.get(date)! + value);
      } else {
        dailyData.set(date, value);
      }
    }
    return dailyData;
  }
  
  aggregateByWeek(records: Array<object>): Array<object> {
    let weeklyData: Array<object> = [];
    let weekMap = new Map<number, number>();
    
    for (let record of records) {
      let date = new Date(record['date'] as string);
      let weekNumber = this.getWeekNumber(date);
      let value = record['value'] as number;
      
      if (weekMap.has(weekNumber)) {
        weekMap.set(weekNumber, weekMap.get(weekNumber)! + value);
      } else {
        weekMap.set(weekNumber, value);
      }
    }
    
    weekMap.forEach((value, week) => {
      weeklyData.push({ week: week, value: value });
    });
    return weeklyData;
  }
  
  private getWeekNumber(date: Date): number {
    let firstDay = new Date(date.getFullYear(), 0, 1);
    let days = Math.floor((date.getTime() - firstDay.getTime()) / (24 * 60 * 60 * 1000));
    return Math.ceil((days + firstDay.getDay() + 1) / 7);
  }
}

数据聚合服务将原始数据按不同维度汇总。aggregateByDay方法将同一天的多条记录合并,适用于一天内有多次运动的情况。aggregateByWeek方法按周汇总数据,用于生成周报图表。getWeekNumber方法计算日期所在的周数,基于年初第一天进行计算。这种聚合服务在数据层处理,减少了传输到前端的数据量,也简化了图表组件的逻辑。不同的图表可能需要不同粒度的数据,聚合服务提供了灵活的数据处理能力。

Flutter环形进度图

class CircularProgressChart extends StatelessWidget {
  final double progress;
  final double size;
  final Color progressColor;
  final Color backgroundColor;
  final String centerText;
  
  const CircularProgressChart({
    Key? key,
    required this.progress,
    this.size = 150,
    this.progressColor = Colors.blue,
    this.backgroundColor = Colors.grey,
    this.centerText = '',
  }) : super(key: key);
  
  
  Widget build(BuildContext context) {
    return SizedBox(
      width: size,
      height: size,
      child: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            size: Size(size, size),
            painter: CircularProgressPainter(
              progress: progress,
              progressColor: progressColor,
              backgroundColor: backgroundColor,
            ),
          ),
          Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('${(progress * 100).toInt()}%', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
              if (centerText.isNotEmpty)
                Text(centerText, style: TextStyle(fontSize: 12, color: Colors.grey)),
            ],
          ),
        ],
      ),
    );
  }
}

class CircularProgressPainter extends CustomPainter {
  final double progress;
  final Color progressColor;
  final Color backgroundColor;
  
  CircularProgressPainter({
    required this.progress,
    required this.progressColor,
    required this.backgroundColor,
  });
  
  
  void paint(Canvas canvas, Size size) {
    double strokeWidth = 12;
    Offset center = Offset(size.width / 2, size.height / 2);
    double radius = (size.width - strokeWidth) / 2;
    
    Paint bgPaint = Paint()
      ..color = backgroundColor.withOpacity(0.2)
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke;
    
    Paint progressPaint = Paint()
      ..color = progressColor
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;
    
    canvas.drawCircle(center, radius, bgPaint);
    
    double sweepAngle = 2 * 3.14159 * progress;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -3.14159 / 2,
      sweepAngle,
      false,
      progressPaint,
    );
  }
  
  
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

环形进度图是展示目标完成度的理想选择。我们使用CustomPaint绑制两层圆弧:底层是灰色的完整圆环作为背景,上层是彩色的进度弧。进度弧的角度根据progress值计算,从顶部(-π/2)开始顺时针绘制。strokeCap设为round使弧线端点圆润。中央使用Stack叠加显示百分比数字和说明文字。这种图表非常适合展示每日步数目标、卡路里目标等单一指标的完成情况,视觉效果直观且占用空间小。

Flutter多数据对比图

class ComparisonChart extends StatelessWidget {
  final List<ChartDataSet> dataSets;
  final List<String> labels;
  
  const ComparisonChart({
    Key? key,
    required this.dataSets,
    required this.labels,
  }) : super(key: key);
  
  
  Widget build(BuildContext context) {
    return Container(
      height: 250,
      padding: EdgeInsets.all(16),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: dataSets.map((ds) => Padding(
              padding: EdgeInsets.symmetric(horizontal: 8),
              child: Row(
                children: [
                  Container(width: 12, height: 12, color: ds.color),
                  SizedBox(width: 4),
                  Text(ds.name, style: TextStyle(fontSize: 12)),
                ],
              ),
            )).toList(),
          ),
          SizedBox(height: 16),
          Expanded(
            child: CustomPaint(
              size: Size(double.infinity, double.infinity),
              painter: ComparisonChartPainter(dataSets: dataSets),
            ),
          ),
        ],
      ),
    );
  }
}

多数据对比图支持在同一图表中展示多个数据系列。顶部的图例区域显示每个数据系列的名称和对应颜色,帮助用户区分不同的数据线。图表区域使用CustomPaint绑制多条折线,每条线使用数据集定义的颜色。这种图表适合比较不同时期的运动数据(如本周vs上周),或者比较不同类型的数据(如步数vs距离)。通过对比,用户可以发现自己的进步或需要改进的地方。

OpenHarmony图表数据导出

import fileIo from '@ohos.file.fs';

class ChartDataExportService {
  async exportToCSV(data: Array<object>, fileName: string): Promise<string> {
    let csvContent = 'Date,Value\n';
    
    for (let item of data) {
      csvContent += `${item['date']},${item['value']}\n`;
    }
    
    let filePath = globalThis.context.filesDir + '/' + fileName + '.csv';
    let file = await fileIo.open(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
    await fileIo.write(file.fd, csvContent);
    await fileIo.close(file);
    
    return filePath;
  }
  
  async exportToJSON(data: Array<object>, fileName: string): Promise<string> {
    let jsonContent = JSON.stringify(data, null, 2);
    
    let filePath = globalThis.context.filesDir + '/' + fileName + '.json';
    let file = await fileIo.open(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
    await fileIo.write(file.fd, jsonContent);
    await fileIo.close(file);
    
    return filePath;
  }
}

图表数据导出服务允许用户将运动数据导出为文件。exportToCSV方法生成CSV格式的文件,这是一种通用的表格数据格式,可以用Excel等软件打开。exportToJSON方法生成JSON格式的文件,便于程序处理和数据迁移。两个方法都使用fileIo模块进行文件操作,文件保存在应用的私有目录中。导出功能让用户可以备份自己的运动数据,或者导入到其他应用进行更深入的分析。

Flutter图表动画效果

class AnimatedBarChart extends StatefulWidget {
  final List<double> values;
  final List<String> labels;
  
  const AnimatedBarChart({Key? key, required this.values, required this.labels}) : super(key: key);
  
  
  State<AnimatedBarChart> createState() => _AnimatedBarChartState();
}

class _AnimatedBarChartState extends State<AnimatedBarChart> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  
  
  void initState() {
    super.initState();
    _controller = AnimationController(duration: Duration(milliseconds: 800), vsync: this);
    _animation = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
    _controller.forward();
  }
  
  
  Widget build(BuildContext context) {
    double maxValue = widget.values.reduce((a, b) => a > b ? a : b);
    
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Container(
          height: 200,
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: List.generate(widget.values.length, (index) {
              double targetHeight = (widget.values[index] / maxValue) * 160;
              double currentHeight = targetHeight * _animation.value;
              
              return Expanded(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: [
                    Container(
                      height: currentHeight,
                      margin: EdgeInsets.symmetric(horizontal: 4),
                      decoration: BoxDecoration(
                        color: Colors.blue,
                        borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
                      ),
                    ),
                    SizedBox(height: 8),
                    Text(widget.labels[index], style: TextStyle(fontSize: 10)),
                  ],
                ),
              );
            }),
          ),
        );
      },
    );
  }
  
  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

图表动画效果提升用户体验,让数据展示更加生动。我们使用AnimationController控制动画时长和进度,CurvedAnimation添加缓动效果使动画更自然。AnimatedBuilder在每一帧重建UI,柱子高度根据动画进度从0增长到目标高度。easeOutCubic曲线使动画开始快结束慢,符合物理直觉。动画在组件初始化时自动播放,用户打开图表页面时会看到柱子从底部生长的效果。这种动画不仅美观,还能引导用户的注意力,帮助他们理解数据。

总结

本文全面介绍了Flutter与OpenHarmony平台上运动数据图表组件的实现方案。从数据模型到查询服务,从折线图到柱状图,从环形图到对比图,涵盖了图表功能的各个方面。通过合理的图表选择和精心的视觉设计,我们可以将枯燥的运动数据转化为直观的可视化展示,帮助用户更好地理解自己的运动表现,发现规律,制定改进计划。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐