Dan Tech Academy
Sau bài này bạn sẽ
  • Phân biệt target với source set và đọc được cây source set do default hierarchy template tạo ra
  • Biết test trong commonTest, androidHostTest, iosTest chạy ở đâu và mỗi lệnh test chứng minh điều gì
  • Nhận ra API không dùng được trong commonMain và cách repo thay thế, gồm cách dùng dispatcher đúng trên iOS
Thời lượng15 phút
Độ khóBeginner

Giải Phẫu Một Project KMP

Nhìn vào bên trong một module Kotlin Multiplatform: target, source set, default hierarchy template, plugin Android cho thư viện KMP và những gì không đặt được trong commonMain.

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

Bài này đi vào bên trong một module KMP: target là gì, source set là gì, code trong mỗi source set được phép gọi API nào, và vì sao một dòng code chạy tốt trên Android lại không compile được trong commonMain.

Sau bài này, mở một project KMP bất kỳ, bạn đọc được nó compile ra những gì, code của mỗi nền tảng nằm ở đâu, và đoán trước được lỗi nào sẽ xuất hiện khi bạn đặt code sai chỗ.

Phần 1: Target Và Source Set

Hai khái niệm này hay bị dùng lẫn, nhưng chúng trả lời hai câu hỏi khác nhau.

Khái niệmTrả lời câu hỏiVí dụ trong repo
TargetModule này compile ra cho máy nàoAndroid, iosArm64, iosSimulatorArm64
Source setĐoạn code này compile cho target nàocommonMain, androidMain, iosMain

Target được khai báo một lần cho mọi module trong convention plugin KmpLibraryConventionPlugin (thư mục build-logic/): một target Android qua block androidLibrary, và hai target iOS iosArm64() cho iPhone thật, iosSimulatorArm64() cho Simulator trên Mac chip Apple Silicon. Repo không khai báo iosX64, nên Simulator trên Mac Intel không nằm trong phạm vi build.

Source set là một thư mục code cộng với dependency của nó. Quy tắc quan trọng nhất của KMP nằm ở đây: compiler chỉ cho một source set dùng những API có mặt trên mọi target mà source set đó compile tới. commonMain compile cho cả Android lẫn iOS, nên nó không thấy android.* và cũng không thấy java.*.

Tài liệu chính thức: Discover your project.

Phần 2: Default Hierarchy Template

Bạn không thấy dòng nào trong build file tạo ra iosMain. Kotlin Gradle plugin tự dựng nó từ default hierarchy template: dựa vào các target bạn khai báo, plugin tạo sẵn các source set trung gian phù hợp và nối chúng với nhau.

Với ba target của repo, cây source set phía production là:

text
commonMain
├── androidMain
└── nativeMain
    └── appleMain
        └── iosMain
            ├── iosArm64Main
            └── iosSimulatorArm64Main

Phía test có cây tương ứng (commonTest, iosTest...). Android test source set của repo tên là androidHostTest, sẽ nói ở Phần 3.

Bạn tự kiểm chứng được cây này mà không cần Mac:

bash
./gradlew :kernel:tasks --all | grep Metadata

Output có compileCommonMainKotlinMetadata, compileNativeMainKotlinMetadata, compileAppleMainKotlinMetadata và compileIosMainKotlinMetadata. Mỗi task là một source set trung gian mà plugin đã tạo.

Ba điều cần nhớ khi đọc project khác:

  • Repo này chỉ dùng iosMain. nativeMain và appleMain tồn tại nhưng trống. Code trong iosMain chạy trên cả iPhone thật lẫn Simulator, nên bạn gần như không bao giờ cần iosArm64Main riêng.
  • Tài liệu ghi rõ: khi project chỉ có target iOS, nativeMain và appleMain vẫn thấy toàn bộ API iOS. Đừng suy ra từ tên rằng chúng bị giới hạn.
  • Nếu một project cũ tự nối source set bằng dependsOn(...), template bị tắt và Gradle in cảnh báo. Gặp cảnh báo đó trong project bạn nhận, hãy đọc kỹ phần cấu hình tay trước khi thêm target mới.

Tài liệu: Source set hierarchy.

Phần 3: Test Chạy Ở Đâu

Test trong commonTest không chạy "ở đâu cả". Nó chạy trên từng target.

Source setChạy bằng taskMáy cần có
commonTesttestAndroidHostTest (JVM)Bất kỳ
commonTestiosSimulatorArm64TestMac
androidHostTesttestAndroidHostTestBất kỳ
iosTestiosSimulatorArm64TestMac

