什麼,Jetpack 也要支援多平臺了!

語言: CN / TW / HK

Android 官方的 twitter 賬號最近釋出了一條訊息:Jetpack 將要支援 KMM 了,目前已經發布了預覽版本。首批的預覽版本中僅支援了 CollectionsDataStore 兩個元件庫,並且在 GitHub 上也開源了全新的專案 kotlin-multiplatform-samples ,來幫助大家更好的理解使用 Jetpack Multiplatform。KMM 由於 Jetpack 的加入,後續的迭代速度應該也會上一個臺階,同時也可能會結束 KMM 三方庫百家爭鳴的局面。

下面就以 kotlin-multiplatform-samples 新倉庫來體驗下使用 Jetpack DataStore 來開發 KMM App 的大致流程。

專案分析

下面我就以 kotlin-multiplatform-samples 專案講解下如何使用 Jetpack Multiplatform 來開發 KMM 專案。示例是一個搖骰子的遊戲,可以設定骰子的個數及形狀(幾面體的骰子),並且可以把上述設定持久化(使用 DataStore)下來。UI 大致如下:

專案整體架構

整個專案的架構大致如下:

流程圖 (4).jpg

注:帶「:」表示的是 Android 的模組,其他表示的是資料夾。

專案中整體有三大部分,分別是 :androidApp:shared 模組以及 iosApp Xcode 工程。

  • :androidApp 是 Android Application 模組,是整個 Android App 的入口,整體採用的是 MVVM 架構,View 使用 Compose 編寫;
  • iosApp是 iOS 的專案工程,可以使用 Xcode 開啟編譯為 iOS App,整體採用的是 MVVM 架構,View 使用 SwiftUI 編寫,使用了 Combine 庫;
  • :shared 是 KMM 的共享程式碼庫,統一提供給 :androidAppiosApp 使用;

下面從資料層至 UI 層的方式看下專案的程式碼細節:

通用資料層的實現

下面就看一下 shared 模組中通用部分的邏輯。

```kotlin class DiceSettingsRepository( private val dataStore: DataStore ) {

private val scope = CoroutineScope(Dispatchers.Default)

// 提供可觀察的資料流供 UI 使用
val settings: Flow<DiceSettings> = dataStore.data.map {
DiceSettings(
        it[diceCountKey] ?: DEFAULT_DICE_COUNT,
        it[sideCountKey] ?: DEFAULT_SIDES_COUNT,
        it[uniqueRollsOnlyKey] ?: DEFAULT_UNIQUE_ROLLS_ONLY,
    )
}

// 使用 DataStore 持久化資料
fun saveSettings(
        diceCount: Int,
        sideCount: Int,
        uniqueRollsOnly: Boolean,
    ) {
        scope.launch {
                dataStore.edit {
                it[diceCountKey] = diceCount
                it[sideCountKey] = sideCount
                it[uniqueRollsOnlyKey] = uniqueRollsOnly
                }
        }
    }

} ```

DataStore 的例項化在 Android 和 iOS 有一些差別,所有這裡差異化處理,首先是在 commonMain 中定義了一個通用的函式

kotlin /** * 獲取一個單例的 DataStore 例項,傳入的是一個儲存檔案的路徑 */ fun getDataStore(producePath: () -> String): DataStore<Preferences> = synchronized(lock) { if (::dataStore.isInitialized) { dataStore } else { PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() } ) .also { dataStore = it } } }

androidMain 中的提供的 getDataStore 函式定義如下:

kotlin /** * 呼叫 commonMain 中的方法,傳入檔案路徑,需要呼叫者傳入 Context */ fun getDataStore(context: Context): DataStore<Preferences> = getDataStore( producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath } )

其中獲取檔案儲存路徑是 Android 平臺特有的 API,是 iOS 平臺不同的。下面就是 iOS 平臺封裝這部分差異的邏輯。

iosMain 中的提供的 getDataStore 函式定義如下:

