1. 为什么我会把 Expo Router 和 Supabase 组合在一起
1.1 先理清两个东西各自是什么
先说结论:Expo Router 解决的是“移动端页面怎么组织、怎么跳转、怎么处理深链”,Supabase 解决的是“用户系统、数据库、实时订阅、文件存储这些后端能力”。这两个东西单独拎出来都不算新鲜,但放在一起以后,整个项目的前端路由和后端权限能形成一套非常顺的闭环。
如果你之前用过 React Navigation,应该对那种手动注册 Stack、Tab、Drawer 的路由写法有印象。Expo Router 是 Expo 官方主推的文件路由方案,玩法跟 Next.js 很像:你往app/目录里放一个文件,它就自动变成一条路由。目录嵌套、动态路由、布局嵌套、深链映射全是文件系统说了算。对独立开发者来说,最直观的好处是少写大量样板代码,项目结构也一眼能看懂。
Supabase 这边,本质是一个托管 Postgres 数据库加一堆现成服务。其中我们日常用得最多的是 Auth 和 Database。Auth 帮你把注册、登录、邮箱验证、第三方 OAuth、密码重置全部接好;Database 就是 Postgres,支持 Row Level Security(RLS),可以在数据库层面控制用户只能读自己的数据。这一点在移动端特别关键,因为你给客户端发的 anon key 本质上是公开的,真正的安全边界必须设在数据库里,而不是靠前端代码隐藏。
1.2 它们组合起来最舒服的地方
我第一次把这两个东西拼起来的时候,最直观的感受是:路由守卫和登录态居然可以绑得这么直接。
Expo Router 的路由是文件驱动的,所以你能在布局文件里做“登录墙”。比如app/(tabs)/_layout.tsx这个文件就是整个 Tab 区域的父布局,我在里面读一下当前用户 session,没登录就直接<Redirect href="/sign-in" />。所有需要登录才能访问的页面,全部塞进这个目录组里,不用在每个页面手动判断一次登录态。
Supabase 的 Auth 又恰好提供了一个全局的onAuthStateChange事件。登录、登出、token 刷新都会触发它。把这两个机制接起来以后,业务代码里几乎不用关心用户状态是怎么变更的。用户登录成功,路由自动跳进去;用户 token 失效,页面自动被弹回登录页。这种体验不是靠堆页面判断实现的,而是从一开始就由路由结构决定的。
1.3 适合谁,不适合谁
这个组合最典型的适用场景是:你需要快速做一个带账号体系的 MVP,或者你是个人开发、小团队,没有专门后端人力。我用它做过一个内部工具 App,从零到能提交测试,大概一个周末就差不多了,后端那套注册登录、数据库建表、权限策略全在 Supabase Dashboard 上搞定。
但如果你要做的是强实时、强离线、复杂事务型应用,或者你们的后端已经有完整 API 体系,那 Supabase 未必是最优选。它不是不能做复杂场景,而是你要额外支付学习成本去理解 RLS 策略、实时订阅、边缘函数这些概念。说白了,Expo Router + Supabase 是把“常见业务后端需求”压缩成了配置文件,但它不会替你设计数据模型和业务边界。
2. 初始化项目:从 create-expo-app 到能启动的最小骨架
2.1 脚手架命令
我习惯直接用官方脚手架,带 TypeScript 和 Expo Router 模板,省得手动搭:
npx create-expo-app@latest expo-supabase-demo --template tabs cd expo-supabase-demo这里--template tabs会生成一个带 Tab 导航的 Expo Router 项目。如果你用的是默认模板,大概率也已经内置 Expo Router 了,只是目录结构不一定有(tabs)分组。脚手架装完以后,先跑一次:
npx expo start确认模拟器或 Expo Go 里能打开默认页面,再往后加东西。不要一上来就把 Supabase 的代码堆进去,否则出了问题你根本分不清是路由问题还是后端问题。
2.2 依赖安装和版本注意事项
需要安装的依赖并不多:
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage react-native-url-polyfill解释一下为什么是这三个:
@supabase/supabase-js:Supabase 的官方 JavaScript 客户端,它的 Auth、数据库查询、Realtime 全在这里面。@react-native-async-storage/async-storage:用于持久化 Supabase 的登录会话,这样 App 杀掉重开以后用户不用重新登录。react-native-url-polyfill:React Native 环境里 URL 实现不完整,Supabase 客户端在解析 Auth 回调地址时依赖标准 URL API,不装这个包会出现类似URL is not defined或者Cannot read property 'search' of undefined的报错。
我遇到过不少人在这一步直接npm install @supabase/supabase-js就开始了,结果跑到登录回调时各种莫名奇妙的问题。这里建议用npx expo install而不是npm install,因为 Expo 会帮你匹配当前 SDK 下兼容的依赖版本,尤其是 AsyncStorage 这类原生模块,版本不对是跑不起来的。
2.3 先建一个能跑的目录结构
装完之后,我给项目规划了这样一套目录结构:
app/ _layout.tsx (auth)/ _layout.tsx sign-in.tsx sign-up.tsx (tabs)/ _layout.tsx index.tsx profile.tsx lib/ supabase.ts auth.tsx database.types.ts括号目录名(auth)、(tabs)是 Expo Router 的分组语法,它不会出现在 URL 路径里,只用于组织布局和路由层级。比如app/(tabs)/index.tsx对应的实际路径是/,app/(auth)/sign-in.tsx对应的实际路径是/sign-in。
这种结构的意义在于:业务页面和认证页面从物理路径上就被分开了,后面做登录墙只需要在(tabs)和(auth)两个分组各自的_layout.tsx里写判断逻辑,不需要在几十个页面里重复判断用户状态。
3. 初始化 Supabase 客户端:最大的坑全在这里
3.1 为什么必须引入 URL Polyfill
这一步是我见过翻车率最高的地方。
Supabase 客户端内部要处理 OAuth 回调、密码重置链接、URL 参数解析,这些逻辑依赖浏览器标准的URL和URLSearchParams。React Native 的 JavaScript 引擎并不完整实现这些 API,尤其是 Android 上,很多 polyfill 不会自动生效。
解决办法很简单,在创建 Supabase 客户端之前先引入:
import 'react-native-url-polyfill/auto'注意是“在创建客户端之前”,最好放在lib/supabase.ts的第一行。我见过有人把 polyfill 放在入口文件里,但 Supabase 客户端在别的模块里先被加载了,依然会报错。这个 import 顺序问题用语言很难查出来,因为报错信息往往五花八门,比如Invalid URL、Network request failed、URL is undefined,很容易被误判成网络问题。
3.2 客户端实例的正确配置
lib/supabase.ts我一般这么写:
import 'react-native-url-polyfill/auto' import AsyncStorage from '@react-native-async-storage/async-storage' import { createClient } from '@supabase/supabase-js' import type { Database } from './database.types' const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL! const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY! export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, }, })这里有一个关键参数:detectSessionInUrl: false。
Supabase 这个参数的默认行为是在 URL 里检测 session,比如 Web 端通过链接携带 access_token 完成认证。但在 React Native 里,没有浏览器地址栏这个概念,如果不关闭,客户端可能去监听一个不存在的 URL,导致登录回调处理失败。移动端统一改成false是标准做法。
persistSession和autoRefreshToken保持true,意思分别是“把 session 存到本地”和“token 快过期时自动刷新”。这两个不开的话,用户每次冷启动都可能要重新登录。
3.3 环境变量怎么配才安全
在项目根目录创建.env文件:
EXPO_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key从 Supabase Dashboard 的 Project Settings -> API 里能复制这两个值。
注意EXPO_PUBLIC_前缀是硬性要求。Expo 的编译机制只把带这个前缀的变量注入到客户端代码里。如果你写成SUPABASE_URL,代码里process.env.SUPABASE_URL编译出来会是undefined。
另一个事实需要摆清楚:anon key本身不算秘密,它是要发到客户端的,任何人反编译 App 都能拿到。所以不要指望没人看见这个 key,真正的安全防线是后文要讲的 RLS。这也意味着.env里不要放service_rolekey,那个是全权限总管理 key,泄露等于数据库裸奔。
3.4 两种存储后端:AsyncStorage 与 SecureStore
官方示例默认用 AsyncStorage,用来存 session token。优点是零配置,跨平台稳定,缺点是它是明文存储,安全性一般。
如果做生产级应用,我更建议用expo-secure-store包装一个 storage adapter,token 会存进系统的 Keychain / Keystore,相对更难被窃取:
import * as SecureStore from 'expo-secure-store' const SecureStorage = { getItem: (key: string) => SecureStore.getItemAsync(key), setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value), removeItem: (key: string) => SecureStore.deleteItemAsync(key), }然后把storage换成SecureStorage。要注意 SecureStore 在 Web 端不可用,如果你的项目同时要发 Web 端,需要判断平台选择存储实现。对纯移动端项目,我推荐直接上 SecureStore,毕竟 auth token 属于敏感信息,能多点防护就多点防护。
4. 认证状态管理:全局 Provider 是必备胶水
4.1 不封装 Context 会有什么后果
你可以直接在页面里调用supabase.auth.getSession(),这样获取登录态确实能用,但不建议。原因有两个。
第一,页面各自获取 session,登录态更新后不会自动通知所有组件。比如用户在“我的”页面改完头像,首页的展示却还是旧头像,你还得手动写事件总线或者反复刷新。
第二,路由守卫需要读登录态。如果你在布局文件里手动调getSession(),每次路由变化都要额外发起一次异步操作,不仅慢,而且容易在 loading 态里出现白屏闪烁。
正确做法是把 session 放进 React Context,让整个应用树都能响应登录态变化。
4.2 AuthProvider 代码拆解
lib/auth.tsx我一般这么写:
import { Session } from '@supabase/supabase-js' import * as React from 'react' import { supabase } from './supabase' const AuthContext = React.createContext<{ session: Session | null loading: boolean }>({ session: null, loading: true, }) export function AuthProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = React.useState<Session | null>(null) const [loading, setLoading] = React.useState(true) React.useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { setSession(session) setLoading(false) }) const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, newSession) => { setSession(newSession) setLoading(false) }) return () => subscription.unsubscribe() }, []) return ( <AuthContext.Provider value={{ session, loading }}> {children} </AuthContext.Provider> ) } export const useAuth = () => React.useContext(AuthContext)然后把根布局包一层:
import { Stack } from 'expo-router' import { AuthProvider } from '@/lib/auth' export default function RootLayout() { return ( <AuthProvider> <Stack screenOptions={{ headerShown: false }} /> </AuthProvider> ) }4.3 为什么 getSession 和 onAuthStateChange 要一起用
这里是我看到很多人会漏掉的细节。
getSession()是“启动时先查一次本地已有的 session”,它是被动读取,适合 App 冷启动恢复登录态。但只有它不够,因为如果 Supabase 检测到 token 刷新了,或者另一个地方发生了登录、登出,前端不会自动收到通知。
onAuthStateChange()是“注册一个长期生效的监听器”,事件发生时主动推送新 session。这两个必须配合:一个负责初始化,一个负责后续更新。
有一点要注意:onAuthStateChange的回调里不要再去调用supabase.auth.getSession()或者supabase.auth.setSession(),否则事件回环可能造成重复请求甚至死循环。回调里只做setSession就足够了。
5. 路由守卫:用 Expo Router 做登录墙
5.1 目录分组:把业务路由和认证路由物理隔离
我在第二节规划的(auth)和(tabs)两个分组,到这一步开始体现作用。
(auth)组里放登录、注册、找回密码这类公开页面;(tabs)组里放所有需要登录才能访问的业务页面。两个分组各自有独立的_layout.tsx,所以守卫逻辑非常集中。
你可能会问:为什么不直接在app/_layout.tsx里判断一次,然后决定渲染哪组?
理论上可以,但 Expo Router 的路由注册是文件系统决定的,根布局里做条件渲染有时会导致路由表不稳定,尤其是动态切换时容易出现 “Screen is not registered” 这类问题。我的实践是用分组布局做守卫,每个分组只负责判断自己管辖的路由,逻辑更干净。
5.2(auth)和(tabs)两组布局的守卫代码
先看app/(auth)/_layout.tsx:
import { Redirect, Stack } from 'expo-router' import { useAuth } from '@/lib/auth' export default function AuthLayout() { const { session, loading } = useAuth() if (loading) return null if (session) return <Redirect href="/" /> return <Stack screenOptions={{ headerShown: false }} /> }逻辑很直白:已经登录的用户如果手动访问/sign-in,直接丢回首页。这在开发时尤其有用,否则你登录完以后退出去想再看一眼登录页,会被迫执行一次登出。
再看app/(tabs)/_layout.tsx:
import { Redirect, Tabs } from 'expo-router' import { useAuth } from '@/lib/auth' export default function TabsLayout() { const { session, loading } = useAuth() if (loading) return null if (!session) return <Redirect href="/sign-in" /> return ( <Tabs screenOptions={{ headerShown: false }}> <Tabs.Screen name="index" options={{ title: '首页' }} /> <Tabs.Screen name="profile" options={{ title: '我的' }} /> </Tabs> ) }loading状态非常重要。它存在的意义是防止误判:App 启动时session还没从 AsyncStorage 里读出来,此时如果直接判断!session,会把一个明明登录过的用户弹到登录页,造成“闪一下登录页”的糟糕体验。先返回null等逻辑执行完,再决定往哪走。
5.3 新版 expo-router 的 Stack.Protected 简化写法
如果你的 expo-router 版本在 3.x 以上,还有一个更简洁的 API:Stack.Protected。可以在根布局一次性声明哪些页面受保护:
import { Stack } from 'expo-router' import { useAuth } from '@/lib/auth' function RootNavigator() { const { session, loading } = useAuth() if (loading) return null return ( <Stack screenOptions={{ headerShown: false }}> <Stack.Protected guard={!!session}> <Stack.Screen name="(tabs)" /> </Stack.Protected> <Stack.Protected guard={!session}> <Stack.Screen name="sign-in" /> <Stack.Screen name="sign-up" /> </Stack.Protected> </Stack> ) }guard为false时,对应路由会从导航器里隐藏。这种做法代码量更少,但我需要提醒一句:这个 API 比较新,如果你的项目是旧版或做了深度自定义导航,建议先确认版本再决定用不用。上面分组布局的写法是万金油,任何版本都能跑,也更容易排查问题。
5.4 关于重定向的一次常见时序问题
这里分享一个我实际遇到过的经典问题:用户点登录按钮以后,页面没有跳转,但控制台也没报错。
排查思路是这样的:
- 先确认
signInWithPassword()是否真的成功了。可以把返回的session打出来看。 - 再确认登录后
(tabs)布局里的session是否更新了。如果 AuthProvider 没包对,或者useAuth()在守卫里拿到的是旧的 Context,就会卡住。 - 最后确认
<Redirect href="/sign-in" />的路径写没写对。
大多数情况下,问题出在 AuthProvider 的层级和onAuthStateChange的触发时机。登录成功以后,setSession是异步更新状态,路由守卫会在下一次渲染时才看到新 session。你不需要在登录按钮里手动router.replace('/'),因为状态一变,布局守卫会自动把路由切过去。手动跳一次反而可能造成重复导航。
6. 注册、登录、登出:从表单到路由跳转的完整闭环
6.1 注册:邮箱确认没开/开了的区别
注册页的代码不复杂:
import { useState } from 'react' import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { supabase } from '@/lib/supabase' async function onSignUp(email: string, password: string) { const { data, error } = await supabase.auth.signUp({ email, password, }) if (error) { Alert.alert('注册失败', error.message) return } if (data.session) { // 邮箱确认关闭时,signUp 会直接返回 session return } // 邮箱确认开启时,这里会走到,需要提示用户去查收邮件 Alert.alert('注册成功', '请前往邮箱点击确认链接') }这里真正要注意的是data.session的判断。在 Supabase Dashboard 的 Auth -> Providers -> Email 里,有一个 “Confirm email” 开关。
- 关闭时:
signUp会直接给你一个 session,用户相当于注册即登录。 - 开启时:
signUp返回的data.session是null,用户必须点邮件里的确认链接以后才能登录。
开发阶段我通常先关掉确认,省得每次注册都要去邮箱点链接;准备提测或上线前再打开。注意改了这个开关,只影响之后新注册的用户,已经注册过的用户状态不会变。
6.2 登录:错误提示要处理而不是抛给用户
登录代码:
async function onSignIn(email: string, password: string) { const { error } = await supabase.auth.signInWithPassword({ email, password, }) if (error) { Alert.alert('登录失败', error.message) } }Supabase 返回的错误信息里,像 “Invalid login credentials” 这种,直接展示给用户其实不太友好。我会做一个简单的映射:
Invalid login credentials-> “邮箱或密码错误”Email not confirmed-> “邮箱未验证,请先查收确认邮件”User not found-> 同样归入“邮箱或密码错误”,避免暴露账号是否存在
这种细节看起来很琐碎,但移动端用户对错误弹窗的容忍度很低,一个莫名其妙的英文报错很容易让用户直接卸载。
6.3 登出:布局守卫自动接管
登出更简单:
await supabase.auth.signOut()因为我们在(tabs)布局里写了if (!session) return <Redirect href="/sign-in" />,所以signOut()一执行,onAuthStateChange会触发,session 变成null,路由会自动跳回登录页。你不需要写任何router.push或router.replace。
顺带一提,登出以后最好把走的页面栈也重置一下。Expo Router 的<Redirect />在分组布局里通常不会保留旧的页面栈,至少在 Tab 页面内是安全的。如果你从某个业务子页面一路进到很深的详情页,登出时在页面里额外加一句router.dismissAll()会更稳妥。
6.4 密码重置流程的跳转细节
密码重置是这样一条链路:
- 用户输入邮箱,调用
resetPasswordForEmail。 - Supabase 给邮箱发一封带链接的邮件。
- 用户点击链接,跳转到你配置的重定向地址。
- 用户在这个地址对应的页面里输入新密码。
- 调用
updateUser({ password: newPassword })完成修改。
关键在于第 3 步的深链配置。比如我在app.json里配置了"scheme": "myapp",那 Supabase Dashboard 的 Auth -> URL Configuration -> Redirect URLs 里就要加:
myapp://reset-password然后项目里创建一个app/reset-password.tsx页面,去处理updateUser。这个页面要能接收到邮件链接带过来的参数,Expo Router 会自动把myapp://reset-password映射到同名路由,你只需要在页面里读一下 URL 参数,判断有没有 token 和 type。
7. 页面数据流:在业务页里读写 Supabase 并让 RLS 生效
7.1 用 useAuth 拿到 session,再拿 supabase 客户端
认证闭环跑通以后,业务页面里的标准写法是这样的:
import { useEffect, useState } from 'react' import { Alert, FlatList, Text, View } from 'react-native' import { useAuth } from '@/lib/auth' import { supabase } from '@/lib/supabase' type Note = { id: string title: string body: string | null created_at: string } export default function HomeScreen() { const { session } = useAuth() const [notes, setNotes] = useState<Note[]>([]) useEffect(() => { if (!session) return loadNotes() }, [session]) async function loadNotes() { const { data, error } = await supabase .from('notes') .select('*') .order('created_at', { ascending: false }) if (error) { Alert.alert('加载失败', error.message) return } setNotes(data ?? []) } return ( <FlatList data={notes} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <View> <Text>{item.title}</Text> <Text>{item.body}</Text> </View> )} /> ) }这里有个认知要纠正一下:前端select('*')看起来会查出所有数据,但如果 RLS 策略正确,Supabase 在数据库层已经帮你过滤了,返回的只能是当前用户自己的数据。
7.2 查询列表:不写 user_id 过滤等于白查
虽然 RLS 会兜底,我还是建议在查询条件里显式加上用户维度:
supabase .from('notes') .select('*') .eq('user_id', session.user.id) .order('created_at', { ascending: false })原因有两点。第一,代码可读性更好,看的人能立刻知道这是按用户隔离的数据。第二,如果你的 RLS 策略因为某些原因写错了,比如忘记建策略或策略条件有误,返回的将是空数组或全部数据。显式加过滤条件至少能提前暴露一部分问题。
我之前排查过一个线上问题:某个用户看到了另一个用户的数据,根因就是建表时没开 RLS,而查询代码里也没写.eq('user_id', ...)。Supabase 的 anon key 是公开的,这种情况等于任何人拿到你的 URL 就能遍历整个表。
7.3 写入数据:user_id 从 session 取,不要信前端传值
插入数据时,user_id要从 session 里取:
await supabase.from('notes').insert({ title: '学习 Expo Router', body: '今天把路由守卫搞明白了', user_id: session.user.id, })有些开发者习惯在表单里放一个隐藏的user_id字段,让用户请求时“带着自己的身份”提交。这在移动端绝不推荐,因为客户端请求可以被篡改。正确的依赖链是:auth.uid()由 Supabase Auth 根据客户端 token 解析出来,RLS 策略里的auth.uid()与前端 session 里的user.id必须一致,否则插入会被拒绝。
所以我建表时会在 SQL 层再加一道保险:
create table public.notes ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) default auth.uid(), title text not null, body text, created_at timestamptz not null default now() ); alter table public.notes enable row level security; create policy "用户可以管理自己的笔记" on public.notes for all using (auth.uid() = user_id) with check (auth.uid() = user_id);default auth.uid()意味着即使前端漏传了user_id,数据库也会自动填上当前登录用户。using控制读已有行,with check控制插入和更新的行。这样组合起来,哪怕是构造请求,也无法插入user_id属于别人的行。
7.4 Realtime 订阅与清理
如果某个页面需要实时更新,比如聊天列表、通知列表,可以直接用 Supabase Realtime:
useEffect(() => { if (!session) return const channel = supabase .channel('notes-changes') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'notes', filter: `user_id=eq.${session.user.id}`, }, (payload) => { const newNote = payload.new as Note setNotes((prev) => [newNote, ...prev]) } ) .subscribe() return () => { supabase.removeChannel(channel) } }, [session])这里有个容易忽略的坑:Realtime 不会自动启用。你需要在 Supabase Dashboard 里打开对应表的 Realtime 开关,或者用alter publication supabase_realtime add table public.notes;这条 SQL 手动添加。
另外订阅一定要在组件卸载时移除,否则页面来回切换很容易堆积无效连接,跑久了 App 会越来越卡。
8. 上线前容易漏掉的清单:深链、类型生成、运行环境
8.1 邮箱确认链接回跳 App 的深链配置
如果你的应用要上架,邮箱验证、密码重置这类流程必须能回跳 App,否则用户只能切到浏览器里折腾一圈。
Expo Router 对深链的支持比较友好,你只需要在app.json里配好 scheme:
{ "expo": { "scheme": "myapp" } }然后在 Supabase Dashboard 的重定向地址里加入:
myapp://(通用回跳)myapp://reset-password(密码重置专用)
第一次真机测试深链时,我犯过一个错误:只改了app.json,没有重启开发服务器。Expo 的 scheme 配置变更必须重启npx expo start,最好再清一下缓存:
npx expo start -c否则你会点开邮件里的链接,发现 App 确实被唤起了,但路由没有落到对应页面。
8.2 类型安全:让 TS 校验表和行结构
Supabase 自带一个类型生成工具,能从数据库 schema 直接生成 TypeScript 类型:
npx supabase gen types typescript --project-id your-project-ref --schema public > lib/database.types.ts然后在createClient里传入类型:
import type { Database } from './database.types' export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, { // ... })这能把查询和插入的表名校验、字段名补全、返回类型推导全部做起来。比如我写.from('note')漏了s,编辑器立刻标红;insert时漏了必填字段,也会有提示。对不熟悉 Postgres 的开发者来说,这个类型文件相当于一份可查询的数据库文档。
8.3 Android 模拟器/真机和 Expo Go 的网络差异
这里整理一个经验:
- Android 模拟器访问宿主机服务,要用
10.0.2.2而不是localhost。 - 真机通过 Expo Go 调试时,Supabase URL 用线上云实例没有网络问题;如果是自建 Supabase,必须保证手机和服务器在同一个局域网,或者后端有公网地址。
- iOS 的 ATS 默认会拦截 HTTP 明文请求,开发时如果自建服务,需要临时调整 Info.plist,否则请求会无声失败。
如果你一直用 Expo Go 开发,建议提交测试前至少用 development build 跑一遍。Expo Go 提供的原生运行时和真正的原生工程还是有差异,尤其是深链、SecureStore、复杂网络配置这些场景,提前验证能省掉不少发布期的手忙脚乱。
8.4 环境变量失效时先检查 EXPO_PUBLIC_ 前缀
这是个小但高频的问题。改完.env之后,如果你发现process.env.EXPO_PUBLIC_SUPABASE_URL是undefined,依次检查:
.env文件在项目根目录,不在src里。- 变量名有没有
EXPO_PUBLIC_前缀。 - 修改
.env后有没有停掉开发服务器重跑,Expo 不会热更新环境变量。
还有一个很多人不知道的点:EXPO_PUBLIC_前缀的变量会直接被打包进 JS bundle,所以控制台或者网络请求里能看到是正常的。绝对不要把service_role这种不该暴露的 key 放在EXPO_PUBLIC_里。
9. 我实际踩过的几个坑(按出现频率排序)
9.1 登录成功后一闪又回到登录页
有一次我登录成功以后,页面先闪到首页,然后立刻又被弹回登录页。查了很久才发现是 AuthProvider 的挂载顺序问题。
我的根布局刚开始是这样的:Stack在AuthProvider外面。也就是说,导航器先渲染,Auth 状态还没有注入进去,路由守卫读到的是初始的session: null,判定为未登录,于是跳到/sign-in。等 AuthProvider 真正挂载、session 恢复以后,又触发一次重定向,就出现了“跳过去又弹回来”的闪烁。
解决办法就是严格把 AuthProvider 放在导航器外层,并且在所有用到useAuth()的守卫布局里,先判断loading再决定渲染什么。
9.2 Select 返回空数组,但数据库里明明有数据
这是 RLS 最常见的“无声失败”场景。客户端查出来的结果是空数组,没有报错,数据库里数据也确实存在。我一开始还以为是查询条件写错,排查到最后发现是表开了 RLS 但没建任何 policy。
Supabase 的规则是:表一旦开启 RLS,如果没有任何 policy,所有客户端查询都会被拒绝。这个拒绝不会以 SQL 异常形式返回,select 就是静默返回空,insert 和 update 才容易报 permission denied。
以后遇到“查询结果异常为空”,排查顺序应该是:
- 先去 Supabase Dashboard 的 Table Editor 看数据是否存在。
- 检查表是否开启 RLS。
- 检查是否建了对应的 policy。
- 再回头检查查询条件。
9.3 onAuthStateChange 重复触发导致死循环
有段时间我的 App 在登录后频繁发网络请求,看日志发现onAuthStateChange被触发了无数次。原因是我在回调里多写了一行supabase.auth.setSession,想“手动同步一下 session”。
其实setSession本身会触发一次认证状态变更,于是回调又被触发,回调里又调用setSession,就形成了死循环。这跟 React 里“在 render 里写 setState”是同一个道理。
回调里只应该做两件事:把新 session 同步到状态,以及根据业务需求做页面跳转或弹窗。不要在这个回调里再调用任何会改变认证状态的方法。
9.4 升级 expo 版本后路由全 404
从旧版升级到新版 Expo SDK 之后,App 能启动,但所有路由都 404,页面白屏。这种问题通常不是业务代码坏了,而是路由缓存或者入口配置问题。
我按顺序做了这几步就好了:
npx expo start -c还不行的话,检查package.json里的main字段是不是:
"main": "expo-router/entry"如果项目是从 React Navigation 老项目迁移过来的,这一步最容易漏。忘了设入口的话,Expo 找不到路由表,自然全部 404。
最后分享一个我个人很受用的习惯:每次改完app/目录里的文件结构,尤其是新增或移动路由文件的场景,我都会顺手执行一次npx expo start -c。Expo Router 的缓存大多数时候会自动更新,但偶尔会抽风,一个干净的启动能帮你排除掉大量“看起来是代码问题”的干扰。