【开源鸿蒙跨平台开发先锋训练营】Flutter实现PDF生成及保存
这里在两个AI中进行了咨询,其实可以在更多的AI去请教对比,以得到最优的方案。对比两个AI的推荐,感觉默认的Copilot推荐更实际些,不过Deepseek的flutter_native_html_to_pdf似乎也有一些吸引力,可以供后期再研究。这里用GlobalKey()来声明了一个代表当前应用的全局变量,以用来获取图表的生成对象,这样可以公用已经生成的数据,节省内存,同时也避免了再度生成图表
欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net
背景
前期文章【开源鸿蒙跨平台开发先锋训练营】Flutter实现图表显示 已经实现了图表的生成,在实际应用中,可能会需要导出报告并保存为pdf文件以便分享给其他相关人员。这里即为相关技术的学习与尝试。
方案调查
VS Code默认Copilot的推荐
推荐方案(概览)
- 生成 PDF:使用
- 图表:两种方式
- 在 Flutter 中用
fl_chart(或任何 Flutter widget)渲染,使用RepaintBoundary导出为图片(PNG),再把图片插入到 PDF 中(最常用、外观一致)。- 或者使用
- 表格:使用
Table.fromTextArray(或pw.Table)动态生成。- 保存:使用
path_provider获取目录(例如应用文档目录或外部 Downloads),然后File.writeAsBytes()写入。若要写入公共目录(Downloads),需处理 Android 存储权限或使用printing包/shared intent 或 SAF。
Deepseek的推荐:
特性 方案一:Syncfusion PDF 方案二:HTML转PDF 核心技术 Dart原生绘制 ( PdfGrid,PdfBitmap)flutter_native_html_to_pdf表格实现 通过API控制,精确灵活 通过HTML/CSS控制,易于排版 图表实现 需将Chart转为图片嵌入 需将Chart转为Base64图片嵌入 保存到本地 直接使用 File写入字节流插件直接指定目录保存 优缺点 功能强大,商业许可,学习曲线略陡 开源免费,开发速度快,依赖系统WebView 推荐场景 企业级复杂报表,需精确控制页面元素 内容排版复杂,开发资源有限,预算敏感 建议:
如果你希望代码更统一、控制更精细,或者团队有预算,Syncfusion 是不错的选择。
如果你想快速实现、充分利用CSS布局,并且希望使用开源免费方案,建议选择 HTML转PDF 路线。目前
flutter_native_html_to_pdf插件对Android、iOS、Windows和Linux的支持都比较完善,文档也很清晰 。
对比两个AI的推荐,感觉默认的Copilot推荐更实际些,不过Deepseek的flutter_native_html_to_pdf似乎也有一些吸引力,可以供后期再研究。
这里在两个AI中进行了咨询,其实可以在更多的AI去请教对比,以得到最优的方案。对于初学者来讲,每一种技术方案都需要了解,但是最好还是从依赖最少的方案开始。所以我选择了Copilot提供的方案,而且他能帮助直接修改代码。
代码修改学习
pubspec.yaml
追加了对pdf, printing以及path_provider的依赖。
+ pdf: ^3.11.3^M
+ printing: ^5.11.0^M
+ path_provider: ^2.0.15^M
lib/main.dart
exportPdf方法
final GlobalKey _chartKey = GlobalKey();^M
bool _exporting = false;^M
Future<void> _exportPdf() async {
setState(() {
_exporting = true;
});
try {
// Capture chart as image
final boundary = _chartKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) throw Exception('图表尚未渲染');
final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) throw Exception('无法获取图像字节');
final Uint8List pngBytes = byteData.buffer.asUint8List();
// Build PDF
final pdf = pw.Document();
final chartImage = pw.MemoryImage(pngBytes);
final headers = ['Branch', 'Count'];
final data = _counts.entries.map((e) => [e.key, e.value.toString()]).toList();
pdf.addPage(
pw.MultiPage(
build: (context) => [
pw.Text('分支统计', style: pw.TextStyle(fontSize: 18)),
pw.SizedBox(height: 12),
pw.Image(chartImage, width: 400, height: 200),
pw.SizedBox(height: 12),
pw.Table.fromTextArray(context: context, headers: headers, data: data),
],
),
);
final bytes = await pdf.save();
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/pipeline_report_${DateTime.now().millisecondsSinceEpoch}.pdf');
await file.writeAsBytes(bytes);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已保存: ${file.path}')));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('导出失败: $e')));
} finally {
if (mounted) setState(() {
_exporting = false;
});
}
}
这里用GlobalKey()来声明了一个代表当前应用的全局变量,以用来获取图表的生成对象,这样可以公用已经生成的数据,节省内存,同时也避免了再度生成图表,代码冗余。
pw.Image 和pw.Table.fromTextArray分别用来添加图片和表格。
不过这里使用了getApplicationDocumentsDirectory()将生成的pdf文档保存到了当前的应用目录下,并不方便查看。
保存PDF文档到Downloads目录
pubspec.yaml
Copilot帮助修改了代码,引入了对permission_handler的依赖, 不过好像发现一个现象,Copilot修改代码,好像用的代码都是最新版本的接口,但是pubspec.yaml中的版本却是旧的版本号,导致两处不匹配,然后编译失败。
比如一开始追加的版本为
permission_handler: ^10.4.0
然后导致编译的时候报告如下错误
D:\flutter\pub\hosted\pub.dev\permission_handler_android-10.3.6\android\src\main\java\com\baseflow\permissionhandler\PermissionHandlerPlugin.java:28: 错误: 找不到符号
@Nullable private io.flutter.plugin.common.PluginRegistry.Registrar pluginRegistrar;
^
当将版本修改为最新的12.0.1 后,编译错误即解决。
lib/main.dart
try {
if (Platform.isAndroid) {
// Request storage permission for Android (legacy write). On Android 11+ this may still be restricted.
final status = await Permission.storage.request();
if (!status.isGranted) {
// continue to try saving to app dir
} else {
// Try get external downloads directory
final extDirs = await getExternalStorageDirectories(type: StorageDirectory.downloads);
if (extDirs != null && extDirs.isNotEmpty) {
final downloadDir = extDirs.first;
final file = File('${downloadDir.path}/$filename');
await file.writeAsBytes(bytes);
savedToDownloads = true;
} else {
// Fallback to common Download path (may require MANAGE_EXTERNAL_STORAGE on Android 11+)
final legacyPath = '/storage/emulated/0/Download';
final file = File('$legacyPath/$filename');
try {
await file.writeAsBytes(bytes);
savedToDownloads = true;
} catch (_) {
savedToDownloads = false;
}
}
}
}
} catch (_) {
savedToDownloads = false;
}
File savedFile;
if (savedToDownloads) {
// if saved to downloads, find the path we wrote earlier
final extDirs = await getExternalStorageDirectories(type: StorageDirectory.downloads);
if (extDirs != null && extDirs.isNotEmpty) {
savedFile = File('${extDirs.first.path}/$filename');
} else {
savedFile = File('/storage/emulated/0/Download/$filename');
}
} else {
final dir = await getApplicationDocumentsDirectory();
savedFile = File('${dir.path}/$filename');
await savedFile.writeAsBytes(bytes);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已保存: ${savedFile.path}')));
// Offer share sheet
try {
await Printing.sharePdf(bytes: bytes, filename: filename);
} catch (e) {
// ignore share errors
}
这段代码中,对于保存目录的选择,针对android单独加了分支检查。感觉并不是很通用,需要找一个更为通用的方案。
更多推荐




所有评论(0)