Supabase Flutter 用户管理系统实战:从魔法链接登录到资料存储的完整指南
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
本文以 Supabase 官方仓库中的 Flutter User Management 示例应用(examples/user-management/flutter-user-management)为核心,系统讲解如何用 Flutter 与 Supabase 从零构建一个具备邮箱魔法链接登录、个人资料读写与头像图片上传能力的完整应用。读完本文,你将掌握
supabase_flutter客户端的初始化、Auth 会话监听与魔法链接鉴权、Postgres 数据库行级安全(RLS)策略设计,以及 Storage 上传与签名 URL 的实战用法,并能在 iOS、Android 与 Web 三端直接运行该示例。
一、示例应用定位:一套最小可运行的 Flutter + Supabase 全栈模板
该示例是 Supabase 官方仓库中user-management系列示例的 Flutter 实现,用于演示三条核心能力链路:
- 使用Supabase Auth + 魔法链接(Magic Link)让用户通过邮箱免密登录;
- 使用Supabase Database(Postgres)存储并读取用户资料;
- 使用Supabase Storage存储用户头像图片文件。
与 Next.js、React、Swift、Vue 等同目录下的兄弟示例不同,flutter-user-management 的独特价值在于它演示了 Flutter 跨端(iOS / Android / Web)下处理 Supabase 的完整写法,尤其是 Web 端魔法链接回调与移动端 Deep Link 回调在代码中的差异处理,这些细节在纯 Web 示例中不会出现。
应用本身是一个标准的 Flutter Material 应用,代码结构清晰:
| 文件 | 职责 |
|---|---|
| lib/main.dart | 入口:加载.env、初始化Supabase、根据会话状态决定首页路由,并提供全局 SnackBar 扩展 |
| lib/pages/splash_page.dart | 启动页:依据本地是否已有会话跳转登录页或账户页(README 主流程之外的辅助页) |
| lib/pages/login_page.dart | 登录页:输入邮箱发送魔法链接,并监听onAuthStateChange自动跳转 |
| lib/pages/account_page.dart | 账户页:读取/更新profiles表,上传头像后回写avatar_url |
| lib/components/avatar.dart | 头像组件:调用系统相册选取图片、上传 Storage、生成签名 URL |
二、快速启动:环境准备与三端运行命令
1. 获取 Supabase 项目凭据并配置.env
参照示例目录下的 .env.example,在flutter-user-management目录中复制出.env文件并填入自己的项目凭据:
SUPABASE_URL=https://mysupabasereference.supabase.co SUPABASE_PUBLISHABLE_KEY=my.supabase.publishable.key两个字段的含义分别为:
SUPABASE_URL:你的 Supabase 项目地址,即 Project Settings → API 中的 Project URL,格式形如https://xxx.supabase.co;SUPABASE_PUBLISHABLE_KEY:对外公开的 API Key(即 anon/publishable key),用于浏览器与移动端等不可信环境。仓库在此处使用 publishable key 命名,对应新版本 Supabase 的密钥体系,旧项目也可沿用 anon key。
.env之所以必须以该文件名出现在仓库中,是因为 pubspec.yaml 的flutter.assets段已将.env声明为打包资源,flutter_dotenv才能在运行时通过dotenv.load()将其注入环境。
2. 初始化与三端运行
环境准备完成后,依次执行:
flutter pub get然后按目标平台选择运行命令。iOS 与 Android 直接运行:
flutter run若以 Web 方式运行,并在localhost:3000启动:
flutter run -d web-server --web-hostname localhost --web-port 30003. 源码中的初始化要点
在 lib/main.dart 的main()中,先加载环境变量,再初始化客户端:
Future<void> main() async { await dotenv.load(); await Supabase.initialize( url: dotenv.env['SUPABASE_URL']!, anonKey: dotenv.env['SUPABASE_ANON_KEY']!, ); runApp(const MyApp()); } final supabase = Supabase.instance.client;需要注意两点:
- 示例通过全局
supabase变量暴露客户端(在main.dart顶部定义,供各页面直接导入使用),它等价于Supabase.instance.client的单例;从源码结构看,这是为示例保持代码简洁而采用的写法,大型项目中更推荐用依赖注入或单一封装模块管理该客户端。 Supabase.initialize内部会完成 WebSocket 与 Auth 的初始配置。main()之后,MaterialApp的home属性根据本地是否已存在会话做首屏分流:supabase.auth.currentSession == null时进入LoginPage,否则直接进入AccountPage。由于supabase_flutter会持久化会话,用户重启 App 后仍可保持登录态。
三、数据库 Schema:一张表 + 三层安全策略
README 中给出了完整的初始化 SQL。仓库将该 SQL 固化在 supabase/migrations/20240404030631_init.sql,可作为本地开发迁移脚本直接使用。全部内容可拆解为四个部分逐一说明。
1.profiles用户资料表
-- Create a table for public "profiles" create table profiles ( id uuid references auth.users not null, updated_at timestamp with time zone, username text unique, avatar_url text, website text, primary key (id), unique(username), constraint username_length check (char_length(username) >= 3) );设计要点:
id是主键,同时外键引用auth.users,即每条 profile 都唯一对应用户系统中的一个 Auth 用户;username声明为unique,且通过username_length检查约束要求用户名至少 3 个字符,从数据层杜绝过短用户名;avatar_url用于存放头像图片地址,website是示例性扩展字段,演示如何为个人资料增加信息项。
2. 开启行级安全(RLS)
alter table profiles enable row level security;开启 RLS 后,若未创建策略,默认所有连接方对该表都没有任何访问权限。示例接下来的三条策略正是在此基础上逐步放开"公开读 + 本人写"的最小权限模型。
3. 三条访问策略
create policy "Public profiles are viewable by everyone." on profiles for select using ( true ); create policy "Users can insert their own profile." on profiles for insert with check ( (select auth.uid()) = id ); create policy "Users can update own profile." on profiles for update using ( (select auth.uid()) = id );- 第一条允许任何人(含匿名用户)读取所有公开资料,这是头像、主页等公开信息的常见需求;
- 第二条限定只能为自己插入 profile,
with check (auth.uid() = id)会在写入时校验行数据,防止用户伪造他人的id写入; - 第三条限定只能更新自己的资料,
using (auth.uid() = id)过滤可被更新的行,防止越权篡改他人记录。
注意:migration 文件与 README 中该约束的写法略有差异——README 版本使用
(select auth.uid()) = id的子查询形式,migration 中使用auth.uid() = id,两者语义等价,以 20240404030631_init.sql 的简洁写法为准即可。
4. 开启 Realtime 并准备 Storage Bucket
-- Set up Realtime! begin; drop publication if exists supabase_realtime; create publication supabase_realtime; commit; alter publication supabase_realtime add table profiles; -- Set up Storage! insert into storage.buckets (id, name) values ('avatars', 'avatars'); create policy "Avatar images are publicly accessible." on storage.objects for select using ( bucket_id = 'avatars' ); create policy "Anyone can upload an avatar." on storage.objects for insert with check ( bucket_id = 'avatars' );- Realtime 部分:先重置
supabase_realtime发布(publication),再把profiles表加入其中。此后的数据变更即可通过 Postgres 逻辑复制推送给订阅了该表的 Realtime 客户端; - Storage 部分:向
storage.buckets插入名为avatars的存储桶,并为该桶中的对象开放"公开可读、人人可上传"两条策略,对应头像这种本身就是要对外展示、且登录用户都能替换自己头像的场景。
四、登录流程:魔法链接 + 会话监听实现无密码鉴权
登录页的完整实现在 lib/pages/login_page.dart,核心是两个异步机制:发送魔法链接与监听登录态变化自动跳转。
1. 发送邮箱魔法链接
await supabase.auth.signInWithOtp( email: _emailController.text.trim(), emailRedirectTo: kIsWeb ? null : 'io.supabase.flutterquickstart://login-callback/', );关键点在于emailRedirectTo的跨端差异:
- 在Web端(
kIsWeb == true)传入null,Supabase 会使用项目默认的 Site URL 作为回调地址; - 在iOS / Android端传入自定义 URL Scheme
io.supabase.flutterquickstart://login-callback/,用户在邮箱点击链接后,系统通过 Deep Link 把认证结果交回 App,由supabase_flutter完成深层链接解析并换取会话。若你修改了 iOS 工程中的 bundle identifier 或 Android 中的包名,需同步更新此处的 scheme,并在两端原生工程(iOSInfo.plist/ Android Manifest 的 intent-filter)中注册对应 scheme。
发送成功后页面通过context.showSnackBar('Check your email for a login link!')提示用户查收邮件。
2. 监听认证状态实现自动跳转
_authStateSubscription = supabase.auth.onAuthStateChange.listen( (data) { if (_redirecting) return; final session = data.session; if (session != null) { _redirecting = true; Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (context) => const AccountPage()), ); } }, onError: (error) { // AuthException 会携带 error.message 用于提示 }, );这里的onAuthStateChange返回Stream<AuthState>,App 生命周期内任何认证事件(登录、登出、令牌刷新)都会触发回调。示例在initState中订阅、在dispose中调用_authStateSubscription.cancel()取消订阅,避免内存泄漏;_redirecting布尔值则防止回调被重复触发导致多次跳转。
由于_signIn会先做表单输入校验与 loading 状态切换,并用on AuthException/ 通用catch分级捕获错误,登录页对"邮箱格式错误""网络故障"等异常都能给出明确的 SnackBar 反馈。
五、资料读写:从profiles表查询与 upsert
进入账户页后,lib/pages/account_page.dart 在initState阶段调用_getProfile()拉取当前用户资料:
final userId = supabase.auth.currentSession!.user.id; final data = await supabase.from('profiles').select().eq('id', userId).single(); _usernameController.text = (data['username'] ?? '') as String; _websiteController.text = (data['website'] ?? '') as String; _avatarUrl = (data['avatar_url'] ?? '') as String;这是典型的 Supabase Dart 查询链:from('profiles')指定表 →select()全列选取 →eq('id', userId)按当前用户主键过滤 →single()断言只返回一行。由于字段可能为空,代码用?? ''兜底回填到输入框,避免空值异常。
更新资料时使用upsert:
final updates = { 'id': user!.id, 'username': userName, 'website': website, 'updated_at': DateTime.now().toIso8601String(), }; await supabase.from('profiles').upsert(updates);upsert的行为是"存在则更新、不存在则插入",配合 RLS 中"只能插入/更新自己"的策略:若用户首次进入尚无 profile 行,这次写入会以auth.uid()为其新建记录;若已存在则整行更新。updated_at使用 Dart 侧生成的 ISO 8601 时间字符串写入,若希望由数据库自动维护该时间戳,可改为updated_at = now()的 RPC 或数据库触发器。
账户页底部提供Sign Out按钮,调用supabase.auth.signOut()清除本地会话,随后跳回LoginPage。
六、头像上传:Storage 上传二进制 + 签名 URL 回写
头像能力封装在 lib/components/avatar.dart,它既是 UI 组件,也承载了"选图 → 上传 → 取 URL → 回写 profile"的完整业务逻辑。
1. 本地选图与压缩
final picker = ImagePicker(); final imageFile = await picker.pickImage( source: ImageSource.gallery, maxWidth: 300, maxHeight: 300, );通过image_picker插件调起系统相册,maxWidth/maxHeight均限定为 300px,在客户端先完成尺寸约束,避免大图直接上传浪费带宽与存储。
2. 上传二进制文件到avatars桶
final bytes = await imageFile.readAsBytes(); final fileExt = imageFile.path.split('.').last; final fileName = '${DateTime.now().toIso8601String()}.$fileExt'; final filePath = fileName; await supabase.storage.from('avatars').uploadBinary( filePath, bytes, fileOptions: FileOptions(contentType: imageFile.mimeType), );实现细节值得留意:
- 文件名以
DateTime.now().toIso8601String()时间戳加原始扩展名拼成,天然唯一,避免不同用户上传同名文件相互覆盖(示例把filePath设为仅文件名,即上传到桶的根目录); - 使用
uploadBinary直接上传内存字节数组(而非upload的文件路径版本),配合FileOptions(contentType: ...)显式声明 MIME 类型,让浏览器/App 端能正确渲染图片。
3. 生成长期有效的签名 URL 并回写
final imageUrlResponse = await supabase.storage .from('avatars') .createSignedUrl(filePath, 60 * 60 * 24 * 365 * 10); widget.onUpload(imageUrlResponse);示例并未走"公开桶直出 URL"的路线,而是为上传对象创建有效期为60 * 60 * 24 * 365 * 10秒(即 10 年)的签名 URL,再通过回调onUpload交给AccountPage,由_onUpload将avatar_urlupsert 到profiles表。这正是 RLS 中存储桶 select 策略"公开可读"存在的原因之一——即便使用了带签名 URL,桶策略依然允许直接公开访问。
Avatar组件在imageUrl非空时用Image.network渲染头像,为空时展示灰色占位块与No Image提示,兼顾了首次登录尚无头像的场景。
七、从示例到项目:可以继续深入仓库的参考路径
如果需要把该示例扩展为真实的业务应用,或想对照其他技术栈的实现方式,可以在仓库中找到以下相互印证的资源:
- 对照实现:同属
user-management目录的 nextjs-user-management、swift-user-management、react-user-management 等示例使用相同的profiles表结构与策略思想,可横向对比不同语言客户端的 API 差异; - 客户端能力扩展:
supabase_flutter还支持基于 PostgREST 的实时订阅、auth.signUp密码注册、auth.onAuthStateChange令牌自动刷新等能力,均可从当前示例的 Auth/Database/Storage 三条链路继续延伸; - 本地开发:示例自带的 supabase/config.toml 与迁移脚本可直接用于
supabase start本地开发环境,从而在不连接云端项目的情况下先跑通全流程。
八、小结:示例应用的整体数据流
把上面的内容串起来,一个完整会话周期中的数据流为:
- 用户在
LoginPage输入邮箱 → 发送魔法链接 → 邮件回调触发onAuthStateChange→ 自动跳转AccountPage; AccountPage依据auth.uid()从profiles表读取本人资料并填充表单;- 用户修改资料点击 Update →
upsert写回(受 RLS 的 insert/update 策略约束); - 用户选择头像 → 客户端压缩后
uploadBinary至avatars桶 → 生成长期签名 URL → 回写avatar_url,页面即时刷新头像; - 点击 Sign Out →
signOut()清除会话 → 返回登录页。
从 README 的快速启动指引,到 20240404030631_init.sql 的完整 Schema,再到 main.dart、login_page.dart、account_page.dart、avatar.dart 的逐层实现,整个示例只用了极少的代码就覆盖了 Auth、Database 与 Storage 三大 Supabase 核心能力。它既是学习supabase_flutter的最佳最小样例,也是一套可以快速改造成真实 Flutter 应用骨架的参考模板。
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考