本文记录了将开源 Flutter 三方库 pdfrx 适配到 OpenHarmony / HarmonyOS 平台的完整过程,
包含适配思路、代码改动对照、关键决策和踩坑复盘。

与常见的「MethodChannel 插件」适配不同,pdfrx 走的是 FFI + native assets 路线——
这决定了本次适配的主战场不在 ArkTS,而在构建钩子、原生库分发和跨 isolate 的函数指针上。


项目地址: AtomGit/oh-flutter/pdfrx

开发工具: 华为云码道

一、背景

1.1 三方库简介

pdfrx 是一个基于 PDFium 的、功能完整的 Flutter PDF 渲染与操作库,提供以下能力:

  • PDF 渲染——滚动、缩放、翻页、双指手势,按需渲染可见页
  • 文本层——文本选择、复制、全文搜索与高亮
  • 文档结构——大纲(outline)、页链接(link)、注释(annotation)解析
  • 文档操作——合并、拆分、页面重排、加密文档密码输入
  • 多数据源——assets、本地文件、网络 URL(分段加载)、内存字节

该库最初支持 Android、iOS、Windows、macOS、Linux、Web 六个平台,本次任务将其适配到 OpenHarmony / HarmonyOS 平台。

项目地址:https://atomgit.com/oh-flutter/pdfrx

上游仓库:https://github.com/espresso3389/pdfrx

1.2 为什么 pdfrx 的适配「不太一样」

在动手之前,先要认清这个库的架构。绝大多数 Flutter 插件长这样:

Dart  ──MethodChannel──►  Android (Kotlin)  ──►  系统 API
                            iOS (Swift)
                            OHOS (ArkTS)

pdfrx 不是。它的渲染核心是 C/C++ 写成的 PDFium,Dart 通过 dart:ffi 直接调用 PDFium 的导出符号,中间没有通道、没有序列化、没有 ArkTS:

Dart  ──dart:ffi──►  libpdfium.so (C++, 16.7 MB)
        ▲
        └── 原生库由 native assets / hooks 机制在构建期准备

这意味着:

MethodChannel 插件pdfrx(FFI 插件)
主战场ArkTS 业务逻辑翻译构建钩子 + 原生库分发
通信成本每次调用跨语言序列化零拷贝,函数直调
平台注册pluginClass + ArkTS 类ffiPlugin: true + 一个空壳类
调研重点「OHOS 有没有对应 API」「OHOS 上这个 .so 从哪来、怎么加载」

前置结论:适配的成败不在于写出多少 ArkTS 代码(实际上只写了一个空壳类),而在于搞清楚 PDFium 二进制在鸿蒙上的来源与加载路径。

1.3 适配目标

维度要求
功能一致性文档打开、页面渲染、文本抽取三项核心能力在真机可用
Dart 层改动仅做平台分支补充,不改变任何公开 API 签名
性能FFI 直调,不引入额外通道开销
工程规范原生库随 HAR 分发;构建期不依赖网络下载
可交付性提供可下载的原生库,使用者无需自行编译

二、适配路线图

明确架构之后,适配路径就清晰了——四个阶段,重心明显偏向「链路打通」而非「代码翻译」:

第 1 阶段:构建钩子   ── 让构建期不再尝试下载 PDFium(code_assets 不认识 ohos)
第 2 阶段:原生库分发 ── 把 libpdfium.so 放进 HAP 的原生库目录,跑通 FFI 加载
第 3 阶段:运行时适配 ── 补齐平台分支:pthread 尺寸、缓存目录、字体回调、依赖
第 4 阶段:真机验证   ── 用可量化的证据(墨水像素占比 + 文本抽取)证明渲染成立

真正消耗时间的是第 2 和第 3 阶段:前者要确认「哪个 PDFium 二进制能在鸿蒙上跑」,后者要定位一个只在鸿蒙上出现的 SIGSEGV。


三、逐步适配过程

第 1 阶段:构建钩子——让构建期不再下载 PDFium

pdfrx 的原生库由 native assets(hooks + code_assets) 机制在构建期准备。packages/pdfium_dart/hook/build.dart 是入口,它的逻辑是:判断目标平台 → 从 bblanchon/pdfium-binaries 下载对应的 .tgz → 解出 libpdfium.so → 注册为 CodeAsset。

问题在于这段代码的第一行就踩了坑:

if (input.config.code.targetOS == OS.iOS) return;

OS 是 code_assets 定义的枚举,目前只有 android / iOS / linux / macOS / windows——没有 ohos。在鸿蒙工具链下读取 targetOS 不是返回「未知」,而是直接抛异常,导致构建在钩子阶段就崩掉。

解法是给钩子加一道守卫,并额外用一个「编译器路径」信号来识别鸿蒙构建(因为 code_assets 报出的 OS 值不可信):

void main(List<String> args) async {
  await build(args, (input, output) async {
    if (!input.config.buildCodeAssets) return;

    // `code_assets` does not know about OpenHarmony, so reading `targetOS`
    // throws there. Bail out instead of failing the build; the PDFium binary
    // is shipped inside the HAP (`entry/libs/<abi>/libpdfium.so`) and loaded
    // by bare name, the same way Android does it.
    OS targetOS;
    try {
      targetOS = input.config.code.targetOS;
    } catch (_) {
      return;
    }
    if (_isOhosCodeBuild(input.config.code)) return;
    if (targetOS == OS.iOS) return;

    final target = _PdfiumTarget.fromCodeConfig(input.config.code);
    // ... 原有的下载逻辑
  });
}

