攜程機票 App Kotlin Multiplatform 初探 [復制鏈接]

2019-6-13 10:00
kengsirLi 閱讀:320 評論:0 贊:1
Tag:  
作者: 陳琦 攜程技術


從2017年9月到2019年5月,經過一年半的努力,攜程機票App團隊完成 90% 從 Native 到 Ctrip React Native (CRN) 的技術棧轉型。


2019年初,我們開始思考下一步規劃。


除了繼續做深 React Native 技術,更快更穩定的迭代交付機票業務需求,優化用戶體驗,我們需要從具體業務邏輯實現層次抽離出來,從一個完整的應用程序架構設計和實現的角度,尋找跨平臺技術的未來方向。


React Native 和 Flutter 這類大前端技術方案已經可以很好的支撐用戶界面和組件,業務邏輯需求功能的實現,但是單線程動態腳本語言在以下領域仍顯不足。


  • 靈活調用強大的平臺/廠商 API (AI, AR, mult-core GPU, ...)

  • 高性能計算

  • 多線程處理

  • 后臺任務

  • 低功耗


我們希望能夠找到一種可靠的跨平臺,原生,或能夠與原生 API 進行靈活自由雙向互操作的技術方案。經過一段時間的針對 Kotlin 及相關開源社區的調研,觀察,實踐,Kotlin Multiplatform 技術在這方面展現出了良好的發展潛力。


一、Native multiplatform



傳統主流的跨平臺原生方案是 C/C++,目前依然是最被廣泛使用的。 React Native 和 Flutter 的底層實現也是如此。



Kotlin Multiplatform 的跨平臺遷移如下圖。



二、Kotlin Native



了解 Kotlin Multiplatform 需要先從 Kotlin Native 入手。相比 Kotlin/JVM,Kotlin Native 使用 Kotlin 語言編譯器,配合 LLVM backend,將 Kotlin 代碼編譯為平臺原生二進制文件,不依賴虛擬機或運行時環境。當前 LLVM 版本 6.0.1 。官方正在將編譯方案從 LLVM 的backend 轉移到 frontend (clang) 。


目前已支持的平臺:


  • iOS 9.0+ (arm32, arm64, x86_64 模擬器)

  • macOS (x86_64)

  • Android (arm32, arm64) ,編譯生成 Linux SO 文件

  • Windows (mingw x86_64, x86)

  • Linux (x86_64, arm32, MIPS, MIPS little endian, Raspberry Pi)

  • WebAssembly (wasm32)


三、Kotlin Native 與 C 雙向互操作



3.1 cinterop


Kotlin Native 官方附帶工具,用于快速生成Kotlin與平臺C庫互相調用操作所需的內容。


首先創建一個.def文件,描述需要包含在語言綁定的內容。


然后使用 cinterop 分析C頭文件,映射生成 Kotlin 語言的類型,函數和常量,完成Kotlin綁定。


最后通過LLVM編譯器鏈接生成最終的可執行文件 *.kexe 或庫文件 *.klib。


kexe 是平臺相關的可執行程序文件格式。


klib 是平臺相關的庫文件格式,類似 JAR 的ZIP格式,細節詳見官網文檔:https://kotlinlang.org/docs/reference/native/libraries.html#the-library-format


解壓后的文件夾結構如下:


- foo/- targets/    - $platform/    - kotlin/        - Kotlin compiled to LLVM bitcode.    - native/        - Bitcode files of additional native objects.    - $another_platform/    - There can be several platform specific kotlin and native pairs.- linkdata/    - A set of ProtoBuf files with serialized linkage metadata.- resources/    - General resources such as images. (Not used yet).- manifest - A file in *java property* format describing the library.


3.2 平臺庫


大多數情況下,我們并不需要使用 cinterop 手動生成所有所需的C庫綁定。


Kotlin Native SDK已經提供了大部分平臺的原生庫綁定。例如:


  • Linux POSIX

  • Windows Win32

  • macOS/iOS Apple Framework, POSIX

  • 以及各平臺的常用熱門庫,OpenGL, zlib 等


Kotlin Native 在本機開發時默認下載到 ~/.konan/ 文件夾,例如 ~/.konan/kotlin-native-macos-1.2.1/, 平臺庫文件位于~/.konan/kotlin-native-macos-1.2.1/klib/platform/,已包含以下內容,可見大部分平臺SDK都已預處理完成。


