Flutter CSV导入导出:大数据处理与用户体验优化
本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何在Flutter应用中实现高效、用户友好的CSV数据导入导出功能。
项目背景
BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。
引言
数据的导入导出是现代应用的基本需求,特别是对于财务管理类应用。用户希望能够:
- 从其他记账软件迁移数据
- 定期备份数据到本地文件
- 在电脑上进行数据分析处理
- 与会计软件进行数据交换
CSV格式因其简单性和通用性,成为了数据交换的首选格式。但在移动端实现高效的CSV处理并不简单,需要考虑性能、内存占用、用户体验等多个方面。
CSV处理架构设计
核心组件架构
dart
// CSV处理服务接口
abstract class CsvService {
Future<CsvExportResult> exportTransactions({
required int ledgerId,
required DateTimeRange dateRange,
required CsvExportOptions options,
});
Future<CsvImportResult> importTransactions({
required String csvContent,
required int ledgerId,
required CsvImportOptions options,
void Function(double progress)? onProgress,
});
Future<List<CsvColumn>> analyzeCsvStructure(String csvContent);
}
// CSV导出选项
class CsvExportOptions {
final bool includeHeader;
final String separator;
final String encoding;
final List<String> columns;
final TransactionFilter? filter;
const CsvExportOptions({
this.includeHeader = true,
this.separator = ',',
this.encoding = 'utf-8',
required this.columns,
this.filter,
});
}
// CSV导入选项
class CsvImportOptions {
final bool hasHeader;
final String separator;
final String encoding;
final Map<String, String> columnMapping;
final bool skipDuplicates;
final ConflictResolution conflictResolution;
const CsvImportOptions({
this.hasHeader = true,
this.separator = ',',
this.encoding = 'utf-8',
required this.columnMapping,
this.skipDuplicates = true,
this.conflictResolution = ConflictResolution.skip,
});
}
// 导出结果
class CsvExportResult {
final bool success;
final String? filePath;
final int recordCount;
final String? error;
const CsvExportResult({
required this.success,
this.filePath,
required this.recordCount,
this.error,
});
factory CsvExportResult.success({
required String filePath,
required int recordCount,
}) {
return CsvExportResult(
success: true,
filePath: filePath,
recordCount: recordCount,
);
}
factory CsvExportResult.failure(String error) {
return CsvExportResult(
success: false,
error: error,
recordCount: 0,
);
}
}
// 导入结果
class CsvImportResult {
final bool success;
final int totalRows;
final int importedRows;
final int skippedRows;
final List<ImportError> errors;
const CsvImportResult({
required this.success,
required this.totalRows,
required this.importedRows,
required this.skippedRows,
required this.errors,
});
}
CSV服务实现
dart
class CsvServiceImpl implements CsvService {
final BeeRepository repository;
final Logger logger;
CsvServiceImpl({
required this.repository,
required this.logger,
});
@override
Future<CsvExportResult> exportTransactions({
required int ledgerId,
required DateTimeRange dateRange,
required CsvExportOptions options,
}) async {
try {
logger.info('Starting CSV export for ledger $ledgerId');
// 获取交易数据
final transactions = await repository.getTransactionsInRange(
ledgerId: ledgerId,
range: dateRange,
filter: options.filter,
);
if (transactions.isEmpty) {
return CsvExportResult.failure('没有找到符合条件的交易记录');
}
// 生成CSV内容
final csvContent = await _generateCsvContent(
transactions,
options,
);
// 保存文件
final filePath = await _saveCsvFile(
csvContent,
'transactions_export_${DateTime.now().millisecondsSinceEpoch}.csv',
);
logger.info('CSV export completed: ${transactions.length} records');
return CsvExportResult.success(
filePath: filePath,
recordCount: transactions.length,
);
} catch (e, stackTrace) {
logger.error('CSV export failed', e, stackTrace);
return CsvExportResult.failure('导出失败: $e');
}
}
@override
Future<CsvImportResult> importTransactions({
required String csvContent,
required int ledgerId,
required CsvImportOptions options,
void Function(double progress)? onProgress,
}) async {
try {
logger.info('Starting CSV import for ledger $ledgerId');
// 解析CSV内容
final List<List<String>> rows = await _parseCsvContent(
csvContent,
options,
);
if (rows.isEmpty) {
return CsvImportResult(
success: false,
totalRows: 0,
importedRows: 0,
skippedRows: 0,
errors: [ImportError(row: 0, message: 'CSV文件为空')],
);
}
// 处理表头
int startRow = options.hasHeader ? 1 : 0;
final dataRows = rows.skip(startRow).toList();
// 批量导入
final result = await _importRows(
dataRows,
ledgerId,
options,
onProgress,
);
logger.info('CSV import completed: ${result.importedRows}/${result.totalRows}');
return result;
} catch (e, stackTrace) {
logger.error('CSV import failed', e, stackTrace);
return CsvImportResult(
success: false,
totalRows: 0,
importedRows: 0,
skippedRows: 0,
errors: [ImportError(row: 0, message: '导入失败: $e')],
);
}
}
Future<String> _generateCsvContent(
List<TransactionWithDetails> transactions,
CsvExportOptions options,
) async {
final StringBuffer buffer = StringBuffer();
// 添加表头
if (options.includeHeader) {
final headers = options.columns.map((col) => _getColumnDisplayName(col));
buffer.writeln(headers.join(options.separator));
}
// 添加数据行
for (final transaction in transactions) {
final row = options.columns.map((column) =>
_formatCellValue(_getTransactionValue(transaction, column))
);
buffer.writeln(row.join(options.separator));
}
return buffer.toString();
}
String _getTransactionValue(TransactionWithDetails transaction, String column) {
switch (column) {
case 'date':
return DateFormat('yyyy-MM-dd').format(transaction.happenedAt);
case 'time':
return DateFormat('HH:mm:ss').format(transaction.happenedAt);
case 'type':
return _getTypeDisplayName(transaction.type);
case 'amount':
return transaction.amount.toStringAsFixed(2);
case 'category':
return transaction.categoryName ?? '';
case 'account':
return transaction.accountName ?? '';
case 'toAccount':
return transaction.toAccountName ?? '';
case 'note':
return transaction.note ?? '';
default:
return '';
}
}
String _formatCellValue(String value) {
// 处理包含逗号、引号、换行符的值
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
return '"${value.replaceAll('"', '""')}"';
}
return value;
}
Future<String> _saveCsvFile(String content, String fileName) async {
final directory = await getApplicationDocumentsDirectory();
final file = File(path.join(directory.path, fileName));
await file.writeAsString(content, encoding: utf8);
return file.path;
}
Future<List<List<String>>> _parseCsvContent(
String content,
CsvImportOptions options,
) async {
// 使用csv包解析内容
return const CsvToListConverter(
fieldDelimiter: ',',
textDelimiter: '"',
eol: '\n',
).convert(content);
}
Future<CsvImportResult> _importRows(
List<List<String>> rows,
int ledgerId,
CsvImportOptions options,
void Function(double progress)? onProgress,
) async {
int importedCount = 0;
int skippedCount = 0;
final List<ImportError> errors = [];
// 批量处理,每次处理100行
const batchSize = 100;
final totalRows = rows.length;
for (int i = 0; i < rows.length; i += batchSize) {
final batchEnd = math.min(i + batchSize, rows.length);
final batch = rows.sublist(i, batchEnd);
final batchResult = await _processBatch(
batch,
ledgerId,
options,
i, // 起始行号
);
importedCount += batchResult.importedRows;
skippedCount += batchResult.skippedRows;
errors.addAll(batchResult.errors);
// 更新进度
if (onProgress != null) {
final progress = batchEnd / totalRows;
onProgress(progress);
}
// 让出控制权,避免阻塞UI
await Future.delayed(const Duration(milliseconds: 10));
}
return CsvImportResult(
success: errors.isEmpty || importedCount > 0,
totalRows: totalRows,
importedRows: importedCount,
skippedRows: skippedCount,
errors: errors,
);
}
Future<BatchImportResult> _processBatch(
List<List<String>> batch,
int ledgerId,
CsvImportOptions options,
int startRowIndex,
) async {
int importedCount = 0;
int skippedCount = 0;
final List<ImportError> errors = [];
final List<Transaction> transactionsToInsert = [];
for (int i = 0; i < batch.length; i++) {
final rowIndex = startRowIndex + i;
final row = batch[i];
try {
final transaction = _parseTransactionFromRow(
row,
ledgerId,
options.columnMapping,
rowIndex,
);
if (transaction != null) {
// 检查是否跳过重复项
if (options.skipDuplicates) {
final exists = await repository.checkTransactionExists(
ledgerId: ledgerId,
amount: transaction.amount,
happenedAt: transaction.happenedAt,
note: transaction.note,
);
if (exists) {
skippedCount++;
continue;
}
}
transactionsToInsert.add(transaction);
importedCount++;
} else {
skippedCount++;
}
} catch (e) {
errors.add(ImportError(
row: rowIndex + 1,
message: e.toString(),
));
skippedCount++;
}
}
// 批量插入交易
if (transactionsToInsert.isNotEmpty) {
await repository.insertTransactionsBatch(transactionsToInsert);
}
return BatchImportResult(
importedRows: importedCount,
skippedRows: skippedCount,
errors: errors,
);
}
Transaction? _parseTransactionFromRow(
List<String> row,
int ledgerId,
Map<String, String> columnMapping,
int rowIndex,
) {
try {
// 解析必需字段
final dateStr = _getColumnValue(row, columnMapping, 'date');
final amountStr = _getColumnValue(row, columnMapping, 'amount');
final typeStr = _getColumnValue(row, columnMapping, 'type');
if (dateStr.isEmpty || amountStr.isEmpty || typeStr.isEmpty) {
throw Exception('缺少必需字段:日期、金额或类型');
}
// 解析日期
final date = _parseDate(dateStr);
if (date == null) {
throw Exception('日期格式不正确:$dateStr');
}
// 解析金额
final amount = double.tryParse(amountStr);
if (amount == null || amount <= 0) {
throw Exception('金额格式不正确:$amountStr');
}
// 解析类型
final type = _parseTransactionType(typeStr);
if (type == null) {
throw Exception('交易类型不支持:$typeStr');
}
// 解析可选字段
final note = _getColumnValue(row, columnMapping, 'note');
final categoryName = _getColumnValue(row, columnMapping, 'category');
final accountName = _getColumnValue(row, columnMapping, 'account');
return Transaction(
id: 0, // 将由数据库自动分配
ledgerId: ledgerId,
type: type,
amount: amount,
categoryId: await _getCategoryId(ledgerId, categoryName, type),
accountId: await _getAccountId(ledgerId, accountName),
happenedAt: date,
note: note.isEmpty ? null : note,
);
} catch (e) {
logger.warning('Failed to parse row $rowIndex: $e');
return null;
}
}
String _getColumnValue(List<String> row, Map<String, String> mapping, String logicalColumn) {
final physicalColumn = mapping[logicalColumn];
if (physicalColumn == null) return '';
final columnIndex = int.tryParse(physicalColumn);
if (columnIndex == null || columnIndex >= row.length) return '';
return row[columnIndex].trim();
}
DateTime? _parseDate(String dateStr) {
// 尝试多种日期格式
final formats = [
'yyyy-MM-dd',
'yyyy/MM/dd',
'MM/dd/yyyy',
'dd/MM/yyyy',
'yyyy-MM-dd HH:mm:ss',
'MM/dd/yyyy HH:mm:ss',
];
for (final format in formats) {
try {
return DateFormat(format).parse(dateStr);
} catch (_) {
continue;
}
}
return null;
}
String? _parseTransactionType(String typeStr) {
final type = typeStr.toLowerCase();
switch (type) {
case '支出':
case 'expense':
case '出':
case '-':
return 'expense';
case '收入':
case 'income':
case '入':
case '+':
return 'income';
case '转账':
case 'transfer':
case '转':
return 'transfer';
default:
return null;
}
}
}
文件选择与预览
文件选择器实现
dart
class CsvImportPage extends ConsumerStatefulWidget {
const CsvImportPage({Key? key}) : super(key: key);
@override
ConsumerState<CsvImportPage> createState() => _CsvImportPageState();
}
class _CsvImportPageState extends ConsumerState<CsvImportPage> {
String? _selectedFilePath;
List<List<String>>? _previewData;
CsvImportOptions? _importOptions;
bool _isAnalyzing = false;
bool _isImporting = false;
double _importProgress = 0.0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('导入CSV'),
actions: [
if (_previewData != null && _importOptions != null)
TextButton(
onPressed: _isImporting ? null : _startImport,
child: const Text('导入'),
),
],
),
body: Column(
children: [
if (_isImporting) _buildProgressIndicator(),
Expanded(
child: _selectedFilePath == null
? _buildFilePicker()
: _buildPreviewAndMapping(),
),
],
),
);
}
Widget _buildFilePicker() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.upload_file,
size: 80,
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
),
const SizedBox(height: 24),
Text(
'选择CSV文件',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'支持从其他记账应用导出的CSV文件',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: _pickFile,
icon: const Icon(Icons.folder_open),
label: const Text('选择文件'),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: _showImportGuide,
icon: const Icon(Icons.help_outline),
label: const Text('导入说明'),
),
],
),
);
}
Widget _buildPreviewAndMapping() {
return Column(
children: [
// 文件信息
Card(
margin: const EdgeInsets.all(16),
child: ListTile(
leading: const Icon(Icons.description),
title: Text(path.basename(_selectedFilePath!)),
subtitle: Text('${_previewData?.length ?? 0} 行数据'),
trailing: IconButton(
onPressed: _clearSelection,
icon: const Icon(Icons.close),
),
),
),
// 预览和字段映射
Expanded(
child: DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(text: '数据预览'),
Tab(text: '字段映射'),
],
),
Expanded(
child: TabBarView(
children: [
_buildDataPreview(),
_buildFieldMapping(),
],
),
),
],
),
),
),
],
);
}
Widget _buildDataPreview() {
if (_previewData == null || _previewData!.isEmpty) {
return const Center(child: Text('无数据可预览'));
}
// 只显示前10行数据
final previewRows = _previewData!.take(10).toList();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 20,
columns: previewRows.first.asMap().entries.map((entry) {
return DataColumn(
label: Text(
'列 ${entry.key + 1}',
style: Theme.of(context).textTheme.bodySmall,
),
);
}).toList(),
rows: previewRows.skip(1).map((row) {
return DataRow(
cells: row.map((cell) {
return DataCell(
Container(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
cell,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
);
}).toList(),
);
}).toList(),
),
),
);
}
Widget _buildFieldMapping() {
if (_previewData == null || _previewData!.isEmpty) {
return const Center(child: Text('无数据可映射'));
}
final headers = _previewData!.first;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'字段映射',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'请将CSV文件的列映射到对应的交易字段',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
...{
'date': '日期 *',
'amount': '金额 *',
'type': '类型 *',
'category': '分类',
'account': '账户',
'note': '备注',
}.entries.map((entry) {
return _buildFieldMappingRow(
entry.key,
entry.value,
headers,
);
}).toList(),
const SizedBox(height: 24),
// 导入选项
_buildImportOptions(),
],
),
);
}
Widget _buildFieldMappingRow(
String field,
String displayName,
List<String> headers,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(
displayName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: displayName.contains('*')
? FontWeight.w600
: FontWeight.normal,
),
),
),
Expanded(
child: DropdownButtonFormField<String>(
value: _importOptions?.columnMapping[field],
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
hint: const Text('选择列'),
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('不映射'),
),
...headers.asMap().entries.map((entry) {
return DropdownMenuItem<String>(
value: entry.key.toString(),
child: Text('列${entry.key + 1}: ${entry.value}'),
);
}),
],
onChanged: (value) {
_updateFieldMapping(field, value);
},
),
),
],
),
);
}
Widget _buildImportOptions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'导入选项',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('第一行为标题行'),
subtitle: const Text('勾选则跳过第一行数据'),
value: _importOptions?.hasHeader ?? true,
onChanged: (value) {
_updateImportOption('hasHeader', value);
},
),
SwitchListTile(
title: const Text('跳过重复记录'),
subtitle: const Text('根据金额、日期和备注判断重复'),
value: _importOptions?.skipDuplicates ?? true,
onChanged: (value) {
_updateImportOption('skipDuplicates', value);
},
),
],
);
}
Widget _buildProgressIndicator() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
LinearProgressIndicator(value: _importProgress),
const SizedBox(height: 8),
Text(
'导入进度: ${(_importProgress * 100).toInt()}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
Future<void> _pickFile() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
allowMultiple: false,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.path != null) {
setState(() {
_selectedFilePath = file.path;
_isAnalyzing = true;
});
await _analyzeCsvFile();
}
}
} catch (e) {
_showErrorDialog('文件选择失败: $e');
}
}
Future<void> _analyzeCsvFile() async {
try {
final file = File(_selectedFilePath!);
final content = await file.readAsString();
// 解析CSV预览数据
final rows = const CsvToListConverter().convert(content);
setState(() {
_previewData = rows;
_importOptions = CsvImportOptions(
hasHeader: true,
skipDuplicates: true,
columnMapping: {},
conflictResolution: ConflictResolution.skip,
);
_isAnalyzing = false;
});
// 尝试智能映射字段
_attemptAutoMapping();
} catch (e) {
setState(() {
_isAnalyzing = false;
});
_showErrorDialog('文件解析失败: $e');
}
}
void _attemptAutoMapping() {
if (_previewData == null || _previewData!.isEmpty) return;
final headers = _previewData!.first.map((h) => h.toLowerCase()).toList();
final Map<String, String> autoMapping = {};
// 智能匹配字段
for (int i = 0; i < headers.length; i++) {
final header = headers[i];
if (header.contains('日期') || header.contains('date') || header.contains('time')) {
autoMapping['date'] = i.toString();
} else if (header.contains('金额') || header.contains('amount') || header.contains('money')) {
autoMapping['amount'] = i.toString();
} else if (header.contains('类型') || header.contains('type') || header.contains('kind')) {
autoMapping['type'] = i.toString();
} else if (header.contains('分类') || header.contains('category')) {
autoMapping['category'] = i.toString();
} else if (header.contains('账户') || header.contains('account')) {
autoMapping['account'] = i.toString();
} else if (header.contains('备注') || header.contains('note') || header.contains('memo')) {
autoMapping['note'] = i.toString();
}
}
setState(() {
_importOptions = _importOptions!.copyWith(columnMapping: autoMapping);
});
}
void _updateFieldMapping(String field, String? columnIndex) {
final newMapping = Map<String, String>.from(_importOptions!.columnMapping);
if (columnIndex != null) {
newMapping[field] = columnIndex;
} else {
newMapping.remove(field);
}
setState(() {
_importOptions = _importOptions!.copyWith(columnMapping: newMapping);
});
}
void _updateImportOption(String option, dynamic value) {
setState(() {
switch (option) {
case 'hasHeader':
_importOptions = _importOptions!.copyWith(hasHeader: value);
break;
case 'skipDuplicates':
_importOptions = _importOptions!.copyWith(skipDuplicates: value);
break;
}
});
}
Future<void> _startImport() async {
// 验证必需字段
final requiredFields = ['date', 'amount', 'type'];
final missingFields = requiredFields.where(
(field) => !_importOptions!.columnMapping.containsKey(field),
).toList();
if (missingFields.isNotEmpty) {
_showErrorDialog('请映射必需字段: ${missingFields.join(', ')}');
return;
}
setState(() {
_isImporting = true;
_importProgress = 0.0;
});
try {
final csvService = ref.read(csvServiceProvider);
final currentLedgerId = ref.read(currentLedgerIdProvider);
final file = File(_selectedFilePath!);
final content = await file.readAsString();
final result = await csvService.importTransactions(
csvContent: content,
ledgerId: currentLedgerId,
options: _importOptions!,
onProgress: (progress) {
setState(() {
_importProgress = progress;
});
},
);
setState(() {
_isImporting = false;
});
_showImportResult(result);
} catch (e) {
setState(() {
_isImporting = false;
});
_showErrorDialog('导入失败: $e');
}
}
void _showImportResult(CsvImportResult result) {
showDialog(
context: context,
builder: (context) => ImportResultDialog(result: result),
);
}
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('错误'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
);
}
void _clearSelection() {
setState(() {
_selectedFilePath = null;
_previewData = null;
_importOptions = null;
});
}
void _showImportGuide() {
showDialog(
context: context,
builder: (context) => const ImportGuideDialog(),
);
}
}
导出功能实现
导出选项配置
dart
class CsvExportPage extends ConsumerStatefulWidget {
const CsvExportPage({Key? key}) : super(key: key);
@override
ConsumerState<CsvExportPage> createState() => _CsvExportPageState();
}
class _CsvExportPageState extends ConsumerState<CsvExportPage> {
DateTimeRange _dateRange = DateTimeRange(
start: DateTime.now().subtract(const Duration(days: 30)),
end: DateTime.now(),
);
final Set<String> _selectedColumns = {
'date',
'type',
'amount',
'category',
'account',
'note',
};
bool _isExporting = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('导出CSV'),
actions: [
TextButton(
onPressed: _isExporting ? null : _startExport,
child: const Text('导出'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDateRangeSelector(),
const SizedBox(height: 24),
_buildColumnSelector(),
const SizedBox(height: 24),
_buildExportOptions(),
],
),
),
);
}
Widget _buildDateRangeSelector() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'选择时间范围',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildDateButton(
'开始日期',
_dateRange.start,
(date) {
setState(() {
_dateRange = DateTimeRange(
start: date,
end: _dateRange.end,
);
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDateButton(
'结束日期',
_dateRange.end,
(date) {
setState(() {
_dateRange = DateTimeRange(
start: _dateRange.start,
end: date,
);
});
},
),
),
],
),
const SizedBox(height: 16),
// 快捷选择按钮
Wrap(
spacing: 8,
children: [
_buildQuickRangeChip('最近7天', 7),
_buildQuickRangeChip('最近30天', 30),
_buildQuickRangeChip('最近90天', 90),
_buildQuickRangeChip('本年', 365),
],
),
],
),
),
);
}
Widget _buildDateButton(String label, DateTime date, Function(DateTime) onSelected) {
return OutlinedButton(
onPressed: () async {
final selected = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (selected != null) {
onSelected(selected);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
Text(
DateFormat('yyyy-MM-dd').format(date),
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
Widget _buildQuickRangeChip(String label, int days) {
return ActionChip(
label: Text(label),
onPressed: () {
setState(() {
_dateRange = DateTimeRange(
start: DateTime.now().subtract(Duration(days: days)),
end: DateTime.now(),
);
});
},
);
}
Widget _buildColumnSelector() {
final availableColumns = {
'date': '日期',
'time': '时间',
'type': '类型',
'amount': '金额',
'category': '分类',
'account': '账户',
'toAccount': '转入账户',
'note': '备注',
};
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'选择导出字段',
style: Theme.of(context).textTheme.titleMedium,
),
Row(
children: [
TextButton(
onPressed: () {
setState(() {
_selectedColumns.addAll(availableColumns.keys);
});
},
child: const Text('全选'),
),
TextButton(
onPressed: () {
setState(() {
_selectedColumns.clear();
});
},
child: const Text('清空'),
),
],
),
],
),
const SizedBox(height: 8),
...availableColumns.entries.map((entry) {
return CheckboxListTile(
title: Text(entry.value),
value: _selectedColumns.contains(entry.key),
onChanged: (value) {
setState(() {
if (value ?? false) {
_selectedColumns.add(entry.key);
} else {
_selectedColumns.remove(entry.key);
}
});
},
contentPadding: EdgeInsets.zero,
);
}).toList(),
],
),
),
);
}
Widget _buildExportOptions() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'导出选项',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('包含表头'),
subtitle: const Text('在第一行包含字段名称'),
value: true,
onChanged: null, // 暂时固定为true
contentPadding: EdgeInsets.zero,
),
ListTile(
title: const Text('文件格式'),
subtitle: const Text('UTF-8 编码的CSV文件'),
trailing: const Text('CSV'),
contentPadding: EdgeInsets.zero,
),
],
),
),
);
}
Future<void> _startExport() async {
if (_selectedColumns.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择至少一个导出字段')),
);
return;
}
setState(() {
_isExporting = true;
});
try {
final csvService = ref.read(csvServiceProvider);
final currentLedgerId = ref.read(currentLedgerIdProvider);
final result = await csvService.exportTransactions(
ledgerId: currentLedgerId,
dateRange: _dateRange,
options: CsvExportOptions(
columns: _selectedColumns.toList(),
includeHeader: true,
),
);
setState(() {
_isExporting = false;
});
if (result.success) {
_showExportSuccess(result);
} else {
_showErrorDialog(result.error ?? '导出失败');
}
} catch (e) {
setState(() {
_isExporting = false;
});
_showErrorDialog('导出失败: $e');
}
}
void _showExportSuccess(CsvExportResult result) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('导出成功'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('已导出 ${result.recordCount} 条记录'),
const SizedBox(height: 8),
Text('文件位置: ${result.filePath}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
FilledButton(
onPressed: () {
// 分享文件
Share.shareFiles([result.filePath!]);
Navigator.pop(context);
},
child: const Text('分享'),
),
],
),
);
}
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('错误'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
);
}
}
性能优化策略
流式处理大文件
dart
class StreamCsvProcessor {
static Future<void> processLargeFile({
required String filePath,
required Function(List<String> row, int rowIndex) onRow,
required Function(double progress) onProgress,
}) async {
final file = File(filePath);
final fileLength = await file.length();
int processedBytes = 0;
final stream = file.openRead();
final lines = stream
.transform(utf8.decoder)
.transform(const LineSplitter());
int rowIndex = 0;
await for (final line in lines) {
// 解析CSV行
final row = _parseCsvLine(line);
// 处理行数据
await onRow(row, rowIndex);
// 更新进度
processedBytes += line.length + 1; // +1 for newline
final progress = processedBytes / fileLength;
onProgress(progress.clamp(0.0, 1.0));
rowIndex++;
// 每处理100行让出一次控制权
if (rowIndex % 100 == 0) {
await Future.delayed(const Duration(milliseconds: 1));
}
}
}
static List<String> _parseCsvLine(String line) {
// 简化的CSV行解析,实际使用应该用专业的CSV解析器
final List<String> fields = [];
bool inQuotes = false;
StringBuffer currentField = StringBuffer();
for (int i = 0; i < line.length; i++) {
final char = line[i];
if (char == '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
// 转义的引号
currentField.write('"');
i++; // 跳过下一个引号
} else {
// 切换引号状态
inQuotes = !inQuotes;
}
} else if (char == ',' && !inQuotes) {
// 字段分隔符
fields.add(currentField.toString());
currentField.clear();
} else {
currentField.write(char);
}
}
// 添加最后一个字段
fields.add(currentField.toString());
return fields;
}
}
内存使用优化
dart
class MemoryEfficientCsvImporter {
static const int _batchSize = 100;
static const int _maxMemoryRows = 1000;
static Future<CsvImportResult> importWithMemoryLimit({
required String csvContent,
required Function(List<Transaction>) onBatch,
required Function(double progress) onProgress,
}) async {
int totalRows = 0;
int importedRows = 0;
final List<ImportError> errors = [];
// 分块处理CSV内容
final chunks = _splitIntoChunks(csvContent, _maxMemoryRows);
for (int chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
final chunk = chunks[chunkIndex];
final chunkResult = await _processChunk(
chunk,
totalRows,
onBatch,
);
totalRows += chunkResult.totalRows;
importedRows += chunkResult.importedRows;
errors.addAll(chunkResult.errors);
// 更新进度
final progress = (chunkIndex + 1) / chunks.length;
onProgress(progress);
// 强制垃圾回收
if (chunkIndex % 5 == 0) {
await _forceGarbageCollection();
}
}
return CsvImportResult(
success: errors.isEmpty || importedRows > 0,
totalRows: totalRows,
importedRows: importedRows,
skippedRows: totalRows - importedRows,
errors: errors,
);
}
static List<String> _splitIntoChunks(String content, int maxRowsPerChunk) {
final lines = content.split('\n');
final List<String> chunks = [];
for (int i = 0; i < lines.length; i += maxRowsPerChunk) {
final end = math.min(i + maxRowsPerChunk, lines.length);
final chunkLines = lines.sublist(i, end);
chunks.add(chunkLines.join('\n'));
}
return chunks;
}
static Future<ChunkImportResult> _processChunk(
String chunk,
int startRowIndex,
Function(List<Transaction>) onBatch,
) async {
// 解析块数据
final rows = const CsvToListConverter().convert(chunk);
final List<Transaction> transactions = [];
final List<ImportError> errors = [];
for (int i = 0; i < rows.length; i++) {
try {
final transaction = _parseTransaction(rows[i]);
if (transaction != null) {
transactions.add(transaction);
// 达到批次大小时处理
if (transactions.length >= _batchSize) {
await onBatch(List.from(transactions));
transactions.clear();
}
}
} catch (e) {
errors.add(ImportError(
row: startRowIndex + i + 1,
message: e.toString(),
));
}
}
// 处理剩余交易
if (transactions.isNotEmpty) {
await onBatch(transactions);
}
return ChunkImportResult(
totalRows: rows.length,
importedRows: rows.length - errors.length,
errors: errors,
);
}
static Future<void> _forceGarbageCollection() async {
// 触发垃圾回收的技巧
final List<List<int>> dummy = [];
for (int i = 0; i < 100; i++) {
dummy.add(List.filled(1000, i));
}
dummy.clear();
// 让出控制权,给垃圾回收器时间
await Future.delayed(const Duration(milliseconds: 10));
}
}
最佳实践总结
1. 文件处理原则
- 分批处理:大文件分批处理,避免内存溢出
- 流式处理:使用流式读取处理超大文件
- 错误恢复:提供重试和断点续传机制
2. 用户体验优化
- 进度反馈:实时显示处理进度
- 错误提示:清晰的错误信息和解决建议
- 智能映射:自动识别和映射常见字段
3. 数据验证
- 格式验证:严格验证数据格式和类型
- 业务验证:检查数据的业务逻辑正确性
- 重复检测:提供重复数据检测和处理选项
4. 性能考虑
- 内存管理:控制内存使用,及时释放资源
- 并发限制:限制并发操作数量
- 缓存策略:合理使用缓存提升性能
实际应用效果
在BeeCount项目中,CSV导入导出功能带来了显著价值:
- 用户迁移便利:支持从其他记账应用快速迁移数据
- 数据安全保障:提供本地数据备份和恢复能力
- 分析能力增强:导出数据进行深度分析
- 用户满意度提升:解决了数据互操作性问题
结语
CSV数据处理是移动应用的重要功能,需要在功能完整性、性能效率和用户体验之间找到平衡。通过合理的架构设计、性能优化和用户体验考虑,我们可以构建出既强大又易用的数据处理系统。
BeeCount的实践证明,优秀的CSV处理功能不仅能解决用户的实际需求,还能提升应用的专业性和竞争力,为用户提供真正的价值。
关于BeeCount项目
项目特色
- 🎯 现代架构: 基于Riverpod + Drift + Supabase的现代技术栈
- 📱 跨平台支持: iOS、Android双平台原生体验
- 🔄 云端同步: 支持多设备数据实时同步
- 🎨 个性化定制: Material Design 3主题系统
- 📊 数据分析: 完整的财务数据可视化
- 🌍 国际化: 多语言本地化支持
技术栈一览
- 框架: Flutter 3.6.1+ / Dart 3.6.1+
- 状态管理: Flutter Riverpod 2.5.1
- 数据库: Drift (SQLite) 2.20.2
- 云服务: Supabase 2.5.6
- 图表: FL Chart 0.68.0
- CI/CD: GitHub Actions
开源信息
BeeCount是一个完全开源的项目,欢迎开发者参与贡献:
- 项目主页 : https://github.com/TNT-Likely/BeeCount
- 开发者主页 : https://github.com/TNT-Likely
- 发布下载 : GitHub Releases
参考资源
官方文档
- Dart CSV包文档 - Dart官方CSV处理库
- Flutter文件处理指南 - Flutter文件操作最佳实践
学习资源
本文是BeeCount技术文章系列的第6篇,后续将深入探讨国际化、CI/CD等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!