swift /** * 使用 NSFileManager 構建檔案路徑,用於 DataStore 內容的儲存 */ fun createDataStore(): DataStore<Preferences> = getDataStore( producePath = { val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( directory = NSDocumentDirectory, inDomain = NSUserDomainMask, appropriateForURL = null, create = false, error = null, ) requireNotNull(documentDirectory).path + "/$dataStoreFileName" } )

Android UI 層的實現

Android UI 層的程式碼入口實現大致如下:

```kotlin class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { DiceRollerTheme { // Compose 函式,具體繪製 UI 的邏輯 DiceApp(viewModel = diceViewModel(LocalContext.current)) } } }

@Composable
private fun diceViewModel(context: Context) = viewModel {
    // 例項化 ViewModel

DiceViewModel( roller = DiceRoller(), // 例項化 shared 模組中的 DiceSettingsRepository settingsRepository = DiceSettingsRepository(getDataStore(context)) ) } } ```

儲存按鈕相關邏輯如下:

```kotlin @Composable private fun Settings( viewModel: DiceViewModel, settings: DiceSettings, modifier: Modifier = Modifier, ) { var diceCount by remember { mutableStateOf(settings.diceCount) } var sideCount by remember { mutableStateOf(settings.sideCount) } var uniqueRollsOnly by remember { mutableStateOf(settings.uniqueRollsOnly) }

Column( //... ) { // ...

    Button(
        // 將事件傳遞給 ViewModel 
        onClick = { viewModel.saveSettings(diceCount, sideCount, uniqueRollsOnly) } ,
        enabled = unsavedNumber || unsavedSides || unsavedUnique,
    ) {

Text(stringResource(R.string.save_settings)) } } } ```

ViewModel 中儲存資料邏輯如下:

```kotlin class DiceViewModel( private val roller: DiceRoller, private val settingsRepository: DiceSettingsRepository, ) : ViewModel() { // ...

// 提供可觀察的資料流供 UI 使用
val settings: StateFlow<DiceSettings?> = settingsRepository
    .settings
    .stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5000L),
        null
    )

// 呼叫 Repository 中儲存資料的邏輯
fun saveSettings(
    number: Int,
    sides: Int,
    unique: Boolean,
) = settingsRepository.saveSettings(number, sides, unique)

} ```

iOS UI 層的實現

iOS UI 層的程式碼入口實現大致如下:

swift @main struct iOSApp : App { var body: some Scene { WindowGroup { ContentView () } } }

儲存按鈕的相關邏輯:

```swift struct SettingsView: View { @EnvironmentObject var viewModel: SettingsViewModel

var body: some View {
    VStack {
        Form {
            Section {
                Stepper("settings_dice_count_label (viewModel.diceCount)", value: $viewModel.diceCount, in: 1...10)
                Stepper("settings_side_count_label (viewModel.sideCount)", value: $viewModel.sideCount, in: 3...100)
                Toggle("settings_unique_numbers_label", isOn: $viewModel.uniqueRollsOnly)
            }

            Section {
                Button("settings_save_button", action: {
                    // 將儲存事件傳遞給 ViewModel
                    viewModel.saveSettings()
                }).disabled(!viewModel.isSettingsModified)
            }
        }
    }
}

} ```

ViewModel 中的儲存資料的相關邏輯如下:

```swift @MainActor final class SettingsViewModel: ObservableObject { private let repository = DiceSettingsRepository(dataStore: CreateDataStoreKt.createDataStore()) private var roller = DiceRoller()

// 將 repository 中的 setting 資料流轉換成單一的屬性供 UI 使用
func startObservingSettings() async {
    do {
        let stream = asyncStream(for: repository.settingsNative)
        for try await settings in stream {
            self.diceCount = Int(settings.diceCount)
            self.sideCount = Int(settings.sideCount)
            self.uniqueRollsOnly = settings.uniqueRollsOnly
            self.rollButtonLabel = String.localizedStringWithFormat(NSLocalizedString("game_roll_button", comment: ""), settings.diceCount, settings.sideCount)
            self.currentSettings = settings
        }
    } catch {
        print("Failed with error: (error)")
    }
}

// 呼叫 Repository 儲存資料 
func saveSettings() {
    repository.saveSettings(diceCount: Int32(diceCount), sideCount: Int32(sideCount), uniqueRollsOnly: uniqueRollsOnly)
}

} ```

