Kotlin & Compose Multiplatform 跨平台开发实践之加入 iOS 支持

Kotlin & Compose Multiplatform 跨平台开发实践之加入 iOS 支持

前言

几个月前 Compose Multiplatform 的 iOS 支撑就宣布进入了 Alpha 阶段,这意味着它现已具有了必定的可用性。

在它发布 Alpha 的时候,我就第一时刻尝鲜,可是仅仅浅尝辄止,没有做过多的探究,最近刚好有点时刻,所以我又从头开端学习 Compose Multiplatform ,而且尝试移植我已有的项目使其支撑 iOS,而且将移植进程收拾记录了下来,即为本文。

这次移植我挑选的依旧是这个运用 Compose 写的计算器项目 calculator-Compose-MultiPlatform 。本来这次我想着移植一个涉及技能稍微多一点的项目的比方这个 githubAppByCompose,可是我细心研讨了一下,究竟现在 Compose Multiplatform 还处于试验阶段,很多对应的功用和库都还没有,所以只能挑选移植前者。

对于这个计算器项目,最开端仅仅一个运用 Compose 完成的纯 Android 项目,后来移植到了支撑 Android 和 桌面 端,所以其完成在再给它添加上 iOS 支撑,也算是补齐了终究一个渠道了,哈哈。

在开端阅读本文之前,我会假定你现已了解而且知道 Compsoe 的根本运用方法。

为了更好的了解本文,或许需求首要阅读这两篇前置文章:

  1. 【译】快速开端 Compose 跨渠道项目
  2. Kotlin & Compose Multiplatform 跨渠道(Android端、桌面端)开发实践之运用 SQLDelight 将数据储存至数据库

前言的终究看一下运转作用:

Android 端:

Kotlin & Compose Multiplatform 跨平台开发实践之加入 iOS 支持

ios 端:

Kotlin & Compose Multiplatform 跨平台开发实践之加入 iOS 支持

桌面端:

Kotlin & Compose Multiplatform 跨平台开发实践之加入 iOS 支持

开端移植

准备工作

首当其冲,咱们需求为 iOS 的支撑更改编译装备文件和添加对应的渠道特定代码。

在我的这个项目中,我通过以下几个过程为其添加了对 iOS 的支撑:

更改同享代码模块称号

把共用代码模块由 common 改为 shared ,其实这儿不必改也行,仅仅模板装备文件中写的 iOS 运用的共用代码途径是 shared ,可是直接改模块名比改装备文件简略多了,所以咱们直接把模块名改了就好了。

改完之后切记要查看一下其他模块引证的姓名是否改了,以及留意查看一下包名是否正确。

添加 native.cocoapods 插件

shared 模块的 build.gradle.kts 文件的 plugins 添加 native.cocoapods 插件:

plugins {
    kotlin("native.cocoapods")
    // ……
}

添加 cocoapods 装备

shared 模块的 build.gradle.kts 文件的 kotlin 下添加 cocoapods 相应的装备:

kotlin {
	// ……
	iosX64()
    iosArm64()
    iosSimulatorArm64()
    cocoapods {
        version = "1.0.0"
        summary = "Some description for the Shared Module"
        homepage = "Link to the Shared Module homepage"
        ios.deploymentTarget = "14.1"
        podfile = project.file("../iosApp/Podfile")
        framework {
            baseName = "shared"
            isStatic = true
        }
        extraSpecAttributes["resources"] = "['src/commonMain/resources/**', 'src/iosMain/resources/**']"
    }
    // ……
}

装备 iOS 源集

shared 模块的 build.gradle.kts 文件的 kotlin 中的 sourceSets 下添加 iOS 的源集装备:

kotlin {
	// ……
	sourceSets {
		// ……
		val iosX64Main by getting
        val iosArm64Main by getting
        val iosSimulatorArm64Main by getting
        val iosMain by creating {
            dependsOn(commonMain)
            iosX64Main.dependsOn(this)
            iosArm64Main.dependsOn(this)
            iosSimulatorArm64Main.dependsOn(this)
        }
	}
}

添加其他插件

在项目根目录下的 settings.gradle.kts 文件的 pluginManagement 中的 plugins 添加插件装备:

pluginManagement {
	//……
	plugins {
		kotlin("jvm").version(extra["kotlin.version"] as String)
		// ……
	}
}

添加 iOS 项目文件

直接把官方模板中的 iosAPP 模块整个目录复制到项目根目录来。

