# Platform Seams: expect/actual Hay Interface + DI

> Chọn giữa expect/actual và interface + DI cho mỗi chỗ code chung phải gọi tới nền tảng, dựa trên ba expect và các interface seam thật của repo.

- URL: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-02-platform-seams-expect-actual-di
- 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-02-platform-seams-expect-actual-di.md

## Mục tiêu

- Đọc được mọi platform seam trong repo và giải thích vì sao mỗi seam chọn expect/actual hay interface + DI
- Áp dụng quy tắc chọn công cụ khi thêm seam mới vào một project KMP
- Hiểu PlaceholderBinding, PlaceholderLedgerTest và AppGraphTest giữ hai nền tảng đồng bộ như thế nào

---

## Mục Tiêu Bài Học

Mọi app KMP đều có những chỗ code chung phải gọi tới nền tảng: phát âm thanh, rung, mở share sheet, đăng nhập Google, chọn thư mục lưu file. Bài này gọi những chỗ đó là platform seam và dạy bạn chọn giữa hai công cụ Kotlin cung cấp: `expect`/`actual`, hoặc interface trong `commonMain` cộng implementation qua dependency injection (DI).

Sau bài này, bạn đọc được mọi seam trong repo, biết vì sao mỗi seam chọn công cụ đó, và tự chọn đúng khi thêm seam mới vào bất kỳ project KMP nào.

Ví dụ quen thuộc nhất là `AudioManager`: một interface, hai implementation (`SoundPoolAudioManager` cho Android, `AvAudioManager` cho iOS). Bài này đi vào câu hỏi thiết kế đằng sau.

## Phần 1: Hai Công Cụ

`expect`/`actual` là cơ chế của ngôn ngữ. Bạn khai báo `expect` trong `commonMain`, rồi mỗi nền tảng viết `actual` cùng tên, cùng package. Compiler kiểm tra mọi `expect` đều có `actual` trên mọi target. `actual` có thể nằm ở source set trung gian như `iosMain`, không cần viết riêng cho `iosArm64Main` và `iosSimulatorArm64Main`.

Interface + DI chỉ dùng Kotlin thông thường. Interface nằm trong `commonMain`, implementation nằm ở `androidMain`/`iosMain`, và Koin chọn implementation lúc app khởi động.

Tài liệu của Kotlin nghiêng hẳn về phía thứ hai. Trang về expect/actual khuyên: nếu project đã dùng DI framework, hãy dùng chính nó cho dependency của nền tảng, và expect/actual chỉ cần ở phần cấu hình DI. Cũng trang đó ghi `expect class` vẫn đang ở trạng thái Beta và không nên dùng cho trường hợp interface đã đủ, vì interface cho phép nhiều implementation trên một nền tảng và dễ thay bằng fake khi test.