Task testAndroidHostTest đến từ dòng withHostTest {} trong KmpLibraryConventionPlugin. Comment trong plugin nói rõ mục đích: chạy commonTest trên Android host. androidHostTest dành cho test cần thư viện chỉ có trên JVM, ví dụ MockK hay JUnit.

Hệ quả thực tế: một test xanh trên máy Windows hay Linux mới chứng minh logic đúng trên JVM. Code iOS trong iosMain chỉ được test khi ai đó chạy iosSimulatorArm64Test trên Mac. Phía JVM, lệnh test cho KMP module là testAndroidHostTest. Các lệnh iOS trong bài này chưa được chạy thử trên Mac khi viết bài.

Tài liệu: Test your multiplatform app.

Phần 4: Plugin Android Cho Thư Viện KMP

Mở KmpLibraryConventionPlugin. Nó áp hai plugin:

text
org.jetbrains.kotlin.multiplatform
com.android.kotlin.multiplatform.library

Plugin thứ hai là plugin Android dành riêng cho thư viện KMP. Các project KMP cũ dùng com.android.library cùng androidTarget(). Google ghi rõ cách đó dựa vào API đã deprecated từ AGP 9. Repo dùng AGP 9.0.0, nên đi theo plugin mới.

Plugin mới khác com.android.library ở vài điểm sẽ ảnh hưởng tới cách bạn chia code:

Đặc điểmHệ quả trong repo
Chỉ một variant, không build type hay product flavorThư viện KMP không có BuildConfig debug/release. Config build nằm ở :app và đi vào code chung qua AppBuildConfig
Không hỗ trợ native build (NDK)Module cần NDK phải là module Android thuần. KDoc của plugin ghi rõ điều này
Test tắt mặc địnhPlugin phải bật withHostTest {} thì mới có testAndroidHostTest
Android resources tắt mặc địnhPlugin bật android.experimental.kmp.enableAndroidResources. Comment giải thích: thiếu cờ này, Compose Resources vẫn compile nhưng không được đóng gói vào APK, và mọi lần đọc resource sẽ lỗi lúc chạy

Một lưu ý khi nâng cấp: tài liệu của Google cho biết từ AGP 8.12 có block android {} thay cho androidLibrary {}, và androidLibrary {} bị deprecated từ AGP 9.1. Repo đang ở AGP 9.0.0 với androidLibrary {}. Khi nâng AGP, đây là chỗ đầu tiên cần đổi.

Tài liệu: Set up the Android Gradle Library Plugin for KMP.

Phần 5: Những Gì Không Sống Được Trong commonMain

Khi chuyển code Android sang commonMain, phần lớn lỗi compile rơi vào vài nhóm quen thuộc. Repo đã đi qua tất cả, nên mỗi nhóm có một lời giải thật để bạn tham khảo.

Code Android quen dùngVì sao không compileRepo thay bằng gì
java.io.File, java.*iOS không có JDKĐường dẫn dạng String qua interface DataStoreFileResolver
Context, ActivityChỉ có trên AndroidInterface trong commonMain, implementation qua Koin
BuildConfigSinh theo build type của Android, thư viện KMP không có build typeAppBuildConfig, do :app hoặc iosAppBuildConfig truyền vào
TimberThư viện chỉ cho AndroidKermit (co.touchlab.kermit). Timber chỉ còn ở androidMain và :app
java.timeAPI của JDKkotlinx-datetime, ví dụ trong LocalGameHistoryRepository
Class.forName, reflectionKotlin/Native không có reflection kiểu JVMDanh sách tường minh trong AppFeatures.kt

Lỗi xuất hiện ở bước compile metadata của commonMain, chạy được trên mọi máy. Thử đặt import java.io.File vào một file trong kernel/src/commonMain, rồi chạy:

bash
./gradlew :kernel:compileCommonMainKotlinMetadata

Build lỗi với Unresolved reference 'java'. Đây là cách nhanh nhất để kiểm tra code chung trước khi đẩy lên CI. Nhớ xóa dòng thử sau khi xem lỗi.

Phần 6: Dispatcher Trên iOS

Mở :kernel, package dispatchers. Interface QzDispatchers có ba thuộc tính io, main, mainImmediate. QzDispatchersImpl cài đặt chúng:

Thuộc tínhGiá trị
ioplatformIoDispatcher (một expect val)
mainDispatchers.Main
mainImmediateDispatchers.Main.immediate

