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

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

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

当前项目处于演示阶段:

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

1. 图片和视频选择 API

使用的 API

当前项目使用:

ts 复制代码
import * as ImagePicker from "expo-image-picker";

核心方法是:

ts 复制代码
ImagePicker.launchImageLibraryAsync(options)

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

实现步骤

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

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

关键参数:

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

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

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

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

ts 复制代码
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:视频时长。

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

ts 复制代码
setSelectedMediaItems(mediaItems);
setShowAppOptions(true);

首页预览区通过:

ts 复制代码
const selectedMedia = selectedMediaItems.at(-1);

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

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

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

txt 复制代码
blob:http://localhost:8081/xxxx

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

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

ts 复制代码
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 端先用浏览器原生文件选择能力:

ts 复制代码
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/*";
input.multiple = true;

用户选择文件后:

ts 复制代码
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

bash 复制代码
npx expo install expo-document-picker

示例流程:

ts 复制代码
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

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

ts 复制代码
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

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

ts 复制代码
const recorder = new MediaRecorder(stream);
recordingChunksRef.current = [];

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

ts 复制代码
recorder.ondataavailable = (event) => {
  if (event.data.size > 0) {
    recordingChunksRef.current.push(event.data);
  }
};

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

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

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

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

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

ts 复制代码
stream.getTracks().forEach((track) => track.stop());

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

ts 复制代码
setSelectedMediaItems([
  createMediaItem(audioUri, "audio", 0, "Recorded audio"),
]);

后续原生端实现

Expo SDK 54 推荐使用 expo-audio

bash 复制代码
npx expo install expo-audio

典型流程是:

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

伪代码结构:

ts 复制代码
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. 统一媒体结构

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

ts 复制代码
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

读取:

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

批量新增:

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

删除:

ts 复制代码
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 端常见写法:

ts 复制代码
const formData = new FormData();
formData.append("file", file);
formData.append("type", mediaType);

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

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

第三步:调用上传接口

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

const savedMedia = await response.json();

第四步:后端保存文件

后端可以把文件保存到:

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

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

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

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

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

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

后端返回:

json 复制代码
{
  "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. 小结

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

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

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

txt 复制代码
上传接口 -> 对象存储 -> 数据库媒体表 -> 列表接口

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

相关推荐
AI服务老曹5 小时前
架构师视角:如何构建支持GB28181/RTSP的异构AI视频平台?从Docker部署到源码交付的深度实践
人工智能·docker·音视频
yantaohk7 小时前
一键下载微信视频号所有页面视频,支持批量下载、加密视频解密、自动去重
网络·微信·音视频
EasyGBS10 小时前
国标GB28181视频平台EasyGBS解决多格式视频流无缝转换难题
ffmpeg·音视频
byte轻骑兵11 小时前
【LE Audio】CAP精讲[2]: 三大角色+服务映射,CAP配置核心流程全拆解
人工智能·音视频·le audio·低功耗音频·蓝牙通话
非凡ghost15 小时前
视频下载神器:直播回放、视频链接一键抓取,还能自动监听!
java·前端·javascript·音视频
ZC跨境爬虫15 小时前
跟着 MDN 学 HTML day_25:(数字音频概念完全解析)
前端·ui·html·edge浏览器·媒体
dayuOK630715 小时前
不会写文案?我用“看图说话”的方法,10分钟搞定一篇
人工智能·职场和发展·新媒体运营·媒体
做萤石二次开发的哈哈15 小时前
萤石×广联达 | 智能视觉融合数字建造,让工地更透明、更安全
人工智能·安全·音视频·智能硬件
尚雷558016 小时前
oracle知识整理_锁及等待事件SQL_第二部分
数据库·sql·oracle·锁及等待事件