Skip to content
SDK 模块速查

SDK 模块速查

Expo 的核心竞争力之一是一整套 expo-* 官方原生模块:相机、定位、通知、SQLite、文件、音频视频……全部由 Expo 团队维护、统一发版、接口风格一致。这一章是这些模块的分类地图:每个模块一句话定位、生产必用的几个 API、一个真实写法、顺手带上踩过的坑;SDK 58 的新增与破坏性改动逐包标注清楚。

边界先说好:组件层的用法见 react-native/02-核心组件.md,SQLite/AsyncStorage/文件系统之外的存储选型对比见 react-native/08-存储与文件.md,expo-router 本身见 04-Expo-Router.md,EAS Update 的平台机制见 07-EAS发布与热更新.md(那章讲"更新从服务器到手机怎么走",这章只讲 expo-updates 的 JS API 怎么写)。

先读我:版本纪律与本章范围

  • 装包只用 npx expo install,不要 npm i expo-xxx。前者会按当前 SDK 解析出配套版本号,后者装 latest 可能装进下一个 SDK 的版本,prebuild 时才炸。
  • SDK 58 起模块版本号与 SDK 同号:expo-file-system@58.0.10、expo-video@58.0.12、expo-hinge@58.0.1、@expo/ui@58.0.21(实测 npm registry,2026-10-20)。例外是还在 alpha 的包,比如 expo-app-intents@0.5.10。
  • 官方模板预装了哪些模块见 00-概述与安装.md;不确定的 API 一律查 docs.expo.dev/versions/latest/sdk/<模块>.md,每页顶部的平台表格写明是否含在 Expo Go——Expo Go 只捆绑一部分模块,用本章标"Expo Go 不可用"的包必须上 development build(见 05-Expo-Go与开发构建.md)。
  • 需要写原生代码或用 inline modules 扩展这些模块,见 09-Expo-Modules与原生扩展.md。

SDK 58 模块变更速查

升级或新起项目时先看这张表(细节在对应小节展开,来源为官方 SDK 58 changelog 与 v58 文档实测):

包 58 的变化
expo-file-system File.write() 变异步(同步版 writeSync());新增 digest()/preview()/canPreview();File.md5 弃用
expo-sqlite 移除 libSQL(syncLibSQL()/libSQLOptions 不复存在、useLibSQL 插件项无效);新增 interruptSync()、web 端 configureWeb() 加载 wa-sqlite
expo-notifications 前台通知默认展示(handler 不否决就显示)、慢 handler 不再丢通知;iOS threadIdentifier 分组;Android delivery: 'alarmClock'、插件 largeIcon
expo-camera CameraView.scanDocumentAsync() 多页文档扫描
expo-audio 锁屏播放控制 + 歌单;AudioStream.startFileRecordingAsync(WAV/PCM 落盘)
expo-image transition.skipOnCacheHit;svgVariables(SVG var() 填值)
expo-location 新增预览版 expo-location/next;iOS 地理围栏不再要求 location background mode
expo-media-library iOS Asset.getUri()/AssetInfo.uri 默认解析 Photos 当前版本,旧行为传 version: AssetUriVersion.ORIGINAL
expo-secure-store Android 新增 requireConfirmation(默认 true,配合 requireAuthentication)
expo-localization iOS 不再用 supportsRTL 强开 RTL,布局方向跟随 I18nManager
expo-screen-orientation iOS 27 可 resize 后 requireFullScreen 不再豁免、lockAsync 可能无效
expo-hinge 全新包:useHinge()/getHinge()/addHingeListener()
expo-widgets Android widget 支持(独立 Hermes runtime);iOS Live Activity staleDate、稳定 ActivityKit 标识符
expo-app-intents 新增(alpha):App Intents/Siri/Spotlight/Apple Intelligence
expo-font 可变字体三端支持(useFonts/loadAsync/config plugin 三条路都通)
expo-glass-effect iOS 27 SDK 构建下 isLiquidGlassAvailable() 返回 true
expo-observe GA(见 07);新增 errorHandlingEnabled、Observe.clientId;AppMetrics 弃用
expo-updates 新增 updates.maxUpdatesToKeep 配置
expo-linking 新增 unwrapDevLaunchURL();parse() 不再二次解码查询参数
expo-modules-core NativeArrayBuffer 弃用,改用 JS ArrayBuffer
@expo/ui iOS 列表回收虚拟化、NavigationStack 系列;backgroundOverlay→background 等弃用一批

基座:应用信息与启动流程

expo-constants:读构建配置,别再拿它读设备

import Constants from 'expo-constants',读运行期不变的信息:Constants.expoConfig(app.json/app.config.ts 解析结果)、Constants.expoVersion、Constants.executionEnvironment(Bare/Standalone/StoreClient,判断是否跑在 Expo Go)、Constants.easConfig、Constants.linkingUri。

这包 58 时代的主线是做减法:老笔记里的 appOwnership、deviceYearClass、Constants.manifest.model/platform/systemVersion 都已标记弃用并各归其主——设备信息去 expo-device,构建号去 expo-application,环境判断用 executionEnvironment。statusBarHeight 还活着但只算"默认高度",布局上请用 safe area insets(react-native/02)。一句话:expo-constants 管"这个包是怎么构建出来的",expo-device 管"它跑在什么机器上"。

expo-device:只读的设备档案

纯同步属性一大堆:Device.isDevice(模拟器判断,推送前必查)、Device.modelName/modelId/manufacturer/osName/osVersion/deviceType/totalMemory,方法侧 getDeviceTypeAsync()、isRootedExperimentalAsync()(风控信号)。没有 Device.isTablet——deviceType === Device.DeviceType.TABLET。典型用法是埋点维度与低端机降级开关,注意 deviceYearClass 这种性能代理指标维护很差,别当分级依据。

expo-system-ui:根视图背景色