Tài liệu: [Expected and actual declarations](https://kotlinlang.org/docs/multiplatform/multiplatform-expect-actual.html), [Use platform-specific APIs](https://kotlinlang.org/docs/multiplatform/multiplatform-connect-to-apis.html).

## Phần 2: Ba `expect` Của Repo

Tìm từ khóa `expect ` trong code production, bạn chỉ thấy ba chỗ:

| Khai báo                                   | File                                      | Android                                  | iOS                     |
| ------------------------------------------ | ----------------------------------------- | ---------------------------------------- | ----------------------- |
| `expect val dataPlatformModule: Module`    | `:data`, `DataPlatformModule.kt`          | Koin module với SoundPool, Vibrator, Firebase | Koin module với AVFoundation, UIKit, placeholder |
| `internal expect val platformIoDispatcher` | `:kernel`, `QzDispatchersImpl.kt`         | `Dispatchers.IO`                         | `Dispatchers.Default`   |
| `expect fun shouldUseHaze(): Boolean`      | `:ui-components`, `HazeUtils.kt`          | `true` từ Android 13 trở lên             | luôn `true`             |

Cả ba có chung một đặc điểm: chúng là một giá trị hoặc một hàm thuần, không giữ state, không cần nhiều implementation trên cùng nền tảng.

- `dataPlatformModule` chính là "phần cấu hình DI" mà tài liệu nhắc tới. Code chung cần gọi tên nó để nạp vào Koin, nhưng nội dung mỗi nền tảng khác nhau. KDoc giải thích vì sao chọn `expect val` thay vì `expect class`: mỗi nền tảng chỉ cần cung cấp một giá trị, không phải sao chép cả hình dạng của một type.
- `platformIoDispatcher` là `internal`. Bên ngoài `:kernel` chỉ thấy interface `QzDispatchers`, nên expect ở đây là chi tiết cài đặt.
- `shouldUseHaze` quyết định có dùng hiệu ứng blur hay không. Bản Android gọi `supportsHazeBlur(sdkInt)`, một hàm `internal` tách riêng để test được với nhiều giá trị SDK. Bản iOS trả `true` vì Skia blur được trên mọi phiên bản iOS.

Test cũng dùng expect. `AppGraphTest` trong `shared-app/src/commonTest` khai báo `expect val graphPlatformModules` và `expect val bindingsNeedingPlatformRuntime`. Bản `actual` nằm trong `androidHostTest` và `iosTest`. Một test viết một lần, chạy trên cả hai nền tảng với Koin module của đúng nền tảng đó.

## Phần 3: Các Interface Seam

Phần còn lại của repo dùng interface. Đây là danh sách đầy đủ:

| Interface                           | Khai báo ở                      | Android                          | iOS                                    |
| ----------------------------------- | ------------------------------- | -------------------------------- | -------------------------------------- |
| `AudioManager`                      | `:domain`                       | `SoundPoolAudioManager`          | `AvAudioManager`                       |
| `HapticManager`                     | `:domain`                       | `VibratorHapticManager`          | `UiKitHapticManager`                   |
| `AnalyticsManager`                  | `:domain`                       | `FirebaseAnalyticsManager`       | `NoOpAnalyticsManager`                 |
| `GoogleSignInLauncher`              | `:domain`                       | `AndroidGoogleSignInLauncher`    | `IosGoogleSignInLauncher`              |
| `RemoteConfigManager`               | `:data`                         | `FirebaseRemoteConfigManager`    | `DefaultRemoteConfigManager`           |
| `DataStoreFileResolver`             | `:data`                         | `AndroidDataStoreFileResolver`   | `IosDataStoreFileResolver`             |
| `Notifier`, `ShareSheet`            | `:core`, package `platform`     | `AndroidNotifier`, `AndroidShareSheet` | `IosNotifier`, `IosShareSheet`   |
| `ScreenWakeLock`                    | `:core`, package `platform`     | `AndroidScreenWakeLock`          | `IosScreenWakeLock`                    |
| `NotificationScheduler`             | `:core`, package `platform`     | `DailyNotificationScheduler` (trong `:app`) | `IosNotificationScheduler`  |

Binding nằm ở bốn Koin module: `dataPlatformModule` (hai bản, trong `:data`), `androidPlatformModule` và `iosPlatformModule` (trong `:shared-app`). `NotificationScheduler` trên Android là ngoại lệ, nằm trong `:app` vì nó dùng WorkManager, mà WorkManager được giữ ở `:app`.

Vì sao những seam này chọn interface? Nhìn vào constructor của implementation:

- `AndroidShareSheet` cần `Context` và activity hiện tại. `AndroidGoogleSignInLauncher` cần một hàm trả về activity đang resume. Đây là object có vòng đời, Koin quản lý được, còn `actual fun` thì không có chỗ để nhận chúng.
- `IosNotificationPermissionChecker` được bind một lần nhưng phục vụ hai vai trò. DI cho phép điều đó, expect/actual thì không.
- ViewModel và use case nhận interface qua constructor, nên test trong `commonTest` truyền fake vào mà không cần chạm tới nền tảng.

## Phần 4: Quy Tắc Chọn

| Tình huống                                                        | Chọn                         |
| ----------------------------------------------------------------- | ---------------------------- |
| Implementation cần `Context`, activity, hay object có vòng đời khác | Interface + DI             |
| Muốn thay bằng fake trong test                                    | Interface + DI               |
| Có thể có nhiều implementation trên cùng nền tảng (thật, no-op, debug) | Interface + DI          |
| Một giá trị hoặc hàm thuần, không dependency, không state          | `expect val` / `expect fun` |
| Chính phần cấu hình DI cần khác nhau theo nền tảng                 | `expect val` của Koin module |
| Phải kế thừa một class có sẵn của nền tảng, hoặc dùng thẳng type của nền tảng qua `actual typealias` | `expect class` (Beta) |

Khi phân vân, chọn interface. Đổi từ interface sang expect hiếm khi cần, còn chiều ngược lại thường kéo theo sửa cả code test.

Một cái bẫy với `expect fun`: test trong `commonTest` không thay được giá trị của nó. Repo né bằng cách giữ `shouldUseHaze` thật mỏng và đẩy logic sang `supportsHazeBlur(sdkInt)`, một hàm nhận tham số nên test được. Nếu một `expect fun` của bạn bắt đầu có nhánh `if`, đó là dấu hiệu nên chuyển nó thành interface.

## Phần 5: Seam Không Làm Gì

Không phải seam nào cũng có implementation thật trên mọi nền tảng. Trên iOS, repo không có Firebase, nên `AnalyticsManager` và `RemoteConfigManager` được bind bằng bản giữ chỗ.

Bản giữ chỗ nguy hiểm ở chỗ nó trông giống hệt một tính năng bị hỏng: app chạy, nút bấm phản hồi, không có gì xảy ra. Repo xử lý bằng hai thứ:

- Interface `PlaceholderBinding` trong `:core`, package `platform`, với một thuộc tính `resolution` ghi số phận của bản giữ chỗ: sẽ được thay ở phase nào, hay được quyết định giữ lại.
- `PlaceholderLedgerTest` trong `shared-app/src/iosTest`. Test dựng toàn bộ Koin graph của iOS, lấy ra mọi binding là `PlaceholderBinding`, và so với danh sách mong đợi.

KDoc của test kể lại lý do nó đọc từ graph thay vì từ danh sách viết tay: danh sách viết tay từng bỏ sót bốn bản giữ chỗ trong `:data`. Hiện còn hai dòng, `NoOpAnalyticsManager` và `DefaultRemoteConfigManager`, cả hai được ghi là giữ vĩnh viễn vì Firebase trên iOS đã bị hủy theo quyết định của owner.

`NoOpAnalyticsManager` cũng không im lặng hoàn toàn: nó ghi log qua Kermit mỗi lần được gọi, để bạn phân biệt "analytics đang là bản giữ chỗ" với "dòng code gọi analytics chưa bao giờ chạy tới".

`PlaceholderLedgerTest` nằm trong `iosTest`, nên chỉ chạy bằng `iosSimulatorArm64Test` trên Mac. Bước này chưa được chạy thử trên Mac khi viết bài.

## Phần 6: Giữ Hai Nền Tảng Đồng Bộ

Interface + DI có một điểm yếu mà expect/actual không có: quên bind trên một nền tảng không phải lỗi compile. App build xanh rồi crash lúc Koin không tìm thấy binding.

Repo bù lại bằng `AppGraphTest`, test khởi động toàn bộ graph Koin và resolve mọi binding. Test nằm trong `commonTest` và dùng `expect val graphPlatformModules` ở Phần 2: trên JVM, giá trị này là `androidPlatformModule`; trên iOS Simulator, nó là `iosPlatformModule`. Cùng một test kiểm tra mỗi nền tảng bằng Koin module của chính nền tảng đó. Hai test liên quan trực tiếp tới seam:

- `platformModulesBindTheSharedSeams`: platform module phải bind đủ `Notifier`, `ShareSheet`, `ScreenWakeLock`, `GoogleSignInLauncher`, `NotificationPermissionRequester`, `NotificationPermissionChecker`.
- `everyRegisteredTypeResolvesWithAllItsDependencies`: mọi binding đều resolve được, trừ một danh sách nhỏ cần runtime thật của nền tảng. Danh sách đó được kiểm tra theo cả hai chiều, nên không ai thêm bừa vào để test xanh.

Chạy phía JVM (đã chạy kiểm chứng, 4 test xanh):

```bash
./gradlew :shared-app:testAndroidHostTest --tests "*AppGraphTest"
```

Lệnh này chỉ kiểm tra `androidPlatformModule`. Thiếu binding trong `iosPlatformModule` chỉ bị bắt khi chạy `:shared-app:iosSimulatorArm64Test` trên Mac. Bước Mac chưa được chạy thử khi viết bài.

Khi thêm một interface seam mới cần có trên cả hai nền tảng, thêm nó vào danh sách `sharedPlatformSeams` trong `AppGraphTest` cùng lúc với hai binding.

## Bảng Xử Lý Lỗi

| Triệu chứng                                                        | Nguyên nhân thường gặp                                  | Cách xử lý                                                   |
| ------------------------------------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------ |
| Compiler báo thiếu `actual` cho một target                         | Thêm `expect` nhưng chỉ viết `actual` cho Android       | Viết `actual` trong `iosMain`, cùng package với `expect`     |
| `actual` đúng tên nhưng compiler vẫn báo không khớp                | Khác package hoặc khác chữ ký                           | So package và kiểu trả về với bản `expect`                   |
| Cảnh báo Beta khi dùng `expect class`                              | Tính năng chưa stable                                   | Cân nhắc đổi sang interface trước khi tắt cảnh báo           |
| App iOS crash vì Koin không tìm thấy definition, Android chạy bình thường | Thiếu binding trong `iosPlatformModule` hoặc `dataPlatformModule` bản iOS | Thêm binding, thêm seam vào `sharedPlatformSeams`, chạy `AppGraphTest` trên iOS Simulator |
| Tính năng iOS "không làm gì" nhưng không lỗi                       | Đang dùng bản giữ chỗ                                    | Xem danh sách trong `PlaceholderLedgerTest`                  |

## Checkpoint

Bạn hoàn tất bài này khi:

- liệt kê được ba `expect` của code production và giải thích vì sao mỗi cái không cần là interface,
- chọn được interface hay expect cho ba ví dụ: đọc phiên bản hệ điều hành, mở trang cài đặt của app, ghi file log có xoay vòng,
- giải thích được vì sao `supportsHazeBlur` tách khỏi `shouldUseHaze`,
- nói được `PlaceholderLedgerTest` ngăn loại lỗi nào,
- chạy `AppGraphTest` phía JVM, chỉ ra test nào sẽ đỏ nếu bạn xóa binding `ShareSheet` khỏi `androidPlatformModule`, và giải thích vì sao xóa nó khỏi `iosPlatformModule` thì lệnh JVM vẫn xanh.

---

- Bài trước: [Giải Phẫu Một Project KMP](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-01-kmp-project-anatomy.md)
- Bài tiếp theo: [iOS Host Và Swift Interop](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-03-ios-host-swift-interop.md)