其中 _isOhosCodeBuild 通过 C 编译器路径识别鸿蒙(OHOS SDK 的 clang 位于 <sdk>/native/llvm/ 下):

/// Detects an OpenHarmony build by looking at the C compiler that the Flutter
/// toolchain selected. OpenHarmony builds use the SDK's own clang under
/// `<sdk>/native/llvm/`, which is a reliable marker even when `code_assets`
/// reports an OS value we do not recognize.
bool _isOhosCodeBuild(CodeConfig config) {
  final cCompiler = config.cCompiler;
  if (cCompiler == null) return false;
  final compilerPath =
      cCompiler.compiler.toFilePath().replaceAll('\\', '/').toLowerCase();
  return compilerPath.contains('/openharmony/native/llvm/');
}

关键点:守卫必须是「静默跳过」(return),而不是抛 UnsupportedError。鸿蒙平台不需要自动下载——原生库改由 HAR 分发(见第 2 阶段),构建期只要不报错即可。


第 2 阶段:原生库分发——libpdfium.so 从哪来

这是整个适配中最需要调研的一步。

2.1 先确认官方二进制能用

PDFium 的官方预编译产物来自 bblanchon/pdfium-binaries。检查 chromium/7811 这个 release(pdfrx 固定使用的版本):

$ 资产列表(共 44 个)
  android-arm / android-arm64 / android-x64 / android-x86
  linux-arm / linux-arm64 / linux-x64 / linux-musl-arm64 / ...
  mac-arm64 / mac-x64 / win-arm64 / win-x64 / ...

没有任何 ohos / harmony 资产。 这条路走不通,于是转向验证:社区是否已有可用的鸿蒙版 PDFium?

2.2 用哈希比对确认「官方 musl 版」就是可用版本

在排查过程中发现社区已有 pdfrx 的鸿蒙适配分支(hxa-flutter/pdfrx 的 br_ohos),其 example/.../libs/arm64-v8a/libpdfium.so 是已验证可用的。把它和官方 linux-musl-arm64 做哈希比对:

$ md5 -q hxa-flutter_ohos/libpdfium.so
91f3f6a16c38f7dd5d35ca4d2ee33add
$ md5 -q pdfium-linux-musl-arm64/lib/libpdfium.so
91f3f6a16c38f7dd5d35ca4d2ee33add     # ← 完全一致

结论:鸿蒙上跑的 PDFium 就是官方的 linux-musl-arm64 构建——因为 OpenHarmony 使用 musl libc(与 Android 的 bionic、Linux 的 glibc 都不同),恰好与这个产物的 ABI 匹配。

这个发现的意义很大:不需要自建 PDFium。自建意味着要检出完整 Chromium/PDFium 源码树、配置 OHOS NDK 交叉编译,构建耗时以小时计、磁盘占用约 50 GB;而直接使用官方产物既零成本,又保证了来源可追溯(官方 release,非个人私改二进制)。

复核二进制性质:

ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked,
BuildID[sha1]=4a51bbf5006d7fd0731caf1c6f5f4a0fa95ed6e9, with debug_info, not stripped

DT_NEEDED: []              # 无外部依赖,仅链接 libc
FPDF_ 导出符号: 1406 个
Android 专有符号: 0

DT_NEEDED 为空、无任何 Android 专有符号引用,进一步佐证它不绑定 Android 运行时。

2.3 把 .so 放进 HAR

在插件包里新增原生库目录:

packages/pdfium_flutter/ohos/
├── index.ets                                  # 模块入口
├── oh-package.json5                           # 包配置
├── build-profile.json5                        # 构建配置(含 nativeLib)
├── libs/
│   └── arm64-v8a/
│       └── libpdfium.so                       # 16.7 MB,随 HAR 打包
└── src/main/
    ├── module.json5                           # HAR 模块配置
    └── ets/components/plugin/
        └── PDFiumFlutterPlugin.ets            # 空壳注册类

hvigor 会把 libs/<abi>/ 下的动态库自动打入 HAP。构建后验证:

$ unzip -l entry-default-signed.hap | grep pdfium
 17505448  libs/arm64-v8a/libpdfium.so     # ✅ 已进入 HAP
2.4 加载路径:裸名 dlopen

packages/pdfium_dart/lib/src/pdfium_loader.dart 负责在运行时定位并打开动态库。它需要为鸿蒙补一个分支:

String _getModuleFileName() {
  if (Platform.isAndroid) return 'libpdfium.so';
  // HarmonyOS (OH Flutter): same bare-name dlopen as Android; the library is
  // resolved from the app's native library directory when bundled under the
  // HAP's libs/<abi>/ directory. Detected by OS name because dart:io exposes
  // no dedicated Platform.isOhos on standard Dart SDKs.
  if (Platform.operatingSystem == 'ohos') return 'libpdfium.so';
  if (Platform.isWindows) return 'pdfium.dll';
  // ...
}

鸿蒙与 Android 一样,走裸名 DynamicLibrary.open('libpdfium.so')——系统会从应用的原生库目录解析。这里用 Platform.operatingSystem == 'ohos' 而非 Platform.isOhos,是为了兼容标准 Dart SDK(后者没有该 API)。

2.5 插件注册:FFI 插件的「空壳」类

