【Flutter for OpenHarmony】Flutter 心情数据统计与导出的鸿蒙化适配与实战指南

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net


一、为什么我要做数据导出功能?

我是 IntMainJhy,上海某高校大一计算机专业的学生。说起数据导出功能,我完全是出于"不放心"才加的。

有一天室友问我:“你记录了这么多心情数据,能不能导出到 Excel 里看看?”

我当时的反应是:“什么?导出?不能吧?”

后来我仔细想了一下,确实应该有这个功能。用户辛辛苦苦记录的数据,如果只能在这个 App 里看,万一出什么问题数据丢失了,那不是太可惜了?

于是我开始研究 Flutter 的数据导出功能,最终实现了 CSV 和 JSON 两种格式的导出。


二、数据导出需求分析

2.1 导出功能点

功能 说明
CSV 导出 导出为 Excel 可读的 CSV 格式
JSON 导出 导出为 JSON 格式
分享功能 导出后直接分享
选择日期范围 只导出特定时间段的数据

2.2 依赖库

# pubspec.yaml
dependencies:
  share_plus: ^10.1.4
  path_provider: ^2.1.5
  
  # 可选:CSV 解析
  csv: ^6.0.0

三、导出服务实现

3.1 CSV 导出服务

// lib/mental_health/services/export_service.dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/mood_model.dart';

/// 数据导出服务
class ExportService {
  /// 导出心情记录为 CSV
  Future<String?> exportMoodToCSV(List<MoodEntry> entries) async {
    if (entries.isEmpty) {
      return null;
    }

    try {
      // 构建 CSV 内容
      final buffer = StringBuffer();
      
      // 表头
      buffer.writeln('日期,时间,心情,心情值,备注');

      // 数据行
      for (final entry in entries) {
        final date = _formatDate(entry.date);
        final time = _formatTime(entry.date);
        final mood = entry.mood.label;
        final moodValue = entry.mood.value;
        final note = _escapeCSV(entry.note ?? '');
        
        buffer.writeln('$date,$time,$mood,$moodValue,$note');
      }

      // 保存文件
      final file = await _saveToFile(
        'mood_export_${_getDateString()}.csv',
        buffer.toString(),
      );

      return file;
    } catch (e) {
      debugPrint('导出 CSV 失败: $e');
      return null;
    }
  }

  /// 导出心情记录为 JSON
  Future<String?> exportMoodToJSON(List<MoodEntry> entries) async {
    if (entries.isEmpty) {
      return null;
    }

    try {
      // 构建 JSON 数据
      final data = {
        'exportTime': DateTime.now().toIso8601String(),
        'totalRecords': entries.length,
        'records': entries.map((e) => {
          'date': _formatDate(e.date),
          'time': _formatTime(e.date),
          'mood': e.mood.label,
          'moodValue': e.mood.value,
          'note': e.note,
        }).toList(),
      };

      // 转换为 JSON 字符串
      final jsonString = const JsonEncoder.withIndent('  ').convert(data);

      // 保存文件
      final file = await _saveToFile(
        'mood_export_${_getDateString()}.json',
        jsonString,
      );

      return file;
    } catch (e) {
      debugPrint('导出 JSON 失败: $e');
      return null;
    }
  }

  /// 导出测试记录为 CSV
  Future<String?> exportQuizToCSV(List<QuizHistoryRecord> records) async {
    if (records.isEmpty) {
      return null;
    }

    try {
      final buffer = StringBuffer();
      
      // 表头
      buffer.writeln('日期,时间,测试类型,总分,满分,结果等级');

      // 数据行
      for (final record in records) {
        final date = _formatDate(record.completedAt);
        final time = _formatTime(record.completedAt);
        final type = record.category.name;
        final score = record.totalScore;
        final maxScore = record.maxScore;
        final level = record.level;
        
        buffer.writeln('$date,$time,$type,$score,$maxScore,$level');
      }

      final file = await _saveToFile(
        'quiz_export_${_getDateString()}.csv',
        buffer.toString(),
      );

      return file;
    } catch (e) {
      debugPrint('导出测试记录失败: $e');
      return null;
    }
  }

  /// 分享文件
  Future<void> shareFile(String filePath) async {
    try {
      await Share.shareXFiles(
        [XFile(filePath)],
        subject: '心理健康数据导出',
        text: '这是我记录的心情数据',
      );
    } catch (e) {
      debugPrint('分享文件失败: $e');
    }
  }

  /// 保存到文件
  Future<String> _saveToFile(String fileName, String content) async {
    final directory = await getApplicationDocumentsDirectory();
    final file = File('${directory.path}/$fileName');
    await file.writeAsString(content);
    return file.path;
  }

  /// 格式化日期
  String _formatDate(DateTime date) {
    return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
  }

