Flutter CSV导入导出:大数据处理与用户体验优化
本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何在Flutter应用中实现高效、用户友好的CSV数据导入导出功能。
项目背景
BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。
引言
数据的导入导出是现代应用的基本需求,特别是对于财务管理类应用。用户希望能够:
- 从其他记账软件迁移数据
- 定期备份数据到本地文件
- 在电脑上进行数据分析处理
- 与会计软件进行数据交换
CSV格式因其简单性和通用性,成为了数据交换的首选格式。但在移动端实现高效的CSV处理并不简单,需要考虑性能、内存占用、用户体验等多个方面。
CSV处理架构设计
核心组件架构
- // 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服务实现
- 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;
- }
- }
- }
复制代码 文件选择与预览
文件选择器实现
- 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(),
- );
- }
- }
复制代码 导出功能实现
导出选项配置
- 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('确定'),
- ),
- ],
- ),
- );
- }
- }
复制代码 性能优化策略
流式处理大文件
- 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;
- }
- }
复制代码 内存使用优化
- 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项目
项目特色
<ul>
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |