← 返回学习路线
🍎

第 8 章:iOS UI(SwiftUI)

在 iOS 端集成 KMP Framework,用 SwiftUI 消费共享 ViewModel,借助 SKIE 优化 Swift 与 Kotlin 的互操作

🔗 Framework 集成方式对比

KMP 将 shared 模块编译为一个原生 iOS Framework(shared.framework),Xcode 项目通过以下方式引用:

方式原理优点缺点
SPM 直接集成 Gradle 任务生成 Framework,Xcode Build Phase 自动调用 无需 CocoaPods;增量编译;官方推荐 配置稍复杂
CocoaPods KMP Gradle 插件生成 Podspec,pod install 引入 与现有 CocoaPods 工程无缝集成 需要安装 CocoaPods;每次修改需 pod install
XCFramework 手动 手动构建 XCFramework,拖入 Xcode 最简单,适合演示 不适合开发迭代

📦 SPM 直接集成(推荐)

8.1 配置 shared 模块输出 Framework

shared/build.gradle.kts kotlin { val iosTargets = listOf(iosX64(), iosArm64(), iosSimulatorArm64()) iosTargets.forEach { target -> target.binaries.framework { baseName = "shared" isStatic = true // 推荐静态 Framework,减少启动时间 } } }

8.2 在 Xcode 中配置 Build Phase

在 Xcode 项目中,选中 iosApp Target → Build Phases → 点击 + → New Run Script Phase,添加如下脚本:

Xcode Run Script Phase cd "$SRCROOT/.." ./gradlew :shared:embedAndSignAppleFrameworkForXcode

将此 Phase 拖动到 "Compile Sources" 之前,确保 Framework 在编译前生成。

8.3 配置 Framework Search Paths

在 Xcode Build Settings 中,添加 Framework Search Paths:

Build Settings → Framework Search Paths $(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)
embedAndSignAppleFrameworkForXcode 任务 是 KMP Gradle 插件提供的,它会自动根据当前构建配置(Debug/Release)和目标 SDK(iphoneos/iphonesimulator)选择正确的 Framework 输出路径。

🥥 CocoaPods 集成

8.4 配置 KMP CocoaPods 插件

shared/build.gradle.kts — CocoaPods 方式 plugins { id("org.jetbrains.kotlin.native.cocoapods") } kotlin { cocoapods { summary = "KMP Shared Module" homepage = "https://github.com/example/newsapp" version = "1.0" ios.deploymentTarget = "16.0" podfile = project.file("../iosApp/Podfile") framework { baseName = "shared" isStatic = true } } }
iosApp/Podfile platform :ios, '16.0' use_frameworks! target 'iosApp' do pod 'shared', :path => '../shared' end
Terminal — 安装 Pods cd iosApp pod install # 之后打开 iosApp.xcworkspace,而不是 .xcodeproj open iosApp.xcworkspace

🔀 Swift 调用 Kotlin

8.5 Kotlin → ObjC/Swift 的类型映射

Kotlin/Native 会自动生成 Objective-C 兼容的头文件(shared.h),Swift 通过这些头文件调用 Kotlin 代码。

Kotlin 类型ObjC/Swift 映射注意事项
StringNSString / String自动桥接
Int / Long / FloatInt32 / Int64 / Float数值类型一一对应
List<T>NSArray<T>不可变;需转为 Swift Array
Map<K, V>NSDictionary<K, V>自动桥接
data classSwift class(不是 struct)无 Equatable/Hashable
sealed class多个 ObjC class(非枚举)SKIE 可改善为 Swift enum
Flow<T>无直接映射需 SKIE 或手动包装
suspend fun接受回调的函数SKIE 可改善为 async/await

8.6 调用 Kotlin 类的基本示例

Swift import shared // 导入 KMP Framework // 调用 Kotlin 对象 let platform = Platform() print(platform.name) // 输出: iOS // 使用 Kotlin data class(注意:是引用类型) let article = Article( id: "1", title: "Hello KMP", summary: "test", imageUrl: nil, publishedAt: Clock.System.now(), isBookmarked: false ) // 调用 Kotlin 单例 object Logger.shared.d(tag: "Swift", message: "Hello from iOS") // sealed class 在没有 SKIE 时很麻烦 let state = viewModel.uiState.value if let success = state as? ArticleListStateSuccess { let articles = success.articles }

✨ SKIE 库详解

8.7 SKIE 改善了什么

SKIE(Swift Kotlin Interface Enhancer)在编译时对 KMP Framework 进行增强,让 Swift 调用 Kotlin 代码更自然:

Flow → AsyncSequence

Kotlin Flow/StateFlow 自动变成 Swift AsyncSequence,可以用 for await 循环消费。

sealed class → Swift enum

Kotlin sealed class 变成 Swift enum,可以用 switch exhaustive 匹配,编译时确保完整性。

suspend fun → async/await

Kotlin suspend 函数变成 Swift async 函数,可以直接在 Swift 并发中使用,无需回调。

默认参数

Kotlin 函数的默认参数在 Swift 端也能正常使用,无需每次传入所有参数。

8.8 SKIE 安装

shared/build.gradle.kts plugins { id("co.touchlab.skie") version "0.9.0" } // 可选:SKIE 全局配置 skie { features { // 启用所有 SKIE 特性(默认全部开启) coroutinesInterop.set(true) sealedInterop.set(true) defaultArgumentsInExternalModules.set(true) } }

8.9 SKIE 效果对比

无 SKIE — Swift 消费 sealed class // 没有 SKIE:繁琐的类型转换 let state = viewModel.uiState.value if let loading = state as? ArticleListStateLoading { // 处理加载 } else if let success = state as? ArticleListStateSuccess { updateUI(success.articles) } else if let error = state as? ArticleListStateError { showError(error.message) }
有 SKIE — Swift 消费 sealed class // SKIE 后:类型安全的 switch switch viewModel.uiState.value { case .loading: showLoadingIndicator() case .success(let success): updateUI(success.articles) case .error(let error): showError(error.message) }

📱 SwiftUI 消费共享 ViewModel

8.10 在 SwiftUI 中使用 ViewModel

iosApp/ContentView.swift import SwiftUI import shared // KMP Framework @MainActor class HomeViewModelWrapper: ObservableObject { let viewModel: HomeViewModel @Published var uiState: HomeUiState = HomeUiState( articles: [], isLoading: true, isRefreshing: false, error: nil, searchQuery: "" ) private var observeTask: Task<Void, Never>? init() { // 通过 Koin 获取 ViewModel viewModel = KoinHelper.shared.getHomeViewModel() } func startObserving() { observeTask = Task { // SKIE 将 StateFlow 变为 AsyncSequence for await state in viewModel.uiState { self.uiState = state } } } func stopObserving() { observeTask?.cancel() } func refresh() { viewModel.refresh() } func toggleBookmark(_ id: String) { viewModel.toggleBookmark(articleId: id) } }
HomeView.swift struct HomeView: View { @StateObject private var wrapper = HomeViewModelWrapper() var body: some View { NavigationStack { Group { if wrapper.uiState.isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = wrapper.uiState.error { ErrorView(message: error) { wrapper.refresh() } } else { ArticleListView( articles: wrapper.uiState.articles, isRefreshing: wrapper.uiState.isRefreshing, onRefresh: wrapper.refresh, onBookmarkToggle: wrapper.toggleBookmark ) } } .navigationTitle("每日资讯") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("刷新") { wrapper.refresh() } } } } .task { wrapper.startObserving() } .onDisappear { wrapper.stopObserving() } } }

📱 完整 iOS 实战示例

8.11 文章列表 View

ArticleListView.swift struct ArticleListView: View { let articles: [Article] let isRefreshing: Bool let onRefresh: () -> Void let onBookmarkToggle: (String) -> Void var body: some View { List { ForEach(articles, id: \.id) { article in NavigationLink(destination: ArticleDetailView(articleId: article.id)) { ArticleRowView( article: article, onBookmarkToggle: { onBookmarkToggle(article.id) } ) } .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } } .listStyle(.plain) .refreshable { onRefresh() } .overlay { if articles.isEmpty { ContentUnavailableView( "暂无内容", systemImage: "newspaper", description: Text("下拉刷新获取最新资讯") ) } } } } struct ArticleRowView: View { let article: Article let onBookmarkToggle: () -> Void var body: some View { HStack(spacing: 12) { // 封面图(AsyncImage 原生加载) if let urlStr = article.imageUrl, let url = URL(string: urlStr) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable() .scaledToFill() .frame(width: 80, height: 80) .clipShape(RoundedRectangle(cornerRadius: 8)) default: RoundedRectangle(cornerRadius: 8) .fill(Color.secondary.opacity(0.2)) .frame(width: 80, height: 80) } } } VStack(alignment: .leading, spacing: 4) { Text(article.title) .font(.subheadline.weight(.semibold)) .lineLimit(2) Text(article.summary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() Button(action: onBookmarkToggle) { Image(systemName: article.isBookmarked ? "bookmark.fill" : "bookmark") .foregroundStyle(article.isBookmarked ? .purple : .secondary) } .buttonStyle(.borderless) } } }

8.12 在 iOS 端初始化 Koin

iOSApp.swift import SwiftUI import shared @main struct iOSApp: App { init() { // 初始化 Koin KoinHelperKt.doInitKoin() } var body: some Scene { WindowGroup { ContentView() } } }
commonMain — KoinHelper(iOS 初始化辅助) fun initKoin() { startKoin { modules(sharedModule, databaseModule) } }
线程注意: Kotlin/Native 在 iOS 上的 Coroutines 调度器默认运行在主线程(Dispatchers.Main 映射到 iOS 主线程)。不要在 Kotlin suspend 函数中直接做 UI 操作,应在 Swift 端的 @MainActor 上下文中更新 UI 状态。

🎯 实践任务

  1. 配置 Xcode Run Script Phase,让 Gradle 构建 Framework
  2. 安装 SKIE 插件,验证 sealed class 变成 Swift switch-friendly enum
  3. 创建 HomeViewModelWrapper,用 for await 消费 StateFlow
  4. 实现 ArticleListViewArticleRowView,展示文章列表