主要就一个函数:SystemUI.setBackgroundColorAsync('black'),改原生根视图颜色——splash 退场到首帧渲染之间那块底色。下拉回弹时露出的"系统层"颜色也由它管。深色模式/静态色板在 app config 里配(userInterfaceStyle,见 03-应用配置与插件.md)。

expo-status-bar:声明式写法

import { StatusBar } from 'expo-status-bar';

<StatusBar style="auto" />   // light | dark | auto;hidden、animated 可选

组件式:每屏渲染一个 <StatusBar>,props 按挂载顺序合并,比命令式 setStyle 好维护。命令式入口是 StatusBar.setHidden()/setStyle()(从 react-native 导入的同名 API 也有,但 expo 这层默认值更适配 Expo 环境)。顺带一提:RN 0.87(SDK 58 捆绑)已移除 backgroundColor、translucent、networkActivityIndicatorVisible 这些旧 props,老教程照抄会编译期报错;iOS 网络指示器请做成 UI 元素。

expo-asset:静态资源的底层账本

Asset.fromModule(require('./logo.png')) + downloadAsync(),或 hooks 版 useAssets([...])。日常你感知不到它——expo-image、expo-font 底层都靠它解析 require() 出来的资源 ID。只有两种场景要直接打交道:运行时才确定要加载什么(先 useAssets 预热再渲染)、以及给原生 API 传资源 URI(asset.uri:web 上是 http URL,原生上下载后是 file://(Android 也可能是 content://),跨端代码先判协议,asset.downloaded 标志看是否已落地)。

expo-splash-screen:默认自动退场

52 起最大的心智变化:splash 默认在 app ready 时自动隐藏,老模板里那句"启动就 preventAutoHideAsync()“不再是必需动作。需要等数据再揭幕时才手动拦:

// app/_layout.tsx(Expo Router)
import * as SplashScreen from 'expo-splash-screen';
SplashScreen.preventAutoHideAsync();          // 模块顶层,越早越好

export default function RootLayout() {
  const ready = useBootstrap();               // 你的 hydrate/取 token 逻辑
  return <View onLayout={() => { if (ready) SplashScreen.hideAsync(); }}>
    {/* ... */}
  </View>;
}

两个坑:preventAutoHideAsync 之后必须保证每条路径都能走到 hideAsync,否则永远停在 splash;淡出动画用 SplashScreen.setOptions({ fade: true, duration: 300 }),别自己叠一个假 splash View。图片/配色这类静态配置归 config plugin(app.json 的 splash 字段,见 03)。

expo-linking:scheme、深链与开发 URL

核心 API:Linking.createURL(path)(拼上本 app 的 scheme 和 dev/hostUri,Expo Go 里也能拿到能用的 URL)、Linking.parse(url)(拆 path/queryParams/scheme)、useURL() hook、openURL/canOpenURL 的转发。iOS 的 canOpenURL 要先把目标 scheme 写进 Info.plist 的 LSApplicationQueriesSchemes(经 ios.infoPlist 配置,见 03),没声明就恒为 false;Android 上无法确认时会直接 reject。

58 两处与"启动 URL 统一契约”(__expo_ 前缀参数,见 05)相关的变化:Linking.parse() 不再对查询值二次解码,percent 编码的 %、+、@ 原样吐出——旧代码里给解码结果"再解一次"的补丁要删;新导出 unwrapDevLaunchURL(url) 把 {scheme}://?__expo_url=... 还原成真正该路由的业务 URL,处理冷启动深链的胶水代码应该换用它,而不是手撕 query。

expo-updates:热更新客户端 API

平台机制、runtimeVersion、渐进发布在 07 讲透,这里只记 JS 面。SDK 52 重写后的模型:默认在冷启动时检查更新、下载、下次启动生效(checkAutomatically: ON_LOAD,app config 可改),大多数生产 app 什么都不用写。需要"立即生效"才手动:

import * as Updates from 'expo-updates';

const check = await Updates.checkForUpdateAsync();
if (check.isAvailable) {
  const fetch = await Updates.fetchUpdateAsync();
  if (fetch.isNew) await Updates.reloadAsync();   // 调用后不要指望还有代码被执行
}

诊断常量:Updates.isEmbeddedLaunch(当前跑的是包内基线还是 OTA)、updateId、channel、runtimeVersion、isEmergencyLaunch/emergencyLaunchReason(错误恢复回滚的原因)、launchDuration。开发模式下这三个 check/fetch/reload 会直接 reject,只有真机上的 production/preview 构建可用。58 新增配置 updates.maxUpdatesToKeep(常切 channel 调试的项目调大,免得重复下载)。旧 API Updates.launchStage 已不在 SDK 58 文档里,存量引用直接删。

网络与数据

expo-sqlite:默认本地数据库

选型先说结论:结构化本地数据、需要关系查询和事务,就选它(对比表见 react-native/08-存储与文件.md)。Android 侧底层是 System SQLite,iOS/macOS 是 Apple SQLite,不带任何远程同步能力——这是 58 之后要反复向同事强调的点,见下文 libSQL 移除。

现代骨架(同步开库、异步读写):

import * as SQLite from 'expo-sqlite';

const db = SQLite.openDatabaseSync('app.db');
db.execSync(`CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)`);

const rows = await db.getAllAsync('SELECT * FROM todo WHERE done = ?', 0);
await db.runAsync('UPDATE todo SET done = 1 WHERE id = ?', id);   // 写操作看 changes/lastInsertRowId

生产必用的成员就这几个:execAsync/execSync(多语句脚本)、getAllAsync/getFirstAsync(返回值)、runAsync(写)、backupDatabaseAsync(在线备份)。React 项目里更常见的形态是 <SQLiteProvider databaseName="app.db"> 包根组件 + useSQLiteContext() 拿库句柄;随包附带种子数据库用 SQLiteProvider 的 assetSource={{ assetId: require('./assets/seed.db') }}。热点查询用 db.prepareAsync(sql) 换 SQLiteStatement 复用(executeAsync/executeSync/getAllAsync,用完必须 finalizeAsync(),建议 try/finally)。

SDK 58 的三件事:

  • libSQL 移除。syncLibSQL()、libSQLOptions 没了,config plugin 的 useLibSQL 保留但不再有任何效果。54–57 期间用内置 libSQL 做远程同步的项目,升级时要么把同步层换成自己的方案(服务端 diff + 批量 upsert、或换驱动库),要么整体迁移到 Turso 生态的独立 SDK,Expo 官方不再代管这条路。
  • db.interruptSync()(58 新增):取消该连接上正在跑的语句,立即返回;要 await 那些在途操作观察它们的报错后再 close。写"页面离开就取消慢查询"这类逻辑用它,比另开连接 closeAsync 体面。注意中断写操作会回滚其所在的显式事务。
  • web 端 SQLite.configureWeb(options):加载自带的 wa-sqlite WebAssembly 构建。Expo 默认 web 打包不带全文检索,需要 FTS5 就编译一个含 fts5 的 wa-sqlite 再 configureWeb() 挂进去。

另外 expo-sqlite 随包附带了 agent skill:npx @expo/agent-cli@latest install expo-sqlite 把官方最佳实践喂给编码 agent(见 02)。坑:迁移别手写一堆 ALTER TABLE 拼 try/catch,库级版本管理看社区方案(如 drizzle-orm/expo-sqlite 或自管 PRAGMA user_version);同一连接上的操作按到达顺序执行,重事务会堵住后续读写,慢查询要拿 withExclusiveTransactionAsync 圈住并及时 interruptSync();真到了并发瓶颈再考虑拆库。

expo/fetch 与 expo-network:别找不存在的包

没有 expo-fetch 这个包。网络请求直接用全局 fetch;expo 主包还提供 WinterCG 兼容的实现 import { fetch } from 'expo/fetch'(支持流式响应,file-system 的下载文档就拿它举例;按 v58 文档的说明,它在 Android/iOS 上还装成了全局 fetch,想退回 RN 自带实现设 EXPO_PUBLIC_USE_RN_FETCH=1)。要"设备当前联网状态"用 expo-network:Network.getNetworkStateAsync()(返回 isConnected/isInternetReachable/type,type 是 NetworkStateType.CELLULAR 这类枚举)、hook 版 useNetworkState()、监听 addNetworkStateListener(),外加 getIpAddressAsync()/isAirplaneModeEnabledAsync()。58 的文档把 URL/URLSearchParams 重写成带 IDNA/TR-46 的实现、TextDecoder 提速,都是 expo 主包提供的 polyfill,代码不用改。

expo-web-browser:OAuth 的正解

WebBrowser.openBrowserAsync(url) 打开应用内受控浏览器(Custom Tab / SFSafariViewController,不是内嵌 WebView,登录态在系统浏览器里);OAuth 闭环用 openAuthSessionAsync(url, redirectUrl) → 等 scheme 回跳 → maybeCompleteAuthSession()。配合 expo-auth-session 的完整授权码流程见官方 guides;要点是 app config 必须配好 scheme(03),回跳解析可以借 expo-linking。网页里"复制链接到外部浏览器"是用户习惯,别用 WebView 硬扛第三方登录页——风控和体验都是坑。

expo-crypto:摘要、随机数与 AES

import * as Crypto from 'expo-crypto';

const sha = await Crypto.digestStringAsync(
  Crypto.CryptoDigestAlgorithm.SHA256, 'hello');          // 对字符串
const bytes = Crypto.getRandomBytes(32);                  // 同步亦可,WebCrypto 风格

和文件摘要分工清楚:散列文件内容用 expo-file-system 的 File.digest()('MD5'|'SHA-1'|'SHA-256'|'SHA-384'|'SHA-512',58 起取代弃用的 File.md5),expo-crypto 负责内存里的字符串/字节与 aesEncryptAsync/aesDecryptAsync(AES-GCM)。expo-random 已弃用——它的功能整体并入了 expo-crypto(getRandomBytes/getRandomBytesAsync/getRandomValues/randomUUID),新项目别再装。

文件与媒体

expo-file-system:53 起的对象式 API

先校准新旧两代心智,SDK 53 引入 File/Directory/Paths 三件套,58 继续在它上面动刀:

import { File, Directory, Paths } from 'expo-file-system';

const file = new File(Paths.cache, 'example.txt');
file.create();                       // 已存在会 throw;可 create({ overwrite: true })
await file.write('Hello');           // 58 起 write() 是异步;同步用 file.writeSync('Hello')
const text = file.textSync();
const b64 = file.base64Sync();
const md5 = await file.digest('MD5');            // 58 新增,取代弃用的 file.md5 属性

Paths 给出 document/cache/bundle/appleSharedContainers 与 availableDiskSpace/totalDiskSpace,拼路径用 Paths.join/dirname/basename,不要再手搓字符串和 FileSystem.documentDirectory。旧静态 API(readAsStringAsync、writeAsStringAsync、makeDirectoryAsync 等)已从主入口移入 expo-file-system/legacy:主入口调用会 throw,老代码要么改 import expo-file-system/legacy 续命,要么按上面的形状迁到 File/Directory——反正都是要还的技术债。

58 的两个动作面较大:File.write() 变异步是破坏性变更(await 补上,或显式 writeSync());新增 File.preview()/canPreview() 走系统预览(iOS 的 Quick Look 一类,可设 title/mimeType;preview(input, options) 还能一次预览多个文件),做"点开附件直接看"的功能省掉自建查看器。下载上传用 File.downloadFileAsync(url, destination) 和实例方法 file.upload(url, { uploadType: UploadType.MULTIPART })(默认 BINARY_CONTENT;表单域从 file.formData() 拿),断点续传/进度回调才需要 createUploadTask/createDownloadTask。Android 分区存储的现实:应用沙盒外挑来的文件 URI 权限随重启失效,想留住就 copy 进自己的目录;53+ 的 File/Directory 全程 URI 思维,比旧 path 字符串难错。

expo-image:跨端图片的事实标准

react-native 自带 Image 的缓存和占位能力约等于没有,列表场景一律换 expo-image(react-native/02 里那节组件层对比不重复):

import { Image } from 'expo-image';

<Image
  source={{ uri }}
  style={styles.avatar}
  contentFit="cover"                 // 替代 resizeMode,值更直白
  cachePolicy="memory-disk"          // 默认 'disk':none|disk|memory|memory-disk
  placeholder={{ blurhash }}
  transition={200}
  recyclingKey={item.id}             // 长列表里防错位
/>

生产要点:source 传对象时支持 { uri, headers }(带鉴权头的图片 URL 能直接喂),iOS 还能用 sf:star.fill 这样的 SF Symbols 名当 source;预热用 Image.prefetch(urls);缓存治理用 Image.clearMemoryCache()/clearDiskCache()。58 新增:transition={{ duration: 300, skipOnCacheHit: 'all' }}('none'|'memory'|'all',首次出现命中缓存就不重放过渡动画——修掉了"每次切 tab 头像都淡入一遍"这个老毛病);svgVariables={{ '--stroke-width': 2 }} 给 SVG 源码里的 CSS var() 自定义属性填值,同一份 SVG 在不同上下文染不同色且保持矢量,注意值会被 tintColor 覆盖(tintColor 是整幅刷色)。tintColor 本身只对模板图生效,别指望它给照片调色。

expo-camera:声明式相机视图

import { CameraView, useCameraPermissions } from 'expo-camera';

const [perm, requestPerm] = useCameraPermissions();
// 渲染:<CameraView ref={ref} style={...} facing="back" mode="photo"
//        barcodeScannerSettings={{ barcodeTypes: ['qr'] }} onBarcodeScanned={handleQR} />
const photo = await ref.current?.takePictureAsync({ quality: 0.8 });
const video = await ref.current?.recordAsync({ maxDuration: 30 });

权限:requestCameraPermissionsAsync()(麦克风另走 useMicrophonePermissions),iOS 用途文案由 config plugin 写进 Info.plist(03)。扫码除传统 onBarcodeScanned 外还有更准的 onModernBarcodeScanned(可用 isModernBarcodeScannerAvailable 探测)。58 加了 CameraView.scanDocumentAsync(options):直接唤起系统级多页文档扫描(iOS VisionKit、Android ML Kit Doc Scanner),返回页图与可选 PDF(requestPdf: true),自动落到缓存目录——要留存请立刻 File copy 到 Paths.document,别赌系统不清理。这个 API 意味着过去为"拍纸质单据"引第三方 SDK 的需求可以直接删了。

expo-video(与 expo-av 的葬礼)

视频播放一律 expo-video:

import { useVideoPlayer, VideoView } from 'expo-video';

const player = useVideoPlayer(source, p => { p.loop = true; p.play(); });
return <VideoView player={player} style={styles.video} nativeControls />;

useVideoPlayer 管生命周期(卸载自动 release());要跨屏存活用 createVideoPlayer 并手动 release()(不 release 必泄漏)。进阶成员:跳转是给 player.currentTime 赋值(或用 seekBy(seconds)),player.muted/playbackRate 都是普通属性;状态靠事件监听——useEventListener(player, 'statusChange', …)(来自 expo 包的通用 hook)或 player.addListener('timeUpdate'|'playingChange'|…, fn);VideoView ref 有 enterFullscreen();预加载即提前建 player 灌缓冲。

expo-av 的当前状态:SDK 52 起弃用,音频迁 expo-audio、视频迁 expo-video;截至 2026-10-20,v58 文档索引里已无 expo-av 页面(/sdk/av 直接重定向到 expo-audio),npm latest 停在 16.0.8 不再随 SDK 发版。存量 Video/Audio 组件迁移是纯机械活:Audio 部分对着 expo-audio 的 player API 重写,Video 部分换 VideoView,没有兼容层。

expo-audio:hooks 化的声音管线

新 API 全是 hook + 实例对象(老 Sound 类那套作废):

import { useAudioPlayer, useAudioRecorder, useAudioRecorderState,
         AudioModule, RecordingPresets, setAudioModeAsync } from 'expo-audio';

// 录音
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const state = useAudioRecorderState(recorder);
await AudioModule.requestRecordingPermissionsAsync();
await recorder.prepareToRecordAsync(); recorder.record();
// 播放 + 锁屏(58 新增)
const player = useAudioPlayer(remoteAudioUri);
player.setActiveForLockScreen(true, { title: '歌名', artist: '作者' });
player.updateLockScreenMetadata({ title: '下一首' });

setAudioModeAsync({ playsInSilentMode: true, interruptionMode: 'mixWithOthers'|'doNotMix' }) 决定与铃声/其他 app 的关系,iOS 后台播放还要声明 background mode(03)。58 的两块新肉:锁屏播放列表控制(useAudioPlaylist 配套 setActiveForLockScreen 的队列元数据,系统音乐卡片可切歌);AudioStream 把麦克风 PCM 帧流出来(useAudioStream),startFileRecordingAsync 可把流直接写成 WAV/PCM 文件——做语音备忘录/上传定长 WAV 不再需要自己封头。权限被拒后 record() 抛错,UI 要有 fallback。

expo-image-picker 与 expo-media-library:挑图与存库两件事

选图用 image-picker(它自带临时权限,多数场景不需要 media-library 权限):

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

const r = await ImagePicker.launchImageLibraryAsync({
  mediaTypes: ['images'],           // 'images'|'videos'|'livePhotos',可数组
  quality: 0.8, allowsEditing: true,
});
if (!r.canceled) useFile(r.assets[0].uri);

拍照 launchCameraAsync;useMediaLibraryPermissions({ writeOnly: true }) 对应 iOS “仅添加"授权。web 端必须在用户手势里调用,否则被浏览器拦截。要存回系统相册或翻相册才上 media-library:MediaLibrary.requestPermissionsAsync()、createAssetAsync(localUri, album)/saveToLibraryAsync(uri)、getAssetsAsync({ first: 50 }),较新的文档还给了 new Query().limit(1).eq(AssetField.MEDIA_TYPE, MediaType.IMAGE).exe() 这样的链式查询构造器。58 破坏性变更(iOS):Asset.getUri() 与 AssetInfo.uri 现在解析 Photos 里的当前版本(用户在相册编辑过就给你编辑后的),要拿原始文件传 version: AssetUriVersion.ORIGINAL;慢动作/裁剪视频解析当前版本会走导出,明显变慢——依赖"uri 指向上传时的原件"做去重的逻辑记得钉死 ORIGINAL。

expo-sensors 与 expo-haptics

传感器一个包全家桶:Accelerometer、Gyroscope、Magnetometer、Barometer(气压计在这个包里,没有独立 expo-barometer)、DeviceMotion、LightSensor、Pedometer,每类同构 API——setUpdateInterval(ms) + addListener(cb) + isAvailableAsync() + requestPermissionsAsync()。老教程的 enableAsync/startObserving 四步舞是旧 API,现在直接 addListener 即可。用 requestPermissionsAsync 后记得在 app config 填传感器用途(03),iOS 隐私政策审核盯得很紧。触觉反馈 expo-haptics:Haptics.selectionAsync()(列表勾选)、impactAsync(Haptics.ImpactFeedbackStyle.Medium)(碰撞)、notificationAsync(Haptics.NotificationFeedbackType.Success|Warning|Error)(结果),三发足够覆盖 90% 场景;Android 上是振动马达,力度感知和 iOS 不同,别用它传达语义差异过强的信息。

位置与设备能力

expo-location:经典 API 与 /next 预览版并存

经典一套仍是生产主力:

import * as Location from 'expo-location';

const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
await Location.enableNetworkProviderAsync();      // Android 提示开定位服务
const pos = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
const sub = await Location.watchPositionAsync({ accuracy: Location.Accuracy.Balanced,
  timeInterval: 5000, distanceInterval: 50 }, p => render(p));

逆地理编码 reverseGeocodeAsync 也在本包。后台持续定位是三步组合:TaskManager.defineTask 在顶层注册任务(见下文 expo-task-manager)、Location.startLocationUpdatesAsync(taskName) 把更新喂给它、iOS 声明 location background mode 并用 requestBackgroundPermissionsAsync 拿 “Always”——旧文档里的 enableBackgroundLocationAsync 已不在 SDK 58 的 API 面。这套组合正是 App Store 2.5.4 审核条款的重灾区。

58 开始提供 expo-location/next 预览版,按 changelog 原文,它围绕 provider 抽象(LocationProvider,可 setLocationProvider(Android())/Gms()/Fallback(providers) 组合,Android 终于能区分系统服务与原生 provider)、更简的位置 API(Location.getPosition()/watchPosition() 返回 handle,带 pause/resume/restart/dispose)、独立的 Android 前台 service 后台更新(defineLocationTask + ensureBackgroundSessionStarted)、以及 iOS 临时全精度授权(系统处于"大致位置"时按场景弹一次"临时精确”,purpose 串由插件的 locationFullAccuracyPermission 写)。另有 hooks:useForegroundLocationPermissions、useLocationServices 等。同时经典 API 也吃到一个红利:iOS 地理围栏(geofencing)不再要求 location background mode——只用围栏过去的包常因"申请了 background location 却没有常驻用途"被 2.5.4 拒。两套怎么选:截至 2026-10-20 官方明说 next 是 preview 并持续把新能力回灌 58;存量项目别整体重写,继续用经典 API;新项目如果只要"前台拿位置 + 按需背景",可以从 expo-location/next 起步,接受它的 API 可能继续动。

expo-localization:语言/区域与 RTL

import { getLocales, useLocales } from 'expo-localization';
const [{ languageTag, regionCode, currencyCode }] = getLocales();  // 'zh-CN', 'CN', 'CNY'

配合 JS 引擎原生 Intl(new Intl.NumberFormat(languageTag, …))做数字/日期格式化,喂给 i18next/lingui 做文案(选型见各库文档)。Android 用户改语言不重启 app 时值会变,useLocales() 会自动重渲染;iOS 改设置要冷启动才生效。58 破坏性变更:iOS 不再依据设备语言经 supportsRTL 强开 RTL,布局方向完全交回 RN 的 I18nManager——阿拉伯语用户过去被自动 RTL 的,升级后若没配 I18nManager.forceRTL(true)(且该开关要求冷启动生效)会变 LTR。方向性布局回归显式控制是好事,但升级说明里要给双语用户一句。

expo-secure-store:钥匙串里的一个键值对

import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('refresh_token', token);
const t = await SecureStore.getItemAsync('refresh_token', { requireAuthentication: true });

iOS 落 Keychain(kSecClassGenericPassword),Android 落 Keystore 加密的 SharedPreferences。API 面就 get/set/delete + isAvailableAsync/canUseBiometricAuthentication,选项 SecureStoreOptions 里值得记的:iOS accessGroup(跨 app 共享)、keychainAccessible(默认 WHEN_UNLOCKED)、requireAuthentication(生物识别门);58 新增 requireConfirmation(Android,默认 true):requireAuthentication 打开后,即使指纹还在验证器上,也要求用户按一次确认键才放行。三个坑背熟:单值约 2048 字节以上可能被 iOS 拒(官方文档口径是"历史上部分 iOS 版本如此",Expo 自己不设限但原生错误要接住);iOS Keychain 数据卸载后仍在,同 bundleId 重装会复活旧 token,登出逻辑要显式 deleteItemAsync;requireAuthentication 在 Expo Go 里因缺 NSFaceIDUsageDescription 不可用,且模拟器不逼真实生物识别——这类功能必须真机 dev build 测。它不是通用存储:装不下就存文件、把 key 藏进 SecureStore。

expo-clipboard:文本、图片与 URL

import * as Clipboard from 'expo-clipboard';
await Clipboard.setStringAsync('https://example.com', { android: { isSensitive: true } });
const text = await Clipboard.getStringAsync();

53 那一轮重写后全是异步命名(老同步 Clipboard.setString 从教程里消失),新增的是类型感知 API:hasStringAsync/hasImageAsync/hasUrlAsync、setUrlAsync/getUrlAsync、图片 base64 的 setImageAsync/getImageAsync,addClipboardListener 监听外部复制(iOS 14+/Android 13+),isPasteButtonAvailable + <ClipboardPasteButton> 走 iOS 免读权限的原生粘贴按钮。敏感内容(密码/OTP/token)传 android.isSensitive 提示系统不落剪贴板历史;Android 13+ 读剪贴板有横幅和权限墙,只在用户明确要粘贴时才读。

通知与用户交互

expo-notifications:本地 + Expo 推送

58 的行为大变更先记:前台收到通知默认就展示(banner、列表、声音、badge 全套),直到你的 handler 否决——过去"忘了 setNotificationHandler 前台就静默"的默认没了;handler 超时 3 秒不再丢通知,而是按默认展示。所以 handler 是"否决器"而不是"开关",忘了写就等于每条都响。

import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';

// 模块作用域,越早越好
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,          // shouldShowAlert 已弃用
    shouldShowList: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

async function registerForPush(userId: string) {
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: '默认', importance: Notifications.AndroidImportance.HIGH,
      vibrationPattern: [0, 250, 250, 250], lightColor: '#FF231F7C',
    });
  }
  const { status } = await Notifications.requestPermissionsAsync();
  if (status !== 'granted') return null;
  return (await Notifications.getExpoPushTokenAsync({
    projectId: Constants.expoConfig!.extra!.eas.projectId,
  })).token;
}

