数据层

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:

RepositoryResult 边界
AuthRepository所有认证命令返回 AppResult<Unit>
TodosRepositoryCRUD、清除已完成、排序返回 AppResult
ProfileRepository读取和逐字段更新返回 AppResult
SubscriptionRepository权益读取返回 AppResult<Subscription?>
NotificationsRepository推送注册和测试推送返回 AppResult

UploadsRepository 是例外:它直接暴露 Flow<UploadStatus>,因为 UI 需要来自 Supabase Storage 的每个上传任务进度事件。

模型

Supabase 行被建模为 @Serializable Kotlin data class:

Model应用使用的字段
Todopublic.todosidtitlecompletedsort_ordercreated_at
Profilepublic.profilesidfull_namebiophonelocationavatar_urlupdated_at
Subscriptionpublic.subscriptions商品、provider、状态、周期、试用、取消和账单时间戳

Subscription 使用宽容 enum serializer。未来服务端新增 enum 值时,旧客户端会解码 成 Unknown,不会直接崩溃。

远程数据源

RepositoryModule 把接口绑定到 Supabase 实现:

Interface生产实现后端表面
AuthRemoteDataSourceSupabaseAuthRemoteDataSourceSupabase Auth + 删除账号时的 PostgREST 清理
TodosRemoteDataSourceSupabaseTodosRemoteDataSourcepublic.todos
ProfileRemoteDataSourceSupabaseProfileRemoteDataSourcepublic.profiles
SubscriptionRemoteDataSourceSupabaseSubscriptionRemoteDataSourcepublic.subscriptions
DeviceTokenRemoteDataSourceSupabaseDeviceTokenRemoteDataSourcepublic.device_tokens
TestPushRemoteDataSourceSupabaseTestPushRemoteDataSourcesend-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() 的行

subscriptionspayments 故意没有客户端写策略。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 实现,以及把失败映射为 AppResultFeatureRepository

用 Hilt 绑定实现

core/di/RepositoryModule.kt 添加 @Binds 方法。

让 ViewModel 保持简单

ViewModel 暴露一个 StateFlow<UiState>,在 viewModelScope 中启动工作,并在 composable 中用具备生命周期感知的 Compose collection 收集。

相关页面

On this page