公司动态
uni-app Android文件操作实战:5+ IO模块解决跨平台路径与权限难题
1. 从一次文件读取失败说起为什么需要5plus的IO模块那天下午我正在调试一个基于uni-app开发的Android应用功能很简单让用户从手机里选一张图片上传到服务器。我信心满满地用了uni-app官方API里的uni.chooseImage在微信小程序和H5上跑得飞快。但一到Android真机上问题就来了——选是能选但拿到手的那个临时文件路径长这样file:///storage/emulated/0/Android/data/io.dcloud.HBuilder/apps/__UNI__XXXXXX/www/...。当我尝试用uni.uploadFile把这个路径的文件发出去时服务器那头收到的永远是个0字节的空文件。折腾了半天查文档、搜社区我才恍然大悟在uni-app的跨平台世界里“文件路径”这个概念在Web/H5/小程序端和真正的原生App尤其是Android端有着天壤之别。Web端基于浏览器沙盒路径是虚拟的而Android App运行在一个拥有严格文件系统权限的沙盒环境里特别是从Android 7.0API 24引入“作用域目录访问”和Android 10API 29的“分区存储”之后直接操作file://路径变得异常困难且不安全。我那uni.chooseImage返回的路径在App的上下文中很可能无法被其他模块如网络模块直接访问这就是上传失败的根源。这时5 Runtime和它的IO模块就登场了。它不是uni-app标准JS API的一部分而是DClouduni-app的开发商为增强原生能力提供的一套底层扩展。简单说当你的uni-app项目需要发行成Android或iOS的App即使用HBuilderX的“云打包”或“离线打包”时5 Runtime这个原生引擎就会被集成进去。它暴露了一个名为plus的全局对象其中plus.io就是专门用于解决上述原生文件操作难题的利器。它提供了一套接近原生开发体验的、异步的、基于URI统一资源标识符的文件系统API让你能安全、高效地在App沙盒内、外需权限进行文件的读取、写入、复制、移动、删除等操作。所以如果你正在开发一个uni-app的Android App并且遇到了以下任何一种情况那么深入理解并调用5plus的IO模块就是你绕不开的必修课需要读取用户通过系统文件选择器非uni.chooseImage选择的任意位置文件。需要将网络下载的文件或应用生成的数据如日志、缓存图片保存到设备的公共目录如DCIM、Download或私有目录。需要处理大文件的分片读取或写入避免界面卡顿。需要获取文件的详细信息如大小、修改时间、MIME类型。标准uni.*文件API无法满足更复杂的、原生级别的文件管理需求。2. 理解核心plus.io的URI体系与Android文件系统权限在直接写代码之前我们必须先打好两个基础概念否则后面的所有操作都将是空中楼阁。这也是很多新手直接拷贝代码却依然报错的核心原因。2.1 五种关键的URI类型plus.io不直接操作字符串路径而是通过一套URIUniform Resource Identifier方案来定位资源。理解每种URI的用途和限制至关重要。URI 类型格式示例对应物理路径大致用途与权限是否需要动态申请权限_www_www/res/icon.png/data/data/应用包名/apps/__UNI__XXXXXX/www/访问应用只读的本地HTML、JS、CSS、图片等资源。打包后存在于APK内。否_doc_doc/userData/config.json/data/data/应用包名/apps/__UNI__XXXXXX/documents/应用私有文档目录。可读写用户不可见除非root。适合存储敏感用户数据、数据库文件。否_documents_documents/MyApp/export.pdf/storage/emulated/0/Documents/或应用专属子目录Android的“文档”公共目录。从Android 10开始访问此目录特定位置需使用Storage Access Framework (SAF)。是READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE且Android 11受限_downloads_downloads/report.xlsx/storage/emulated/0/Download/公共下载目录。常用于保存用户下载的文件。是同上_www_www/res/icon.png/data/data/应用包名/apps/__UNI__XXXXXX/www/访问应用只读的本地HTML、JS、CSS、图片等资源。打包后存在于APK内。否file://file:///storage/emulated/0/DCIM/Camera/IMG.jpg设备上的绝对路径。最复杂、最不推荐直接使用。在Android不同版本上行为差异巨大极易导致权限错误和文件找不到。是且策略随系统版本变化核心提示对于App自己产生的、不希望被其他应用或用户轻易访问的数据优先使用_doc目录。对于需要与用户或其他应用共享的文件如下载的PDF、导出的图片再考虑使用_downloads等公共目录或通过plus.gallery.save保存到相册。2.2 Android存储权限的“时代变迁”你的代码能否运行很大程度上取决于目标设备的Android版本以及你在manifest.json中的配置。Android 5.1 ~ 9 (API 22-28)相对“宽松”的时代。在manifest.json中声明uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE /和写权限应用安装后首次访问时动态申请用户授权后即可相对自由地通过file://路径访问外部存储。Android 10 (API 29) ~ 至今“分区存储Scoped Storage”时代。游戏规则改变。_doc、_www等私有目录无需任何权限随意读写。媒体文件图片、视频、音频通过plus.gallery等特定API或SAF访问需要READ_MEDIA_IMAGES等精细权限或用户交互选择。其他公共目录如Download、Documents及任意file://路径极度受限。即使拥有READ/WRITE_EXTERNAL_STORAGE权限也无法直接通过路径访问。必须使用Storage Access Framework (SAF)即调用系统文件选择器plus.io.requestFileSystem或plus.android.invoke调用原生Intent让用户主动选择文件或目录并获得一个特殊的“内容URI”content://后续操作都基于这个URI。这也是为什么开头提到的uni.chooseImage路径可能失效的原因之一。实操配置在HBuilderX项目的manifest.json中{ app-plus: { distribute: { android: { permissions: [ // 如果需要访问公共媒体文件Android 13 uses-permission android:name\android.permission.READ_MEDIA_IMAGES\ /, uses-permission android:name\android.permission.READ_MEDIA_VIDEO\ /, uses-permission android:name\android.permission.READ_MEDIA_AUDIO\ /, // 如果需要访问公共目录如DownloadAndroid 10-12或适配旧版 uses-permission android:name\android.permission.READ_EXTERNAL_STORAGE\ /, uses-permission android:name\android.permission.WRITE_EXTERNAL_STORAGE\ /, // Android 8.0 安装APK需要 uses-permission android:name\android.permission.REQUEST_INSTALL_PACKAGES\ / ], // 关键适配Android 11 分区存储的旧版兼容性谨慎使用 useAndroidX: true, compileSdkVersion: 33, // 建议使用较高版本SDK编译 targetSdkVersion: 33 // 此值直接影响系统应用何种权限策略。设为29以上即启用分区存储。 } } } }重要抉择targetSdkVersion 29时系统会强制启用分区存储。如果你的应用有大量遗留代码依赖直接文件路径且短期内无法重构适配SAF可以暂时将其设为28或以下但这并非长久之计未来新设备上可能会受限。3. 实战演练五大核心文件操作场景与代码实现理论说得再多不如一行代码。下面我们针对最常见的五个场景给出完整的、可复现的代码示例和避坑指南。3.1 场景一将在线图片保存到应用私有目录_doc这是最安全、最常用的场景比如缓存用户头像、保存临时生成的图表。// 假设这是一个下载并保存图片的函数 async function downloadAndSaveImage(imageUrl, fileName) { // 1. 首先将网络图片下载为临时文件这里用uni.downloadFile模拟实际plus.io有plus.downloader const tempFilePath await new Promise((resolve, reject) { uni.downloadFile({ url: imageUrl, success: (res) { if (res.statusCode 200) { resolve(res.tempFilePath); // 注意这个tempFilePath是平台特定的临时路径 } else { reject(new Error(下载失败: ${res.statusCode})); } }, fail: reject }); }); // 2. 关键步骤将临时文件移动到应用的_doc目录 // 先解析临时文件路径为plus.io能识别的URL // uni.downloadFile在App端返回的路径可能是file://开头也可能是_doc等。 // 更通用的方法是使用plus.io.convertLocalFileSystemURL const srcURL plus.io.convertLocalFileSystemURL(tempFilePath); // 定义目标路径在_doc下的cached_images子目录 const dstURL _doc/cached_images/${fileName || Date.now()}.jpg; // 3. 使用plus.io.resolveLocalFileSystemURL获取文件对象 return new Promise((resolve, reject) { plus.io.resolveLocalFileSystemURL(srcURL, (entry) { // entry 是源文件对象 plus.io.resolveLocalFileSystemURL(_doc/cached_images/, (dirEntry) { // dirEntry 是目标目录对象如果目录不存在需要先创建 entry.copyTo(dirEntry, fileName, (copiedEntry) { console.log(文件保存成功:, copiedEntry.toLocalURL()); resolve(copiedEntry.toLocalURL()); // 返回新文件的本地URL }, (e) { // 如果目录不存在先创建目录 plus.io.resolveLocalFileSystemURL(_doc/, (rootEntry) { rootEntry.getDirectory(cached_images, { create: true }, (newDirEntry) { entry.copyTo(newDirEntry, fileName, (copiedEntry) { resolve(copiedEntry.toLocalURL()); }, reject); }, reject); }, reject); }); }, (e) { // 目录不存在创建之 plus.io.resolveLocalFileSystemURL(_doc/, (rootEntry) { rootEntry.getDirectory(cached_images, { create: true }, (newDirEntry) { entry.copyTo(newDirEntry, fileName, (copiedEntry) { resolve(copiedEntry.toLocalURL()); }, reject); }, reject); }, reject); }); }, reject); }); } // 使用示例 // downloadAndSaveImage(https://example.com/avatar.jpg, user_avatar.jpg).then(savedPath { // console.log(最终保存路径:, savedPath); // 类似 file:///data/data/your.package/... // // 这个路径可以用于后续的读取、显示如用image :srcsavedPath或上传。 // });避坑点uni.downloadFile的成功回调中的tempFilePath在App端不一定是_doc或_www下的路径它位于一个系统管理的临时目录。这个目录可能被系统清理所以必须尽快将其移动到持久化目录如_doc。plus.io.convertLocalFileSystemURL()方法是将各种路径格式转换为标准本地URL的神器建议在操作前对路径进行转换。操作_doc目录不需要任何权限但操作前最好确保目录存在。上述代码演示了如何安全地创建子目录。3.2 场景二从应用私有目录读取JSON配置文件并解析假设我们在_doc/config/目录下有一个settings.json文件。function readConfigFile() { return new Promise((resolve, reject) { const configURL _doc/config/settings.json; plus.io.resolveLocalFileSystemURL(configURL, (entry) { // entry是一个FileEntry对象 entry.file((file) { // file是一个File对象包含了文件的二进制数据 const reader new plus.io.FileReader(); reader.onloadend function(evt) { // 读取完成 if (evt.target.result ! undefined) { try { const configObj JSON.parse(evt.target.result); resolve(configObj); } catch (e) { reject(new Error(配置文件JSON解析失败: e.message)); } } else { reject(new Error(文件读取结果为空)); } }; reader.onerror function(e) { reject(new Error(文件读取失败: e.message)); }; // 以文本形式读取文件 reader.readAsText(file); }, (error) { // 文件不存在或其他错误 console.warn(配置文件不存在将使用默认配置, error); // 可以在这里创建默认配置文件 createDefaultConfig().then(resolve).catch(reject); }); }, (error) { // 路径解析失败可能是目录不存在 console.warn(配置目录不存在尝试创建, error); createDefaultConfig().then(resolve).catch(reject); }); }); } function createDefaultConfig() { return new Promise((resolve, reject) { const defaultConfig { theme: light, fontSize: 14 }; const configContent JSON.stringify(defaultConfig, null, 2); const configURL _doc/config/settings.json; // 先确保目录存在 plus.io.resolveLocalFileSystemURL(_doc/, (rootEntry) { rootEntry.getDirectory(config, { create: true }, (dirEntry) { dirEntry.getFile(settings.json, { create: true }, (fileEntry) { fileEntry.createWriter((writer) { writer.onwriteend function(evt) { console.log(默认配置文件创建成功); resolve(defaultConfig); }; writer.onerror function(e) { reject(new Error(写入默认配置失败: e.toString())); }; // 创建Blob对象并写入 const blob new plus.io.Blob([configContent], { type: application/json }); writer.write(blob); }, reject); }, reject); }, reject); }, reject); }); } // 使用示例 // readConfigFile().then(config { // console.log(应用配置:, config); // // 更新UI或逻辑 // }).catch(err { // console.error(读取配置失败:, err); // // 使用硬编码默认值 // });避坑点plus.io.FileReader的API与Web标准FileReader类似但它是5 Runtime提供的对象务必从plus.io模块获取。读取操作是异步的一定要在onloadend或onerror事件回调中处理结果。文件或目录可能不存在代码中必须包含完善的错误处理onerror回调和降级方案如创建默认文件。3.3 场景三使用系统文件选择器SAF让用户选择任意文件这是适配Android高版本10分区存储的标准做法。我们不能直接让用户输入路径而是唤起系统UI。function pickFileWithSAF() { return new Promise((resolve, reject) { // 注意此方法依赖于5 Runtime的扩展并非所有版本都完全支持所有参数。 // 更底层、更可控的方式是使用plus.android.invoke调用原生Intent但更复杂。 // 方法1使用plus.io.requestFileSystem (较通用) // 这里演示选择单个文件类型为所有文件 plus.io.requestFileSystem( plus.io.PUBLIC_DOCUMENTS, function(fs){ // fs.root 是根目录Entry但通常我们用这个回调来触发文件选择器 // 实际上requestFileSystem在获取到文件系统后我们可以再调用其getFile等方法。 // 但对于让用户选择任意文件更直接的方法是 plus.io.requestFileSystem( plus.io.PRIVATE_DOC, function(fs){ // 这个回调仅表示文件系统可用并非文件选择器。 // 真正唤起选择器需要别的API。5的API在这里有些模糊。 // 因此对于复杂的SAF需求推荐方法2。 reject(new Error(此方法无法直接唤起系统文件选择器请使用方法2)); }); }); // 方法2通过WebView桥接调用原生Intent推荐更直接 // 此方法需要一定的原生知识并确保在plusready后调用 document.addEventListener(plusready, function() { const Intent plus.android.importClass(android.content.Intent); const Activity plus.android.runtimeMainActivity(); const intent new Intent(Intent.ACTION_GET_CONTENT); intent.setType(*/*); // 所有文件类型也可指定 image/*, application/pdf等 intent.addCategory(Intent.CATEGORY_OPENABLE); // 启动Activity等待结果 Activity.startActivityForResult(intent, 1001, function(resultCode, data) { if (resultCode Activity.RESULT_OK data) { const uri data.getData(); const uriString uri.toString(); // 得到一个 content:// 开头的URI console.log(用户选择的文件URI:, uriString); // 现在你拿到了一个content:// URI不能直接用plus.io操作。 // 需要将其转换为本地路径或直接使用。 // 可以使用plus.android.invoke调用ContentResolver打开输入流。 // 这是一个更进阶的操作通常用于直接读取文件内容。 // 如果只是想获取这个文件用于上传现代网络库如uni.uploadFile可能支持content:// URI。 // 但为了兼容性通常将其复制到应用私有目录(_doc)再处理。 resolve({ uri: uriString, nativeIntent: true }); } else { reject(new Error(用户取消选择或选择失败)); } }); }, false); }); } // 简化版使用5的plus.gallery.pick仅限于媒体文件 function pickImage() { return new Promise((resolve, reject) { plus.gallery.pick(function(e) { // e.files 是选中的文件路径数组通常是file://格式但高版本上可能是content:// const filePath e.files[0]; if (filePath) { // 同样建议转换为本地URL并复制到_doc const localURL plus.io.convertLocalFileSystemURL(filePath); resolve(localURL); } else { reject(new Error(未选择文件)); } }, function(e) { reject(new Error(选择失败: JSON.stringify(e))); }, { filter: image, // 筛选图片 multiple: false, system: true // 使用系统选择器而非5内置 }); }); }避坑点plus.io.requestFileSystem的文档和实际行为在高版本Android上可能不符合预期它主要用于获取已知目录的FileSystem对象而非直接唤起系统文件选择器。最可靠、最面向未来的方式是使用plus.android.invoke调用原生Intent.ACTION_GET_CONTENT或Intent.ACTION_OPEN_DOCUMENT。这需要一些Android开发知识但一劳永逸。通过SAF获取到的是content://URI不能直接当作文件路径使用。你需要使用ContentResolver来打开流读取数据或者尝试将其传递给支持content://URI的API如某些图片加载库或经过适配的uni.uploadFile。一个常见的兼容性做法是获取content://URI后立即通过ContentResolver读取其输入流并将数据写入到_doc目录下的一个临时文件中后续所有操作都基于这个临时文件进行。这虽然多了一步拷贝但保证了代码在所有场景下的统一性。3.4 场景四将应用内的文件保存到公共下载目录这是一个需要动态权限的典型场景。async function saveToPublicDownloads(sourceFileLocalURL, displayName) { // 0. 检查并申请存储权限Android 6.0 const hasPermission await checkAndRequestStoragePermission(); if (!hasPermission) { throw new Error(用户拒绝了存储权限无法保存到公共目录); } // 1. 解析源文件 const srcEntry await new Promise((resolve, reject) { plus.io.resolveLocalFileSystemURL(sourceFileLocalURL, resolve, reject); }); // 2. 获取或创建目标目录Entry这里以Download目录为例 // 注意plus.io.PUBLIC_DOWNLOADS 常量可能不存在我们使用URL字符串。 // 在高版本Android上直接操作_downloads可能失败更推荐使用SAF让用户选择保存位置。 // 以下代码在targetSdkVersion 29时可能有效。 const dstDirURL _downloads/MyAppExports/; // 尝试在Download下创建子文件夹 let dstDirEntry; try { dstDirEntry await new Promise((resolve, reject) { plus.io.resolveLocalFileSystemURL(dstDirURL, resolve, (err) { // 目录不存在尝试创建 plus.io.resolveLocalFileSystemURL(_downloads/, (downloadsRoot) { downloadsRoot.getDirectory(MyAppExports, { create: true }, resolve, reject); }, reject); }); }); } catch (dirErr) { console.error(无法访问或创建Download子目录可能无权限或系统限制Android 10。将尝试直接保存到Download根目录。, dirErr); // 降级方案直接保存到_downloads根目录如果系统允许 dstDirEntry await new Promise((resolve, reject) { plus.io.resolveLocalFileSystemURL(_downloads/, resolve, reject); }); } // 3. 复制文件 return new Promise((resolve, reject) { srcEntry.copyTo(dstDirEntry, displayName, (copiedEntry) { console.log(文件已保存至公共目录:, copiedEntry.toLocalURL()); // 可选发送广播通知系统媒体扫描器使文件在图库/文件管理器中立即可见 notifyMediaScanner(copiedEntry.toLocalURL()); resolve(copiedEntry.toLocalURL()); }, (copyErr) { console.error(复制到公共目录失败:, copyErr); // 最终降级方案如果连复制都失败提示用户手动保存 reject(new Error(保存失败。请尝试手动将文件从应用内部存储移至手机下载文件夹。原文件位置${sourceFileLocalURL})); }); }); } // 检查并申请存储权限简化版实际需处理更多细节 function checkAndRequestStoragePermission() { return new Promise((resolve) { // 使用5的权限API plus.android.requestPermissions([android.permission.WRITE_EXTERNAL_STORAGE], function(result) { // result 是一个对象需要检查每个权限的授权状态 const granted Object.values(result).every(grant grant 0); // 0表示授权 resolve(granted); }, function(error) { console.error(申请权限出错:, error); resolve(false); }); }); } // 通知媒体扫描器仅对图片、视频、音频等媒体文件有效 function notifyMediaScanner(filePath) { const Context plus.android.importClass(android.content.Context); const Intent plus.android.importClass(android.content.Intent); const Uri plus.android.importClass(android.net.Uri); const File plus.android.importClass(java.io.File); const context plus.android.runtimeMainActivity(); const file new File(filePath.replace(file://, )); const uri Uri.fromFile(file); const mediaScanIntent new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); mediaScanIntent.setData(uri); context.sendBroadcast(mediaScanIntent); }避坑点权限是前置条件在Android 6.0上即使manifest.json中声明了权限也必须动态申请。plus.android.requestPermissions是5提供的申请方法。高版本Android10的“死亡之握”即使拥有WRITE_EXTERNAL_STORAGE权限App也无法随意在/storage/emulated/0/Download/下创建文件除非你的targetSdkVersion 29或者该目录是应用专属的如Android/data/your.package/files/Download。对于真正公共的Download目录最合规的方式是使用SAF的Intent.ACTION_CREATE_DOCUMENT或Intent.ACTION_OPEN_DOCUMENT_TREE让用户指定保存位置。上面的代码在targetSdkVersion 29时很可能失败。notifyMediaScanner仅对媒体文件有效对于PDF、TXT等文档系统文件管理器可能需要一段时间刷新或手动下拉刷新才能看到。3.5 场景五递归遍历并删除私有缓存目录定期清理缓存是良好的开发习惯。function clearCacheDirectory(dirURL, excludePatterns []) { return new Promise((resolve, reject) { plus.io.resolveLocalFileSystemURL(dirURL, (dirEntry) { const directoryReader dirEntry.createReader(); const entriesToRemove []; // 定义一个递归读取目录的函数 function readEntries() { directoryReader.readEntries((entries) { if (entries.length 0) { entries.forEach(entry { // 检查是否在排除模式中例如不想删除的配置文件 const shouldExclude excludePatterns.some(pattern { if (pattern instanceof RegExp) { return pattern.test(entry.name); } return entry.name pattern; }); if (!shouldExclude) { entriesToRemove.push(entry); } // 如果是子目录需要递归这里简化处理先收集再删除 // 更严谨的做法是递归进入子目录收集其下的文件。 }); // 继续读取直到没有更多条目 readEntries(); } else { // 所有条目已读取完毕开始删除 deleteEntriesSequentially(entriesToRemove, 0, resolve, reject); } }, (error) { reject(new Error(读取目录条目失败: error.message)); }); } readEntries(); }, (error) { // 目录不存在视为清理完成 console.log(目录不存在无需清理:, dirURL); resolve(); }); }); } function deleteEntriesSequentially(entries, index, resolve, reject) { if (index entries.length) { console.log(所有指定条目已删除); resolve(); return; } const entry entries[index]; if (entry.isDirectory) { // 如果是目录递归删除其内容后再删除自身 clearCacheDirectory(entry.toLocalURL()).then(() { entry.remove(() { deleteEntriesSequentially(entries, index 1, resolve, reject); }, (dirErr) { console.warn(删除目录失败 ${entry.name}:, dirErr); // 即使删除目录失败也继续尝试下一个 deleteEntriesSequentially(entries, index 1, resolve, reject); }); }).catch(err { console.error(清理子目录 ${entry.name} 失败:, err); deleteEntriesSequentially(entries, index 1, resolve, reject); }); } else { // 如果是文件直接删除 entry.remove(() { deleteEntriesSequentially(entries, index 1, resolve, reject); }, (fileErr) { console.warn(删除文件失败 ${entry.name}:, fileErr); // 继续下一个 deleteEntriesSequentially(entries, index 1, resolve, reject); }); } } // 使用示例清理_doc下的cache文件夹但保留一个config.json文件 // clearCacheDirectory(_doc/cache/, [config.json]).then(() { // uni.showToast({ title: 缓存清理完成 }); // }).catch(err { // uni.showToast({ title: 清理失败, icon: none }); // console.error(err); // });避坑点directoryReader.readEntries()方法不会一次性返回目录下的所有条目它可能分批次返回。因此需要用递归或循环来读取所有条目直到返回的数组长度为0。上述代码展示了递归读取的标准模式。文件系统操作是异步且可能失败的例如文件被占用。代码中采用了“尽力而为”的策略即使某个文件/目录删除失败也继续尝试后面的并在最后统一报告完成。根据你的需求也可以改为“一个失败就整体失败”。删除操作是不可逆的。务必在操作前让用户确认尤其是当目录路径可能由用户输入时。对于关键数据建议先移动到“回收站”文件夹另一个_doc下的目录定期真正清理。4. 进阶技巧与性能优化掌握了基本操作后下面这些技巧能让你在实战中更游刃有余。4.1 大文件操作与进度反馈直接使用entry.file()读取大文件如几十MB的视频到内存可能会导致内存溢出OOM和界面卡死。正确的做法是使用FileReader的readAsArrayBuffer或readAsBinaryString分段读取或者使用FileWriter分段写入。function readLargeFileInChunks(fileEntry, chunkSize 1024 * 1024) { // 默认1MB一块 return new Promise((resolve, reject) { fileEntry.file((file) { const totalSize file.size; let offset 0; const chunks []; const reader new plus.io.FileReader(); reader.onloadend function(evt) { if (evt.target.result ! undefined) { chunks.push(evt.target.result); // result 是 ArrayBuffer offset chunkSize; // 计算并更新进度假设有一个更新UI的函数updateProgress const progress Math.min(offset, totalSize) / totalSize; console.log(读取进度: ${(progress * 100).toFixed(1)}%); // updateProgress(progress); if (offset totalSize) { // 继续读取下一块 readNextChunk(); } else { // 全部读取完成 console.log(文件分块读取完成); // 如果需要合并可以在这里处理但通常分块上传或处理不需要合并。 resolve({ chunks, totalSize }); } } else { reject(new Error(读取数据块时发生错误)); } }; reader.onerror reject; function readNextChunk() { const blob file.slice(offset, offset chunkSize); reader.readAsArrayBuffer(blob); } // 开始读取第一块 readNextChunk(); }, reject); }); }对于写入可以使用FileWriter的write方法多次写入Blob对象。关键在于将大文件数据分片成Blob。4.2 路径转换的“黑盒”plus.io.convertLocalFileSystemURL这个方法是处理不同路径格式混乱局面的瑞士军刀。它接受一个字符串路径并尝试返回一个标准的、plus.io能稳定识别的本地URL。输入可以是file://路径、_www等相对路径、content://URI部分情况、甚至是http://网络路径会下载到本地缓存并返回缓存路径。输出一个以file://或_开头的、指向本地文件的URL。经验法则在任何plus.io.resolveLocalFileSystemURL()调用之前如果对你的路径格式不确定先调用const safeURL plus.io.convertLocalFileSystemURL(yourPath);。4.3 错误处理与调试心得5 IO模块的错误回调通常返回一个FileError对象它有一个code属性。以下是常见错误码及其含义错误码常量 (plus.io.*)值含义NOT_FOUND_ERR1未找到文件或目录SECURITY_ERR2安全错误如无权限ABORT_ERR3操作被中止NOT_READABLE_ERR4文件不可读ENCODING_ERR5编码错误NO_MODIFICATION_ALLOWED_ERR6不允许修改如只读文件系统INVALID_STATE_ERR7无效状态SYNTAX_ERR8语法错误如无效URLINVALID_MODIFICATION_ERR9无效修改如将目录复制到其自身子目录QUOTA_EXCEEDED_ERR10超出配额TYPE_MISMATCH_ERR11类型不匹配如对文件调用getDirectoryPATH_EXISTS_ERR12路径已存在调试技巧真机调试文件操作的问题在模拟器上可能无法复现务必使用真机调试。通过HBuilderX的“真机运行”连接手机在console中打印路径和错误信息。使用adb shell对于Android你可以通过adb shell命令连接到手机查看应用私有目录(/data/data/your.package/)和SD卡目录下的文件是否按预期创建。这能帮你确认操作是否真的执行成功。adb shell run-as your.package.name # 进入应用数据目录需要debug包 ls -la apps/__UNI__XXXXXX/documents/ # 查看_doc内容日志记录在关键操作步骤如解析路径、开始读写、完成回调添加详细的console.log输出当时的URL、Entry类型等。错误回调中一定要把整个错误对象JSON.stringify(e)打印出来。5. 避坑大全那些年我踩过的文件操作“天坑”结合我自己的项目和社区常见问题这里汇总了几个高频坑点。坑1异步回调地狱与Promise封装5的IO API大量使用回调函数嵌套多了就是“回调地狱”。强烈建议在项目初期就将核心API用Promise封装起来如上文示例所示。甚至可以进一步封装成统一的FileService类管理所有文件操作让业务代码更清晰。坑2targetSdkVersion的“魔力”这是Android权限策略的开关。在manifest.json里把它设成28和33你的应用在Android 13设备上收到的权限挑战是完全不同的。在开发初期就要确定好你的存储策略是坚持旧路径模式targetSdkVersion 28还是全面拥抱分区存储和SAFtargetSdkVersion 29。混合模式会带来巨大的维护成本。坑3content://URI的“短命”通过SAF文件选择器获取的content://URI可能只是一个临时授权。不要长期保存这个URI字符串并在下次启动时直接使用因为它可能已经失效。正确的做法是一旦拿到content://URI立即读取其内容并保存到_doc目录或者使用takePersistableUriPermission()尝试获取持久化权限这需要Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION在5中实现更复杂。坑4iOS与Android的差异本文聚焦Android但别忘了uni-app是跨平台的。5 IO模块在iOS上同样可用但行为有差异iOS没有_downloads这样的公共目录常量。保存文件到“共享”位置通常使用plus.ios.saveToAlbum保存到相册或使用plus.share分享到其他App。iOS的文件系统沙盒更严格应用间文件隔离更彻底。_doc目录是唯一可靠的、可持久化的私有读写空间。在封装文件工具函数时一定要用uni.getSystemInfoSync().platform判断平台进行条件编译或运行时分支处理。坑5HBuilderX版本与基座不同版本的HBuilderX和自定义基座其内置的5 Runtime版本可能不同某些API的行为或有细微差别。在云打包前务必使用最新版本的自定义调试基座进行完整测试。遇到诡异问题可以尝试更新HBuilderX和基座。最后文件操作是App开发中最基础也最易出错的部分之一。在uni-app的跨平台语境下又叠加了原生与Web的差异。我的经验是对于内部数据坚定不移地用_doc目录对于需要出沙盒的数据尽早设计基于SAF系统文件选择器的用户交互流程永远对路径持有怀疑态度用convertLocalFileSystemURL进行标准化并且做好详尽的错误处理和用户提示。把这些原则贯彻到代码中就能避开大多数明枪暗箭构建出稳定可靠的文件管理功能。