需求留意的是,其实这个 iosAPP 目录并不是一个 idea 模块,而是一个 Xcode 项目。可是现在暂时不需求知道这是什么,只需求把相应的文件整个复制到自己项目中就行了。

然后把官方模版中的 sahred -> iosMain 文件夹整个复制到 咱们项目的 sahred 模块根目录中。

适配代码

在这一节中,首要需求适配的有两种类型的代码:

一是之前就现已在项目中声明了的 expect 函数,需求为 iOS 也加上对应的 actual 函数。

二是需求将本来运用到的 jvm 相关或者说一切运用 java 完成的库和相关代码都需求从头编写或适配。

由于不同于 Android 和 桌面端,kotlin 终究会被编译成 jvm 代码,在 iOS 端,kotlin 会编译成 native 代码,所以一切运用 java 写的代码将无法再运用。

这也便是我前言中说的为啥不挑选移植更复杂的项目的原因,便是由于我在其间引证了大量的运用 java 编写的第三方库,而这些第三方库又暂时没有运用纯 kotlin 完成的可用替代品。

下面,咱们就开端适配代码。

更改进口

为了确保三端界面共同,咱们将本来的UI界面再额外的抽出一个统一的进口函数 APP(),将其放到 shared 模块的 common 包下:

@Composable
fun APP(
    standardChannelTop: Channel<StandardAction>? = null,
    programmerChannelTop: Channel<ProgrammerAction>? = null,
) {
    val homeChannel = remember { Channel<HomeAction>() }
    val homeFlow = remember(homeChannel) { homeChannel.consumeAsFlow() }
    val homeState = homePresenter(homeFlow)
    val standardChannel = standardChannelTop ?: remember { Channel() }
    val standardFlow = remember(standardChannel) { standardChannel.consumeAsFlow() }
    val standardState = standardPresenter(standardFlow)
    val programmerChannel = programmerChannelTop ?: remember { Channel() }
    val programmerFlow = remember(programmerChannel) { programmerChannel.consumeAsFlow() }
    val programmerState = programmerPresenter(programmerFlow)
    CalculatorComposeTheme {
        val backgroundColor = MaterialTheme.colors.background
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = backgroundColor
        ) {
            HomeScreen(
                homeChannel,
                homeState,
                standardChannel,
                standardState,
                programmerChannel,
                programmerState
            )
        }
    }
}

而且,由于不同渠道需求差异化完成部分功用,以及现在我还没找到一个好使的支撑跨渠道的依靠注入库,所以我干脆将一切 操控(channel) 和 状况(state) 都提升到了最顶层,作为参数传递给下面的 Compose 函数。

然后,更改三端各自的进口函数:

Android (android 模块下的 MainActivity.kt 文件)

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            APP()
        }
    }
}

desktop (dektop 模块下的 Main.kt 文件)

fun main() = application {
    val state = if (Config.boardType.value == KeyboardTypeStandard) {
        rememberWindowState(size = defaultWindowSize, position = defaultWindowPosition)
    } else {
        rememberWindowState(size = landWindowSize, position = defaultWindowPosition)
    }
    val standardChannel = remember { Channel<StandardAction>() }
    val programmerChannel = remember { Channel<ProgrammerAction>() }
    Window(
        onCloseRequest = ::exitApplication,
        state = state,
        title = Text.AppName,
        icon = painterResource("icon.png"),
        alwaysOnTop = Config.isFloat.value,
        onKeyEvent = {
            if (isKeyTyped(it)) {
                val btnIndex = asciiCode2BtnIndex(it.utf16CodePoint)
                if (btnIndex != -1) {
                    if (Config.boardType.value == KeyboardTypeStandard) {
                        standardChannel.trySend(StandardAction.ClickBtn(btnIndex))
                    }
                    else {
                        programmerChannel.trySend(ProgrammerAction.ClickBtn(btnIndex))
                    }
                }
            }
            true
        }
    ) {
        APP()
    }
}

iOS ( shared模块 下的 main.ios.kt 文件)

fun MainViewController() = ComposeUIViewController {
    APP()
}

留意,不同于其他渠道,iOS 的进口函数在 shared模块 中。

当然,你要是想直接改 iosAPP 目录中的代码,那也不是不可,仅仅对于咱们安卓开发来说,仍是直接改 shared 更方便点。

完成 iOS 的 渠道代码

之前咱们的项目中有几个当地的完成依靠于渠道,所以写了一些 expect 函数,现在咱们需求给 iOS 完成对应的 actual 函数。

