使用 Expo React Native 构建用户管理应用
本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,以及 行级别安全,以保护数据并确保用户只能访问他们自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase Storage - 允许用户上传个人资料照片。

如果在阅读本指南时遇到问题,请参考 GitHub 上的完整示例。
项目设置#
在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。
创建项目#
- 在 Supabase 控制面板中创建一个新项目。
- 输入您的项目详细信息。
- 等待新的数据库启动。
设置数据库 schema#
现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。
获取 API 详细信息#
现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。
为此,您需要从 项目的 Connect 对话框中获取项目 URL 和密钥。
API 密钥的更改
Supabase 正在更改密钥的工作方式,以提高项目安全性和开发人员体验。您可以 阅读完整的公告,但在过渡期间,您可以使用当前的 anon 和 service_role 密钥以及新的可发布密钥,格式为 sb_publishable_xxx,它将取代旧的密钥。
在大多数情况下,您可以从 项目的 Connect 对话框中获取正确的密钥,但如果您需要特定的密钥,可以在 项目设置页面的 API 密钥部分中找到所有密钥
- 对于旧版密钥,从 旧版 API 密钥 选项卡中复制
anon密钥用于客户端操作,并复制service_role密钥用于服务器端操作。 - 对于新密钥,打开 API 密钥 选项卡,如果您还没有可发布密钥,请单击 创建新的 API 密钥,并复制 可发布密钥 部分中的值。
阅读 API 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
让我们从头开始构建 React Native 应用。
初始化 React Native 应用#
我们可以使用 expo 初始化一个名为 expo-user-management 的应用
1npx create-expo-app -t expo-template-blank-typescript expo-user-management23cd expo-user-management然后让我们安装额外的依赖项: supabase-js
1npx expo install @supabase/supabase-js @rneui/themed expo-sqlite现在让我们创建一个辅助文件来初始化 Supabase 客户端。我们需要您之前复制的 API URL 和密钥 此处。由于 Supabase 在您的数据库上启用了 行级别安全性,因此这些变量可以安全地暴露在您的 Expo 应用中。
1import 'expo-sqlite/localStorage/install';2import { createClient } from '@supabase/supabase-js'34const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL5const supabasePublishableKey = YOUR_REACT_NATIVE_SUPABASE_PUBLISHABLE_KEY67export const supabase = createClient(supabaseUrl, supabasePublishableKey, {8 auth: {9 storage: localStorage,10 autoRefreshToken: true,11 persistSession: true,12 detectSessionInUrl: false,13 },14})设置登录组件#
让我们设置一个 React Native 组件来管理登录和注册。用户可以使用他们的电子邮件和密码登录。
1import React, { useState } from 'react'2import { Alert, StyleSheet, View, AppState } from 'react-native'3import { supabase } from '../lib/supabase'4import { Button, Input } from '@rneui/themed'56// Tells Supabase Auth to continuously refresh the session automatically if7// the app is in the foreground. When this is added, you will continue to receive8// `onAuthStateChange` events with the `TOKEN_REFRESHED` or `SIGNED_OUT` event9// if the user's session is terminated. This should only be registered once.10AppState.addEventListener('change', (state) => {11 if (state === 'active') {12 supabase.auth.startAutoRefresh()13 } else {14 supabase.auth.stopAutoRefresh()15 }16})1718export default function Auth() {19 const [email, setEmail] = useState('')20 const [password, setPassword] = useState('')21 const [loading, setLoading] = useState(false)2223 async function signInWithEmail() {24 setLoading(true)25 const { error } = await supabase.auth.signInWithPassword({26 email: email,27 password: password,28 })2930 if (error) Alert.alert(error.message)31 setLoading(false)32 }3334 async function signUpWithEmail() {35 setLoading(true)36 const {37 data: { session },38 error,39 } = await supabase.auth.signUp({40 email: email,41 password: password,42 })4344 if (error) Alert.alert(error.message)45 if (!session) Alert.alert('Please check your inbox for email verification!')46 setLoading(false)47 }4849 return (50 <View style={styles.container}>51 <View style={[styles.verticallySpaced, styles.mt20]}>52 <Input53 label="Email"54 leftIcon={{ type: 'font-awesome', name: 'envelope' }}55 onChangeText={(text) => setEmail(text)}56 value={email}57 placeholder="email@address.com"58 autoCapitalize={'none'}59 />60 </View>61 <View style={styles.verticallySpaced}>62 <Input63 label="Password"64 leftIcon={{ type: 'font-awesome', name: 'lock' }}65 onChangeText={(text) => setPassword(text)}66 value={password}67 secureTextEntry={true}68 placeholder="Password"69 autoCapitalize={'none'}70 />71 </View>72 <View style={[styles.verticallySpaced, styles.mt20]}>73 <Button title="Sign in" disabled={loading} onPress={() => signInWithEmail()} />74 </View>75 <View style={styles.verticallySpaced}>76 <Button title="Sign up" disabled={loading} onPress={() => signUpWithEmail()} />77 </View>78 </View>79 )80}8182const styles = StyleSheet.create({83 container: {84 marginTop: 40,85 padding: 12,86 },87 verticallySpaced: {88 paddingTop: 4,89 paddingBottom: 4,90 alignSelf: 'stretch',91 },92 mt20: {93 marginTop: 20,94 },95})默认情况下,Supabase Auth 需要在为用户创建会话之前进行电子邮件验证。要支持电子邮件验证,您需要 实现深度链接处理!
在测试时,您可以在 项目的电子邮件身份验证提供程序设置中禁用电子邮件确认。
账户页面#
用户登录后,我们可以允许他们编辑他们的个人资料详细信息并管理他们的帐户。
让我们创建一个名为 Account.tsx 的新组件。
1import { useState, useEffect } from 'react'2import { supabase } from '../lib/supabase'3import { StyleSheet, View, Alert } from 'react-native'4import { Button, Input } from '@rneui/themed'5import { Session } from '@supabase/supabase-js'67export default function Account({ session }: { session: Session }) {8 const [loading, setLoading] = useState(true)9 const [username, setUsername] = useState('')10 const [website, setWebsite] = useState('')11 const [avatarUrl, setAvatarUrl] = useState('')1213 useEffect(() => {14 if (session) getProfile()15 }, [session])1617 async function getProfile() {18 try {19 setLoading(true)20 if (!session?.user) throw new Error('No user on the session!')2122 const { data, error, status } = await supabase23 .from('profiles')24 .select(`username, website, avatar_url`)25 .eq('id', session?.user.id)26 .single()27 if (error && status !== 406) {28 throw error29 }3031 if (data) {32 setUsername(data.username)33 setWebsite(data.website)34 setAvatarUrl(data.avatar_url)35 }36 } catch (error) {37 if (error instanceof Error) {38 Alert.alert(error.message)39 }40 } finally {41 setLoading(false)42 }43 }4445 async function updateProfile({46 username,47 website,48 avatar_url,49 }: {50 username: string51 website: string52 avatar_url: string53 }) {54 try {55 setLoading(true)56 if (!session?.user) throw new Error('No user on the session!')5758 const updates = {59 id: session?.user.id,60 username,61 website,62 avatar_url,63 updated_at: new Date(),64 }6566 const { error } = await supabase.from('profiles').upsert(updates)6768 if (error) {69 throw error70 }71 } catch (error) {72 if (error instanceof Error) {73 Alert.alert(error.message)74 }75 } finally {76 setLoading(false)77 }78 }7980 return (81 <View style={styles.container}>82 <View style={[styles.verticallySpaced, styles.mt20]}>83 <Input label="Email" value={session?.user?.email} disabled />84 </View>85 <View style={styles.verticallySpaced}>86 <Input label="Username" value={username || ''} onChangeText={(text) => setUsername(text)} />87 </View>88 <View style={styles.verticallySpaced}>89 <Input label="Website" value={website || ''} onChangeText={(text) => setWebsite(text)} />90 </View>9192 <View style={[styles.verticallySpaced, styles.mt20]}>93 <Button94 title={loading ? 'Loading ...' : 'Update'}95 onPress={() => updateProfile({ username, website, avatar_url: avatarUrl })}96 disabled={loading}97 />98 </View>99100 <View style={styles.verticallySpaced}>101 <Button title="Sign Out" onPress={() => supabase.auth.signOut()} />102 </View>103 </View>104 )105}106107const styles = StyleSheet.create({108 container: {109 marginTop: 40,110 padding: 12,111 },112 verticallySpaced: {113 paddingTop: 4,114 paddingBottom: 4,115 alignSelf: 'stretch',116 },117 mt20: {118 marginTop: 20,119 },120})启动!#
现在我们已经准备好所有组件,让我们更新 App.tsx
1import { useState, useEffect } from 'react'2import { supabase } from './lib/supabase'3import Auth from './components/Auth'4import Account from './components/Account'5import { View } from 'react-native'6import { Session } from '@supabase/supabase-js'78export default function App() {9 const [session, setSession] = useState<Session | null>(null)1011 useEffect(() => {12 supabase.auth.getSession().then(({ data: { session } }) => {13 setSession(session)14 })1516 supabase.auth.onAuthStateChange((_event, session) => {17 setSession(session)18 })19 }, [])2021 return (22 <View>23 {session && session.user ? <Account key={session.user.id} session={session} /> : <Auth />}24 </View>25 )26}完成此操作后,在终端窗口中运行以下命令
1npm start然后按您想要测试该应用的相应键,您应该会看到完成的应用程序。
奖励:个人资料照片#
每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。
其他依赖项安装#
您需要一个适用于您将构建项目的环境的图像选择器,在本例中我们将使用 expo-image-picker。
1npx expo install expo-image-picker创建一个上传小部件#
让我们为用户创建一个头像,以便他们可以上传个人资料照片。我们可以从创建一个新组件开始
1import { useState, useEffect } from 'react'2import { supabase } from '../lib/supabase'3import { StyleSheet, View, Alert, Image, Button } from 'react-native'4import * as ImagePicker from 'expo-image-picker'56interface Props {7 size: number8 url: string | null9 onUpload: (filePath: string) => void10}1112export default function Avatar({ url, size = 150, onUpload }: Props) {13 const [uploading, setUploading] = useState(false)14 const [avatarUrl, setAvatarUrl] = useState<string | null>(null)15 const avatarSize = { height: size, width: size }1617 useEffect(() => {18 if (url) downloadImage(url)19 }, [url])2021 async function downloadImage(path: string) {22 try {23 const { data, error } = await supabase.storage.from('avatars').download(path)2425 if (error) {26 throw error27 }2829 const fr = new FileReader()30 fr.readAsDataURL(data)31 fr.onload = () => {32 setAvatarUrl(fr.result as string)33 }34 } catch (error) {35 if (error instanceof Error) {36 console.log('Error downloading image: ', error.message)37 }38 }39 }4041 async function uploadAvatar() {42 try {43 setUploading(true)4445 const result = await ImagePicker.launchImageLibraryAsync({46 mediaTypes: ImagePicker.MediaTypeOptions.Images, // Restrict to only images47 allowsMultipleSelection: false, // Can only select one image48 allowsEditing: true, // Allows the user to crop / rotate their photo before uploading it49 quality: 1,50 exif: false, // We don't want nor need that data.51 })5253 if (result.canceled || !result.assets || result.assets.length === 0) {54 console.log('User cancelled image picker.')55 return56 }5758 const image = result.assets[0]59 console.log('Got image', image)6061 if (!image.uri) {62 throw new Error('No image uri!') // Realistically, this should never happen, but just in case...63 }6465 const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer())6667 const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg'68 const path = `${Date.now()}.${fileExt}`69 const { data, error: uploadError } = await supabase.storage70 .from('avatars')71 .upload(path, arraybuffer, {72 contentType: image.mimeType ?? 'image/jpeg',73 })7475 if (uploadError) {76 throw uploadError77 }7879 onUpload(data.path)80 } catch (error) {81 if (error instanceof Error) {82 Alert.alert(error.message)83 } else {84 throw error85 }86 } finally {87 setUploading(false)88 }89 }9091 return (92 <View>93 {avatarUrl ? (94 <Image95 source={{ uri: avatarUrl }}96 accessibilityLabel="Avatar"97 style={[avatarSize, styles.avatar, styles.image]}98 />99 ) : (100 <View style={[avatarSize, styles.avatar, styles.noImage]} />101 )}102 <View>103 <Button104 title={uploading ? 'Uploading ...' : 'Upload'}105 onPress={uploadAvatar}106 disabled={uploading}107 />108 </View>109 </View>110 )111}112113const styles = StyleSheet.create({114 avatar: {115 borderRadius: 5,116 overflow: 'hidden',117 maxWidth: '100%',118 },119 image: {120 objectFit: 'cover',121 paddingTop: 0,122 },123 noImage: {124 backgroundColor: '#333',125 borderWidth: 1,126 borderStyle: 'solid',127 borderColor: 'rgb(200, 200, 200)',128 borderRadius: 5,129 },130})添加新的小部件#
然后我们可以将小部件添加到 Account 页面
1// Import the new component2import Avatar from './Avatar'34// ...5return (6 <View>7 {/* Add to the body */}8 <View>9 <Avatar10 size={200}11 url={avatarUrl}12 onUpload={(url: string) => {13 setAvatarUrl(url)14 updateProfile({ username, website, avatar_url: url })15 }}16 />17 </View>18 {/* ... */}19 </View>20)21// ...现在您需要运行 prebuild 命令才能使应用程序在您选择的平台上工作。
1npx expo prebuild此时,您已经拥有一个功能齐全的应用!