ITPub博客

首页 > 应用开发 > Javascript > 技术解读:把开源 PDF 引擎塞进 JSVM:在鸿蒙 PC 上跑出一个 JasperReports 报表设计器-CSDN博客

技术解读:把开源 PDF 引擎塞进 JSVM:在鸿蒙 PC 上跑出一个 JasperReports 报表设计器-CSDN博客

Javascript 作者:Flow_Official 时间:2026-10-13 11:27:34 0 删除 编辑
这篇文章讲的是,我们怎么把一个 2.3MB 的纯 JavaScript PDF 生成引擎(pdf-lib + fontkit),在不引入任何外部二进制的前提下,完整搬到 HarmonyOS PC 上,做出了一个能编辑五带报表、能嵌入中文字体、能导出像素级完美 PDF 的 JasperReports 风格工具。所有结论都是在真机上构建、安装、点击、抓日志、跑系统对话框之后总结出来的,不是看代码推断的。
设计器主界面——JRXML Bands 结构面板 + 五带编辑 + 控制台日志

一、缘起与目标:把报表引擎带上鸿蒙 PC

JasperReports 是世界上最流行的开源报表引擎之一(Java 编写,LGPL-3.0 协议),其杀手锏是一套被无数企业验证过的"报表能力模型":把一张报表拆成几个带(band)—— Title、Column Header、Detail、Summary、Page Footer——每个带放不同内容,按顺序流式渲染,最终导出像素级完美的 PDF。配合 Jaspersoft Studio 这个 Eclipse RCP 的桌面设计器,开发者可以"先设计模板,再填充数据,再导出报告"地完成一整套业务交付。

鸿蒙 PC 桌面生态正在快速补齐原生应用,但"桌面报表设计 / PDF 生成"这一类生产力工具基本空白:用户在鸿蒙 PC 上想做一份带中文的导出式报表,要么用浏览器打开在线工具,要么远程调用 macOS/Windows 上的 JasperReports,要么干脆装个 WPS 自己拼——体验割裂,数据出域,安全也有隐患。能不能在鸿蒙 PC 上原生跑一个"能设计、能编辑、能导出"的 JasperReports 式工具?

这就是我们给自己定的目标:一个 HAP 完全自包含、不依赖任何外部运行时、能在鸿蒙 PC 真机上完整设计并导出中文 PDF 报表的应用。

二、技术选型:JSVM 而非 JDK 内嵌

最容易想到的路线是直接移植 JasperReports Library 的 Java 源码 :在鸿蒙 NDK 里交叉编译 JDK,把 JasperReports jar 装到 HNP(鸿蒙原生包)里,通过 execmem shim 启动 JVM 执行——这条路在 ohos_JasperReports 那个工程里我们走通了,但代价是 100MB+ 运行时、复杂的 shim 层、加上鸿蒙原生层对 hnp_file / filesDir 的 SELinux noexec 限制要绕坑—— /data/local/tmp 上任何文件都不能 exec,哪怕 chmod 755 加 musl ELF 也是 Permission denied。这条路线能跑通,但要持续维护一套 JDK/JVM shim,且 HAP 体积膨胀到 130MB+,对桌面端工具来说太重了。 https://github.com/Jaspersoft/jasperreports

我们选了一条更轻的路:找 纯 JavaScript 实现 的 PDF 生成引擎—— pdf-lib (Hopding/pdf-lib,LGPL-3.0)+ @pdf-lib/fontkit (负责字体解析与子集化),把它们作为
界面层随目标平台原生技术栈落地,执行层保持上游真实实现 ——这是跨平台 GUI 移植的通行做法。
  • JSVM-API ( libjsvm.so ):NDK 自带的完整标准 JavaScript 引擎(ark_runtime 的服务侧),支持 ES 2020+、Promise、TypedArray、Proxy、 OH_JSVM_PerformMicrotaskCheckpoint 微任务泵;

  • esbuild IIFE :把 pdf-lib + fontkit 的 CommonJS 模块全量打包成 2.3MB 的 IIFE( globalName: PDFLib ),含 Buffer/process polyfill,无需任何 Node 原生依赖;

  • OpenHarmony Worker :把 JS 引擎的同步阻塞调用挪出 UI 线程,杜绝 APP_FREEZE。

