uni-app实现Android大文件分片上传的实践方案
2026/9/23 9:52:08 网站建设 项目流程

1. 问题背景与核心挑战

在开发基于uni-app的Android应用时,文件上传是一个常见需求。当需要处理大文件上传时,分片上传(Chunked Upload)成为必备技术方案。然而,在Android 10(API 29)及更高版本系统中,随着存储访问权限的收紧,传统的文件路径处理方式会遇到严重兼容性问题。

核心问题具体表现为:当应用的targetSdkVersion设置为29或更高时,尝试直接访问通过uni.chooseImage等API获取的临时文件路径(如/storage/emulated/0/...)会失败。这是因为Android 10引入了Scoped Storage(分区存储)机制,限制应用直接访问外部存储路径。

提示:从Android 10开始,应用只能通过Storage Access Framework或MediaStore API访问共享存储空间中的特定类型文件,否则必须将文件复制到应用私有目录(App-specific directory)才能进行操作。

2. 整体解决方案设计

2.1 技术选型与架构

我们的解决方案基于以下技术栈:

  • uni-app:跨平台开发框架,使用Vue.js语法
  • HTML5+ Runtime:uni-app底层依赖的原生能力扩展
  • Java FileChannel:Android平台的高效文件操作类

整体流程分为四个关键阶段:

  1. 获取安全的文件路径(私有目录)
  2. 计算分片参数(大小、数量等)
  3. 使用FileChannel导出分片临时文件
  4. 通过uni.uploadFile上传分片并清理临时文件

2.2 与传统方案的对比

传统方案通常尝试直接使用FileReader.readAsArrayBuffer读取文件内容并切片,但在Android平台上存在明显缺陷:

方案优点缺点
Base64+ArrayBuffer纯前端实现,不依赖原生API内存占用高,大文件易OOM
FileWriter.write()简单直接存在数据膨胀问题,iOS兼容性差
FileChannel方案内存高效,性能稳定需要原生API支持,实现较复杂

3. 核心实现细节

3.1 安全路径处理

首先需要将用户选择的文件复制到应用私有目录。关键代码如下:

