- Đọ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
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.
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
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, Use platform-specific APIs.
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.
dataPlatformModulechí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ọnexpect valthay 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.platformIoDispatcherlàinternal. Bên ngoài:kernelchỉ thấy interfaceQzDispatchers, nên expect ở đây là chi tiết cài đặt.shouldUseHazequyết định có dùng hiệu ứng blur hay không. Bản Android gọisupportsHazeBlur(sdkInt), một hàminternaltách riêng để test được với nhiều giá trị SDK. Bản iOS trảtruevì 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:
AndroidShareSheetcầnContextvà activity hiện tại.AndroidGoogleSignInLaunchercần một hàm trả về activity đang resume. Đây là object có vòng đời, Koin quản lý được, cònactual funthì 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
commonTesttruyề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
PlaceholderBindingtrong:core, packageplatform, với một thuộc tínhresolutionghi số phận của bản giữ chỗ: sẽ được thay ở phase nào, hay được quyết định giữ lại. PlaceholderLedgerTesttrongshared-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):
./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
expectcủ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
supportsHazeBlurtách khỏishouldUseHaze, - nói được
PlaceholderLedgerTestngăn loại lỗi nào, - chạy
AppGraphTestphía JVM, chỉ ra test nào sẽ đỏ nếu bạn xóa bindingShareSheetkhỏiandroidPlatformModule, và giải thích vì sao xóa nó khỏiiosPlatformModulethì lệnh JVM vẫn xanh.