一套完整的 React Native 图片/视频压缩上传方案,涵盖原生压缩、并发控制、断点重试、进度追踪与优雅取消。
前言
在移动端应用中,媒体文件上传几乎是每个业务系统的刚需------用户反馈、动态发布、资料提交......然而,直接上传原始文件往往面临三大痛点:
- 文件体积过大:手机拍摄的图片动辄 5-10MB,视频更是数百 MB,直接上传既慢又费流量。
- 弱网环境:移动网络不稳定,上传中断频繁,需要可靠的重试机制。
- 批量操作体验差:多文件上传时缺少进度反馈、无法取消、无法单独重试失败项。
本文将介绍我们如何用一个自包含的 media-upload 库系统性地解决这些问题。整个方案基于 React Native 0.77 新架构,兼容鸿蒙(RNOH)适配层,已在生产环境验证。
一、整体架构
整个库由 9 个文件组成,职责清晰:
bash
src/lib/media-upload/
├── types.ts # 类型定义
├── index.ts # 统一导出
├── CompressionService.ts # 压缩服务
├── UploadService.ts # 上传服务(并发 + 重试)
├── MediaUploadManager.ts # 核心调度器
├── useMediaUpload.ts # React Hook(消费端 API)
├── MediaUploadSheet.tsx # 上传进度底部弹窗
├── MediaUploadItem.tsx # 单任务行组件
└── USAGE.md # 使用文档
分层设计如下:
scss
┌─────────────────────────────────────────┐
│ 消费层 (Demo / 业务页面) │
│ useMediaUpload Hook │
├─────────────────────────────────────────┤
│ 调度层 MediaUploadManager │
│ (事件系统、任务生命周期、阶段编排) │
├────────────────┬────────────────────────┤
│ CompressionService │ UploadService │
│ (react-native- │ (axios + │
│ compressor) │ Semaphore) │
└─────────────────────────────────────────┘
这种分层的核心思想是:压缩与上传解耦,调度层编排流程,Hook 层桥接 React 状态。
二、类型设计:用类型驱动整个流程
良好的类型设计是可靠系统的基础。我们定义了覆盖全生命周期的类型体系:
2.1 任务状态机
typescript
type UploadTaskStatus =
| 'pending' // 等待处理
| 'compressing' // 压缩中
| 'uploading' // 上传中
| 'completed' // 完成
| 'failed' // 失败
| 'cancelled'; // 已取消
六个状态覆盖了任务的完整生命周期。注意 pending 状态出现了两次------压缩前和压缩后各一次,这是有意为之:压缩完成后任务回到 pending,等待上传调度。
2.2 压缩元数据
typescript
interface CompressedMedia extends MediaAsset {
originalSize: number;
compressedSize: number;
compressionRatio: number; // 0-1,越小压缩越多
compressionTime: number; // 毫秒
}
继承自 MediaAsset 的设计使得压缩后的文件可以无缝替换原始文件参与后续流程。compressionRatio 为 1 表示未压缩(压缩失败时的降级值),为 0.3 表示压缩到了原始大小的 30%。
2.3 批量结果
typescript
interface BatchUploadResult {
total: number;
successful: number;
failed: number;
cancelled: number;
tasks: UploadTask[];
responses: UploadResponse[];
duration: number; // 总耗时 ms
}
一次性返回所有维度的统计,消费方无需自行聚合。
三、压缩策略:顺序执行,优雅降级
3.1 为什么选择顺序压缩
图片和视频压缩是 CPU 和内存密集型操作。在移动端,同时压缩多个大文件极易触发 OOM(Out of Memory)。因此我们选择逐个压缩:
typescript
async compressMultiple(
assets: MediaAsset[],
onProgress?: (index: number, total: number) => void,
): Promise<CompressedMedia[]> {
const results: CompressedMedia[] = [];
for (let i = 0; i < assets.length; i++) {
results.push(await this.compress(assets[i]));
onProgress?.(i + 1, assets.length);
}
return results;
}
这是一个关键的设计决策:牺牲压缩速度换取内存安全。实测 9 张 4K 图片顺序压缩约需 3-5 秒,用户可接受。
3.2 图片压缩
使用 react-native-compressor 的原生压缩能力,在 iOS 上调用 ImageIO,在 Android 上调用 BitmapFactory:
typescript
private async compressImage(asset: MediaAsset): Promise<CompressedMedia> {
const { imageQuality: quality = 0.8, maxImageDimension = 1920 } = this.config;
const resultUri = await Image.compress(asset.uri, {
compressionMethod: 'auto',
maxWidth: maxImageDimension,
maxHeight: maxImageDimension,
quality,
});
// ...
}
参数解读:
compressionMethod: 'auto':让库自动选择最佳压缩算法(iOS 用 HEIC/JPEG,Android 用 WebP/JPEG)。maxWidth/maxHeight: 1920:限制长边不超过 1920px,覆盖绝大多数屏幕显示需求。quality: 0.8:80% 质量在视觉感知上几乎无损,但体积通常能减少 40-60%。
3.3 视频压缩
typescript
private async compressVideo(asset: MediaAsset): Promise<CompressedMedia> {
const resultUri = await Video.compress(asset.uri, {
compressionMethod: 'auto',
});
// ...
}
视频压缩目前使用 auto 模式,让原生层根据设备能力自动选择编码参数。这是一个务实的选择------视频压缩参数调优非常复杂,auto 模式在大多数场景下已经足够好。
3.4 压缩失败的降级策略
typescript
try {
const compressed = await this.compressImage(asset);
return compressed;
} catch (error) {
console.warn('Image compression failed, using original:', error);
return {
...asset,
originalSize: asset.size,
compressedSize: asset.size,
compressionRatio: 1,
compressionTime: 0,
};
}
压缩失败不应阻断上传流程 。当压缩失败时,我们返回原始文件并标记 compressionRatio: 1,上传继续进行。这个降级策略在生产环境中非常重要------某些特殊格式的图片或损坏的视频文件可能导致压缩失败,但用户仍然期望上传能完成。
四、上传引擎:信号量 + 指数退避
4.1 用信号量控制并发
并发上传的核心问题是控制同时进行的请求数。我们实现了一个轻量级的 FIFO 信号量:
typescript
class Semaphore {
private permits: number;
private queue: Array<() => void> = [];
constructor(permits: number) {
this.permits = permits;
}
async acquire(): Promise<void> {
if (this.permits > 0) {
this.permits--;
return;
}
return new Promise<void>(resolve => {
this.queue.push(resolve);
});
}
release(): void {
if (this.queue.length > 0) {
const next = this.queue.shift()!;
next();
} else {
this.permits++;
}
}
}
使用方式:
typescript
async upload(task: UploadTask, onProgress?: ProgressCallback) {
await this.semaphore.acquire();
try {
// ... 执行上传
} finally {
this.semaphore.release();
}
}
默认并发数为 3,这是一个经验值:太少则吞吐量低,太多则在移动端容易触发连接数限制或内存问题。
4.2 指数退避重试
网络请求失败是移动端的常态。我们实现了带指数退避的重试机制:
typescript
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await this.uploadFile(task, signal, onProgress);
} catch (error) {
if (isCancelledError(error) || isClientError(error)) {
throw error; // 不可重试的错误直接抛出
}
if (attempt < maxRetries) {
const delay = retryBaseDelay * Math.pow(2, attempt);
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError;
重试策略:
- 可重试:网络错误、HTTP 5xx、超时
- 不可重试:HTTP 4xx(客户端错误)、用户主动取消
- 退避公式 :
delay = 1000ms × 2^attempt,即 1s → 2s → 4s
指数退避避免了在服务端过载时的"惊群效应"------所有客户端同时重试会加剧问题,间隔递增则给服务端恢复的时间。
4.3 Axios 与自定义请求的双通道
typescript
private async uploadFile(task: UploadTask, signal: AbortSignal, onProgress?) {
const file = task.compressed || task.asset;
const formData = new FormData();
formData.append(this.config.fieldName || 'file', {
uri: file.uri,
name: file.fileName,
type: file.mimeType,
} as any);
if (this.config.customRequest) {
return this.config.customRequest(formData, { signal, onUploadProgress });
}
return axios.post(this.config.url, formData, {
headers: { 'Content-Type': 'multipart/form-data', ...this.config.headers },
timeout: this.config.timeout,
signal,
onUploadProgress,
});
}
customRequest 的设计让库可以无缝对接不同的后端 SDK。在我们的项目中,业务层有自己的请求封装(含鉴权、签名),通过 customRequest 注入即可复用整套上传流程。
五、取消机制:细粒度 AbortController
每个上传任务都有独立的 AbortController:
typescript
const controller = new AbortController();
this.abortControllers.set(task.id, controller);
// 单个取消
cancel(taskId: string) {
const controller = this.abortControllers.get(taskId);
controller?.abort();
}
// 全部取消
cancelAll() {
this.isCancelled = true;
for (const controller of this.abortControllers.values()) {
controller.abort();
}
}
isCancelled 标志位确保取消后不会再启动新的上传:
typescript
async upload(task: UploadTask) {
await this.semaphore.acquire();
if (this.isCancelled) {
this.semaphore.release();
throw new Error('Upload cancelled');
}
// ...
}
配合 Promise.allSettled,单个任务的取消不会影响其他任务的完成:
typescript
const results = await Promise.allSettled(
tasks.map(task => this.upload(task, onProgress))
);
六、事件驱动的 React 集成
6.1 事件系统
MediaUploadManager 内置了 13 种事件,覆盖任务和批次两个维度:
typescript
// 任务级事件
'task:created' | 'task:compressing' | 'task:compressed' |
'task:uploading' | 'task:progress' | 'task:completed' |
'task:failed' | 'task:cancelled' | 'task:retrying'
// 批次级事件
'batch:started' | 'batch:completed' | 'batch:cancelled' | 'batch:failed'
每个事件都携带 timestamp,便于时序分析和调试。
6.2 Hook 封装
useMediaUpload 将事件流桥接到 React 状态:
typescript
// 核心模式:事件 → 状态更新
useEffect(() => {
const unsubscribers = [
manager.on('task:progress', () => setTasks(manager.getTasks())),
manager.on('task:completed', () => setTasks(manager.getTasks())),
manager.on('task:failed', () => setTasks(manager.getTasks())),
// ... 其他事件
];
return () => unsubscribers.forEach(unsub => unsub());
}, [manager]);
为了避免闭包陷阱,所有回调都通过 useRef 持有最新引用:
typescript
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
});
6.3 派生状态
Hook 提供了丰富的派生状态,消费方无需自行计算:
typescript
const overallProgress = tasks.length
? Math.round(tasks.reduce((sum, t) => sum + t.progress, 0) / tasks.length)
: 0;
const isDone = tasks.length > 0 &&
tasks.every(t => ['completed', 'failed', 'cancelled'].includes(t.status));
七、UI 组件:底部弹窗的交互设计
7.1 进度展示
底部弹窗(MediaUploadSheet)提供了实时的上传进度:
- 状态文案:根据阶段动态切换------"正在压缩文件..." → "上传中 67%" → "全部完成 (9)"
- 进度条:绿色正常,橙色有失败项
- 任务计数 :
completed / total格式
7.2 单任务行
每个 MediaUploadItem 展示:
- 文件类型图标(图片/视频)
- 文件名和大小(压缩后显示压缩大小)
- 压缩比(如 "压缩: 65%")
- 状态指示灯 + 文案
- 操作按钮(取消/重试)
7.3 操作设计
三个关键操作按钮按上下文显示:
- 取消全部:上传进行中显示
- 重试失败 (N):有失败项时显示,点击批量重试所有失败任务
- 清除已完成:上传结束后显示
关闭按钮在上传期间禁用,防止误触导致状态丢失。
八、使用示例
typescript
import {useMediaUpload, MediaUploadSheet} from '@/lib/media-upload';
function MyScreen() {
const { tasks, isUploading, startUpload, cancelAll, ... } = useMediaUpload({
uploadUrl: '',
config: {
maxFiles: 9,
maxFileSize: 50 * 1024 * 1024, // 50MB
compression: { imageQuality: 0.8, maxImageDimension: 1920 },
upload: {
concurrency: 3,
maxRetries: 3,
customRequest: myApiUpload, // 注入业务请求
},
},
onComplete: (result) => {
console.log(`成功 ${result.successful},失败 ${result.failed}`);
},
});
const handlePick = async () => {
const files = await DocumentPicker.pick({
type: [DocumentPicker.types.images, DocumentPicker.types.video],
allowMultiSelection: true,
});
await startUpload(files);
};
return (
<>
<Button onPress={handlePick} disabled={isUploading}>选择文件</Button>
<MediaUploadSheet visible={showSheet} tasks={tasks} ... />
</>
);
}
九、设计决策复盘
9.1 为什么压缩顺序而上传并发?
| 阶段 | 策略 | 原因 |
|---|---|---|
| 压缩 | 顺序 | CPU/内存密集,同时压缩多个大文件易 OOM |
| 上传 | 并发 | I/O 密集,等待响应时 CPU 空闲,并发可充分利用带宽 |
这是一个经典的 CPU-bound vs I/O-bound 区分。
9.2 为什么用信号量而非简单的并发池?
信号量的 FIFO 语义保证了公平性------先请求的任务先获得上传机会。同时,acquire 的 Promise 特性让代码可以自然地用 async/await 表达,无需回调嵌套。
9.3 为什么事件系统而非直接回调?
13 种事件类型提供了细粒度的扩展点。消费方可以通过 listeners 选项订阅任意事件,实现如埋点统计、日志记录等横切关注点,而无需修改核心逻辑。
十、已知限制与改进方向
-
视频压缩配置未生效 :
CompressionConfig中定义了videoQuality和maxVideoDuration,但compressVideo仅传递{ compressionMethod: 'auto' }。后续应将配置透传到原生层。 -
批量总耗时计算 :Hook 的
batch:completed事件处理中duration硬编码为 0,应使用 Manager 计算的实际耗时。 -
任务对象引用 :Manager 原地修改任务对象,Hook 中
setTasks拿到的新数组包含相同的对象引用。这对纯展示无影响,但如果消费方使用React.memo且依赖对象引用相等性做优化,可能产生问题。可考虑在getTasks()中做浅拷贝。 -
压缩缓存清理 :当前在
cleanup()时清理缓存,但未处理压缩产生的临时文件在异常退出后的残留。可考虑在启动时做一次延迟清理。
总结
这套方案的核心设计原则:
- 分层解耦:压缩、上传、调度、UI 各司其职
- 优雅降级:压缩失败不阻断上传,单任务失败不阻断批次
- 可观测性:13 种事件 + 实时进度 UI,全链路透明
- 用户控制:细粒度取消、单任务重试、批量操作
在实际生产中,这套方案支撑了用户反馈场景下的 9 图/视频批量上传,在弱网(3G/地铁)环境下表现稳定,压缩平均减少 50% 以上的上传体积,用户感知的上传速度提升约 2 倍。