Kotlin Multiplatform

上面介紹了使用 Jetpack DataStore 來開發 KMM App 的關鍵流程,除了 DataStore 之外,這次一起釋出的還有 Collections 元件。

Jetpack for multiplatform

目前 Jetpack Multiplatform 僅僅支援了 CollectionsDataStore 兩個元件:

  • Collections :Collections 是一個用 Java 程式語言編寫的庫示例,它沒有特定於 Android 的依賴項,但實現了 Java 集合 API。
  • DataStore:完全用 Kotlin 編寫,它在 API 定義和實現中都使用協程。

而且 Jetpack Multiplatform 還處於早期的預覽階段,不建議在線上版本使用。其實這兩個元件並不是什麼全新的庫,而是基於現在的 Android Jetpack 版本之上進行迭代開發的,原始碼也在 androidx 倉庫中。

兩個倉庫的二進位制結構大致如下:

Collections

DataStore

Collections 是全平臺都已實現,DataStore 雖然也已經支援平臺,但是並沒有找到對應的原始碼資訊,可以在 Google Maven 倉庫檢視這部分的支援情況。下面就以 Collections 庫做一個簡單的講解。

Jetpack Multiplatform(JMP) 本身還是基於 Kotlin Multiplatform(KMP) 的開發規範來實現的。想要了解 JMP 的一些底層實現,就需要先了解 KMP 的一些基本概念。

Kotlin 多平臺實現步驟

多平臺繞不過去的一個點就是:如何使用同一的 API 來提供多個平臺的具體邏輯實現。KMP 的定義也是相對簡單,使用 expectactual 兩個關鍵字就能搞定,也是比較好理解:

  • expect:期望的意思,也就是介面定義的部分,可以修飾類與函式;
  • actual:實際的意思,也就是在各個平臺上對 expect 的具體實現,可以修飾類與函式;

在 Android 與 iOS 上的定義大致如下:

Kotlin 多平臺實現示例

我們以一個具體例子來詳細講解下,比如我們要實現是個多平臺的 UUID 方法。那麼首先 common 層的定義如下:

// Common expect fun randomUUID(): String

Android 側的實現如下:

``` // Android import java.util.*

actual fun randomUUID() = UUID.randomUUID().toString() ```

iOS 側實現如下:

``` // iOS import platform.Foundation.NSUUID

actual fun randomUUID(): String = NSUUID().UUIDString() ```

整體架構圖如下:

工程結構如下:

其中 commonMain 是介面定義的部分,androidMain 是 Android 側的具體實現,iosMain 是 iOS 側的具體實現。

其實androidMainiosMain 除了寫 actual 的具體實現外,也可以寫單端的特有業務邏輯。比如在 iosMain 中可以定義普通的類及函式,這裡定義的內容在 Android App 中就無法訪問,反之亦然。所以通用的業務邏輯還是需要定義在 commonMain 目錄中。

除了 Android 和 iOS 平臺之間共享程式碼之外,其他平臺也是通過相同的方式進行程式碼共享。

更多的匹配規則可以到官網就行檢視。

KMM 與 Flutter 的對比

如果使用 Flutter 實現上述搖骰子的遊戲的話,那麼大致的核心類以及架構如下圖(右側)

流程圖 (2).jpg

Flutter 整體實現思路大致如下:

  1. UI 層中使用 Flutter 方式實現 Android 與 iOS 雙端的 UI 繪製;
  2. Data Layer 中的 Repository 也是使用 Dart 來進行編寫,也是雙端只實現一份;
  3. Data Layer 中的 DataSource 是雙平臺特有的 API,需要使用 platform-channels 來實現,首先需要在 plugin 模組中定義對應的方法、傳參及返回值,然後在雙端各自實現對應的協議。這部分採用的介面約定的方式,編譯器並不能檢查是否實現以及實現是否正確。當然,這部分仍然是可以使用一些三方庫來解決。

從上述邏輯來看,單純從共享程式碼的佔比來看,Flutter 整體上是優於 KMM 的。