Android Native Arm32


├── android├── android_arm32.tree.txt├── builtin├── egl├── gles├── gles2├── gles3├── glesCommon├── linux├── media├── omxal├── posix├── sles└── zlib

13 directories, 1 file


iOS Arm64


├── ARKit├── AVFoundation├── AVKit├── Accelerate├── Accounts├── AdSupport├── AddressBook├── AddressBookUI├── AssetsLibrary├── AudioToolbox├── AuthenticationServices├── BusinessChat├── CFNetwork├── CallKit├── CarPlay├── ClassKit├── CloudKit├── CommonCrypto├── Contacts├── ContactsUI├── CoreAudio├── CoreAudioKit├── CoreBluetooth├── CoreData├── CoreFoundation├── CoreGraphics├── CoreImage├── CoreLocation├── CoreMIDI├── CoreML├── CoreMedia├── CoreMotion├── CoreNFC├── CoreServices├── CoreSpotlight├── CoreTelephony├── CoreText├── CoreVideo├── DeviceCheck├── EAGL├── EventKit├── EventKitUI├── ExternalAccessory├── FileProvider├── FileProviderUI├── Foundation├── GLKit├── GSS├── GameController├── GameKit├── GameplayKit├── HealthKit├── HealthKitUI├── HomeKit├── IOSurface├── IdentityLookup├── IdentityLookupUI├── ImageIO├── Intents├── IntentsUI├── LocalAuthentication├── MapKit├── MediaAccessibility├── MediaPlayer├── MediaToolbox├── MessageUI├── Messages├── Metal├── MetalKit├── MetalPerformanceShaders├── MobileCoreServices├── ModelIO├── MultipeerConnectivity├── NaturalLanguage├── Network├── NetworkExtension├── NewsstandKit├── NotificationCenter├── OpenAL├── OpenGLES├── OpenGLES2├── OpenGLES3├── OpenGLESCommon├── PDFKit├── PassKit├── Photos├── PhotosUI├── PushKit├── QuartzCore├── QuickLook├── ReplayKit├── SafariServices├── SceneKit├── Security├── Social├── Speech├── SpriteKit├── StoreKit├── SystemConfiguration├── Twitter├── UIKit├── UserNotifications├── UserNotificationsUI├── VideoSubscriberAccount├── VideoToolbox├── Vision├── WatchConnectivity├── WatchKit├── WebKit├── builtin├── darwin├── iAd├── iconv├── ios_arm64.tree.txt├── objc├── posix└── zlib

116 directories, 1 file


四、Kotlin Native 與 Swift/Objective-C 雙向互操作



基于cinteroop,增加了面向對象的映射。細節詳見官網文檔:https://kotlinlang.org/docs/reference/native/objc_interop.html#mappings



五、Kotlin Multiplatform



Kotlin 1.3 重新設計了多平臺工程項目架構,以提高工程結構的靈活性和擴展性,更容易共享復用Kotlin代碼。


Kotlin Native 變成 Kotlin Multiplatfrom 的目標平臺之一, 相關庫和插件轉為內部實現。


例如對于應用程序開發人員使用的Gradle插件,從org.jetbrains.kotlin.konan 變更為 org.jetbrains.kotlin.multiplatform。


konan 變成 multiplatform 內部引用的依賴庫,創建Kotlin Gradle工程時后臺自動下載并保持在本機 ~/.konan/ 文件夾。


為了減少開發人員的誤解,對外提供的SDK版本號都統一跟隨Kotlin語言版本號。


例如,Kotlin語言最新版本是1.3.31,各平臺庫SDK版本號也一樣,而 kotlin native macos 1.2.1 僅用于Kotlin內部開發人員的版本Tag,對使用者透明,我們無需關心。


因此網上搜索得到的大部分基于konan的文章教程和GitHub源碼均已過時,需留意gradle配置中是否基于multiplatform plugin。包括官網部分文檔。


IntelliJ IDEA 提供了 Kotlin Mulitplatform的工程模版。實際上IDEA + Android SDK 可以替代Android Studio 99%的開發工作。針對Native平臺有4種模版,大同小異,區別僅是Gradle Modue結構略有不同。



1)Kotlin/Native 模版是針對單一平臺的最小化工程模版。


2)Kotlin (Mobile Android/iOS) 模版沿用Android工程的默認結構,將Android主工程,Kotlin Common代碼集放在root/app/src,根目錄額外增加了一個iOS主工程文件夾。


