1. 项目概述:为什么非媒体文件处理是个“技术活”?
在Uniapp开发Android应用时,处理图片、视频上传下载是家常便饭,uni.uploadFile和uni.downloadFile这两个API用起来也相当顺手。但一旦需求变成“上传一份PDF合同”或者“下载一个.zip压缩包到本地指定目录”,很多开发者就会发现,事情开始变得棘手。这不仅仅是调用一个API那么简单,它涉及到文件系统的权限博弈、不同Android版本的路径“玄学”、后台服务的兼容性,以及如何让用户能像在电脑上一样方便地找到和管理这些文件。
我最近刚完成一个企业办公类的Android应用,核心功能就是让销售人员在客户现场用平板签署电子合同(PDF),并上传回服务器,同时也能下载公司下发的产品资料包(ZIP)。踩了一路的坑之后,我意识到,非媒体文件的处理,恰恰是检验一个Uniapp应用是否足够“原生”、体验是否流畅的关键环节。它要求开发者不仅要懂前端,还得对Android的文件系统、权限模型和Intent机制有深入的理解。这篇文章,我就把自己从选型、开发到上线调试的全过程梳理一遍,重点分享那些官方文档里不会写的“坑”和“技巧”,目标是让你看完后,能独立搞定任何非媒体文件的上传与下载需求。
2. 核心思路与方案选型:为什么不用uni.uploadFile?
当接到“上传PDF”的需求时,第一反应可能是直接用
uni.uploadFile
。但实测下来,对于非媒体文件,尤其是用户从手机存储中任意位置选择的文件,这条路往往走不通。
2.1 标准API的局限性分析
uni.uploadFile
在设计上主要服务于媒体文件(图片、视频),其
filePath
参数通常依赖于
uni.chooseImage
或
uni.chooseVideo
等选择器API返回的临时路径。这些路径形如
http://tmp/xxx.jpg
,位于应用的内置缓存目录,系统有明确的访问权限。然而,当用户通过系统文件选择器(比如用
uni.chooseFile
)选择了一个位于
/storage/emulated/0/Documents/contract.pdf
的文件时,你得到的路径是一个
file://
或
content://
协议的URI。在Android高版本(特别是Android 10及以上)的沙盒机制(Scoped Storage)下,应用无法直接通过
file://
路径访问其他应用创建的文件,
uni.uploadFile
在处理这类路径时极易失败,报错“文件不存在”或“无权限访问”。
2.2 分而治之的混合方案
因此,一个稳健的方案必须采用“分而治之”的策略,针对上传和下载的不同特点,组合使用不同的技术:
-
上传方案:
uni.chooseFile+ 文件读取 + 自定义请求-
文件选择
:使用
uni.chooseFile(HBuilderX 3.4.0+)让用户选择文件。注意,要配置extension参数过滤文件类型,如['.pdf', '.doc', '.docx', '.zip'],提升用户体验。 -
文件读取
:选择成功后,得到的是一个
File对象或临时路径。这里的关键是使用uni.getFileSystemManager().readFile()将这个文件读取为ArrayBuffer或Base64格式。这一步相当于把文件内容“装载”到应用内存中,绕过了直接访问原始路径的权限问题。 -
自定义上传
:将读取到的二进制数据,通过
uni.request以multipart/form-data格式或二进制流(ArrayBuffer)的形式发送到服务器。你需要手动构建请求体,设置正确的Content-Type头部(如application/pdf)。
-
文件选择
:使用
-
下载方案:
uni.downloadFile+ 文件保存API-
文件下载
:
uni.downloadFile在这里依然可用,因为它是从网络下载到应用的临时缓存目录,这个过程不受沙盒限制。 -
关键保存
:下载成功后的临时路径,必须使用
uni.saveFile(小程序习惯)或更底层的uni.getFileSystemManager().saveFileAPI,将文件保存到用户可访问的公共目录,如Downloads(下载)或Documents(文档)目录。仅仅下载而不保存,文件可能在清理缓存时丢失,且用户无法在系统文件管理器中找到。
-
文件下载
:
注意 :
uni.saveFile在App端保存到的默认目录是应用私有目录,用户不可见。因此,在Android端,我们通常需要先获取公共目录路径,再使用文件系统API进行保存。
2.3 工具选型考量
-
UI组件
:对于文件选择,除了官方API,也可以使用
uni-file-picker组件,它集成了选择、预览、上传状态显示,能节省不少开发时间。 -
后端配合
:与后端沟通好上传接口,确保其能接收
multipart/form-data格式的二进制文件流,并返回包含文件唯一标识(如URL或ID)的响应,用于后续的下载。
3. 核心实现细节与避坑指南
理论清晰了,我们进入实战环节。每一步都有细节需要注意,这里是我踩过坑后的经验总结。
3.1 上传功能的完整实现与权限获取
上传的第一步是让用户选文件。在
pages.json
中对应页面的
style
配置里,添加
"app-plus": {"androidPermission": ["READ_EXTERNAL_STORAGE"]}
。从Android 6.0开始,还需要在应用首次请求时动态申请权限。我们可以封装一个权限检查函数:
// utils/permission.js
export const checkAndRequestReadStorage = () => {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
plus.android.requestPermissions(
['android.permission.READ_EXTERNAL_STORAGE'],
(e) => {
if (e.deniedAlways.length > 0) {
// 被永久拒绝,引导用户去设置页打开
uni.showModal({
title: '提示',
content: '需要文件读取权限以选择合同等文件,请前往应用设置中开启权限。',
showCancel: true,
success(res) {
if (res.confirm) {
plus.runtime.openURL(appSettings);
}
}
});
reject(new Error('权限被永久拒绝'));
} else if (e.denied.length > 0) {
// 本次拒绝
reject(new Error('权限被拒绝'));
} else {
// 已授权
resolve();
}
},
(err) => {
reject(err);
}
);
// #endif
// #ifndef APP-PLUS
resolve(); // 非App端直接通过
// #endif
});
};
接下来是核心的文件选择和上传代码:
// pages/upload/upload.vue
methods: {
async chooseAndUploadFile() {
try {
// 1. 检查并申请权限
await checkAndRequestReadStorage();
// 2. 选择文件
const [fileRes] = await uni.chooseFile({
count: 1,
type: 'all',
extension: ['.pdf', '.doc', '.docx', '.xlsx', '.zip', '.rar'],
});
const file = fileRes; // file 对象包含 name, path, size 等信息
console.log('选中文件:', file.name, '路径:', file.path);
// 3. 读取文件为 ArrayBuffer (关键步骤!)
const fs = uni.getFileSystemManager();
const arrayBuffer = await new Promise((resolve, reject) => {
fs.readFile({
filePath: file.path,
encoding: 'binary', // 指定为二进制
success: (res) => resolve(res.data),
fail: reject
});
});
// 4. 构建 FormData 并上传
const formData = {
fileName: file.name,
fileSize: file.size,
// 其他业务字段...
};
// 将 ArrayBuffer 转换为可用于 FormData 的格式
// 注意:uni.request 的 data 不支持直接传 ArrayBuffer,需要转换
// 一种常见做法是让后端接口接收 base64,或者使用 uni.uploadFile(但需处理路径问题)
// 更通用的方法是使用 uni.request 发送 multipart/form-data,但需要正确构建
// 这里演示一个将ArrayBuffer作为二进制体发送的简化方案(需后端配合)
const uploadTask = uni.request({
url: 'https://your-api.com/upload',
method: 'POST',
header: {
'Content-Type': 'application/octet-stream', // 二进制流
'X-File-Name': encodeURIComponent(file.name) // 文件名通过header传递
},
data: arrayBuffer, // 直接发送 ArrayBuffer
success: (uploadRes) => {
if (uploadRes.statusCode === 200) {
uni.showToast({ title: '上传成功' });
// 处理成功响应,获取文件ID或URL
console.log('服务器返回:', uploadRes.data);
} else {
uni.showToast({ title: `上传失败: ${uploadRes.statusCode}`, icon: 'none' });
}
},
fail: (err) => {
uni.showToast({ title: '网络请求失败', icon: 'none' });
console.error(err);
}
});
// 可选:监听上传进度
// uploadTask.onProgressUpdate((res) => {
// console.log('上传进度:' + res.progress + '%');
// });
} catch (error) {
console.error('上传过程出错:', error);
uni.showToast({ title: `操作失败: ${error.message}`, icon: 'none' });
}
}
}
实操心得 :
uni.chooseFile返回的file.path在Android上可能是content://开头的URI。readFileAPI能很好地处理这种URI,将其内容读出来。这是绕过Scoped Storage限制的关键。如果后端要求multipart/form-data格式,你需要更复杂地构建请求体,可以使用new Blob([arrayBuffer])(在条件编译中处理)或寻找支持直接发送FormData的插件。
3.2 下载与保存到用户可见目录
下载功能相对直接,但保存到正确的位置是重点。
// pages/download/download.vue
methods: {
async downloadAndSaveFile(fileUrl, fileName) {
uni.showLoading({ title: '下载中...', mask: true });
try {
// 1. 下载到临时目录
const downloadRes = await uni.downloadFile({
url: fileUrl,
});
if (downloadRes.statusCode !== 200) {
throw new Error(`下载失败,状态码: ${downloadRes.statusCode}`);
}
const tempFilePath = downloadRes.tempFilePath;
console.log('临时文件路径:', tempFilePath);
// 2. 获取公共下载目录路径 (Android)
// #ifdef APP-PLUS
const os = plus.os.name;
let saveDir = '';
if (os === 'Android') {
// Android 使用 plus.android.invoke 调用原生方法获取公共目录
const Context = plus.android.importClass('android.content.Context');
const main = plus.android.runtimeMainActivity();
const downloadsDir = main.getSystemService(Context.DOWNLOAD_SERVICE);
// 注意:直接获取DOWNLOAD_SERVICE的路径比较复杂,更通用的方法是使用Environment
const Environment = plus.android.importClass('android.os.Environment');
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
saveDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getPath();
} else {
// 无外部存储,使用应用私有目录
saveDir = plus.io.convertLocalFileSystemURL('_downloads/'); // 应用私有下载目录
}
} else {
// iOS 可以使用 plus.io.PUBLIC_DOWNLOADS
saveDir = plus.io.PUBLIC_DOWNLOADS;
}
// #endif
// 3. 构建最终保存路径
const finalPath = saveDir + '/' + fileName;
console.log('目标保存路径:', finalPath);
// 4. 保存文件
const fs = uni.getFileSystemManager();
await new Promise((resolve, reject) => {
fs.saveFile({
tempFilePath: tempFilePath,
filePath: finalPath,
success: (saveRes) => {
console.log('文件保存成功,永久路径:', saveRes.savedFilePath);
uni.hideLoading();
uni.showModal({
title: '下载完成',
content: `文件已保存至: ${saveRes.savedFilePath}\n是否立即查看?`,
success: (modalRes) => {
if (modalRes.confirm) {
// 5. 用系统能力打开文件
plus.runtime.openFile(saveRes.savedFilePath, {}, (e) => {
uni.showToast({ title: '未找到打开此文件的应用', icon: 'none' });
});
}
}
});
resolve();
},
fail: (err) => {
console.error('保存失败:', err);
reject(new Error('文件保存失败,请检查存储空间和权限'));
}
});
});
} catch (error) {
uni.hideLoading();
console.error('下载保存全过程出错:', error);
uni.showToast({ title: `操作失败: ${error.message}`, icon: 'none' });
}
}
}
避坑指南 :
uni.downloadFile的临时文件有效期不确定, 务必立即处理 (保存或使用)。保存时,saveFile的filePath参数在某些版本下可能不支持绝对路径,建议先使用plus.io.convertLocalFileSystemURL将平台绝对路径转换为Uniapp可识别的URL格式,或者直接使用相对路径(如'_downloads/myfile.pdf')保存到应用私有目录,再通过uni.openDocument或plus.runtime.openFile打开。对于需要让用户在文件管理器中看到的文件,引导用户使用“分享”或“另存为”功能到公共目录是更通用的做法。
4. 平台差异与深度适配策略
Uniapp虽好,但“一套代码,多端运行”在文件操作上会遇到显著的平台差异,必须做条件编译和深度适配。
4.1 Android版本碎片化应对
-
Android 10 (API 29) 及以上 (Scoped Storage) :
-
挑战
:应用无法直接通过
file://路径访问其他应用创建的文件(如图片、下载的文件)。uni.chooseFile返回的可能是content://URI。 -
解决方案
:如前所述,核心是使用
readFileAPI读取内容。对于下载保存,优先尝试保存到MediaStore管理的公共目录(如Downloads),这需要更复杂的原生交互。一个更简单的回退方案是保存到应用私有目录,然后通过Intent.ACTION_VIEW打开,让用户选择“用其他应用保存”到公共目录。
-
挑战
:应用无法直接通过
-
Android 11 (API 30) 及以上 :
-
挑战
:引入了“所有文件访问权限”(
MANAGE_EXTERNAL_STORAGE),普通应用几乎无法申请。对公共目录的写入也有限制。 -
解决方案
:遵循“分区存储”最佳实践。
-
应用私有目录(
/data/data/包名或/storage/emulated/0/Android/data/包名)可自由读写。 -
使用
MediaStoreAPI访问媒体文件(图片、视频、音频)。 -
使用
Storage Access Framework(SAF,即系统文件选择器) 让用户授权访问特定文件夹。这对应Uniapp的uni.chooseFile,它背后调用的就是SAF。 - 对于Downloads目录,应用可以写入自己创建的文件,但可能无法直接覆盖其他应用创建的文件。
-
应用私有目录(
-
挑战
:引入了“所有文件访问权限”(
4.2 文件打开与分享的进阶处理
仅仅下载保存还不够,用户需要能打开和分享文件。
-
打开文件
:使用
plus.runtime.openFile或uni.openDocument(主要用于PDF等文档)。这里的关键是 文件类型(MIME Type)的匹配 。如果系统无法识别,会打开失败。我们可以根据文件后缀名映射MIME Type:
function getMimeType(fileName) {
const ext = fileName.toLowerCase().split('.').pop();
const mimeMap = {
'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',
'zip': 'application/zip',
'txt': 'text/plain',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
};
return mimeMap[ext] || 'application/octet-stream'; // 未知类型用二进制流
}
// 在打开文件时使用
plus.runtime.openFile(savedFilePath, {
mimeType: getMimeType(fileName)
}, (e) => {
uni.showToast({ title: '未找到可打开此文件的应用,请安装相关应用(如WPS)', icon: 'none' });
});
-
分享文件
:使用
uni.share或plus.share.sendWithSystem。分享时,同样需要提供文件的 真实路径 。如果文件保存在应用私有目录,需要先将其复制到缓存目录,再分享缓存目录的文件路径,因为其他应用无权访问你的私有目录。
async shareFile(filePath, fileName) {
// #ifdef APP-PLUS
// 检查文件是否在私有目录,如果是,可能需要先复制到缓存目录
const shareFilePath = await prepareFileForSharing(filePath);
plus.share.sendWithSystem({
type: 'file',
pictures: [],
content: '分享一个文件',
href: shareFilePath,
extra: {
scene: 'WXSceneSession' // 微信会话
}
}, (res) => {
console.log('分享成功');
}, (err) => {
uni.showToast({ title: '分享失败', icon: 'none' });
});
// #endif
}
5. 性能优化与异常处理实录
处理大文件或网络不稳定时,性能和稳定性问题会凸显。
5.1 大文件上传的切片与断点续传
当文件超过10MB时,直接上传风险高。可以采用前端切片上传。
-
前端切片
:使用
File对象的slice方法(在浏览器环境中)将ArrayBuffer分片。在Uniapp中,我们可以在读取文件为ArrayBuffer后,手动进行分片。 - 计算哈希 :对整个文件计算MD5或SHA-1哈希,作为文件唯一标识,用于服务器端合并和校验。
- 分片上传 :依次上传每个分片,携带分片索引、总片数、文件哈希等元数据。
- 服务器合并 :服务器接收所有分片后,按索引顺序合并。
- 断点续传 :在上传前,先询问服务器该文件已上传了哪些分片,前端只上传剩余分片。
这是一个简化的前端切片示例思路:
async function uploadLargeFile(filePath, fileName) {
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB一片
const fs = uni.getFileSystemManager();
const arrayBuffer = await readFileAsArrayBuffer(fs, filePath);
const totalChunks = Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE);
const fileHash = await computeMD5(arrayBuffer); // 需要引入MD5计算库
// 1. 询问服务器上传状态
const { uploadedChunks } = await uni.request({
url: 'https://your-api.com/upload/status',
data: { fileHash }
});
// 2. 上传未完成的分片
for (let i = 0; i < totalChunks; i++) {
if (uploadedChunks.includes(i)) continue; // 跳过已上传
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, arrayBuffer.byteLength);
const chunk = arrayBuffer.slice(start, end);
const formData = new FormData();
formData.append('file', new Blob([chunk]));
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('fileHash', fileHash);
formData.append('fileName', fileName);
await uploadChunk(formData); // 封装的上传函数
// 更新进度...
}
// 3. 通知服务器合并
await uni.request({
url: 'https://your-api.com/upload/merge',
data: { fileHash, fileName }
});
}
5.2 网络异常与失败重试机制
无论是上传还是下载,都必须有健壮的错误处理和重试。
-
超时设置
:在
uni.request和uni.downloadFile中设置合理的timeout(如30000毫秒)。 -
错误分类处理
:
- 网络错误 (如超时、断开):提示用户检查网络,并提供重试按钮。
- 服务器错误 (4xx, 5xx):根据状态码提示具体信息(如“文件太大”、“服务器繁忙”)。
- 客户端错误 (如权限不足、存储空间满):给出明确的引导操作(如“请开启存储权限”、“清理存储空间”)。
- 指数退避重试 :对于暂时性网络错误,可以实现一个重试机制,每次重试间隔时间指数级增加。
async function requestWithRetry(options, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
const res = await uni.request(options);
return res; // 成功则返回
} catch (error) {
lastError = error;
console.warn(`请求失败,第${i + 1}次重试`, error);
if (i < maxRetries - 1) {
// 指数退避等待
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError; // 所有重试都失败
}
5.3 存储空间检查与清理提示
在下载或保存大文件前,检查可用存储空间是一个好习惯。
// #ifdef APP-PLUS
function checkStorageSpace(requiredBytes) {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => {
fs.root.getFreeSpace((freeSpace) => {
if (freeSpace > requiredBytes) {
resolve(true);
} else {
uni.showModal({
title: '存储空间不足',
content: `可用空间不足,请清理后重试。所需空间: ${(requiredBytes / 1024 / 1024).toFixed(2)}MB`,
showCancel: false
});
reject(new Error('存储空间不足'));
}
}, reject);
}, reject);
});
}
// #endif
6. 常见问题排查与调试技巧
开发过程中,你一定会遇到各种奇怪的问题。这里记录了几个最典型的案例和排查思路。
6.1 问题速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
uni.chooseFile
选择文件后,
file.path
为
content://
开头,上传失败。
| Android高版本Scoped Storage限制,应用无直接访问该URI对应文件的权限。 |
1.
不要直接使用该路径上传
。
2. 使用
uni.getFileSystemManager().readFile()
将文件内容读取为
ArrayBuffer
。
3. 将
ArrayBuffer
通过
uni.request
以二进制流形式上传。
|
| 文件下载成功,但保存后在图库或文件管理器中找不到。 | 文件被保存到了应用私有目录,该目录对用户和其他应用不可见。 |
1. 使用
plus.io.convertLocalFileSystemURL
确认最终保存路径。
2. 尝试保存到公共目录,如
Downloads
(需处理Android版本兼容)。
3. 保存后,使用
plus.runtime.openFile
打开,用户可选择“保存到”其他位置。
|
plus.runtime.openFile
打开文件失败,提示“未找到应用”。
|
1. 系统没有安装能处理该文件类型的应用。
2. 提供的MIME Type不正确。 3. 文件路径无效或无权访问。 |
1. 提示用户安装相关应用(如WPS、Office)。
2. 根据文件后缀名设置正确的
mimeType
参数。
3. 检查文件路径是否存在且可读。 |
| 上传大文件时,应用闪退或提示内存不足。 |
一次性将整个大文件读入内存(
ArrayBuffer
),导致内存溢出。
|
实现
分片上传
。不要一次性
readFile
,而是分片读取和上传。
|
在Android 11+设备上,无法将文件保存到
Downloads
文件夹。
| Android 11对公共目录的写入权限进一步收紧。 |
1. 优先使用
MediaStore.Downloads
API(需写原生插件)。
2. 降级方案:保存到应用私有目录,然后通过系统分享或“另存为”对话框让用户选择保存位置。 |
uni.downloadFile
进度回调不触发或频率低。
| 网络请求库的实现问题,或文件太小瞬间完成。 |
1. 对于大文件,确保服务器支持分块传输(
Accept-Ranges
)。
2. 使用
uni.request
自己实现下载并监听
onChunkReceived
(如果支持)来模拟进度。
|
6.2 真机调试心得
- 一定要用真机 :文件系统权限问题在模拟器上可能表现不同,真机调试必不可少。
-
使用ADB命令
:通过
adb shell连接到手机,查看应用私有目录和公共目录下的文件是否真的创建了,权限是否正确。命令如ls -la /storage/emulated/0/Android/data/你的包名/。 -
查看控制台日志
:
console.log输出所有关键路径和错误信息。使用HBuilderX的“真机运行”功能,日志会直接输出在控制台。 - 分步调试 :将上传/下载流程拆解成:选择/请求 -> 读取/下载 -> 处理 -> 保存/打开。在每个阶段结束后都打印日志,确认数据状态,能快速定位问题阶段。
6.3 关于
content://com.baidu.searchbox.fileprovider/...
这类路径
这是其他应用(如百度搜索框)通过FileProvider共享给当前应用的URI。处理方式和
content://
URI一样,
不能直接当文件路径用
。必须通过
uni.getFileSystemManager().readFile
来读取其内容。这再次印证了处理非媒体文件的核心:
放弃对路径的直接操作,转而操作文件内容本身
。
整个流程走下来,我的体会是,Uniapp处理非媒体文件,更像是在Web前端思维和原生移动开发思维之间架一座桥。你不能完全用Web那一套(直接操作路径),也不能完全写原生代码。关键在于理解Android系统的权限和文件访问模型,然后灵活运用Uniapp提供的、能穿透这层模型的API(如
readFile
、
saveFile
)。把文件当作一坨二进制数据来处理,而不是一个固定的位置,思路就会清晰很多。最后,多测试、多抓日志、善用条件编译来区分平台处理,这些看似琐碎的工作,才是保证功能稳定上线的基石。

277

被折叠的 条评论
为什么被折叠?



