
做鸿蒙HarmonyOS文件类应用的朋友十有八九会遇到这么一组需求界面上展示某个文件的名称、大小、类型上传附件前判断后缀合不合法、体积超没超限清理类工具里按大小排序扫出来的文件。看起来都是“读个元数据”的小事但真正落到 HarmonyOS 6.0 上写代码时你会发现它不是简单一行file.size就能解决的——沙箱边界、路径与文件描述符fd的取舍、Stat 结构体的字段含义、文件类型识别的方式每一层都有值得掰开揉碎讲清楚的地方。这篇文章是《HarmonyOS 6.0 实战》系列的第 16 篇不绕弯子直接围绕“获取文件大小”和“获取文件类型”这两个点把方案选型、完整代码、封装方式和实测中踩过的坑都整理出来。适合正在做鸿蒙文件管理、文档扫描、媒体 App 或者有上传下载模块的开发者也适合刚接触 ArkTS 想补足工程经验的朋友。1. 先想清楚文件大小和类型到底在哪些场景里真正影响体验很多教程上来就贴stat接口但我建议先花两分钟想一下使用场景因为场景决定了你用哪套读写姿势、要不要开权限、甚至要不要碰媒体库。这两年在实际项目里我遇到的核心场景大概是下面三种。1.1 文件列表与管理类 App这是最常见的场景。用户打开某个目录应用沙箱目录、公共下载目录或者通过文件选择器拿到的目录你需要把文件逐个展示出来左边是类型图标右边是文件名下面一行小字写着“12.4MB · 修改于昨天”。这里的“12.4MB”就是文件大小“PDF”“MP4”就是文件类型。如果只是展示倒还好麻烦的是管理类 App 通常还伴随筛选、排序和搜索。按大小排序时必须拿到精确的字节数类型筛选时不能只靠文件名后缀还要考虑无后缀文件、系统隐藏文件、目录项等等。这时候单纯美化 UI 的思路就不够用底层数据结构要提前设计好。1.2 上传、下载与附件校验另一个高频场景是上传前校验。聊天类工具在发送图片、压缩包或文档前通常要判断扩展名是否在允许列表里文件大小是否超过限制超过就直接提示“文件不能超过 200MB”根本没机会把文件完整读进内存。这种场景下stat拿 size 是最经济的手段——既不读内容也不占内存一次系统调用就能搞定。但“判断类型”在这里就得谨慎了。用户把文件后缀改一下比如把一个压缩包.zip改成.doc单看扩展名会通过校验后面服务端处理时大概率直接爆。所以从经验上看凡是涉及“类型合法性”的校验扩展名只能作为第一关后面最好还是用文件头魔数兜底这一节后面会专门说。1.3 为什么这类需求值得单独梳理表面看“获取文件大小和文件类型”只是两个值但实际上它横跨了鸿蒙系统的三个模块普通文件用CoreFileKit的fileIo媒体资源用MediaLibraryKit文件选择器又是另一套身影。再加上新版 API 重命名、file://URI 与真实路径的转换、同步接口对主线程的影响真正做起来比想象中要曲折。这也是我把这个主题单独拿出来写一篇的原因——很多开发者不是不会调接口而是容易在“用哪个接口、传什么参数、要不要额外权限”这几个选择上卡壳。2. 用对 API 姿势路径、文件描述符与 Stat 结构体的选择在 HarmonyOS 6.0 上官方把文件相关能力统一归到了CoreFileKit模块名也从老的ohos.file.fs换成了 Kit 风格。这里有个容易忽略的细节虽然导入方式变了但核心接口名一直保持稳定所以网上的旧代码大多还能跑只是建议新项目直接按新写法人门。2.1 导入方式与版本差异新项目里通常这样导入import { fileIo as fs } from kit.CoreFileKit;代码里仍然通过fs这个别名调用openSync、statSync、readSync之类的方法。之所以统一到 Kit 包是因为 API 12 之后官方希望开发者按照“Kit”维度来管理依赖而不是散装地在各个ohos.*模块里找 API。对写业务代码的人来说最大的好处是升级大版本时迁移成本低基本不用改业务逻辑。2.2 传路径还是传 fd两者各自适用什么fileIo里获取文件状态的接口有两类直接传入路径或者传入已经打开的文件描述符。看起来都能拿到 Stat但适用场景不一样。直接传路径的方式比如statSync(/data/storage/el2/base/.../test.txt)适合你手上只有一个文件路径、且只做一次状态查询的情况。系统会帮你完成打开、读取状态、关闭的全过程。缺点是如果后续你还打算读内容、写内容路径方式会白白多一次打开关闭的开销并且和后续操作不是同一个文件句柄。先openSync拿 fd再fstatSync(fd)最后closeSync的方式胜在“一次打开多次使用”。典型场景是你先拿到 fd 读一下文件头判断类型又读取大小再接下去可能还要读一段内容做预览。多个操作共用一个 fd性能和一致性都更好。代价是要自己管理生命周期忘记closeSync会导致占用的文件句柄越来越多。2.3 Stat 结构体中那些不起眼的字段拿到Stat对象后很多人只关心size但其实里面还有几个有用字段export interface Stat { size: number; // 文件大小单位字节 mode: number; // 文件模式包含文件类型和权限信息 mtime: number; // 修改时间单位毫秒 ctime: number; // 创建时间单位毫秒 atime: number; // 最后访问时间单位毫秒 }mode是大家在 Linux 上常说的文件模式。它不只是“可读可写”的权限位还包含文件类型位。判断它是普通文件还是目录可以这样算const S_IFMT 0o170000; // 文件类型位掩码 const S_IFREG 0o100000; // 普通文件 const S_IFDIR 0o040000; // 目录 function isDirectory(stat: fs.Stat): boolean { return (stat.mode S_IFMT) S_IFDIR; }这个判断很有用尤其是目录扫描遇到子文件夹时你要决定是继续递归还是跳过。很多文件列表 App 的初期版本没做这一步结果把文件夹也当成普通文件传到详情页点进去就崩——其实是mode字段用得不够细。3. 获取文件大小两种落地写法和让人读懂的格式化这一节给可复制的代码。核心思路前面已经说了要么路径直达要么 fd 通行。下面两种写法都能拿到字节数。3.1 一步式直接通过路径获取大小如果你的需求只是“给我看下这个文件多大”用最简写法就行import { fileIo as fs } from kit.CoreFileKit; function getFileSizeByPath(filePath: string): number { const stat fs.statSync(filePath); return stat.size; }注意statSync是同步接口。调用它时线程会阻塞等待系统返回结果。在主线程拿一两个文件没问题但如果要扫描整个目录、几百上千个文件就要考虑放后台执行否则容易卡 UI严重时还会被系统判为“无响应”。3.2 两步式打开文件拿到 fd 再读大小更稳妥的写法是显式打开、读状态、再关闭import { fileIo as fs } from kit.CoreFileKit; function getFileSizeByFd(filePath: string): number { const file fs.openSync(filePath, fs.OpenMode.READ_ONLY); try { const stat fs.fstatSync(file.fd); return stat.size; } finally { fs.closeSync(file); } }finally里做closeSync是保护性写法保证即使中间抛异常文件句柄也不会泄漏。OpenMode.READ_ONLY是最小化的打开模式只读不写既能拿到状态也不会影响文件的修改时间等属性。3.3 把字节数显示成“40.2MB”的换算逻辑size的单位是字节直接显示成4294967296在界面上肯定不行需要格式化成人类可读的字符串。我一般用下面这个函数function formatSize(bytes: number): string { if (bytes 1024) { return ${bytes} B; } const units [KB, MB, GB, TB]; let index -1; let value bytes; while (value 1024 index units.length - 1) { value value / 1024; index; } const digits value 100 ? 0 : value 10 ? 1 : 2; return ${value.toFixed(digits)} ${units[index]}; }这里有个经验点不要所有单位都统一保留两位小数。1.00 MB和1.02 MB在体验上有差别但当文件变成1280.55 MB时保留两位反而显得臃肿。我习惯大于 100 的单位部分不再显示小数几百 M 的直接显示成1280 MB看一眼就能懂。另外注意鸿蒙不同地区的显示习惯如果做国际化B、KB这类单位尽量用通用缩写避免本地化翻译造成歧义。4. 文件类型识别轻量后缀法、硬核魔数法与媒体库特例文件类型是另一个“说简单也简单说复杂也复杂”的点。我这里总结了三种方案按场景选用轻量级后缀映射法、硬核的魔数识别法以及媒体库资源的mediaType字段。4.1 后缀映射大部分场景最快的解法最直观的方式就是看扩展名。把文件名拆出后缀再映射成你要展示的类型文本或 MIME 类型function getExtension(fileName: string): string { const index fileName.lastIndexOf(.); if (index 0 || index fileName.length - 1) { return ; } return fileName.substring(index 1).toLowerCase(); } const MIME_MAP: Recordstring, string { jpg: image/jpeg, jpeg: image/jpeg, png: image/png, gif: image/gif, webp: image/webp, txt: text/plain, pdf: application/pdf, doc: application/msword, docx: application/vnd.openxmlformats-officedocument.wordprocessingml.document, xls: application/vnd.ms-excel, xlsx: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, ppt: application/vnd.ms-powerpoint, pptx: application/vnd.openxmlformats-officedocument.presentationml.presentation, mp3: audio/mpeg, mp4: video/mp4, zip: application/zip, rar: application/vnd.rar, ... }; function getMimeType(fileName: string): string { const ext getExtension(fileName); return MIME_MAP[ext] ?? application/octet-stream; }后缀法的优点很直接不需要打开文件不增加 IO 开销性能极好代码一眼看明白。但它有两个明显短板。第一改后缀就失效文件实际内容可能是别的东西第二有些文件没有后缀比如常见的README、.gitignore这时候只能返回未知。4.2 魔数识别不信任扩展名时的兜底方案在线开发中保护自己如果你在做一个上传服务或需要判断真实内容的工具别只依赖扩展名——读一下文件的开头几个字节比对文件的“签名”。这些签名被业界称为魔数Magic Number像 JPEG 文件头是FF D8 FFPNG 是89 50 4E 47PDF 是25 50 44 46ZIP 是50 4B 03 04。只要开头匹配基本就能锁定真实类型哪怕扩展名被人为改掉也不影响判断。HarmonyOS 上读取文件头也很方便打开文件后调用readSync读一个 16 字节的缓冲区即可import { fileIo as fs } from kit.CoreFileKit; interface SignatureRule { ext: string; mime: string; magic: number[]; } const SIGNATURE_RULES: SignatureRule[] [ { ext: jpg, mime: image/jpeg, magic: [0xFF, 0xD8, 0xFF] }, { ext: png, mime: image/png, magic: [0x89, 0x50, 0x4E, 0x47] }, { ext: gif, mime: image/gif, magic: [0x47, 0x49, 0x46, 0x38] }, { ext: pdf, mime: application/pdf, magic: [0x25, 0x50, 0x44, 0x46] }, { ext: zip, mime: application/zip, magic: [0x50, 0x4B, 0x03, 0x04] }, { ext: rar, mime: application/vnd.rar, magic: [0x52, 0x61, 0x72, 0x21] }, // webp 需要综合判断这里只是示意 { ext: webp, mime: image/webp, magic: [0x52, 0x49, 0x46, 0x46] }, ]; function detectTypeByMagic(filePath: string): string { const file fs.openSync(filePath, fs.OpenMode.READ_ONLY); try { const buffer new ArrayBuffer(16); fs.readSync(file.fd, buffer, { offset: 0, length: 16, position: 0 }); const view new Uint8Array(buffer); for (const rule of SIGNATURE_RULES) { if (rule.magic.some((byte, index) byte ! view[index])) { continue; } return rule.mime; } return application/octet-stream; } finally { fs.closeSync(file); } }这段代码里我故意用“全匹配失败才继续”的逻辑而不是先找长度再逐个比这是因为大部分魔数长度都在 2~4 字节之间一次性循环判断会简洁一些。这里要特别强调一个小坑readSync的position参数是文件绝对偏移必须从0开始才能读到文件头如果漏传position行为可能不符合预期这在后面第 6 节会详细说。看完这段你可能已经发现魔数法也有“识别表不全”的问题。实际工程里没必要自己维护上百种规则够覆盖常见图片、PDF、压缩包就可以重点是把它作为“兜底”而不是“唯一”。我的习惯是扩展名映射先给一个初步结果魔数检测通过后再覆盖为精确结果两者一致时直接信任不一致时显示魔数结果并提示用户“文件类型与后缀可能不符”。4.3 媒体库资源直接用 mediaType别自己造轮子如果是 App 里通过相册、音频库拿到的媒体资源最好不要自己去路径读魔数因为媒体库的资源有一套更规范的类型标记。HarmonyOS 的PhotoAccessHelper在返回资源时会给每个资源一个mediaType字段取值和含义是mediaType含义说明1图片包括 JPEG、PNG、GIF、WebP 等2视频包括 MP4、MKV、MOV 等3音频主要是音乐、录音文件使用方式大致如下import { photoAccessHelper } from kit.MediaLibraryKit; import { common } from kit.AbilityKit; async function fetchFirstMediaType(context: common.UIAbilityContext): Promisenumber | undefined { const helper photoAccessHelper.getPhotoAccessHelper(context); const fetchOptions: photoAccessHelper.FetchOptions { fetchColumns: [media_type] }; const result await helper.getAssets(fetchOptions); const asset await result.getFirstObject(); return asset?.mediaType; }用系统给的mediaType好处是不用自己维护媒体后缀和 MIME 的映射表也不容易因为文件被改名而误判。需要提醒的是访问公共媒体库资源前要申请相应的媒体读取权限否则getAssets会抛权限异常如果只是通过系统文件选择器选中的单个文件就不需要全局权限走选择器返回的 URI 处理即可。5. 把能力收进一个工具模块再接到列表页面上前面讲的是单个文件的获取实际开发中更多是“扫目录把每个文件的信息整理出来”。我建议把大小和类型判断收成一个工具模块统一返回结构化的对象。这样通用逻辑不用散落页面里后面接 UI、接搜索、接上传校验都能复用。5.1 定义统一的信息接口设计一个FileBrief接口集中放文件名、路径、大小、类型等字段export interface FileBrief { name: string; path: string; isDirectory: boolean; size: number; sizeText: string; extension: string; mimeType: string; modifiedTime: number; }szieText是格式化后的展示字段例如12.4 MB。isDirectory是给列表用来区分目录项和文件项的。modifiedTime记录修改时间列表里“修改于昨天”就是从这里算出来的。5.2 扫描目录组装文件信息结合前面的函数写一个目录扫描函数import { fileIo as fs } from kit.CoreFileKit; import { FileBrief } from ./FileBrief; function scanDirectory(dirPath: string): FileBrief[] { const names fs.listFileSync(dirPath); const briefs: FileBrief[] []; for (const name of names) { const fullPath ${dirPath}/${name}; try { const stat fs.statSync(fullPath); const isDir (stat.mode 0o170000) 0o040000; const brief: FileBrief { name: name, path: fullPath, isDirectory: isDir, size: isDir ? 0 : stat.size, sizeText: isDir ? 目录 : formatSize(stat.size), extension: isDir ? : getExtension(name), mimeType: isDir ? : getMimeType(name), modifiedTime: stat.mtime, }; briefs.push(brief); } catch (error) { console.error(扫描文件失败: ${fullPath}, 错误码: ${error.code}, 信息: ${error.message}); } } return briefs; }几个设计细节目录项的size我置为 0因为对目录调stat拿到的size通常也不是文件的实际内容大小展示成“目录”比数字更友好扫描过程中单个文件读取失败不能影响整个列表用try/catch包住记录日志后继续下一个这个处理在真实项目里很重要总会有个别文件因权限或占用读取失败。5.3 接进列表界面时的一点建议拿到FileBrief[]后接 UI 就简单了。可以用ForEach配合List组件展示Builder function fileRow(item: FileBrief) { Row({ space: 12 }) { Text(item.extension.toUpperCase()) .fontSize(12) .width(48) .height(48) .textAlign(TextAlign.Center) .backgroundColor(0xFFEEF2FF) .borderRadius(8) Column({ space: 4 }) { Text(item.name) .fontSize(16) .maxLines(1) Text(${item.sizeText} · ${item.mimeType}) .fontSize(12) .fontColor(0xFF999999) }.alignItems(HorizontalAlign.Start) .layoutWeight(1) } .width(100%) .padding(12) }类型图标不一定只显示后缀文字。更好的做法是根据mimeType前缀映射到不同颜色的小图标但核心思路都一样用extension或mimeType驱动 UI 分支保证列表看起来统一又不费力。比较大的坑是如果目录文件很多在主线程里同步scanDirectory会卡顿。按我的经验超过两三百个文件就该考虑放到TaskPool或Worker里执行扫描完成再把结果丢回主线程刷新状态。后面经验笔记里也会再次强调。6. 实测中容易翻车的细节与经验笔记代码看完最后把我在实测里踩过、以及身边同事经常踩的坑集中列出来。这些细节不写在官方 Demo 里但一旦碰上非常耽误时间。6.1 file:// URI 直接传给 statSync 会报错很多接口文件选择器、媒体库拿回来的都是file://开头的 URI例如file://media/Photo/10。如果你把这个 URI 直接传进statSync大概率会得到类似“No such file or directory”的错误。原因很简单fileIo的路径参数要求真实文件系统路径而不是 URI。遇到这种情况先用fileUri工具转一下真实路径import { fileUri } from kit.CoreFileKit; const realPath fileUri.getPathFromUri(uriString); const stat fs.statSync(realPath);反过来如果你要把一个真实路径传给文件选择器或其他组件也可能需要先转成file://URI用fileUri.getUriFromPath(path)。这个转换步骤非常容易漏建议在工具函数里统一处理不要在业务代码里到处散落。6.2 权限冲突导致的表现不是“权限不足”而是“找不到文件”这是最坑的一点。鸿蒙的沙箱数据隔离严格当你访问没有权限的公共目录或者应用没有申请对应媒体权限时很多接口返回的错误并不仅仅是“Permission denied”而是直接给你“No such file or directory”或者error.code为通用文件错误码。第一次遇到时我一度怀疑是不是路径写错了。经验是先确认访问对象是不是应用沙箱内部路径如果要访问公共目录优先通过系统文件选择器让用户主动选择这样既合规又不需要额外权限如果走媒体库务必检查ohos.permission.READ_IMAGEVIDEO等权限是否已在配置文件和运行时申请。定位问题时不要只盯路径把权限链路也过一遍。6.3 同步接口在主线程跑大批量目录真的会卡listFileSync、statSync这些都是同步接口单个调用都在毫秒级但架不住文件数量多。我曾在模拟目录里放了三千多个文件直接在 UI 线程跑扫描掉帧非常明显部分设备上甚至直接 ANR。这个不是代码逻辑问题是设计问题。正规做法是把扫描任务放到TaskPoolimport { taskpool } from kit.ArkTS; Concurrent function scanInTask(dirPath: string): string { const briefs scanDirectory(dirPath); return JSON.stringify(briefs); } const task new taskpool.Task(scanInTask, /data/storage/el2/base/.../files); const result await taskpool.execute(task);这里用JSON.stringify把结果从后台线程传递回主线程再在主线程里反序列化成对象列表。注意自定义接口类型在跨线程传递时直接传对象可能因为序列化规则受限字符串传参是最稳的。6.4 读文件头时的 position 参数和缓冲区长度前面魔数识别里我特意写了{ offset: 0, length: 16, position: 0 }这个参数。实际开发中很多人会把offset和position搞混offset是相对于缓冲区首地址的写入偏移position是相对于文件开头的读取位置。如果漏传position实际行为会受当前文件内指针位置影响。尤其当你刚用同一个 fd 做过其他读取或 seek再读文件头时可能已经不在文件开头了。所以我的建议是每个需要固定从文件开头读取的调用都显式写position: 0不要依赖隐含行为。缓冲区长度给 16 字节在常见场景下足够覆盖 JPEG、PNG、PDF、ZIP 这些魔数如果你的识别表里包含更多类型比如既需要前 4 字节又需要偏移 8 字节的组合可以适当放大到 32 字节多读几个字节的成本可以忽略不计。6.5 无后缀文件、大小写后缀和目录项的处理后缀映射法有个天然盲区无后缀文件。像Makefile、LICENSE、.env这类文件lastIndexOf(.)的结果可能返回 0 或 -1处理不好会得到奇怪的extension。我的约定是文件名不以点开头的无后缀文件extension返回空字符串以点开头的隐藏文件如.gitignore也不要把.gitignore当成后缀。大小写问题也要统一处理。getExtension里我已经做了.toLowerCase()这样README.TXT和readme.txt映射结果一致不会被大小写坑到。另外扫目录判断出isDirectory后千万不要继续对目录做后缀映射和魔数识别否则不仅结果怪还白白多开一次文件句柄。写在文件操作之外这几年做文件类功能我越来越觉得“文件大小”和“文件类型”是两个最容易被低估的字段。它们看起来只是列表里一行小字背后却牵扯到数据隔离、权限模型、URI 转换、同步与异步的取舍甚至文件内容的真实识别。真要在不同 Android、iOS、鸿蒙平台上做统一体验这些东西就是最磨人的地方。如果你最近正好在处理鸿蒙的文件类需求不妨把上面的工具函数直接复制进项目试试尤其建议先把“魔数识别”和“URI 转路径”这两个能力沉淀到模块里——它们平时用不上但一旦遇到改后缀的文件、或者从系统选择器拿到的 URI能省下整整一下午排查时间。后面如果大家感兴趣我可以再写一篇关于 TaskPool 结合文件扫描做后台任务调度的完整案例那个才是文件列表在文件量大时能否保持流畅的关键。