整个应用 不 exec 任何外部二进制 (不依赖 JDK、不依赖 CPython、不依赖任何 HNP),HAP 26MB(含 22MB 中文字体)完全自包含——天然绕开了鸿蒙原生层所有的 noexec 坑。

在这里插入图片描述

三、整体架构:JSVM 内嵌 PDF 引擎

HAP 工程目录很标准,配置层、NAPI 桥、rawfile 资源、ArkTS UI 四齐全齐。数据流是这样的:

在这里插入图片描述

rawfile 三件套( pdflib.global.js / pdfgen.js / cjk-font.ttf )首次启动由 ArkTS 落盘到 filesDir/engine/ ,之后 C++ 桥自己 ifstream 读文件,不再走任何序列化路径——这是后来修第二个坑时定下的铁律。

UI 层按 Jaspersoft Studio 桌面设计器的信息架构做了完整对齐:顶部工具栏(JasperReports 橙 logo + 状态 + 生成/另存为),左侧 JRXML Bands 结构面板(六个带实时反映设计数据),中部设计区(Title/字段/明细三张编辑卡片 + 合计行),底部 IDE 风格控制台日志。

四、五个深坑实录

接下来是文章的主体。这一路我们一共踩了五个深度从浅到深的坑,每个坑都是在真机上稳定复现、定位、修复、回归验证过的。把这五个坑讲明白,整套移植思路就讲明白了一半。

坑 1:JSVM 没有事件循环,Promise 永远不 settle

第一版 glue 把 __generatePdf 直接返回 Promise。NAPI 在 C++ 端 OH_JSVM_CallFunction 同步拿到返回值,按 Uint8Array 读取,结果当然不对——Promise 对象永远不是 TypedArray。报错:read 时识别成非 typedarray。

第二版在 glue 里加了微任务泵:循环 OH_JSVM_PerformMicrotaskCheckpoint 把排队的 Promise 续体跑掉,问题只解决了一半——但凡引擎里有一个用了 await ,那个 await 之后的 .then 回调就是 microtask,要靠泵才能跑。第三版做了一个彻底的改造: glue 不再返回 Promise ,而是用 syncAwait(promise, label) 这个同步包装器:它把 promise.then(resolve, reject) 注册成两个回调,写到共享的 __pdfState = {done, ok, value, error} ,再在 while (!done) __pumpMicrotasks() 里死等直到 settle。

function syncAwait(p, label) {
  var settled = false;
  p.then(v => settled = true, e => settled = true);
  while (!settled) {
    __pumpMicrotasks();   // native 函数 → OH_JSVM_PerformMicrotaskCheckpoint(vm)
  }
}

这个套路看起来野蛮,但它就是 JSVM 这种"无事件循环的嵌入式 JS 引擎"里把异步 API 当同步 API 用的标准做法。实测验证:embedFont 阶段 0.83 秒(22MB 中文字体子集化)就走完了,且 __pumpMicrotasks 被反复调用期间 UI 线程完全空闲——因为所有这些都跑在 Worker 线程上。

坑 2:Worker 消息序列化限制,23MB 字体过不去

第一版架构里,引擎资产(2.3MB bundle + 23MB font)由 ArkTS 读 rawfile 得到字节,再通过 worker.postMessage({cmd: 'init', bundleSrc, glueSrc, fontBytes}) 传给 worker 线程。一安装跑起来,ArkTS 直接抛:

An exception occurred during serialization, failed to serialize…

worker 的序列化层对 ArrayBuffer / 长字符串有尺寸上限,23MB 字体加 2.3MB 字符串一次性塞消息对象里直接超限——而且 PDF 引擎的实际需求恰恰就是"收到一份原始字节"。

修法: 大资源一律走文件路径 。ArkTS 端把三个 rawfile 资产写到 filesDir/engine/ ,消息里只传三个路径字符串( bundlePath , gluePath , fontPath )与 spec JSON;C++ 桥自己 ifstream 读文件到 vector 后再用:

std::string bundleSrc;
ReadFileText(bundlePath, bundleSrc);   // 2.3MB
std::vectorfontBytes;
ReadFileBytes(fontPath, fontBytes);     // 22MB

Worker 消息体里只剩小字符串和 JSON,序列化层毫无压力,C++ 桥的文件 IO 又快又稳。顺带修了一个非常隐蔽的缓存陷阱:之前 sendInit 里写了 if (!fileIo.accessSync(dst)) { ... write ... } ——如果设备已经跑过旧版 HAP,rawfile 已经落过旧版 pdfgen.js ,应用启动会跳过重写,永远用旧 glue。改为每次启动强制重写( TRUNC + 覆盖)后,这种"改了代码但没生效"的问题彻底消失。

坑 3:Native 回调结构体的生命周期,glue eval 立刻崩

加完微任务泵、加上 Worker,引擎初始化走到 eval glue start 这一步就崩了——native crash, OH_JSVM_RunScript 内部直接 dump,进程被杀。 hdc hilog 抓到的栈显示: JsvmHost::Initialize+1076 → RunSource → OH_JSVM_RunScript 。

llvm-addr2line 精确定位到 jsvm_host.cpp:149 行的 OH_JSVM_RunScript ,再分析 glue 顶层的代码:glue 第一句是 var log = function(msg) { try { console.log(...) } catch(_) {} }; log('glue loaded...') —— console.log 走 native 回调 ConsoleLogCb 。bundle eval 期间 bundle 本身不调 console.log(不崩),glue eval 期间一调 console.log 立刻崩。典型的 use-after-free 。

栈变量嫌疑最大——重新看 RegisterConsole 的实现:

void
RegisterConsole
(
JSVM_Env env
)
{
JSVM_Value console
=
nullptr
;
OH_JSVM_CreateObject
(
env
,
&
console
)
;
JSVM_CallbackStruct cbStruct
=
{
ConsoleLogCb
,
nullptr
}
;
for
(
auto
m
:
methods
)
{
OH_JSVM_CreateFunction
(
env
,
m
,
strlen
(
m
)
,
&
cbStruct
,
&
fn
)
;
.
.
.
}
}

JSVM 的 OH_JSVM_CreateFunction 接收的是 JSVM_Callback (指向 CallbackStruct 的指针)。如果 JSVM 实现 保留了指针本身 而非拷贝其中的函数指针,那么 RegisterConsole 返回后栈地址悬空,glue 里真正调用 console.log 时解引用 → UAF。setTimeout 那组我已经用了 static ,唯独 console 用的是栈变量——致命的漏掉。

修法:把 console 的 cbStruct 也改成 static ,所有注入 native 回调的 struct 统一生命周期管理。同时把三处 OH_JSVM_GetValueStringUtf8 提取 JS 字符串到 std::string 的写法规范化(用统一的 GetStringSafe helper:分配 len+1 容量,传 len+1 , resize(copied) 截断),避免极端情况下的越界隐患——这俩修完之后,bundle 完整 eval、glue 完整 eval、 JsvmHost initialized OK ,引擎就此稳定下来。

坑 4:fontkit Node Stream 调度链断,save 永久卡死

至此 create/embedFont/draw 全部通了,只剩最后一步 pdf.save() 。结果 syncAwait(save) 跑三百万次循环都不 settle ,且探针显示 setTimeout 调用计数为 0,但 native setTimeout invoked 日志显示 native callback 被调了 4 次后就停了。

四个数据点凑在一起,几乎把"哪条调度链断了"讲得明明白白:

  • hb=true (心跳 microtask 跑了)—— OH_JSVM_PerformMicrotaskCheckpoint 对新排 Promise 是有效的;

  • stCalls=0 (glue 里的覆盖版 setTimeout 没被调)—— setTimeout 不走我们覆盖的微任务版本;

  • native setTimeout invoked 被调 4 次(drainQueue 正常)—— fontkit 内嵌的 browserify process.nextTick 队列通过 cachedSetTimeout 同步 drain;

  • 次后停—— queue.length === 1 && !draining 条件不再满足,或 readable-stream 的内部调度链断了。

