【免费下载链接】skills
A collection of AI agent skills for working with Expo projects and Expo Application Services
本文以expo-native-ui技能中的媒体参考文档为主体,系统讲解在 Expo / React Native 中接入相机、图片选择、音频播放、麦克风录音、视频播放与媒体保存的完整方案。读完后你将掌握一套可直接落地的媒体层实现范式:权限如何按需请求、摄像头界面如何贴合 iOS 原生观感、expo-audio/expo-video如何替代已废弃的expo-av,以及 base64 图片为何必须先落盘才能写入相册。文中所有代码均来自仓库文档并可结合同仓库的其他参考文件交叉印证。
一、这份文档在技能体系中的位置
expo-native-ui是一个面向"原生质感界面"的技能,其 SKILL.md 把参考资料拆成了若干专题,媒体能力单独收敛在 media.md:
references/ controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker gradients.md CSS gradients via experimental_backgroundImage (New Arch only) icons.md SF Symbols via expo-symbols SymbolView: names, weights, animations; Material icons on Android media.md Camera, audio, video, and file saving storage.md SQLite, AsyncStorage, SecureStore visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect) webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.js这意味着媒体能力并不是孤立模块,它与同目录另外两份文档强耦合:
- 图标:相机界面里的"翻转、相册、闪光"图标来自 icons.md 的 SF Symbols 约定;
- 玻璃质感按钮:
GlassView/GlassButton定义在 visual-effects.md 的 "Glass Buttons" 小节。
同时,SKILL.md 的 "Library Preferences" 给出了两条硬性选型规则,直接决定了本文所有示例的 import:
| 场景 | 必须使用 | 禁止使用 |
|---|---|---|
| 音频播放 / 录音 | expo-audio | expo-av |
| 视频播放 | expo-video | expo-av |
| iOS 图标 | expo-symbols的SymbolView | 仅 SF Symbols 而无 Android 兜底 |
| 安全区 | react-native-safe-area-context | React Native 内置SafeAreaView |
| 平台判断 | process.env.EXPO_OS | Platform.OS |
expo-av的废弃结论在 expo-upgrade 的废弃包表 中被再次确认:expo-av的替代品是expo-audio与expo-video两个包。因此下文不会出现任何Audio.Sound.createAsync或<Video>写法。
二、相机:五条设计约束与完整实现
media.md 在代码之前先给了五条来自实战的界面约束:
- 全屏相机必须隐藏导航头(相机本身是沉浸式界面,顶部标题栏会破坏取景体验);
- 用
mirror镜像前置摄像头,以模拟社交类应用的自拍观感; - 相机上的操作按钮使用 liquid glass(
expo-glass-effect); - 图标使用 SF Symbols 的三个固定名字:
arrow.triangle.2.circlepath(翻转)、photo(相册)、bolt(闪光); - 权限必须由一层"说明页 + Grant 按钮"门控,绝不能在组件挂载时弹出系统权限弹窗;媒体库权限则在第一次保存或选择时才懒请求。
这三条图标名与 icons.md 的 Camera 分组完全一致,该小节还额外给出了bolt.slash(关闭闪光)与camera/camera.fill,可在实现闪光切换时补齐。
2.1 完整代码
import React, { useRef, useState } from "react"; import { View, Pressable, Text } from "react-native"; import { CameraView, CameraType, useCameraPermissions } from "expo-camera"; import * as ImagePicker from "expo-image-picker"; import * as Haptics from "expo-haptics"; import { colors } from "@/theme/colors"; import { GlassView } from "expo-glass-effect"; import { useSafeAreaInsets } from "react-native-safe-area-context"; function Camera({ onPicture }: { onPicture: (uri: string) => Promise<void> }) { const [permission, requestPermission] = useCameraPermissions(); const cameraRef = useRef<CameraView>(null); const [type, setType] = useState<CameraType>("back"); const { bottom } = useSafeAreaInsets(); if (!permission?.granted) { return ( <View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: colors.systemBackground }}> <Text style={{ color: colors.label, padding: 16 }}>Camera access is required</Text> <GlassView isInteractive tintColor={colors.systemBlue} style={{ borderRadius: 12 }}> <Pressable onPress={requestPermission} style={{ padding: 12, borderRadius: 12 }}> <Text style={{ color: "white" }}>Grant Permission</Text> </Pressable> </GlassView> </View> ); } const takePhoto = async () => { await Haptics.selectionAsync(); if (!cameraRef.current) return; const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 }); await onPicture(photo.uri); }; const selectPhoto = async () => { await Haptics.selectionAsync(); const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: "images", allowsEditing: false, quality: 0.8, }); if (!result.canceled && result.assets?.[0]) { await onPicture(result.assets[0].uri); } }; return ( <View style={{ flex: 1, backgroundColor: "black" }}> <CameraView ref={cameraRef} mirror style={{ flex: 1 }} facing={type} /> <View style={{ position: "absolute", left: 0, right: 0, bottom: bottom, gap: 16, alignItems: "center" }}> <GlassView isInteractive style={{ padding: 8, borderRadius: 99 }}> <Pressable onPress={takePhoto} style={{ width: 64, height: 64, borderRadius: 99, backgroundColor: "white" }} /> </GlassView> <View style={{ flexDirection: "row", justifyContent: "space-around", paddingHorizontal: 8 }}> <GlassButton onPress={selectPhoto} icon="photo" /> <GlassButton onPress={() => setType(t => t === "back" ? "front" : "back")} icon="arrow.triangle.2.circlepath" /> </View> </View> </View> ); }2.2 逐段拆解
权限门控。useCameraPermissions()返回[permission, requestPermission],其中permission在权限状态尚未解析完成时可能为null。示例用的是if (!permission?.granted)——注意这里的可选链:未决(null)与拒绝(granted: false)会走同一个分支,用户看到的是说明页而不是黑屏。requestPermission只绑定在 Grant 按钮上,因此系统弹窗的出现时机完全由用户意图决定,这正是文档中"never fire the permission prompt on mount"的实现方式。
mirror与facing。<CameraView ref={cameraRef} mirror style={{ flex: 1 }} facing={type} />中,facing由type状态驱动,初始为"back";mirror让前置预览像镜子一样左右翻转,符合用户对自拍界面的预期(但不改变实际保存的照片)。
拍照。cameraRef.current.takePictureAsync({ quality: 0.8 })返回带uri的对象,示例立即把photo.uri交给父级的onPicture回调。这里有两个防御点值得保留:await Haptics.selectionAsync()在拍照前提供触觉反馈;if (!cameraRef.current) return避免 ref 尚未挂载时的空指针。quality: 0.8是压缩质量,文档在拍照与相册选择两处都用了同一个值,保证两条入口产出的图片体量一致。
相册选择。ImagePicker.launchImageLibraryAsync({ mediaTypes: "images", allowsEditing: false, quality: 0.8 }):mediaTypes限定为图片,allowsEditing: false跳过系统裁剪页(相机界面已是一次取景,再进裁剪会多一跳),结果通过result.canceled与result.assets?.[0]双重判空后再取uri。用户取消时canceled为true,此时不应触发任何回调。
布局与安全区。底部控制条使用position: "absolute"加bottom: bottom(来自useSafeAreaInsets().bottom),从而规避 iPhone 底部 Home Indicator;gap: 16是 SKILL.md "General Styling Rules" 中"优先用 flex gap 而非 margin/padding"的落地。整屏容器背景为"black",且没有包裹 ScrollView——这与 SKILL.md 的规则一致:相机、地图、画布这类 full-bleed 界面既不需要外层 ScrollView,也不需要contentInsetAdjustmentBehavior。
颜色。colors.systemBackground、colors.label、colors.systemBlue来自@/theme/colors,即 SKILL.md 中基于expo-router的ColorAPI 建立的语义色板(Platform.select+default十六进制兜底),因此明暗模式自动适配,无需手写两套色值。
2.3 玻璃按钮:需要 iOS 26+ 的守卫
示例里的GlassButton来自 visual-effects.md:
function GlassButton({ icon, onPress }) { return ( <GlassView isInteractive style={{ borderRadius: 50 }}> <Pressable style={{ padding: 12 }} onPress={onPress}> <SymbolView name={icon} tintColor={colors.label} size={24} /> </Pressable> </GlassView> ); } // Usage <GlassButton icon="plus" onPress={handleAdd} /> <GlassButton icon="gear" onPress={handleSettings} />同一份文档明确说明expo-glass-effect用于iOS 26+的 liquid glass,并给出双重可用性判断:
import { isLiquidGlassAvailable, isGlassEffectAPIAvailable } from "expo-glass-effect"; const canUseGlass = isLiquidGlassAvailable() && isGlassEffectAPIAvailable();两个守卫缺一不可——某些 iOS 26 beta 版本缺少该 API,只判断前者会直接崩溃。文档给出的降级组件是AdaptiveGlass:可用时渲染GlassView,否则退回BlurView tint="systemMaterial" intensity={80}。此外还要遵守两条玻璃使用禁忌:GlassView及其祖先不能设置overflow: 'hidden'(玻璃靠borderRadius自行裁剪,外部裁剪会切掉边缘高光与按压隆起),也不能对其做透明度动画。
另外必须为 Android 准备图标兜底。icons.md 指出 SF Symbols 在 Android 上渲染为空白,每个图标都要有 Material 来源:
import MaterialIcons from "@expo/vector-icons/MaterialIcons"; process.env.EXPO_OS === "ios" ? ( <SymbolView name="gear" tintColor={colors.label} style={{ width: 24, height: 24 }} /> ) : ( <MaterialIcons name="settings" size={24} color={colors.label} /> );也就是说,GlassButton在生产代码里应当接收icon(SF 名)与一个 Material 名两个参数,并按process.env.EXPO_OS分支渲染,而不是只传一个 SF 名字。
三、音频播放:用expo-audio取代expo-av
media.md 的音频播放段极简,一句话加五行代码:
import { useAudioPlayer } from 'expo-audio'; const player = useAudioPlayer({ uri: 'https://stream.nightride.fm/rektory.mp3' }); <Button title="Play" onPress={() => player.play()} />关键点是"Useexpo-audionotexpo-av"。这个 hook 的设计取向在 expo-av → expo-audio 迁移文档 里写得很清楚:
- 即时加载:hook 挂载时音频立即开始加载,不需要显式 preload;
- 自动清理:卸载时由 hook 释放资源,无需
unloadAsync(); - 单位为秒:时间统一用秒而非毫秒(对齐 Web 标准);
- 直接属性访问:
player.volume = 0.5、player.loop = true、player.playbackRate直接赋值,不再是setVolumeAsync一类的异步方法; - 播放结束不回零:
play()播完后播放器停在末尾并保持 paused,要重播必须先player.seekTo(0)再play(); - 多播放器:可以创建多个
useAudioPlayer实例并存,全部立即加载。
常用控制与旧 API 的对应关系(摘自同一迁移文档的 API Mapping 表):
expo-av | expo-audio |
|---|---|
Audio.Sound.createAsync() | useAudioPlayer(source) |
sound.playAsync() | player.play() |
sound.pauseAsync() | player.pause() |
sound.setPositionAsync(ms) | player.seekTo(seconds) |
sound.setVolumeAsync(vol) | player.volume = vol |
sound.setRateAsync(rate) | player.playbackRate = rate |
sound.setIsLoopingAsync(loop) | player.loop = loop |
sound.unloadAsync() | hook 自动处理 |
playbackStatus.positionMillis | player.currentTime(秒) |
playbackStatus.durationMillis | player.duration(秒) |
playbackStatus.isPlaying | player.playing |
注意最后一行的量纲差异:把毫秒值直接喂给seekTo会跳到错误位置,这是从expo-av迁移时最常见的一类隐性 bug。
四、麦克风录音:按用户意图请求权限
录音段落的代码更长,因为它把"权限申请"和"音频会话配置"的顺序显式写了出来:
import { useAudioRecorder, AudioModule, RecordingPresets, setAudioModeAsync, useAudioRecorderState, } from 'expo-audio'; import { Alert, Button } from 'react-native'; function App() { const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); const recorderState = useAudioRecorderState(audioRecorder); // Request the microphone on first record, not on mount — no prompt before user intent const record = async () => { const status = await AudioModule.requestRecordingPermissionsAsync(); if (!status.granted) { Alert.alert('Permission to access microphone was denied'); return; } await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true }); await audioRecorder.prepareToRecordAsync(); audioRecorder.record(); }; const stop = () => audioRecorder.stop(); return ( <Button title={recorderState.isRecording ? 'Stop' : 'Start'} onPress={recorderState.isRecording ? stop : record} /> ); }这段代码里有四个容易被忽略的细节:
- hook 分三件套:
useAudioRecorder(preset)创建录制器,useAudioRecorderState(recorder)提供响应式状态(isRecording等),按钮文案直接由状态推导,而不是自己再维护一个布尔值。 - 权限在
record()内部请求:与相机一样遵循"不在 mount 时弹窗"的原则,注释原文是 "no prompt before user intent"。 setAudioModeAsync必须在录制前调用:{ playsInSilentMode: true, allowsRecording: true }让音频在 iOS 静音开关打开时依然可播,并切换到允许录音的会话类别。对照 迁移文档,旧写法是allowsRecordingIOS/playsInSilentModeIOS,新写法去掉了平台后缀;后台播放对应shouldPlayInBackground,混音策略对应interruptionMode: 'doNotMix'。- 严格的三步顺序:
prepareToRecordAsync()→record()。stop()之后用recorder.uri取文件地址(旧 API 是stopAndUnloadAsync()+getURI())。
把顺序搞错(例如先record()再prepareToRecordAsync())会导致录制失败或产出空文件;这在迁移文档的 API Mapping 表末几行有对应标注。
五、视频播放:useVideoPlayer+VideoView
视频段落同样只有一句结论加一段示例:
import { useVideoPlayer, VideoView } from 'expo-video'; import { useEvent } from 'expo'; const videoSource = 'https://example.com/video.mp4'; const player = useVideoPlayer(videoSource, player => { player.loop = true; player.play(); }); const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing }); <VideoView player={player} fullscreenOptions={{}} allowsPictureInPicture />media.md 列出了VideoView的五个选项:
| 选项 | 类型 | 作用 |
|---|---|---|
allowsPictureInPicture | boolean | 允许画中画 |
contentFit | 'contain' \| 'cover' \| 'fill' | 视频在容器内的填充方式 |
nativeControls | boolean | 使用系统原生播放控件 |
playsInline | boolean | 内联播放,不强制全屏 |
startsPictureInPictureAutomatically | boolean | 进入后台等场景自动启动 PiP |
5.1 播放器与视图分离
video 迁移文档 把expo-video的模型差异总结为四点,正好解释了上面示例为什么长这样:
- player 与 view 分离:播放逻辑不再绑在组件 ref 上,一个 player 可以在多个
VideoView间复用; - 单位为秒:
player.currentTime = seconds,player.duration也是秒; - 事件系统取代回调 prop:用
expo的useEvent/useEventListener替代onPlaybackStatusUpdate。示例中的useEvent(player, 'playingChange', { isPlaying: player.playing })就是"响应式状态"写法;一次性副作用(如播放结束)则用useEventListener(player, 'playToEnd', ...); - 预加载能力:只创建 player、不挂载
VideoView即可预加载,切换页面时更快;VideoSource里设置useCaching: true可获得持久化离线缓存。
5.2 画中画与后台播放的构建约束
allowsPictureInPicture只是视图层开关,原生能力仍需 config plugin 打开。迁移文档要求在app.json中声明:
{ "expo": { "plugins": [ ["expo-video", { "supportsBackgroundPlayback": true, "supportsPictureInPicture": true }] ] } }按 SKILL.md 的构建判定规则,凡是需要在app.json里做原生配置、Expo Go 无法表达的能力,就必须走npx expo run:ios/eas build的自定义构建;而普通的视频播放本身可以先用 Expo Go 验证。该节的首要原则是"始终先试 Expo Go",因为自定义构建会显著拖慢迭代。
5.3 已知坑
同一份迁移文档列出了四条实测问题,直接影响媒体页面的稳定性:
- 必须先卸载
expo-av:Android 上两者共存会让VideoView黑屏; - Android 上不要在多个
VideoView里同时挂载同一个 player:会黑屏(iOS 正常); - Android 上不要在
useVideoPlayer的 setup 回调里设置player.currentTime:需在挂载后再设置; - 切换视频源用
player.replace(newSource),不要重建 player。
六、保存媒体到相册
media.md 给出最短路径:
import * as MediaLibrary from "expo-media-library"; const { granted } = await MediaLibrary.requestPermissionsAsync(); if (granted) { await MediaLibrary.saveToLibraryAsync(uri); }要点有三:requestPermissionsAsync()返回{ granted },必须判断后再执行保存;saveToLibraryAsync接收的是本地文件 URI;按文档开头第 10 行的约定,媒体库权限应当"懒请求"——在第一次保存或选择时才请求,而不是应用启动即请求。
七、保存 base64 图片:先落盘,再入库
这是整份文档中最容易被踩的一处:MediaLibrary.saveToLibraryAsync只接受本地文件路径,因此服务端返回的 base64(例如 AI 生成的图片)必须先写入磁盘。文档给出的转换函数如下:
import { File, Paths } from "expo-file-system/next"; function base64ToLocalUri(base64: string, filename?: string) { if (!filename) { const match = base64.match(/^data:(image\/[a-zA-Z]+);base64,/); const ext = match ? match[1].split("/")[1] : "jpg"; filename = `generated-${Date.now()}.${ext}`; } if (base64.startsWith("data:")) base64 = base64.split(",")[1]; const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(new ArrayBuffer(len)); for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i); const f = new File(Paths.cache, filename); f.create({ overwrite: true }); f.write(bytes); return f.uri; }7.1 五步流程
- 推断扩展名:当调用方没有传
filename时,用正则/^data:(image\/[a-zA-Z]+);base64,/从 data URI 头部取出 MIME,再split("/")[1]得到png/jpeg等;匹配失败一律回退jpg。 - 剥离前缀:如果字符串以
data:开头,取,之后的纯 base64 段,否则atob会直接抛错。 - 解码为二进制字符串:
atob(base64)得到的是 Latin-1 字符串,每个字符代表一个字节。 - 转成字节数组:先按长度
new ArrayBuffer(len)分配,再用Uint8Array视图逐字符charCodeAt(i)填充。注意这里不是TextEncoder——base64 解出的已经是原始字节,再按 UTF-8 编码会破坏数据。 - 写入缓存目录并返回 URI:
new File(Paths.cache, filename)在缓存目录下建文件,f.create({ overwrite: true })允许覆盖同名文件,f.write(bytes)写入二进制,最后返回f.uri供saveToLibraryAsync使用。
选Paths.cache而不是文档目录是有意为之:这是一份为"保存到相册"服务的临时文件,系统可以随时回收,不应占用持久存储。文件名里的Date.now()则避免同一秒内的多次生成互相覆盖。
7.2 组装成完整的"生成即保存"链路
把第六节和本节串起来,就是一条完整可用的链路:
const uri = base64ToLocalUri(base64FromServer); const { granted } = await MediaLibrary.requestPermissionsAsync(); if (granted) { await MediaLibrary.saveToLibraryAsync(uri); }延伸一点:如果只是要在界面里展示这张图,无需落盘——expo-image的source可以直接接受 data URI;只有当目标动作是"保存到相册"或"分享为文件"时,才需要上面的磁盘中转。这一判断与 SKILL.md 中"用expo-image而非内建img元素"的选型规则配合使用。
八、贯穿全篇的三条工程约定
媒体页面往往同时踩中性能与交互问题,把散落在各文档里的约束汇总成三条,可以直接作为 review 清单:
其一,安全区与滚动容器。相机这类全屏界面不用 ScrollView;含滚动内容的页面用<ScrollView contentInsetAdjustmentBehavior="automatic" />而非SafeAreaView,FlatList/SectionList同样适用该属性;需要精细控制时用useSafeAreaInsets()(相机底部控制条就是这么做的)。测量屏幕尺寸一律用useWindowDimensions,不要Dimensions.get()。
其二,触觉反馈要"有条件、有兜底"。示例中的Haptics.selectionAsync()在拍照与选图前触发,属于 expo-animation 的触觉表 里"值跨过一个档位(picker、滑块、分段控件)"的场景。该文档同时强调两条:iOS 上按需使用;触觉永远不能是唯一反馈——很多用户全局关闭了触觉,Android 多数设备也没有振动,视觉状态必须独立成立。
其三,平台判断与图标双源。用process.env.EXPO_OS而不是Platform.OS;SF Symbols 只在 iOS 生效,Android 必须给 Material 备用名,且同一平台只允许一种图标体系,不要混用。
九、排错对照表
| 现象 | 常见原因 | 对应依据 |
|---|---|---|
| 一进页面就弹权限框 | 在 mount 时调用了requestPermission | 相机需说明页门控;麦克风在record()内请求 |
| 相机整屏黑 | permission为null时误判为已授权 | 用!permission?.granted覆盖未决态 |
| Android 图标空白 | 只给了 SF Symbols 名字 | 需md/ MaterialIcons 兜底 |
| 玻璃按钮在旧系统崩溃 | 只判断isLiquidGlassAvailable() | 必须加isGlassEffectAPIAvailable() |
| 玻璃边缘高光被切掉 | 在GlassView或祖先上设了overflow: 'hidden' | 玻璃靠borderRadius自裁剪 |
| Android 视频黑屏 | 与expo-av共存,或同一 player 挂多个VideoView | 迁移文档 Known Issues |
| 音频跳转到错误位置 | 传了毫秒 | expo-audio单位为秒 |
| 播完后点播放无反应 | 播完停在末尾 | 先player.seekTo(0) |
saveToLibraryAsync失败 | 传了 base64 或远程 URL | 只接受本地文件 URI,需先落盘 |
| 保存的图片打不开 | 漏了剥离data:前缀或误用文本编码 | 见base64ToLocalUri五步流程 |
十、继续深入
- 媒体主文档:plugins/expo/skills/expo-native-ui/references/media.md
- 玻璃按钮与降级:plugins/expo/skills/expo-native-ui/references/visual-effects.md
- 相机图标命名:plugins/expo/skills/expo-native-ui/references/icons.md
- 语义色板与整体规范:plugins/expo/skills/expo-native-ui/SKILL.md
expo-av迁移手册:plugins/expo/skills/expo-upgrade/references/expo-av-to-audio.md、plugins/expo/skills/expo-upgrade/references/expo-av-to-video.md- 可脚手架化的相机示例:
with-camera(见 示例目录,可用npx create-expo --example with-camera拉起最小工程)
【免费下载链接】skills
A collection of AI agent skills for working with Expo projects and Expo Application Services
相关推荐
用 Expo 构建原生媒体体验:相机、音频、视频与媒体保存全实战指南
用 Expo 构建原生媒体体验:相机、音频、视频与媒体保存全实战指南 本指南以 agentic awesome skills 仓库中 building nati
AI 技能AI 插件人工智能DyberPet 桌面宠物实战指南:配置角色、动作与物品
DyberPet 桌面宠物实战指南:配置角色、动作与物品 DyberPet 是一个用 Python 和 PySide6 编写的桌面宠物框架,让角色常驻桌面,内置
桌面应用AI 应用Kivy多媒体处理:音频、视频和相机功能详解
Kivy多媒体处理:音频、视频和相机功能详解 想要在Python应用中轻松实现多媒体功能吗?Kivy框架提供了强大的多媒体处理能力,让开发者能够快速集成音频播放
桌面应用移动开发跨平台UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考