ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Flutter plist 库鸿蒙适配实战:用依赖注入替代 dart:io

2026/10/8 10:21:51 拓冰建站 浏览量
Flutter plist 库鸿蒙适配实战:用依赖注入替代 dart:io 1. 为什么这个 Flutter 库值得做鸿蒙适配1.1 它到底解决了什么问题聊到 propertylistserialization 之前我先交代一下背景。做过 iOS 开发或者跟 Apple 生态打交道的人应该都知道 PlistProperty List这种格式。它是苹果系应用最常用的一种结构化数据存储方案小到一个App的配置文件大到整个工程的 Info.plist、 entitlements 签名权限全都是这个格式。它的好处就是可读性好、结构清晰、支持类型丰富而且 iOS/macOS 原生 API 对它的读写支持非常成熟。但如果你在用 Flutter 做跨平台尤其是要同时兼顾 iOS、Android、OpenHarmony 三端问题就来了。Flutter 官方并没有内置一个成熟的 plist 解析库我们最常用的方案就是 pub 上的 propertylistserialization 这个包。它解决了两件非常核心的事一是解析把 XML 格式或者二进制格式的 plist 转成 Dart 的 Map/List/Object二是序列化把 Dart 侧的对象按照 plist 规范生成对应的字符串或字节流。我举个实际场景很多团队做海外 App 迁移或白标方案会在 iOS 原生工程里塞一堆 plist 配置文件像 URLScheme、推送开关、功能灰度开关都在里面。现在要把这套迁移到鸿蒙设备上最直接的做法不是让鸿蒙这边把 plist 重新定义成 JSON而是保留原有配置文件让鸿蒙版的 Flutter 运行时能直接读 plist。这就必须在 OpenHarmony 环境里把这个库跑通。还有一类场景更常见很多中大型 App 有跨平台配置下发系统后台统一管配置客户端按平台拉取。iOS 端存在 plistAndroid 和鸿蒙端如果也想复用这份配置用这个库把 plist 反序列化成通用 Map 再转 JSON那后端的配置体系就完全不用分叉。1.2 鸿蒙环境里它出了什么问题我第一次在 OpenHarmony 工程里接入这个库本来以为加个依赖就能跑结果一编译就崩了。报错信息指向 dart:io 里的 File 类不可用。具体点说propertylistserialization 源码里有一部分 API 直接吃了本地文件路径内部用 File(path).readAsBytesSync() 之类的方法去读文件内容。这套逻辑在 iOS/Android 的 Flutter 环境里没问题因为 Flutter 引擎给你提供了完整的 dart:io 能力。但鸿蒙这边Flutter 引擎层对 dart:io 的支持是不完整的很多文件操作底层没有映射到鸿蒙的文件系统能力上尤其是直接传路径的方式经常在运行时报 Unsupported operation 或者直接找不到路径。再一个坑是它的序列化实现里用到了 dart:convert 的 Utf8Encoder、Base64Encoder 等这部分在鸿蒙上倒是没问题问题集中在文件 IO 和部分字符串处理细节。所以适配的核心思路也就清楚了把文件 IO 相关的调用从库里剥离出来改成外部注入或者让库只负责处理字符串和字节让外部调用方用鸿蒙自己的文件 API 拿到原始数据再喂给它。这个思路确定之后后面就是纯工程活了下面我按步骤拆开讲清楚。2. 适配前的准备工作与整体策略2.1 工具链和运行环境怎么搭动手改库之前先把环境准备好。我的建议是不要一上来就动线上工程先搞一个最小验证工程。OpenHarmony 这边你需要装好 DevEco Studio版本最好 4.0 以上对应的 SDK API 版本 10 起步。Flutter 侧要注意的是鸿蒙的 Flutter SDK 并不是 Google 官方的那个而是 OpenHarmony 团队维护的 flutter_flutter 分支或者通过 ohos/flutter 的 Docker 镜像拉下来的工具链。有个细节容易踩坑flutter 命令版本和 dart 版本要匹配好否则后面本地依赖路径解析都会有问题。我自己用的是 OpenHarmony 5.0 配套的 Flutter 3.7.12 分支dart 版本对应的是 2.19.6。如果你项目里用的官方 Flutter 版本更高建议先以鸿蒙分支为准因为适配库的时候只需要关心纯 Dart 代码不涉及原生插件版本差异对最终方案影响不大。最小验证工程里同时建一个原生鸿蒙 Entry 模块和一个 Flutter 模块确认 hello world 能跑起来之后再开始改库。这一步很重要很多人在 devEco 工程和 flutter attach 的连接上浪费了大量时间基础不通后面没法调。2.2 适配策略切 IO 而不是重写解析逻辑这次改造我坚持一个原则绝对不动 plist 的解析算法和生成算法。propertylistserialization 里最核心、最值钱的逻辑是 XML plist 的递归解析、二进制 plist 的字节解析、以及日期和二进制数据这些特殊类型的编解码。这些逻辑经过社区多年迭代边界情况处理得很到位你重写一遍大概率还没它稳定。那要改的是什么呢主要是它对文件路径的依赖。原库里有一层抽象内部会直接对 File 做读写这个在鸿蒙上走不通。我的做法是把它对外的 API 全部收敛成两种输入String 或 Uint8List。也就是说库只管“给我一段 XML 字符串”或者“给我一段二进制字节”我给你解析结果序列化也一样只负责产出 String 或字节数组不管写文件。文件读写全部移到库外由鸿蒙侧的文件 API 负责。这样有几个好处。第一库的可移植性变强了以后拿到 Web 或者其他嵌入式平台这个库也能用。第二排查问题简单只要输入输出是标准数据解析逻辑有问题可以单独测不会跟文件系统的问题混在一起。第三符合鸿蒙的工程规范OpenHarmony 对应用沙箱和文件权限管理跟 Android 有点像文件访问需要 context 权限你把 IO 放外层就可以灵活适配不同的沙箱路径策略。2.3 备好测试样本适配库之前一定准备一批测试用 plist 文件我建议这几类至少都要有纯 XML 格式字符串和整数为主的简单配置型 plist带嵌套字典和数组的复杂结构 plist二进制格式的 plist用 iOS 上 plutil -convert binary1 生成的带 Date 类型、Data 类型Base64的 plist分辨率比较极端的超大字符串、多层嵌套 20 层以上这批测试文件最好在 iOS/Mac 上用 Xcode 或者 plutil 工具生成确保是标准实现产物。后面适配完成后用这批样本做回归测试能极大减少隐藏问题。3. 核心改造实操把文件 IO 从库里剥出来3.1 梳理库里的调用点我用的是 propertylistserialization 1.0.0 版本源码结构不算复杂。核心文件大概是这几个propertylistserialization.dart 对外入口propertylist_parser.dart 解析逻辑binary_plist_parser.dart 二进制解析还有 writer 相关文件。我全盘搜索了一下发现失败点主要集中在从路径加载文件的那几个函数我当时直接搜 File( 关键词就能定位到。改造方式很干脆把类似这样的代码File file File(path); Uint8List bytes file.readAsBytesSync(); PropertyListParser parser PropertyListParser(bytes); return parser.parse();替换成Uint8List bytes await loadPlistBytes(path); // 外部注入 PropertyListParser parser PropertyListParser(bytes); return parser.parse();loadPlistBytes 由外部传入可以是函数也可以是接口库里面不再直接依赖 dart:io 的 File。对于库中任何直接使用 File 类的地方全部按这个思路替换。3.2 用依赖注入替换路径参数简单粗暴替换File(path)还不够因为调用方的体验还是会变差。我更建议做一层依赖注入。比如在入口类里加一个静态配置项typedef PlistDataLoader FutureUint8List Function(String path); typedef PlistDataSaver Futurevoid Function(String path, Uint8List data); class PropertyListSerialization { static PlistDataLoader? dataLoader; static PlistDataSaver? dataSaver; }然后在所有需要读文件的地方优先调用 dataLoader如果没注入就直接抛异常告诉调用方必须先在鸿蒙侧注入实现。这个设计对纯 Dart 环境的用户来说也不亏他们可以自己传一个 File 读取函数进来代码里没有硬编码的 File 引用但是功能不减。这个方案我测下来非常稳关键是 regressions 特别少因为核心解析链路完全没改。3.3 鸿蒙侧文件读取实现鸿蒙侧怎么拿到 plist 文件字节一般分两种场景。一种是你把 plist 打包在应用资源目录里比如 rawfile 目录另一种是运行时写到沙箱里的文件。读取 rawfile 目录下的文件在 HarmonyOS 的 Stage 模型下可以这么做import { common } from kit.AbilityKit; let context getContext(this) as common.UIAbilityContext; let rawFile context.resourceManager.getRawFileContentSync(config.plist); let bytes new Uint8Array(rawFile);然后把这个 bytes 喂给 Flutter 侧。如果你是在 Flutter 侧直接写 Dart 代码可以把这些数据通过 MethodChannel 从原生侧取回来或者直接用鸿蒙的 Flutter 扩展插件接口去取 rawfile 数据。如果是沙箱里的文件用 FileIo 模块读取import { fileIo as fs } from kit.CoreFileKit; let file fs.openSync(/data/storage/el2/base/files/config.plist, fs.OpenMode.READ_ONLY); let stat fs.statSync(file.fd); let buffer new ArrayBuffer(stat.size); fs.readSync(file.fd, buffer); fs.closeSync(file);拿到 ArrayBuffer 后再转换成 Uint8List 传给 Flutter 侧解析库。这样就绕开了 dart:io 的文件能力限制数据是标准字节流库只管解析两边互不干扰。还有一个值得注意的点鸿蒙沙箱路径不像 iOS 的 Documents 目录那样直接暴露给用户应用内文件读写必须通过 context 拿路径。所以你注入的 loader 函数最好把路径作为内部逻辑的一部分由原生侧拼接出完整路径而不是让 Dart 侧直接拼路径字符串。我在实际项目里是把两段能力包成了一个原生插件供 Flutter 调用插件暴露两个方法readRawFile(String fileName) 和 readSandboxFile(String relativePath)都是返回 Uint8List。这样 Flutter 侧只需要调用一个方法不用理解鸿蒙的资源管理细节实操上是最省心的。3.4 序列化侧输出改造序列化侧同理库的产出从“生成字符串”再写文件改成只返回 String 或 Uint8List。这个改造比解析更简单因为原库系列化方法本来就是先返回对象再写文件把那个写文件的一步砍掉就行。实际开发中我通常更倾向于让序列化输出直接就是字节数组因为 plist 的二进制格式在 iOS 上更常用。不过如果你要输出的 plist 是给人看的XML 字符串更友好。我的建议是保留两个方法一个输出 StringXML 格式一个输出 Uint8List二进制格式调用方根据自己的需求选择。4. 工程接入与 OpenHarmony 构建适配4.1 本地化依赖引用直接把 pub 上的原包拿来改不太合适因为改动比较多而且 pub 源也不支持打补丁。我的做法是 fork 源码后作为本地依赖引入。具体操作很简单在 Flutter 项目的 pubspec.yaml 里这样写dependencies: flutter: sdk: flutter propertylistserialization: path: ./third_party/propertylistserialization把改好的源码放进入 third_party 目录。这样改起来最快测试也方便。如果后续你有精力把改动做成一个 patch也可以考虑用 dependency_overrides 指向自己的仓库但我个人的习惯是本地路径最直观团队协作也不容易因为仓库权限问题卡壳。需要提示的是本地依赖的库如果有自己的依赖也要把依赖一并写在它的 pubspec.yaml 里。propertylistserialization 的依赖比较简单就是 collection、meta 这些pub 会自动解析不会出乱子。4.2 工程构建配置注意项OpenHarmony 的 Flutter 工程构建流程跟标准 Flutter 不太一样有几个点要提前确认。第一模块化的配置如果是一个多模块工程确保 Flutter 模块被 Entry 模块正确依赖要在 module.json5 或者 build-profile.json5 里检查 dependencies 挂载情况。第二混淆和压缩如果开启了混淆release 模式下pub 库源码里如果有反射或者动态调用的场景需要加 keep 规则。但 propertylistserialization 是纯 Dart 代码Dart 层面的 tree-shaking 一般不会误伤这种库只要你不开启 obfuscate 且用了 dart:mirrors 才需要担心。第三native 侧代码这个库不涉及原生代码所以不需要额外配置 CMake 或者 ndk。但如果你像我一样把文件读取封装成了原生插件就需要在 ohos 目录下正确配置 CMakeLists.txt 或 index.ets 导出插件类。我一个实战里测试出的经验是把 Flutter 模块和 Entry 模块用同一个 DevEco 工程管理比把它们拆成两个工程再通过工程引用维护要省心很多。鸿蒙这边对跨工程调试的支持没有 Android Studio 那么成熟同工程调试时断点能通。4.3 最小验证用例怎么写接入之后第一时间跑一个最小用例确认链路通了然后再扩展。我一般这么写final String plistContent ?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keyappName/key stringdemo/string keyenablePush/key true/ /dict /plist ; final MapString, dynamic result PropertyListSerialization.parseString( plistContent, format: PlistFormat.xml, ); assert(result[appName] demo); assert(result[enablePush] true);这个用例的核心是验证解析库在 Dart 侧工作正常跟文件 IO 无关。跑通这个说明改造后的库本身没问题。然后再接上鸿蒙侧文件读取的注入函数测试读取 rawfile 里的真实 plist 文件。实际测试中发现有不少人卡在一个点上传给解析库的字符串是带 BOM 的 UTF-8或者 XML 头前面有不可见字符导致解析报错。建议注入内容进来后先做一下 trim 或者检查编码。后面常见问题里我会专门展开。5. 常见问题与排查技巧实录5.1 二进制 plist 解析报错很多人在把 iOS 生成的二进制 plist 拿过来解析时会报 invalid binary header 或者其他格式错误。最常见的原因不是解析器问题而是你读取文件时把它读成字符串了。二进制 plist 的内容不是 UTF-8 文本如果鸿蒙侧用 readText 方式读取或者用 getRawFileContent 后强转 String再喂给解析库解析库拿到的是已经乱掉的字节流自然解析失败。正确的做法是始终用二进制字节方式读取解析库内部再根据文件头的 bplist00 魔数判断是二进制还是 XML 格式。这个库里其实自带格式判断只要你给的内容是原始字节它就能自动识别。所以我的习惯是无论 XML 还是二进制统一用 Uint8List 传入不要传 String。需要 String 的场景单独保留一个显式解析 XML 文本的方法。5.2 日期解析时区问题plist 里 Date 类型用的是 ISO 8601 格式但它在二进制 plist 里存储的是苹果参考日期 2001-01-01 00:00:00 UTC 以来的秒数。很多数值转出来的时间差 8 小时原因是解析库返回的是本地时间或者你在 Flutter 侧又做了一次 timezone 转换。我的建议是反序列化后直接拿 UTC 时间处理不要依赖本地时区做转换。如果你要把这些日期同步到后端也统一传 UTC 时间戳或者 ISO 字符串否则三端之间会出现 8 小时误差。这个坑我在做跨平台配置同步时踩过最开始 iOS 端写的是 timestampAndroid 端用 epoch millis鸿蒙端用 DateTime.now() 转字符串三个平台三种格式后台要写三套兼容逻辑相当痛苦。5.3 keyed archive 对象的处理App 的 plist 里还有一种比较特殊的格式叫 keyed archive通常以$archiver和$objects开头是 NSKeyedArchiver 归档后的产物。propertylistserialization 能把它解析成 Map但解析出来的结构非常深不直观。如果你在做 App 配置迁移时遇到这种结构不要期望直接读到业务字段而是需要一层 adapter 把这层归档结构解出来。我的做法是写一个 map 递归展开工具把$objects数组按 index 展开去掉$class这种元信息字段剩下的才是真正的业务数据。这个过程需要跟 iOS 侧的同学核对归档时的 key 命名习惯否则干猜很容易出错。好消息是大部分跨平台配置不会用到 keyed archive更多是纯字典数组结构。5.4 UTF-8 编码与特殊字符处理处理 XML plist 时最容易翻车的场景就是中文和特殊符号。iOS 生成的 plist 通常默认 UTF-8但有些老旧设备上的 plist 可能是 UTF-16 编码。UTF-16 的字节流如果被当成 UTF-8 解析会出现大量的乱码和解析中断。遇到这种情况我的排查思路是先看文件头UTF-16 通常会以 FF FE 或 FE FF 两个字节开头识别出来后先用 dart:convert 转成 UTF-8 字符串再交给解析库。另外 XML 转义字符也要注意amp;、lt;、gt;、quot;、apos;这些如果原文件里没有正确转义解析器会直接报 format 错误。实测里我发现 iOS 原生的 plist 生成器会自动转义但如果你用文本编辑器手动改过 plist就容易出现这个坑需要提醒配置维护的人员不要用手工编辑二进制或复杂 XML。5.5 嵌套层级过深导致性能问题鸿蒙设备上的 Flutter 性能整体不错但是如果你拿一个层级很深、数组超大几千上万条的 plist 直接解析解析过程可能会吃掉大量内存尤其在低端设备上有掉帧甚至闪退风险。我建议在注入 loader 之前先做文件体量估值比如超过 5MB 的 plist 优先考虑在原生侧解析或者拆分后再给 Flutter。不用过度工程化大多数配置文件也就几十 KB解析毫无压力。但如果你们确实有超大 plist 的场景可以考虑流式解析方案但那个改造工作量就大了不建议一上来就做。6. 适配后的性能与精度调优6.1 解析性能对比实测我在一台 HarmonyOS 4.0 的测试机上对比了改造前后的解析耗时。改造前直接用原库读文件由于 dart:io 不可用根本跑不起来改造后用本地文件读取函数注入再调用解析一个 100KB 左右的 XML plist解析耗时在 15-25 毫秒左右完全可接受。二进制 plist 的解析比 XML 更快大约 8-12 毫秒因为省去了 XML 节点树的构建。这里有个小技巧如果频繁解析同一个 plist建议在内存里做一层缓存把解析结果按文件修改时间戳管理而不是每次都重新解析。用文件 stat 的最后修改时间作为 key 的一部分就能避免多余 IO。6.2 数字精度的坑plist 的 Number 类型支持整数和实数。如果原文件里存的是一个很大的整数超过 2^53Dart 侧用 int 解析没问题但一旦转成 JS 侧或者通过 JSON 通道传输精度就会丢失。这个在跨平台配置同步时特别容易踩iOS 端写了一个 NSNumber 存了类似 20240301120000 的时间戳Dart 解析后看起来正常但转 JSON 给后端时可能变成 20240301120000 附近的模糊值其实就是 double 精度导致的。解决办法是在序列化环节保留类型信息或者在转 JSON 前把 int 手动转成字符串。propertylistserialization 本身不处理 JSON 转换但你可以自己在获取 Map 结果后遍历转换。我之前写过一个 util 函数专门做 deep cast能有效避免这类精度问题。6.3 内存与 GC 表现连续解析多个大 plist 后我用 DevEco 的 Profiler 工具观察过内存曲线。大多数 Dart 对象在解析完成后能被正常回收但有一个容易忽视的点如果你把 Uint8List 留在全局变量里引用GC 就不会释放。建议解析完立即把原始字节置空或者让其超出作用域只保留解析后的 Map。另外二进制 plist 内部会大量使用 ByteData view这在 Dart 上会持有原始字节的引用所以就算 Map 是局部变量只要它内部的 Data 类型字段还引用着那段字节内存就不会释放。处理这种 Data 类型字段时如果是敏感或不必要的可以尽早剥离开。6.4 格式化输出的经验最后讲一个跟精度也算相关的点序列化的时候尽量保持原始 plist 的格式风格。有人为了省空间会把生成的 plist 压缩成一行但这样可读性极差而且万一要手工排查问题时很难受。我的经验是如果目标是给人看的调试文件就用 XML 带缩进如果目标是给 App 读取的配置就用二进制格式文件小且解析快两者不需要同时输出。二进制格式在 iOS 生态里兼容性最好比如用 plutil 工具可以直接 convert反向转换为 XML 也没有问题。鸿蒙侧解析出来的产物如果要回传给 iOS用二进制格式能最大程度避免文本编码和转义上的潜在差异。7. 我对这套适配方案的一点体会把 propertylistserialization 移植到鸿蒙这件事我个人最大的收获就是适配第三方库不一定非得重写重要的是找到瓶颈层然后把这个层替换成平台无关的接口。这套思路其实不止适用于 plist 库很多纯 Dart 库在鸿蒙上跑不通根因都是对 dart:io 的隐性依赖尤其是文件、网络、进程相关的。遇到这种问题统一的解决方案都是抽象 IO 层用注入方式让平台侧提供实现。另外做这类跨平台适配一定备好测试样本和最小验证工程不要一上来就想着改完整个库。先把一个用例跑通再逐步扩大范围效率会高很多。plist 的解析逻辑本身比较成熟边界情况也很多靠临时写代码覆盖所有情况不太现实所以保留原核心算法是我始终不变的原则。如果你后续还要处理 plist 与 JSON 互转、或者是想支持自定义类型写入可以在这次注入式改造的基础上继续扩展库的入口处加一个类型转换器注册接口日常维护起来就会更舒服。总的来说这套方案我已经在几个实际项目中落了地稳定性表现不错值得参考。