platformIoDispatcher là một expect val khai báo trong QzDispatchersImpl.kt: Dispatchers.IO trên Android và Dispatchers.Default trên iOS. KDoc giải thích vì sao vẫn cần lớp gián tiếp này: mỗi nền tảng có đúng một chỗ quyết định "I/O" nghĩa là gì, và bỏ nó đi thì việc đọc ghi đĩa sẽ chạy trên thread gọi tới.

Nhiều tài liệu cũ nói Kotlin/Native không có Dispatchers.IO. Điều đó không còn đúng. Changelog của kotlinx.coroutines ghi Dispatchers.IO được thêm vào Kotlin/Native từ bản 1.7.0, và API reference khai báo nó cho nhóm target JVM cùng Native. Repo dùng kotlinx.coroutines 1.10.2, và KDoc của QzDispatchersImpl.ios.kt ghi đúng điều này: Dispatchers.IO đã có trên Kotlin/Native, nhưng bản iOS vẫn chọn Dispatchers.Default, vì worker pool của nó là nơi chạy việc đọc ghi DataStore. Trong commonMain của một project chỉ có Android và iOS, bạn dùng được Dispatchers.IO với một import riêng:

kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO

val io = Dispatchers.IO

Đoạn này đã được compile thử trong :kernel bằng compileCommonMainKotlinMetadata. Trên JVM, IO là thành viên của Dispatchers nên không cần import thêm. Trong code chung, nó là extension, nên thiếu dòng import kotlinx.coroutines.IO là lỗi unresolved. Nếu project sau này thêm target JS hoặc Wasm, Dispatchers.IO không có ở đó và code chung sẽ không compile nữa.

Repo giữ Dispatchers.Default trên iOS là một lựa chọn có chủ đích, và nó chạy đúng. Điều đáng học là thiết kế: feature không gọi Dispatchers.* trực tiếp mà nhận QzDispatchers qua Koin, nên test thay được bằng dispatcher của kotlinx-coroutines-test và đổi dispatcher iOS chỉ chạm một file.

Còn Dispatchers.Main trên iOS chạy trên main queue của hệ điều hành, đúng chỗ UIKit yêu cầu cập nhật UI.

Tài liệu: Dispatchers.IO API, kotlinx.coroutines changelog.

Phần 7: Đọc Một Project KMP Lạ

Khi nhận một project KMP mới, đi theo thứ tự này:

  1. settings.gradle.kts: danh sách module.
  2. build-logic/ hoặc buildSrc/: convention plugin khai báo target, SDK, toolchain. Đọc một lần là hiểu mọi module.
  3. build.gradle.kts của từng module: plugin thật sự được áp và dependency theo source set (commonMain.dependencies, androidMain.dependencies...).
  4. Thư mục src/ của từng module: source set nào có code thật.
  5. Tìm từ khóa expect để biết project phụ thuộc vào nền tảng ở đâu.
  6. Tìm gradle.properties có dòng kotlin.mpp.applyDefaultHierarchyTemplate=false hay không, và build file có dependsOn( hay không.

Bảng Xử Lý Lỗi

Triệu chứngNguyên nhân thường gặpCách xử lý
Unresolved reference 'java' hoặc 'android' trong commonMainDùng API của JVM hoặc Android trong code chungChuyển sang thư viện multiplatform hoặc tách ra interface
Unresolved reference 'IO' trong commonMainThiếu import kotlinx.coroutines.IOThêm import, hoặc dùng QzDispatchers như repo
Test xanh trên Linux nhưng app iOS lỗiChỉ chạy testAndroidHostTest, chưa chạy test trên iOSChạy iosSimulatorArm64Test trên Mac
Compose Resources lỗi lúc chạy trên Android dù build xanhThiếu cờ enableAndroidResources ở module mới tự cấu hìnhDùng kta.kmp.library thay vì tự cấu hình plugin
Gradle cảnh báo default hierarchy template không được áp dụngBuild file có dependsOn(...) tự viếtBỏ cấu hình tay hoặc gọi lại applyDefaultHierarchyTemplate()

Checkpoint

Bạn hoàn tất bài này khi:

  • chạy được ./gradlew :kernel:tasks --all | grep Metadata và chỉ ra source set trung gian nào xuất hiện,
  • giải thích được vì sao code trong iosMain chạy cả trên iPhone lẫn Simulator,
  • nói được testAndroidHostTest chứng minh điều gì và không chứng minh điều gì,
  • kể được ba thứ không đặt được trong commonMain cùng cách repo thay thế,
  • giải thích được vì sao Dispatchers.IO dùng được trong commonMain của repo này nhưng sẽ không compile nếu thêm target web.
Đăng nhập