Android 13+ 要先建 channel 再取 token,否则权限弹窗都不出。token 两套:Expo 通道用 getExpoPushTokenAsync(配合 EAS Push,凭证由 EAS 托管,见 06);自建后端直连 APNs/FCM 用 getDevicePushTokenAsync(),APNs key 上传 eas credentials。58 的增量:iOS 用 contentAvailable + threadIdentifier 让通知中心按线程分组堆叠(Android 侧同名参数是 group key);scheduleNotificationAsync({ trigger: { type: 'date', channelId, delivery: 'alarmClock' } }) 的 delivery: 'alarmClock' 走 AlarmManager.setAlarmClock(),OEM 省电不杀、状态栏挂闹钟图标,但要 SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM 权限(requestExactAlarmPermissionsAsync() 申请,没有就自动降级 bestEffort——闹钟类 app 必配);config plugin 新增 largeIcon 属性配 Android 大图标。本地交互(类别按钮)用 setNotificationCategoryAsync(identifier, actions, options) 注册、getNotificationCategoriesAsync() 回读,tap 后用 useLastNotificationResponse 拿载荷做路由跳转。Expo Go 里安卓推送基本不可用(共享 FCM 项目),推送一律在 dev build 验(05)。

expo-task-manager:背景 JS 的执行台

