一、实战目标:我们要做一个什么 项目

很多业务都有"导出"按钮:报表页导出 Excel/PDF、笔记应用导出 Markdown、账单页导出 CSV。这个 项目就把这条链路完整跑一遍——做一个"季度销售报表"页面,点一下按钮,把 HTML 报告和 CSV 数据两个文件保存到鸿蒙设备的文档目录,用户在文件管理里能直接打开。

做完后的效果:

  • 页面展示一份示例销售报表(表格);
  • 点"导出报表",鸿蒙系统弹出"保存文档"选择器,预填 q1_report.htmlq1_report.csv 两个文件名;
  • 用户确认位置后写入完成,应用收到"导出完成"提示;
  • 用户点返回取消,应用安静地回到原界面,不崩溃、无残留。

在这里插入图片描述

本文用的就是我们在鸿蒙上适配好的 document_file_save_plus(适配版托管在 AtomGit)。它是怎么适配出来的(系统 Picker 方案、字节写入细节、踩坑过程)写在另一篇《document_file_save_plus 鸿蒙化适配指南》里,本文只管把它用起来、做出真实功能。

本文的验证环境:

版本
Flutter SDK3.41.10-ohos-1.0.1(Dart 3.11.5)
DevEco Studio / CLI1.3.0-stable
验证设备HUAWEI PSN-AL00 真机,OpenHarmony 7.0.0.105(API 26)
插件document_file_save_plus 2.0.1 鸿蒙适配版(feat/ohos-adaptation 分支)

二、准备工作:环境与依赖

2.1 环境搭建

本文不展开环境安装步骤,请直接按照官方文档完成 Flutter 鸿蒙化环境搭建:

Flutter for OpenHarmony 环境搭建

完成后再执行 flutter doctor 确认输出中不缺少鸿蒙构建所需项,即可继续下文。

2.2 创建工程并引入适配版插件

创建一个普通 Flutter 工程即可(不需要任何鸿蒙特殊模板):

flutter create export_demo
cd export_demo

pubspec.yaml 中添加鸿蒙适配版插件(AtomGit Git 依赖,锁定适配分支):

dependencies:
  flutter:
    sdk: flutter
  document_file_save_plus:
    git:
      url: https://atomgit.com/oh-flutter/document_file_save_plus.git
      ref: feat/ohos-adaptation

拉取依赖:

flutter pub get

不想从头搭的读者,完整示例工程已托管到 AtomGit:https://atomgit.com/qq_15502821/flutter_demos (export_demo 目录,代码与本文逐行一致)。

就这一步,不需要任何额外的鸿蒙工程改动,也不需要声明任何权限——保存走系统"保存文档"选择器,用户确认本身就是授权。pub get 之后插件自动注册进鸿蒙工程的 GeneratedPluginRegistrant(工具生成的文件,不要手改),pubspec.lock 里能看到插件来源是 AtomGit 的 git 描述。

三、实现 demo:报表导出页

3.1 页面设计

一个StatefulWidget:上半部分用 Table 展示三个月的销售数据,下方两个按钮——“导出报表”(一次导出 HTML + CSV 两个文件)和"仅导出 CSV"(演示单文件接口)。

3.2 完整代码

lib/main.dart 全部内容如下,可直接替换新建工程的同名文件:

import 'dart:convert';

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

void main() => runApp(const ExportDemoApp());

class ExportDemoApp extends StatelessWidget {
  const ExportDemoApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: ReportPage(),
    );
  }
}

class ReportPage extends StatefulWidget {
  const ReportPage({super.key});

  
  State<ReportPage> createState() => _ReportPageState();
}

class _ReportPageState extends State<ReportPage> {
  static const List<List<String>> _rows = [
    ['月份', '销售额', '同比'],
    ['1月', '128,400', '+12%'],
    ['2月', '96,200', '-3%'],
    ['3月', '152,800', '+21%'],
  ];

  final DocumentFileSavePlus _plugin = DocumentFileSavePlus();
  bool _exporting = false;

  // 报表数据里带千分位逗号,写 CSV 时要给含逗号的字段加引号,否则列会错位
  String _csvCell(String c) => c.contains(',') ? '"$c"' : c;

  String _buildCsv() =>
      _rows.map((r) => r.map(_csvCell).join(',')).join('\n');