3)Kotlin (Multiplatform Library) 和 Kotlin (Mobile Shared Library)  非常相似,可以簡單的認為后者是前者的子集。


前者包含Kotlin/JS和3個Native (macOS,Windows,Linux) 平臺。


后者僅包含Android,iOS 2個平臺。


其中僅有一處細微差異。前者jvmMain模塊依賴stdlib-jdk8,后者jvmMain模塊依賴stdlib。即前者JVM運行環境是Java服務端,后者JVM運行環境是Android設備。


4)Kotlin(Mobile Shared Library) 是最簡結構,文件夾如下。


├── build.gradle├── gradle│   └── wrapper│       └── gradle-wrapper.properties├── gradle.properties├── settings.gradle└── src    ├── commonMain    │   ├── kotlin    │   │   └── sample    │   │       └── Sample.kt    │   └── resources    ├── commonTest    │   ├── kotlin    │   │   └── sample    │   │       └── SampleTests.kt    │   └── resources    ├── iosMain    │   ├── kotlin    │   │   └── sample    │   │       └── SampleIos.kt    │   └── resources    ├── iosTest    │   ├── kotlin    │   │   └── sample    │   │       └── SampleTestsNative.kt    │   └── resources    ├── jvmMain    │   ├── kotlin    │   │   └── sample    │   │       └── SampleJvm.kt    │   └── resources    └── jvmTest        ├── kotlin        │   └── sample        │       └── SampleTestsJVM.kt        └── resources

27 directories, 10 files


接下來我們需要首先解決一些平臺相關的上手問題,結合一些簡單且實際存在的小場景,探索Kotlin Multiplatform的實踐現狀。


六、場景1:Logger



打印輸出日志是任何平臺和技術棧的開發人員每天面對的簡單且必需的功能。我們首先看看各平臺的基礎方案。大致都是定義好日志級別,提供出全局單例或靜態方法API。


6.1 Java Logger


Since Java 1.4


import java.util.logging.Logger

private val logger = Logger.getLogger("Module")

fun javaLog() { logger.fine("Fine Msg") logger.config("Config Msg") logger.info("Info Msg") logger.warning("Warning Msg") logger.severe("Error Msg")}


6.2 Android Log


Since API Level 1


import android.util.Log

private val tag = "Module"

fun androidLog() { Log.v(tag, "Verbose Msg") Log.d(tag, "Debug Msg") Log.i(tag, "Info Msg") Log.w(tag, "Warning Msg") Log.e(tag, "Error Msg")}


實際在Android日常開發中,我個人更傾向于使用Java Logger,相比Android.util.Log,Java Logger可以通過注冊 ConsoleHandler, FileHandler, SockeHandler, StreamHandler,配合 SimpleFormatter, XMLFormatter,將日志轉存到手機本地文件或后端服務器,供后續分析,以及每一行日志代碼可以少寫一個Tag參數。


6.3 iOS os_log


忽略 C print 和 Objective-C NSLog,僅看 iOS 10提供的 unified logging system。


void iOSLog(){    os_log(OS_LOG_DEFAULT, "Msg");    os_log_fault(OS_LOG_DEFAULT, "Fault Msg");    os_log_error(OS_LOG_DEFAULT, "Error Msg");    os_log_info(OS_LOG_DEFAULT, "Info Msg");    os_log_debug(OS_LOG_DEFAULT, "Debug Msg");    os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_DEFAULT, "Msg with type");}


6.4 Kotlin Log - Common Expect


參考 android.util.Log,復制一份 Kotlin Common 模塊的聲明。


跨平臺公共模塊的實現,除了使用常規的 Interface/Implementation 方案,Kotlin 提供了 expect/actual 聲明語法。


這里使用 expect 關鍵字聲明一個單例Log對象的預期。其類成員方法等同于Java的純虛方法。


/** * Keep consistent with android.utl.log constant level. * */enum class Level(val value: Int) {    VERBOSE(2),    DEBUG(3),    INFO(4),    WARN(5),    ERROR(6),    ASSERT(7)}

expect object Log { fun isLoggable(tag: String, level: Level): Boolean fun v(tag: String, msg: String) fun d(tag: String, msg: String) fun i(tag: String, msg: String) fun w(tag: String, msg: String) fun e(tag: String, msg: String) fun wtf(tag: String, msg: String)}