TaskManager.defineTask(name, async ({ data, error }) => { … }) 必须在模块作用域执行(Expo Router 项目放 tasks.ts 并在根 _layout.tsx 顶格 import,否则导航先跑任务未注册)。配套 getRegisteredTasksAsync 回读、unregisterTaskAsync(taskName)/unregisterAllTasksAsync() 清理。它是 expo-notifications 后台任务(registerTaskAsync)和 expo-location 背景 session 的底座;后台任务里禁止依赖 React 树——它是独立加载 bundle 的 headless 执行。

expo-app-intents:把 app 动作交给 Siri 与 Apple Intelligence(alpha)

58 新增、alpha 状态,接口会频繁破坏。心智:App Intents 是 iOS 侧的"动作与实体的类型声明",Siri/Shortcuts/Spotlight/Apple Intelligence 靠它理解你的 app。包负责的是 JS↔原生的桥:intent 本体在 app-intents/ 目录用 Swift inline module 声明(编译期被 Apple 提取元数据),JS 侧 useAppIntents(handler) 接调用、更新实体数据(包内建实体存储)。初始化跑 npx expo-app-intents init;agent skill 用 npx @expo/agent-cli@latest install expo-app-intents 装。还带 Spotlight 索引、intent donation(系统建议)、屏幕内容标识(appEntityIdentifier() 修饰 @expo/ui 视图、AppEntityView 包 RN 视图,需 iOS 18.4+ 与 Xcode 27 构建)。路线图:即将支持从 intent handler 触发 expo-task-manager 任务,JS 干活结果回给 Siri 而不打开 app。alpha 意味着:现在只适合尝鲜/内部实验,生产 app 别把关键路径压上去。

