媒体选择、上传与音频采集 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 }),
);
};
转换流程:
- 判断是不是 Web 端
blob:地址。 - 使用
fetch(uri)读取 Blob。 - 把 Blob 包成 File。
- 使用
FileReader.readAsDataURL()转成data:URL。 - 再写入
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);
};
实现步骤:
- 创建隐藏的文件选择 input。
- 设置
accept = "audio/*",只选择音频。 - 设置
multiple = true,支持多选。 - 用户选择后读取
input.files。 - 使用
FileReader.readAsDataURL()转成可缓存的data:URL。 - 转成统一的
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 类似:
- 判断是否取消。
- 遍历返回的音频文件。
- 读取
uri、name、mimeType、size等字段。 - 转成统一媒体结构。
- 保存或上传。
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
典型流程是:
- 请求录音权限。
- 设置音频模式。
- 创建 recorder。
- 开始录音。
- 停止录音。
- 读取录音文件
uri。 - 上传或保存媒体记录。
伪代码结构:
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 官方文档
-
Expo ImagePicker:图片 / 视频选择
-
Expo ImagePicker 教程:从媒体库选择图片
-
Expo DocumentPicker:文件 / 音频选择
-
Expo Audio:音频播放和录音,SDK 54 推荐方案
-
Expo SDK 54 总览
MDN Web 官方文档
-
MediaDevices.getUserMedia():请求麦克风 / 摄像头权限并获取媒体流https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
-
MediaRecorder:录制MediaStreamhttps://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder
-
FileReader.readAsDataURL():把 File / Blob 转成 Data URLhttps://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL
-
FormData.append():构造文件上传表单https://developer.mozilla.org/en-US/docs/Web/API/FormData/append
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。