iOS 跨平台开发:Kotlin Multiplatform 入门
本文要点
-
Kotlin Multiplatform 让开发者在开发跨平台应用时省去很多重复编写代码逻辑的麻烦。
-
用 KMP 并不能做出在各个平台完全共享的代码,因为很多时候 UI 逻辑需要原生编程,各平台的逻辑很难通用。
-
Swift 和 Kotlin 语法有很高的相似度,这极大降低了学习使用 KMP 业务逻辑的难度。
-
可以使用 Android Studio 创建可复用的 KMP 组件,然后将其作为框架导入 Xcode 项目。
iOS 开发者的福音:Kotlin Multiplatform
DRY(避免重复劳动)是编程的基本原则之一,但开发跨平台应用时经常需要重复编写许多代码逻辑。出色的编程工作就是要尽量减少重复劳动,而应用使用的跨平台共享代码越多,重复劳动越少,代码质量也就越高。
想想你正在开发的 iOS 项目:它是否支持 Android 或 web 等平台?如果答案是肯定的,那么你的 iOS 应用与其它平台的版本共享了多少代码逻辑? 如果答案是否定的,但你准备开发支持其它平台的版本,那么跨平台开发会带来多少重复劳动?不管是哪个问题,答案都很可能是”很多“。
了解一下 Kotlin Multiplatform (KMP)吧。Kotlin 是一种静态类型的编程语言,与 Swift 有着惊人的相似度,并可与 Java 完全互操作。从某些角度来看,你可以把它当作是 Android 上的 Swift。KMP 是 Kotlin 的一项功能组件,用它可以在应用的各平台版本之间共享代码,这样各个平台的原生 UI 就可以调用公共的底层代码了。KMP 并不能实现跨平台版本之间完全共享代码,但它也 向这一目标迈出了重要的一步 。
KMP 使用 Kotlin 来编写跨平台应用中通用的业务逻辑。接下来 各个平台的原生 UI 会调用这些公共逻辑。很多时候 UI 逻辑还是要原生开发的,因为各个平台的差异太大了。在 iOS 中,这意味着要将用 KMP 编写的.frameworkfile 像其它外部库一样导入到 Xcode 项目中。在 iOS 上使用 KMP 也要用到 Swift 语言,所以 KMP 没法取代 Swift 。
KMP 也可以迭代式引入项目,所以实现 KMP 不需要中断当前项目,不需要替换现有的 Swift 代码。你只要在下次为跨平台应用加入新功能的时候使用 KMP 编写业务逻辑,然后将其部署到各个平台,并开发原生 UI 就行了。在 iOS 这边,就是用 Kotlin 开发业务逻辑并用 Swift 开发 UI 逻辑。
Swift 和 Kotlin 的语法高度相似,所以学习用 KMP 编写业务逻辑会非常容易。需要下功夫学习的就只有一个 IDE 了:那就是 Android Studio。
Kotlin Multiplatform 项目仍然是 Kotlin 的实验性功能组件,所以每次更新后其 API 都可能有改动。
入门
本教程适用于几乎没接触过 Android Studio 或 Kotlin 的 iOS 开发者。如果你还没有安装 Android Studio,请按照 安装指南 操作。
把我们制作的 入门项目 克隆下来。它带有一个 KMP 样板项目,其中有一个空的 KMP 库。这个入门项目还有一个 iOS 应用和一个 Android 应用,两个应用都是用来显示一个 URL 列表的 GIF 图片的,列表中有 25 个硬编码的“meh”URL。
这两个应用是为你准备的,但那个库不是,因为本文的重点就是如何制作一个 KMP 库,不是教你应用该如何使用 KMP 库。但请放心,如果你想深入了解 Android 开发的话,这篇文章教你的知识也会很有用。
你要用 KMP 编写网络逻辑,在 Giphy 的公共 API 中搜索短语“whoa”,获取 25 个 URL,替换掉两个平台中原来的硬编码地址。
现在打开 Android Studio,选择 Open an existing Android Studio project 。在弹出的 Finder 窗口中选择之前克隆来的启动项目的顶级 GifGetter/ 目录。
如果弹出这个对话框,请按“ Update ”。
Android Studio
如果你之前没有深入研究过 Android Studio,可能就要花些时间先来熟悉它了。这一章会讲解本教程用到的 Android Studio 的一些功能。如果你想深入了解这个 IDE,请点击 此处 。
首先是基础操作:左边是项目导航器,应该会显示项目的文件结构。如果没看到文件结构,请点击左上角的“ Projcet ”选项卡,弹出项目导航器。在项目导航器顶部,你很可能会看到标有 Android 的下拉菜单。点一下,然后在下拉菜单中选择“ Project ”选项。
- Android Studio 顶部的按钮行是工具栏。这一栏有许多功能,如构建、运行、调试运行、应用新更改以及启动 Android 虚拟设备(AVD)管理器。在它下面是导航栏,显示项目文件夹结构中当前打开文件的位置,在本例中为:
/GifGetter/GifLibrary/commonMain/kotlin/platform/common.kt。
-
以下是工具栏中的一些工具介绍:
-
锤子图形是 Build 按钮
-
旁边的下拉列表是 configurations
-
它旁边是 Run 按钮,就像在 Xcode 中一样
- Android 的官方文档把它叫做工具窗口之一,但人们一般叫它项目结构、文件结构、文件夹结构或类似术语。它和 Xcode 中的项目导航器是一个用途。
- 请注意顶部的 Project 下拉菜单。这里它应该指的是本教程使用的 Project 。如果它显示的是 Android 或别的什么内容,请重新选择 Project ,否则你的文件和文件夹可能会在下面的结构视图里显示不全。
-
这里是编辑器窗口,写代码用的。注意顶部的文件: android.kt 、 common.kt 、 ios.kt 和 GifLibrary ,这些都是当前打开的后台选项卡。右键单击其中一个选项卡就有选项可以在右侧、左侧、顶部或底部打开更多的编辑器窗口。
-
包在外面一圈的叫工具窗口栏,这里只用到黄圈中的选项卡,尤其是 Terminal 和 Build 这俩。
项目结构
下面介绍 KMP 的项目结构:
- **.gradle/ 和 .idea/** 文件夹:这里存放的是本地文件,不要把它们提交到多数 Android Studio 项目会使用的源码管理平台里。它们包括 IDE、项目和依赖项的本地设置,因此与本教程无关。
- androidApp/ 文件夹:这是整个 KMP 项目中 Android 应用项目的根目录,其中有 Android 端的所有 UI 逻辑。
- build/ 文件夹:存放 KMP 构建的输出。它也不要提交到源代码管理平台上。
- iosApp/ 文件夹:整个 KMP 项目中 Xcode 项目的根目录。
GifLibrary/ 文件夹:KMP 逻辑的主目录。它将作为 Android 项目的业务逻辑,然后 KMP 将它生成为 iOS 项目使用的 GifLibrary.framework 。其中 src/ 文件夹包含以下内容:
-
androidLibMain:Android 平台专用的 KMP 逻辑
-
commonMain:不需要任何平台专属代码的 KMP 业务逻辑
-
iosMain:iOS 平台专用的 KMP 逻辑,能够与 Apple API 交互(如 UIKit、GCD 等)
-
main:包含一个 AndroidManifest.xml 文件,该文件将 GifLibrary/ 定义为 Android 库。很大程度上它就像一个 Xcode.plist。
- build.gradle :这可能是一个新概念,因为 iOS 开发中没有直接对应的概念。某种层次上它是一个构建脚本或 makefile,用于定义项目的依赖项、脚本和其它设置。这样来看,它也有点像.xcodeproj 文件。
- External Libraries :项目的所有依赖项。Android 和 KMP 项目与 iOS 项目的不同之处在于前者需要的外部依赖项要多很多。
项目顶层的其它文件(如 local.properties 和所有.iml 文件)由 Android Studio 生成,与我们在 KMP 中的操作无关。另外, settings.gradle 是应提交到源代码控制中的配置文件。与 build.gradle 文件不同, settings.gradle 不是构建脚本,而是 gradle 的配置文件。
现在你已经了解了 Android Studio 的基础知识,该来研究 KMP 项目了!
为 KMP 配置 Gradle
- 注意:如前所述,KMP 仍然是实验性组件,因此这些 gradle 配置很容易发生变化。
打开 GifLibrary/build.gradle ,然后看 kotlin {}块:
复制代码
kotlin { targets { fromPreset(presets.android, 'androidLib') def buildForDevice = project.findProperty("device")?.toBoolean()?:false def iosPreset = (buildForDevice) ? presets.iosArm64 : presets.iosX64 def iOSTarget =System.getenv('SDK_NAME')?.startsWith('iphoneos')\ ? presets.iosArm64 : presets.iosX64 fromPreset(iosPreset, 'ios'){ binaries { framework { // Disable bitcode embedding for the simulator build. if(!buildForDevice) { embedBitcode("disable") } } } } fromPreset(iOSTarget, 'ios'){ compilations.main.outputKinds('FRAMEWORK') } } sourceSets { commonMain { dependencies { implementation"io.ktor:ktor-client-json:$ktor_version" } } }
这是新建项目常见的 KMP gradle 配置。首先看一下 kotlin {}块内的 targets {}块。简单来说,fromPreset() {}为 gradle 提供了 KMP 的预设配置。iOS 的预设需要额外的代码,因为设备和模拟器构建所需的预设不一样。
然后看一下 sourceSets {}块。现在只有 commonMain 是有其它依赖项的 sourceSet,但另外两个 sourceSet 也需要其它依赖项。在 commonMain {}之后将以下内容添加到 sourceSets {}:
复制代码
androidLibMain{ dependencies{ implementation"io.ktor:ktor-client-json-jvm:$ktor_version" } } iosMain { dependencies{ implementation"io.ktor:ktor-client-ios:$ktor_version" implementation"io.ktor:ktor-client-json-native:$ktor_version" } }
添加成功后应该会弹出一个消息框说: Gradle files have changed since the last project sync. A project sync may be necessary for the IDE to work properly (自上次项目同步以来 Gradle 文件已更改。IDE 可能需要项目同步才能正常工作)。暂时忽略此消息,因为这个 gradle 文件还要做很多改动。
现在三个必需的依赖项都已包含在 sourceSets 中了,但还需要最后一次更改才能让这个 gradle 准备就绪。在 kotlin {}块下面有一个名为 copyFramework {}的 task,它负责将 KMP 项目作为 iOS 框架复制到相应的文件夹中,这样 iosApp/ project 就能找到它了。
为了确保在项目构建时执行这些 task,请在 copyFramework {}之后插入下面这行:
复制代码
tasks.build.dependsOncopyFramework
现在点击 Sync Now 以更新 gradle 文件。需要下载新的依赖项时可能要花些时间。你可以在 Android Studio 底部的标签栏中查看 Gradle 同步的进度。
探索 Giphy 的 API
先去查阅 Giphy 的 API 并创建一个 Giphy 开发者帐户。如果你已经有帐户就登录进去。然后点击“ Create an App ”并在出现提示时输入“GifGetter”和描述信息。然后复制新生成的 Api 密钥。
现在返回 Android Studio 并打开 GifLibrary/src/commonMain/kotlin/GiphyAPI 并找到以下类:
复制代码
classGiphyAPI{ val apiKey:String =“” }
将你的 Giphy API 密钥粘贴到这个 val 语句的值中。这就是在 Kotlin 中声明类和字符串常量的方式。注意到目前为止,所有这些代码都可以用在 Swift 里,只有 val 关键字是例外,在 Kotlin 中它等同于 Swift 的 let。
平台专属代码
针对 iOS 和 Android 平台的差异部分,KMP 会告诉公共代码在各个平台会得到怎样的反馈。先转到 GifLibrary/src/commonMain/kotlin/platform/ 。点击 File → New → Kotlin File / Class ,然后创建一个新的 Kotlin 文件 / 类并将其命名为 common 。然后在下面的 Kinddropdown 菜单中点击 File。
这些 iOS 和 Android 应用需要做网络调用,因此他们需要运行异步代码。iOS 使用 Grand Central Dispatch(GCD)处理此类并发操作,但由于 Android 不使用 GCD 所以这部分代码不能进入公共部分。相对应的,你要告诉公共代码它们能从各个平台得到的 期望 expect。本例中,公共代码需要期望获得以下调度器 dispatcher:
复制代码
importkotlinx.coroutines.CoroutineDispatcher internalexpectvaldispatcher: CoroutineDispatcher
这里将出现错误,因为 iosMan/ 和 androidLibMain/ 组件现在 期望 声明一个类型为 CoroutineDispatcher 的常量命名 dispatcher。在 iOS 这边,转到 GifLibrary/src/iosMain/platform/ 并创建一个名为 iOS 的新文件粘贴到下面的代码里:
复制代码
internalactualvaldispatcher: CoroutineDispatcher = NsQueueDispatcher(dispatch_get_main_queue()) internalclassNsQueueDispatcher(privatevaldispatchQueue: dispatch_queue_t) : CoroutineDispatcher() { overridefundispatch(context:CoroutineContext, block:Runnable){ dispatch_async(dispatchQueue.freeze()) { block.run() } } }
这个逻辑是说,对于 iOS 平台,KMP 将使用 GCD 获取主队列来运行异步代码。Android 这边,转到 Android 模块中的对应文件夹: GifLibrary/src/androidLibMain/platform/ 。创建另一个 Kotlin 文件并将其命名为 Android 。所幸 Android 平台上可以轻松获取主调度器:
复制代码
internalactualvaldispatcher: CoroutineDispatcher = Dispatchers.Main
这样应该就不会出现编译错误了,因为 commonMain 对两个平台各自期望的调度器都已经获得了对应的实现。
准备数据
现在我们需要能够代表我们从 Giphy API 获得的 JSON 数据的类。去 GifLibrary/src/commonMain/kotlin/ 中创建一个新的 Kotlin 文件,将其命名为 Data 。将以下代码粘贴到新文件中:
复制代码
importkotlinx.serialization.Serializable @Serializable dataclassGifResult( val`data`:List ) @Serializable dataclassData( valimages:Images ) @Serializable dataclassImages( valoriginal:Original ) @Serializable dataclassOriginal( valurl:Stri
在这个文件顶部必须 导入 Serializable 以识别 数据类 前面的 @Serializable 语句。数据类可以视为 Swift 中的结构。它们不是值类型对象,但它们的行为很像后者。从 Giphy API 获得的 JSON 将映射到这些数据类上。现在,commonMain 已经为大部分业务逻辑做好了准备。
编写 KMP 业务逻辑
现在回到 GiphyAPI 文件并将以下代码粘贴到 apiKey 下面:
复制代码
privatevalclient: HttpClient = HttpClient {// 1 install(JsonFeature) {// 2 serializer =KotlinxSerializer(Json.nonstrict).apply {// 3 setMapper(GifResult::class, GifResult.serializer())// 4 } } } privatefunHttpRequestBuilder.apiUrl(path: String){// 5 url {// 6 takeFrom("https://api.giphy.com/")// 7 encodedPath = path// 8 } }
下面逐条解释这段代码的效果:
- 声明一个类型为 HttpClient 的常量。这一行代码与 Swift 中写法的唯一区别还是关键字 val,不是 Swift 中的 let。
- 将 JsonFeature 安装到 client 对象中,使其能够序列化和反序列化 JSON。
- 实例化并配置 serializer,这是 HttpClient 对象的一个属性,默认值为 null。Json.nonstrict 格式表示响应的 JSON 将包含与下一行设置的数据类无关的字段。
- 为 serializer 提供顶级数据类,然后它会将响应的 JSON 序列化为 GifResult 对象。JSON 中的“data”字段将填充对应的 GifResult.data 属性。这些字段将继续填充嵌套数据类的层次结构。
- 将函数 apiUrl(path:String) 添加到 HttpRequestBuilder 类。
- 构造一个 URL。
- 提供基础 URL。
- 使用指定的路径扩展 URL。
现在有了网络逻辑模版,就该对 Giphy 的端点做联网调用以获取“whoa”的搜索结果了。在你刚粘贴的网络代码下面添加以下代码:
复制代码
suspendfuncallGiphyAPI(): GifResult = client.get{ apiUrl(path ="v1/gifs/trending?api_key=$apiKey&limit=25&rating=G") }
等一下,suspend 关键字是哪里来的?有什么用?其实这是通过协程在 Kotlin 中执行异步代码的一种方法。现在你只要知道这里只能从一个协程或其它挂起函数调用该函数就行了,例如 HttpClient 上的 get {}函数。
下一步是设置可用于 iOS 和 Android 应用的函数。这里的语法又很接近 Swift:
复制代码
fungetGifUrls(callback: (List )->Unit) {// 1 GlobalScope.apply {// 2 launch(dispatcher) {// 3 valresult: GifResult = callGiphyAPI()// 4 valurls = result.data.map {// 5 it.images.original.url// 6 } callback(urls)// 7 } } }
- 声明函数 getGifUrls(callback: (List
) → Unit)。Android 和 iOS 代码将会调用这个函数。返回的 Unit 类型相当于 Swift 里的 void。
- 在 GlobalScope 的上下文中运行后面的代码,其中包括它自己的调度器和作业取消逻辑。
- 启动我们自己的 dispatcher,这里不用默认的调度器。记住,调度器的实现在各个平台是不一样的。
- 声明一个值,等于 callGiphyApi() 函数返回的值。
- 映射 GifResult 对象的数据列表;
- 映射完毕后就能获得每个 url 的属性了。url 是一个字符串,因此它将映射到 List
对象。Kotlin 中的 it 关键字表示 lambda 的参数,这里只有一个参数。
- 将这个 URL 列表传递给作为函数参数提供的回调。
在 Android 平台上实现 KMP
Android 应用的最后一步是使用从 Giphy API 获取的 URL 替换原来的硬编码 URL 列表。其实这也没那么难啦。
首先打开 androidApp/src/main/kotlin/MainActivity 文件。Android 的 Activity 与 ViewController 密切相关,这里 MainActivity 中实现的第一个函数是 onCreate(savedInstanceState:Bundle?),这与 viewDidLoad() 有点相似:
复制代码
overridefunonCreate(savedInstanceState: Bundle?){ super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) valgifList: RecyclerView = findViewById(R.id.gif_list) vallayoutManager =LinearLayoutManager(this) gifList.layoutManager = layoutManager adapter =GifAdapter(this) gifList.adapter = adapter adapter.setResults(urls)
删掉这个函数的最后一行(原来这行是用 fakedata 设定输出的),换成:
复制代码
getGifUrls { adapter.setResults(it) }
setResults(results: List
) 这个函数会将获取的 gif URL 设置为新的数据源并重新加载 RecyclerView,后者实际上是 UITableView 和 UICollectionView 的 Android 版本,不过它们还是有一些关键区别的。现在 Android 这边的所有逻辑就完成了!
如果它没能自动导入,你还遇到了未解决的引用错误,则可能需要将以下内容添加到文件顶部的导入列表中:
复制代码
import org.gifLibrary.GiphyAPI
在 Android 模拟器上运行
点击 Android Studio 顶部工具栏中的“Run”按钮。它的图标与 Xcode 的“运行”按钮很像。之后 Android 虚拟设备(AVD)管理器将显示一个窗口,类似 Xcode 管理模拟器的那个窗口。不同之处在于 Xcode 里的模拟器是现成的,无需再下载。
在新窗口中,如果你在列表中看到有什么可用的 Android 设备就选择它运行应用。否则就点击“ Create New Virtual Device ”。在设备列表中选择 Phone 类别和 Pixel 2 XL ,然后点击右下方的 Next 。
接下来选择 Android 最新版本旁边的 Download ,这样会下载 API 级别最高的系统版本。然后会出现另一个窗口来安装所选的 Android 版本。要是没看到下载按钮说明你已经下好了,那就选择该版本,然后再点击 Next 。等系统下载完以后点击 Finish 就行了。
Android 这边就搞定了!好好庆祝一下,体验自己的成果吧。接下来回到熟悉的领域。
Xcode 设置
打开 Xcode 项目并转到 iosApp 目标的 Build Phases 并点击 **+**,就是下图的红圈部分:
从弹出的列表中选择“ New Run Script Phase ”选项。你会在主窗口的列表末尾看到一个新的 Run Script 。将其向上拖动到列表中,使其位于 Compile Sources 之上。单击它旁边的箭头并粘贴以下脚本:
复制代码
cd"$SRCROOT/../" ./gradlew GifLibrary:copyFramework \ -Pconfiguration.build.dir="$CONFIGURATION_BUILD_DIR"\ -Pkotlin.build.type="$KOTLIN_BUILD_TYPE"\ -Pdevice="$KOTLIN_DEVICE"
这个运行脚本会读取 Xcode 项目的 Build Settings 中的定义,以便在 copyFramework gradle 任务中使用,回到 GifGetter/GifLibrary/build.gradle 中就能看到这个任务。但 Xcode 项目还没有这些设置。为了创建这些设置,请转到目标的 Build Settings 选项卡,然后点击 **+** 按钮,如下所示:
在弹出的小下拉菜单中选择 Add User-Defined Setting 。将新设置命名为“KOTLIN_BUILD_TYPE”。单击它旁边的箭头以显示 Debug 和 Release 环境。为 Debug 赋值“DEBUG”,为 Release 赋值“RELEASE”。
添加另一个用户定义的设置,并将其命名为“KOTLIN_DEVICE”。然后单击 Debug 右侧的 **+ 按钮。这将在 Debug 下创建一个字段,表示 Any Architecture** | Any SDK 。单击它以查看架构选项列表,然后选择 Any iOS Simulator SDK 。为该字段赋值 false 。对 Debug 做 相同操作,但这次添加的是 Any iOS SDK 并将其赋值为 true 。
在“KOTLIN_DEVICE”下的 Release 中重复这些步骤。最后,你的 User-Defined Settings 应该变成这样:
然后在右上角的搜索栏中搜索“Framework Search Paths”:
将以下内容添加到 Debug 和 Release 的 Framework Search Paths :
复制代码
"$(SRCROOT)/../GifLibrary/build"
KMP 会把 **.framework 文件输出到这里,这个文件包含 iOS 应用所需的业务逻辑。在 Finder 中访问 GifGetter/GifLibrary/build/ ,你会看到 GifLibrary.framework** 文件已准备好供 Xcode 使用了。
然后转到 iOSApp 目标的“ General ”选项卡,滚动到底部,然后 Embedded 点击 Embedded Binaries 下的 **+ 按钮。在弹出的窗口中点击底部的“Add Other”;在弹出的 Finder 窗口中选择 GifGetter/GifLibrary/build/ 并选中 GifLibrary.framework 文件,然后按点击“ Open ”。这样就会把框架添加到 Linked Binaries and Frameworks** 了。
打开 GifRetriever.swift ,你会看到一个包含 25 个 URL 字符串的硬编码数组。在它下面是 requestGifs(_closure: @escaping StringsClosure) 函数,负责传回硬编码 URL。在文件顶部的 import Foundation 语句下添加 import GifLibrary。
现在替换 requestGifs(_closure: @escaping StringsClosure) 函数的主体,像这样:
复制代码
funcrequestGifs(_closure:@escapingStringsClosure) { GiphyAPI().getGifUrls{gifs->KotlinUnitin closure(gifs) returnKotlinUnit() } }
iOS 应用也可以进模拟器运行了! 现在你已经在两个平台上部署了同一个应用,两个版本通过 KMP 共享网络逻辑!
结语
可以在 GitHub 上访问 已完成的项目 以供参考。请记住,KMP 仍然是实验性组件,很容易发生变化,而且它在迅速发展和进化。不仅如此, 已经有 App Store 中的现实应用成功应用 KMP 了。对于想要做跨平台开发的 iOS 开发者来说 KMP 是首选。
KMP 不仅是 iOS 工程师成为跨平台移动开发者的捷径,你还可以用它创建业务逻辑库,然后将其部署到其它平台。如果你希望将应用部署到 Javascript 环境,这个业务逻辑库就能派上用场了。
如果你想深入了解 KMP 或 Kotlin,请查看 这些资源 ,其中包括 Kotlin reddit 版块和 Slack 频道的链接。美国东部时间 2019 年 6 月 4 日下午 1 点到 2 点,Touchlab 将举办一次 网络研讨会 ,主题与本文相同。最后,下面是一些关于 KMP 的文章介绍以供查阅:
作者介绍
Ben Whitley是纽约的一名 iOS 开发者,拥有 4 年的 Xcode、Swift 和 Objective-C 工作经验。在过去的一年半中,他一直是 Touchlab 的主力 iOS 开发人员,在此期间他与多家知名客户密切合作过。他目前致力于将 Kotlin Multiplatform 发展为 iOS 开发者的原生平台,并帮助 Android 开发者将他们的 iOS 和 Xcode 相关问题转化到 KMP 上解决。