入门

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


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

Supabase User Management example

项目设置#

在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。

创建项目#

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

设置数据库 schema#

现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。

  1. 转到控制面板中的 SQL 编辑器 页面。
  2. 点击 社区 > 快速入门 选项卡下的 用户管理 Starter
  3. 点击 运行

获取 API 详细信息#

现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。

为此,您需要从 项目的 Connect 对话框中获取项目 URL 和密钥。

阅读 API 密钥文档 以全面了解所有密钥类型及其用途。

构建应用#

让我们从头开始构建 React Native 应用。

初始化 React Native 应用#

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

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

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

1
npx expo install @supabase/supabase-js @rneui/themed expo-sqlite

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

1
import 'expo-sqlite/localStorage/install';
2
import { createClient } from '@supabase/supabase-js'
3
4
const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
5
const supabasePublishableKey = YOUR_REACT_NATIVE_SUPABASE_PUBLISHABLE_KEY
6
7
export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
8
auth: {
9
storage: localStorage,
10
autoRefreshToken: true,
11
persistSession: true,
12
detectSessionInUrl: false,
13
},
14
})

设置登录组件#

让我们设置一个 React Native 组件来管理登录和注册。用户可以使用他们的电子邮件和密码登录。

1
import React, { useState } from 'react'
2
import { Alert, StyleSheet, View, AppState } from 'react-native'
3
import { supabase } from '../lib/supabase'
4
import { Button, Input } from '@rneui/themed'
5
6
// Tells Supabase Auth to continuously refresh the session automatically if
7
// the app is in the foreground. When this is added, you will continue to receive
8
// `onAuthStateChange` events with the `TOKEN_REFRESHED` or `SIGNED_OUT` event
9
// if the user's session is terminated. This should only be registered once.
10
AppState.addEventListener('change', (state) => {
11
if (state === 'active') {
12
supabase.auth.startAutoRefresh()
13
} else {
14
supabase.auth.stopAutoRefresh()
15
}
16
})
17
18
export default function Auth() {
19
const [email, setEmail] = useState('')
20
const [password, setPassword] = useState('')
21
const [loading, setLoading] = useState(false)
22
23
async function signInWithEmail() {
24
setLoading(true)
25
const { error } = await supabase.auth.signInWithPassword({
26
email: email,
27
password: password,
28
})
29
30
if (error) Alert.alert(error.message)
31
setLoading(false)
32
}
33
34
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
})
43
44
if (error) Alert.alert(error.message)
45
if (!session) Alert.alert('Please check your inbox for email verification!')
46
setLoading(false)
47
}
48
49
return (
50
<View style={styles.container}>
51
<View style={[styles.verticallySpaced, styles.mt20]}>
52
<Input
53
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
<Input
63
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
}
81
82
const 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
})

账户页面#

用户登录后,我们可以允许他们编辑他们的个人资料详细信息并管理他们的帐户。

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

1
import { useState, useEffect } from 'react'
2
import { supabase } from '../lib/supabase'
3
import { StyleSheet, View, Alert } from 'react-native'
4
import { Button, Input } from '@rneui/themed'
5
import { Session } from '@supabase/supabase-js'
6
7
export 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('')
12
13
useEffect(() => {
14
if (session) getProfile()
15
}, [session])
16
17
async function getProfile() {
18
try {
19
setLoading(true)
20
if (!session?.user) throw new Error('No user on the session!')
21
22
const { data, error, status } = await supabase
23
.from('profiles')
24
.select(`username, website, avatar_url`)
25
.eq('id', session?.user.id)
26
.single()
27
if (error && status !== 406) {
28
throw error
29
}
30
31
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
}
44
45
async function updateProfile({
46
username,
47
website,
48
avatar_url,
49
}: {
50
username: string
51
website: string
52
avatar_url: string
53
}) {
54
try {
55
setLoading(true)
56
if (!session?.user) throw new Error('No user on the session!')
57
58
const updates = {
59
id: session?.user.id,
60
username,
61
website,
62
avatar_url,
63
updated_at: new Date(),
64
}
65
66
const { error } = await supabase.from('profiles').upsert(updates)
67
68
if (error) {
69
throw error
70
}
71
} catch (error) {
72
if (error instanceof Error) {
73
Alert.alert(error.message)
74
}
75
} finally {
76
setLoading(false)
77
}
78
}
79
80
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>
91
92
<View style={[styles.verticallySpaced, styles.mt20]}>
93
<Button
94
title={loading ? 'Loading ...' : 'Update'}
95
onPress={() => updateProfile({ username, website, avatar_url: avatarUrl })}
96
disabled={loading}
97
/>
98
</View>
99
100
<View style={styles.verticallySpaced}>
101
<Button title="Sign Out" onPress={() => supabase.auth.signOut()} />
102
</View>
103
</View>
104
)
105
}
106
107
const 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

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

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