6.5 Kotlin Log - Android Actual


Android平臺的實現直接綁定android.util.Log。


// Kotlin Android Implementationactual object Log {    actual fun isLoggable(tag: String, level: Level): Boolean = android.util.Log.isLoggable(tag, level.value)    actual fun v(tag: String, msg: String) { android.util.Log.v(tag, msg) }    actual fun d(tag: String, msg: String) { android.util.Log.d(tag, msg) }    actual fun i(tag: String, msg: String) { android.util.Log.i(tag, msg) }    actual fun w(tag: String, msg: String) { android.util.Log.w(tag, msg) }    actual fun e(tag: String, msg: String) { android.util.Log.e(tag, msg) }    actual fun wtf(tag: String, msg: String) { android.util.Log.wtf(tag, msg) }}


6.6 Kotlin Log - iOS Actual


由于cinterop工具僅處理C庫的實例和函數的綁定,不能實現 macro宏定義的綁定。而os_log()提供的常用API實際是 macro宏定義,所以我們需要找到其內部實際調用的函數 _os_log_internal()。這對新手可能是一個小坑,在未詳細了解平臺API的情況下,在Kotlin平臺庫中費時費力查找綁定方法無果。


// <os/log.h> declaration#define os_log(log, format, ...) \        os_log_with_type(log, OS_LOG_TYPE_DEFAULT, format, ##__VA_ARGS__)

#define os_log_with_type(log, type, format, ...) __extension__({ \ _Pragma("clang diagnostic push") \ _Pragma("clang diagnostic error \"-Wformat\"") \ _Static_assert(__builtin_constant_p(format), "format argument must be a string constant"); \ _os_log_internal(&__dso_handle, log, type, format, ##__VA_ARGS__); \ _Pragma("clang diagnostic pop") \})


_os_log_internal() 的第一個參數 __dso_handle 定義如下。這里有趣了,我們需要調用它的內存地址指針,kotlinx.cinterop.ptr就是為此而生。


// <os/trace_base.h> declarationextern struct mach_header __dso_handle;


下面這段實現是純Kotlin語言代碼,是上文中 Kotlin Common 的Log單例對象 expect 聲明對應的 actual實現。調用 Kotlin Native iOS平臺庫已提供好的 os_log API綁定。


// Kotlin iOS Implementationimport kotlinx.cinterop.ptrimport platform.darwin.*

actual object Log { actual fun isLoggable(tag: String, level: Level): Boolean = os_log_type_enabled(OS_LOG_DEFAULT, level.toPlatform()) actual fun v(tag: String, msg: String) = _os_log_internal(__dso_handle.ptr, OS_LOG_DEFAULT, OS_LOG_TYPE_DEFAULT, "$tag | $msg") actual fun d(tag: String, msg: String) = _os_log_internal(__dso_handle.ptr, OS_LOG_DEFAULT, OS_LOG_TYPE_DEBUG, "$tag | $msg") actual fun i(tag: String, msg: String) = _os_log_internal(__dso_handle.ptr, OS_LOG_DEFAULT, OS_LOG_TYPE_INFO, "$tag | $msg") actual fun w(tag: String, msg: String) = _os_log_internal(__dso_handle.ptr, OS_LOG_DEFAULT, OS_LOG_TYPE_INFO, "$tag | $msg") actual fun e(tag: String, msg: String) = _os_log_internal(__dso_handle.ptr, OS_LOG_DEFAULT, OS_LOG_TYPE_ERROR, "$tag | $msg") actual fun wtf(tag: String, msg: String) = _os_log_internal(__dso_handle.ptr, OS_LOG_DEFAULT, OS_LOG_TYPE_FAULT, "$tag | $msg")}


6.7 Kotlin Log - AndroidNativeArm Actual


大部分業務場景中,我們使用Kotlin/JVM實現Android平臺的功能,Kotlin Native for AndroidNativeArm32/64 可用但仍不好用。我們簡單看看其實現。


與iOS Native類似,只需在build.gradle文件中添加相應Target。這里使用了Gradle Kotlin DSL新版本。使用Kotlin編寫Gradle配置文件的體驗比Groovy更佳,IDE支持語法高亮,自動補全,代碼跳轉,編譯提示等便捷功能。