除了複用程度之外,兩者在實現平臺特有 API 上也是有差異的。

  • KMM :是基於 Kotlin 編譯器將對應的程式碼編譯為目標平臺的位元組碼,這種方式效能損耗較少;
  • Flutter:是通過 Channel (IPC)的方式進行通訊,這種方式會有一定的效能損耗;

從語言層面來看,KMM 使用的是 Kotlin 語言,Flutter 使用的是 Dart 語言。雖然說各自語言有各自的優勢,但是 Dart 整體上看是介於 Java 和 Kotlin 之間的一門語言,它雖然解決了 Java 語言當中的一些冗餘語法,提供了一些現代語言的設計(可空性、擴充套件等),但是在整體設計上還是達不到 Kotlin 這門語言的水平。Dart 這門語言藉助於 Flutter 起死回生,同樣它也幫助 Flutter 能夠快速實現自己的想法,在目前整個時間點來看是一種雙贏的結果。如果在站在一個更大的時間尺度上看(其他跨平臺技術發展的好的話),Dart 對 Flutter 而言可能更像是“成也蕭何敗蕭何”的情況。雖然前期 Flutter 藉著宣告式 UI 程式設計方式快速崛起,但是等到 Compose、SwiftUI 這些後來者追上的時候,Dart 語言可能就會成為一種劣勢。從 TIOBE 的程式語言排行榜中也能窺見一二。

除了平臺之外,一些基建(三方庫)配套是否齊全也是平臺是否能夠持續發展的重要原因。目前 Dart/Flutter 相關的三方庫可以在 pub.dev 上進行檢視,想要使用的一些功能基本上都能找到對應三反庫。KMM 這部分則是沒有官方的 hub 倉庫來彙總所有的 SDK,不過在 kmm-awesome 這個倉庫已經統計了一些 SDK。個人感覺,目前來看兩者的社群狀態是差不多的。

總結

我們從 Jetpack 支援多平臺引出 KMP 的基本開發流程:

  • 將通用的業務邏輯寫在 commonMain 目錄中,各個平臺特有的內容寫在自己平臺中,如 androidMainiosMain 等;

  • 涉及到平臺差異的部分,可以在 commonMain 中定義 expect 修飾的類或函式,然後分別在各自平臺的目錄中進行實現並新增 actual修飾;

針對 KMM 開發,Android 也給出了一個使用 Jetpack Multiplatform 元件 DataStore 進行持久化的示例。整體架構如下:

流程圖 (4).jpg

下面講一下我對 Jetpack 支援 Kotlin Multiplatform 的一點理解,個人觀點,歡迎討論。自從 2017 年 Android 宣佈 Kotlin First 以來,Kotlin 語言本身、Jetpack 中的 ktx 庫以及 Compose 等都取得了一些不錯的反響。反觀 JetBarins 的 Kotlin Multiplatform Mobile 現在才剛剛釋出第一個 Beta 版本,相比之下節奏確實有點慢。

Android 想要做這件事情,思路也是比較簡單,把自己成功的經驗複製一下就可以了。把自己當時怎麼在 Android 上“扶持” Kotlin 的,現在就怎麼“扶持” Kotlin Multiplatform Mobile。除了這套成功方法論之外,也是基於目前 KMM 的現狀來決定的,現在的 KMM 只是一個基礎的通訊平臺,至於在這個平臺上怎麼通訊,並沒有好的規範及解決方案,所以也導致社群中對這塊兒也是處於一個“百家爭鳴”的階段。這樣就導致只有一些相對的激進的開發者才有興趣去嘗試 KMM 技術,發展自然也就慢了下來。

Android 想要解決這個問題就比較簡單了,那就是制定一套規範並且提供一些開箱即用的 SDK,儘可能降低開發者使用 KMM 的門檻。那這套規範目前雖然沒有,但是 Android 可以抄自己的作業呀,Android 上就有一套現成的開發規範,那就是 Jetpack 元件。那讓 Jetpack 元件支援 KMM 也是順理成章的事情了。

關於 KMP 的更多內容

  • https://github.com/terrakok/kmm-awesome