「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上的不同,业务逻辑双端保持了一致性。

相关推荐
深海呐2 小时前
Android AlertDialog圆角背景不生效的问题
android
ljl_jiaLiang2 小时前
android10 系统定制:增加应用使用数据埋点,应用使用时长统计
android·系统定制
花花鱼2 小时前
android 删除系统原有的debug.keystore,系统运行的时候,重新生成新的debug.keystore,来完成App的运行。
android
落落落sss4 小时前
sharding-jdbc分库分表
android·java·开发语言·数据库·servlet·oracle
一丝晨光4 小时前
逻辑运算符
java·c++·python·kotlin·c#·c·逻辑运算符
消失的旧时光-19436 小时前
kotlin的密封类
android·开发语言·kotlin
服装学院的IT男7 小时前
【Android 13源码分析】WindowContainer窗口层级-4-Layer树
android
CCTV果冻爽8 小时前
Android 源码集成可卸载 APP
android
码农明明8 小时前
Android源码分析:从源头分析View事件的传递
android·操作系统·源码阅读
秋月霜风9 小时前
mariadb主从配置步骤
android·adb·mariadb