expo-widgets:桌面小组件与实时活动

58 的里程碑是 Android 支持:widget 跑在专用 Hermes runtime 上的独立 JS bundle(所以主 app 的 Context/hook 一概不存在),支持交互与 Material Colors。核心写法是 'widget' 指令标记的组件——只能用 @expo/ui/swift-ui 组件、无 hook、无异步:

// MyWidget.tsx —— 组件体第一行就是 'widget' 指令,bundler 据此把它编译进独立 bundle
import { Text, VStack } from '@expo/ui/swift-ui';
import { createWidget, type WidgetEnvironment } from 'expo-widgets';

const MyWidget = (props: { count: number }, environment: WidgetEnvironment) => {
  'widget';
  return <VStack><Text>Count: {props.count} / {environment.widgetFamily}</Text></VStack>;
};
export default createWidget('MyWidget', MyWidget, { count: 0 }); // 名字要对上 app config

主 app 侧用它返回的句柄更新:MyWidget.updateSnapshot({ count: 5 })(立即生效的单帧)或 MyWidget.updateTimeline([{ date, props }, …])(排好时间线让系统自己切帧)、getTimeline() 回读;widget 里点按钮回 app 用 addUserInteractionListener。iOS Live Activity 侧 58 加了 LiveActivity.start(props, url, staleDate)/update(props, staleDate) 的 staleDate(过期后置灰,锁屏"已结束"状态的正规做法)与稳定 ActivityKit 标识符(跨重启引用同一活动)。目录与 config plugin(widgetsDirectory)见 03;Expo Go 不支持本包。