  /// 格式化时间
  String _formatTime(DateTime date) {
    return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}';
  }

  /// 获取日期字符串
  String _getDateString() {
    final now = DateTime.now();
    return '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
  }

  /// 转义 CSV 内容
  String _escapeCSV(String value) {
    if (value.contains(',') || value.contains('"') || value.contains('\n')) {
      return '"${value.replaceAll('"', '""')}"';
    }
    return value;
  }
}

四、导出 Provider

// lib/mental_health/providers/export_provider.dart

import 'package:flutter/material.dart';
import '../services/export_service.dart';

/// 导出 Provider
class ExportProvider extends ChangeNotifier {
  final ExportService _exportService = ExportService();
  
  bool _isExporting = false;
  String? _lastExportPath;
  String? _error;

  bool get isExporting => _isExporting;
  String? get lastExportPath => _lastExportPath;
  String? get error => _error;

  /// 导出心情数据
  Future<bool> exportMoodData(List<MoodEntry> entries, {String format = 'csv'}) async {
    _isExporting = true;
    _error = null;
    notifyListeners();

    try {
      String? filePath;
      
      if (format == 'csv') {
        filePath = await _exportService.exportMoodToCSV(entries);
      } else if (format == 'json') {
        filePath = await _exportService.exportMoodToJSON(entries);
      }

      if (filePath != null) {
        _lastExportPath = filePath;
        _isExporting = false;
        notifyListeners();
        return true;
      } else {
        _error = '导出失败';
        _isExporting = false;
        notifyListeners();
        return false;
      }
    } catch (e) {
      _error = e.toString();
      _isExporting = false;
      notifyListeners();
      return false;
    }
  }

  /// 导出测试记录
  Future<bool> exportQuizData(List<QuizHistoryRecord> records) async {
    _isExporting = true;
    _error = null;
    notifyListeners();

    try {
      final filePath = await _exportService.exportQuizToCSV(records);
      
      if (filePath != null) {
        _lastExportPath = filePath;
        _isExporting = false;
        notifyListeners();
        return true;
      } else {
        _error = '导出失败';
        _isExporting = false;
        notifyListeners();
        return false;
      }
    } catch (e) {
      _error = e.toString();
      _isExporting = false;
      notifyListeners();
      return false;
    }
  }

  /// 分享导出文件
  Future<void> shareLastExport() async {
    if (_lastExportPath != null) {
      await _exportService.shareFile(_lastExportPath!);
    }
  }
}

五、导出页面

// lib/mental_health/screens/export_screen.dart

import 'package:flutter/material.dart';
import '../providers/export_provider.dart';