挖到 bundle 里 fontkit 的源码真相:

_proto
.
encodeStream
=
function
encodeStream
(
)
{
var
_this
=
this
;
var
s
=
new
restructure
.
EncodeStream
(
)
;
nextTick
(
function
(
)
{
_this
.
encode
(
s
)
;
return
s
.
end
(
)
;
}
)
;
return
s
;
}
;

serializeFont 在外面 .on("data")/.on("end") 等事件——这中间所有 readable-stream 的 resume_/flow/endReadableNT 都依赖 process.nextTick 队列的持续 drain。问题在于队列 drain 的几轮后, queue.length === 1 && !draining 这个守卫条件不再为真(队列里可能有未清空项,或 draining 状态未复位),drain 调度停了,可读端的续体永远等不到——settled 永远是 false。

我们不再修事件循环 ,改成外科手术——直接 monkey-patch CustomFontSubsetEmbedder.prototype.serializeFont :

Ctor
.
prototype
.
serializeFont
=
function
(
)
{
var
subset
=
this
.
subset
;
return
new
Promise
(
function
(
resolve
,
reject
)
{
try
{
var
s
=
subset
.
encodeStream
(
)
;
s
.
on
(
'error'
,
function
(
)
{
}
)
;
subset
.
encode
(
s
)
;
s
.
end
(
)
;
var
parts
=
[
]
;
var
chunk
;
while
(
(
chunk
=
s
.
read
(
)
)
!==
null
)
{
parts
.
push
(
chunk
)
;
}
...
合并为 Uint8Array
,
resolve
...
}
catch
(
e
)
{
reject
(
e
)
;
}
}
)
;
}
;

外加给 pdf.save() 传 { objectsPerTick: Infinity, useObjectStreams: false } ,关掉协作让步与流压缩路径。修复后 save 从"永久卡死"变成 95ms 一次完成。心跳探针当场打到 ALIVE 日志——链路通了。事后想想这是一招"识破引擎内部异步依赖,绕过去自己写同步版"的典型技巧——任何"在嵌入式 JSVM 里跑浏览器/Node JS 库"都会撞上类似的事,把流式 API 手动收集是个通用的转同步套路。

坑 5:UI 线程被同步 NAPI 阻塞,触发 APP_FREEZE

引擎真能跑了—— pdf.embedFont(fontU8, {subset: true}) 在 0.83 秒内完成子集化、生成 PDF 字节——但是整个调用是 同步 NAPI :ArkTS await pdflibBridge.generatePdf(...) 是个同步方法,期间 UI 线程被锁住。22MB 字体的子集化确实只要一秒不到,但这一秒里用户切窗都切不动,应用直接被系统报 APP_FREEZE(应用冻结)。

修法: 全部引擎调用迁入专用持久 Worker 线程 ( @ohos.worker.ThreadWorker ),UI 线程只收发消息。架构变成:

  • UI 线程: new ThreadWorker(...) 创建引擎线程 → postMessage({cmd:'init', bundlePath, gluePath, fontPath}) 等响应

  • Worker 线程:接收 init → initEngine(...) → 响应 init-ok;接收 generate → generatePdf(...) → 把 Uint8Array 通过 transfer 给回 UI 线程

迁移时还踩了两个 API 兼容坑:鸿蒙 API 23 的 @ohos.worker 里 ThreadWorker 模块 接收主线程消息的对象叫 workerPort 不是 workerInstance ,事件类型 MessageEvents (不是 ThreadWorkerMessageEvents ),错误事件 ErrorEvent (来自 @ohos.worker 的 named export 而不是 worker.ErrorEvent )——这些以本机 SDK 的 d.ts 为准,不能想当然写老文档里看到的名字。

修完之后 UI 线程在 generate 期间零阻塞:可以切窗口、可以改字段、可以再次点"生成"——只是 busy=true 时按钮置灰。这是"线程化长任务"在鸿蒙上的标准做法,但在 ArkTS 里 Worker 实例的生命周期( aboutToDisappear 调 terminate )和 postMessage 的序列化约定(特别是大对象必须走 transfer 数组)要小心。

