Dan Tech Academy

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.

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:

// 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 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ốngNê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 testinterface + 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 typealiasexpect 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.

Đọc blog thì vui. Đi theo lộ trình thì đến đích.

Kotlin Android Roadmap sắp xếp các bài Android Mastery, OOP và Design Patterns thành 5 cấp độ - từ dòng Kotlin đầu tiên đến app lên store.

Kèm bài viết mới mỗi tuần. Huỷ bất cứ lúc nào.