【Flutter for OpenHarmony】Flutter 心情数据统计与导出的鸿蒙化适配与实战指南
·
【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 | 无数据提示 | 无数据时提示用户 |
十、大一学生真实学习总结
数据导出功能让我学到了文件操作和分享功能。
最重要的几点:
-
CSV 格式要注意转义
- 特殊字符需要转义
- 否则 Excel 打开会错乱
-
使用 share_plus 分享
- 可以分享到微信、邮件等
- 非常方便
-
鸿蒙路径问题
- 鸿蒙设备路径可能不同
- 要用
getApplicationDocumentsDirectory()
作者:IntMainJhy
创作时间:2026年5月