在 pubspec.yaml 注册鸿蒙平台,注意 ffiPlugin: true:

flutter:
  plugin:
    platforms:
      ios:
        pluginClass: PDFiumFlutterPlugin
        ffiPlugin: true
        sharedDarwinSource: true
      macos:
        pluginClass: PDFiumFlutterPlugin
        ffiPlugin: true
        sharedDarwinSource: true
      ohos:                              # ← 新增
        pluginClass: PDFiumFlutterPlugin
        ffiPlugin: true

ArkTS 侧只需一个注册用的空壳类——没有任何通道逻辑,因为所有调用都是 FFI:

import { FlutterPlugin, FlutterPluginBinding } from '@ohos/flutter_ohos';

/**
 * PDFiumFlutterPlugin — OpenHarmony registration stub for the PDFium FFI plugin.
 *
 * This is an FFI plugin: there is no platform channel. Dart reaches PDFium
 * directly through `dart:ffi`, loading `libpdfium.so` by bare name. ...
 */
export default class PDFiumFlutterPlugin implements FlutterPlugin {
  getUniqueClassName(): string {
    return "PDFiumFlutterPlugin"
  }

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    // FFI plugin: nothing to wire up here. PDFium is accessed through FFI.
  }

  onDetachedFromEngine(binding: FlutterPluginBinding): void {
    // Nothing to tear down.
  }
}

注意:getUniqueClassName() 的返回值必须与 pubspec.yaml 的 pluginClass 大小写完全一致。flutter create 生成的模板会把 PDFiumFlutterPlugin 自动转为 PdfiumFlutterPlugin,若不修正,插件在运行时会找不到(表现为 FFI 符号全部解析失败)。


第 3 阶段:运行时适配——补齐平台分支

原生库加载通了之后,还有一批「只在鸿蒙上暴露」的平台分支要补。这些改动分散在几个包中,但每一处都对应一个具体的崩溃或阻塞。

3.1 pthread 结构体尺寸

pdfrx 为了在 PDFium 回调里做线程同步,直接用 FFI 调用 pthread_mutex_* / pthread_cond_*,因此需要知道这两个结构体在目标平台上的字节大小——传错会导致内存越界。

/// Size of pthread_mutex_t varies by platform
int get sizeOfPthreadMutex {
  if (Platform.isAndroid || Platform.isOhos) {
    return 40; // Android/OpenHarmony use 40 bytes for pthread_mutex_t on 64-bit
  } else if (Platform.isLinux) {
    return 40; // Linux uses 40 bytes for pthread_mutex_t on 64-bit
  } else if (Platform.isIOS || Platform.isMacOS) {
    return 64; // Darwin (iOS/macOS) uses 64 bytes for pthread_mutex_t on 64-bit
  }
  throw UnsupportedError('Unsupported platform for pthread mutex size');
}

/// Size of pthread_cond_t varies by platform
int get sizeOfPthreadCond {
  if (Platform.isAndroid || Platform.isOhos) {
    return 48; // Android/OpenHarmony use 48 bytes for pthread_cond_t on 64-bit
  } else if (Platform.isLinux) {
    return 48; // Linux uses 48 bytes for pthread_cond_t on 64-bit
  } else if (Platform.isIOS || Platform.isMacOS) {
    return 48; // Darwin (iOS/macOS) uses 48 bytes for pthread_cond_t on 64-bit
  }
  throw UnsupportedError('Unsupported platform for pthread cond size');
}

依据:鸿蒙与 Android 同为 64 位、同用 musl/bionic 系 libc,pthread_mutex_t 为 40 字节、pthread_cond_t 为 48 字节,与 Darwin(64 字节)不同。

3.2 文件访问辅助类