expo-hinge:翻盖与折叠屏的铰链(58 全新)

import { useHinge } from 'expo-hinge';
const hinge = useHinge();   // { angle: 0–180, status: 'closed'|'partiallyOpen'|'fullyOpen'|'unknown' } | null

getHinge()/addHingeListener() 是非 hook 入口,isAvailable() 判断构建与系统(iOS 27.1+ 且用 27.1 SDK 构建;Android 11 起折叠屏 API),注意 isAvailable() 为 true 只代表能力在,有没有铰链看返回值是否非 null(null 也可能是窗口没跨折痕)。还有个 Android 怪癖要记:系统不报 closed,合盖判断得靠 angle 近零自定阈值。SwiftUI 侧对应 onHingeChange 修饰符(@expo/ui),配 useWorkletCallback 让回调跑在 UI 线程做跟手的视差。官方对用途边界说得很直白:铰链状态用于交互与特效(桌拍模式当快门、半开即拍那种),不用于布局——折叠形态的布局走 safe area、ArrangementView 与可 resize 适配(iPhone Duo 适配总览见 05,布局基本盘见 react-native/03)。

外观与系统 UI

expo-glass-effect:Liquid Glass

import { GlassView, GlassContainer, isLiquidGlassAvailable } from 'expo-glass-effect';
if (isLiquidGlassAvailable()) { /* iOS 26+ 且用 iOS 27 SDK 构建返回 true(58 起) */ }

