快速开始

使用 Swift 和 SwiftUI 构建用户管理应用


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

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 密钥。

构建应用

让我们从零开始构建一个SwiftUI应用。

在Xcode中创建SwiftUI应用

打开Xcode并创建一个新的SwiftUI项目。

添加supabase-swift依赖项。

https://github.com/supabase/supabase-swift包添加到您的应用中。具体操作说明请参阅Apple关于添加包依赖项的教程

创建一个辅助文件来初始化Supabase客户端。 您需要之前复制的API URL和anon密钥。 这些变量将在应用程序中公开,这完全没有问题,因为您的数据库已启用 行级安全

1
2
3
4
5
6
7
import Foundationimport Supabaselet supabase = SupabaseClient( supabaseURL: URL(string: "YOUR_SUPABASE_URL")!, supabaseKey: "YOUR_SUPABASE_ANON_KEY")

设置登录视图

设置一个 SwiftUI 视图来管理登录和注册功能。 用户应该能够通过魔法链接进行登录。

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
import SwiftUIimport Supabasestruct AuthView: View { @State var email = "" @State var isLoading = false @State var result: Result<Void, Error>? var body: some View { Form { Section { TextField("Email", text: $email) .textContentType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() } Section { Button("Sign in") { signInButtonTapped() } if isLoading { ProgressView() } } if let result { Section { switch result { case .success: Text("Check your inbox.") case .failure(let error): Text(error.localizedDescription).foregroundStyle(.red) } } } } .onOpenURL(perform: { url in Task { do { try await supabase.auth.session(from: url) } catch { self.result = .failure(error) } } }) } func signInButtonTapped() { Task { isLoading = true defer { isLoading = false } do { try await supabase.auth.signInWithOTP( email: email, redirectTo: URL(string: "io.supabase.user-management://login-callback") ) result = .success(()) } catch { result = .failure(error) } } }}

账户视图

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

为此创建一个名为 ProfileView.swift 的新视图。

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
import SwiftUIstruct ProfileView: View { @State var username = "" @State var fullName = "" @State var website = "" @State var isLoading = false var body: some View { NavigationStack { Form { Section { TextField("用户名", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) TextField("全名", text: $fullName) .textContentType(.name) TextField("网站", text: $website) .textContentType(.URL) .textInputAutocapitalization(.never) } Section { Button("更新资料") { updateProfileButtonTapped() } .bold() if isLoading { ProgressView() } } } .navigationTitle("个人资料") .toolbar(content: { ToolbarItem(placement: .topBarLeading){ Button("退出登录", role: .destructive) { Task { try? await supabase.auth.signOut() } } } }) } .task { await getInitialProfile() } } func getInitialProfile() async { do { let currentUser = try await supabase.auth.session.user let profile: Profile = try await supabase .from("profiles") .select() .eq("id", value: currentUser.id) .single() .execute() .value self.username = profile.username ?? "" self.fullName = profile.fullName ?? "" self.website = profile.website ?? "" } catch { debugPrint(error) } } func updateProfileButtonTapped() { Task { isLoading = true defer { isLoading = false } do { let currentUser = try await supabase.auth.session.user try await supabase .from("profiles") .update( UpdateProfileParams( username: username, fullName: fullName, website: website ) ) .eq("id", value: currentUser.id) .execute() } catch { debugPrint(error) } } }}

模型

ProfileView.swift 中,您使用了两种模型类型来反序列化响应和序列化请求到 Supabase。将这些模型添加到一个新的 Models.swift 文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Profile: Decodable { let username: String? let fullName: String? let website: String? enum CodingKeys: String, CodingKey { case username case fullName = "full_name" case website }}struct UpdateProfileParams: Encodable { let username: String let fullName: String let website: String enum CodingKeys: String, CodingKey { case username case fullName = "full_name" case website }}

启动应用

现在您已经创建了所有视图,为应用程序添加一个入口点。这将验证用户是否具有有效会话,并将他们路由到已认证或未认证状态。

