用 Swift 和 SwiftUI 构建一个用户管理应用
本教程演示了如何构建一个基本的用户管理应用。该应用可以进行用户认证和识别,将用户的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料信息以及上传头像。该应用使用了:
🌐 This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,并且有 行级安全,所以数据是受保护的,用户只能访问自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase 存储 - 允许用户上传个人头像。

如果你在跟着这个指南操作时遇到困难,你可以在GitHub上找到完整示例。
🌐 If you get stuck while working through this guide, you can find the full example on GitHub.
项目设置 #
🌐 Project setup
在你开始构建之前,你需要先设置数据库和 API。你可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“架构”来完成这一步。
🌐 Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.
创建一个项目 #
🌐 Create a project
- 在 Supabase 仪表板中创建一个新项目。
- 输入你的项目详情。
- 等新数据库上线。
设置数据库模式 #
🌐 Set up the database schema
现在设置数据库模式。你可以在 SQL 编辑器中使用“用户管理入门”快速开始,也可以复制粘贴下面的 SQL 并运行。
🌐 Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.
- 在仪表板中转到SQL 编辑器页面。
- 在 参考 > 示例 选项卡下点击 用户管理入门。
- 点击运行。
你可以通过运行 db pull 命令将数据库架构拉到本地项目。查看本地开发文档获取详细说明。
🌐 You can pull the database schema down to your local project by running the db pull command. Read the local development docs for detailed instructions.
supabase link --project-ref <project-id># You can get <project-id> from your project's dashboard URL: https://supabase.nodejs.cn/dashboard/project/<project-id>supabase db pull获取 API 详情 #
🌐 Get API details
要与数据库表中的数据进行交互,你可以使用封装了自动生成的数据 API 端点的客户端库,并使用来自项目 Connect 对话框的项目 URL 和密钥进行认证。
🌐 To interact with data in database tables, you use the client libraries that wrap the auto-generated Data API endpoints, authenticating using the Project URL and key from the project Connect dialog.
正在构建应用 #
🌐 Building the app
从零开始构建 SwiftUI 应用。
🌐 Build the SwiftUI app from scratch.
在 Xcode 中创建一个 SwiftUI 应用 #
🌐 Create a SwiftUI app in Xcode
打开 Xcode,然后创建一个新的 SwiftUI 项目。
🌐 Open Xcode and create a new SwiftUI project.
添加 supabase-swift 依赖。
🌐 Add the supabase-swift dependency.
把 https://github.com/supabase/supabase-swift 包添加到你的应用里。具体操作步骤可以看苹果关于添加包依赖的教程。
🌐 Add the https://github.com/supabase/supabase-swift package to your app. For instructions, see the Apple tutorial on adding package dependencies.
创建一个辅助文件来初始化 Supabase 客户端。 你需要之前复制的 API URL 和密钥。 这些变量将在应用中暴露,这完全没问题,因为你的数据库已经启用了行级安全(Row Level Security)。
🌐 Create a helper file to initialize the Supabase client. You need the API URL and the key that you copied earlier. These variables will be exposed on the application, and that's completely fine since you have Row Level Security enabled on your database.
import Foundationimport Supabaselet supabase = SupabaseClient( supabaseURL: URL(string: "YOUR_SUPABASE_URL")!, supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY")设置一个登录界面 #
🌐 Set up a sign-in view
设置一个 SwiftUI 视图来管理登录和注册。用户应该能够使用魔法链接登录。
🌐 Set up a SwiftUI view to manage sign-ins and sign-ups. Users should be able to sign in using a magic link.
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) } } }}这个示例使用了一个自定义的 redirectTo URL。要使其工作,需要在 Supabase 添加一个自定义重定向 URL,并在你的 SwiftUI 应用中添加一个自定义 URL 方案。请按照实现深度链接处理指南操作。
🌐 The example uses a custom redirectTo URL. For this to work, add a custom redirect URL to Supabase and a custom URL scheme to your SwiftUI application. Follow the guide on implementing deep link handling.
账户视图 #
🌐 Account view
用户登录后,你可以让他们编辑个人资料信息并管理账户。
🌐 After a user is signed in, you can allow them to edit their profile details and manage their account.
为它创建一个名为 ProfileView.swift 的新视图。
🌐 Create a new view for that called ProfileView.swift.
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("Username", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) TextField("Full name", text: $fullName) .textContentType(.name) TextField("Website", text: $website) .textContentType(.URL) .textInputAutocapitalization(.never) } Section { Button("Update profile") { updateProfileButtonTapped() } .bold() if isLoading { ProgressView() } } } .navigationTitle("Profile") .toolbar(content: { ToolbarItem(placement: .topBarLeading){ Button("Sign out", 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) } } }}模型 #
🌐 Models
在 ProfileView.swift 中,你使用了 2 种模型类型来反序列化响应和序列化发送到 Supabase 的请求。把它们加到一个新的 Models.swift 文件里。
🌐 In ProfileView.swift, you used 2 model types for deserializing the response and serializing the request to Supabase. Add those in a new Models.swift file.
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 }}头像照片 #
🌐 Profile photos
接下来,添加一个让用户上传个人资料照片的方式。Supabase 会为每个项目配置 Storage,用于管理像照片和视频这样的大文件。
🌐 Next, add a way for users to upload a profile photo. Supabase configures every project with Storage for managing large files like photos and videos.
添加 PhotosPicker#
🌐 Add PhotosPicker
添加对用户从图库中选择图片并上传的支持。 首先创建一个新类型来存储选中的头像图片:
🌐 Add support for the user to pick an image from the library and upload it. Start by creating a new type to hold the picked avatar image:
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#
🌐 Add PhotosPicker to profile page
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("Username", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) TextField("Full name", text: $fullName) .textContentType(.name) TextField("Website", text: $website) .textContentType(.URL) .textInputAutocapitalization(.never) } Section { Button("Update profile") { updateProfileButtonTapped() } .bold() if isLoading { ProgressView() } } } .navigationTitle("Profile") .toolbar(content: { ToolbarItem { Button("Sign out", 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 }}最后,更新你的模型。
🌐 Finally, update your Models.
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 结构体来处理请求和响应。
🌐 You no longer need the UpdateProfileParams struct, as you can now reuse the Profile struct for both request and response calls.
触发! #
🌐 Launch!
在所有视图就位后,为应用添加一个入口点。
🌐 With all the views in place, add an entry point for the application.
新建一个 AppView.swift 文件。
🌐 Add a new AppView.swift file.
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 中运行以在模拟器中启动你的应用。
🌐 Update the entry point to the newly created AppView. Run in Xcode to launch your application in the simulator.
在这个阶段,你已经有了一个完全可用的应用!
🌐 At this stage you have a fully functional application!