GlassView(glassEffectStyle="regular|clear|none"、tint、isInteractive,不支持的平台自动退化成 View)与 GlassContainer(玻璃间避免互相透视的分组)。风格切换可以给 glassEffectStyle 传配置对象 { style: 'clear', animate: true, animationDuration: 0.5 } 走原生淡入淡出(官方推荐的显隐方式,别改 opacity)——Apple 明确不建议玻璃或其父视图带低透明度,极低 opacity 下玻璃效果直接不渲染,要动画就用 Reanimated 包一层壳。58 起以 iOS 27 SDK 构建时 isLiquidGlassAvailable() 才为 true。

@expo/ui:SwiftUI/Compose 直达

入口分三个:@expo/ui(universal 跨端)、@expo/ui/swift-ui、@expo/ui/jetpack-compose——iOS 上不能用 swift-ui 入口之外的路径指望 web 渲染。<Host> 是原生视图树的挂载点,RNHostView 把 RN 组件嵌回去。58 的大货:List/LazyVStack/LazyHStack(iOS)与 LazyColumn/LazyRow(Android)改为回收虚拟化,写法是传 data + keyExtractor + 返回行的 children 函数(List.ForEach 等配套),长列表性能问题从"能不能用 @expo/ui"变成"会不会用回收";iOS 新增 NavigationStack/NavigationLink/NavigationDestination/Toolbar/navigationTitle,NavigationSplitView 支持两栏三栏。58 的弃用点记三个高频的:iOS backgroundOverlay→background;border/strokeBorder 的 color 参数→content;List.ForEach 用 JSX 子元素传数据的方式弃用(转 data/keyExtractor);Android RNHostView 删了 style prop(用 modifiers)、iOS <Host> 从居中改为顶对齐(flex: 1 行为与 Android 一致了)、HStack/VStack/GlassEffectContainer 未设 spacing 时转发 nil 用系统默认间距(想保留 0 间距要显式 spacing={0})。Router 与原生导航怎么混用见 04。

expo-symbols:一套图标两库通用

iOS/tvOS 用 SF Symbols,Android 与 web 用 Material Symbols:<SymbolView name={{ ios: 'sun.max.fill', android: 'wb_sunny' }} size={32} type="hierarchical" weight="semibold" />(tintColor、动画 animation 可选)。“跨平台同名图标"不存在——一个符号一个对象写两套名。58 起它从 router 的硬依赖变成可选 peer dependency:NativeTabs.Trigger.Icon 的 md 属性(Android Material 图标)要渲染才需要装它(见 04)。

expo-screen-orientation:iOS 27 后锁屏是尽力而为

常规:ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)、unlockAsync()、getOrientationAsync()、addEventListener('orientationDidChange')。相机类 app 常用的 ios.requireFullScreen 插件配置,58 的文档口径是iOS 27+ 不再豁免窗口 resize,随之 lockAsync 可能被系统拒绝、getOrientationAsync 描述的可能不是你窗口的实际形状(iPhone Mirroring 恒报 portrait)。新文档页 “Orientation locks on iOS 27 and later” 把现实讲透了:把方向当偏好而非保证,UI 按实际尺寸自适应(呼应 iPhone Duo/safe area,见 05 与 react-native/03)。iPad 分屏老问题同源于此。

expo-font:三条加载路径与可变字体

