「KMP」现有项目无痛改造:让你的Android项目运行到iOS设备上

上篇文章「「Hello KMP」创建你的第一个KMP项目」我们尝试从无到有创建了第一个KMP项目。然而大多数情况下,我们更多的是基于已有的项目进行开发。这篇文章我们聚焦于「改造」现有项目,使其具备跨平台的能力,同时运行在Android和iOS设备上。

为了让我们对KMP项目有一个更深的了解,Kotlin官方也为我们提供了一个Demo程序,并对其进行了改造,使其可以同时运行在Android和iOS系统上。该Demo程序逻辑相对简单,只有一个登录界面,对用户输入的用户名/密码校验,项目地址:GitHub - Kotlin/kmm-integration-sample 。我们本次以此为例,一步一步完成项目的KMP改造。

项目包含4个分支,我们只需关注masterfinal分支。其中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

  1. 打开Android Studio,点击 File |New|New module
  2. 在弹出的弹窗中选择Kotlin Multiplatform Shared Module 模版,Module Name填写为 sharediOS framework distribution选择Regular framework
  3. 点击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

  1. 打开我们的app模块,在其build.gradle.kt文件中声明对shared模块的依赖:

需要注意的是,sharedapp模块中声明的compileSDKminSDK需要保持一致,否则编译会报错。全部调整完后sync一下项目。

  1. 验证下我们的shared模块是否正常可用:

我们可以在com.jetbrains.simplelogin.androidapp.ui.login.LoginActivity#onCreate方法中添加一行代码,然后就能看到来自shared模块中的com.example.shared.Greeting#greet方法被成功调用,日志也被成功打印了出来:

step 4:迁移业务逻辑代码至shared

现在我们可以尝试将业务逻辑代码迁移到shared module中,以实现在iOS和Android平台上共享这部分逻辑:

  1. 长按拖动com.jetbrains.simplelogin.androidaoo.data包至shared模块下的commonMain
  1. 点击Refactor
  1. 点击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项目

  1. 打开xcode,点击 File|New|Project,选择iOS/App,点击Next
  1. 项目名称我们填写simpleLoginIOS,然后点击Next
  1. 项目的位置我们选择我们demo项目的根目录,然后点击create

然后我们就能在Android Studio中看到我们创建的iOS工程目录,我们可以给它改个名字,就改成iosApp和Android工程保持统一的命名风格。

连接framework至iOS项目

刚刚创建的iOS项目和我们的shared模块目前没有任何关系,我们需要手动建立他们的联系:

  1. 打开Xcode,双击项目名称打开iOS项目设置。
  2. 在弹出的窗口中选中Build Phasestab,点击 "+",选择New Run Script Phase选项
  1. 添加构建脚本
kotlin 复制代码
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
  1. 长按Run Script条目,将其拖动至Compile Sources上方
  1. 点击Build Settingstab,选中All,在Search Paths下指定Framework Search Paths
kotlin 复制代码
$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)
  1. 选择Build Settingstab,搜索sandboxing,禁用Build Options下的User Script Sandboxing
  1. 使用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上的不同,业务逻辑双端保持了一致性。

相关推荐
一起搞IT吧10 分钟前
相机Camera日志实例分析之五:相机Camx【萌拍闪光灯后置拍照】单帧流程日志详解
android·图像处理·数码相机
浩浩乎@30 分钟前
【openGLES】安卓端EGL的使用
android
Kotlin上海用户组2 小时前
Koin vs. Hilt——最流行的 Android DI 框架全方位对比
android·架构·kotlin
zzq19962 小时前
Android framework 开发者模式下,如何修改动画过度模式
android
木叶丸2 小时前
Flutter 生命周期完全指南
android·flutter·ios
阿幸软件杂货间3 小时前
阿幸课堂随机点名
android·开发语言·javascript
没有了遇见3 小时前
Android 渐变色整理之功能实现<二>文字,背景,边框,进度条等
android
没有了遇见4 小时前
Android RecycleView 条目进入和滑出屏幕的渐变阴影效果
android
站在巨人肩膀上的码农4 小时前
去掉长按遥控器power键后提示关机、飞行模式的弹窗
android·安卓·rk·关机弹窗·power键·长按·飞行模式弹窗
呼啦啦--隔壁老王5 小时前
屏幕旋转流程
android