React Native 压缩上传全链路方案:从架构设计到生产实践

一套完整的 React Native 图片/视频压缩上传方案,涵盖原生压缩、并发控制、断点重试、进度追踪与优雅取消。

前言

在移动端应用中,媒体文件上传几乎是每个业务系统的刚需------用户反馈、动态发布、资料提交......然而,直接上传原始文件往往面临三大痛点:

  1. 文件体积过大:手机拍摄的图片动辄 5-10MB,视频更是数百 MB,直接上传既慢又费流量。
  2. 弱网环境:移动网络不稳定,上传中断频繁,需要可靠的重试机制。
  3. 批量操作体验差:多文件上传时缺少进度反馈、无法取消、无法单独重试失败项。

本文将介绍我们如何用一个自包含的 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 选项订阅任意事件,实现如埋点统计、日志记录等横切关注点,而无需修改核心逻辑。


十、已知限制与改进方向

  1. 视频压缩配置未生效CompressionConfig 中定义了 videoQualitymaxVideoDuration,但 compressVideo 仅传递 { compressionMethod: 'auto' }。后续应将配置透传到原生层。

  2. 批量总耗时计算 :Hook 的 batch:completed 事件处理中 duration 硬编码为 0,应使用 Manager 计算的实际耗时。

  3. 任务对象引用 :Manager 原地修改任务对象,Hook 中 setTasks 拿到的新数组包含相同的对象引用。这对纯展示无影响,但如果消费方使用 React.memo 且依赖对象引用相等性做优化,可能产生问题。可考虑在 getTasks() 中做浅拷贝。

  4. 压缩缓存清理 :当前在 cleanup() 时清理缓存,但未处理压缩产生的临时文件在异常退出后的残留。可考虑在启动时做一次延迟清理。


总结

这套方案的核心设计原则:

  • 分层解耦:压缩、上传、调度、UI 各司其职
  • 优雅降级:压缩失败不阻断上传,单任务失败不阻断批次
  • 可观测性:13 种事件 + 实时进度 UI,全链路透明
  • 用户控制:细粒度取消、单任务重试、批量操作

在实际生产中,这套方案支撑了用户反馈场景下的 9 图/视频批量上传,在弱网(3G/地铁)环境下表现稳定,压缩平均减少 50% 以上的上传体积,用户感知的上传速度提升约 2 倍。

相关推荐
Rain5091 小时前
05. mini-cc 工具系统:让 AI 拥有动手能力
linux·前端·人工智能·ubuntu·typescript·ai编程
YiWait1 小时前
基于 Vue 3 的网络收音机,编译为桌面应用软件
前端·javascript·vue.js
虾壳云官方1 小时前
OpenClaw 绑定企业微信完整指南
服务器·前端·网络·人工智能·企业微信·open claw·小龙虾
MichaelJohn1 小时前
别卷框架了!前端人,用 JS + LangChain + DeepSeek 开启你的 AI 转型第一步
前端
古法编程第一人1 小时前
使用Electric同步前后端数据
前端·vue.js
朱涛的自习室2 小时前
30天11万行代码,我用 Trae 和 Gemini 造了个 AI 测试引擎
android·前端·人工智能
大连好光景2 小时前
登录凭证 | Session+Cookie | Redis Token | JWT
前端·javascript
deepin_sir2 小时前
11 - 模块与包
前端·数据库·python
小小小小宇2 小时前
前端 Redux applyMiddleware 中间件链原理
前端