使用 Swift 和 SwiftUI 构建用户管理应用
本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- 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 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
让我们从头开始构建 SwiftUI 应用。
在 Xcode 中创建一个 SwiftUI 应用#
打开 Xcode 并创建一个新的 SwiftUI 项目。
添加 supabase-swift 依赖项。
将 https://github.com/supabase/supabase-swift 包添加到您的应用。有关说明,请参阅 Apple 关于添加包依赖项的教程。
创建一个辅助文件来初始化 Supabase 客户端。您需要之前复制的 API URL 和密钥 此处。这些变量将在应用程序中公开,这完全没问题,因为您已在数据库上启用了 行级别安全。
1import Foundation2import Supabase34let supabase = SupabaseClient(5 supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,6 supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY"7)设置登录视图#
设置一个 SwiftUI 视图来管理登录和注册。用户应该能够使用 magic link 登录。
1import SwiftUI2import Supabase34struct AuthView: View {5 @State var email = ""6 @State var isLoading = false7 @State var result: Result<Void, Error>?89 var body: some View {10 Form {11 Section {12 TextField("Email", text: $email)13 .textContentType(.emailAddress)14 .textInputAutocapitalization(.never)15 .autocorrectionDisabled()16 }1718 Section {19 Button("Sign in") {20 signInButtonTapped()21 }2223 if isLoading {24 ProgressView()25 }26 }2728 if let result {29 Section {30 switch result {31 case .success:32 Text("Check your inbox.")33 case .failure(let error):34 Text(error.localizedDescription).foregroundStyle(.red)35 }36 }37 }38 }39 .onOpenURL(perform: { url in40 Task {41 do {42 try await supabase.auth.session(from: url)43 } catch {44 self.result = .failure(error)45 }46 }47 })48 }4950 func signInButtonTapped() {51 Task {52 isLoading = true53 defer { isLoading = false }5455 do {56 try await supabase.auth.signInWithOTP(57 email: email,58 redirectTo: URL(string: "io.supabase.user-management://login-callback")59 )60 result = .success(())61 } catch {62 result = .failure(error)63 }64 }65 }66}示例使用了自定义 redirectTo URL。要使其工作,请将自定义重定向 URL 添加到 Supabase,并将自定义 URL scheme 添加到您的 SwiftUI 应用程序。请遵循有关 实现深度链接处理的指南。
账户视图#
用户登录后,您可以允许他们编辑个人资料详细信息并管理他们的帐户。
创建一个名为 ProfileView.swift 的新视图。
1import SwiftUI23struct ProfileView: View {4 @State var username = ""5 @State var fullName = ""6 @State var website = ""78 @State var isLoading = false910 var body: some View {11 NavigationStack {12 Form {13 Section {14 TextField("Username", text: $username)15 .textContentType(.username)16 .textInputAutocapitalization(.never)17 TextField("Full name", text: $fullName)18 .textContentType(.name)19 TextField("Website", text: $website)20 .textContentType(.URL)21 .textInputAutocapitalization(.never)22 }2324 Section {25 Button("Update profile") {26 updateProfileButtonTapped()27 }28 .bold()2930 if isLoading {31 ProgressView()32 }33 }34 }35 .navigationTitle("Profile")36 .toolbar(content: {37 ToolbarItem(placement: .topBarLeading){38 Button("Sign out", role: .destructive) {39 Task {40 try? await supabase.auth.signOut()41 }42 }43 }44 })45 }46 .task {47 await getInitialProfile()48 }49 }5051 func getInitialProfile() async {52 do {53 let currentUser = try await supabase.auth.session.user5455 let profile: Profile =56 try await supabase57 .from("profiles")58 .select()59 .eq("id", value: currentUser.id)60 .single()61 .execute()62 .value6364 self.username = profile.username ?? ""65 self.fullName = profile.fullName ?? ""66 self.website = profile.website ?? ""6768 } catch {69 debugPrint(error)70 }71 }7273 func updateProfileButtonTapped() {74 Task {75 isLoading = true76 defer { isLoading = false }77 do {78 let currentUser = try await supabase.auth.session.user7980 try await supabase81 .from("profiles")82 .update(83 UpdateProfileParams(84 username: username,85 fullName: fullName,86 website: website87 )88 )89 .eq("id", value: currentUser.id)90 .execute()91 } catch {92 debugPrint(error)93 }94 }95 }96}模型#
在 ProfileView.swift 中,您使用了 2 种模型类型来反序列化响应并序列化请求到 Supabase。将它们添加到新的 Models.swift 文件中。
1struct Profile: Decodable {2 let username: String?3 let fullName: String?4 let website: String?56 enum CodingKeys: String, CodingKey {7 case username8 case fullName = "full_name"9 case website10 }11}1213struct UpdateProfileParams: Encodable {14 let username: String15 let fullName: String16 let website: String1718 enum CodingKeys: String, CodingKey {19 case username20 case fullName = "full_name"21 case website22 }23}启动!#
现在您已经创建了所有视图,请添加应用程序的入口点。这将验证用户是否具有有效的会话,并将他们路由到已认证或未认证的状态。
添加一个新的 AppView.swift 文件。
1import SwiftUI23struct AppView: View {4 @State var isAuthenticated = false56 var body: some View {7 Group {8 if isAuthenticated {9 ProfileView()10 } else {11 AuthView()12 }13 }14 .task {15 for await state in supabase.auth.authStateChanges {16 if [.initialSession, .signedIn, .signedOut].contains(state.event) {17 isAuthenticated = state.session != nil18 }19 }20 }21 }22}更新到新创建的 AppView 的入口点。在 Xcode 中运行以在模拟器中启动您的应用程序。
奖励:个人资料照片#
每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。
添加 PhotosPicker#
让我们添加支持,以便用户可以选择图像库中的图像并上传它。首先创建一个新的类型来保存选定的头像图像
1import SwiftUI23struct AvatarImage: Transferable, Equatable {4 let image: Image5 let data: Data67 static var transferRepresentation: some TransferRepresentation {8 DataRepresentation(importedContentType: .image) { data in9 guard let image = AvatarImage(data: data) else {10 throw TransferError.importFailed11 }1213 return image14 }15 }16}1718extension AvatarImage {19 init?(data: Data) {20 guard let uiImage = UIImage(data: data) else {21 return nil22 }2324 let image = Image(uiImage: uiImage)25 self.init(image: image, data: data)26 }27}2829enum TransferError: Error {30 case importFailed31}将 PhotosPicker 添加到个人资料页面#
1import PhotosUI2import Storage3import Supabase4import SwiftUI56struct ProfileView: View {7 @State var username = ""8 @State var fullName = ""9 @State var website = ""1011 @State var isLoading = false1213 @State var imageSelection: PhotosPickerItem?14 @State var avatarImage: AvatarImage?1516 var body: some View {17 NavigationStack {18 Form {19 Section {20 HStack {21 Group {22 if let avatarImage {23 avatarImage.image.resizable()24 } else {25 Color.clear26 }27 }28 .scaledToFit()29 .frame(width: 80, height: 80)3031 Spacer()3233 PhotosPicker(selection: $imageSelection, matching: .images) {34 Image(systemName: "pencil.circle.fill")35 .symbolRenderingMode(.multicolor)36 .font(.system(size: 30))37 .foregroundColor(.accentColor)38 }39 }40 }4142 Section {43 TextField("Username", text: $username)44 .textContentType(.username)45 .textInputAutocapitalization(.never)46 TextField("Full name", text: $fullName)47 .textContentType(.name)48 TextField("Website", text: $website)49 .textContentType(.URL)50 .textInputAutocapitalization(.never)51 }5253 Section {54 Button("Update profile") {55 updateProfileButtonTapped()56 }57 .bold()5859 if isLoading {60 ProgressView()61 }62 }63 }64 .navigationTitle("Profile")65 .toolbar(content: {66 ToolbarItem {67 Button("Sign out", role: .destructive) {68 Task {69 try? await supabase.auth.signOut()70 }71 }72 }73 })74 .onChange(of: imageSelection) { _, newValue in75 guard let newValue else { return }76 loadTransferable(from: newValue)77 }78 }79 .task {80 await getInitialProfile()81 }82 }8384 func getInitialProfile() async {85 do {86 let currentUser = try await supabase.auth.session.user8788 let profile: Profile =89 try await supabase90 .from("profiles")91 .select()92 .eq("id", value: currentUser.id)93 .single()94 .execute()95 .value9697 username = profile.username ?? ""98 fullName = profile.fullName ?? ""99 website = profile.website ?? ""100101 if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {102 try await downloadImage(path: avatarURL)103 }104105 } catch {106 debugPrint(error)107 }108 }109110 func updateProfileButtonTapped() {111 Task {112 isLoading = true113 defer { isLoading = false }114 do {115 let imageURL = try await uploadImage()116117 let currentUser = try await supabase.auth.session.user118119 let updatedProfile = Profile(120 username: username,121 fullName: fullName,122 website: website,123 avatarURL: imageURL124 )125126 try await supabase127 .from("profiles")128 .update(updatedProfile)129 .eq("id", value: currentUser.id)130 .execute()131 } catch {132 debugPrint(error)133 }134 }135 }136137 private func loadTransferable(from imageSelection: PhotosPickerItem) {138 Task {139 do {140 avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)141 } catch {142 debugPrint(error)143 }144 }145 }146147 private func downloadImage(path: String) async throws {148 let data = try await supabase.storage.from("avatars").download(path: path)149 avatarImage = AvatarImage(data: data)150 }151152 private func uploadImage() async throws -> String? {153 guard let data = avatarImage?.data else { return nil }154155 let filePath = "\(UUID().uuidString).jpeg"156157 try await supabase.storage158 .from("avatars")159 .upload(160 filePath,161 data: data,162 options: FileOptions(contentType: "image/jpeg")163 )164165 return filePath166 }167}最后,更新您的模型。
1struct Profile: Codable {2 let username: String?3 let fullName: String?4 let website: String?5 let avatarURL: String?67 enum CodingKeys: String, CodingKey {8 case username9 case fullName = "full_name"10 case website11 case avatarURL = "avatar_url"12 }13}您不再需要 UpdateProfileParams 结构体,因为现在可以重用 Profile 结构体来进行请求和响应调用。
此时,您已经拥有一个功能齐全的应用!