欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net

背景

前期文章【开源鸿蒙跨平台开发先锋训练营】Flutter实现图表显示 已经实现了图表的生成,在实际应用中,可能会需要导出报告并保存为pdf文件以便分享给其他相关人员。这里即为相关技术的学习与尝试。

方案调查

VS Code默认Copilot的推荐

推荐方案(概览)

  • 生成 PDF:使用 pdf 包(dart_pdf)构建 PDF 内容(支持表格、文字、图片、绘图等)。
  • 图表:两种方式
    1. 在 Flutter 中用 fl_chart(或任何 Flutter widget)渲染,使用 RepaintBoundary 导出为图片(PNG),再把图片插入到 PDF 中(最常用、外观一致)。
    2. 或者使用 pdf 包的绘图 API 直接绘制图表(更复杂,不常用)。
  • 表格:使用 pdf 包的 Table.fromTextArray(或 pw.Table)动态生成。
  • 保存:使用 path_provider 获取目录(例如应用文档目录或外部 Downloads),然后 File.writeAsBytes() 写入。若要写入公共目录(Downloads),需处理 Android 存储权限或使用 printing 包/shared intent 或 SAF。

Deepseek的推荐:

特性 方案一:Syncfusion PDF 方案二:HTML转PDF
核心技术 Dart原生绘制 (PdfGridPdfBitmap) pdf 包 + 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单独加了分支检查。感觉并不是很通用,需要找一个更为通用的方案。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