同类型的分支补充,把鸿蒙纳入「使用 pthread 实现」的平台集合:

} else if (Platform.isAndroid || Platform.isOhos || Platform.isLinux || Platform.isIOS || Platform.isMacOS) {
  _instance = PdfiumFileAccessHelperPthread();
} else if (Platform.isAndroid || Platform.isOhos || Platform.isLinux || Platform.isIOS || Platform.isMacOS) {
  _instance = PdfiumFileWriteHelperPthread();
3.3 缓存目录:HOME 可能不存在

pdfrx 初始化时会确定一个缓存目录,原逻辑在非 Windows 平台直接解引用 HOME:

// 原逻辑
return Directory(path.join(Platform.environment['HOME']!, '.pdfrx'));

鸿蒙应用进程不保证暴露 HOME 环境变量,! 断言会直接抛异常。改为逐级兜底:

if (Platform.isWindows) {
  return Directory(path.join(Platform.environment['LOCALAPPDATA']!, 'pdfrx'));
}
// OpenHarmony apps do not reliably expose `HOME`, and dereferencing it would
// throw. Fall back to the OS-provided temp directory, which is always set.
final home = Platform.environment['HOME'];
if (home != null && home.isNotEmpty) {
  return Directory(path.join(home, '.pdfrx'));
}
return Directory(path.join(Directory.systemTemp.path, 'pdfrx'));
3.4 移动端判定

isMobile 会影响若干 UI 行为(如滚动条、手势),需要把鸿蒙算进去:

/// Whether the current platform is mobile (Android, OpenHarmony, iOS, or Fuchsia).
final isMobile = Platform.isAndroid || Platform.isOhos || Platform.isIOS || Platform.isFuchsia;
3.5 依赖缺口:path_provider 没有鸿蒙后端

pdfrxFlutterInitialize() 内部会调用 getTemporaryDirectory(),而它的实现来自 path_provider——这个包没有 OpenHarmony 实现。在鸿蒙上调用不会报错,而是静默挂起(MethodChannel 找不到 handler),表现为首屏空白且无任何异常。

AppStorageHost: SetUserDir: No registered handler for path_provider ...

解法是引入社区维护的鸿蒙实现:

dependencies:
  pdfrx: ^2.6.5
  # OpenHarmony only: pdfrx calls path_provider during initialization, and the
  # upstream package ships no OHOS backend, so the call never completes.
  path_provider_ohos: ^2.2.1
3.6 编译期拦路虎:material_ui 的穷尽性 switch

这一项不走通,鸿蒙根本编译不过。

pdfrx 2.6.5 把 UI 层的 Material 依赖指向了 material_ui——这是 Flutter 官方把 Material 库从 SDK 中拆出来独立发布的包:

# packages/pdfrx/pubspec.yaml
dependencies:
  material_ui: ^1.0.0

问题在于 material_ui 内部大量使用穷尽性 switch 来列举所有 TargetPlatform 值:

switch (theme.platform) {
  case TargetPlatform.android: ...
  case TargetPlatform.iOS: ...
  case TargetPlatform.macOS: ...
  // ... 列举了所有它已知的平台
}

鸿蒙 Flutter 分支新增了 TargetPlatform.ohos,样式这类 switch 就不再穷尽,Dart 编译器直接报错:

Error: The type 'TargetPlatform' is not exhaustively matched by the switch cases
       since it doesn't match 'TargetPlatform.ohos'.

统计下来,material_ui-1.4.0 里有 67 处这样的报错点,涉及 37 个文件;而且所有版本都有这个问题(试过 1.0.1 / 1.1.0 / 1.2.0,case TargetPlatform. 出现 288 次),降级依赖版本无法绕过。

尝试过两条路:

方案做法结果
A. 本地 shim + dependency_overrides把 material_ui 换成一个只 export 'package:flutter/material.dart' 的本地包❌ 构建能过,但对使用者无效
B. 直接改用 SDK 的 flutter/material把 28 处 import 'package:material_ui/material_ui.dart' 替换为 import 'package:flutter/material.dart'✅ 采纳

方案 A 失败的根因值得记录:dependency_overrides 只在根包生效。我在 example 工程里加 override 后本地构建通过,但任何把 pdfrx 作为依赖引入的 App,其根包并不会继承这个 override,依然会编译失败——这对一个要发布的 fork 是不可接受的。

方案 B 之所以成立,是因为 material_ui 本身就是 SDK Material 库的拆分包,lib/material_ui.dart 只是把 package:flutter/widgets.dart 和一堆 src/*.dart 重新导出。而 OH SDK 自带的 flutter/material 已经维护好了 ohos 分支(39 个文件已处理 TargetPlatform.ohos)。核验 pdfrx 未使用任何 material_ui 独有 API 后,直接替换 import 即可:

// 替换前
import 'package:material_ui/material_ui.dart';
// 替换后
import 'package:flutter/material.dart';

同类问题:过程中还发现 cupertino_ui(Cupertino 的拆分包)有完全相同的穷尽性 switch 问题。它原本是 material_ui 的传递依赖,移除 material_ui 声明后二者一并消失。

3.7 真机崩溃定位:字体回调的失效函数指针

以上都补完之后,应用能装、能启动,但一打开文档就崩,且是原生崩溃——Dart 层没有任何异常。

Process name: com.example.my
Reason:Signal:SIGSEGV(SEGV_MAPERR)@0xffffffe8d2ef02e0
Process life time:4s                      # ← 启动后 4 秒即崩
Fault thread info:
Tid:23285, Name:DartWorker                # ← 崩在 Dart worker isolate 线程
#00 pc ffffffe8d2ef02e0 Not mapped        # ← 跳到了一个未映射地址
#01 pc 00000000001f0c30 libpdfium.so
#02 pc 000000000018c300 libpdfium.so
...
#13 pc 00000000001f5e54 libpdfium.so(FPDF_LoadPage+132)
#14 pc 0000000000168244 [anon:dart-code]  # ← 调用方是 Dart JIT 代码

崩溃形态很典型:SEGV_MAPERR + 跳转到未映射地址——即 PDFium 通过一个函数指针发起调用,而这个指针指向了垃圾地址。同时注意两个细节:崩溃线程是 DartWorker,而调用链最终来自 [anon:dart-code]。

libpdfium.so 虽被 strip 了符号表,但仍保留调试信息,可以按偏移还原:

$ llvm-addr2line -f -C -e libpdfium.so 0x1f0c30 0x18c300 0x189190 0x2aa570 0x2a6628
CFX_ExternalFontInfo::MapFont(int, bool, FX_Charset, int, fxcrt::ByteString const&)
CFX_FontMapper::FindSubstFace(fxcrt::ByteString const&, bool, unsigned int, int, int, FX_CodePage, CFX_SubstFont*)
CFX_Font::LoadSubstFace(fxcrt::ByteString const&, bool, unsigned int, int, int, FX_CodePage, bool)
CPDF_SimpleFont::LoadCommon()
CPDF_Font::Create(CPDF_Document*, fxcrt::RetainPtr<CPDF_Dictionary>, CPDF_Font::FormFactoryIface*)

链路清晰了:解析文档 → 创建字体 → 查找替换字体 → CFX_ExternalFontInfo::MapFont → 调用外部函数指针 → 💥

CFX_ExternalFontInfo 正是 PDFium 用来包装宿主注入的系统字体接口(FPDF_SYSFONTINFO)的类。而 pdfrx 确实会安装这套接口:

// packages/pdfrx_engine/lib/src/native/pdfrx_pdfium.dart
final fontMapper = _PdfFontMapper()..install();
// PDFium keeps this pointer. The mapper instance must remain alive until PDFium is destroyed.
pdfium.FPDF_SetSystemFontInfo(fontMapper.sysFontInfo);

而 install() 里创建的都是 NativeCallable.isolateLocal:

_mapFont = NativeCallable<...>.isolateLocal(_mapFontCallback);
// ...
_sysFontInfo.ref.MapFont = _mapFont.nativeFunction;

根因:这些 NativeCallable 是在 BackgroundWorker 的 worker isolate 内创建的(_installFontMapper 整体包在 BackgroundWorker.compute 里)。isolateLocal 创建的 trampoline 生命周期与本 isolate 绑定,而 PDFium 会在另一个线程上调用该指针。在鸿蒙的运行时环境下,这个跨线程调用的 trampoline 变成了失效地址,于是 MapFont 调用直接跳飞。

对比参考:社区已验证可用的 pdfrx 2.4.3 版本根本没有安装系统字体回调——这解释了「为什么旧版能跑、新版崩」。

解法:在鸿蒙上跳过字体回调安装,让 PDFium 回退到内置的字体替换逻辑:

/// Install the system font info in PDFium.
Future<void> _installFontMapper() async {
  // OpenHarmony: the `NativeCallable` trampolines are created inside the worker
  // isolate, and PDFium dereferences them from a different thread. On OHOS that
  // stale function pointer is still invoked during document parsing
  // (`CFX_ExternalFontInfo::MapFont`) and the process dies with SIGSEGV before
  // the page is loaded. Skip installing the font callbacks there; PDFium then
  // falls back to its own font substitution for missing embedded fonts.
  if (Platform.isOhos) {
    return;
  }
  await BackgroundWorker.compute((params) {
    // ... 原有安装逻辑
  });
}

取舍:代价是「PDF 内嵌字体缺失时,回退字体的选择不再经过 Flutter 侧的字体管理器」。对一个以「能打开、能渲染」为目标的首次适配而言,这个降级是可接受的,已记入「遗留问题」。


第 4 阶段:真机验证

在这里插入图片描述

4.1 构建产物
$ flutter build hap --debug --target-platform ohos-arm64
✓ Built build/ohos/hap/entry-default-signed.hap.

产物中确认原生库已打包:

$ unzip -l entry-default-signed.hap | grep -E "libpdfium|libflutter"
17505448  libs/arm64-v8a/libpdfium.so      # Stored(未压缩,mmap 友好)
42337480  libs/arm64-v8a/libflutter.so
4.2 如何取得「可量化」的验证证据

鸿蒙上有个现实困难:Dart 的 stdout 不会进入 hilog,Flutter 又渲染在单一 surface 上(uitest dumpLayout 拿不到内部组件树)。最初靠「截图颜色」判断,但很快发现证据不够硬——空白白页渲染出来也接近全白,无法区分「渲染成功」和「渲染了个白板」。

最终采用写文件取证:把结果写到应用沙箱,再通过 hdc 读回。鸿蒙的 /data/app/el2/100/base/<bundle>/files 视图与应用的 /data/storage/el2/base/files 指向同一位置:

const String kOutPath = '/data/storage/el2/base/files/pdfrx_diag.txt';

同时把判定指标从「非零像素」改成更严格的真实墨水占比:

// 统计与纯白不同的像素(真实内容)与明显暗的像素(笔画/线条)
if (r != 255 || g != 255 || b != 255) nonWhite++;
if ((r * 299 + g * 587 + b * 114) / 1000 < 128) dark++;
4.3 验证结果
$ hdc shell "cat /data/app/el2/100/base/com.example.my/files/pdfrx_diag.txt"
pdfrx diag
isOhos=true
INIT=ok
OPEN=ok pages=3
RENDER=ok 400x400 nonWhite=6589(4.12%) dark=3107(1.94%)
TEXT=ok len=120

三项证据分别对应三条链路:

指标含义说明
INIT=okPDFium 加载成功FFI 符号解析正常,libpdfium.so 加载路径正确
OPEN=ok pages=3文档解析成功PDFium 读到了文档结构,且字体回调崩溃已消除
RENDER=ok ... dark=1.94%页面渲染成功400×400 像素中 1.94% 是暗像素——证明画出了真实内容,而非空白页
TEXT=ok len=120文本抽取成功独立于渲染的第二条验证通道

文本内容抽样也符合预期:

"pdfrx Hello, This is pdfrx, a Flutter PDF viewer implementa..."

四、完整代码对照

4.1 适配方式总览

由于 pdfrx 是 FFI 插件,它的「代码对照」不与 MethodChannel 插件同构——没有 Android↔OHOS 的逐方法翻译,而是「各平台如何准备并加载同一个原生库」的对照:

维度AndroidOpenHarmonyiOS / macOS
原生库来源构建期下载 android-arm64随 HAR 分发(官方 linux-musl-arm64)XCFramework 静态链接
注册方式Gradle 插件ffiPlugin: true + 空壳 ArkTS 类ffiPlugin: true + Swift 类
加载方式dlopen("libpdfium.so")dlopen("libpdfium.so")DynamicLibrary.process()
平台判定Platform.isAndroidPlatform.isOhos / operatingSystem == 'ohos'Platform.isIOS/isMacOS
构建钩子下载对应 ABI跳过下载(_isOhosCodeBuild 守卫)iOS 跳过 / macOS 下载 dylib

4.2 关键改动清单

文件改动类型说明
packages/pdfium_dart/hook/build.dart修改加 OHOS 守卫 + _isOhosCodeBuild,构建期不下载 PDFium
packages/pdfium_dart/lib/src/pdfium_loader.dart修改补 operatingSystem == 'ohos' 分支,裸名加载
packages/pdfium_flutter/pubspec.yaml修改注册 ohos 平台 + ffiPlugin: true
packages/pdfium_flutter/ohos/新增HAR 骨架 + libs/arm64-v8a/libpdfium.so
packages/pdfrx_engine/.../pdfrx_pdfium.dart修改鸿蒙跳过字体回调安装
packages/pdfrx_engine/.../pthread/pthread.dart修改pthread 结构体尺寸
packages/pdfrx_engine/.../pdfium_file_access.dart
.../pdfium_file_write.dart
修改纳入 pthread 实现集合
packages/pdfrx_engine/.../pdfrx_initialize_dart.dart修改HOME 缺失时兜底到临时目录
packages/pdfrx/lib/src/utils/native/native.dart修改isMobile 纳入鸿蒙
packages/pdfrx/lib/**(12 个文件)
packages/pdfrx/example/**(16 个文件)
修改material_ui → flutter/material
packages/pdfrx/pubspec.yaml
.../example/*/pubspec.yaml
修改移除 material_ui,example 加 path_provider_ohos
README.OpenHarmony_CN.md
README.OpenHarmony.md
CHANGELOG.OpenHarmony.md
新增鸿蒙适配文档

规模统计:

$ git show --stat HEAD | tail -2
76 files changed, 1128 insertions(+), 38 deletions(-)

4.3 Dart 层平台判定的统一模式

所有平台分支都遵循同一模式——Platform.isOhos(鸿蒙 Flutter 分支已提供该 API):

// 移动端判定
final isMobile = Platform.isAndroid || Platform.isOhos || Platform.isIOS || Platform.isFuchsia;
// pthread 尺寸
if (Platform.isAndroid || Platform.isOhos) { return 40; }
// 文件访问辅助类
} else if (Platform.isAndroid || Platform.isOhos || Platform.isLinux || ...) {

唯一例外是 pdfium_loader.dart,那里用 Platform.operatingSystem == 'ohos':

// Detected by OS name because dart:io exposes no dedicated Platform.isOhos
// on standard Dart SDKs.
if (Platform.operatingSystem == 'ohos') return 'libpdfium.so';

为什么要区分:pdfium_dart 是纯 Dart 包(可脱离 Flutter 使用,也支持 dart test),需要兼容标准 Dart SDK;而 pdfrx / pdfrx_engine 运行在鸿蒙 Flutter 上,可直接用 Platform.isOhos。


五、关键决策说明

决策 1:用官方 linux-musl-arm64,不自建 PDFium

调研初期考虑过自建:检出 PDFium 源码、配置 OHOS NDK 交叉编译。评估后放弃。

方案优点缺点
官方 linux-musl-arm64 ✅零成本;来源可追溯(官方 release);已验证可用理论上未针对鸿蒙编译,但 musl ABI 匹配
自建 PDFium完全可控,可裁剪需完整源码树 + OHOS NDK;构建以小时计;磁盘约 50 GB;升级需重复

决定性依据是哈希比对——社区已验证可用的鸿蒙版与官方 musl 版 md5 完全一致(91f3f6a1…)。既然官方产物就是可用产物,自建纯属浪费。

维护策略:升级 PDFium 版本时(pdfrx 的 _pdfiumRelease 常量),从官方 release 取同名 linux-musl-arm64 产物,替换 ohos/libs/arm64-v8a/libpdfium.so 并同步更新 README/Release 中的 MD5。

决策 2:原生库随 HAR 分发,而非构建期下载

pdfrx 在其他平台是「构建期从 GitHub 下载」,鸿蒙上改为「随 HAR 分发」。

方案优点缺点
随 HAR 分发 ✅开箱即用;构建不依赖外网;版本确定仓库体积增加 16.7 MB
构建期下载仓库保持轻量需适配鸿蒙下载源;code_assets 不支持 ohos;企业内网易失败

这与 pdfrx 其他平台的既有做法不同,但符合鸿蒙生态惯例(HAR 自带 libs/<abi>/),也让使用者不必关心原生库来源。

决策 3:构建钩子「静默跳过」而非报错

code_assets 不认识 ohos,读取 targetOS 会抛异常。选择 try/catch + return 而非向上抛 UnsupportedError。

理由:鸿蒙路径本就不需要构建钩子做任何事(原生库已随 HAR 就位)。钩子的唯一职责是「不要阻碍构建」,因此静默跳过是正确语义。

决策 4:material_ui → flutter/material,而非本地 shim

详见 3.6。核心教训是 dependency_overrides 只在根包生效——用 shim 能让自己的 example 编过,却让所有使用者编不过,对一个要发布的 fork 而言是伪修复。

选择直接替换 import,因为 material_ui 本就是 SDK Material 的拆分包,语义等价;且 OH SDK 的 flutter/material 已维护好 ohos 分支。

决策 5:鸿蒙跳过系统字体回调(明确降级)

崩溃根因是跨 isolate 的 NativeCallable 函数指针在鸿蒙上失效。修复方向有两个:

方案优点缺点
鸿蒙跳过安装 ✅一行判断;立即稳定;已验证可用缺失内嵌字体时回退逻辑不经过 Flutter 字体管理器
改在 Dart 主 isolate 创建回调保留全部字体能力需重构 BackgroundWorker 架构;风险高、回归面大

首次适配以「稳定可用」优先,选择跳过并明确写入文档的「遗留问题」,而非默默吞掉。

决策 6:把 libpdfium.so 作为 Release 附件发布

原生库虽已随 HAR 分发,但仍有使用场景需要单独获取(如业务工程不使用 pdfium_flutter 插件、或需固定到特定 ABI)。

做法:创建 Release 并上传 libpdfium.so 附件,README 给出 curl 一键下载命令与 MD5 校验值。理由:16.7 MB 的二进制让使用者从仓库里翻找不合适,Release 附件是标准分发渠道,且便于校验完整性。


六、测试与验证

测试环境

项目版本
Flutter3.47.5-ohos-1.0.0
Dart3.10.x(随 Flutter)
HarmonyOS SDK6.0.0(26)(compatibleSdkVersion: 26.0.0)
IDEDevEco Studio 26.0.0(26.0.0.821)
设备 ROMOpenHarmony-7.0.0.105
真机型号ALN-AL00(HUAWEI,arm64-v8a,非模拟器)

版本获取方式:

版本项获取方式
Flutter / Dartflutter --version
HarmonyOS SDKexample/ohos/build-profile.json5 的 compatibleSdkVersion
IDEdefaults read /Applications/DevEco-Studio.app/Contents/Info.plist CFBundleShortVersionString
设备 ROMhdc shell "param get const.ohos.fullname; param get const.product.model"

验证要点

  1. PDFium 加载 — INIT=ok,FFI 符号解析成功,无 Failed to load PDFium module
  2. 文档解析 — OPEN=ok pages=3,字体回调崩溃已消除
  3. 页面渲染 — RENDER=ok 400x400 dark=1.94%,暗像素占比证明画出真实内容
  4. 文本抽取 — TEXT=ok len=120,独立第二通道验证
  5. 构建产出 — hvigor assembleHap 成功,HAP 内含 libs/arm64-v8a/libpdfium.so
  6. 插件注册 — getUniqueClassName() 与 pluginClass 一致,运行时可解析
  7. Release 附件 — 从 Release 下载的 .so 哈希与仓库内文件一致

附件完整性校验

$ curl -L -O https://atomgit.com/oh-flutter/pdfrx/releases/download/2.6.5-ohos-1.0.0-beta.1/libpdfium.so
$ md5 -q libpdfium.so
91f3f6a16c38f7dd5d35ca4d2ee33add      # 与仓库内文件一致 ✅
$ file libpdfium.so
ELF 64-bit LSB shared object, ARM aarch64, ...   # 有效 aarch64 动态库 ✅

七、运行效果

在真机(ALN-AL00)上安装并启动适配后的 example,PDF 正常渲染:

$ hdc shell "aa start -a EntryAbility -b com.example.my"
$ hdc shell "snapshot_display -f /data/local/tmp/render.jpeg"

实测数据(应用沙箱取证):

isOhos=true
INIT=ok
OPEN=ok pages=3
RENDER=ok 400x400 nonWhite=6589(4.12%) dark=3107(1.94%)
TEXT=ok len=120

截图区域分析显示 PDF 内容区以白色页面为主、含深色笔画:

区域白色占比暗色占比色阶数
PDF 内容区97.8%0.9%113
标题栏79.6%8.4%210

首页内容为 pdfrx 的示例文档(assets/hello.pdf),文本抽样:"pdfrx Hello, This is pdfrx, a Flutter PDF viewer implementa..."


八、遗留问题与改进方向

踩坑复盘

踩坑点现象 / 报错根因与解法
code_assets 无 ohos构建在钩子阶段抛异常枚举无 OHOS 项,读 targetOS 直接抛错。加 try/catch 静默返回,并用编译器路径(/openharmony/native/llvm/)识别鸿蒙构建
找不到 OHOS 版 PDFium官方 pdfium-binaries 44 个资产无 ohos哈希比对发现官方 linux-musl-arm64 与社区验证版 md5 完全一致,因鸿蒙用 musl libc,ABI 恰好匹配
插件类名大小写插件找不到flutter create 生成 PdfiumFlutterPlugin,与 pluginClass: PDFiumFlutterPlugin 不一致。重命名文件并同步 getUniqueClassName()
material_ui 编译失败The type 'TargetPlatform' is not exhaustively matched ... 'TargetPlatform.ohos'material_ui 的穷尽性 switch 缺 ohos 分支,67 处报错、所有版本均有。改用 SDK 的 flutter/material(material_ui 即其拆分包)
dependency_overrides 伪修复本地构建通过,使用者仍编译失败override 只在根包生效,被依赖方不继承。必须改源码而非加 override
cupertino_ui 同类问题同上它是 material_ui 的传递依赖,移除 material_ui 后一并消失
首屏空白无异常应用启动但不显示内容path_provider 无 OHOS 实现,MethodChannel 找不到 handler,调用静默挂起。引入 path_provider_ohos
HOME 未定义初始化抛异常鸿蒙进程不保证暴露 HOME,原代码用 ! 断言。改为逐级兜底(HOME → 系统临时目录)
文档一打开即崩SIGSEGV(SEGV_MAPERR)@0xffffffe8...,#00 Not mapped,线程 DartWorker,启动 4 秒崩字体回调 FPDF_SYSFONTINFO 的 NativeCallable.isolateLocal trampoline 在跨线程调用时失效。addr2line 定位到 CFX_ExternalFontInfo::MapFont。鸿蒙跳过 _installFontMapper()
Dart 日志不可见诊断输出在 hilog 中查不到鸿蒙上 Dart stdout 不进 hilog,Flutter 又是单一 surface(dumpLayout 无组件树)。改用写沙箱文件 + hdc 读回取证
「非零像素」证据太弱误判渲染成功空白白页的非零像素同样接近 100%。改用真实墨水占比(与纯白不同的像素 + 暗像素)作为判据

已知问题

  1. 系统字体回调未安装 — 鸿蒙平台跳过 FPDF_SYSFONTINFO 安装(原因见 3.7)。影响:PDF 内嵌字体缺失时,回退字体的选择由 PDFium 内置逻辑决定,不经过 Flutter 侧字体管理器。
  2. 仅验证 arm64-v8a — 本次仅覆盖 arm64-v8a 真机。官方 PDFium 亦提供 linux-musl-arm(32 位)产物,但未做真机验证。
  3. 上游 API 变动风险 — material_ui 的引入是 pdfrx 2.6.5 的新变化。若上游后续版本调整 Material 依赖策略,本次的 import 替换需要同步复核。

未来优化

  • 恢复字体回调能力 — 研究在 Dart 主 isolate 创建 NativeCallable、或改用 NativeCallable.listener 的可行性,以恢复完整的字体映射能力。
  • 自动校验原生库哈希 — 在构建钩子中校验 libpdfium.so 的 MD5,防止误替换导致的运行时崩溃。
  • 补充 32 位与模拟器支持 — 验证 linux-musl-arm 产物在鸿蒙 32 位设备/模拟器上的可用性。
  • 向上游提交适配 — 目前 pdfium_loader.dart 已有 ohos 分支(上游已具备部分鸿蒙意识),可将完整适配整理为 PR 回馈上游。

九、总结

这次适配的真正难点

pdfrx 的适配,与「把 Android 的 Kotlin 逻辑翻译成 ArkTS」完全是两回事。ArkTS 代码总共只写了一个空壳类,真正的战场在三个地方:

1. 构建期 ── 让 code_assets 不认识 ohos 这件事不阻碍构建
2. 分发期 ── 找到能在鸿蒙上跑的 PDFium,并让它随 HAR 到达设备
3. 运行期 ── 定位并消除只在鸿蒙出现的跨线程函数指针崩溃

可复用的经验

其一:先判断插件类型,再定适配策略。

插件类型特征适配重心
MethodChannel 插件pubspec.yaml 无 ffiPluginArkTS 业务逻辑翻译
FFI 插件ffiPlugin: true + 无通道构建钩子 + 原生库分发 + ABI 匹配

对 FFI 插件而言,ArkTS 侧几乎是空活,把精力花在写 ArkTS 上是南辕北辙。

其二:遇到「平台没有对应产物」,先用哈希比对认清现实。

本次最大的时间节省来自一个简单动作——把社区已验证可用的二进制与官方产物做 md5 比对。结果完全一致,直接省掉了数小时的自建编译和 50 GB 磁盘。断言「必须在鸿蒙上重新编译」之前,先验证这个断言。

其三:原生崩溃要用工具定位,不要猜。

SIGSEGV 的栈帧里有 Not mapped 与 #01 ... libpdfium.so,配合 llvm-addr2line 还原出 CFX_ExternalFontInfo::MapFont,从「字体回调」一路追到「worker isolate 的 NativeCallable 跨线程失效」。如果只盯着 Dart 层看,这个 bug 永远不会被找到。

其四:证据要够硬。

验证阶段一度以为截图「白底 = 渲染成功」就够了,直到意识到空白白页也是白底。改用「真实墨水占比 + 文本抽取」双通道后,结论才站得住。能区分成功与失败的最小证据,才是合格证据。

交付物

项目内容
仓库https://atomgit.com/oh-flutter/pdfrx
TAG2.6.5-ohos-1.0.0-beta.1
Release附件 libpdfium.so(16.7 MB,arm64-v8a)
文档README.OpenHarmony_CN.md / README.OpenHarmony.md / CHANGELOG.OpenHarmony.md
规模76 个文件、+1128 / −38 行

Dart 层公开 API 零改动,其他平台代码完全不受影响——这正是 Flutter 跨平台生态的价值所在:即便是一个 FFI 深度的库,平台扩展也只需在既有架构上补分支,而不必重写。


参考文档


开源协议

本项目基于 MIT 协议开源,与上游 pdfrx 保持一致。

Logo

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

更多推荐