五、品牌与界面:从 PDF 库到 JasperReports 形态

引擎全通之后,剩下的是"长什么样"。Jaspersoft Studio 桌面设计器有非常成熟的视觉语言:顶部品牌栏 + 左侧结构面板 + 中部设计画布 + 底部控制台。我们按这个布局做了完整的 ArkTS 实现。

设计层用 @Builder 把"工具栏/结构面板/设计区/控制台"四个区域拆出来,数据模型升级为二维表格( columns: Array

导出 PDF 后的应用状态——左侧结构面板反映实时数据

品牌也完全对齐 JasperReports:窗口标题、应用名、桌面图标、标题栏 logo——所有图标有两处要改( AppScope/resources/base/media/app_icon.png 是桌面图标的真正来源, entry/.../media/ 是应用内启动图标),我们一开始漏换了前者,桌面就还显示模板壳自带的图标。颜色用 Jaspersoft 橙 #E95722 ,与官方品牌色一致;logo 图形用 ArkTS 画的"橙色圆角方块 + 白色三柱"——跟用 PIL 制作的的桌面图标保持视觉一致。

PDF 渲染输出也按 JasperReports 经典报表样式做的:Title 带双线分隔、Column Header 灰底矩形 + 黑字、Detail 带斑马纹 + 金额右对齐 + 分隔线、Summary 右对齐合计 + 强调色、Page Footer 每页加 Page N 页码。这套样式跨平台显示一致,无论用什么 PDF 阅读器都能稳定呈现。

六、最终效果:可获取的 PDF

系统保存对话框用的是 @ohos.file.picker.DocumentViewPicker ——这是免权限的官方推荐做法,用户在弹窗里选位置(桌面/下载/文档都行),选完回写 PDF 字节即可,无需 READ_WRITE_DOWNLOAD_DIRECTORY 受限权限。

系统保存对话框——免权限选位置导出

导出后的 PDF 用 WPS Office 打开:

WPS Office 打开的导出 PDF——三列标准样例
WPS Office 打开的导出 PDF——动态加列后的四列样例

Title 副标题、灰底列头、斑马纹明细、末列右对齐、自动页码、合计强调色——五带报表该有的样子都有。生成一份 3 行 3 列的标准样例 PDF 大约 24KB,加 4 列后 26KB,差异主要是列宽计算和文本宽度测量——与字段数与字符长度线性相关,符合预期。

七、经验与思考

把这条路趟通之后,回头看有几个判断可以共享给后续做"开源 X 移植到鸿蒙 PC"的同学们:

1. JSVM 路线适合"小到中型、纯 JS、无重度 Node 原生依赖"的开源项目 。pdf-lib + fontkit + readme 之类的纯计算库几乎是天作之合。如果目标是 Knex.js / pdfkit / sharp 那种带 Node 原生扩展的库,就要权衡是引入 HNP 还是改用纯 JS 替代实现。判断的标尺很简单: 看依赖图里有没有 .node / node-gyp ——没有就值得试 JSVM。

2. Worker 线程 + NAPI 桥几乎是无脑正确的解药 。任何"长任务(>100ms)"——比如字体子集化、JPEG 解码、SQL 查询、大数据集排序——都应该跑在 Worker 线程而不是主线程。这条不管什么平台都是对的,鸿蒙的 @ohos.worker 实现简单可靠,关键是别忘了 aboutToDisappear 调 terminate() 。

3. JSVM 没有事件循环,必须把异步调用同步化 。三种打法可选:

  • 同步包装器 ( syncAwait )——把 Promise 注册 then 写到全局状态,循环泵微任务直到 settle;

  • 事件循环模拟 ——自己维护宏任务/微任务队列,setTimeout 走宏任务队列、Promise.then 走微任务队列,复杂度高但通用;

  • 外科手术 monkey-patch ——像坑 4 那样直接绕开异步 API 用同步重写。 简单项目用第一种就够;中型项目三种混用;不要尝试完整模拟 Node 事件循环,那是 Node.js 二十年沉淀的产物。

4. 图标有两处都要换 —— AppScope 管桌面图标, entry 管应用内启动图标,模板壳派生的项目里几乎一定会在某处遗漏,需要 search_file -pattern '*.png' 全仓扫一遍。

5. 真机实测是唯一权威 。DevEco 模拟器的行为跟真机可能不一样(PDF 大文件渲染、性能、字体子集化时间等),而且只有真机上有 APP_FREEZE / SELinux / hdc shell 权限边界这些平台特有的坑。每一个修复都必须在真机上点过、看过 hilog、看过 UI 状态才算完。"看起来工作"和"真机工作"之间的差距,是开源移植最大的成本来源。

整个工程约 6000 行代码(ArkTS 约 1000 行、C++ 桥约 400 行、glue 约 500 行、esbuild 打包脚本约 100 行、上游 pdf-lib+fontkit 经打包后约 25000 行——是的,上游引擎源码完整参与运行),一个 HAP 完全自包含,总体积 26MB。鸿蒙 PC 真机冷启动 2 秒就绪,生成一份 3 行 3 列中文报表 1.2 秒,UI 全程零冻结。

下一步计划:把内置 22MB 的占位字体替换为 OFL 开源中文字体(如思源黑体按常用字子集化),把 HAP 压到 8MB 以内;引入 JRXML 模板解析,让用户能导入/导出 JasperReports 模板格式;接 CSV/JSON 数据源,从外部文件填充 Detail 带——路线已经验证,剩下的是把拼图一块块填齐。

八、常见问题 FAQ

Q1:为什么不用 JDK HNP 路线直接移植 JasperReports 的 Java 源码? 能走通,但要背上 100MB+ 的 JDK 运行时和一套 execmem shim,HAP 体积膨胀到 130MB 以上,后续还要持续维护 JVM 适配层。pdf-lib + fontkit 是纯 JavaScript 实现,塞进 JSVM 后整个 HAP 26MB 完全自包含、零外部二进制——对桌面工具类应用,这笔账明显更划算。

Q2:装上 HAP 后点「生成 PDF」没反应? 先看状态栏文字:如果一直停在「正在加载」说明 Worker 线程的 initEngine 没完成,用 hdc hilog | grep pdflib 看引擎日志;如果显示「生成中…」且按钮变灰,是正常状态——22MB 字体子集化约 1 秒,期间 UI 不卡但按钮锁定,等状态变绿即可。

Q3:改了 glue 脚本(pdfgen.js)为什么在设备上不生效? 早期版本会把 rawfile 落盘 filesDir/engine/ 后跳过重写,旧文件一直被复用。现已改为每次启动强制覆盖;如果用的是老版本 HAP,卸载重装即可。

Q4:桌面图标还是模板壳的旧图标? 图标有两处: AppScope/resources/base/media/ 管桌面图标(app.json5 的 icon 引用), entry/.../media/ 管应用内启动图标。只换后者桌面不会变,两处都要换。

Q5:安装时报 9568320? 部署的是未签名包。DevEco → File → Project Structure → Signing Configs 勾选自动签名重新构建;确认 build-profile.json5 里 products[].signingConfig 指向了 signingConfigs[].name 。

Q6:生成的 PDF 存在哪、怎么拿出来? 沙箱副本在 filesDir/ ( /data/storage/el2/base/haps/entry/files/ ),应用私有,文件管理器看不到、hdc 也拉不出来。用应用里的「另存为…」按钮走系统保存对话框( DocumentViewPicker ,免权限),选桌面或下载即可在文件管理器中直接看到、双击可开。

把开源 X 移植到鸿蒙 PC 这件事,没那么难,也没那么简单。希望这篇实战能帮到下一个趟这条路的人。


转载声明:本文为转载整理,原文版权归原作者及原站点所有。

原文来源:CSDN HarmonyOS

原创地址:https://blog.csdn.net/weixin_52908342/article/details/165120597

原文作者:一键难忘;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2022-08-02

  • 博文量
    36
  • 访问量
    34892