添加一个新的 AppView.swift 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUIstruct AppView: View { @State var isAuthenticated = false var body: some View { Group { if isAuthenticated { ProfileView() } else { AuthView() } } .task { for await state in supabase.auth.authStateChanges { if [.initialSession, .signedIn, .signedOut].contains(state.event) { isAuthenticated = state.session != nil } } } }}

将入口点更新为新创建的 AppView。在 Xcode 中运行以在模拟器中启动您的应用程序。

附加功能:个人资料照片

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

添加 PhotosPicker

让我们添加支持用户从相册选择图片并上传的功能。首先创建一个新类型来保存选择的头像图片:

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
import SwiftUIstruct AvatarImage: Transferable, Equatable { let image: Image let data: Data static var transferRepresentation: some TransferRepresentation { DataRepresentation(importedContentType: .image) { data in guard let image = AvatarImage(data: data) else { throw TransferError.importFailed } return image } }}extension AvatarImage { init?(data: Data) { guard let uiImage = UIImage(data: data) else { return nil } let image = Image(uiImage: uiImage) self.init(image: image, data: data) }}enum TransferError: Error { case importFailed}

在个人资料页面添加 PhotosPicker

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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import PhotosUIimport Storageimport Supabaseimport SwiftUIstruct ProfileView: View { @State var username = "" @State var fullName = "" @State var website = "" @State var isLoading = false @State var imageSelection: PhotosPickerItem? @State var avatarImage: AvatarImage? var body: some View { NavigationStack { Form { Section { HStack { Group { if let avatarImage { avatarImage.image.resizable() } else { Color.clear } } .scaledToFit() .frame(width: 80, height: 80) Spacer() PhotosPicker(selection: $imageSelection, matching: .images) { Image(systemName: "pencil.circle.fill") .symbolRenderingMode(.multicolor) .font(.system(size: 30)) .foregroundColor(.accentColor) } } } Section { TextField("用户名", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) TextField("全名", text: $fullName) .textContentType(.name) TextField("网站", text: $website) .textContentType(.URL) .textInputAutocapitalization(.never) } Section { Button("更新资料") { updateProfileButtonTapped() } .bold() if isLoading { ProgressView() } } } .navigationTitle("个人资料") .toolbar(content: { ToolbarItem { Button("退出登录", role: .destructive) { Task { try? await supabase.auth.signOut() } } } }) .onChange(of: imageSelection) { _, newValue in guard let newValue else { return } loadTransferable(from: newValue) } } .task { await getInitialProfile() } } func getInitialProfile() async { do { let currentUser = try await supabase.auth.session.user let profile: Profile = try await supabase .from("profiles") .select() .eq("id", value: currentUser.id) .single() .execute() .value username = profile.username ?? "" fullName = profile.fullName ?? "" website = profile.website ?? "" if let avatarURL = profile.avatarURL, !avatarURL.isEmpty { try await downloadImage(path: avatarURL) } } catch { debugPrint(error) } } func updateProfileButtonTapped() { Task { isLoading = true defer { isLoading = false } do { let imageURL = try await uploadImage() let currentUser = try await supabase.auth.session.user let updatedProfile = Profile( username: username, fullName: fullName, website: website, avatarURL: imageURL ) try await supabase .from("profiles") .update(updatedProfile) .eq("id", value: currentUser.id) .execute() } catch { debugPrint(error) } } } private func loadTransferable(from imageSelection: PhotosPickerItem) { Task { do { avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self) } catch { debugPrint(error) } } } private func downloadImage(path: String) async throws { let data = try await supabase.storage.from("avatars").download(path: path) avatarImage = AvatarImage(data: data) } private func uploadImage() async throws -> String? { guard let data = avatarImage?.data else { return nil } let filePath = "\(UUID().uuidString).jpeg" try await supabase.storage .from("avatars") .upload( filePath, data: data, options: FileOptions(contentType: "image/jpeg") ) return filePath }}

最后,更新您的模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Profile: Codable { let username: String? let fullName: String? let website: String? let avatarURL: String? enum CodingKeys: String, CodingKey { case username case fullName = "full_name" case website case avatarURL = "avatar_url" }}

您不再需要 UpdateProfileParams 结构体,因为现在可以复用 Profile 结构体进行请求和响应调用。

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