为什么我们选择Kotlin作为我们的目标语言之一。第2部分:Kotlin多平台

我们继续介绍将Kotlin语言引入我们的开发过程的系列文章。在这里寻找第一部分

2017年,Jetbrains雄心勃勃的项目崭露头角,为跨平台开发提供了新视角。将kotlin代码编译成各种平台的本机代码!反过来,在Domklik,我们一直在寻找优化开发过程的方法。我们认为,有什么比重用代码更好?是的-完全不用编写代码。这样,一切都可以按您想要的方式工作。但是虽然这不会发生。如果有一种解决方案可以让我们无需花费太多精力就可以将单个代码库用于不同的平台,那为什么不尝试呢?

大家好!我的名字叫Gennady Vasilkov,我是Domklik公司的一名Android开发人员,今天我想与大家分享我们在为移动设备开发Kotlin Multiplatform方面的经验,告诉我们遇到了什么困难,如何解决以及最终得到了什么。想要尝试Kotlin MPP(多平台项目)或已经尝试过但尚未完成生产的人肯定会对该主题感兴趣。还是带来了,但不是我想要的。我将尝试传达我们对如何安排开发和交付已开发库的过程的看法(以其中一个为例,我将告诉您Kotlin MPP开发道路的开始)。

您想要我们如何做到的故事吗?我们有他们!



简要介绍技术


对于那些尚未听说或尚未沉迷于该主题的人,它挂在Java世界中,而直接进入Kotlin世界(或者不走,只是窥视一眼):Kotlin MPP是一项技术,使您可以一次在多个平台上使用一次编写的代码。

毫无疑问,使用IntelliJ IDEA或Android Studio中的Kotlin语言进行了开发。优点是所有android开发人员(至少与我们在一起)都知道并喜欢这种语言以及这些出色的开发环境。

将结果代码编译成每个平台的本地语言(OBJ-C,JS,使用JVM一切都清楚)也是一大优势。

也就是说,一切都很棒。我认为,Kotlin Multiplatform是将业务逻辑带入库的理想选择。也可以完全编写一个应用程序,但是现在看来太极端了,但是库开发适合像我们这样的大型项目。

一些技术知识,以了解该项目如何在Kotlin MPP上运行


  • 构建系统是Gradle,它支持groovy和kotlin脚本(kts)中的语法。
  • Kotlin MPP具有目标的概念-目标平台。在这些块中,配置了我们需要的操作系统,其本机代码将在Kotlin上编译我们的代码。在下面的图片中,实现了两个目标,即jvm和js(图片部分摘自网站):

    对其他平台的支持以相同的方式实现。
  • 源代码集-名称本身很清楚,平台的源代码存储在这里。有一个通用的源集和平台(项目中有许多源和平台,而IDE紧随其后)。这里不可能不提到期望实际机制。



    该机制允许从Common模块访问特定于平台的代码。我们在Common模块中声明Expect声明,并在平台模块中实现它。以下是使用该机制获取设备上日期的示例:

    Common:
    internal expect val timestamp: Long
    
    Android/JVM:
    internal actual val timestamp: Long 
        get() = java.lang.System.currentTimeMillis()
    
    iOS:
    internal actual val timestamp: Long 
        get() = platform.Foundation.NSDate().timeIntervalSince1970.toLong()
    

    如您所见,对于平台模块,分别有Java和iOS Foundation系统库。

这些要点足以理解稍后将要讨论的内容。

我们的经验


因此,在某个时候,我们决定一切都被Kotlin MPP(当时也称为Kotlin / Native)接受为标准,并且我们开始编写共享通用代码的库。最初,它只是针对移动平台的代码,在某些时候,他们添加了对jvm后端的支持。在android上,已开发库的开发和发布不会引起问题,在iOS上,它们在实践中遇到了一些问题,但已成功解决,并开发了用于开发和发布框架的工作模型。

是时候闲逛了!


我们已经在主项目中使用的单独的库中执行了各种功能。

1)分析


发生的背景


