# expect/actual hay interface: viết code riêng từng nền tảng trong Kotlin Multiplatform

> Khi nào dùng expect/actual, khi nào chỉ cần một interface rồi truyền implementation vào. Có code Android, iOS, unit test và trạng thái Beta của expect class trên Kotlin 2.4.

- URL: https://dantech.academy/blog/kotlin/expect-actual-hay-interface-kotlin-multiplatform
- Ngày đăng: 2026-11-05
- Thời gian đọc: 6 phút
- Tags: kotlin, multiplatform, android, architecture

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

1. `expect`/`actual`: khai báo ở `commonMain`, mỗi nền tảng viết phần `actual` của mình.
2. Một `interface` Kotlin 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:

```kotlin
// 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:

```kotlin
// androidMain/kotlin/com/example/match/Now.android.kt
package com.example.match

actual fun currentTimeMillis(): Long = System.currentTimeMillis()
```

```kotlin
// 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:

```kotlin
// 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:

```kotlin
// 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))
    }
}
```

```kotlin
// 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:

```kotlin
// 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`:

```kotlin
// 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:

```text
'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
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 `actual` cầ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.LocalDate` trê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](https://dantech.academy/courses/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.
