数据层
仓库、远程数据源、AppResult 与 UiState。
数据层是功能与 Supabase 通信的方式——而功能本身无需知道 Supabase 的存在。它有两个可测试的 接缝(仓库与远程数据源)、一个位于边界的无抛出结果类型,以及一个三态屏幕模型。学会这套形态 一次,之后每个功能读起来都一样。
仓库模式
每个功能都与 *RepositoryProtocol 通信,绝不直接接触网络。仓库负责业务规则(排序、归一化、
重试)并映射错误;真正的 Supabase 调用位于独立的 *RemoteDataSource。这就是两个注入
接缝:
ViewModel → *RepositoryProtocol → *RemoteDataSource → SupabaseClient
↑ ↑
注入假仓库 注入假数据源
(视图模型测试) (仓库测试)TodosRepository 很好地体现了这一点:add(text:userId:) 会裁剪并校验标题、拉取现有行以
计算下一个 sort_order,再把插入委托给 TodosRemoteDataSource。测试换入一个假数据源,因此
不走网络。
并非每个仓库都需要数据源拆分。TodosRepository 与 ProfileRepository 用了它;
UploadsRepository 直接与 Supabase Storage 通信,因为没有需要隔离的行映射逻辑。
AppResult 与 AppError
仓库不在其边界处抛出异常——它们返回 AppResult<T>:
enum AppResult<Value> {
case success(Value)
case failure(AppError)
}AppError 携带一个类型化的 code(authentication、rateLimited、network、
validation、notFound、unknown)、一段面向用户的 message,以及原始的 original
错误。AppError.map(_:) 把原始错误翻译成友好文案——supabase-swift 的 AuthError 各分支变成
诸如「邮箱或密码不正确」的消息,URLError 则变成「你似乎已离线……」。视图模型直接展示
message。
部分仓库(TodosRepository、ProfileRepository)在自己的协议上使用 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——id、userId、title、completed、sortOrder及时间戳。Profile——id,以及可选的fullName/bio/phone/location/avatarURL加时间戳(对应profiles表;字段可空)。
ProfileRepository 通过一个动态键的 upsert payload 一次只写一个字段(只有 id、
updated_at 与被改动的那一列发往 Postgres),因此局部编辑绝不会覆盖其他字段。
行级安全(RLS)预期
每张表都受 RLS 保护并限定到当前登录用户(auth.uid() = user_id,profiles 则为
= 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 容器里构建该仓库,视图便能解析到它——见 架构。
