Flutter CSV导入导出:大数据处理与用户体验优化

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导入导出功能带来了显著价值:

  1. 用户迁移便利:支持从其他记账应用快速迁移数据
  2. 数据安全保障:提供本地数据备份和恢复能力
  3. 分析能力增强:导出数据进行深度分析
  4. 用户满意度提升:解决了数据互操作性问题

结语

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是一个完全开源的项目,欢迎开发者参与贡献:

参考资源

官方文档

学习资源


本文是BeeCount技术文章系列的第6篇,后续将深入探讨国际化、CI/CD等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!