# 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ữ.

- URL: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-04-kmp-data-stack
- Khóa học: [Mobile Action Pro](https://dantech.academy/courses/kotlin-accelerator.md)
- Module: Nền Tảng Kotlin Multiplatform
- Thời lượng: 15 phút
- Độ khó: beginner
- Truy cập: Miễn phí, đọc toàn bài
- English: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-04-kmp-data-stack.md

## Mục tiêu

- 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ữ

---

## 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`:

```kotlin
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](https://ktor.io/docs/client-engines.html).

## 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](https://supabase.com/docs/reference/kotlin/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 DataStoreFileResolver` với một hàm `pathFor(name)`.
- `object DataStoreNames` gom 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ọi `PreferenceDataStoreFactory.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:

```bash
./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)](https://developer.android.com/kotlin/multiplatform/datastore), [DataStore](https://developer.android.com/topic/libraries/architecture/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)](https://developer.android.com/kotlin/multiplatform/room), [SQLDelight](https://sqldelight.github.io/sqldelight/latest/), [multiplatform-settings](https://github.com/russhwolf/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 `DataModule` không áp dụng cho request Supabase,
- chạy được `DataStoreFileResolverTest` và giải thích vì sao bản Android gọi `preferencesDataStoreFile` thay 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.

---

- Bài trước: [iOS Host Và Swift Interop](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-03-ios-host-swift-interop.md)
- Bài tiếp theo: [Thiết Kế UI Model Bất Biến](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/02-compose-ui-from-state/lesson-01-design-immutable-ui-models.md)
