Dan Tech Academy
Sau bài này bạn sẽ
  • Giải thích framework Shared được build và nối vào Xcode ra sao, và header của nó chứa những gì
  • Nắm giới hạn interop qua Objective-C với suspend, Flow, generic, sealed, enum, tham số mặc định, exception, và khi nào cần SKIE hay Swift export
  • Gọi API Apple từ iosMain và hiểu những điểm cơ bản của memory model Kotlin/Native khi làm việc với ARC
Thời lượng18 phút
Độ khóBeginner

iOS Host Và Swift Interop

Kotlin trở thành framework iOS như thế nào, Swift nhìn thấy gì qua Objective-C header, những tính năng bị mất khi đi qua cầu nối và cách Kotlin gọi API của Apple.

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 giải thích cơ chế đằng sau việc chạy app KMP trên iOS: Kotlin trở thành framework như thế nào, Swift nhìn thấy gì từ framework đó, những tính năng Kotlin nào bị mất khi đi qua cầu nối, và Kotlin gọi API của Apple ra sao.

Sau bài này, bạn thiết kế được ranh giới giữa Kotlin và Swift cho một project KMP: để Swift thấy những gì, giấu những gì, và khi nào cần tới công cụ như SKIE hay Swift export.

Các bước cần Mac trong bài (xem header, chạy Xcode) chưa được chạy thử trên Mac khi viết bài. Mọi chi tiết về repo được đọc từ code.

Phần 1: Kotlin Thành Framework

Mở shared-app/build.gradle.kts. Với mỗi target iOS, module khai báo một framework:

kotlin
iosTarget.binaries.framework {
    baseName = "Shared"
    isStatic = true
}
  • baseName = "Shared" là tên Swift dùng trong import Shared.
  • isStatic = true tạo static framework. Comment giải thích lý do: dynamic framework tốn thời gian khởi động app mà không mang lại gì, vì không có gì khác link tới nó.

Xcode gọi Gradle qua build phase embedAndSignAppleFrameworkForXcode. Cách nối này gọi là direct integration. Các project khác có thể dùng CocoaPods, hoặc build XCFramework rồi phân phối qua Swift Package Manager khi đội iOS làm việc ở repository riêng. Gặp project lạ, mở Build Phases trong Xcode để biết nó dùng cách nào.

Tài liệu: iOS integration methods, Build final native binaries.

Phần 2: Swift Thấy Gì

Swift không đọc trực tiếp code Kotlin. Compiler Kotlin/Native sinh ra một Objective-C header cho framework, và Swift import header đó. Mọi thứ Swift thấy đều đã đi qua Objective-C.

Vài quy tắc dịch bạn sẽ gặp ngay trong repo:

KotlinSwift thấy
Hàm top-level doInitApp() trong SharedKoin.ktSharedKoinKt.doInitApp()
Hàm top-level MainViewController() trong MainViewController.ktMainViewControllerKt.MainViewController()
Class, interfaceClass, protocol, tên có tiền tố lấy từ tên framework trong Objective-C
objectClass có thuộc tính shared
private, internalKhông xuất hiện

Hàm top-level được gom vào một class mang tên file cộng hậu tố Kt. Đó là lý do KDoc của doInitApp nhắc rằng file tên SharedKoin.kt để class trong header là SharedKoinKt. Đổi tên file Kotlin là phải sửa cả Swift.

Hàm được đặt tên doInitApp thay vì initApp. Trong Objective-C, method bắt đầu bằng init thuộc nhóm initializer và được đối xử khác với method thường. KDoc giải thích repo chọn tránh hẳn tiền tố đó.

Header Chứa Những Gì

Theo tài liệu, framework mặc định chứa API public của chính module được build. API của module dependency chỉ vào header khi bạn gọi export(...) trong block framework và khai báo dependency đó bằng api(...). Tài liệu khuyên không bật transitiveExport vì nó kéo cả dependency gián tiếp, làm tăng thời gian build và kích thước binary.

:shared-app không gọi export(...) nào. Comment đầu file giải thích chủ đích: chỉ những gì thật sự cần trong header mới được export.

