上篇文章「「Hello KMP」创建你的第一个KMP项目」我们尝试从无到有创建了第一个KMP
项目。然而大多数情况下,我们更多的是基于已有的项目进行开发。这篇文章我们聚焦于「改造」现有项目,使其具备跨平台的能力,同时运行在Android和iOS设备上。
为了让我们对KMP项目有一个更深的了解,Kotlin官方也为我们提供了一个Demo程序,并对其进行了改造,使其可以同时运行在Android和iOS系统上。该Demo程序逻辑相对简单,只有一个登录界面,对用户输入的用户名/密码校验,项目地址:GitHub - Kotlin/kmm-integration-sample 。我们本次以此为例,一步一步完成项目的KMP改造。
项目包含4个分支,我们只需关注
master
和final
分支。其中master
分支为原始的纯Android项目代码,final
为改造完成后的代码。
将项目拉取下来,代码结构也十分简单:
业务逻辑部分代码存放于com.jetbrains.simplelogin.androidapp.data
包下,至于UI部分的代码,存放在com.jetbrains.simplelogin.androidapp.ui.login
包下。
运行到Android设备上长这样(目前也只能运行在Android设备上):
step 1:确定哪些代码需要跨平台
想要让这份为Android平台编写的代码运行到iOS平台上,首要的第一步就是决定项目的哪些代码需要「共享」给iOS,哪些代码仍然保持差异性,即交由iOS和Android平台各自实现。
作为一个跨平台项目,我们当然希望项目的逻辑部分保持高度统一性,所以最简单的一个简单的规则是:尽可能多地共用业务逻辑部分的代码。
映射到我们的demo项目中,就是com.jetbrains.simplelogin.androidapp.data
包中的代码。所以我们要让这部分的代码跨平台。
step 2:创建共享代码块
KMP
要求跨平台代码必须存储在特定的shared module
下,使用上篇文章「「Hello KMP」创建你的第一个KMP项目」中提到的The Kotlin Multiplatform plugin
可以很方便的创建该shared module
:
- 打开
Android Studio
,点击 File |New|New module - 在弹出的弹窗中选择
Kotlin Multiplatform Shared Module
模版,Module Name
填写为shared
,iOS framework distribution
选择Regular framework
- 点击
Finish
,我们的shared module
就创建完成了。
现在
Kotlin Multiplatform Shared Module
模版在Android Studio中作为实验特性默认被隐藏,你可以通过设置启用它。具体路径为:Settings | Advanced Settings,搜索 「Kotlin」,找到Enable experimental Multiplatform IDE features
,勾选启用即可。
ps:现阶段使用
Kotlin Multiplatform Plugin
创建的Kotlin Multiplatform Shared Module
默认使用version catalogs
进行依赖管理,demo项目里并有没使用version catalogs
,所以构建项目时会报错:
解决办法也很简单,将插件依赖代码替换一下即可:
step 3:添加shared模块依赖
现在我们已经创建出来跨平台共享的代码模块shared
,为了能够使用这部分的代码,我们需要在我们的app
模块中依赖shared
:
- 打开我们的
app
模块,在其build.gradle.kt
文件中声明对shared
模块的依赖:
需要注意的是,shared
和app
模块中声明的compileSDK
和minSDK
需要保持一致,否则编译会报错。全部调整完后sync
一下项目。
- 验证下我们的
shared
模块是否正常可用:
我们可以在com.jetbrains.simplelogin.androidapp.ui.login.LoginActivity#onCreate
方法中添加一行代码,然后就能看到来自shared
模块中的com.example.shared.Greeting#greet
方法被成功调用,日志也被成功打印了出来:
step 4:迁移业务逻辑代码至shared
现在我们可以尝试将业务逻辑代码迁移到shared module
中,以实现在iOS和Android平台上共享这部分逻辑:
- 长按拖动
com.jetbrains.simplelogin.androidaoo.data
包至shared
模块下的commonMain
- 点击
Refactor
- 点击
continue
step 5:替换平台特性代码
经过上述改造,你会发现,原本正常的不能在正常的代码在IDE里飘了红:
无一例外,飘红的代码全是和平台相关的代码。由于shared模块为跨平台共用代码,所以此处的代码都需要保持清真
,摒弃平台特性,接下来就是对其进行改造:
替换Android平台特有代码
我们需要将shared模块中所有JVM相关的代码替换成Kotlin依赖,例如在LoginDataSource
中,我们使用了IOException
,而IOException
属于JVM特有的代码,我们须将其替换成Kotlin实现:
kotlin
// 调整前
return Result.Error(IOException("Error logging in", e))
// Kotlin中并没有IOException,此处调整为RuntimeException
return Result.Error(RuntimeException("Error logging in", e))
同时也需要将Android特有的代码替换为Kotlin实现,例如com.example.shared.data.LoginDataValidator#isEmailValid
方法中的使用了Patterns
来校验邮箱格式,而Patterns
位于android.util
包下,属于Android平台特有代码,我们需要将其改造为Kotlin实现:
kotlin
// 调整前
private fun isEmailValid(email: String) = Patterns.EMAIL_ADDRESS.matcher(email).matches()
// 调整后
private fun isEmailValid(email: String) = emailRegex.matches(email)
companion object {
private val emailRegex =
("[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}" +
"\\@" +
"[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" +
"(" +
"\\." +
"[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}" +
")+").toRegex()
}
使用expect/actual编写跨平台代码
某些平台特性的代码可能Kotlin并没有提供对应的实现,这个时候需要使用expect/actual
关键字来桥接平台实现来完成跨平台,例如我们在登录时,会调用java.util.UUID.randomUUID()
来生成用户ID,Kotlin标准库并不提供生成uuid的功能,这个时候我们就仍然需要使用特定于平台提供的功能,Android设备上仍然使用java.util.UUID.randomUUID()
,iOS设备上则使用NSUUID().UUIDString()
。
使用expect声明方法
在shared/src/commonMain
目录下新建Utils.kt
,并提供expect
声明 :
kotlin
package com.example.shared
expect fun randomUUID(): String
替换平台特定代码引用
将LoginDataSource
中对java.util.UUID.randomUUID()
的引用替换成上面声明的randomUUID()
:
kotlin
val fakeUser = LoggedInUser(randomUUID(), "Jane Doe")
使用actual提供不同的平台实现
在shared/src/androidMain
目录下创建Utils.kt
,并提供Android平台中的实现:
kotlin
package com.example.shared
import java.util.UUID
actual fun randomUUID() = UUID.randomUUID().toString()
在shared/src/iosMain
目录下创建Utils.kt
,并提供iOS平台中的实现:
kotlin
package com.example.shared
import platform.Foundation.NSUUID
actual fun randomUUID(): String = NSUUID.UUID().UUIDString
全部调整完成后再次运行项目,可以看到项目依旧能在Android设备上成功运行起来。
step 6:将代码运行到iOS设备上
完成跨平台改造,重用业务逻辑代码后,我们尝试将代码运行到iOS设备上。
使用 xcode 创建iOS项目
- 打开xcode,点击 File|New|Project,选择iOS/App,点击Next
- 项目名称我们填写simpleLoginIOS,然后点击Next
- 项目的位置我们选择我们demo项目的根目录,然后点击create
然后我们就能在Android Studio中看到我们创建的iOS工程目录,我们可以给它改个名字,就改成iosApp
和Android工程保持统一的命名风格。
连接framework至iOS项目
刚刚创建的iOS项目和我们的shared模块目前没有任何关系,我们需要手动建立他们的联系:
- 打开Xcode,双击项目名称打开iOS项目设置。
- 在弹出的窗口中选中
Build Phases
tab,点击 "+",选择New Run Script Phase
选项
- 添加构建脚本
kotlin
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
- 长按
Run Script
条目,将其拖动至Compile Sources
上方
- 点击
Build Settings
tab,选中All
,在Search Paths
下指定Framework Search Paths
:
kotlin
$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)
- 选择
Build Settings
tab,搜索sandboxing
,禁用Build Options
下的User Script Sandboxing
。
- 使用Xcode重新构建项目,我们可以更改一下
ContentView.swift
模块的代码来确认我们的shared模块是否在iOS平台生效了:
swift
import SwiftUI
import shared
struct ContentView: View {
var body: some View {
Text(Greeting().greet())
.padding()
}
}
然后打开Android Studio,运行我们的iosApp: 效果如下: 可以看到,来自shared模块下的com.example.shared.Greeting#greet
方法也已经成功被调用。
使用swiftUI写一个简单的登录页面
我们仿照Android项目的登录页面,使用swiftUI
写一个简单的登录页面:
swift
#ContentView.swift
import SwiftUI
import shared
struct ContentView: View {
@State private var username: String = ""
@State private var password: String = ""
@ObservedObject var viewModel: ContentView.ViewModel
var body: some View {
VStack(spacing: 15.0) {
ValidatedTextField(titleKey: "Username", secured: false, text: $username, errorMessage: viewModel.formState.usernameError, onChange: {
viewModel.loginDataChanged(username: username, password: password)
})
ValidatedTextField(titleKey: "Password", secured: true, text: $password, errorMessage: viewModel.formState.passwordError, onChange: {
viewModel.loginDataChanged(username: username, password: password)
})
Button("Login") {
viewModel.login(username: username, password: password)
}.disabled(!viewModel.formState.isDataValid || (username.isEmpty && password.isEmpty))
}
.padding(.all)
}
}
struct ValidatedTextField: View {
let titleKey: String
let secured: Bool
@Binding var text: String
let errorMessage: String?
let onChange: () -> ()
@ViewBuilder var textField: some View {
if secured {
SecureField(titleKey, text: $text)
} else {
TextField(titleKey, text: $text)
}
}
var body: some View {
ZStack {
textField
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.onChange(of: text) { _ in
onChange()
}
if let errorMessage = errorMessage {
HStack {
Spacer()
FieldTextErrorHint(error: errorMessage)
}.padding(.horizontal, 5)
}
}
}
}
struct FieldTextErrorHint: View {
let error: String
@State private var showingAlert = false
var body: some View {
Button(action: { self.showingAlert = true }) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
}
.alert(isPresented: $showingAlert) {
Alert(title: Text("Error"), message: Text(error), dismissButton: .default(Text("Got it!")))
}
}
}
extension ContentView {
struct LoginFormState {
let usernameError: String?
let passwordError: String?
var isDataValid: Bool {
get { return usernameError == nil && passwordError == nil }
}
}
class ViewModel: ObservableObject {
@Published var formState = LoginFormState(usernameError: nil, passwordError: nil)
let loginValidator: LoginDataValidator
let loginRepository: LoginRepository
init(loginRepository: LoginRepository, loginValidator: LoginDataValidator) {
self.loginRepository = loginRepository
self.loginValidator = loginValidator
}
func login(username: String, password: String) {
if let result = loginRepository.login(username: username, password: password) as? ResultSuccess {
print("Successful login. Welcome, \(result.data.displayName)")
} else {
print("Error while logging in")
}
}
func loginDataChanged(username: String, password: String) {
formState = LoginFormState(
usernameError: (loginValidator.checkUsername(username: username) as? LoginDataValidator.ResultError)?.message,
passwordError: (loginValidator.checkPassword(password: password) as? LoginDataValidator.ResultError)?.message)
}
}
}
调整一下simpleLoginIOSApp.swift
代码:
swift
import SwiftUI
import shared
@main
struct SimpleLoginIOSApp: App {
var body: some Scene {
WindowGroup {
ContentView(viewModel: .init(loginRepository: LoginRepository(dataSource: LoginDataSource()), loginValidator: LoginDataValidator()))
}
}
}
运行起来看一下:
现在这个登录页面,除了UI部分的代码保持平台独立性外(iOS使用swiftUI,Android使用Compose),业务逻辑部分的代码实现了跨平台的完全统一共享。
step 7:一次编写,双端运行
最后,我们来 **enjoy **一下我们的成果,我们来尝试修改一下登录的校验,原来的密码校验逻辑只会校验密码的字符串长度 > 5
即可,现在我们调整下,密码不能为"password
":
kotlin
#LoginDataValidator.kt
// 调整前
fun checkPassword(password: String): Result {
return if (password.length > 5) Result.Success else Result.Error("Password must be >5 characters")
}
// 调整后
fun checkPassword(password: String): Result {
return when {
password.length < 5 -> Result.Error("Password must be >5 characters")
password.lowercase() == "password" -> Result.Error("Password shouldn't be \"password\"")
else -> Result.Success
}
}
再次运行看看效果: 可以看到,除了UI上的不同,业务逻辑双端保持了一致性。