优先级:能 config plugin 静态打包就打包(构建期链接,首帧即可用,见 03);运行时确定才 useFonts({ RobotoFlex: require('./fonts/RobotoFlex.ttf') })(文档原话:useFonts only if you can’t use the config plugin);动态远程字体 Font.loadAsync(family, source)。iOS 找 family 名用 getLoadedFonts() 对(文件名≠内部名是经典坑)。58 给三端补齐可变字体:一个 TTF 携带多个字重/样式,用 fontWeight/fontStyle 选 face,配合 RN 0.88 新增的 fontVariationSettings 直接调 wght 轴。web 端字体注入走 expo-font 的 web 实现,字族名以加载配置为准。

expo-keep-awake 与 expo-brightness:一行流

useKeepAwake()、KeepAwake.activateKeepAwakeAsync(tag)(同步版 activateKeepAwake 已标记弃用)+ deactivateKeepAwake(tag)——扫码、导航、演示防熄屏;用 tag 多处激活时要成对逐个 deactivate 才会重新允许熄屏。Brightness.setSystemBrightnessAsync(0.8)(仅 Android,改的要 WRITE_SETTINGS 权限,usePermissions()/requestPermissionsAsync 会把你引到系统授权页)、setBrightnessAsync(app 内覆盖,iOS 只有这一档)、restoreSystemBrightnessAsync() 交还控制权。

调试与观测

expo-observe:生产遥测客户端

EAS Observe 平台侧(指标看板、按 update 对比)见 07。JS 面最短路径:根布局包 ObserveRoot.wrap 自动记 Time to First Render,就绪时 useObserve().markInteractive()(或声明式 <ObserveInteractiveMarker params={{…}} />)记 TTI;自定义事件 Observe.logEvent(name, options);错误 Observe.reportError(err) 或 ObserveErrorBoundary。58 新增 Observe.configure({ errorHandlingEnabled: false }) 关掉"未捕获 JS 错误自动上报”(自有 Sentry 之类会双报时实用),以及 Observe.clientId——一个安装级伪匿名 ID,作为 expo.eas_client.id 挂在所有 Observe 事件上,跨服务对账就拿它。debug 构建默认不上报。AppMetrics 命名(SDK 56 及以前)已弃用,一律迁 Observe。

expo-modules-core 与错误呈现

写模块才直接碰它(见 09),但升级有两个面:JS 值层面 NativeArrayBuffer 弃用,RN 0.88 给 ArrayBuffer 提供了原生双端表示,二进制 API 直接改标 ArrayBuffer,迁移机械但全仓库 grep 一遍。iPhone Duo 适配里新增的 SceneGeometry.anchorPopover 帮助函数(弹层在任意 regular-width 屏上正确锚定)留给模块作者。另外 @expo/metro-config 不再折叠 node_modules 栈帧、@expo/log-box 与终端现在能显示"全折叠栈"的真实调用帧——库内抛错定位比 57 省时间,无代码可改。

选包指南

需求 用什么
读 app 配置/运行环境 expo-constants(expoConfig、executionEnvironment)
机型/内存/是否模拟器 expo-device
首帧底色、下拉背板色 expo-system-ui
状态栏 expo-status-bar
开屏/揭幕时机 expo-splash-screen
深链/URL 生成解析 expo-linking(Router 内见 04)
热更新客户端 API expo-updates(机制与流程见 07)
本地结构化数据库 expo-sqlite
联网状态/飞行模式 expo-network
外部浏览器 OAuth expo-web-browser(+ expo-auth-session)
哈希/随机数/AES-GCM expo-crypto(文件摘要用 file-system 的 digest)
文件读写/下载/预览 expo-file-system(File/Directory/Paths)
图片展示/缓存 expo-image
拍照/录像/扫码/文档扫描 expo-camera
视频播放 expo-video;音频播放录音 expo-audio
相册选图/存回相册 expo-image-picker / expo-media-library
传感器/震动反馈 expo-sensors / expo-haptics
定位/围栏 expo-location(新实验可看 /next)
语言地区/RTL expo-localization
小密钥安全存储 expo-secure-store(大数据别塞)
剪贴板 expo-clipboard
通知与推送 expo-notifications(后台任务 expo-task-manager)
Siri/App Intents expo-app-intents(alpha)
桌面小组件/实时活动 expo-widgets(Android 为 58 起)
折叠屏铰链 expo-hinge
Liquid Glass/原生 UI expo-glass-effect、@expo/ui、expo-symbols
屏幕方向/常亮/亮度/字体 expo-screen-orientation / expo-keep-awake / expo-brightness / expo-font
生产监控埋点 expo-observe

实战建议

模块几十个,记不住是常态,查的路要熟:docs.expo.dev/versions/latest/ 按包名列全部模块 API(每页顶部表格标 Expo Go 支持),第三方组件到 reactnative.directory 看维护状态与新架构适配再决定装不装。三条老手习惯:其一,带权限的模块(camera/location/microphone/media-library/notifications)在 iOS 都要 purpose string,统一在 app config 用对应 config plugin 一次配好(03-应用配置与插件.md),升级时跑 npx expo install --fix 让版本表自洽,配合 expo-doctor(11)。其二,本章的 58 变更里真正会咬人的三处提前部署:File.write() 变异步(搜全部无 await 的调用点)、expo-sqlite 的 libSQL 残留(syncLibSQL/useLibSQL 全删)、media-library 的 getUri 语义(需要原件的地方补 AssetUriVersion.ORIGINAL)。其三,alpha/preview 的包(app-intents、expo-location/next)只进实验分支,别让它们在发版主干上裸奔;反过来,GA 的东西别怕用——camera 的 scanDocument、notifications 的 alarmClock、observe 的 clientId 这类"以前要引第三方 SDK 的功能",先在本章清单里找一圈官方模块,十有八九有现成的。