快速开始

使用Expo React Native构建用户管理应用


本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:

Supabase 用户管理示例

项目设置

在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。

创建项目

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入项目详细信息。
  3. 等待新数据库启动。

设置数据库模式

现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。

  1. 转到仪表板中的SQL 编辑器页面。
  2. 点击 用户管理入门
  3. 点击 运行
1
2
3
4
supabase link --project-ref <project-id># 您可以从项目的仪表板 URL 获取 <project-id>:https://supabase.com/dashboard/project/<project-id>supabase db pull

获取 API 密钥

既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。 我们只需要从 API 设置中获取项目 URL 和 anon 密钥。

  1. 前往仪表板中的 API 设置 页面。
  2. 在该页面找到您的项目 URLanonservice_role 密钥。

构建应用

让我们从零开始构建这个React Native应用。

初始化 React Native 应用

我们可以使用 expo 来初始化一个名为 expo-user-management 的应用:

1
2
3
npx create-expo-app -t expo-template-blank-typescript expo-user-managementcd expo-user-management

然后安装额外的依赖项:supabase-js

1
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage @rneui/themed

现在创建一个辅助文件来初始化 Supabase 客户端。我们需要之前复制的 API URL 和 anon 密钥。这些变量在您的 Expo 应用中暴露是安全的,因为 Supabase 已在您的数据库上启用了行级安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import AsyncStorage from '@react-native-async-storage/async-storage'import { createClient } from '@supabase/supabase-js'const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URLconst supabaseAnonKey = YOUR_REACT_NATIVE_SUPABASE_ANON_KEYexport const supabase = createClient(supabaseUrl, supabaseAnonKey, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, },})

设置登录组件

让我们设置一个React Native组件来管理登录和注册功能。 用户将能够通过邮箱和密码进行登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import React, { useState } from 'react'import { Alert, StyleSheet, View, AppState } from 'react-native'import { supabase } from '../lib/supabase'import { Button, Input } from '@rneui/themed'// 告诉Supabase Auth在前台状态下自动持续刷新会话// 添加此功能后,当用户会话终止时,您将继续收到带有`TOKEN_REFRESHED`或`SIGNED_OUT`事件的// `onAuthStateChange`通知。此功能只需注册一次。AppState.addEventListener('change', (state) => { if (state === 'active') { supabase.auth.startAutoRefresh() } else { supabase.auth.stopAutoRefresh() }})export default function Auth() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) async function signInWithEmail() { setLoading(true) const { error } = await supabase.auth.signInWithPassword({ email: email, password: password, }) if (error) Alert.alert(error.message) setLoading(false) } async function signUpWithEmail() { setLoading(true) const { data: { session }, error, } = await supabase.auth.signUp({ email: email, password: password, }) if (error) Alert.alert(error.message) if (!session) Alert.alert('请检查您的收件箱以完成邮箱验证!') setLoading(false) } return ( <View style={styles.container}> <View style={[styles.verticallySpaced, styles.mt20]}> <Input label="邮箱" leftIcon={{ type: 'font-awesome', name: 'envelope' }} onChangeText={(text) => setEmail(text)} value={email} placeholder="email@address.com" autoCapitalize={'none'} /> </View> <View style={styles.verticallySpaced}> <Input label="密码" leftIcon={{ type: 'font-awesome', name: 'lock' }} onChangeText={(text) => setPassword(text)} value={password} secureTextEntry={true} placeholder="Password" autoCapitalize={'none'} /> </View> <View style={[styles.verticallySpaced, styles.mt20]}> <Button title="登录" disabled={loading} onPress={() => signInWithEmail()} /> </View> <View style={styles.verticallySpaced}> <Button title="注册" disabled={loading} onPress={() => signUpWithEmail()} /> </View> </View> )}const styles = StyleSheet.create({ container: { marginTop: 40, padding: 12, }, verticallySpaced: { paddingTop: 4, paddingBottom: 4, alignSelf: 'stretch', }, mt20: { marginTop: 20, },})

账户页面

用户登录后,我们可以允许他们编辑个人资料信息和管理账户。

让我们为此创建一个名为 Account.tsx 的新组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { useState, useEffect } from 'react'import { supabase } from '../lib/supabase'import { StyleSheet, View, Alert } from 'react-native'import { Button, Input } from '@rneui/themed'import { Session } from '@supabase/supabase-js'export default function Account({ session }: { session: Session }) { const [loading, setLoading] = useState(true) const [username, setUsername] = useState('') const [website, setWebsite] = useState('') const [avatarUrl, setAvatarUrl] = useState('') useEffect(() => { if (session) getProfile() }, [session]) async function getProfile() { try { setLoading(true) if (!session?.user) throw new Error('No user on the session!') const { data, error, status } = await supabase .from('profiles') .select(`username, website, avatar_url`) .eq('id', session?.user.id) .single() if (error && status !== 406) { throw error } if (data) { setUsername(data.username) setWebsite(data.website) setAvatarUrl(data.avatar_url) } } catch (error) { if (error instanceof Error) { Alert.alert(error.message) } } finally { setLoading(false) } } async function updateProfile({ username, website, avatar_url, }: { username: string website: string avatar_url: string }) { try { setLoading(true) if (!session?.user) throw new Error('No user on the session!') const updates = { id: session?.user.id, username, website, avatar_url, updated_at: new Date(), } const { error } = await supabase.from('profiles').upsert(updates) if (error) { throw error } } catch (error) { if (error instanceof Error) { Alert.alert(error.message) } } finally { setLoading(false) } } return ( <View style={styles.container}> <View style={[styles.verticallySpaced, styles.mt20]}> <Input label="邮箱" value={session?.user?.email} disabled /> </View> <View style={styles.verticallySpaced}> <Input label="用户名" value={username || ''} onChangeText={(text) => setUsername(text)} /> </View> <View style={styles.verticallySpaced}> <Input label="网站" value={website || ''} onChangeText={(text) => setWebsite(text)} /> </View> <View style={[styles.verticallySpaced, styles.mt20]}> <Button title={loading ? '加载中...' : '更新'} onPress={() => updateProfile({ username, website, avatar_url: avatarUrl })} disabled={loading} /> </View> <View style={styles.verticallySpaced}> <Button title="退出登录" onPress={() => supabase.auth.signOut()} /> </View> </View> )}const styles = StyleSheet.create({ container: { marginTop: 40, padding: 12, }, verticallySpaced: { paddingTop: 4, paddingBottom: 4, alignSelf: 'stretch', }, mt20: { marginTop: 20, },})

