做 Flutter 跨端文件处理的人,大概都撞过这样一面“看不见的墙”:同一个文件名,在 Windows 上创建得好好的,同步到鸿蒙设备上就变成了一堆FileSystemException。我之前一直以为是鸿蒙文件系统“太挑”,直到把一个叫 legalize 的三方库完整跑了一遍鸿蒙化适配,才想明白问题不在平台挑剔,而在于“合法文件名”从来就没有一个适用于所有平台的绝对定义。这篇博文会把我在 Flutter、legalize、鸿蒙三者之间的完整适配过程、踩坑记录和最终落地方案整理出来,尤其是那些最常见的跨平台文件系统非法字符清洗场景,希望对正要给 Flutter 应用做鸿蒙适配的团队有帮助。
1. 从“一个冒号和问号”引发的线上事故说起
1.1 一次被文件名干翻的跨端同步
事情起因很简单:我们的网盘类 Flutter 应用要支持 Windows 客户端上传的文件在鸿蒙端下载预览。联调阶段,测试同学上传了一个在 Windows 上创建的名为需求文档: V2?.docx的文件。Windows 上这个文件名其实是非法的,因为冒号:和问号?都触犯了 Windows 的保留字符规则,但文件是通过某个旧版本工具生成的,就这么稀里糊涂存了下来。
到了鸿蒙端,应用下载文件后调用dart:io的File写入本地目录,直接抛出了FileSystemException: Creation failed, path = ...。一开始团队内部争论方向完全跑偏了——有人怀疑鸿蒙沙箱路径写错了,有人怀疑是下载组件的字节流问题。最后单独跑了一个最小复现 Demo,只用File写一个包含?的文件名,问题百分百复现,这才把矛头指向了文件名本身。
这个事故最麻烦的地方在于:它不是偶发问题,而是只要用户上传的文件名命中了鸿蒙的禁用字符,下载流程就必然崩溃。而且由于错误被包装在下载任务回调里,现场看起来像网络故障,排查成本极高。
1.2 “合法文件名”是一个平台相关的概念
很多人以为文件名只要不含/就能在所有系统上通用。真实情况比这复杂得多,我列一个当时整理的对照表:
| 平台 | 主要非法字符 | 其他限制 |
|---|---|---|
| Windows | \ / : * ? " < > | | 不能以空格或点结尾,保留 CON、PRN、AUX、NUL、COM1 等设备名,不区分大小写 |
| macOS | /和:(路径语义) | 对 Unicode 支持较强,底层一般用 NFC 规范化存储 |
| Linux | /和空字符\0 | 区分大小写,文件名单字节上限 NAME_MAX 255 字节 |
| Android | 大体同 Linux | 部分存储卡 / FUSE 文件系统对* ? < >等字符有额外限制 |
| 鸿蒙 | 大体继承 POSIX 语义 | 沙箱目录、公共媒体库、跨设备同步还有各自的额外约束 |
看到这个表你就明白了,我们常说的“合法文件名”,本质上是每个平台根据自己的路径解析、保留设备名、历史兼容性各自定义的一套规则。Windows 和 macOS 之所以限制更多,是因为它们的路径解析器会把某些字符当成语法符号;Linux 和鸿蒙虽然对字符宽容,但对路径长度、字节数、以及不同挂载文件系统的行为差异非常敏感。
那为什么在 Windows、Android、iOS 上都跑得好好的 Flutter 代码,到了鸿蒙就崩?因为过去的测试矩阵只验证了“当前平台自己能生成和读取的文件”,没有验证“其他平台生成的合法文件名,在当前平台是否合法”。跨平台文件系统非法字符清洗,本质上就是解决这个“跨平台互换合法文件名”的问题,而不是单纯把脏字符删掉。
1.3 清洗不是一个删除动作,而是一套决策规则
我当时犯过的另一个错误是:直接把所有非法字符替换成_。结果用户上传的会议记录: 2024.docx被改成了会议记录_ 2024.docx,虽然不崩了,但用户下载后名字变得很怪,而且每次同步都会生成一个“改了名”的新文件,造成大量重复文件。
所以后来我把清洗重新定义为一套决策过程:
- 哪些字符必须删除(例如空字符,任何文件系统都无法容忍);
- 哪些字符应该替换成可读的替代符(例如 Windows 的
:替换为全角冒号:或下划线); - 哪些字符虽然合法,但可能引发跨平台问题(例如末尾空格、末尾点、保留设备名);
- 清洗后如果和已有文件重名,如何兜底。
这些决策正是 legalize 这类工具的核心价值。它不是一个“删字符函数”,而是一套可配置、可理解、可预期的人性化规则。可惜的是,它的默认规则覆盖了 Windows、macOS、Linux,却对鸿蒙几乎没有做针对性处理,于是才有了后面这轮适配。
2. legalize 做了什么,以及它的默认规则盲区
2.1 这个库的定位:轻量但设计考究
legalize 本身是一个非常轻量的 Dart 库,核心代码量不大。它的思路很简单:接受一个原始文件名和一组规则,输出一个“在当前平台下可以安全创建”的文件名。公开 API 大致长这样:
String legalize( String input, { LegalizeMode mode = LegalizeMode.windows, LegalizeOptions options = const LegalizeOptions(), });LegalizeMode决定使用哪一套非法字符表,LegalizeOptions则允许自定义替换字符、是否保留扩展名、是否转换大小写等。默认情况下,它会把非法字符替换成下划线,而不是直接删掉,这样文件名看起来还是“有个东西在这里”,用户在文件管理器里不至于完全看不懂。
我在项目里最常用的是LegalizeMode.windows,因为它是整套规则里最严格的一个:只要能在 Windows 上合法,基本在 macOS 和 Linux 上也能安全。这个经验在我做传统跨端时是有效的,但扩展到鸿蒙之后就不够用了。
2.2 默认三层规则:字符、控制符、设备名
拆开看,legalize 的默认规则主要分成三层:
第一层是非法字符表。Windows 模式会处理\ / : * ? " < > |,macOS 模式会额外处理:,POSIX 模式则主要处理/和\0。这一层最好理解。
第二层是控制字符。ASCII 控制字符(0x00到0x1F)以及 DEL(0x7F)在很多文件系统中要么不可见,要么会被系统解释成特殊含义。比如0x0A换行符,在 Windows 资源管理器里能创建,但在某些同步盘和文件索引服务里会造成解析错乱。legalize 默认会把这些控制字符替换掉,而不是删除,避免字符串长度突变导致后续截断逻辑混乱。
第三层是 Windows 保留设备名。CON、PRN、AUX、NUL、COM1到COM9、LPT1到LPT9,这些名字在 Windows 上即使加上.txt后缀也是非法设备引用。legalize 会在遇到这类文件名时给它加上前置下划线,比如CON.txt变成CON_.txt。
这一层非常重要,因为很多开发者在做清洗时只处理了非法字符,忘了还有保留设备名这回事。用户上传一个叫NUL.txt的文件,Windows 上 Flutter 直接写入就会失败。
2.3 盲区一:没有为鸿蒙的路径和同步语义建模
legalize 做得很好,但它默认规则的服务对象是 PC 时代那三大桌面系统。鸿蒙出现后,默认模式就出现了两个明显盲区:
第一个盲区是“鸿蒙沙箱内可写,不代表跨设备同步后可读”。HarmonyOS 的应用沙箱目录对字符的容忍度相对接近 Linux,但在云盘、多设备协同、媒体库这类场景里,文件名还要经过文件管理服务和同步服务。有些字符在本地 POSIX 层是合法的,同步到另一台设备或者被媒体库扫描时却可能触发异常或直接被替换。legalize 的默认规则没有覆盖这层“服务端语义”。
第二个盲区是“长度限制”。鸿蒙上层接口对单段文件名的限制通常还是看底层文件系统,而底层大概率是 255 字节(NAME_MAX)。但有些文件系统块大小、加密目录、FUSE 挂载会让字节上限更小,或者对 UTF-8 多字节字符的计数方式不同。legalize 默认只处理字符层面,不会根据 UTF-8 字节数截断,遇到一串中文 emoji 混合文件名,一不留神就超出了底层限制。
2.4 盲区二:清洗不可逆,重名碰撞缺少兜底
还有一点必须正视:清洗是破坏性操作。A:B.txt经过 Windows 模式处理后变成A_B.txt,原始信息已经丢失,无法反解出原始文件名。如果用户再上传一个原本就叫A_B.txt的文件,第二次清洗结果和第一次完全相同,写入时就会覆盖同名文件。
这在传统桌面平台上问题不大,因为桌面文件系统可以用“是否已存在”做实时判断;但我们的鸿蒙适配场景涉及离线同步、批量导入,清洗完一批文件后才开始写入,重名碰撞只能靠预扫描处理。legalize 本身不解决这个问题,适配时必须在外面包一层去重逻辑。
正是因为这两个盲区,我决定不只是“在调用 legalize 前把鸿蒙字符串预先替换一遍”,而是直接给它补一个鸿蒙语义的规则模式,并配套一套封装好的兜底策略。
3. 适配鸿蒙前,必须先搞清楚的四个边界问题
3.1 沙箱目录和公共媒体库是两套语法
鸿蒙应用读取本地文件,通常有两种路径来源:一种是应用自己的沙箱目录,比如files、cache,通过dart:io写路径是相对干净直接的;另一种是通过系统文件选择器或媒体库拿到的 URI,背后是公共目录、SD 卡、跨设备云盘等不同存储服务。
这两种来源对非法字符的容忍度很不一样。沙箱目录里,只要底层是 Linux 文件系统,最核心的红线就是/和空字符,其他字符大多能创建成功。公共媒体库则不同,因为媒体库要维护索引、缩略图、跨设备同步,文件名里带?、*、控制字符,很可能在 MediaLibrary 扫描阶段就被过滤或标记异常。
所以适配时不能只准备一套清洗规则,至少要有两档:
- 沙箱严格模式:核心是防止路径注入和系统级非法字符;
- 媒体库/同步严格模式:在沙箱规则之上,进一步处理问号、星号、尖括号、竖线、控制字符等“媒体库不友好”字符。
3.2 保留字符与保留名,比文档里写的更多
踩坑过程中我发现,鸿蒙文档里明确禁止的字符并不复杂,主要就是路径分隔符和控制字符。但真机行为远比文档丰富。同样一个:, 在应用的沙箱目录里可以创建成功,但通过文件管理应用重命名时会提示非法;同样一个?,在部分厂家的设备底层是允许的,一旦开启云同步,服务端又把它转义成其他字符。
我的建议是:不要挑战平台能力边界,直接采用“最严格桌面规则 + 鸿蒙语义修正”的策略,把: * ? " < > |都纳入清洗范围。就算某个鸿蒙版本底层允许这些字符,清洗掉也不会伤害用户体验,但能避免未来版本收紧规则时出现线上事故。
保留设备名这一层也要扩展到鸿蒙场景。虽然鸿蒙不是 DOS 系文件系统,理论上不存在CON设备文件,但鸿蒙文件管理服务兼容了许多 Windows 生态的外部存储设备,U 盘、NTFS、exFAT 都可能带过来。为了保险,CON、PRN、AUX、NUL、COM1-9、LPT1-9这类名字在鸿蒙适配里同样要处理。
3.3 文件名的长度限制是“字节”不是“字符”
这一点是适配过程中最容易漏的。很多开发者按String.length截断文件名,比如限制为 200 字符,以为这样肯定安全。实际上 POSIX 文件系统限制的是单段文件名的字节数,也就是 UTF-8 编码后的字节数,常见上限是 255 字节。
一个“中文字符”在 UTF-8 下占 3 字节,一个 emoji 可能占 4 字节甚至更多。同样是 80 个字符的文件名,纯英文 80 字节完全没问题,纯 emoji 可能已经 320 字节,写入直接失败。
所以适配时我写了一个专门函数,优先按“UTF-8 字节上限”截断,而不是按字符数截断。截断时还要小心不要把多字节字符拦腰切断,产生一个非法 UTF-8 序列,否则文件名生成阶段就会引入新的崩溃点。
3.4 大小写敏感性:看起来是小事,爆炸是大事
Windows 和 macOS 的默认文件系统通常不区分大小写,Linux 和鸿蒙底层则区分。这在跨平台文件同步场景里是个大坑。
举一个例子:用户在一个 Windows 客户端上同时创建了README.md和readme.md,这在 Windows 文件系统里会被当成同一个文件,第三个文件写入时覆盖前者。但这两个文件名如果在鸿蒙本地目录里创建,底层会认为它们完全独立,于是同步模块可能在同一目录下留下两个内容不同的文件。反过来,鸿蒙本地合法创建了A.txt和a.txt,同步到 Windows 时就有一个会失败或覆盖。
legalize 和大分类似,默认不会帮你统一大小写。我们的适配策略是:如果产品逻辑允许,对最终展示名做“小写副本”用于冲突检测;如果产品要求保留用户原始大小写,那就要在去重逻辑里同时考虑大小写不敏感对比,避免同步到 Windows 时产生一对“看起来同名”的文件。
4. 鸿蒙化适配落地:规则扩展、平台检测、去重兜底
4.1 给 legalize 增加一个 harmony 规则模式
确定边界后,我第一件事是给 legalize 的LegalizeMode增加一个harmony值。实现上并不复杂,因为它的源码结构本来就是“按模式维护一组字符集合”,我只需要按上一节的分析,把鸿蒙模式需要的非法字符集补充进去。
enum LegalizeMode { windows, unix, macos, harmony, } const harmonyIllegalChars = <String>{ '/', '\\', ':', '*', '?', '"', '<', '>', '|', }; const harmonyControlChars = <int>{ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x7F, };这里我没有把全部非法字符直接删掉,而是让 legalize 用统一的替换策略处理。项目里我选择的默认替代符是下划线,但我在 Options 里留了一个开关,允许在“媒体库严格模式”下把?和*替换成全角字符,比如?和*。这样用户看到的文件语义变化最小,双向同步时也能减少重名冲突。
4.2 平台检测:别迷信dart:io的 Platform
适配中一个很实际的痛点是怎么判断“当前跑在鸿蒙上”。早期 Flutter 鸿蒙支持还不完善的时候,Platform.isAndroid在鸿蒙上可能返回true,因为上层兼容层会伪装成 Android 环境。等 HarmonyOS NEXT 逐步铺开,Flutter 鸿蒙运行时走的是ohos分支,Platform的行为又会发生变化。
我最后采用的方案不是一个单一判断,而是分层判断:
bool get isHarmonyOS { try { if (Platform.environment.containsKey('OHOS_APP_TYPE')) return true; if (Platform.environment['ohos.bundle.cfg'] != null) return true; } catch (_) {} try { final info = await DeviceInfoPlugin().harmonyOsInfo; return info != null; } catch (_) { return false; } }第一层是编译期常量。如果用 OpenHarmony 的 Flutter SDK 构建,通常在环境中能拿到OHOS_APP_TYPE或类似字段;第二层用device_info_plus查询鸿蒙系统信息,它内部走的是鸿蒙 API 通道,比Platform可靠。两层都失败时,默认按“非鸿蒙”处理,套用原来的逻辑。
这个判断很重要,因为我们的封装层要根据平台选择不同规则集。如果在 Android 上误用了鸿蒙严格规则,顶多多清洗几个字符,影响不大;但如果在鸿蒙上误用了 Windows 规则,虽然结果同样安全,却可能把合法文件名改得过度,破坏用户预期。
4.3 统一封装:清洗、截断、去重三步走
规则模式扩展好后,我在项目里封装了一个HarmonyFileNameSanitizer,对外只暴露一个方法。这个封装内部固定执行三步:清洗、字节截断、重名去重。
清洗阶段:
String _clean(String rawName, {required bool mediaLibraryMode}) { var name = rawName.trim(); if (mediaLibraryMode) { name = legalize(name, mode: LegalizeMode.harmony, options: LegalizeOptions( replaceChar: '_', additionalReplacements: {'?': '?', '*': '*'}, )); } else { name = legalize(name, mode: LegalizeMode.harmony); } final dotIndex = name.lastIndexOf('.'); final base = dotIndex <= 0 ? name : name.substring(0, dotIndex); final ext = dotIndex <= 0 ? '' : name.substring(dotIndex); return _truncateByUtf8Bytes(base, ext); }我特意把扩展名拆出来单独处理。因为截断时如果直接截整个字符串,很可能把.docx截没了,用户拿到一个没有扩展名的文件,预览器就不认了。拆开后,主体部分按字节上限截断,扩展名保留,最后再拼回去。
字节截断阶段:
String _truncateByUtf8Bytes(String base, String ext) { const maxBytes = 240; // 留出一点余量,避免文件系统额外开销 final extBytes = utf8.encode(ext).length; var remain = maxBytes - extBytes; if (remain <= 0) return base.substring(0, 1) + ext; final runes = base.runes.toList(); final buffer = StringBuffer(); var used = 0; for (final rune in runes) { final len = utf8.encode(String.fromCharCode(rune)).length; if (used + len > remain) break; buffer.writeCharCode(rune); used += len; } return buffer.toString() + ext; }注意不要用substring直接按字符截断后拼好再量字节,因为一个中文字符被切断的瞬间会变成无效字符串。我这个实现是按 Unicode Rune 遍历,逐个累加 UTF-8 字节,确保不会切断字符本身。
重名去重阶段,我用了“清洗后扁平化对照”的思路:
String deduplicate(String fileName, Set<String> usedKeys) { var candidate = fileName; var index = 1; final dotIndex = fileName.lastIndexOf('.'); final base = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); final ext = dotIndex <= 0 ? '' : fileName.substring(dotIndex); var key = candidate.toLowerCase(); while (usedKeys.contains(key)) { final suffix = ' ($index)'; final newBase = base.substring(0, base.length) + suffix; candidate = newBase + ext; key = candidate.toLowerCase(); index++; } usedKeys.add(key); return candidate; }这里有两个细节值得说。第一,重名检测的 key 全部转成小写,正是因为考虑到 Windows 大小写不敏感的问题;如果目标同步链路里有 Windows 节点,A.txt和a.txt就必须被判定为冲突。第二,去重后缀用了带空格的(1)而不是_1,这是因为如果原始文件名已经有_1之类的后缀,再加下划线拼接容易让用户分不清是“原始命名”还是“系统追加”。
4.4 测试用例:把每个平台的“作恶样本”都摆上桌
适配完后,我搭了一套很简单的测试用例矩阵,把历史上踩过的每一种文件名都塞进去跑一遍:
| 输入文件名 | Windows 规则结果 | 鸿蒙严格规则结果 |
|---|---|---|
需求文档: V2?.docx | 需求文档_ V2_.docx | 需求文档_ V2_.docx |
CON.txt | CON_.txt | CON_.txt |
report\u0000final.txt | report_final.txt | report_final.txt |
A:B*C?.txt | A_B_C_.txt | A_B_C_.txt |
文件名长度验证...(80个中文) | 不截断 | 按 240 字节截断 |
readme.md与README.md同时出现 | 保留原大小写 | 去重逻辑识别为冲突 |
这个矩阵不只是给测试同学当验收依据,也成了我后来和其他业务方沟通的“共同语言”。很多人一听到“非法字符清洗”就觉得是小事,但看到CON.txt在 Windows 上真的写不进去、?在鸿蒙媒体库里真的会被过滤时,基本都会收起“这很简单”的态度。
5. 完成这轮适配后,我再回头看清洗这件事
5.1 不要试图“清洗一切”,要给业务留出口
刚开始做鸿蒙适配时,我想着把规则搞到最严,所有文件名都统一清洗成 ASCII 字母数字加下划线,这样肯定不会出错。但很快被产品和用户教育了:中文文件名、日文假名、泰文、韩文、emoji,这些用户写进文件名里的符号承载着真实信息。清洗的目的不是把文件名变成“机器愿意看的东西”,而是让它在目标平台上可创建、可读取、可同步,同时尽量保留用户能理解的样子。
所以我在 public API 设计上留了三个参数:
String sanitizeFileName( String rawName, { required bool forMediaLibrary, bool keepUnicode = true, bool conflictDetectAsCaseInsensitive = true, });keepUnicode为false时才会真正把非 ASCII 字母全部转拼音或替换,但这只是极端场景的逃生通道,默认永远打开。
5.2 不要只防运行时崩溃,还要防“未来被回滚”
鸿蒙的 Flutter 生态还在快速变化中,今天编译出来的ohos分支行为,可能下个 SDK 版本就变了。我的建议是把清洗逻辑和平台检测逻辑隔离成独立的模块,不要散落在业务代码里。这样后面官方适配升级了,你只需要更新isHarmonyOS的判断,或者调整harmonyIllegalChars这个常量集合,不用动任何业务代码。
另外,在所有可能抛FileSystemException的地方打日志时,千万别直接把File(path)的原始 path 打出来。有些控制字符在日志系统里会触发转义问题,而且这些字符可能带有终端控制能力,内部日志平台很容易被这种字符串干扰格式,甚至造成日志检索混乱。我在日志里统一对 path 做了一次脱敏,把非可见字符强制替换成[UNPRINTABLE],附件上传排查问题时再通过内部工具还原。
5.3 一个值得长期保留的经验项
整个适配过程里,我最深刻的体会是:跨平台文件系统非法字符清洗,不应该是一次性的应急补丁,而应该成为 Flutter 工程的“基础卫生设施”。
合法文件名是平台相关的,但清洗策略必须是产品层面统一的。与其让各个业务模块各自写一套replaceAll逻辑,不如从一开始就围绕legalize这样的库做一次集中封装,定好规则、截断、去重三个层次的默认行为,把所有平台差异关在同一个模块里面。后期无论接入鸿蒙、OpenHarmony 还是未来某个新的文件系统,都只需要在这个模块内部做增量适配。
这次给 legalize 做鸿蒙化,技术上不算难,真正难的是把平台差异的认知从“我以为我知道”纠正成“每个平台都有各自的底线”。适配完成后,我特意保留了一套跨端文件名测试夹具,里面放着 Windows 的保留名、macOS 的冒号场景、Linux 的超长字节、鸿蒙的媒体库过滤样本。每次新 SDK 发布,我都会把这套夹具重新跑一遍,确认没有新的边界变化。这个方法也推荐给你,与其反复纠结一个字符要不要清洗,不如让测试用例帮你守住底线。