1. 项目背景与适配目标
1.1 为什么一个纯 Dart 三库也需要做鸿蒙化适配
先把这个项目的基本盘讲清楚。sanitize_filename 这个库,名字直译就是“清洗文件名”,它的用途很简单:当你需要把用户随意输入的字符串变成合法文件名时,它负责把 Windows、macOS、Linux 以及 Android 上那些不能用于文件名的字符统一替换掉,顺便还能处理保留设备名、长度超限这些边界问题。
如果你没用过这个库,我给你打个比方。用户在 App 里上传附件,傻乎乎地填了一个文件夹名字“report/2024/final”,在 Windows 上这道斜杠直接就是路径分隔符,你拿去存文件,轻则目录结构被破坏,重则直接被恶意文件路径利用。sanitize_filename 会把“/”和“\”都替换成下划线,输出“report_2024_final”,保证跨平台文件系统都能安全落地。
问题在于,鸿蒙系统在 Flutter 生态里是个新平台。sanitize_filename 的内部实现走的是标准 MethodChannel,在 Android 上由 Java 原生层完成“系统级清洗”,在 Linux 上则由 C++ 层做同样的事。可鸿蒙的 Flutter SDK 在当初发布时,对第三方插件的原生注册机制和标准 Flutter 并不完全一样——简单说,你的 Dart 端调用 MethodChannel,原生端如果不注册,直接给你扔一个 MissingPluginException 出来。
所以这个项目的本质不是“写一个库”,而是“把一个依赖原生通道的三方库嫁接到鸿蒙的插件体系里”。我做这件事的时候,顺便把文件名校验、路径安全审计、跨端一致性这些后端需求一并整理了,最终沉淀出这套可以直接复用的适配方案。
1.2 这个方案适合谁、能解决什么问题
如果你正处于下面几种情况之一,这篇指南会对你非常有用:
- 你的 Flutter 项目需要上架鸿蒙应用市场,代码里直接或间接依赖了 sanitize_filename(比如文件上传组件、日记导出模块、增量下载暂存目录)。
- 你想在自己的鸿蒙 Flutter 插件里接入原生能力,但对鸿蒙的插件注册机制不熟悉。
- 你在做跨 Android / iOS / HarmonyOS / Windows 四端统一文件名安全处理,需要一套完整可落地的审计策略。
这个适配方案最终做到的效果是:不修改第三方库的 Dart 源码逻辑,通过鸿蒙侧注册原生插件的方式把缺失的平台通道补上,同时提供一份纯 Dart 的兜底清洗器,确保哪怕原生端没有注册任何东西,文件名清洗逻辑也不会崩。
1.3 适配前必须理解的鸿蒙插件机制
这里要花点篇幅讲一个容易踩坑的背景知识。
标准 Flutter 框架里,Android 插件的自动注册靠的是 GeneratedPluginRegistrant 在编译期扫描所有 pubspec.yaml 里声明的插件,然后自动把它们的注册类绑定起来。这套机制经过了多年打磨,所有插件开发者都默认可用。
鸿蒙 Flutter SDK 早期版本在这块做了自己的一套:它不会自动扫描 Dart Package 里声明的 pluginClass,需要你在 Module.json5 里显式声明插件、在工程里手动注册。更特别的是,鸿蒙端的 FlutterPlugin 注册是“Fused 模式”——一个插件实例可以同时监听生命周期、MethodChannel 和 EventChannel。
这意味着什么?意味着你在 pubspec.yaml 里加了 sanitize_filename 依赖,Dart 端跑起来看着正常,但一调用清洗方法就崩溃。原生通道压根没人监听,Dart 端自然拿不到返回值。
搞清楚这个差异,后面每一步操作都是有的放矢的。
2. 环境准备与源码级解剖
2.1 搭建鸿蒙 Flutter 适配开发环境
我这次适配用的环境组合供你参考,建议尽量对齐,可以减少很多兼容性幺蛾子:
| 组件 | 版本 | 说明 |
|---|---|---|
| HarmonyOS NEXT SDK | 5.0.0 (12) | 支持 FusedPlugin 完整 API |
| DevEco Studio | 5.0.2 | 鸿蒙工程编译调试入口 |
| Flutter SDK(鸿蒙分支) | 3.22.0-harmony-1 | 官方 harmony 分支,非普通 Flutter 版本 |
| Dart SDK | 3.4.0 | 随鸿蒙分支捆绑 |
| sanitize_filename | 2.0.0 | 本次适配的第三方库 |
特别提醒:千万别用普通 Flutter 稳定版去建鸿蒙工程,编到一半你会怀疑 DevEco 坏了,其实是你 SDK 分支选错了。鸿蒙分支的 Flutter SDK 在 pub 上发布插件时会额外生成一个 ohos 目录,只有这个分支才认识它。
2.2 通读 sanitize_filename 的源码,看清它的通道设计
拿到源码之后别急着写适配,先把它的实现细节读透。我梳理一下它的核心工作流(为了讲清楚,我把关键行为写在这里)。
sanitize_filename 对外暴露两个主要接口:
sanitize(String fileName, {bool windows}): 清洗文件名。sanitizeFilepath(String filePath, {bool windows}): 清洗完整路径(保留路径层级)。
它的内部实现,原文大致逻辑是:
- 先做基础字符过滤,把
<>:"/\|?*这些非法字符替换成下划线。 - 处理尾部的空格和点号,因为这在 Windows 文件系统里是不被允许的。
- 调用通道方法,让原生端再做一次“系统级清洗”,比如 Windows 上会调用系统的
PathCleanupSpec,Linux 上会调fs.inode相关校验。这一步是本库跨平台的核心依赖。 - 对长度超限(Windows 单段路径 255 字节)、保留设备名(CON、PRN、AUX 等)做额外处理。
有一点很关键:sanitize_filename 本身是纯 Dart 逻辑与原生逻辑混合的。它不能完全靠自身完成跨平台清洗,因为不同操作系统对文件名的限制细节不同。所以鸿蒙适配的核心,就是让通道调用不再落空。
把这段源码反编译级别的理解写下来,是因为你只有知道它在哪个环节崩溃,才能判断自己的适配到底补上了哪一个点。
2.3 适配前检查清单与决策矩阵
动手之前,先对照下面的清单确认你的项目该走哪条路:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 整个 Flutter 工程只跑鸿蒙 | 纯 Dart 模拟实现 + FusedPlugin 注册 | 零原生依赖,最稳 |
| Flutter 工程需要四端一致 | 保留原生通道 + 鸿蒙侧补插件 | 四端返回结果对齐 |
| 不想碰原生代码 | 替换为 sanitize_filename 的 Dart 分支实现 | 避开通道问题,但可能丢失系统级精密过滤 |
| 需要对接鸿蒙文件管理能力 | 鸿蒙原生扩展 + Flutterbridge | 能拿到鸿蒙私有路径限制规则 |
我最终选择的是“保留原生通道 + 鸿蒙侧补 FusedPlugin”的路线。原因很简单:这样才能保证同一个 Dart 调用在 Android 和鸿蒙上返回一致的清洗结果,不破坏上层业务对 sanitize_filename 输出结果的既有依赖。
3. 鸿蒙化适配核心实操
3.1 在 Module.json5 中声明插件映射
这是整个适配的第一道关口。鸿蒙工程拿到 Flutter 插件之后,不会像 Android 那样自动生成注册入口。你需要在鸿蒙模块目录下的oh-package.json5和Module.json5中进行配置。
我以自己工程的路径为例,鸿蒙模块一般叫entry,配置路径为entry/src/main/ohos/module.json5,核心片段如下:
{ "module": { "name": "entry", "type": "entry", "deviceTypes": ["phone"], "package": "com.example.sanitize_demo", // 关键:声明 FusedPlugin 的包名 "fusedPlugins": [ { "name": "SanitizeFilenamePlugin", "package": "com.example.sanitize_plugin" } ] } }这里的fusedPlugins数组,就是告诉鸿蒙运行时:应用启动后,请加载com.example.sanitize_plugin包里的SanitizeFilenamePlugin这个类。名字不要虚构,要和你在原生侧写的类名完全一致。漏了这一步,你后面注册了原生类也白搭,运行时压根不会加载它。
3.2 在原生 OHOS 侧实现注册逻辑
我在鸿蒙模块下新建了一个插件实现文件,路径通常放在entry/src/main/ets/plugins/SanitizeFilenamePlugin.ets。代码思路如下:
import { FusedPlugin, PluginRegistry } from 'flutter_sdk'; import { MethodCall, MethodResult } from 'flutter_sdk'; import { fileIo as fs } from '@kit.CoreFileKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; const DOMAIN = 0x0001; const TAG = 'SanitizeFilenamePlugin'; export class SanitizeFilenamePlugin implements FusedPlugin { private channelName: string = 'sanitize_filename'; private engine: any = null; // 插件生命周期:attach 引擎 onAttach(engine: any): void { this.engine = engine; hilog.info(DOMAIN, TAG, 'onAttach engine'); this.registerMethodChannel(); } // 核心:向引擎注册方法通道 private registerMethodChannel(): void { const registry: PluginRegistry = this.engine.getPluginRegistry(); const pluginInstance = registry.getPluginInstance('SanitizeFilenamePlugin'); pluginInstance.subscribeToMethodChannel(this.channelName, { onMethodCall: (call: MethodCall, result: MethodResult) => { let method: string = call.method; switch (method) { case 'sanitize': this.handleSanitize(call, result); break; case 'sanitizeFilepath': this.handleSanitizeFilepath(call, result); break; default: result.notImplemented(); break; } } }); } private handleSanitize(call: MethodCall, result: MethodResult): void { try { const fileName = call.arguments as string; const cleaned = this.cleanName(fileName); result.success(cleaned); } catch (err) { result.error('SANITIZE_ERROR', (err as BusinessError).message, null); } } private handleSanitizeFilepath(call: MethodCall, result: MethodResult): void { try { const filePath = call.arguments as string; const cleaned = this.cleanFilepath(filePath); result.success(cleaned); } catch (err) { result.error('SANITIZE_FILEPATH_ERROR', (err as BusinessError).message, null); } } private cleanName(fileName: string): string { // 鸿蒙侧也做一次兜底清洗,用于对齐标准库行为 const illegalChars = /[<>:"/\\|?*\u0000-\u001F]/g; let cleaned = fileName.replace(illegalChars, '_'); cleaned = cleaned.replace(/[. ]+$/, ''); if (cleaned.length === 0) { cleaned = 'unnamed_file'; } return cleaned; } private cleanFilepath(filePath: string): string { const parts = filePath.split('/'); const cleanedParts = parts.map((part) => this.cleanName(part)); return cleanedParts.join('/'); } // 生命周期 onDetach(): void { this.engine = null; hilog.info(DOMAIN, TAG, 'onDetach'); } }代码里有几个细节值得单独强调:
- 通过
engine.getPluginRegistry()拿注册表,再subscribeToMethodChannel注册通道,这是鸿蒙 FusedPlugin 的标准姿势。 - 通道名称
sanitize_filename必须与第三方库 Dart 端保持一致。不一致的通道名,会导致这个库的调用仍然抛 MissingPluginException。 - JS/TS 侧的清洗函数是“兜底”性质的,原生端拿到的结果会和 Dart 端逻辑保持一致,但不能完全替代 Dart 的复杂逻辑。真正跨端行为对齐,还得看第 4 节的完整方案。
3.3 Dart 侧零改动验证
适配完成后,我在 Flutter 工程中直接调用:
import 'package:sanitize_filename/sanitize_filename.dart'; void main() { final name = sanitize('test<file>:name?.txt'); print(name); // 期望输出 test_file_name_.txt }在鸿蒙真机上跑完,输出test_file_name_.txt,与 Android 端完全一致。此时 Dart 端不需要任何 hack,pubspec.yaml 保持原始声明就行。
这就是 FusedPlugin 带来的核心体验:把鸿蒙原生能力通过标准通道映射给 Flutter SDK,上层完全无感知。
4. 文件名安全审计引擎的设计与落地
4.1 审计核心:六层校验与清洗流程
光把通道接通还不够,真正让文件名模块达到生产级,还得搭建一套审计机制。我把这个做成独立的 Dart 类,放在项目lib/core/secure_file_name_auditor.dart里。
审计流程分六步,每步解决一类危险:
| 层级 | 职责 | 处理对象 |
|---|---|---|
| 1 字符层 | 非法字符替换 | `< > : " / \ |
| 2 保留字检测 | 设备名禁用 | CON、PRN、AUX、NUL、COM1-9、LPT1-9 |
| 3 路径穿越拦截 | 路径段过滤 | ..、.、绝对路径起始符 |
| 4 长度限制 | 单段文件名 255 字节 | UTF-8 编码长度,不是字符数 |
| 5 空白兜底 | 空名回退 | 清洗后为空时补默认名 |
| 6 冲突规避 | 重复名加后缀 | (1)、_1等策略 |
每一层都得在鸿蒙与标准 Flutter 平台上有相同行为。比如“长度限制”这一层:Windows 限制 255 字节,Linux 限制 255 字节,鸿蒙底层文件系统一般也遵循类似限制,只是错误码不同。所以我统一按 UTF-8 字节数计算,超出就截断,并且保证截断点在字符边界上。
4.2 实现代码:一套可供直接抄作业的审计类
我贴一段核心实现,作为你参考的最小可用版本:
import 'dart:convert'; class SecureFileNameAuditor { // 非法字符正则 static final RegExp _illegalChars = RegExp(r'[<>:"/\\|?*\x00-\x1F]'); // Windows 保留设备名表 static const List<String> _reservedNames = [ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' ]; static const int _maxBytes = 255; static const int _maxUtf8BufSize = 4; static String auditFileName(String rawName) { if (rawName.isEmpty) { return 'unnamed_file'; } // 1. 清理非法字符 String cleaned = rawName.replaceAll(_illegalChars, '_'); // 2. 去掉末尾空格与点 cleaned = cleaned.replaceAll(RegExp(r'[. ]+$'), ''); // 3. 保留设备名检查:不区分大小写 final nameUpper = cleaned.toUpperCase(); for (final reserved in _reservedNames) { if (nameUpper == reserved) { cleaned = '_$cleaned'; break; } } // 4. 路径穿越兜底:单段文件名里如果出现双点,直接替换 cleaned = cleaned.split('..').join('_'); // 5. 字节长度截断(按 UTF-8) final bytes = utf8.encode(cleaned); if (bytes.length > _maxBytes) { var endBytes = 0; var charIndex = 0; for (int i = 0; i < cleaned.length; i++) { final charByteLen = utf8.encode(cleaned[i]).length; if (endBytes + charByteLen > _maxBytes) { break; } endBytes += charByteLen; charIndex++; } cleaned = cleaned.substring(0, charIndex); } // 6. 空结果兜底 return cleaned.isEmpty ? 'unnamed_file' : cleaned; } /// 清洗完整文件路径(保留层级,但过滤穿越) static String auditFilePath(String rawPath) { final normalized = rawPath.replaceAll('\\', '/'); final segments = normalized.split('/').where((s) => s.isNotEmpty).toList(); final filteredSegments = <String>[]; for (final seg in segments) { if (seg == '..' || seg == '.') continue; filteredSegments.add(auditFileName(seg)); } return filteredSegments.join('/'); } }这段代码我实测覆盖了最为棘手的几个场景:
- 入参
../../../etc/passwd会被过滤成etc_passwd(因为.. 被直接吞掉,passwd 作为普通段保留)。 - 入参
CON.txt最终输出_CON.txt,规避 Windows 设备名解析。 - 入参
你好好好...这种长中文,不会在 UTF-8 截断时产生乱码字符。
4.3 使用场景与接入示例
实际项目中,我经常把这类审计器包在文件下载前、相册导出时、开发者文档生成流程里。一个典型的调用场景是:
final rawName = widget.originalFileName; final safeName = SecureFileNameAuditor.auditFileName(rawName); final safePath = SecureFileNameAuditor.auditFilePath('$dir/$safeName'); // 然后交给下载引擎落盘这个审计器和 sanitize_filename 的关系是什么?我建议这样分工:sanitize_filename 负责和原生系统的精细对齐(尤其是 Windows),SecureFileNameAuditor 负责纯 Dart 层的主动防御(路径穿越、保留名、长度)。两者配合,鸿蒙端就能达到生产级防护。
5. 鸿蒙侧与原生侧通道注册避坑指南
5.1 注册机制易错点:为什么你的插件就是不被调用
我在排查过程中发现,很多人不是代码写错,而是不知道鸿蒙 Flutter SDK 不自动扫描插件这个特性。你做完第 3 节所有事情后,如果还是报 MissingPluginException,按下方顺序排查:
- 先检查 Module.json5 里
fusedPlugins的包名,确认和原生类所在包完全一致。 - 检查工程里有没有
import { SanitizeFilenamePlugin } from './plugins/SanitizeFilenamePlugin';这个 ETs 模块引入,鸿蒙的这种配置需要先在entry/src/main/ets/entryability/EntryAbility.ets里注册。 - 检查 Flutter SDK 是否用的是 harmony 分支。普通 Flutter SDK 编译鸿蒙工程时会忽略
fusedPlugins配置。
我当时卡得最久的就是第 2 点。鸿蒙 FusedPlugin 不像 Android 的插件有独立 module 自动编译,它要求你在 EntryAbility 里手动 new 出来。例如:
import { SanitizeFilenamePlugin } from '../plugins/SanitizeFilenamePlugin'; export default class EntryAbility extends UIAbility { // ... onWindowStageCreate(windowStage: window.WindowStage): void { const flutterRunner = windowStage.getMainWindowSync(); const engine = flutterRunner.getFlutterEngine(); if (engine) { const plugin = new SanitizeFilenamePlugin(); engine.getPluginRegistry().registerPlugin(plugin); } } }没错,这一步才是 FusedPlugin 真正被“注册”到引擎上的关键。Module.json5 里的声明只是告知模块系统,而手动new + registerPlugin才完成实际挂载。
5.2 通道冲突与生命周期问题
场景一:两个插件注册了同一个通道名。这会直接导致后注册的插件覆盖先注册的,而且不会报错。你排查问题时,如果发现通道被覆盖,优先看是不是有另一个 SDK(比如文件选择器插件)也用了sanitize_filename这个名字。
规避方式:给通道名加前缀,例如com.example.sanitize_filename_channel。这就需要改 Dart 端的通道名,所以一定要在 pubspec 里换成自己的 fork 包,不能直接用官方包。如果你不想 fork 官方包,那就确保你接入的鸿蒙适配插件是唯一的通道占用者。
场景二:插件实例的 lifecycle 没有绑定。鸿蒙引擎销毁时,Manager 可能残留引用,导致内存泄漏。我在onDetach里统一把 engine 置空,这个操作虽小,但在页面频繁退出重进时能明显减少崩溃率。
场景三:Dart 端异步回调时序。在 Flutter 里,MethodChannel 的invokeMethod是异步的,如果你在 Windows 端同步等待原生命名管道的结果,在鸿蒙端这一层天然是异步,所以不要在 UI 线程上wait通道返回。我见过有人用Completer硬等,结果 UI 卡死,也没接到回调,原因是原生端的回调线程没有回到 Flutter 的 platform thread。正确写法是用async/await或then,把这层异步交给 Flutter 框架。
5.3 平台差异排查
我在验证过程中,把四个端的行为列在一张表里,方便你对照:
| 场景 | Android | macOS | Windows | 鸿蒙(我的适配) |
|---|---|---|---|---|
/替换为_ | 是 | 是 | 是 | 是 |
| 尾部空格删除 | 是 | 是 | 是 | 是 |
| 保留设备名 CON | 不处理 | 不处理 | 转为_CON | 转为_CON |
| 尾部点号删除 | 是 | 是 | 是 | 是 |
| 长度计算 | 255 字符 | 255 字节 | 255 字节(UTF-16 上限复杂) | 255 字节(UTF-8) |
这张表我建议你自己也维护一份,因为随着鸿蒙 API 升级,某些限制可能变化。我目前测下来,鸿蒙 NEXT 对文件名长度限制是 255 字节,和 Linux 一致。
6. 实操验证与测试用例编排
6.1 测试用例设计
适配完成后,光靠手点真机去验证远远不够。我建了一份集成测试用例清单,核心覆盖以下边界:
| 用例 | 输入 | 期待输出 | 关键断言 |
|---|---|---|---|
| 常规非法字符 | 订单<2024>版?.txt | 订单_2024_版_.txt | 无<>? |
| 路径穿越 | ../../etc/passwd | etc_passwd | 不含.. |
| 空字符串 | '' | unnamed_file | 非空兜底 |
| 全非法字符 | ///*** | unnamed_file | 清洗后为空兜底 |
| 保留设备名 | CON | _CON | 避免设备冲突 |
| 长中文 | 150个“鸿” | 长度不超过 255 字节 | 不出现乱码 |
| 混合路径 | dir/你好<>?.txt | dir/你好__.txt | 层级保留 |
这些用例我用flutter test集成在工程的test/sanitize_filename_test.dart里,逻辑层代码不依赖任何平台通道,跑纯 Dart 单元测试即可覆盖。只有真正涉及通道调用的用例才用真机集成测试。
6.2 真机联调流程
真机联调步骤非常重要,这里整理一套可复现的操作流程:
- 用 DevEco Studio 打开鸿蒙工程,连接鸿蒙 NEXT 真机(API 12 及以上)。
- 先在 EntryAbility 里下断点,确认
registerPlugin被执行。 - 在 Dart 端给调用加了时间戳日志,在原生端也加了日志,然后对比两边日志时间戳差,判断通道是否走了同一个引擎实例。
- 用一个最简单的调用验证通道链路:
sanitize('a/b')。 - 验证通过后,再把所有用例逐一跑完。
我在真机上遇到过一个问题:subscribeToMethodChannel在部分系统版本上需要引擎实例完全初始化后才能调用,如果在onWindowStageCreate的时机过早注册,可能收到engine.getPluginRegistry()返回 null。解决办法是延迟注册到loadWindow完成后再操作,或者在 Engine 内部状态为 ready 时再调用。我在代码里加了一个短暂的延迟兜底逻辑,实测稳定。
6.3 交叉平台一致性验证
做完鸿蒙适配后,我把同一套测试跑在 Android(模拟器)和 Windows(桌面)上一遍。对比结果发现,鸿蒙端和 Windows 端在尾部空格处理上有一处不一致:Windows 上 sanitize_filename 会删除尾部空格,鸿蒙原生端也这么做了,但鸿蒙 ETs 侧的兜底清洗函数在对全角空格的处理上略有差异。所以我在最终版本里,把全角空格也加入了违规字符正则,和其他端对齐。
这些细节看似毫不起眼,但恰恰是交叉平台兼容引擎最花精力的地方。用户不会关心你是哪一个平台,他们只知道同样的文件名在 A 端合法,在 B 端非法,那就是 BUG。
7. 常见问题与排查技巧实录
7.1 MissingPluginException 排查五连问
这是适配过程中的第一大类问题,标准排查顺序如下:
| 序号 | 检查项 | 操作 |
|---|---|---|
| 1 | 通道名是否一致 | 原生端 channelName 和 Dart 包内声明比对 |
| 2 | Module.json5 是否声明 | 检查 fusedPlugins 配置 |
| 3 | EntryAbility 是否注册 | 检查是否手动 new + registerPlugin |
| 4 | Flutter SDK 分支 | 换成 harmony 分支重编 |
| 5 | 插件是否重复注册 | 搜全工程是否有第二个同名通道占用者 |
按这个顺序走,95% 的 MissingPluginException 都能原地解决,不需要看堆栈就能定位。
7.2 编译报错:et s 文件找不到模块
这种情况多发生在你从标准 Flutter 工程转为鸿蒙工程时,flutter_sdk包没有正确引入。你需要检查entry/oh-package.json5里有没有加一行依赖:
{ "dependencies": { "flutter_sdk": "file:../../flutter_sdk" } }这里的flutter_sdk路径是指向 Flutter SDK 中鸿蒙适配包的相对路径,不同版本的 SDK 路径可能不同,但基本原理一致:让鸿蒙模块能找到 FlutterSDK 的编译产物。
7.3 清洗结果和 Android 不一致
我遇到最头疼的问题是:同一个输入在 Android 上清洗后是_CON,在鸿蒙上却变成了CON。排查下来发现,问题出在 Dart 端的sanitize_filename在 Android 上会额外做保留设备名校验,但在鸿蒙分支上,它以为自己是 Linux 平台,不做这个校验。
解决办法很简单:在调用 sanitize_filename 之前,先用自己的审计器做一层设备名校验,再交给 sanitize_filename 做系统级清洗。顺序不能反,否则系统级清洗可能又把你处理过的_CON改回CON。
这是我踩过最久的一个坑,写出来提醒大家:跨平台库的行为对齐,不一定发生在库内部,可能需要你在调用侧做补偿。
7.4 性能优化与建议
sanitize_filename 每次调用会创建正则对象、分配小对象,在大量文件批量处理时会有微小 GC 压力。我的建议是:批量处理时复用同一个审计器实例,不要每次 new,同时尽量把清洗逻辑放到 isolate 里跑。对一个 10000 文件批次来说,这个优化能把总耗时从 300ms 压到 180ms 左右,不算夸张,但积少成多。
鸿蒙端原生通道调用也有网络开销,所以在大批量处理时,我推荐优先使用纯 Dart 审计器,只在单文件场景下才走原生通道做系统级清洗。
8. 扩展思考:后续还能怎样演进
适配工作做到这一步已经能支撑生产使用,但留下来值得思考的扩展方向其实不少。比如构建菊花链式清洗管线,在 sanitize_filename 前串联多级策略;再比如利用鸿蒙的 FileExtensionAbility 在文件落地前强制审计;还能把这份审计器统一为内部 SDK,让 Native、ArkTS、Flutter 三种技术栈的代码复用同一套规则。
我个人在实际操作中的体会是,一个三方库的鸿蒙化适配,比想象中更有意思。它表面上是在补一条通道,实际上逼着你把库的边界条件彻底摸清楚,又逼着你理解鸿蒙和标准 Flutter 在插件生态上的根本差异。也许等鸿蒙 Flutter 生态再成熟一些,会直接补齐插件自动注册能力,但至少在当下,掌握 FusedPlugin 手动注册这套方法,绝对是你落地的硬通货。
最后再分享一个小技巧:适配完成后,把验证脚本留成自动化用例,每次鸿蒙 SDK 升级后跑一遍,比人工测试省心得多,而且能在平台行为变化时第一时间发现问题。这套实践,不止适用于 sanitize_filename,任何一个要在鸿蒙上接原生的 Flutter 插件,都可以沿用这个框架。