首要在 shared 模块的 iosMain 包中创建一个包途径,保持和 commonMainexpect 函数包共同:

Kotlin & Compose Multiplatform 跨平台开发实践之加入 iOS 支持

留意: 包途径必定要共同,否则会编译失利,我就在这儿踩了坑,没留意到包名不一样, debug 了好久。

这个项目中的渠道差异函数首要有四个:

操控振动

由于我对 iOS 一无所知,所以不知道怎样写,干脆直接留空了:

actual fun vibrateOnClick() {
}
actual fun vibrateOnError() {
}
actual fun vibrateOnClear() {
}
actual fun vibrateOnEqual() {
}

操控屏幕旋转和显现小窗

这儿同上,不知道怎样写,直接留空:

actual fun showFloatWindows() {
}
actual fun changeKeyBoardType(changeTo: Int) {
}

数据库(sqldelight)

actual fun createDriver(): SqlDriver {
    return NativeSqliteDriver(HistoryDatabase.Schema, "history.db")
}

关于运用 sqldelight 的详细介绍,能够看前言中的前置文章了解。

其实这儿这样写是编译不通过的,由于还没加 sqldelight 依靠,下面介绍一下怎样加依靠,这儿又是一个大坑。

给 iOS 添加 sqldelight 支撑

首要,在 shared 模块下的 build.gradle.kts 文件中的 kotlin -> sourceSets -> iosMain 添加 sqldelight 的 驱动依靠:

kotlin {
	// ……
	sourceSets {
		// ……
	    val iosMain by creating {
	    	// ……
	        dependencies {
	            implementation("app.cash.sqldelight:native-driver:2.0.0")
	        }
        }
	}
}

此刻如果你直接 sync gradle 后编译运转,大概率会报错:

Undefined symbols for architecture arm64:
"_sqlite3_bind_text16", referenced from:
_SQLiter_SQLiteStatement_nativeBindString in app(combined.o)
"_sqlite3_bind_int64", referenced from:
_SQLiter_SQLiteStatement_nativeBindLong in app(combined.o)
"_sqlite3_last_insert_rowid", referenced from:
_SQLiter_SQLiteStatement_nativeExecuteForLastInsertedRowId in app(combined.o)
"_sqlite3_reset", referenced from:
_SQLiter_SQLiteConnection_nativeResetStatement in app(combined.o)
"_sqlite3_changes", referenced from:
_SQLiter_SQLiteStatement_nativeExecuteForChangedRowCount in app(combined.o)
_SQLiter_SQLiteStatement_nativeExecuteForLastInsertedRowId in app(combined.o)
"_sqlite3_open_v2", referenced from:
_SQLiter_SQLiteConnection_nativeOpen in app(combined.o)
"_sqlite3_db_config", referenced from:
_SQLiter_SQLiteConnection_nativeOpen in app(combined.o)
"_sqlite3_busy_timeout", referenced from:
_SQLiter_SQLiteConnection_nativeOpen in app(combined.o)
"_sqlite3_trace", referenced from:
_SQLiter_SQLiteConnection_nativeOpen in app(combined.o)
"_sqlite3_bind_parameter_index", referenced from:
_SQLiter_SQLiteConnection_nativeBindParameterIndex in app(combined.o)
"_sqlite3_column_bytes", referenced from:
_SQLiter_SQLiteConnection_nativeColumnGetString in app(combined.o)
_SQLiter_SQLiteConnection_nativeColumnGetBlob in app(combined.o)
"_sqlite3_finalize", referenced from:
_SQLiter_SQLiteStatement_nativeFinalizeStatement in app(combined.o)
"_sqlite3_column_text", referenced from:
_SQLiter_SQLiteConnection_nativeColumnGetString in app(combined.o)
"_sqlite3_column_name", referenced from:
_SQLiter_SQLiteConnection_nativeColumnName in app(combined.o)
"_sqlite3_bind_double", referenced from:
_SQLiter_SQLiteStatement_nativeBindDouble in app(combined.o)
"_sqlite3_profile", referenced from:
_SQLiter_SQLiteConnection_nativeOpen in app(combined.o)
"_sqlite3_close", referenced from:
_SQLiter_SQLiteConnection_nativeClose in app(combined.o)
_SQLiter_SQLiteConnection_nativeOpen in app(combined.o)
"_sqlite3_prepare16_v2", referenced from:
_SQLiter_SQLiteConnection_nativePrepareStatement in app(combined.o)
"_sqlite3_column_type", referenced from:
_SQLiter_SQLiteConnection_nativeColumnIsNull in app(combined.o)
_SQLiter_SQLiteConnection_nativeColumnType in app(combined.o)
"_sqlite3_column_count", referenced from:
_SQLiter_SQLiteConnection_nativeColumnCount in app(combined.o)
"_sqlite3_bind_blob", referenced from:
_SQLiter_SQLiteStatement_nativeBindBlob in app(combined.o)
"_sqlite3_db_readonly", referenced from:
_SQLiter_SQLiteConnection_nativeOpen in app(combined.o)
"_sqlite3_column_int64", referenced from:
_SQLiter_SQLiteConnection_nativeColumnGetLong in app(combined.o)
"_sqlite3_bind_null", referenced from:
_SQLiter_SQLiteStatement_nativeBindNull in app(combined.o)
"_sqlite3_extended_errcode", referenced from:
android::throw_sqlite3_exception(sqlite3*) in app(combined.o)
android::throw_sqlite3_exception(sqlite3*, char const*) in app(combined.o)
"_sqlite3_column_double", referenced from:
_SQLiter_SQLiteConnection_nativeColumnGetDouble in app(combined.o)
"_sqlite3_column_blob", referenced from:
_SQLiter_SQLiteConnection_nativeColumnGetBlob in app(combined.o)
"_sqlite3_step", referenced from:
_SQLiter_SQLiteConnection_nativeStep in app(combined.o)
_SQLiter_SQLiteStatement_nativeExecute in app(combined.o)
_SQLiter_SQLiteStatement_nativeExecuteForChangedRowCount in app(combined.o)
_SQLiter_SQLiteStatement_nativeExecuteForLastInsertedRowId in app(combined.o)
"_sqlite3_clear_bindings", referenced from:
_SQLiter_SQLiteConnection_nativeClearBindings in app(combined.o)
"_sqlite3_errmsg", referenced from:
android::throw_sqlite3_exception(sqlite3*) in app(combined.o)
android::throw_sqlite3_exception(sqlite3*, char const*) in app(combined.o)
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