androidNativeArm32() {        binaries {            sharedLib()// or staticLib() or executable()        }    }


Android NDK Log API


/** * Writes the constant string `text` to the log, with priority `prio` and tag * `tag`. */int __android_log_write(int prio, const char* tag, const char* text);

/** * Writes a formatted string to the log, with priority `prio` and tag `tag`. * The details of formatting are the same as for * [printf(3)](http://man7.org/linux/man-pages/man3/printf.3.html). */int __android_log_print(int prio, const char* tag, const char* fmt, ...)#if defined(__GNUC__) __attribute__((__format__(printf, 3, 4)))#endif ;


繼續純Kotlin語言實現。


// Kotlin AndroidNativeArm Implementationimport platform.android.*

@kotlin.ExperimentalUnsignedTypesactual object Log { actual fun isLoggable(tag: String, level: Level): Boolean = level >= Level.INFO

actual fun v(tag: String, msg: String) { __android_log_write(ANDROID_LOG_VERBOSE.toInt(), tag, msg) } actual fun d(tag: String, msg: String) { __android_log_write(ANDROID_LOG_DEBUG.toInt(), tag, msg) } actual fun i(tag: String, msg: String) { __android_log_write(ANDROID_LOG_INFO.toInt(), tag, msg) } actual fun w(tag: String, msg: String) { __android_log_write(ANDROID_LOG_WARN.toInt(), tag, msg) } actual fun e(tag: String, msg: String) { __android_log_write(ANDROID_LOG_ERROR.toInt(), tag, msg) } actual fun wtf(tag: String, msg: String) { __android_log_write(ANDROID_LOG_FATAL.toInt(), tag, msg) }}


以上Demo源碼工程,詳見:https://github.com/9468305/log-kotlin


實際生產環境使用,推薦 Jake Wharton's Timber:https://github.com/JakeWharton/timber


七、場景2:IO File



本地文件讀寫相比打印輸出日志略復雜但也很常用。Android/JVM 和 Java 服務端的 IO File 技術方案一致但場景選型不同。


  • Java IO (Blocking IO)

    Default IO Streaming.

  • Java NIO (Non-Blocking IO)

    Since 1.4.

  • Java NIO2 (Asynchronous I/O, AIO)

    Since 7, Enhancements in 8.


移動端常用 Blocking IO,一方面是因為該方案適合嵌入式平臺,另一方面是因為Android系統版本對JDK高版本的支持更新進展緩慢。長期以來我們需要兼容JDK 6,最低系統版本升級至Android 4.4(API Level 19)以上才能夠兼容JDK 7,Android 8.0(API Level 26)才升級至JDK 8,并且僅支持部分功能API。


Android NIO實際廣泛應用于網絡組件的實現,例如Google Guava,Square OKHttp。


iOS File 就是C Posix 使用方式,這里不再贅述。


下面這段代碼使用純Kotlin語言調用iOS平臺POSIX File API。memScoped{}表示該作用域內的申請的內存空間,當離開作用域后,會被自動釋放。這是Kotlin Native不依賴JVM GC的內存管理方式,即ARC自動引用計數。


fun sample() {    val file = fopen(__filename = "filename", __mode = "r")    if (file != null) {        try {            memScoped { // ARC - Automatic Reference Counting                val bufferLength = 1024                val buffer = allocArray<ByteVar>(bufferLength)                while (true) {                    val line = fgets(buffer, bufferLength, file)?.toKString()                    if (line == null || line.isEmpty())                        break                    println(line)                }            }        } finally {            fclose(file)        }    }}


那么如何Kotlin Multiplatform實現 java.io.file 與 C POSIX File API的統一?我們看看官方現狀。


Package kotlin.io for native 僅有3個方法。


// Prints the given message to the standard output stream.fun print()// Prints the given message and the line separator to the standard output stream.fun println()// Reads a line of input from the standard input stream.fun readLine(): String?


Package kotlinx.io 基于NIO方案實現,目前仍處于Experimental階段,官方建議配合 kotlinx.coroutines, kotlinx.atomicfu 一起使用,尚未支持Native平臺。


所以目前我們只能自己實現雙平臺的統一封裝。這部分實現并不難,可參考 OpenJDK 和 AOSP源碼。Java File底層實現原理也是通過 JNI 調用 C POSIX。Android 源碼部分改寫了OpenJDK的實現。具體細節詳見Android SDK FileInputStream/FileOutputSteam源碼。


另外 Okio 2 正在進行遷移至Kotlin和支持多平臺,square團隊的最終目標是將Retrofit和OkHttp運行在多平臺。詳見:

https://github.com/square/okio/issues/370


八、場景3: SQLite



嵌入式平臺主流關系型數據存儲方案。


1、Android SQLiteOpenHelper


  • Android SDK 默認提供的SQLite方案。

  • SQLite low-level API

  • Raw SQL queries

  • 使用比較繁瑣


2、Android Jetpack Room


  • Jetpack 新組件。

  • SQLite 之上的 ORM 抽象層。


3、iOS SQLite library


  • 相比 Android,iOS 更接近原始 SQLite C 庫。


SQLDelight


https://github.com/square/sqldelight


目前最成熟穩定的 Kotlin 多平臺 SQLite解決方案。作者 Alec Strong, Jake Wharton(又見大神)。不論是Android Java 開發,還是Kotlin多平臺開發,我都建議大家了解一下它。


它的思路非常有趣,與Room為代表的各種ORM方案截然相反。它是從SQL查詢語句生成代碼,而不是從代碼生成SQL查詢。這里不展開介紹,直接放上Jake Wharton關于SQLDelight vs Room的評論原文。


In my opinion, Room exists at the wrong level of abstraction.


The reason Retrofit and Gson/Moshi/etc. are successful is because there's nothing from which to generate the interfaces and model objects so you write both by hand duplicating an implicit contract. If you switch to protocol buffers, you stop writing models by hand–the tool can generate those. If you switch to gRPC or Swagger, you stop writing Retrofit interfaces by hand–a tool can generate those. When you have an explicit source of schema you no longer need to duplicate that schema by hand in code.


SQL table definitions and queries are a schema. They can define the types and names of both the model objects and the interface through which you interact with queries. Thus, it doesn't really make sense to force the user to duplicate that schema by writing the model objects and interface by hand. You wouldn't do it with protobuf and gRPC. Why are you doing it with your database?


Room is an okay choice. It's far better than all the ORMs people have been using for years. Alec and I have given talks where the conclusion was that we don't care which you choose, just don't choose an ORM (https://youtu.be/4eUuD7LsqMs).


That being said, it's hard not to see SQLDelight as a step up from Room. It validates more. It has better tooling. It generates Kotlin. And it's multiplatform. Yes, I'm biased, but Alec can tell you how long we spent evaluating what the right level of abstraction is and what the right developer UX is. It's a pleasant experience writing only SQL and having SQLDelight generate the interfaces and model objects that you'd otherwise be forced to write by hand with Room. And when you do that, you also stop (ab)using star selects in your queries and selecting only the minimal amount of columns necessary rather than selecting entire tables so you can reuse entities.


You write the SQL query and you write the method signature. Generating the method signature is a pure function from the SQL query. You have to keep both in sync manually instead of having the method generated automatically based on the SQL bind args and selected columns. SQLDelight generates the interface methods that Room makes you write given the same SQL command.


Similarly, when you write a query that returns results, Room forces you to write a model object which conforms to the selected data. SQLDelight generates the model objects that Room makes you write given the same SQL query.


In summary, you can take all the SQL you're already using with Room, delete all of the interfaces and model objects you had to write manually, and SQLDelight will generate them for you (along with some extra validation that they're correct).


九、The Future



Kotlin解決方案組件的穩定性和進展,詳見:

https://kotlinlang.org/docs/reference/evolution/components-stability.html


Kotlin Native 處于 Additions in Incremental Releases (AIR) 階段。


Multiplatform Projects 處于 Moving fast (MF) 階段。


前不久的Google IO 2019大會上,Kotlin語言在Android平臺的地位進一步上升。Android Jetpack 系列組件優先支持Kotlin。Square的Okio 2,OkHttp 4.0 正在遷移Kotlin并支持多平臺。


所以我相信 Kotlin Multiplatform 的未來充滿想象力。


十、One more thing



https://gradle.org/kotlin/


Gradle 5.0 已發布 Kotlin DSL v1.0 穩定版,建議盡早遷移 Gradle工程至 KTS 版本。

分享到:
我來說兩句
facelist
您需要登錄后才可以評論 登錄 | 立即注冊
所有評論(0)

領先的中文移動開發者社區
18620764416
7*24全天服務
意見反饋:[email protected]

掃一掃關注我們

Powered by Discuz! X3.2© 2001-2019 Comsenz Inc.( 粵ICP備15117877號 )

两码中特期期