1
npm start

然后按您想要测试该应用的相应键,您应该会看到完成的应用程序。

奖励:个人资料照片#

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

其他依赖项安装#

您需要一个适用于您将构建项目的环境的图像选择器,在本例中我们将使用 expo-image-picker

1
npx expo install expo-image-picker

创建一个上传小部件#

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

1
import { useState, useEffect } from 'react'
2
import { supabase } from '../lib/supabase'
3
import { StyleSheet, View, Alert, Image, Button } from 'react-native'
4
import * as ImagePicker from 'expo-image-picker'
5
6
interface Props {
7
size: number
8
url: string | null
9
onUpload: (filePath: string) => void
10
}
11
12
export 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 }
16
17
useEffect(() => {
18
if (url) downloadImage(url)
19
}, [url])
20
21
async function downloadImage(path: string) {
22
try {
23
const { data, error } = await supabase.storage.from('avatars').download(path)
24
25
if (error) {
26
throw error
27
}
28
29
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
}
40
41
async function uploadAvatar() {
42
try {
43
setUploading(true)
44
45
const result = await ImagePicker.launchImageLibraryAsync({
46
mediaTypes: ImagePicker.MediaTypeOptions.Images, // Restrict to only images
47
allowsMultipleSelection: false, // Can only select one image
48
allowsEditing: true, // Allows the user to crop / rotate their photo before uploading it
49
quality: 1,
50
exif: false, // We don't want nor need that data.
51
})
52
53
if (result.canceled || !result.assets || result.assets.length === 0) {
54
console.log('User cancelled image picker.')
55
return
56
}
57
58
const image = result.assets[0]
59
console.log('Got image', image)
60
61
if (!image.uri) {
62
throw new Error('No image uri!') // Realistically, this should never happen, but just in case...
63
}
64
65
const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer())
66
67
const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg'
68
const path = `${Date.now()}.${fileExt}`
69
const { data, error: uploadError } = await supabase.storage
70
.from('avatars')
71
.upload(path, arraybuffer, {
72
contentType: image.mimeType ?? 'image/jpeg',
73
})
74
75
if (uploadError) {
76
throw uploadError
77
}
78
79
onUpload(data.path)
80
} catch (error) {
81
if (error instanceof Error) {
82
Alert.alert(error.message)
83
} else {
84
throw error
85
}
86
} finally {
87
setUploading(false)
88
}
89
}
90
91
return (
92
<View>
93
{avatarUrl ? (
94
<Image
95
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
<Button
104
title={uploading ? 'Uploading ...' : 'Upload'}
105
onPress={uploadAvatar}
106
disabled={uploading}
107
/>
108
</View>
109
</View>
110
)
111
}
112
113
const 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 component
2
import Avatar from './Avatar'
3
4
// ...
5
return (
6
<View>
7
{/* Add to the body */}
8
<View>
9
<Avatar
10
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 命令才能使应用程序在您选择的平台上工作。

1
npx expo prebuild

此时,您已经拥有一个功能齐全的应用!