所有移动应用程序都收集大量分析数据已不是什么秘密。事件挂有所有重要事件,而不是非常重要的事件(例如,在我们的应用程序中,收集了600多种不同的指标)。什么是指标收集?以一种简单的方式,这是对一个函数的调用,该函数将具有特定键的事件发送到分析引擎的肠道。然后,它进入了各种分析系统,例如firebase,appmetrica等。这有什么问题?在两个平台上不断重复相同(正负)代码!是的,在任何地方都找不到人为因素-开发人员可能会误认为密钥的名称以及随事件发送的元数据集。这显然需要编写一次并在每个平台上使用。制造通用逻辑和测试技术的理想人选(这是我们在Kotlin Mpp中使用的测试笔)。

如何实施


我们将事件本身转移到库中(以带有有线键的函数形式和参数中一组必需的元数据的形式),并编写了处理这些事件的新逻辑。事件格式是统一的,并且创建了处理程序引擎(总线),所有事件都注入了该引擎。对于各种分析系统,适配器侦听器写道(事实证明,一个非常有用的解决方案是,在添加任何新分析时,我们可以轻松地将所有事件重定向到新的分析,或将每个事件选择性地重定向到新的分析)。



在实施过程中有趣


由于在Kotlin / Native for iOS中使用流的实施实现的功能,您不能随便将Kotlin对象作为一个实例来使用和处理,并随时在其中写入数据。所有在线程之间转移状态的对象都必须冻结(为此有一个freeze()函数)。但是,除其他事项外,该库存储的状态可能会在应用程序的生命周期内发生变化。有几种方法可以解决这种情况,我们选择了最简单的方法:

Common:

expect class AtomicReference<T>(value_: T) {
    var value: T
}

Android:

actual class AtomicReference<T> actual constructor(value_: T) {
    actual var value: T = value_
}

iOS:

actual typealias AtomicReference<V> = kotlin.native.concurrent.AtomicReference<V>

此选项适用于简单的状态存储。在我们的情况下,平台配置存储:

object Config {
    val platform = AtomicReference<String?>("")
    var manufacturer = AtomicReference<String?>("")
    var model = AtomicReference<String?>("")
    var deviceId = AtomicReference<String?>("")
    var appVersion = AtomicReference<String?>("")
    var sessionId = AtomicReference<String?>("")
    var debug = AtomicReference<Boolean>(false)
}

为什么需要这个?当模块应该已经加载并且其对象已经处于冻结状态时,我们在应用程序启动时没有得到任何信息。为了将此信息添加到配置中,然后可以从iOS上的不同流中读取它,因此需要进行此操作。

我们还面临着发布图书馆的问题。而且,如果Android对此没有任何问题,那么出于多种原因,当时官方文档提出的将组装好的框架直接连接到iOS项目的方法不适合我们,我想获得尽可能简单和透明的新版本。此外,还支持版本控制。
解决方案是通过Pod文件连接框架。为此,他们生成了podspec文件和框架本身,将它们放在存储库中,并将库连接到项目对于iOS开发人员而言变得非常简单和方便。同样,为了方便开发,我们为所有体系结构收集了一个工件,即所谓的胖框架(实际上是一个粗体二进制文件,其中所有体系结构和由kotlin插件生成的添加元文件在一起)。扰流板下这件事的实现:

谁在乎我们如何在正式决定之前做到这一点
在kotlin插件为我们收集了许多用于不同架构的不同框架之后,我们手动创建了一个新的通用框架,我们可以从该通用框架中复制任何元数据(在本例中为arm64):
//add meta files (headers, modules and plist) from arm64 platform
task copyMeta() {
    dependsOn build

    doLast {
        copy {
            from("$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework")
            into "$buildDir/iphone_universal/${frameworkName}.framework"
        }
    }
}

, , , . lipo, :
//merge binary files into one
task lipo(type: Exec) {
    dependsOn copyMeta

    def frameworks = files(
            "$buildDir/bin/iphone32/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphoneSim/main/release/framework/${frameworkName}.framework/$frameworkName"
    )
    def output = file("$buildDir/iphone_universal/${frameworkName}.framework/$frameworkName")
    inputs.files frameworks
    outputs.file output
    executable = 'lipo'
    args = frameworks.files
    args += ['-create', '-output', output]
}

