# Flutter for OpenHarmony 实战:用 document_file_save_plus 做一个鸿蒙报表导出功能
一、实战目标:我们要做一个什么 项目
很多业务都有"导出"按钮:报表页导出 Excel/PDF、笔记应用导出 Markdown、账单页导出 CSV。这个 项目就把这条链路完整跑一遍——做一个"季度销售报表"页面,点一下按钮,把 HTML 报告和 CSV 数据两个文件保存到鸿蒙设备的文档目录,用户在文件管理里能直接打开。
做完后的效果:
- 页面展示一份示例销售报表(表格);
- 点"导出报表",鸿蒙系统弹出"保存文档"选择器,预填
q1_report.html和q1_report.csv两个文件名; - 用户确认位置后写入完成,应用收到"导出完成"提示;
- 用户点返回取消,应用安静地回到原界面,不崩溃、无残留。

本文用的就是我们在鸿蒙上适配好的 document_file_save_plus(适配版托管在 AtomGit)。它是怎么适配出来的(系统 Picker 方案、字节写入细节、踩坑过程)写在另一篇《document_file_save_plus 鸿蒙化适配指南》里,本文只管把它用起来、做出真实功能。
本文的验证环境:
| 项 | 版本 |
|---|---|
| Flutter SDK | 3.41.10-ohos-1.0.1(Dart 3.11.5) |
| DevEco Studio / CLI | 1.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 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):
- 应用启动,展示报表表格与两个按钮;

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

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

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

另外两个顺手验证过的行为: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
更多推荐



所有评论(0)