Bài tập đọc code: tìm các khai báo public ở top-level trong shared-app/src/commonMain và shared-app/src/iosMain. Bạn sẽ thấy AppShell, appKoinModules, SplashViewModel, IosNotifier... Swift chỉ gọi đúng hai hàm. Phần còn lại vẫn là public nên vẫn vào header. Muốn giấu một khai báo khỏi Swift mà vẫn cho module Kotlin khác dùng, tài liệu đưa ra @HiddenFromObjC. Còn nếu chỉ module đó dùng, internal là đủ.

Tài liệu: Interoperability with Swift/Objective-C.

Phần 3: Những Gì Mất Khi Đi Qua Objective-C

Objective-C già hơn Kotlin và Swift nhiều, nên nhiều tính năng không có đường dịch trọn vẹn. Đây là bảng bạn cần nhớ khi thiết kế API cho Swift gọi:

Tính năng KotlinSwift nhận được
suspend funHàm có completion handler. Gọi bằng async trong Swift được, nhưng tài liệu ghi tính năng này còn rất thử nghiệm
FlowInterface Kotlin thường, phải thu thập qua callback. Kiểu phần tử bị mất
GenericChỉ giữ được trên class. Generic trên interface và trên hàm bị mất
sealed class/interfaceMột cây class bình thường. switch trong Swift phải có default
enum classClass, không phải Swift enum
Tham số có giá trị mặc địnhSwift phải truyền đủ mọi tham số
ExceptionHàm không có @Throws mà ném exception sang Swift sẽ làm app dừng hẳn
Int?Bị bọc thành object như KotlinInt

Exception là dòng nguy hiểm nhất. Kotlin không có checked exception, Swift thì có. Tài liệu ghi rõ: exception đi tới Swift mà không thuộc danh sách khai báo trong @Throws bị coi là chưa xử lý và khiến chương trình dừng. Với suspend fun không có @Throws, chỉ CancellationException được chuyển thành NSError.

Repo né toàn bộ bảng trên bằng thiết kế: Swift chỉ gọi hai hàm không tham số, một hàm không trả về gì và một hàm trả UIViewController. Toàn bộ UI viết bằng Compose Multiplatform trong Kotlin, nên không có ViewModel, Flow hay sealed class nào phải băng qua cầu nối. Swift shell càng mỏng, bảng này càng ít ảnh hưởng.

Nếu bạn nhận một project mà UI iOS viết bằng SwiftUI và gọi ViewModel Kotlin, bảng này sẽ chạm vào mọi màn hình. Đó là lúc cần Phần 4.

Tài liệu: Kotlin-Swift interopedia do JetBrains duy trì, có ví dụ cho từng dòng trong bảng.

Phần 4: SKIE Và Swift Export

Hai công cụ giải quyết bảng ở Phần 3. Repo không dùng cái nào, nhưng bạn sẽ gặp chúng trong project khác.

Công cụLà gìLấp đượcTrạng thái
SKIEGradle plugin của Touchlab, sinh thêm code Swift vào frameworkFlow thành AsyncSequence, suspend có hủy, enum và sealed switch đầy đủ, tham số mặc địnhThư viện bên thứ ba
Swift exportTính năng của chính Kotlin, xuất thẳng sang Swift không qua Objective-Csuspend thành async, Flow thành AsyncSequence, sealed thành Swift enum, package được giữAlpha theo tài liệu Kotlin

Hai lưu ý khi cân nhắc Swift export: tài liệu ghi nó chỉ chạy với direct integration, và generic bị xóa về upper bound khi export. Vì đang Alpha, đừng đặt project có tiền thật lên nó mà không thử kỹ.

Tài liệu: Swift export, SKIE.

Phần 5: Kotlin Gọi API Của Apple

Chiều ngược lại dễ hơn nhiều. Trong iosMain, framework hệ thống của Apple có sẵn dưới package platform.*, không cần cấu hình thêm.

