数据层
Repository、远程数据源、模型、AppResult 和 Supabase PostgREST 访问。
Android 模板不会让 composable 直接访问 Supabase。页面调用 ViewModel, ViewModel 调用 repository,repository 再调用远程数据源或本地 controller flow。
基本形状
Composable screen
-> @HiltViewModel
-> Repository
-> RemoteDataSource interface
-> Supabase implementation
-> PostgREST / Auth / Storage / Functions大多数功能 repository 都返回 AppResult<T>,这样 ViewModel 可以渲染成功、
加载和失败状态,而不需要在 UI 层捕获 Supabase 异常。
关键文件
AppResult<T>
core/result/AppResult.kt 定义:
sealed interface AppResult<out T> {
data class Success<T>(val data: T) : AppResult<T>
data class Failure(val error: AppError) : AppResult<Nothing>
}appSuspendRunCatching 会保留 coroutine cancellation,并把常见异常映射成友好的
AppError.message。Auth 错误会对无效凭据、邮箱未确认、OTP 过期、频率限制、
弱密码和 provider 禁用做特殊文案处理。
当前使用这个模式的 repository:
| Repository | Result 边界 |
|---|---|
AuthRepository | 所有认证命令返回 AppResult<Unit> |
TodosRepository | CRUD、清除已完成、排序返回 AppResult |
ProfileRepository | 读取和逐字段更新返回 AppResult |
SubscriptionRepository | 权益读取返回 AppResult<Subscription?> |
NotificationsRepository | 推送注册和测试推送返回 AppResult |
UploadsRepository 是例外:它直接暴露 Flow<UploadStatus>,因为 UI 需要来自
Supabase Storage 的每个上传任务进度事件。
模型
Supabase 行被建模为 @Serializable Kotlin data class:
| Model | 表 | 应用使用的字段 |
|---|---|---|
Todo | public.todos | id、title、completed、sort_order、created_at |
Profile | public.profiles | id、full_name、bio、phone、location、avatar_url、updated_at |
Subscription | public.subscriptions | 商品、provider、状态、周期、试用、取消和账单时间戳 |
Subscription 使用宽容 enum serializer。未来服务端新增 enum 值时,旧客户端会解码
成 Unknown,不会直接崩溃。
远程数据源
RepositoryModule 把接口绑定到 Supabase 实现:
| Interface | 生产实现 | 后端表面 |
|---|---|---|
AuthRemoteDataSource | SupabaseAuthRemoteDataSource | Supabase Auth + 删除账号时的 PostgREST 清理 |
TodosRemoteDataSource | SupabaseTodosRemoteDataSource | public.todos |
ProfileRemoteDataSource | SupabaseProfileRemoteDataSource | public.profiles |
SubscriptionRemoteDataSource | SupabaseSubscriptionRemoteDataSource | public.subscriptions |
DeviceTokenRemoteDataSource | SupabaseDeviceTokenRemoteDataSource | public.device_tokens |
TestPushRemoteDataSource | SupabaseTestPushRemoteDataSource | send-test-push Edge Function |
测试会在这些 seam 注入 fake。模板的单元测试使用普通内存 fake 和
kotlinx-coroutines-test;repository 和 ViewModel 测试不需要 Mockk。
PostgREST 示例
SupabaseTodosRemoteDataSource.fetchAll() 会按 owner 筛选,并使用 UI 期望的排序:
table.select {
filter { eq("user_id", userId) }
order("sort_order", Order.ASCENDING)
order("created_at", Order.DESCENDING)
}.decodeList<Todo>()TodosRepository.add() 会把新项目排在当前第一项之前:
val nextOrder = currentItems.minOfOrNull { it.sortOrder }?.minus(1) ?: 0
remoteDataSource.add(userId, title.trim(), nextOrder)Profile 更新会把空白输入归一化为 null,再发送单字段 patch:
val cleaned = value?.trim()?.takeIf { it.isNotEmpty() }
remoteDataSource.update(userId, mapOf(field to cleaned))本地状态
AppPreferences 用 typed flow 包装 DataStore Preferences,覆盖:
- 主题和语言。
- Dynamic color 开关。
- Onboarding 完成状态。
- 生物识别锁开关。
- 已接受的 legal version。
- 已忽略的 soft update version。
- 应用内评价计数和提示历史。
NetworkObserver 包装 ConnectivityManager.NetworkCallback,并暴露
StateFlow<Boolean> 给离线横幅使用。它同时要求 internet capability 和
validated network capability。
RLS 预期
移动端客户端假设这些 owner-scoped RLS:
| 表 | 客户端操作 |
|---|---|
profiles | 只能 select、insert、update id = auth.uid() 的行 |
todos | 只能 select、insert、update、delete user_id = auth.uid() 的行 |
device_tokens | 只能 select、insert、update、delete user_id = auth.uid() 的行 |
subscriptions | 只能 select user_id = auth.uid() 的行 |
payments | 只能 select user_id = auth.uid() 的行 |
subscriptions 和 payments 故意没有客户端写策略。RevenueCat webhook 校验
webhook Authorization 密钥和 RevenueCat app_user_id 后,用 service-role key
写入这些表。
添加自己的功能
先创建 schema 和 RLS
除非是一对一用户资料行,否则新增表通常使用
user_id uuid references auth.users(id)。只给 authenticated 用户开放应用真正需要
的操作。
添加可序列化模型
把行模型放在 data/model/。对 snake_case 字段使用 @SerialName,让应用其他地方
保持惯用 Kotlin 命名。
拆分 repository 和远程数据源
创建 FeatureRemoteDataSource 接口、Supabase 实现,以及把失败映射为
AppResult 的 FeatureRepository。
用 Hilt 绑定实现
在 core/di/RepositoryModule.kt 添加 @Binds 方法。
让 ViewModel 保持简单
ViewModel 暴露一个 StateFlow<UiState>,在 viewModelScope 中启动工作,并在
composable 中用具备生命周期感知的 Compose collection 收集。
