Dan Tech Academy
Sau bài này bạn sẽ
  • Đọ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
Thời lượng15 phút
Độ khóBeginner

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 đang cập nhật

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.

Bản quay đang trong khâu hậu kỳ

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áoFileAndroidiOS
expect val dataPlatformModule: Module:data, DataPlatformModule.ktKoin module với SoundPool, Vibrator, FirebaseKoin module với AVFoundation, UIKit, placeholder
internal expect val platformIoDispatcher:kernel, QzDispatchersImpl.ktDispatchers.IODispatchers.Default
expect fun shouldUseHaze(): Boolean:ui-components, HazeUtils.kttrue từ Android 13 trở lênluô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 đủ:

InterfaceKhai báo ởAndroidiOS
AudioManager:domainSoundPoolAudioManagerAvAudioManager
HapticManager:domainVibratorHapticManagerUiKitHapticManager
AnalyticsManager:domainFirebaseAnalyticsManagerNoOpAnalyticsManager
GoogleSignInLauncher:domainAndroidGoogleSignInLauncherIosGoogleSignInLauncher
RemoteConfigManager:dataFirebaseRemoteConfigManagerDefaultRemoteConfigManager
DataStoreFileResolver:dataAndroidDataStoreFileResolverIosDataStoreFileResolver
Notifier, ShareSheet:core, package platformAndroidNotifier, AndroidShareSheetIosNotifier, IosShareSheet
ScreenWakeLock:core, package platformAndroidScreenWakeLockIosScreenWakeLock
NotificationScheduler:core, package platformDailyNotificationScheduler (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ốngChọn
Implementation cần Context, activity, hay object có vòng đời khácInterface + DI
Muốn thay bằng fake trong testInterface + 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 stateexpect val / expect fun
Chính phần cấu hình DI cần khác nhau theo nền tảngexpect 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 typealiasexpect 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ứngNguyên nhân thường gặpCách xử lý
Compiler báo thiếu actual cho một targetThêm expect nhưng chỉ viết actual cho AndroidViết actual trong iosMain, cùng package với expect
actual đúng tên nhưng compiler vẫn báo không khớpKhá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 classTính năng chưa stableCâ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ườngThiếu binding trong iosPlatformModule hoặc dataPlatformModule bản iOSThê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.
Đăng nhập