Flutter 三方库 pdfrx 的 OpenHarmony 适配实战
本文记录了将开源 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 平台。
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=ok | PDFium 加载成功 | 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 的逐方法翻译,而是「各平台如何准备并加载同一个原生库」的对照:
| 维度 | Android | OpenHarmony | iOS / macOS |
|---|---|---|---|
| 原生库来源 | 构建期下载 android-arm64 | 随 HAR 分发(官方 linux-musl-arm64) | XCFramework 静态链接 |
| 注册方式 | Gradle 插件 | ffiPlugin: true + 空壳 ArkTS 类 | ffiPlugin: true + Swift 类 |
| 加载方式 | dlopen("libpdfium.so") | dlopen("libpdfium.so") | DynamicLibrary.process() |
| 平台判定 | Platform.isAndroid | Platform.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.mdREADME.OpenHarmony.mdCHANGELOG.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 附件是标准分发渠道,且便于校验完整性。
六、测试与验证
测试环境
| 项目 | 版本 |
|---|---|
| Flutter | 3.47.5-ohos-1.0.0 |
| Dart | 3.10.x(随 Flutter) |
| HarmonyOS SDK | 6.0.0(26)(compatibleSdkVersion: 26.0.0) |
| IDE | DevEco Studio 26.0.0(26.0.0.821) |
| 设备 ROM | OpenHarmony-7.0.0.105 |
| 真机型号 | ALN-AL00(HUAWEI,arm64-v8a,非模拟器) |
版本获取方式:
| 版本项 | 获取方式 |
|---|---|
| Flutter / Dart | flutter --version |
| HarmonyOS SDK | example/ohos/build-profile.json5 的 compatibleSdkVersion |
| IDE | defaults read /Applications/DevEco-Studio.app/Contents/Info.plist CFBundleShortVersionString |
| 设备 ROM | hdc shell "param get const.ohos.fullname; param get const.product.model" |
验证要点
- PDFium 加载 —
INIT=ok,FFI 符号解析成功,无Failed to load PDFium module - 文档解析 —
OPEN=ok pages=3,字体回调崩溃已消除 - 页面渲染 —
RENDER=ok 400x400 dark=1.94%,暗像素占比证明画出真实内容 - 文本抽取 —
TEXT=ok len=120,独立第二通道验证 - 构建产出 — hvigor
assembleHap成功,HAP 内含libs/arm64-v8a/libpdfium.so - 插件注册 —
getUniqueClassName()与pluginClass一致,运行时可解析 - 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%。改用真实墨水占比(与纯白不同的像素 + 暗像素)作为判据 |
已知问题
- 系统字体回调未安装 — 鸿蒙平台跳过
FPDF_SYSFONTINFO安装(原因见 3.7)。影响:PDF 内嵌字体缺失时,回退字体的选择由 PDFium 内置逻辑决定,不经过 Flutter 侧字体管理器。 - 仅验证 arm64-v8a — 本次仅覆盖 arm64-v8a 真机。官方 PDFium 亦提供
linux-musl-arm(32 位)产物,但未做真机验证。 - 上游 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 无 ffiPlugin | ArkTS 业务逻辑翻译 |
| 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 |
| TAG | 2.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 深度的库,平台扩展也只需在既有架构上补分支,而不必重写。
参考文档
- pdfrx 官方仓库
- PDFium 官方
- pdfium-binaries 预编译产物
- HarmonyOS Flutter 适配指南
- Dart FFI 官方文档
- Flutter native assets / hooks
开源协议
本项目基于 MIT 协议开源,与上游 pdfrx 保持一致。
更多推荐



所有评论(0)