- Giải thích cách repo tạo HttpClient trong commonMain và bind engine Ktor theo nền tảng, kể cả cách supabase-kt chọn engine
- Dùng DataStore trong KMP mà không làm mất dữ liệu người dùng hiện tại, và hiểu vì sao mỗi store phải là một instance
- Chọn giữa DataStore, multiplatform-settings, Room và SQLDelight cho từng nhu cầu lưu trữ
Data Stack Đa Nền Tảng
Tầng data của app KMP: một HttpClient với engine theo nền tảng, supabase-kt, DataStore qua path resolver và cách chọn thư viện lưu trữ.
Video bài học sắp lên sóng
Trong lúc chờ, bạn hãy đọc bản text của bài học bên dưới: đầy đủ lý thuyết, code mẫu và checklist thực hành.
Mục Tiêu Bài Học
Bài này đi qua tầng data của một app KMP: gọi mạng bằng Ktor, gọi Supabase bằng supabase-kt, và lưu dữ liệu local bằng DataStore. Với mỗi phần, bạn sẽ thấy phần nào viết một lần trong commonMain, phần nào buộc phải khác nhau theo nền tảng, và repo nối hai phần đó bằng cách nào.
Sau bài này, bạn chọn được thư viện lưu trữ phù hợp cho một project KMP, và tránh được lỗi đắt nhất của tầng data đa nền tảng: âm thầm làm mất dữ liệu của người dùng hiện tại sau một bản cập nhật.
Repository interface nằm ở :domain, implementation nằm ở :data. Bài này tập trung vào phần của :data chạm tới nền tảng.
Phần 1: Bức Tranh Chung
| Thành phần | commonMain | Android | iOS |
|---|---|---|---|
| HTTP | HttpClient trong DataModule | Engine Android.create() | Engine Darwin.create() |
| Supabase | SupabaseClientFactory | Engine Ktor chọn từ dependency | Engine Ktor chọn từ dependency |
| Preferences | createPreferencesDataStore trong DataStoreFiles.kt | AndroidDataStoreFileResolver | IosDataStoreFileResolver |
| Log | Kermit | Kermit, Timber ở code chỉ có Android | Kermit |
Mẫu lặp lại ở mọi dòng: logic và cấu hình nằm trong commonMain, nền tảng chỉ cung cấp phần nhỏ nhất không thể dùng chung. Binding của phần đó nằm trong dataPlatformModule, một expect val khai báo trong commonMain của :data với bản actual cho Android và iOS.
Phần 2: Một HttpClient, Engine Theo Nền Tảng
Ktor client là multiplatform, nhưng việc gửi request thật cần một engine riêng cho từng nền tảng. Mở data/build.gradle.kts:
androidMain.dependencies {
implementation(libs.ktor.client.android)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
Engine Darwin dùng hệ thống mạng của Apple, engine Android dùng API mạng của Android. Bảng engine trong tài liệu Ktor liệt kê đầy đủ engine nào chạy trên nền tảng nào. Một số project chọn OkHttp cho Android, hoặc CIO cho mọi nền tảng.
Repo bind engine trong dataPlatformModule: single<HttpClientEngine> { Android.create() } ở Android và single<HttpClientEngine> { Darwin.create() } ở iOS. Còn HttpClient được tạo một lần trong DataModule ở commonMain, nhận engine qua get<HttpClientEngine>() rồi cài plugin:
| Plugin | Cấu hình | Vì sao |
|---|---|---|
ContentNegotiation | JSON với instance Json dùng chung | Một cấu hình ignoreUnknownKeys cho mọi request |
HttpTimeout | Request 10 giây, connect 5 giây | Mạng chậm không treo màn hình vô thời hạn |
Logging | Mức BODY, chỉ khi isDebugBuild() | Log body hữu ích khi debug, nhưng body có thể chứa dữ liệu nhạy cảm, nên bản release không log |
Vì engine là một binding, test có thể thay bằng MockEngine của ktor-client-mock. GeminiHintProviderTest (trong androidHostTest, vì nó dùng thêm MockK) dùng cách này để giả lập phản hồi của Gemini, lỗi mạng và số lần retry mà không cần mạng thật.
Nếu bạn gọi HttpClient() không truyền engine, Ktor tự chọn engine từ các dependency có trong build. Cách này gọn nhưng giấu đi quyết định. Repo truyền engine tường minh để test thay được và để đọc code là biết engine nào đang chạy.
Tài liệu: Ktor client engines.
Phần 3: supabase-kt
Mở SupabaseClientFactory. Factory đọc URL và publishable key (StaticSecretKey.SUPABASE_PUBLISHABLE_KEY) từ SecretManager, rồi gọi createSupabaseClient với hai plugin Auth và Postgrest.
Điểm cần để ý: factory không truyền HttpClient của DataModule vào. supabase-kt tự tạo client Ktor riêng, và tài liệu của Supabase yêu cầu thêm một Ktor engine cho mỗi target. Repo đáp ứng điều đó bằng chính hai dependency engine ở Phần 2. Hệ quả: timeout và logging cấu hình trong DataModule không áp dụng cho request Supabase. Khi debug request Supabase, đừng tìm log của KtorHttpClient.
supabase-kt vẫn cho phép truyền engine qua tham số httpEngine. Test LeaderboardRepositoryImplQueryTest trong commonTest truyền MockEngine vào đó để kiểm tra query gửi tới PostgREST có sắp xếp theo rank và đúng giới hạn số dòng. Test này nằm ở commonTest, nên chạy trên cả JVM lẫn iOS Simulator.
Về bảo mật: publishable key của Supabase (sb_publishable_...) được thiết kế để nằm trong app, và an toàn của dữ liệu dựa vào Row Level Security trên server. Policy RLS của các bảng nằm trong scripts/sql/setup.sql.
Tài liệu: supabase-kt installing.
Phần 4: DataStore Đa Nền Tảng
Trên Android thuần, bạn quen viết by preferencesDataStore("name"). Delegate đó chỉ có trên Android và giấu đường dẫn file. Bản multiplatform buộc bạn nói rõ file nằm ở đâu.
Repo chia việc này làm ba mảnh trong DataStoreFiles.kt:
fun interface DataStoreFileResolvervới một hàmpathFor(name).object DataStoreNamesgom bốn tên store vào một chỗ, để gõ sai một tên không làm mồ côi một file.createPreferencesDataStore(resolver, name)gọiPreferenceDataStoreFactory.createWithPath.
Hai implementation:
| Nền tảng | Class | File nằm ở |
|---|---|---|
| Android | AndroidDataStoreFileResolver | Đúng file mà delegate cũ dùng, lấy qua preferencesDataStoreFile(name) của androidx |
| iOS | IosDataStoreFileResolver | Thư mục datastore trong Application Support, tìm bằng NSFileManager |
Vì Sao Đường Dẫn Quan Trọng
KDoc của DataStoreFileResolver nói thẳng: đặt sai đường dẫn không làm build hay test lỗi. App sẽ mở một file trống, và mọi người dùng hiện tại mất settings, lịch sử chơi, thành tích sau khi cập nhật. Đây là thay đổi duy nhất trong cuộc migrate không quay lại được.
Vì vậy bản Android không tự ghép filesDir/datastore/... mà gọi helper của androidx. Nếu androidx đổi cách đặt file, resolver đi theo. DataStoreFileResolverTest ghim lại đường dẫn để mọi thay đổi đều lộ ra:
./gradlew :data:testAndroidHostTest --tests "*DataStoreFileResolverTest"
Lệnh này đã được chạy kiểm chứng (6 test xanh).
Ở iOS, ví dụ trong tài liệu Android đặt file ở thư mục Documents. Repo chọn Application Support, trong một thư mục datastore riêng. KDoc của IosDataStoreFileResolver giải thích: thư mục Documents có thể hiện ra trong app Files, mà các store này không phải tài liệu của người dùng. Repo không đánh dấu loại trừ backup, nên các file này vẫn nằm trong iCloud backup mặc định. Chúng còn sau khi cập nhật app và mất khi gỡ app, giống filesDir trên Android. Khi đọc ví dụ trong tài liệu, hãy coi đường dẫn là một quyết định của bạn.
Một Instance Cho Mỗi File
Trong DataModule, mỗi store là một single có qualifier theo tên. Comment giải thích: delegate cũ tự giữ một instance cho mỗi Context, factory thì không, và hai DataStore cùng mở một file sẽ lỗi ở lần đọc đầu tiên. Tài liệu DataStore cũng ghi rõ quy tắc này: không bao giờ tạo quá một instance DataStore cho cùng một file trong cùng một process.
Theo hướng dẫn KMP của Android, DataStore hỗ trợ KMP từ bản 1.1.0, và trong project KMP chỉ Preferences DataStore được hỗ trợ. Repo dùng 1.2.1.
Tài liệu: DataStore (Kotlin Multiplatform), DataStore.
Phần 5: Chọn Thư Viện Lưu Trữ
DataStore không phải lựa chọn duy nhất. Khi nhận project khác, bạn sẽ gặp những thư viện này:
| Thư viện | Phù hợp khi | Ghi chú |
|---|---|---|
| DataStore Preferences | Key-value, đọc ghi bất đồng bộ qua Flow | Repo dùng cho settings, lịch sử chơi, thành tích, daily challenge |
| multiplatform-settings | Key-value đơn giản, đọc ghi đồng bộ | Bọc SharedPreferences trên Android và NSUserDefaults trên iOS, có KeychainSettings cho Keychain |
| Room (KMP) | Dữ liệu quan hệ, query phức tạp, đội đã quen Room trên Android | Dùng KSP, tài liệu khuyên dùng BundledSQLiteDriver để có cùng bản SQLite trên mọi nền tảng |
| SQLDelight | Muốn viết SQL trước, sinh Kotlin type-safe từ SQL | Có driver cho từng nền tảng |
Repo này không dùng multiplatform-settings. Trước đây nó từng được khai báo trong data/build.gradle.kts và app/build.gradle.kts mà không file Kotlin nào import com.russhwolf, và đã được gỡ ở PR #30. Rule -dontwarn com.russhwolf.settings.** còn sót trong app/proguard-rules.pro được gỡ ở PR #32. Đây là bài học khi đọc project lạ: dependency trong build file chỉ cho biết thứ có thể được dùng. Muốn biết thứ đang được dùng, tìm import trong code.
Dữ liệu nhạy cảm như token không nên nằm trong file preferences thường. Trên iOS, nơi phù hợp là Keychain.
Tài liệu: Room (Kotlin Multiplatform), SQLDelight, multiplatform-settings.
Bảng Xử Lý Lỗi
| Triệu chứng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Người dùng mất settings sau khi cập nhật, không có lỗi nào | Đường dẫn DataStore đổi so với bản trước | So đường dẫn với bản cũ, chạy DataStoreFileResolverTest |
| DataStore lỗi ở lần đọc đầu tiên | Hai instance cùng mở một file | Bind mỗi store bằng single có qualifier, như DataModule |
| Request iOS lỗi trong khi Android chạy được | Thiếu engine Darwin trong iosMain.dependencies | Thêm ktor-client-darwin vào iosMain |
| Không thấy log của request Supabase | supabase-kt dùng client Ktor riêng | Debug ở tầng repository, hoặc dùng log của supabase-kt |
| Log request in ra body chứa token ở bản release | Bật Logging không có điều kiện | Chỉ cài Logging khi debug, như DataModule |
Checkpoint
Bạn hoàn tất bài này khi:
- chỉ ra được nơi
HttpClientđược tạo và nơi engine của từng nền tảng được bind, - giải thích được vì sao timeout trong
DataModulekhông áp dụng cho request Supabase, - chạy được
DataStoreFileResolverTestvà giải thích vì sao bản Android gọipreferencesDataStoreFilethay vì tự ghép đường dẫn, - nói được vì sao mỗi DataStore phải là
single, - chọn được thư viện lưu trữ cho ba nhu cầu: cờ "đã xem onboarding", danh sách câu hỏi tải về có lọc theo chủ đề, refresh token.