数据层

仓库、远程数据源、AppResult 与 UiState。

数据层是功能与 Supabase 通信的方式——而功能本身无需知道 Supabase 的存在。它有两个可测试的 接缝(仓库与远程数据源)、一个位于边界的无抛出结果类型,以及一个三态屏幕模型。学会这套形态 一次,之后每个功能读起来都一样。

仓库模式

每个功能都与 *RepositoryProtocol 通信,绝不直接接触网络。仓库负责业务规则(排序、归一化、 重试)并映射错误;真正的 Supabase 调用位于独立的 *RemoteDataSource。这就是两个注入 接缝

ViewModel → *RepositoryProtocol → *RemoteDataSource → SupabaseClient
                   ↑                      ↑
              注入假仓库              注入假数据源
            (视图模型测试)          (仓库测试)

TodosRepository 很好地体现了这一点:add(text:userId:) 会裁剪并校验标题、拉取现有行以 计算下一个 sort_order,再把插入委托给 TodosRemoteDataSource。测试换入一个假数据源,因此 不走网络。

并非每个仓库都需要数据源拆分。TodosRepositoryProfileRepository 用了它; UploadsRepository 直接与 Supabase Storage 通信,因为没有需要隔离的行映射逻辑。

AppResultAppError

仓库不在其边界处抛出异常——它们返回 AppResult<T>

enum AppResult<Value> {
    case success(Value)
    case failure(AppError)
}

AppError 携带一个类型化的 codeauthenticationrateLimitednetworkvalidationnotFoundunknown)、一段面向用户的 message,以及原始的 original 错误。AppError.map(_:) 把原始错误翻译成友好文案——supabase-swift 的 AuthError 各分支变成 诸如「邮箱或密码不正确」的消息,URLError 则变成「你似乎已离线……」。视图模型直接展示 message

部分仓库(TodosRepositoryProfileRepository)在自己的协议上使用 async throws,让 视图模型去捕获并经 AppError.map 映射。AuthRepository 则在内部映射并直接返回 AppResult。两者最终都汇聚到 AppError——区别只是 map 在哪里执行。

UiState —— 屏幕状态

视图模型用一个枚举表达屏幕状态,而不是一堆 isLoading / error / items 标志的混杂:

enum UiState<Value: Sendable>: Sendable {
    case idle          // 加载前的骨架
    case loading       // 首次拉取或重新加载
    case data(Value)   // 成功——空结果用 .data([])
    case error(String) // 要展示的 AppError.message
}

idle 渲染干净的首屏骨架;空结果为 .data([]),使空状态视图能区分「无数据」与「尚未 加载」。视图模型为 @MainActor @Observable;视图用 @State 持有它们。

模型

模型是普通的 Codable 结构体,用 snake_case 的 CodingKeys 映射到 Postgres 列,并实现 Identifiable + Sendable

  • Todo —— iduserIdtitlecompletedsortOrder 及时间戳。
  • Profile —— id,以及可选的 fullName / bio / phone / location / avatarURL 加时间戳(对应 profiles 表;字段可空)。

ProfileRepository 通过一个动态键的 upsert payload 一次只写一个字段(只有 idupdated_at 与被改动的那一列发往 Postgres),因此局部编辑绝不会覆盖其他字段。

行级安全(RLS)预期

每张表都受 RLS 保护并限定到当前登录用户(auth.uid() = user_idprofiles 则为 = id)。由于 RLS 在服务端强制归属,应用的查询只需按 user_id 过滤并信任策略即可。 subscriptions / payments 表对客户端仅 SELECT——写入只发生在 service-role Edge Function 中。完整策略 SQL 见 Supabase 配置

离线感知

NetworkObserver@MainActor @Observable)封装 NWPathMonitor 并发布 isOnline,在全 应用范围挂载以驱动离线横幅(见 UI 组件)。它位于 NetworkMonitoring 协议之后,方便测试注入假的网络状态。仓库目前只走网络——没有本地缓存—— 因此横幅就是 UI 表达降级状态的方式。

本地偏好

AppPreferences 是对 UserDefaults@Observable 封装,用于非机密的应用状态(主题、 语言、引导/同意标志)。认证令牌绝不放这里——它们存在钥匙串中(见 身份认证)。

添加你自己的功能

端到端照搬 Todos 的形态:

模型

添加一个 Codable, Identifiable, Sendable 结构体,用 snake_case 的 CodingKeys 匹配你的 表。

远程数据源

定义一个 *RemoteDataSource 协议 + 一个 Supabase*RemoteDataSource,执行 from("table").select()/insert()/update()/delete() 调用。

仓库

添加一个 *RepositoryProtocol + 实现,承载业务规则;再加一个 convenience init(supabase:) 接上真实数据源。

视图模型 + 视图

一个暴露 UiState<T>、通过 AppError.map 映射失败的 @MainActor @Observable 视图模型; 以及一个用 @State 持有它的 SwiftUI 视图。

AppEnvironment 中注册

在 DI 容器里构建该仓库,视图便能解析到它——见 架构

相关页面

On this page