const ensurePrivateDocPath = (rawPath, extname = "") => { return new Promise((resolve, reject) => { if (!rawPath) return reject(new Error("文件路径为空")); const withPrefix = rawPath.startsWith("file://") ? rawPath : `file://${rawPath}`; // 已在私有目录则直接返回 if (/_doc\//.test(withPrefix)) { return resolve(withPrefix); } plus.io.resolveLocalFileSystemURL(withPrefix, (entry) => { plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { const newName = `upload_${Date.now()}${extname ? "." + extname.replace(/^\./, "") : ""}`; entry.copyTo(fs.root, newName, (newEntry) => { const url = typeof newEntry.toLocalURL === "function" ? newEntry.toLocalURL() : newEntry.fullPath; resolve(url.startsWith("file://") ? url : `file://${url}`); }, reject); }, reject); }, reject); }); };

注意事项:Android 10+设备上,必须确保所有文件操作都在应用私有目录(_doc/)内进行。plus.io.PRIVATE_DOC对应的是应用专属存储空间,不受Scoped Storage限制。

3.2 分片参数计算

分片上传需要精确计算每个分片的起始位置和大小:

const chunkSize = 5 * 1024 * 1024; // 5MB const chunks = Math.ceil(fileSize / chunkSize); for (let i = 0; i < chunks; i++) { const start = i * chunkSize; const endInclusive = Math.min(((i + 1) * chunkSize) - 1, fileSize - 1); const realChunkSize = Math.max(0, (endInclusive - start + 1)); // 构造分片参数 const payload = { chunk: i, chunks, chunkSize, realChunkSize, // ...其他元数据 }; }

参数说明:

  • chunkSize:固定分片大小(如5MB),用于后端校验
  • realChunkSize:实际分片大小(最后一片可能小于chunkSize)
  • start/endInclusive:分片在文件中的字节范围(包含结束位置)

3.3 FileChannel分片导出

核心的分片导出使用Android的FileChannel类,相比传统IO流具有更好性能:

export const exportAndroidChunkFile = ( originPath, start, length, tempFileName ) => { return new Promise((resolve, reject) => { // 导入Java类 const File = plus.android.importClass("java.io.File"); const FileInputStream = plus.android.importClass("java.io.FileInputStream"); const FileOutputStream = plus.android.importClass("java.io.FileOutputStream"); const FileChannel = plus.android.importClass("java.nio.channels.FileChannel"); const ByteBuffer = plus.android.importClass("java.nio.ByteBuffer"); // 创建输入输出流 const originFile = new File(absOrigin); const fis = new FileInputStream(originFile); const inChannel = fis.getChannel(); inChannel.position(actualStart); const targetFile = new File(absTargetPath); const fos = new FileOutputStream(targetFile, false); const outChannel = fos.getChannel(); // 使用直接内存缓冲区 const buffer = ByteBuffer.allocateDirect(64 * 1024); // 64KB缓冲区 let remaining = finalLength; while (remaining > 0) { buffer.clear(); const limit = Math.min(remaining, buffer.capacity()); buffer.limit(limit); const read = inChannel.read(buffer); if (read <= 0) break; buffer.flip(); const written = outChannel.write(buffer); remaining -= written; } outChannel.force(true); // 强制刷盘 // ...关闭资源 resolve(normalizeUploadFilePath(absTargetPath)); }); };

关键优化点:

  1. 直接内存缓冲区:通过ByteBuffer.allocateDirect()分配堆外内存,减少GC压力
  2. 固定大小缓冲区:64KB平衡了内存占用和IO效率
  3. 强制刷盘force(true)确保数据写入物理存储
  4. 精确位置控制position()limit()实现准确的分片范围

4. 完整上传流程实现

4.1 主流程代码

整合各环节的完整上传示例:

async function uploadFile(file) { // 1. 获取安全路径 const safePath = await ensurePrivateDocPath(file.tempFilePath); // 2. 准备上传参数 const fileSize = file.size; const chunkSize = 5 * 1024 * 1024; const chunks = Math.ceil(fileSize / chunkSize); // 3. 分片上传 for (let i = 0; i < chunks; i++) { const start = i * chunkSize; const end = Math.min((i + 1) * chunkSize, fileSize) - 1; const realChunkSize = end - start + 1; // 4. 导出分片临时文件 const tempName = `chunk_${i}_${Date.now()}`; const tempPath = await exportAndroidChunkFile( safePath, start, realChunkSize, tempName ); // 5. 上传分片 const uploadRes = await uni.uploadFile({ url: 'https://api.example.com/upload', filePath: tempPath, name: 'file', formData: { chunk: i, chunks, chunkSize, realChunkSize, fileName: file.name } }); // 6. 清理临时文件 try { plus.io.resolveLocalFileSystemURL(tempPath, (entry) => { entry.remove(() => {}, (e) => {}); }); } catch (e) {} } }

4.2 进度反馈实现

良好的用户体验需要实时上传进度反馈:

// 在for循环内添加: const progress = Math.round(((i + 1) / chunks) * 100); uni.showLoading({ title: `上传中 ${progress}%`, mask: true }); // 可选的更精细进度(基于实际字节传输) let transferred = 0; const progressInterval = setInterval(() => { const chunkProgress = Math.round((transferred / realChunkSize) * 100); const totalProgress = Math.round( (i * chunkSize + transferred) / fileSize * 100 ); console.log(`分片${i}进度: ${chunkProgress}%,总进度: ${totalProgress}%`); }, 500); // 在buffer写入循环中更新: transferred += written; // 上传完成后: clearInterval(progressInterval);

5. 关键问题与解决方案

5.1 Android高版本兼容性问题

问题现象

  • targetSdkVersion≥29时,直接访问外部存储路径失败
  • 错误信息包含"Permission denied"或"Exposed beyond app through Intent.getData()"

解决方案

  1. 将所有文件操作限制在应用私有目录(_doc/
  2. 使用plus.io.convertLocalFileSystemURL()处理路径转换
  3. 对于用户选择的文件,立即复制到私有目录

5.2 内存溢出(OOM)问题

问题现象

  • 使用readAsArrayBuffer时,大文件分片导致内存不足
  • 日志中出现"Java heap space"或"OutOfMemoryError"

解决方案

  1. 使用FileChannel替代内存缓冲
  2. 分片大小建议值:
    • 普通设备:2-5MB
    • 低内存设备:512KB-1MB
  3. 避免在JavaScript层处理二进制数据

5.3 分片大小不一致问题

问题现象

  • 后端校验失败,报告分片大小不符
  • 最后一片大小计算错误

解决方案

// 正确的分片结束位置计算 const endInclusive = Math.min( ((i + 1) * chunkSize) - 1, // 理论结束位置 fileSize - 1 // 文件实际结束位置 ); const realChunkSize = endInclusive - start + 1; // 包含结束位置

5.4 临时文件清理

最佳实践

  1. 每个分片上传后立即清理
  2. 添加上传失败的重试机制
  3. 应用启动时清理残留临时文件
// 启动时清理旧临时文件 plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { fs.root.getDirectory('temp_uploads', {}, (dir) => { const reader = dir.createReader(); reader.readEntries((entries) => { entries.forEach(entry => { if (entry.name.startsWith('a_chunk_')) { entry.remove(() => {}, (e) => {}); } }); }); }); });

6. 性能优化建议

6.1 上传并发控制

虽然分片可以并行上传,但需要平衡速度和设备性能:

// 示例:最大3个并发上传 const MAX_CONCURRENT = 3; let currentConcurrent = 0; const queue = []; async function processQueue() { while (queue.length > 0 && currentConcurrent < MAX_CONCURRENT) { currentConcurrent++; const task = queue.shift(); try { await task(); } catch (e) { console.error('上传失败:', e); } finally { currentConcurrent--; processQueue(); } } } // 将分片上传任务加入队列 for (let i = 0; i < chunks; i++) { queue.push(() => uploadChunk(i)); processQueue(); }

6.2 断点续传实现

通过本地存储记录上传状态:

// 获取已上传分片信息 const uploadedChunks = uni.getStorageSync(file.name) || []; async function uploadChunk(index) { if (uploadedChunks.includes(index)) return; // ...上传逻辑 // 上传成功后记录 uploadedChunks.push(index); uni.setStorageSync(file.name, uploadedChunks); } // 全部完成后清理记录 uni.removeStorageSync(file.name);

6.3 网络状态适配

根据网络类型动态调整分片大小:

function getDynamicChunkSize() { const networkType = uni.getNetworkType(); switch(networkType) { case 'wifi': return 5 * 1024 * 1024; // 5MB case '4g': return 1 * 1024 * 1024; // 1MB default: return 512 * 1024; // 512KB } }

7. 完整工具类封装

建议将核心功能封装为可复用的工具类:

// androidUploader.js export default { /** * 初始化上传器 * @param {Object} options * @param {Number} options.maxConcurrent 最大并发数 * @param {Function} options.onProgress 进度回调 */ init(options) { this.options = options; this.queue = []; this.activeCount = 0; }, /** * 添加上传任务 * @param {File} file 文件对象 * @param {String} uploadUrl 上传接口 */ async addTask(file, uploadUrl) { const task = { file, uploadUrl, chunks: [], uploaded: [] }; // 获取安全路径 task.safePath = await this._ensurePrivatePath(file); task.fileSize = file.size; task.chunkSize = this._getOptimalChunkSize(); task.totalChunks = Math.ceil(task.fileSize / task.chunkSize); // 创建分片任务 for (let i = 0; i < task.totalChunks; i++) { task.chunks.push({ index: i, start: i * task.chunkSize, end: Math.min((i + 1) * task.chunkSize, task.fileSize) - 1 }); } this.queue.push(task); this._processQueue(); }, // ...其他内部方法 };

使用示例:

import AndroidUploader from './androidUploader'; const uploader = AndroidUploader.init({ maxConcurrent: 3, onProgress: (progress) => { console.log('上传进度:', progress); } }); uni.chooseImage({ success: (res) => { uploader.addTask(res.tempFiles[0], 'https://api.example.com/upload'); } });

8. 实际应用中的经验总结

经过多个项目的实践验证,以下是关键经验点:

  1. 路径处理黄金法则

    • 所有文件操作前先调用normalizeUploadFilePath()
    • 永远不要假设路径是否包含file://前缀
    • Android 10+必须使用_doc/私有目录
  2. 分片大小选择

    • WiFi环境:3-5MB
    • 4G网络:1-2MB
    • 弱网环境:256-512KB
    • 测试表明:过小的分片(<100KB)反而降低整体吞吐量
  3. 内存管理要点

    • 避免在JavaScript层保留大文件引用
    • 每个分片处理完成后手动置空相关变量
    • 使用ByteBuffer.allocateDirect()而非普通缓冲区
  4. 调试技巧

    // 在关键节点添加详细日志 console.log(`分片${index}参数`, { originPath: originPath.replace(/file:\/\//, ''), start, length, tempFileName });
    • 使用Android Studio的Logcat查看原生层日志
    • 重点关注FileChannelposition()limit()
  5. 兼容性检查清单

    • [x] targetSdkVersion≥29的设备
    • [x] Android 10+的存储权限处理
    • [x] 各种文件扩展名测试(含无扩展名文件)
    • [x] 超大文件测试(>1GB)
    • [x] 网络切换测试(WiFi/4G切换)

9. 扩展思考:iOS适配方案

虽然本文聚焦Android实现,但iOS平台也有其特殊性:

  1. 关键差异点

    • iOS没有Scoped Storage限制
    • 但需要注意FileWriter.write(string)会导致数据膨胀
    • 推荐使用NSDataNSFileHandle进行文件操作
  2. 统一接口设计

    function exportChunkFile(platform, originPath, start, length, tempName) { if (platform === 'android') { return exportAndroidChunkFile(originPath, start, length, tempName); } else { return exportIosChunkFile(originPath, start, length, tempName); } }
  3. iOS实现要点

    • 使用NSFileHandleseekToFileOffsetreadDataOfLength
    • 避免将整个分片读入内存
    • 注意NSDataArrayBuffer的转换效率

10. 总结与资源推荐

通过本文介绍的技术方案,我们成功解决了uni-app在Android高版本系统上的分片上传难题。核心创新点包括:

  1. 安全的私有目录文件操作流程
  2. 基于FileChannel的高效分片导出
  3. 全面的异常处理和资源管理

进一步学习资源:

  • Android文件存储最佳实践
  • Java NIO FileChannel文档
  • uni-app原生插件开发指南

在实际项目中,建议将上传模块封装为独立的服务类,便于各页面复用。同时结合后台实现秒传、断点续传等高级特性,可以进一步提升用户体验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询