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

- URL: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-03-ios-host-swift-interop
- Khóa học: [Mobile Action Pro](https://dantech.academy/courses/kotlin-accelerator.md)
- Module: Nền Tảng Kotlin Multiplatform
- Thời lượng: 18 phút
- Độ khó: beginner
- Truy cập: Miễn phí, đọc toàn bài
- English: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-03-ios-host-swift-interop.md

## Mục tiêu

- 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

---

## 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](https://kotlinlang.org/docs/multiplatform/multiplatform-ios-integration-overview.html), [Build final native binaries](https://kotlinlang.org/docs/multiplatform/multiplatform-build-native-binaries.html).

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

```mermaid
%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "primaryBorderColor": "#475569", "secondaryColor": "#1e293b", "tertiaryColor": "#0f172a", "lineColor": "#64748b", "textColor": "#e2e8f0", "clusterBkg": "#0f172a", "clusterBorder": "#475569", "titleColor": "#cbd5e1", "edgeLabelBackground": "#334155", "fontSize": "15px"}, "flowchart": {"wrappingWidth": 420}}}%%
flowchart TB
    classDef ui fill:#1e293b,stroke:#3b82f6,stroke-width:2px,color:#e2e8f0
    classDef vm fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef dom fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#e2e8f0
    classDef data fill:#1e293b,stroke:#8b5cf6,stroke-width:2px,color:#e2e8f0
    classDef root fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#e2e8f0
    classDef bad fill:#1e293b,stroke:#ef4444,stroke-width:2px,color:#e2e8f0
    classDef good fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef note fill:#0f172a,stroke:#475569,stroke-dasharray:4 3,color:#cbd5e1
    accTitle: Kotlin đi vào app iOS như thế nào
    accDescr: Code Kotlin của shared-app được Kotlin/Native biên dịch cho iosArm64 và iosSimulatorArm64 thành static framework Shared kèm một Objective-C header. Xcode gọi Gradle qua build phase embedAndSignAppleFrameworkForXcode. Swift import Shared và chỉ gọi hai hàm: SharedKoinKt.doInitApp trong iOSApp.swift và MainViewControllerKt.MainViewController trong ContentView.swift.

    K[":shared-app<br/>commonMain + iosMain"]:::vm
    K -->|"Kotlin/Native<br/>iosArm64, iosSimulatorArm64"| F["Shared.framework (static)<br/>+ Objective-C header"]:::dom
    X["Xcode build phase<br/>embedAndSignAppleFrameworkForXcode"]:::root -->|"gọi Gradle, nhúng framework"| F
    F -->|"import Shared"| SW["Swift"]:::ui
    SW --> C1["iOSApp.swift<br/>SharedKoinKt.doInitApp()"]:::ui
    SW --> C2["ContentView.swift<br/>MainViewControllerKt.MainViewController()"]:::ui
    C2 --> C3["ComposeUIViewController { AppShell() }"]:::vm
    C1 ~~~ N["Header chỉ có API public của :shared-app (không export(...)).<br/>Hàm top-level thành class tên file + Kt."]:::note
```

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

| Kotlin                                      | Swift thấy                                         |
| ------------------------------------------- | -------------------------------------------------- |
| Hàm top-level `doInitApp()` trong `SharedKoin.kt` | `SharedKoinKt.doInitApp()`                   |
| Hàm top-level `MainViewController()` trong `MainViewController.kt` | `MainViewControllerKt.MainViewController()` |
| Class, interface                            | Class, protocol, tên có tiền tố lấy từ tên framework trong Objective-C |
| `object`                                    | Class có thuộc tính `shared`                       |
| `private`, `internal`                       | Khô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](https://kotlinlang.org/docs/native-objc-interop.html).

## 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 Kotlin           | Swift nhận được                                                         |
| -------------------------- | ----------------------------------------------------------------------- |
| `suspend fun`              | Hà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 |
| `Flow`                     | Interface Kotlin thường, phải thu thập qua callback. Kiểu phần tử bị mất |
| Generic                    | Chỉ giữ được trên class. Generic trên interface và trên hàm bị mất       |
| `sealed class`/`interface` | Một cây class bình thường. `switch` trong Swift phải có `default`        |
| `enum class`               | Class, không phải Swift enum                                             |
| Tham số có giá trị mặc định | Swift phải truyền đủ mọi tham số                                        |
| Exception                  | Hà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](https://github.com/kotlin-hands-on/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 được                                                  | Trạng thái                  |
| -------------- | ----------------------------------------------------------------- | --------------------------------------------------------- | --------------------------- |
| SKIE           | Gradle plugin của Touchlab, sinh thêm code Swift vào framework     | `Flow` thành `AsyncSequence`, `suspend` có hủy, enum và sealed `switch` đầy đủ, tham số mặc định | Thư viện bên thứ ba |
| Swift export   | Tính năng của chính Kotlin, xuất thẳng sang Swift không qua Objective-C | `suspend` 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](https://kotlinlang.org/docs/native-swift-export.html), [SKIE](https://skie.touchlab.co/).

## 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 repo              | API Apple dùng                                         |
| ---------------------------- | ------------------------------------------------------ |
| `IosDataStoreFileResolver`   | `NSFileManager`, `NSURL` (Foundation)                  |
| `SharedKoin.kt`              | `NSBundle` để đọc giá trị trong `Info.plist`           |
| `IosShareSheet`              | `UIActivityViewController` (UIKit)                     |
| `IosGoogleSignInLauncher`    | `ASWebAuthenticationSession` (AuthenticationServices)  |
| `GoogleOAuthPkce`            | `CC_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](https://kotlinlang.org/docs/native-platform-libs.html).

## 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](https://kotlinlang.org/docs/native-memory-manager.html), [Integration with Swift/Objective-C ARC](https://kotlinlang.org/docs/native-arc-integration.html).

## 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](https://kotlinlang.org/docs/multiplatform/compose-swiftui-integration.html).

## Bảng Xử Lý Lỗi

| Triệu chứng                                                  | Nguyên nhân thường gặp                                    | Cách xử lý                                                          |
| ------------------------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------- |
| Swift báo không tìm thấy `SharedKoinKt`                      | File 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 Kotlin           | Hàm ném exception mà không có `@Throws`                   | Bắ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ác                     | Module đó 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ó `default` | Sealed class đi qua Objective-C thành cây class thường    | Chấp nhận `default`, hoặc dùng SKIE                                 |
| Bộ nhớ app iOS tăng dần quanh một màn hình                   | Closure Swift giữ `self` trong object Kotlin               | Dù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.

---

- Bài trước: [Platform Seams: expect/actual Hay Interface + DI](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-02-platform-seams-expect-actual-di.md)
- Bài tiếp theo: [Data Stack Đa Nền Tảng](https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-04-kmp-data-stack.md)