这是由于 ios 的 Xcode 项目没有添加 sqlite 依靠,咱们还需求为 ios 独自添加 sqlite 依靠。

ios 运用的是 cocoapods 进行依靠管理,咱们需求运用 pod 添加依靠。

咱们有两种挑选:

一是在 shared 模块的 build.gradle.kts 中相应的方位添加 pod 依靠装备。

二是直接在 pod 装备文件中添加。

这儿咱们就挑选直接改 pod 的装备文件。

翻开项目根目录下的 iosAPP 目录中的 Podfile 文件,在其间添加 sqlite3 依靠:

target 'iosApp' do
  # ……
  pod 'sqlite3', '~> 3.42.0'
  # ……
end

添加完记住需求 sync 一下 gradle。

此刻再编译运转,大概率仍是会报错:

ld: file not found: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphonesimulator.a

不必担心,再在方才的装备文件中加上这么一段:

# iosApp's podfile
post_install do |installer|
    installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
            config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.1'
        end
    end
end

此刻应该就不会有任何问题了。

适配 jvm 相关代码

正如咱们在上一节所说,由于 iOS 运用 native 代码,所以项目中就不能再运用 java 代码,包含引证的第三方库也是。

在我这个项目中涉及到需求适配的首要有两个当地。一个是进制转化时运用到了 java 的 Long 类的方法;另一个便是运算时运用的是 BigInteger BigDecimal

进制转化

之前的代码运用的是 java 中的 java.lang.Long.toXXXString

这儿适配起来其实很简略,要么自己运用 kotlin 完成一个进制转化东西类,要么就像我一样,直接把 Long.java 中需求的部分 CV 一下,然后运用 Android studio 的 java 转 kotlin 一键转化就行了。

下面便是我转好的东西类:

package com.equationl.common.utils
import kotlin.math.max
object LongUtil {
    val digits = charArrayOf(
        '0', '1', '2', '3', '4', '5',
        '6', '7', '8', '9', 'a', 'b',
        'c', 'd', 'e', 'f', 'g', 'h',
        'i', 'j', 'k', 'l', 'm', 'n',
        'o', 'p', 'q', 'r', 's', 't',
        'u', 'v', 'w', 'x', 'y', 'z'
    )
    fun toBinaryString(i: Long): String {
        return toUnsignedString0(i, 1)
    }
    fun toHexString(i: Long): String {
        return toUnsignedString0(i, 4)
    }
    fun toOctalString(i: Long): String {
        return toUnsignedString0(i, 3)
    }
    fun toUnsignedString0(`val`: Long, shift: Int): String {
        // assert shift > 0 && shift <=5 : "Illegal shift value";
        val mag: Int = Long.SIZE_BITS - numberOfLeadingZeros(`val`)
        val chars: Int = max((mag + (shift - 1)) / shift, 1)
        //if (COMPACT_STRINGS) {
            val buf = ByteArray(chars)
            formatUnsignedLong0(`val`, shift, buf, 0, chars)
            return buf.map { it.toInt().toChar() }.toCharArray().concatToString()
//        } else {
//            val buf = ByteArray(chars * 2)
//            java.lang.Long.formatUnsignedLong0UTF16(`val`, shift, buf, 0, chars)
//            return String(buf, UTF16)
//        }
    }
    private fun formatUnsignedLong0(
        `val`: Long,
        shift: Int,
        buf: ByteArray,
        offset: Int,
        len: Int
    ) {
        var `val` = `val`
        var charPos = offset + len
        val radix = 1 shl shift
        val mask = radix - 1
        do {
            buf[--charPos] = digits[`val`.toInt() and mask].code.toByte()
            `val` = `val` ushr shift
        } while (charPos > offset)
    }
    fun numberOfLeadingZeros(i: Long): Int {
        val x = (i ushr 32).toInt()
        return if (x == 0) 32 + numberOfLeadingZeros(i.toInt()) else numberOfLeadingZeros(
            x
        )
    }
    fun numberOfLeadingZeros(i: Int): Int {
        // HD, Count leading 0's
        var i = i
        if (i <= 0) return if (i == 0) 32 else 0
        var n = 31
        if (i >= 1 shl 16) {
            n -= 16
            i = i ushr 16
        }
        if (i >= 1 shl 8) {
            n -= 8
            i = i ushr 8
        }
        if (i >= 1 shl 4) {
            n -= 4
            i = i ushr 4
        }
        if (i >= 1 shl 2) {
            n -= 2
            i = i ushr 2
        }
        return n - (i ushr 1)
    }
}

然后更改咱们的代码中运用到的当地即可,例如:

Long.toBinaryString 改为 LongUtil.toBinaryString(long)

记住把导入的包也改了:

import java.lang.Long 改为 import com.equationl.common.utils.LongUtil

当然,如果你的东西类直接取名叫 Long 的话,那么调用代码就不必改了,改导入包就行了。

BigInteger 和 BigDecimal

接下来便是 BigInteger 和 BigInteger,同样的思路,咱们能够挑选自己运用 kotlin 写一个功用相同的东西类,可是明显,这两个类可不同于进制转化,它涉及到的代码量可要大多了。

好在现已有大神写好了纯 kotlin 的支撑跨渠道的 BigInteger 和 BigDecimal: kotlin-multiplatform-bignum 。咱们只需求简略的引证它就能够了。

shared 模块下的 build.gradle.kts 文件中的 kotlin -> sourceSets -> commonMain -> dependencies 添加依靠

kotlin {
	sourceSets {
		val commonMain by getting {
			dependencies {
				implementation("com.ionspin.kotlin:bignum:0.3.8")
			}
		}
	}
}

sync gradle 后,依次修改项目中运用到 BigInteger 和 BigDecimal 当地的代码即可。

需求留意的是,这个库的 API 和 java 的 BigInteger 以及 BigDecimal 并非彻底共同,因此需求咱们逐一查看并修改。

例如,在 java 的 BigDecimal 中,除法的 API 是: divide(BigDecimal divisor, int scale, RoundingMode roundingMode)

而在这个库中则变为了 divide(other: BigDecimal, decimalMode: DecimalMode? = null)

除此之外,还有一些小当地的代码或许引证的是 java 代码,这儿就不再赘述了,依照上述两种思路逐一适配即可。

总结

自此,咱们的项目就彻底移植到了完好形态的 Compose Multiplatform 中了!现在它现已彻底支撑 Android、iOS 和 desktop 了!

不知道你们有没有发现,在全文中,我几乎都是在说怎样适配和移植逻辑代码,并没有提到有关 UI 的代码。

哈哈,不是由于我忘记说了,而是由于 Compose Multiplatform 代码真的做到了一套代码,多渠道通用。新添加 iOS 支撑彻底不必动 UI 部分的代码。

完好项目代码: calculator-Compose-MultiPlatform