媒体选择、上传与音频采集 API 实现流程

0 阅读5分钟

媒体选择、上传与音频采集 API 实现流程

这份文档说明当前项目里图片、视频、音频相关能力是怎么一步步实现的,以及后续接后端时应该如何把本地演示逻辑升级成真实上传流程。

当前项目处于演示阶段:

  • 图片 / 视频:通过 expo-image-picker 选择。
  • 音频文件:Web 端通过 input[type=file] 选择。
  • 音频录制:Web 端通过 getUserMedia + MediaRecorder 采集。
  • 本地保存:暂时写入 LocalStorage
  • 后续生产方案:上传到后端或对象存储,数据库只保存文件 URL 和元数据。

1. 图片和视频选择 API

使用的 API

当前项目使用:

import * as ImagePicker from "expo-image-picker";

核心方法是:

ImagePicker.launchImageLibraryAsync(options)

它会打开系统媒体库,让用户选择图片或视频。

实现步骤

第一步,调用系统媒体库:

const result = await ImagePicker.launchImageLibraryAsync({
  mediaTypes: ["images", "videos"],
  allowsEditing: false,
  allowsMultipleSelection: true,
  quality: 1,
});

关键参数:

  • mediaTypes: ["images", "videos"]:允许选择图片和视频。
  • allowsMultipleSelection: true:允许多选。
  • allowsEditing: false:关闭编辑裁剪,多选时也更符合预期。
  • quality: 1:尽量保留原始质量。

第二步,判断用户是否取消:

if (result.canceled) {
  Alert.alert("No media selected", "You did not select a photo or video.");
  return;
}

第三步,读取返回的媒体列表:

const mediaItems = await Promise.all(
  result.assets.map(async (asset, index): Promise<StoredMedia> => ({
    id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`,
    uri: await uriToPersistentDemoUri(asset.uri),
    type: asset.type === "video" ? "video" : "image",
    fileName: asset.fileName,
    width: asset.width,
    height: asset.height,
    duration: asset.duration,
    createdAt: new Date().toISOString(),
  })),
);

result.assets 中每一项就是用户选择的一个媒体文件。常用字段包括:

  • uri:本地文件地址或 Web 端临时地址。
  • type:媒体类型,例如 image / video
  • fileName:文件名。
  • width / height:图片或视频宽高。
  • duration:视频时长。

第四步,保存到当前待提交状态:

setSelectedMediaItems(mediaItems);
setShowAppOptions(true);

首页预览区通过:

const selectedMedia = selectedMediaItems.at(-1);

展示最后一个选择的文件。

2. 为什么 Web 端要转换 blob URL

Web 端选择图片或视频时,拿到的 asset.uri 可能是:

blob:http://localhost:8081/xxxx

blob: URL 是浏览器当前页面生命周期内的临时地址。刷新页面后,这个地址会失效。

所以当前演示阶段做了一步转换:

const uriToPersistentDemoUri = async (uri: string) => {
  if (Platform.OS !== "web" || !uri.startsWith("blob:")) {
    return uri;
  }

  const response = await fetch(uri);
  const blob = await response.blob();

  return fileToDataUri(
    new File([blob], "selected-media", { type: blob.type }),
  );
};

转换流程:

  1. 判断是不是 Web 端 blob: 地址。
  2. 使用 fetch(uri) 读取 Blob。
  3. 把 Blob 包成 File。
  4. 使用 FileReader.readAsDataURL() 转成 data: URL。
  5. 再写入 LocalStorage

这样刷新页面后,演示数据仍然能显示。

注意:这只适合演示。data: URL 会变大,占用 LocalStorage 容量,不适合保存大视频。

3. 音频文件选择 API

当前 Web 实现

当前项目没有安装 expo-document-picker,所以 Web 端先用浏览器原生文件选择能力:

const input = document.createElement("input");
input.type = "file";
input.accept = "audio/*";
input.multiple = true;

用户选择文件后:

input.onchange = async () => {
  const files = Array.from(input.files ?? []);

  const audioItems = await Promise.all(
    files.map(async (file, index) =>
      createMediaItem(
        await fileToDataUri(file),
        "audio",
        index,
        file.name,
      ),
    ),
  );

  setSelectedMediaItems(audioItems);
  setShowAppOptions(true);
};

实现步骤:

  1. 创建隐藏的文件选择 input。
  2. 设置 accept = "audio/*",只选择音频。
  3. 设置 multiple = true,支持多选。
  4. 用户选择后读取 input.files
  5. 使用 FileReader.readAsDataURL() 转成可缓存的 data: URL。
  6. 转成统一的 StoredMedia

后续原生端实现

iOS / Android 上应该使用 Expo 官方的 expo-document-picker

npx expo install expo-document-picker

示例流程:

import * as DocumentPicker from "expo-document-picker";

const result = await DocumentPicker.getDocumentAsync({
  type: "audio/*",
  multiple: true,
  copyToCacheDirectory: true,
});

后续处理逻辑和 Web 类似:

  1. 判断是否取消。
  2. 遍历返回的音频文件。
  3. 读取 urinamemimeTypesize 等字段。
  4. 转成统一媒体结构。
  5. 保存或上传。

4. 音频录制 API

当前 Web 实现

当前 Web 录音使用两个浏览器 API:

  • navigator.mediaDevices.getUserMedia()
  • MediaRecorder

第一步,请求麦克风权限并拿到音频流:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

第二步,用音频流创建录音器:

const recorder = new MediaRecorder(stream);
recordingChunksRef.current = [];

第三步,录音过程中收集数据块:

recorder.ondataavailable = (event) => {
  if (event.data.size > 0) {
    recordingChunksRef.current.push(event.data);
  }
};

第四步,停止录音后合并 Blob:

recorder.onstop = async () => {
  const audioBlob = new Blob(recordingChunksRef.current, {
    type: recorder.mimeType || "audio/webm",
  });
};

第五步,把 Blob 转成可缓存的音频文件:

const audioUri = await fileToDataUri(
  new File([audioBlob], `recording-${Date.now()}.webm`, {
    type: audioBlob.type,
  }),
);

第六步,释放麦克风资源:

stream.getTracks().forEach((track) => track.stop());

第七步,转成统一媒体结构:

setSelectedMediaItems([
  createMediaItem(audioUri, "audio", 0, "Recorded audio"),
]);

后续原生端实现

Expo SDK 54 推荐使用 expo-audio

npx expo install expo-audio

典型流程是:

  1. 请求录音权限。
  2. 设置音频模式。
  3. 创建 recorder。
  4. 开始录音。
  5. 停止录音。
  6. 读取录音文件 uri
  7. 上传或保存媒体记录。

伪代码结构:

import {
  AudioModule,
  RecordingPresets,
  setAudioModeAsync,
  useAudioRecorder,
} from "expo-audio";

const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);

const start = async () => {
  const permission = await AudioModule.requestRecordingPermissionsAsync();
  if (!permission.granted) return;

  await setAudioModeAsync({
    allowsRecording: true,
    playsInSilentMode: true,
  });

  await recorder.prepareToRecordAsync();
  recorder.record();
};

const stop = async () => {
  await recorder.stop();
  const uri = recorder.uri;
};

拿到 uri 后,就可以进入和图片 / 视频一样的统一处理流程。

5. 统一媒体结构

当前项目把图片、视频、音频统一成一个结构:

export type StoredMedia = {
  id: string;
  uri: string;
  type: "image" | "video" | "audio";
  fileName?: string | null;
  width?: number;
  height?: number;
  duration?: number | null;
  createdAt: string;
};

这样做的好处是:

  • 首页只需要处理一组 selectedMediaItems
  • About 页面只需要消费一组媒体列表。
  • 删除逻辑可以复用。
  • 后续上传后端时,数据库表结构也更清晰。

6. 当前 LocalStorage 保存流程

当前保存逻辑在 lib/mediaStore.ts

读取:

export function getStoredMedia(): StoredMedia[] {
  const rawValue = storage.getItem(MEDIA_STORAGE_KEY);
  return rawValue ? JSON.parse(rawValue) : [];
}

批量新增:

export function addStoredMediaItems(items: StoredMedia[]) {
  const existingItems = getStoredMedia();
  saveStoredMedia([...items, ...existingItems]);
}

删除:

export function deleteStoredMedia(id: string) {
  const existingItems = getStoredMedia();
  saveStoredMedia(existingItems.filter((item) => item.id !== id));
}

这个流程只是演示缓存。正式接后端后,不建议把大文件转成 data: URL 存到 LocalStorage

7. 后续真实上传流程

后续加入后端和数据库后,推荐流程如下。

第一步:前端选择或录制媒体

来源可能是:

  • expo-image-picker 返回的图片 / 视频。
  • expo-document-picker 返回的音频文件。
  • expo-audio 返回的录音文件。
  • Web MediaRecorder 返回的录音 Blob。

第二步:构造 FormData

Web 端常见写法:

const formData = new FormData();
formData.append("file", file);
formData.append("type", mediaType);

React Native / Expo 原生端常见写法:

const formData = new FormData();
formData.append("file", {
  uri: media.uri,
  name: media.fileName ?? "upload",
  type: media.mimeType ?? "application/octet-stream",
} as unknown as Blob);

第三步:调用上传接口

const response = await fetch("/api/media/upload", {
  method: "POST",
  body: formData,
});

const savedMedia = await response.json();

第四步:后端保存文件

后端可以把文件保存到:

  • 本地磁盘
  • 阿里云 OSS
  • 腾讯云 COS
  • AWS S3
  • MinIO

生产环境更推荐对象存储。

第五步:数据库保存元数据

数据库不建议直接存大文件,建议保存:

{
  id: string;
  url: string;
  type: "image" | "video" | "audio";
  fileName: string;
  mimeType: string;
  size: number;
  width?: number;
  height?: number;
  duration?: number;
  createdAt: Date;
}

第六步:前端展示后端返回的 URL

后端返回:

{
  "id": "media_001",
  "url": "https://cdn.example.com/media/demo.mp4",
  "type": "video",
  "fileName": "demo.mp4",
  "createdAt": "2026-05-07T10:00:00.000Z"
}

前端把 url 映射成当前的 uri 字段即可继续复用现有展示组件。

8. 官方文档入口

Expo 官方文档

MDN Web 官方文档

9. 注意点

1. Web 端文件选择必须由用户操作触发

浏览器通常要求文件选择、摄像头、麦克风等能力必须由按钮点击等用户行为触发,不能在页面加载时自动弹出。

2. 麦克风需要 HTTPS 或本地开发环境

getUserMedia() 只在安全上下文可用。一般本地 localhost 可以,线上需要 HTTPS。

3. blob URL 不能当长期地址保存

blob: 地址刷新后会失效。演示阶段可以转成 data: URL;生产阶段应该上传文件并保存后端 URL。

4. LocalStorage 不适合保存大文件

图片还勉强可以演示,小视频和音频很容易超过容量限制。真实业务应该走上传接口。

5. 原生端要补权限和配置

iOS / Android 需要处理:

  • 相册权限
  • 文件访问权限
  • 麦克风权限
  • app config / Info.plist / AndroidManifest 权限说明

Expo 的 config plugin 可以减少一部分手动配置。

10. 小结

当前项目的媒体能力可以理解成三条链路:

图片/视频选择 -> 统一媒体结构 -> LocalStorage -> About 轮播展示
音频文件选择 -> 统一媒体结构 -> LocalStorage -> About 轮播展示
音频录制采集 -> 统一媒体结构 -> LocalStorage -> About 轮播展示

后续接后端时,把中间的 LocalStorage 替换为:

上传接口 -> 对象存储 -> 数据库媒体表 -> 列表接口

前端展示层可以尽量保持不变,只需要把 uri 从本地演示地址换成后端返回的资源 URL。