Room 進階用法、Retrofit 整合與 Offline-First 架構
當問題涉及本地儲存、網路、快取一致性、Offline-First 架構時載入。本 skill 預設 Android-only 專案使用 Room;KMP 專案的 SQLDelight 路線由 @kotlin_multiplatform 接管,本 skill 提供決策矩陣指向它。
@kotlin_multiplatform@release_automation@deep_performance_tuningMigrationTestHelper 測試。NetworkResult + interceptor stack(auth / retry / logging)。| 元件 | 版本 / 推薦 | 備註 |
|---|---|---|
| Room | 2.7.x(KMP-ready) | KSP2 編譯 |
| Retrofit | 2.11+ | 配 Kotlinx Serialization Converter |
| OkHttp | 4.12+ | call timeout、event listener |
| Ktor Client | 3.x | KMP 場景 |
| DataStore Preferences | 1.1+ | 簡單 key-value |
| DataStore Proto | 1.1+ + protobuf-kotlin | 結構化、版本化、推薦預設 |
| Paging | 3.3+ | Compose 整合用 LazyPagingItems |
| WorkManager | 2.10+ | KSP 編譯、含 expedited |
目標: <例:商品列表離線可看,背景每 30 分鐘同步>
本地: Room (entity + dao) 或 Proto DataStore (settings)
遠端: Retrofit + Kotlinx Serialization
快取策略: read-through (local-first emit, fetch update)
同步: WorkManager periodic + OneTime on connectivity
錯誤模型: NetworkResult<T> + DomainError sealed
驗收: Quick Checklist
| 因素 | Room | SQLDelight |
|---|---|---|
| Android-only 專案 | ✅ 預設 | 可用但無優勢 |
| KMP(Android + iOS)共享 schema | ⚠️ Room 2.7+ 已支援 KMP,但 iOS 仍實驗 | ✅ 成熟 |
| 寫 SQL(vs 註解) | 註解為主 | 純 SQL |
| 編譯期檢查 | ✅(query 驗證) | ✅ |
| 與 AndroidX 生態(Paging、WorkManager) | ✅ 原生 | 需 wrapper |
| Reactive(Flow) | ✅ 內建 | ✅ Coroutines extension |
| Migration 工具 | ✅ MigrationTestHelper | 手寫 .sqm |
| 建議 | Android-only / Compose-first | 真 KMP 跨 iOS / 純 SQL 偏好 |
選擇 SQLDelight 後請載入 @kotlin_multiplatform 看 commonMain 設計。
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE users ADD COLUMN avatar_url TEXT")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE users_new (id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, email TEXT NOT NULL)")
db.execSQL("INSERT INTO users_new (id, name, email) SELECT id, name, COALESCE(email,'') FROM users")
db.execSQL("DROP TABLE users")
db.execSQL("ALTER TABLE users_new RENAME TO users")
}
}
// androidTest/.../MigrationsTest.kt
@get:Rule val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
)
@Test fun migrate1To3() {
helper.createDatabase(TEST_DB, 1).apply {
execSQL("INSERT INTO users (id, name, email) VALUES ('u1', 'Alice', NULL)")
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_1_2, MIGRATION_2_3)
val cursor = db.query("SELECT email FROM users WHERE id = 'u1'")
cursor.moveToFirst()
assertEquals("", cursor.getString(0))
}
@Dao interface UserDao {
@Query("SELECT * FROM users ORDER BY name") fun pagingSource(): PagingSource<Int, UserEntity>
}
class UserRepository @Inject constructor(private val dao: UserDao) {
fun pagedUsers(): Flow<PagingData<User>> = Pager(
config = PagingConfig(pageSize = 30, prefetchDistance = 10, enablePlaceholders = false),
pagingSourceFactory = { dao.pagingSource() },
).flow.map { it.map(UserEntity::toDomain) }
}
@Composable
fun UserListScreen(viewModel: UserListViewModel = hiltViewModel()) {
val items = viewModel.users.collectAsLazyPagingItems()
LazyColumn {
items(items.itemCount, key = items.itemKey { it.id }) { i ->
items[i]?.let { UserRow(it) }
}
}
}
@Fts4(contentEntity = ArticleEntity::class)
@Entity(tableName = "articles_fts")
data class ArticleFtsEntity(@ColumnInfo("title") val title: String, @ColumnInfo("content") val content: String)
@Dao interface ArticleDao {
@Query("SELECT * FROM articles WHERE rowid IN (SELECT rowid FROM articles_fts WHERE articles_fts MATCH :query)")
fun search(query: String): Flow<List<ArticleEntity>>
}
sealed interface NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>
data class HttpError(val code: Int, val body: String?) : NetworkResult<Nothing>
data object NetworkUnavailable : NetworkResult<Nothing>
data class Unknown(val cause: Throwable) : NetworkResult<Nothing>
}
suspend fun <T> safeApiCall(block: suspend () -> T): NetworkResult<T> = try {
NetworkResult.Success(block())
} catch (e: HttpException) {
NetworkResult.HttpError(e.code(), e.response()?.errorBody()?.string())
} catch (e: IOException) {
NetworkResult.NetworkUnavailable
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
NetworkResult.Unknown(e)
}
@Provides @Singleton
fun okHttp(
@AuthenticatedToken tokenProvider: TokenProvider,
): OkHttpClient = OkHttpClient.Builder()
.callTimeout(30, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor(tokenProvider))
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) BODY else NONE
})
.addNetworkInterceptor(EtagCacheInterceptor())
.eventListenerFactory(OkHttpEventListenerFactory(metrics))
.build()
class AuthInterceptor(private val tokens: TokenProvider) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val req = chain.request().newBuilder()
.header("Authorization", "Bearer ${tokens.access()}")
.build()
val res = chain.proceed(req)
if (res.code == 401) {
tokens.refresh()
res.close()
return chain.proceed(req.newBuilder().header("Authorization", "Bearer ${tokens.access()}").build())
}
return res
}
}
不要用第三方 RetryInterceptor 包退避策略;交給 OkHttp callTimeout + WorkManager backoff,更可控。
class UserRepository @Inject constructor(
private val remote: UserRemoteDataSource,
private val local: UserLocalDataSource,
@IoDispatcher private val io: CoroutineDispatcher,
) {
fun observeUser(id: String): Flow<User?> = local.observeUser(id)
suspend fun refresh(id: String): Result<Unit> = withContext(io) {
when (val r = safeApiCall { remote.fetchUser(id) }) {
is NetworkResult.Success -> {
local.upsert(r.data.toEntity())
Result.success(Unit)
}
is NetworkResult.HttpError -> Result.failure(HttpException(r.code))
is NetworkResult.NetworkUnavailable -> Result.success(Unit) // 容忍離線
is NetworkResult.Unknown -> Result.failure(r.cause)
}
}
}
UI 永遠 collect 自 local;refresh 失敗不影響顯示,只回報為 snackbar/state。
class SyncWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters,
private val userRepo: UserRepository,
private val orderRepo: OrderRepository,
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val results = listOf(
userRepo.refresh(currentUserId),
orderRepo.syncPending(),
)
return if (results.all { it.isSuccess }) Result.success() else Result.retry()
}
}
object SyncScheduler {
fun schedulePeriodic(ctx: Context) {
val req = PeriodicWorkRequestBuilder<SyncWorker>(30, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(ctx)
.enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.UPDATE, req)
}
fun syncOnConnectivity(ctx: Context) {
val req = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(ctx).enqueueUniqueWork("sync_now", ExistingWorkPolicy.KEEP, req)
}
}
衝突策略:local 帶 lastUpdatedAt 時間戳;同步時 server-wins 或 client-wins 或 merge,依業務寫入 SyncPolicy。
val Context.settings by preferencesDataStore(
name = "settings",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "old_prefs")) },
)
private val DARK_THEME = booleanPreferencesKey("dark_theme")
class SettingsRepository @Inject constructor(@ApplicationContext private val ctx: Context) {
val darkTheme: Flow<Boolean> = ctx.settings.data.map { it[DARK_THEME] ?: false }
suspend fun setDarkTheme(v: Boolean) { ctx.settings.edit { it[DARK_THEME] = v } }
}
// app/src/main/proto/user_settings.proto
syntax = "proto3";
option java_package = "com.example.proto";
option java_multiple_files = true;
message UserSettings {
bool dark_theme = 1;
string locale = 2;
int32 sync_interval_min = 3;
}
object UserSettingsSerializer : Serializer<UserSettings> {
override val defaultValue: UserSettings = UserSettings.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserSettings = UserSettings.parseFrom(input)
override suspend fun writeTo(t: UserSettings, output: OutputStream) = t.writeTo(output)
}
val Context.userSettings by dataStore("user_settings.pb", UserSettingsSerializer)
選用:是結構化資料、需版本化、要型別安全 → Proto;單純開關/字串 → Preferences。
@kotlin_multiplatform:跨 iOS 共享 → SQLDelight + Ktor,本 skill 提供 Android-side 接合。@dependency_injection_mastery:Repository / DataSource / OkHttpClient 的 Hilt module 設計。@navigation_patterns:以 type-safe routes 帶資料 ID 而非整個物件。@deep_performance_tuning:DAO query 慢時用 Macrobenchmark + Perfetto 分析。@release_automation:API key 與 base URL 注入由它管。safeApiCall,回 NetworkResult<T>