启动应用!

现在我们已经准备好了所有组件,让我们更新 App.tsx 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useState, useEffect } from 'react'import { supabase } from './lib/supabase'import Auth from './components/Auth'import Account from './components/Account'import { View } from 'react-native'import { Session } from '@supabase/supabase-js'export default function App() { const [session, setSession] = useState<Session | null>(null) useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { setSession(session) }) supabase.auth.onAuthStateChange((_event, session) => { setSession(session) }) }, []) return ( <View> {session && session.user ? <Account key={session.user.id} session={session} /> : <Auth />} </View> )}

完成后,在终端窗口中运行以下命令:

1
npm start

然后根据您要测试的环境按下相应的键,您应该能看到完整的应用程序。

额外功能:个人资料照片

每个 Supabase 项目都配置了存储功能,用于管理照片和视频等大型文件。

安装额外依赖

您需要一个适用于目标环境的图片选择器,本示例中我们将使用 expo-image-picker

1
npx expo install expo-image-picker

创建上传组件

让我们为用户创建一个头像组件,使他们能够上传个人资料照片。 我们可以从创建一个新组件开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import { useState, useEffect } from 'react'import { supabase } from '../lib/supabase'import { StyleSheet, View, Alert, Image, Button } from 'react-native'import * as ImagePicker from 'expo-image-picker'interface Props { size: number url: string | null onUpload: (filePath: string) => void}export default function Avatar({ url, size = 150, onUpload }: Props) { const [uploading, setUploading] = useState(false) const [avatarUrl, setAvatarUrl] = useState<string | null>(null) const avatarSize = { height: size, width: size } useEffect(() => { if (url) downloadImage(url) }, [url]) async function downloadImage(path: string) { try { const { data, error } = await supabase.storage.from('avatars').download(path) if (error) { throw error } const fr = new FileReader() fr.readAsDataURL(data) fr.onload = () => { setAvatarUrl(fr.result as string) } } catch (error) { if (error instanceof Error) { console.log('下载图片错误: ', error.message) } } } async function uploadAvatar() { try { setUploading(true) const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, // 仅限图片 allowsMultipleSelection: false, // 只能选择一张图片 allowsEditing: true, // 允许用户在上传前裁剪/旋转照片 quality: 1, exif: false, // 我们不需要这些数据 }) if (result.canceled || !result.assets || result.assets.length === 0) { console.log('用户取消了图片选择') return } const image = result.assets[0] console.log('获取到图片', image) if (!image.uri) { throw new Error('没有图片uri!') // 实际上这种情况应该不会发生,但以防万一... } const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer()) const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg' const path = `${Date.now()}.${fileExt}` const { data, error: uploadError } = await supabase.storage .from('avatars') .upload(path, arraybuffer, { contentType: image.mimeType ?? 'image/jpeg', }) if (uploadError) { throw uploadError } onUpload(data.path) } catch (error) { if (error instanceof Error) { Alert.alert(error.message) } else { throw error } } finally { setUploading(false) } } return ( <View> {avatarUrl ? ( <Image source={{ uri: avatarUrl }} accessibilityLabel="头像" style={[avatarSize, styles.avatar, styles.image]} /> ) : ( <View style={[avatarSize, styles.avatar, styles.noImage]} /> )} <View> <Button title={uploading ? '上传中...' : '上传'} onPress={uploadAvatar} disabled={uploading} /> </View> </View> )}const styles = StyleSheet.create({ avatar: { borderRadius: 5, overflow: 'hidden', maxWidth: '100%', }, image: { objectFit: 'cover', paddingTop: 0, }, noImage: { backgroundColor: '#333', borderWidth: 1, borderStyle: 'solid', borderColor: 'rgb(200, 200, 200)', borderRadius: 5, },})

添加新组件

然后我们可以将这个组件添加到账户页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 导入新组件import Avatar from './Avatar'// ...return ( <View> {/* 添加到主体部分 */} <View> <Avatar size={200} url={avatarUrl} onUpload={(url: string) => { setAvatarUrl(url) updateProfile({ username, website, avatar_url: url }) }} /> </View> {/* ... */} </View>)// ...

现在你需要运行预构建命令,使应用在你选择的平台上运行:

1
npx expo prebuild

至此,你已经拥有了一个功能完整的应用程序!