开头我先说个事。前阵子接到一个活,要在 OpenHarmony 设备上做一个文档预览功能,Flutter 端需要读取 .doc 和 .docx 文件里的纯文本内容。我第一反应是找现成 Flutter 插件,看了一圈发现 doc_text 最贴近需求:API 简单,支持 fromFile / fromBytes / fromAsset 三种方式,还能从加密文档里至少把标题和元数据拿出来。但问题也很现实——doc_text 的 Android 侧原本是跑在 JVM 上的,里面那把文档解析的重武器 Apache POI 跟 OpenHarmony 的运行时并不完全兼容。想要让它稳定跑在鸿蒙设备上,第一件事不是写新代码,而是把 Android 端的 POI 实现彻底拆开看明白:它到底解了什么、依赖了什么、哪些是 OpenHarmony 给不了的。这篇文章就是我这次适配前的完整分析记录,把这些内容整理出来,给正在做 Flutter 三方库向 OpenHarmony 迁移的团队一个可参考的底稿,尤其是那些依赖 POI 类 Java 库的插件,思路基本可以复用。
1. doc_text 的 Android 侧实现拆解:HWPF 与 XWPF 的文本提取链路
1.1 Flutter 插件层到 POI 的调用链
doc_text 这个库的架构属于典型的 Flutter 插件模式:Dart 层暴露面向用户的 API,实际干活的是各端的原生代码。Android 端整个调用链大致是这样:
Dart 调用 TextDocument.fromFile(path) ↓ MethodChannel "doc_text" ↓ TextDocumentPlugin(Android 侧 Handler) ↓ 提取路径参数,交给 Apache POI 解析 ↓ 返回纯文本 / 元数据这个链路本身很常规,但需要注意的是MethodChannel传递数据时的边界:Dart 侧传过来的 path 是字符串,如果是从content://之类的 ContentProvider 拿到的 Uri,Android 侧还要先转成真实路径或者用 ContentResolver 打开流。这也是我之前在一些项目的 issue 里看到的经典坑——用文档管理器的content://打开文件时,拿不到直接可用的绝对路径,很多插件死在这一步。
doc_text 的 Android 实现里,这类文件访问逻辑其实比较朴素,基本逻辑就是“传路径就开文件,传二进制就走内存流”。实际上 POI 的解析入口也是高度统一的:InputStream进来,HWPFDocument或者XWPFDocument出去,剩下就是调用 extractor 取文本。
1.2 POI 处理 .doc 与 .docx 的底层差异
Apache POI 在解析文档时,内部针对两种格式走了完全不同的两套实现:
- .doc 文件:本质是 OLE2 复合文档(Composite Document)。POI 里对应
HWPFDocument,文本提取走WordExtractor。HWPF 这条路处理的是二进制结构,文件头、目录扇区、FAT 表这些都要解析,对不规范的文档容错性比 docx 差一些。 - .docx 文件:本质是一个 ZIP 压缩包,里面装着一堆 XML 文件,正文在
word/document.xml。POI 里对应XWPFDocument,文本提取走XWPFWordExtractor。这条路最核心的逻辑其实是“遍历 body 里的 paragraph 和 table,把每个w:t节点里的文本拼起来”。
这里有个很容易被忽略的技术点:.doc和.docx虽然都叫 Word 文档,但它们的文本提取复杂度不在一个量级。.docx只要你愿意,自己写个 ZIP 解压加 XML 解析就能把文本搞出来,因为 text 基本都在document.xml里;.doc的文本存储却分散在 WRT(Word Text)流里,还要处理字符格式覆盖,手工实现的工作量大得多。
1.3 文本抽取的具体实现路径
doc_text 在 Android 端如果用的是 XWPF 系列,那么核心代码基本类似下面这种逻辑:
import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.extractor.XWPFWordExtractor; import org.apache.poi.hwpf.HWPFDocument; import org.apache.poi.hwpf.extractor.WordExtractor; public String extractText(InputStream is, String extension) { if (extension.endsWith(".doc")) { try (HWPFDocument doc = new HWPFDocument(is)) { WordExtractor extractor = new WordExtractor(doc); return extractor.getText(); } } else { try (XWPFDocument doc = new XWPFDocument(is)) { XWPFWordExtractor extractor = new XWPFWordExtractor(doc); return extractor.getText(); } } }看到这里可能有读者会有疑问:XWPFWordExtractor.getText()到底干了什么?我专门看过 POI 源码,这个方法的本质就是遍历 document body 下的所有区块,对每个XWPFParagraph调用getText(),对每个XWPFTable调用getText(),然后把它们逐行拼接。也就是说,对于一个内容比较复杂的 docx,拿到手的纯文本是丢掉大部分排版信息的——表格和段落虽然保留了顺序,但图片、批注、页眉页脚默认不输出。doc_text 的定位是“提取文本内容”,不是“还原排版”,搞清楚这层界限对后续适配很重要。
1.4 为什么引入 POI 而不是自己解析
如果是面对一个“只读文本”的需求,常见的替代方案包括 Tika、PDFBox、或者直接解压 ZIP。PDFBox 只能处理 PDF,和 Word 无关;Tika 是个聚合框架,会把 POI、PDFBox、ICU4J 全拖进来,体积爆炸;纯手工解 ZIP 能搞定 docx,但搞不定 doc,而且市面上老旧的 .doc 文件存量非常大。POI 的好处是 Apache 基金会维护、格式兼容覆盖广、社区案例多。所以 doc_text 选 POI 作为 Android 端解析引擎,逻辑上没有问题。
但这里要提前埋个伏笔:POI 的依赖树非常庞大。poi-ooxml会拉进poi、poi-ooxml-lite、xmlbeans、commons-compress、commons-io、curvesapi、log4j-api等。这些类库大多数都是纯 Java 实现,而让它们跑在 OpenHarmony 上,就是一个大问题。
2. 移植 OpenHarmony 之前先盘清楚依赖账:JVM 缺口与 Android API 耦合
2.1 OpenHarmony 并不是一个“标准 Java 运行时”
很多做 Flutter 移植的团队在评估 Java 库时,第一反应是“OpenHarmony 不是兼容 Java API 吗,把 jar 包扔进去不就行了?” 这个想法对一大半项目其实行不通。OpenHarmony 应用层确实支持 Java/Kotlin 开发,也提供了一套 Java 运行时,但它的 API 面跟标准 JDK 并不完全一样,更不是 Android SDK 的复刻。它有自己的ohos.*和org.apache.harmony.*系列 API,对javax.xml、java.awt、javax.imageio这些包的支持是有选择性的。
POI 这种重型库的问题恰恰在这里。比如org.apache.poi.ooxml模块里的 XML 解析大量使用javax.xml下的接口和 XMLBeans 生成的类型;某些输出相关特性会 touch 到java.awt;poi-scratchpad里处理图片的代码会对图像编解码有依赖。真要把 POI 打进 OpenHarmony,最麻烦的不是代码本身,而是编译期过完、运行期在某个冷门方法上直接抛NoClassDefFoundError。
所以在评估适配成本时,我习惯先做一遍“API 面审查”:把 POI 相关 jar 导入到一个标准 JDK 环境,用jdeps或者 IDE 的依赖分析工具扫描它引用了哪些java.*/javax.*包,再对着 OpenHarmony 公开的 Java API 列表一个个比对。这个方法很土,但非常有效,五分钟就能把风险清单拉出来。
2.2 Android SDK 特有 API 的耦合点
doc_text 的 Android 端除了 POI,还不可避免地和 Android SDK 绑定在一起。常见的耦合点有这几类:
- 文件访问:Android 上用
File、ContentResolver、Uri拿文件流;OpenHarmony 上用fileIo的openSync拿到 fd,或者用ohos.file.fs的工具操作。两者代码不能通用。 - 日志输出:Android 的
Log.i()/Log.e()在 OpenHarmony 里对应HiLog,API 签名完全不同。 - Flutter 插件注册:Android 的
MethodChannel是io.flutter.plugin.common,OpenHarmony 的 Flutter SDK 有独立的插件注册方式和 capability 声明机制。 - 上下文对象:Android 的
Context和 OpenHarmony 的Context(ohos.app.Context)是两套体系,凡是拿到 context 去做资源读取、文件路径拼接的逻辑都要重写。
别看这些都是“小问题”,真到移植时,每一个都会让代码编译不过。最稳妥的做法是:把 doc_text 的 Android 端拆成两层——纯 Java 的文本解析层和 Android 的插件桥接层。解析层在理论上不依赖 Android SDK,只要 POI 能跑起来就能复用;桥接层在 OpenHarmony 上用 ArkTS 或者 Java 重写。
但现实是,doc_text 最初编写时并没有刻意做这个分层,POI 解析和 Android 文件访问的逻辑大概率是混在同一个类里的。这就是为什么“分析 Android 端 POI 实现”会成为适配 OpenHarmony 的第一步攻坚任务。
2.3 POI 依赖集的体积问题
还有一个容易被忽略的指标是体积。POI 完整依赖集包括:
| 模块 | 体积(约) | 用途 |
|---|---|---|
| poi | 2.4 MB | 核心 OLE2 支持和 HWPF |
| poi-ooxml | 3.3 MB | OOXML 支持 |
| poi-ooxml-lite | 8.8 MB | 精简版 XMLBeans 类型 |
| xmlbeans | 3.5 MB | XML 对象映射 |
| commons-compress | 1.2 MB | ZIP 解压等 |
| commons-io | 0.3 MB | IO 工具 |
| log4j-api | 0.3 MB | 日志 |
| curvesapi | 0.1 MB | 曲线图生成 |
加起来二十多 MB。对于一个 HAR 包(OpenHarmony 的二进制包格式)来说,这会直接影响应用包体积和启动加载时间。Flutter 插件本身已经包括了 Flutter engine,再把二十多兆的 POI 塞进去,在设备存储和内存都比较紧凑的 OpenHarmony 设备上是一个真实的负担。
这就引出来了设计上的核心矛盾:是要功能全、体积大的 POI 全家桶,还是为了 OpenHarmony 的轻量化做减法?这个矛盾直接决定了适配路线的选择。
3. 三条落地路线:POI 移植版、Channel 轻量解析和混合方案的取舍
3.1 路线 A:将 POI 以 jar 形式直接迁入 OpenHarmony
这个路线的思路是最直观的:既然 OpenHarmony 支持 Java,就把 POI 编译好的 jar 或者它的 HAR 包装包放进工程,保留 doc_text 的核心解析逻辑,只把 Android 桥接层替换为 OpenHarmony 桥接层。
这个方案有一个现成的开源基础——GitHub 上有一个叫deptofchina/poi的项目,它维护着 OpenHarmony 版本的 Apache POI 移植,把poi、poi-ooxml、xmlbeans等编译成 OpenHarmony 平台可用的jar/har。我实际测试下来,新版对标准的 docx 文本提取基本能用,但版本往往滞后于 Apache 官方主线,而且对华夫饼这类边缘特性的支持不够稳。
优点很突出:
- 文本提取质量最高,和原版 POI 一致,尤其是 .doc 老格式兼容性有保障。
- 代码改动量最小,doc_text 的 Java 解析层可以整体复用。
缺点也很明显:
- 包体积大,二十多 MB 不可能忽视。
- 如果 POI 内部某个类在 OpenHarmony 运行时缺失,运行期才会暴露,问题定位较难。
- 后续 POI 官方升级新特性,移植版不一定能及时跟上。
3.2 路线 B:绕开 POI,在 ohos 侧实现轻量解析
这个路线不碰 POI,而是针对文本提取这个具体需求,在 OpenHarmony 原生侧写一个轻量解析器:
.docx:直接 ZIP 解压,读word/document.xml,提取所有<w:t>节点的文本。.doc:解析 OLE2 复合文档,这工程量太大,建议对 .doc 直接返回不支持提示,或者调用外部转换能力。
轻量解析器最大的优势是体积和可控性。解析一把刀、一个 XML 解析器就能跑完。一个最小实现只需要几百行代码。同时也带来了巨大的劣势:功能边界非常窄,只适合“提取纯文本”的场景,对复杂格式(批注、修订、文本框内文字等)会有遗漏;.doc 老格式几乎无解。
如果你面对的是一个对老文档兼容性要求很高的产品(比如 Office 的历史文件归档系统),纯自己写 OLE2 解析完全是投入产出不成比例的。
3.3 路线 C:docx 走轻量解析,doc 走 POI 移植物
这是我在实际工程里倾向于推荐的路子:把需求场景拧开,不要一根筋。对.docx,用轻量解析满足绝大多数场景,因为 docx 是当前 Word 默认保存格式;对.doc,保留 POI 移植版作为兜底,毕竟 .doc 文件量虽然越来越少,但存量不可忽视。
这样做的好处是:包体积上,如果只保留poi(HWPF 部分)和它运行需要的 core 依赖,可以避开poi-ooxml和xmlbeans那一大坨,体积能从二十多兆压到三到五兆。doc_text 里原本的 .docx 解析逻辑改成轻量解析后,稳定性由自己做主,遇到问题可以直接在 XML 层面介入处理。
混合方案需要额外注意模块划分,最好把 docx 解析做成独立模块,POI 的 doc 支持做成可选模块。这样如果未来 OpenHarmony 设备存储压力大,可以只发布 docx 轻量版,doc 解析通过条件编译或运行时检测来降级。
3.4 三条路线的对比总结
| 评估维度 | 路线 A:POI 全量移植 | 路线 B:轻量解析 | 路线 C:docx 轻量 + doc 走 POI |
|---|---|---|---|
| .docx 文本提取 | 完整 | 基础完整 | 完整(自研) |
| .doc 老格式支持 | 完整 | 基本不支持 | 完整(POI 兜底) |
| 包体积 | 20MB+ | < 1MB | 3-5MB |
| 移植工作量 | 中 | 高 | 中高 |
| 维护成本 | 中(依赖上游) | 高(全自研) | 中(两套逻辑) |
| 稳定性风险 | 低 | 中(XML 各种变体) | 低 |
表格看着简单,但每个数字背后都对应着一堆细节。我做适配决策时不会只看一个维度,而是先明确业务优先级:如果项目对这个功能的需求是“能读就行,但一定要小”,就走路线 B;如果产品定位是办公套件,doc 兼容性是底线,那宁可体积大一点,也要走路线 A 或 C。
4. doc_text 的 ohos 插件改造实战:从 Dart 接口到原生解析
4.1 插件工程结构梳理
我按照路线 C 的思路做了一个最小可跑的验证工程。首先是工程结构改造。Flutter 插件支持 ohos 平台时,工程根目录下要有ohos目录,里面是一个完整的 OpenHarmony 模块。doc_text 原本的目录结构大概是:
doc_text/ ├── lib/ // Dart 层 ├── android/ // Android 插件 ├── ios/ // iOS 插件 └── ohos/ // 需要新增在ohos目录内,需要写一个Index.ets或者 Java 类来注册插件,监听来自 Dart 侧的 MethodChannel 调用。关键是桥接层的注册方式要和 OpenHarmony 的 Flutter SDK 兼容。以 ArkTS 侧实现为例,核心框架大致是:
import { MethodChannel, MethodCallHandler, MethodCall, FlutterPlugin } from '@ohos/flutter_ohos'; export class DocTextPlugin implements FlutterPlugin { private channel: MethodChannel; onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel = new MethodChannel(binding.getFlutterEngine().getDartExecutor(), 'doc_text'); this.channel.setMethodCallHandler((call: MethodCall, result: any) => { if (call.method === 'getText') { // 调用 native 解析器 } }); } }OpenHarmony 侧的 Flutter 嵌入 API 和 Android 有出入,但 MethodChannel 这套基本概念是保留的。大的方向就是:Dart 层传文件名或二进制 -> Native 层打开文件 -> 解析 -> 回调字符串。
4.2 Dart 侧接口保持不动
doc_text 的 Dart 层对外 API 不需要改,用户原来怎么写还是怎么写:
final text = await TextDocument.fromFile(File('...')); print(text.text);这样改的好处很明显:上层业务代码零改动,适配工作被完全封装在插件内部。如果是在一个已经有大量业务在用的项目里做 OpenHarmony 适配,保持 Dart API 稳定是第一原则。换个思路说,Flutter 的跨端价值之所以存在,就是这套接口抽象把平台差异化给挡住了。
4.3 docx 轻量解析的实现细节
在 ohos 侧实现 docx 解析,我用的思路是:ZIP 解压 + XML 解析提取<w:t>。OpenHarmony 的ohos.file.fs可以拿到文件描述符,用@ohos/zlib或系统提供的解压能力把 zip 打开,找到word/document.xml。
核心代码逻辑示意如下:
function extractDocx(filePath: string): string { // 1. 打开 ZIP 包 const zipFile = new unzip.ZipFile(filePath); // 2. 读取 word/document.xml const documentXml = zipFile.readText('word/document.xml'); // 3. 用 XML 解析提取文本 const texts: string[] = []; const parser = new xml.XmlPullParser(documentXml); // 遍历,遇到 <w:t> 取文本内容,遇到 </w:p> 换行 return texts.join('\n'); }这里有三点需要注意:
- XML 命名空间:
document.xml的根节点通常带xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"这类命名空间。做标签匹配时必须忽略前缀w:,或者按命名空间 URI 匹配,不能简单拿字符串equals。 - 换行处理:段落结束标签
</w:p>要转成\n,否则整个文档会挤在一起。表格里的单元格结束标签</w:tc>建议转成制表符\t,这样后续做文本分析时排版信息还留了一部分。 - 嵌套标签:
<w:t>标签里可能还有w:br、w:tab等特殊字符,文本拼接时要有对应的处理分支。
这只是 docx 最朴素的文本抽取方案,和 POI 相比少了表格结构识别、单元格合并信息等,但对大多数文本提取需求来说已经够用了。
4.4 doc 老格式的兜底策略
对于.doc格式,我最终的决定是走 POI 移植版。原因也很直白:OLE2 复合文档的解析复杂度不是一个团队花几周能搞定的,它涉及目录结构、FAT 表、流分配、字符格式映射,规不规范一堆坑。POI 移植版再滞后,也是 Apache 十几年迭代下来的成果,比自研靠谱得多。
实际操作时,我在 ohos 工程里把deptofchina/poi编译出的.jar或.har作为依赖引入,封装一个DocParser类:
import org.apache.poi.hwpf.HWPFDocument; import org.apache.poi.hwpf.extractor.WordExtractor; public class DocParser { public static String extract(byte[] bytes) throws Exception { try (HWPFDocument doc = new HWPFDocument(new java.io.ByteArrayInputStream(bytes))) { WordExtractor extractor = new WordExtractor(doc); return extractor.getText(); } } }注意这里用byte[]而不是文件路径,是为了在上层把文件打开逻辑统一下来:不管是路径、fd 还是内存数据,最终统一转成InputStream或byte[]再交给解析器。这个抽象层别看简单,能省掉很多平台差异的纠缠。
4.5 异常体系的设计
doc_text 原本的异常处理比较粗糙,很多情况直接往外抛。适配 ohos 时我重建了一套错误码体系,方便 Flutter 侧统一处理:
| 错误码 | 含义 | 触发场景 |
|---|---|---|
| 1001 | 文件不存在 | 路径非法或文件被删除 |
| 1002 | 文件格式不支持 | 扩展名不是 .doc / .docx |
| 1003 | 文档已损坏 | 解压失败 / XML 解析失败 / OLE2 头不合法 |
| 1004 | 文档已加密 | 需要密码才能打开 |
| 1005 | 解析超时或文本过大 | 超过预设大小限制 |
Dart 侧拿到这些错误码后,可以转换成用户界面上的提示。这一层设计在后续维护中价值很大,不然原生层一个字符串异常抛回来,Flutter 侧根本不知道该怎么展示。
5. 移植之后的稳定性细节:XXE 修复、内存控制与文档兼容矩阵
5.1 POI 版本与 XML 外部实体漏洞
移植 POI 时,最先要盯住的是版本问题。Apache POI 4.1.0 及以下版本被曝出过XSSFExportToXML相关的 XXE(XML External Entity)漏洞,CVE 编号我记得是 CVE-2021-23827 之类,恶意构造的 xlsx 文件可能在导出 XML 时读取本地文件。doc_text 虽然主要做文本提取,但如果工程里同时有导出功能,或者 POI 的 XML 解析器被间接触发,就存在风险。
我的建议很明确:凡是引入 POI,无论走哪条迁移路线,版本起点不要低于 4.1.2,最好直接上 5.x 主线。同时,如果自己写了 XML 解析的代码,解析document.xml时也要禁用DOCTYPE 声明,这是 XXE 的根源。在 ArkTS 里用系统 XML 解析器时,尽量关闭外部实体加载。下面是一个典型的防御式写法:
// 在解析 XML 前过滤掉 DOCTYPE if (documentXml.includes('<!DOCTYPE')) { // 拒绝解析,或剥离 DOCTYPE 后再处理 }这个检查看起来粗暴,但很有效。绝大多数正常生成的 docx 里根本不会有DOCTYPE声明,出现这个内容就基本可以判定为恶意构造。
5.2 大文档解析与内存控制
POI 在解析超大文档时吃内存是有名的,尤其是加载 XWPFDocument 的时候,它会尝试把整个文档结构加载到内存。doc_text 这类库如果直接拿来做大文件解析,有大概率把 Flutter 性能拖垮。我在验证工程里做了一个保护策略:解析入口加文档大小判断,超过 20MB 直接拒绝或者走异步解析。
异步解析在 ohos 侧也需要注意。OpenHarmony 的 UI 线程不能做重活,原生解析必须放到 Worker 线程中去。Flutter 侧本身有异步模型,但当 MethodChannel 回调发生时,原生侧的实际执行线程仍然要处理好。如果原生解析阻塞了平台的 platform thread,整个应用界面都会卡死。
我最后定的方案是:解析任务放进一个单线程的 Executor 里,解析完成后通过 Handler 回到主线程,再通过 MethodChannel 的result.success()返回。Flutter 侧本身不感知线程切换,但 UI 流畅度明显不一样。
5.3 文本编码与兼容性细节
Word 文档的编码问题比预想的要多。docx 的document.xml文件头一般声明 UTF-8,但实际遇到不规范文件,有的可能是 UTF-8 BOM,有的可能连 XML 声明都没有。解析时不能盲目用readText不指定编码,最好自己读字节流,先用 BOM 检测,再回退到 UTF-8。
.doc 老文件的编码就更复杂了,内部字符可能以 UTF-16LE 存储,可能受系统代码页影响。POI 本身处理得比较好,这也是我坚持 doc 走 POI 的原因之一。如果你完全自研 doc 解析,编码这块会非常头疼。
还有一个细节:一些从 WPS 等国产软件生成的 docx,命名空间定义、标签使用上跟 Microsoft 生成的略有差异。实测同一份文本在微软 Office 和 WPS 下保存,document.xml的标签结构会有不同。轻量解析器必须多做几个样本测试,别拿一份文件测完就上线。
5.4 兼容性测试矩阵
适配完成后,我整理了一套最小测试矩阵,供团队做回归验证:
| 测试场景 | 文件特征 | 预期结果 |
|---|---|---|
| 基础英文 docx | 纯文本、若干段落 | 文本完整返回,段落换行正确 |
| 中文 docx | 带中文标点、多级标题 | 无乱码,换行正确 |
| 表格 docx | 三行三列表格 | 单元格文本按行返回,或带制表符 |
| 图片 + 文字 docx | 文绕图排版 | 只返回文字,忽略图片描述 |
| 老式 .doc | 二进制格式 | 通过 POI 兜底返回文本 |
| 加密 docx | 打开需要密码 | 返回错误码 1004 |
| 损坏 docx | 手工破坏 zip 结构 | 返回错误码 1003 |
| 超大 docx | 10MB 以上 | 走异步解析,不卡 UI,或拒绝并给出提示 |
这一套跑完,基本能覆盖绝大多数业务使用场景。适配 OpenHarmony 不能只看“能跑”,要看不同来源的办公文档在各种边缘情况下是否都能有合理表现。
从个人体会来说,这次把 doc_text 的 Android 端 POI 实现拆开分析,最核心的产出不是代码,而是一张清晰的“兼容边界地图”:哪些能力在 OpenHarmony 上是现成的,哪些要借道 POI,哪些需要自己做减法。按照这套思路,后续无论适配哪个依赖 POI 的 Flutter 插件,都只需要替换桥接层、保留解析层,再根据平台差异做好防御式处理。对照当前 OpenHarmony 生态的成熟度,先把 docx 轻量解析这条腿站稳,再把 doc 老格式作为可选增强包,是我目前认为最务实、最可持续的做法。