/// 数据导出页面
class ExportScreen extends StatelessWidget {
  const ExportScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('数据导出'),
      ),
      body: Consumer2<MoodProvider, QuizHistoryProvider>(
        builder: (context, moodProvider, quizProvider, child) {
          return ListView(
            padding: const EdgeInsets.all(16),
            children: [
              // 说明卡片
              _buildInfoCard(),
              
              const SizedBox(height: 24),
              
              // 心情数据导出
              _buildExportSection(
                context,
                title: '心情记录',
                icon: Icons.mood,
                color: const Color(0xFF6C63FF),
                count: moodProvider.entries.length,
                onExportCSV: () => _exportMood(context, 'csv'),
                onExportJSON: () => _exportMood(context, 'json'),
              ),
              
              const SizedBox(height: 16),
              
              // 测试记录导出
              _buildExportSection(
                context,
                title: '测试记录',
                icon: Icons.psychology,
                color: const Color(0xFFFF6B6B),
                count: quizProvider.records.length,
                onExportCSV: () => _exportQuiz(context),
                onExportJSON: null,
              ),
              
              const SizedBox(height: 24),
              
              // 导出历史
              if (context.watch<ExportProvider>().lastExportPath != null)
                _buildExportHistory(context),
            ],
          );
        },
      ),
    );
  }

  Widget _buildInfoCard() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: const Color(0xFFE3F2FD),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        children: [
          const Icon(Icons.info_outline, color: Color(0xFF2196F3)),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text(
                  '数据导出说明',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 4),
                Text(
                  '导出的数据可以在 Excel 或 Numbers 中打开查看和分析。',
                  style: TextStyle(
                    fontSize: 13,
                    color: Colors.grey[700],
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildExportSection(
    BuildContext context, {
    required String title,
    required IconData icon,
    required Color color,
    required int count,
    required VoidCallback onExportCSV,
    VoidCallback? onExportJSON,
  }) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Container(
                  padding: const EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: color.withOpacity(0.1),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Icon(icon, color: color),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        title,
                        style: const TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      Text(
                        '共 $count 条记录',
                        style: TextStyle(
                          fontSize: 13,
                          color: Colors.grey[600],
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: count > 0 ? onExportCSV : null,
                    icon: const Icon(Icons.table_chart),
                    label: const Text('导出 CSV'),
                  ),
                ),
                if (onExportJSON != null) ...[
                  const SizedBox(width: 12),
                  Expanded(
                    child: OutlinedButton.icon(
                      onPressed: count > 0 ? onExportJSON : null,
                      icon: const Icon(Icons.code),
                      label: const Text('导出 JSON'),
                    ),
                  ),
                ],
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildExportHistory(BuildContext context) {
    return Consumer<ExportProvider>(
      builder: (context, provider, child) {
        return Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Row(
                  children: [
                    Icon(Icons.history, size: 20),
                    SizedBox(width: 8),
                    Text(
                      '最近导出',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                  ],
                ),
                const SizedBox(height: 12),
                ListTile(
                  contentPadding: EdgeInsets.zero,
                  leading: const Icon(Icons.insert_drive_file),
                  title: Text(
                    provider.lastExportPath!.split('/').last,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  trailing: IconButton(
                    icon: const Icon(Icons.share),
                    onPressed: provider.shareLastExport,
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }

  Future<void> _exportMood(BuildContext context, String format) async {
    final moodProvider = context.read<MoodProvider>();
    final exportProvider = context.read<ExportProvider>();
    
    final success = await exportProvider.exportMoodData(
      moodProvider.entries,
      format: format,
    );

    if (context.mounted) {
      if (success) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: const Text('导出成功!'),
            action: SnackBarAction(
              label: '分享',
              onPressed: () => exportProvider.shareLastExport(),
            ),
          ),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('导出失败: ${exportProvider.error}'),
            backgroundColor: Colors.red,
          ),
        );
      }
    }
  }

  Future<void> _exportQuiz(BuildContext context) async {
    final quizProvider = context.read<QuizHistoryProvider>();
    final exportProvider = context.read<ExportProvider>();
    
    final success = await exportProvider.exportQuizData(quizProvider.records);

    if (context.mounted) {
      if (success) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: const Text('导出成功!'),
            action: SnackBarAction(
              label: '分享',
              onPressed: () => exportProvider.shareLastExport(),
            ),
          ),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('导出失败: ${exportProvider.error}'),
            backgroundColor: Colors.red,
          ),
        );
      }
    }
  }
}

六、CSV 格式说明

6.1 心情记录 CSV

日期,时间,心情,心情值,备注
2026-05-01,20:30:00,开心,5,今天考试考得不错!
2026-05-02,21:00:00,平静,4,
2026-05-03,22:00:00,一般,3,有点累

6.2 测试记录 CSV

日期,时间,测试类型,总分,满分,结果等级
2026-05-01,15:30:00,PHQ-9,8,27,轻度抑郁
2026-05-03,14:00:00,GAD-7,6,21,轻度焦虑

七、鸿蒙平台适配

适配点:文件路径

鸿蒙设备上的文件路径可能不同:

Future<String> _saveToFile(String fileName, String content) async {
  // 鸿蒙设备使用应用文档目录
  final directory = await getApplicationDocumentsDirectory();
  final file = File('${directory.path}/$fileName');
  await file.writeAsString(content);
  return file.path;
}

八、我的踩坑记录

坑1:CSV 中含有特殊字符

问题:导出的 CSV 中含有逗号或换行符,导致格式错乱。

解决

String _escapeCSV(String value) {
  if (value.contains(',') || value.contains('"') || value.contains('\n')) {
    return '"${value.replaceAll('"', '""')}"';
  }
  return value;
}

坑2:文件保存失败

问题:无法保存文件到指定路径。

原因:权限问题或路径不存在。

解决:确保使用正确的应用目录。


坑3:分享功能不工作

问题:调用 share 时没有任何反应。

解决

await Share.shareXFiles(
  [XFile(filePath)],
  subject: '标题',
  text: '描述',
);

九、功能验证清单

序号 检查项 预期结果
1 导出 CSV 生成正确的 CSV 文件
2 导出 JSON 生成正确的 JSON 文件
3 分享功能 可以分享文件
4 无数据提示 无数据时提示用户

十、大一学生真实学习总结

数据导出功能让我学到了文件操作和分享功能。

最重要的几点:

  1. CSV 格式要注意转义

    • 特殊字符需要转义
    • 否则 Excel 打开会错乱
  2. 使用 share_plus 分享

    • 可以分享到微信、邮件等
    • 非常方便
  3. 鸿蒙路径问题

    • 鸿蒙设备路径可能不同
    • 要用 getApplicationDocumentsDirectory()

作者:IntMainJhy
创作时间:2026年5月
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/5b0bb5ed484b41b68fc6e7c766d563bd.pn

更多推荐