  String _buildHtml() {
    final tableRows = _rows
        .map((r) => '<tr>${r.map((c) => '<td>$c</td>').join()}</tr>')
        .join();
    return '<!DOCTYPE html><html><head><meta charset="utf-8">'
        '<title>Q1 销售报表</title></head><body>'
        '<h1>Q1 销售报表</h1><table border="1">$tableRows</table>'
        '</body></html>';
  }

  // 中文内容必须走 utf8.encode;不能用 .codeUnits(UTF-16 码元塞进
  // Uint8List 会被截断成乱码)
  Uint8List _bytesOf(String content) =>
      Uint8List.fromList(utf8.encode(content));

  Future<void> _exportAll() => _runSave(() async {
        await _plugin.saveMultipleFiles(
          dataList: [
            _bytesOf(_buildHtml()),
            _bytesOf(_buildCsv()),
          ],
          fileNameList: ['q1_report.html', 'q1_report.csv'],
          mimeTypeList: ['text/html', 'text/csv'],
        );
      });

  Future<void> _exportCsvOnly() => _runSave(() async {
        await _plugin.saveFile(
          _bytesOf(_buildCsv()),
          'q1_report.csv',
          'text/csv',
        );
      });

  Future<void> _runSave(Future<void> Function() task) async {
    final messenger = ScaffoldMessenger.of(context);
    setState(() => _exporting = true);
    try {
      await task();
      messenger.showSnackBar(
        const SnackBar(content: Text('导出完成,可在文件管理中查看')),
      );
    } on PlatformException catch (e) {
      String hint;
      switch (e.code) {
        case 'SAVE_IN_PROGRESS':
          hint = '上一次保存还没结束,请先完成或取消';
          break;
        case 'SAVE_FAILED':
          hint = '写入失败:${e.message}';
          break;
        default:
          hint = '导出失败:${e.message}';
      }
      messenger.showSnackBar(SnackBar(content: Text(hint)));
    } finally {
      if (mounted) setState(() => _exporting = false);
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Q1 销售报表')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Table(
              border: TableBorder.all(color: Colors.grey),
              children: _rows
                  .map((r) => TableRow(
                        decoration: r == _rows.first
                            ? const BoxDecoration(color: Color(0xFFEEEEFF))
                            : null,
                        children: r
                            .map((c) => Padding(
                                  padding: const EdgeInsets.all(8),
                                  child: Text(c),
                                ))
                            .toList(),
                      ))
                  .toList(),
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: _exporting ? null : _exportAll,
              child: const Text('导出报表(HTML + CSV)'),
            ),
            const SizedBox(height: 8),
            OutlinedButton(
              onPressed: _exporting ? null : _exportCsvOnly,
              child: const Text('仅导出 CSV'),
            ),
          ],
        ),
      ),
    );
  }
}

3.3 四个值得注意的实现点

第一,导出内容就是字节,中文必须走 utf8.encode saveFile / saveMultipleFiles 接收的是 Uint8List——真实业务里可以是 pdf 包生成的字节、数据库导出流、下载内容,接口不变。这里有个新手最容易踩的坑:String.codeUnits 返回的是 UTF-16 码元,中文字符的码元值超过 255,塞进 Uint8List 会被静默截断成乱码。demo 里统一走 _bytesOf()(内部 utf8.encode),导出的中文才能正常打开。

第二,CSV 字段带逗号要加引号。 报表数据习惯用千分位(128,400),直接 join(',') 会把一个数字拆成两列,Excel 打开就是错位的。_csvCell() 给含逗号的字段套引号,这是 demo 顺手处理的第二个真实业务细节。

第三,取消是静默的。 用户在系统选择器里点返回时,调用安静地完成(与 iOS 行为一致),所以 demo 的成功提示对"取消"也会显示"导出完成"。如果业务要区分两者,可在调用后检查目标文件是否真的出现;不需要为取消写任何 catch。

第四,错误码是给业务用的。 demo 里把 SAVE_IN_PROGRESS(上次保存未结束)和 SAVE_FAILED(写入失败)分开提示——这些错误码是适配版按契约设计的,见文末 FAQ。

四、在鸿蒙真机上跑起来

先构建:

flutter build hap --debug

然后用 DevEco CLI 签名、安装、启动(也可以直接用 DevEco Studio 运行)。真机第一次跑需要生成一次调试签名(模拟器可跳过):

