Bài toán
Trong một app Kotlin Multiplatform (KMP), phần lớn logic nằm ở commonMain: ViewModel, repository, luật chơi, định dạng dữ liệu. Nhưng sớm muộn bạn sẽ cần một thứ chỉ nền tảng mới có, ví dụ giờ hệ thống, clipboard, hay Context của Android.
Kotlin cho bạn hai cách để gọi những thứ đó từ code chung:
expect/actual: khai báo ởcommonMain, mỗi nền tảng viết phầnactualcủa mình.- Một
interfaceKotlin bình thường ởcommonMain, mỗi nền tảng viết một class implement nó, rồi truyền implementation vào từ bên ngoài.
Cả hai đều chạy được. Bài này giúp bạn chọn đúng cách cho từng chỗ, dựa trên tài liệu chính thức của Kotlin và code mình đã build thử trên Kotlin 2.4.
expect/actual hoạt động thế nào
Bạn khai báo một hàm với expect ở commonMain, không có thân hàm:
// commonMain/kotlin/com/example/match/Now.kt
package com.example.match
expect fun currentTimeMillis(): Long
Mỗi source set nền tảng viết phần actual, cùng package:
// androidMain/kotlin/com/example/match/Now.android.kt
package com.example.match
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain/kotlin/com/example/match/Now.ios.kt
package com.example.match
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
Khi build cho từng target, compiler ghép expect với actual tương ứng thành một khai báo duy nhất. Nó cũng kiểm tra giúp bạn ba việc: mọi expect đều có actual ở mọi nền tảng bạn build, phần expect không chứa implementation, và hai bên nằm cùng package. Quên viết actual cho iOS thì build iOS báo lỗi ngay.
Cách này hợp với những thứ nhỏ và không có state: lấy giờ, chọn CoroutineDispatcher cho IO, đọc tên nền tảng.
Khi nào expect fun bắt đầu khó dùng
Giả sử bạn muốn copy kết quả trận đấu vào clipboard. Trên Android, việc này cần một Context. Mà commonMain không biết Context là gì, nên bạn không thể viết expect fun copy(context: Context, text: String).
Bạn có thể lách bằng cách giữ Context trong một biến global ở androidMain, nhưng đó là state ẩn: test khó viết, và code chung phụ thuộc vào thứ nó không nhìn thấy.
Lúc này một interface gọn hơn nhiều:
// commonMain/kotlin/com/example/match/Clipboard.kt
package com.example.match
interface Clipboard {
fun copy(text: String)
}
class ShareResult(private val clipboard: Clipboard) {
fun copyWinner(winner: String) {
clipboard.copy("Winner: $winner")
}
}
Mỗi nền tảng implement interface bằng API của mình. Bản Android nhận Context qua constructor:
// androidMain/kotlin/com/example/match/AndroidClipboard.kt
package com.example.match
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
class AndroidClipboard(private val context: Context) : Clipboard {
override fun copy(text: String) {
val manager = context.getSystemService(ClipboardManager::class.java)
manager.setPrimaryClip(ClipData.newPlainText("match result", text))
}
}
// iosMain/kotlin/com/example/match/IosClipboard.kt
package com.example.match
import platform.UIKit.UIPasteboard
class IosClipboard : Clipboard {
override fun copy(text: String) {
UIPasteboard.generalPasteboard.string = text
}
}
Chỗ tạo object nằm ở code nền tảng, nơi có đủ thông tin:
// Android, ví dụ trong một Activity
val shareResult = ShareResult(AndroidClipboard(applicationContext))
// iOS
val shareResult = ShareResult(IosClipboard())
Nếu project đã dùng một DI framework như Koin, bạn đăng ký implementation của từng nền tảng trong module DI thay cho hai dòng trên. Tài liệu Kotlin cũng khuyên vậy: khi dependency là một interface, expect/actual chỉ cần xuất hiện ở phần cấu hình DI, nếu có.
Interface dễ test hơn
commonTest được compile và chạy riêng cho từng target. Với expect fun, test luôn gọi bản actual thật của target đang chạy, và bạn không có cách nào thay nó bằng bản giả.
Với interface, bạn viết một fake ngay trong commonTest:
// commonTest/kotlin/com/example/match/ShareResultTest.kt
package com.example.match
import kotlin.test.Test
import kotlin.test.assertEquals
class FakeClipboard : Clipboard {
val copied = mutableListOf<String>()
override fun copy(text: String) {
copied += text
}
}
class ShareResultTest {
@Test
fun copiesTheWinnerLine() {
val clipboard = FakeClipboard()
ShareResult(clipboard).copyWinner("X")
assertEquals(listOf("Winner: X"), clipboard.copied)
}
}
Test này không cần emulator, không đụng clipboard thật, và chạy giống nhau trên mọi target.
Còn expect class thì sao?
Bạn cũng có thể viết expect class, expect object hay expect interface. Theo tài liệu Kotlin hiện tại, expected và actual class vẫn đang ở trạng thái Beta. Mình build thử trên Kotlin 2.4.10 (bản mới nhất lúc viết là 2.4.21) và compiler vẫn in cảnh báo:
'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta. Consider using the '-Xexpect-actual-classes' flag to suppress this warning.
Nếu bạn đã cân nhắc và vẫn muốn dùng, thêm flag này trong build.gradle.kts để tắt cảnh báo:
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
Tài liệu không khuyên dùng expect class cho những trường hợp đơn giản mà interface làm được. Lý do giống phần trên: interface cho bạn nhiều implementation trên cùng một nền tảng và dễ thay bằng fake khi test. expect class đáng dùng trong vài trường hợp cụ thể hơn:
- Bản
actualcần kế thừa một class có sẵn của nền tảng. - Bạn muốn trỏ thẳng tới một type có sẵn bằng
actual typealias, ví dụactual typealias MyDate = java.time.LocalDatetrên JVM. - Bạn viết framework và cần một base class chung để người dùng kế thừa.
Chọn cách nào
| Tình huống | Nên dùng |
|---|---|
| Một hàm hoặc giá trị nhỏ, không state, không cần thay khi test (giờ, dispatcher, tên nền tảng) | expect fun / expect val |
Cần dependency của nền tảng như Context, có state, hoặc cần fake trong test | interface + truyền implementation vào |
Bản actual phải kế thừa class nền tảng, hoặc trỏ tới type có sẵn bằng typealias | expect class (chấp nhận Beta) |
Khi phân vân, cứ bắt đầu bằng interface. Tài liệu Kotlin cũng ghi rõ: dùng cấu trúc ngôn ngữ thông thường bất cứ khi nào có thể, và chỉ dùng expect/actual khi thật sự cần.
Học tiếp
Trong khóa Kotlin Mobile 101, bạn đọc và sửa một app Kotlin Multiplatform hoàn chỉnh. Trong app có cả chỗ dùng expect/actual lẫn chỗ dùng interface, kèm bộ unit test để bạn tự kiểm tra từng thay đổi của mình.