, - Kotlin MPP Info.plist UIRequiredDeviceCapabilities, ( ). PlistBuddy:
//workaround
//remove UIRequiredDeviceCapabilities key from plist file (because we copy this file from arm64, only arm64 architecture was available)
task editPlistFile(type: Exec) {
    dependsOn lipo

    executable = "/bin/sh"
    def script = './scripts/edit_plist_file.sh'
    def command = "Delete :UIRequiredDeviceCapabilities"
    def file = "$buildDir/iphone_universal/${frameworkName}.framework/Info.plist"

    args += [script, command, file]
}

edit_plist_file.sh:
/usr/libexec/PlistBuddy -c "$1" "$2"

, zip ( podspec ):
task zipIosFramework(type: Zip) {
    dependsOn editPlistFile
    from "$buildDir/iphone_universal/"
    include '**/*'
    archiveName = iosArtifactName
    destinationDir(file("$buildDir/iphone_universal_zipped/"))
}

podspec :
task generatePodspecFile(type: Exec) {
    dependsOn zipIosFramework

    executable = "/bin/sh"

    def script = './scripts/generate_podspec_file.sh'

    def templateStr = "version_name"
    def sourceFile = './podspec/template.podspec'
    def replaceStr = "$version"

    args += [script, templateStr, replaceStr, sourceFile, generatedPodspecFile]
}

generate_podscpec_file.sh :
sed -e s/"$1"/"$2"/g <"$3" >"$4"

curl :
task uploadIosFrameworkToNexus(type: Exec) {
    dependsOn generatePodspecFile

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $buildDir/iphone_universal_zipped/$iosArtifactName $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

task uploadPodspecFileToNexus(type: Exec) {
    dependsOn uploadIosFrameworkToNexus

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $generatedPodspecFile $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

:
task createAndUploadUniversalIosFramework() {
    dependsOn uploadPodspecFileToNexus
    doLast {
        println 'createAndUploadUniversalIosFramework complete'
    }
}


Profit!


在撰写本文时,存在用于创建胖框架和生成podspec文件(通常与CocoaPods集成)的官方解决方案。因此,我们只需要将创建的框架和podspec文件上传到存储库,并以与以前相同的方式在iOS中连接项目:




在实现了一个用于存储事件的单一位置,一种用于处理和发送指标的机制的方案之后,我们添加新指标的过程简化为一个开发人员向该库添加了一个新功能,该功能描述了事件密钥以及开发人员必须针对特定事件传输的必要元数据。 。此外,另一个平台的开发人员只需下载该库的新版本并在正确的位置使用该新功能。维护整个分析系统的一致性变得更加容易;现在更改事件总线的内部实现也变得更加容易。例如,在某个时候,对于我们的一位分析师而言,我们有必要实现自己的事件缓冲区,该事件缓冲区在库中立即完成,而无需在平台上更改代码。利润!

我们将继续将越来越多的代码转移到Kotlin MPP,在接下来的文章中,我将继续使用两个另外的库作为示例来介绍跨平台开发领域的故事,其中使用了序列化和数据库,并且需要时间。我将告诉您有关在一个iOS项目中使用多个kotlin框架时遇到的限制以及如何解决此限制。

现在简要介绍一下我们的研究结果


+一切在生产中都能稳定运行。
+适用于所有平台的业务服务逻辑的统一代码库(更少的错误和差异)。
+降低了支持成本,并缩短了新要求的开发时间。
+我们增加了开发人员的专业知识。

相关链接


Kotlin Multiplatform网站:www.jetbrains.com/lp/mobilecrossplatform

ZY:
我不想重复讲究这种语言的简洁性,因此在编写代码时将重点放在业务逻辑上。我可以建议

涉及此主题的几篇文章:“引擎盖下的Kotlin-参见反编译的字节码”-描述Kotlin编译器操作并涵盖该语言基本结构的文章。
habr.com/en/post/425077
“上一篇文章,Kotlin,字节码和性能的编译”两部分。
habr.com/ru/company/inforion/blog/330060
habr.com/ru/company/inforion/blog/330064
帕夏·芬克尔斯坦(Pasha Finkelstein)的报告“科特林-两年的生产,没有任何差距”,该报告基于在我们公司中使用和实施科特林的经验。
www.youtube.com/watch?v=nCDWb7O1ZW4

All Articles