cd ohos
devecocli signature generate    # 仅首次:生成调试签名材料并写入 build-profile.json5
devecocli run --build-mode debug --device <设备序列号>

不签名直接装会报 9568320 no signature file——模拟器不校验签名,真机强制校验,这是两者最大的环境差异。

本文的实测流程(HUAWEI PSN-AL00,OpenHarmony 7.0.0.105):

  1. 应用启动,展示报表表格与两个按钮;

在这里插入图片描述

  1. 点"导出报表",系统"保存文档"选择器弹出,预填 q1_report.html / q1_report.csv 两个文件名,选 Download 目录确认;

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 返回应用,出现"导出完成"提示;

在这里插入图片描述

  1. 用日志验证写入:hilog 中能看到逐字节证据,Saved 364 bytes to .../q1_report.htmlSaved 81 bytes to .../q1_report.csv,字节数与源数据的 UTF-8 编码长度完全一致(含中文标题的 HTML 364 字节、加了引号转义的 CSV 81 字节,都是可以本地复算的);
  2. 再点一次导出、这次在选择器里点返回——应用安静返回原界面,无异常弹窗、无崩溃。

在这里插入图片描述

另外两个顺手验证过的行为:Download 目录里已有 q1_report.html 时再导一次,系统自动存成 q1_report (1).html(重名自增,不覆盖用户文件,日志里显示为 URL 编码的 %20(1));连续快速点两次导出按钮,第二次会收到 SAVE_IN_PROGRESS 提示(demo 的按钮禁用一般不会触发,但逻辑在)。

五、FAQ:使用中的常见问题

Q1:鸿蒙上导出和 Android 行为不一样?

是的:Android 经 MediaStore 静默写入公共 Downloads;鸿蒙禁止三方应用静默写公共目录,必须由用户经系统选择器确认位置。这是系统规则,也是合规优势(零权限声明)。业务文案建议写"选择位置保存"而不是"直接保存到下载"。

Q2:取消导出会抛异常吗?

不会,静默返回。需要区分成功与取消时,在调用后检查目标文件是否生成。

Q3:报 SAVE_IN_PROGRESS?

上一次保存的选择器还没关闭。等用户完成或取消上一次操作即可;批量导出请串行调用。

Q4:运行时报 MissingPluginException?

依次检查:pubspec.lock 里插件来源是否为 AtomGit 的 git 描述 → 修改依赖后是否重新 flutter pub get → 是否完整重启应用(热重载不重新注册插件)。

Q5:大文件导出有什么建议?

接口不限制大小,但超大文件(数百 MB)建议业务层分块或加进度提示。KB 级到 MB 级在真机上实测秒级完成;更大规模建议在目标真机压测。

Q6:发现问题如何提交 issue?

到适配仓库提交 issue,附上:设备型号、系统/API 版本、Flutter 与 DevEco 版本、复现步骤与日志(devecocli log --bundle-name <包名>DocumentFileSavePlus 标签的输出):https://atomgit.com/oh-flutter/document_file_save_plus/issues

Q7:如何提交 PR 参与共建?

Fork 仓库后基于 feat/ohos-adaptation 分支修改,本地保证 flutter analyze 零问题、flutter test 全绿、构建通过,涉及设备行为的改动在模拟器或真机实测后,向源仓库发起 Pull Request 并写清问题、改动点与验证证据。

六、总结

这个 项目把"文件导出"在鸿蒙上的完整链路跑通了:业务代码三段式——构造字节、调用 saveFile / saveMultipleFiles、按错误码提示结果——和 Android/iOS 工程里的写法一致,唯一的平台差异是用户多一次"选择保存位置"的确认,而这恰好是鸿蒙安全模型下唯一合规的方式。示例应用在 HUAWEI PSN-AL00 真机(OpenHarmony 7.0.0.105)上完成了双文件导出、逐字节校验、重名自增、取消静默四条路径的验证。把 demo 里的报表换成你自己的业务数据,这个功能就可以直接上线。

欢迎加入 Flutter 鸿蒙化社区(CPF-Flutter 组织):https://atomgit.com/CPF-Flutter
本文所用适配版插件的仓库地址:https://atomgit.com/oh-flutter/document_file_save_plus

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