File trong repoAPI Apple dùng
IosDataStoreFileResolverNSFileManager, NSURL (Foundation)
SharedKoin.ktNSBundle để đọc giá trị trong Info.plist
IosShareSheetUIActivityViewController (UIKit)
IosGoogleSignInLauncherASWebAuthenticationSession (AuthenticationServices)
GoogleOAuthPkceCC_SHA256 (CoreCrypto), SecRandomCopyBytes (Security)

Một số API kiểu C yêu cầu @OptIn(ExperimentalForeignApi::class), như bạn thấy trên IosDataStoreFileResolver và trong GoogleOAuthPkce.

Giới hạn quan trọng: Kotlin chỉ gọi được Swift khi API đó được expose sang Objective-C bằng @objc. Module Swift thuần chưa được hỗ trợ. KDoc của IosGoogleSignInLauncher cho thấy hệ quả: thay vì kéo Google Sign-In SDK vào, repo tự viết luồng OAuth với PKCE bằng API của Apple, để không phải thêm một seam viết bằng Swift.

Tài liệu: Platform libraries.

Phần 6: Memory Model Cơ Bản

Kotlin/Native dùng tracing garbage collector giống JVM: object nằm trên heap dùng chung, truy cập được từ mọi thread. Swift và Objective-C dùng ARC, đếm tham chiếu. Hai cơ chế này phần lớn phối hợp trơn tru, nhưng có hai điểm bạn cần biết:

  • Retain cycle. GC của Kotlin thu hồi được vòng tham chiếu giữa các object Kotlin. ARC thì không. Theo tài liệu, chỉ cần một object Objective-C hay Swift nằm trong vòng, cả vòng không được thu hồi. Khi Swift truyền closure giữ self vào Kotlin, dùng [weak self] như với code Swift bình thường.
  • Deinit. Object Swift được truyền vào Kotlin trên main thread sẽ chạy deinit trên main thread, sau một chu kỳ GC chứ không ngay lập tức.

Nếu đọc tài liệu hay bài viết cũ nhắc tới freeze() hay việc không được chia sẻ object giữa các thread, đó là memory manager cũ. Code hiện tại không cần những thứ đó.

Tài liệu: Kotlin/Native memory management, Integration with Swift/Objective-C ARC.

Phần 7: Compose Trong App iOS

MainViewController gói AppShell() vào ComposeUIViewController, trả về một UIViewController thường. ContentView.swift bọc nó để SwiftUI hiển thị. Đây là mẫu chuẩn mà tài liệu Compose Multiplatform mô tả cho việc nhúng Compose vào app SwiftUI.

Config như Supabase URL hay RevenueCat key đi từ Config.xcconfig vào Info.plist, rồi Kotlin đọc bằng NSBundle trong iosAppBuildConfig.

Tài liệu: Integration with SwiftUI.

Bảng Xử Lý Lỗi

Triệu chứngNguyên nhân thường gặpCách xử lý
Swift báo không tìm thấy SharedKoinKtFile Kotlin bị đổi tên, class trong header đổi theoĐổi tên lại file hoặc sửa lời gọi trong Swift
App iOS dừng đột ngột khi Swift gọi một hàm KotlinHàm ném exception mà không có @ThrowsBắt lỗi trong Kotlin, hoặc khai báo @Throws để Swift xử lý
Swift không thấy một type của module khácModule đó không được export(...)Thêm export(...) và khai báo api(...), chỉ cho type thật sự cần
switch trên sealed class trong Swift bắt phải có defaultSealed class đi qua Objective-C thành cây class thườngChấp nhận default, hoặc dùng SKIE
Bộ nhớ app iOS tăng dần quanh một màn hìnhClosure Swift giữ self trong object KotlinDùng [weak self] trong closure

Checkpoint

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

  • giải thích được vì sao Swift gọi SharedKoinKt.doInitApp() chứ không phải doInitApp(),
  • nói được framework Shared chứa API của module nào, và cần làm gì để Swift thấy type của :domain,
  • chọn ra ba dòng trong bảng Phần 3 sẽ gây khó nhất nếu UI iOS viết bằng SwiftUI,
  • giải thích được vì sao repo không cần SKIE hay Swift export,
  • kể được hai API Apple mà repo gọi trực tiếp từ Kotlin.
Đăng nhập