当前位置:   article > 正文

Jetpack 支持跨平台了_jetpack与flutter

jetpack与flutter

Android 官方账号最近发布了一条消息:Jetpack 将要支持 KMM 了,目前已经发布了预览版本。

首批的预览版本中仅支持了 Collections 和 DataStore 两个组件库,并且在 GitHub 上也开源了全新的项目,来帮助大家更好的理解使用 Jetpack Multiplatform。

KMM 由于 Jetpack 的加入,后续的迭代速度应该也会上一个台阶,同时也可能会结束 KMM 三方库百家争鸣的局面。

下面就以 kotlin-multiplatform-samples 新仓库来体验下使用 Jetpack DataStore 来开发 KMM App 的大致流程。

https://github.com/android/kotlin-multiplatform-samples

1

项目分析

下面我就以 kotlin-multiplatform-samples 项目讲解下如何使用 Jetpack Multiplatform 来开发 KMM 项目。

示例是一个摇骰子的游戏,可以设置骰子的个数及形状(几面体的骰子),并且可以把上述设置持久化(使用 DataStore)下来。

UI 大致如下:

整个项目的架构大致如下: 

注:带「:」表示的是 Android 的模块,其他表示的是文件夹。

项目中整体有三大部分,分别是 :androidApp 、:shared 模块以及 iosApp Xcode 工程。

  • :androidApp 是 Android Application 模块,是整个 Android App 的入口,整体采用的是 MVVM 架构,View 使用 Compose 编写;

  • iosApp是 iOS 的项目工程,可以使用 Xcode 打开编译为 iOS App,整体采用的是 MVVM 架构,View 使用 SwiftUI 编写,使用了 Combine 库;

  • :shared 是 KMM 的共享代码库,统一提供给 :androidApp 与 iosApp 使用;

下面从数据层至 UI 层的方式看下项目的代码细节:

通用数据层的实现

下面就看一下 shared 模块中通用部分的逻辑。

  1. class DiceSettingsRepository(
  2.     private val dataStore: DataStore<Preferences>
  3. ) {
  4.     private val scope = CoroutineScope(Dispatchers.Default)
  5.     // 提供可观察的数据流供 UI 使用
  6.     val settings: Flow<DiceSettings> = dataStore.data.map {
  7.         DiceSettings(
  8.             it[diceCountKey] ?: DEFAULT_DICE_COUNT,
  9.             it[sideCountKey] ?: DEFAULT_SIDES_COUNT,
  10.             it[uniqueRollsOnlyKey] ?: DEFAULT_UNIQUE_ROLLS_ONLY,
  11.         )
  12.     }
  13.     // 使用 DataStore 持久化数据
  14.     fun saveSettings(
  15.         diceCount: Int,
  16.         sideCount: Int,
  17.         uniqueRollsOnly: Boolean,
  18.     ) {
  19.         scope.launch {
  20.             dataStore.edit {
  21.                 it[diceCountKey] = diceCount
  22.                 it[sideCountKey] = sideCount
  23.                 it[uniqueRollsOnlyKey] = uniqueRollsOnly
  24.             }
  25.         }
  26.     }
  27. }
 

DataStore 的实例化在 Android 和 iOS 有一些差别,所以这里差异化处理,首先是在 commonMain 中定义了一个通用的函数:

  1. /**
  2.  * 获取一个单例的 DataStore 实例,传入的是一个存储文件的路径
  3.  */
  4. fun getDataStore(producePath: () -> String): DataStore<Preferences> =
  5.     synchronized(lock) {
  6.         if (::dataStore.isInitialized) {
  7.             dataStore
  8.         } else {
  9.             PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
  10.                 .also { dataStore = it }
  11.         }
  12.     }
 

androidMain 中的提供的 getDataStore 函数定义如下:

 
  1. /**
  2.  * 调用 commonMain 中的方法,传入文件路径,需要调用者传入 Context
  3.  */
  4. fun getDataStore(context: Context): DataStore<Preferences> = getDataStore(
  5.     producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath }
  6. )
 

其中获取文件存储路径是 Android 平台特有的 API,是 iOS 平台不同的。下面就是 iOS 平台封装这部分差异的逻辑。

iosMain 中的提供的 getDataStore 函数定义如下:

 
  1. /**
  2.  * 使用 NSFileManager 构建文件路径,用于 DataStore 内容的存储
  3.  */
  4. fun createDataStore(): DataStore<Preferences> = getDataStore(
  5.     producePath = {
  6.         val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
  7.             directory = NSDocumentDirectory,
  8.             inDomain = NSUserDomainMask,
  9.             appropriateForURL = null,
  10.             create = false,
  11.             error = null,
  12.         )
  13.         requireNotNull(documentDirectory).path + "/$dataStoreFileName"
  14.     }
  15. )
 

Android UI 层的实现

Android UI 层的代码入口实现大致如下:

  1. class MainActivity : ComponentActivity() {
  2.     override fun onCreate(savedInstanceState: Bundle?) {
  3.         super.onCreate(savedInstanceState)
  4.         setContent {
  5.             DiceRollerTheme {
  6.                   // Compose 函数,具体绘制 UI 的逻辑
  7.                 DiceApp(viewModel = diceViewModel(LocalContext.current))
  8.             }
  9.         }
  10.     }
  11.     @Composable
  12.     private fun diceViewModel(context: Context) = viewModel {
  13.         // 实例化 ViewModel
  14.         DiceViewModel(
  15.             roller = DiceRoller(),
  16.             // 实例化 shared 模块中的 DiceSettingsRepository
  17.             settingsRepository = DiceSettingsRepository(getDataStore(context))
  18.         )
  19.     }
  20. }
 

保存按钮相关逻辑如下:

  1. @Composable
  2. private fun Settings(
  3.     viewModel: DiceViewModel,
  4.     settings: DiceSettings,
  5.     modifier: Modifier = Modifier,
  6. ) {
  7.     var diceCount by remember { mutableStateOf(settings.diceCount) }
  8.     var sideCount by remember { mutableStateOf(settings.sideCount) }
  9.     var uniqueRollsOnly by remember { mutableStateOf(settings.uniqueRollsOnly) }
  10.     Column(
  11.     //...
  12.     ) {
  13.         // ...
  14.         Button(
  15.             // 将事件传递给 ViewModel
  16.             onClick = { viewModel.saveSettings(diceCount, sideCount, uniqueRollsOnly) },
  17.             enabled = unsavedNumber || unsavedSides || unsavedUnique,
  18.         ) {
  19.             Text(stringResource(R.string.save_settings))
  20.         }
  21.     }
  22. }
 

ViewModel 中保存数据逻辑如下:

 
  1. class DiceViewModel(
  2.     private val roller: DiceRoller,
  3.     private val settingsRepository: DiceSettingsRepository,
  4. ) : ViewModel() {
  5.     // ...
  6.     // 提供可观察的数据流供 UI 使用
  7.     val settings: StateFlow<DiceSettings?> = settingsRepository
  8.         .settings
  9.         .stateIn(
  10.             viewModelScope,
  11.             SharingStarted.WhileSubscribed(5000L),
  12.             null
  13.         )
  14.     // 调用 Repository 中存储数据的逻辑
  15.     fun saveSettings(
  16.         number: Int,
  17.         sides: Int,
  18.         unique: Boolean,
  19.     ) = settingsRepository.saveSettings(number, sides, unique)
  20. }
 

iOS UI 层的实现

iOS UI 层的代码入口实现大致如下:

  1. @main
  2. struct iOSApp: App {
  3.  var body: some Scene {
  4.   WindowGroup {
  5.    ContentView()
  6.   }
  7.  }
  8. }

保存按钮的相关逻辑:
 
  1. struct SettingsViewView {
  2.     @EnvironmentObject var viewModel: SettingsViewModel
  3.     var body: some View {
  4.         VStack {
  5.             Form {
  6.                 Section {
  7.                     Stepper("settings_dice_count_label \(viewModel.diceCount)"value: $viewModel.diceCount, in1...10)
  8.                     Stepper("settings_side_count_label \(viewModel.sideCount)"value: $viewModel.sideCount, in3...100)
  9.                     Toggle("settings_unique_numbers_label"isOn: $viewModel.uniqueRollsOnly)
  10.                 }
  11.                 Section {
  12.                     Button("settings_save_button"action: {
  13.                         // 将保存事件传递给 ViewModel
  14.                         viewModel.saveSettings()
  15.                     }).disabled(!viewModel.isSettingsModified)
  16.                 }
  17.             }
  18.         }
  19.     }
  20. }
 

ViewModel 中的保存数据的相关逻辑如下:

  1. @MainActor
  2. final class SettingsViewModelObservableObject {
  3.     private let repository = DiceSettingsRepository(dataStore: CreateDataStoreKt.createDataStore())
  4.     private var roller = DiceRoller()
  5.     // 将 repository 中的 setting 数据流转换成单一的属性供 UI 使用
  6.     func startObservingSettings() async {
  7.         do {
  8.             let stream = asyncStream(for: repository.settingsNative)
  9.             for try await settings in stream {
  10.                 self.diceCount = Int(settings.diceCount)
  11.                 self.sideCount = Int(settings.sideCount)
  12.                 self.uniqueRollsOnly = settings.uniqueRollsOnly
  13.                 self.rollButtonLabel = String.localizedStringWithFormat(NSLocalizedString("game_roll_button", comment: ""), settings.diceCount, settings.sideCount)
  14.                 self.currentSettings = settings
  15.             }
  16.         } catch {
  17.             print("Failed with error: \(error)")
  18.         }
  19.     }
  20.     // 调用 Repository 保存数据
  21.     func saveSettings() {
  22.         repository.saveSettings(diceCount: Int32(diceCount), sideCount: Int32(sideCount), uniqueRollsOnly: uniqueRollsOnly)
  23.     }
  24. }
 

2

Kotlin Multiplatform

上面介绍了使用 Jetpack DataStore 来开发 KMM App 的关键流程,除了 DataStore 之外,这次一起发布的还有 Collections 组件。

Jetpack for multiplatform

目前 Jetpack Multiplatform 仅仅支持了 Collections 和 DataStore 两个组件:

  • Collections :Collections 是一个用 Java 编程语言编写的库示例,它没有特定于 Android 的依赖项,但实现了 Java 集合 API。

  • DataStore:完全用 Kotlin 编写,它在 API 定义和实现中都使用协程。

而且 Jetpack Multiplatform 还处于早期的预览阶段,不建议在线上版本使用。其实这两个组件并不是什么全新的库,而是基于现在的 Android Jetpack 版本之上进行迭代开发的,源码也在 androidx 仓库中。

  • Collections: https://developer.android.com/jetpack/androidx/releases/collection 

  • DataStore: https://developer.android.com/topic/libraries/architecture/datastore 

  • androidx: https://github.com/androidx/androidx/tree/androidx-main/datastore/datastore-core

两个仓库的二进制结构大致如下:


 

Collections 是全平台都已实现,DataStore 虽然也已经支持平台,但是并没有找到对应的源码信息,可以在 Google Maven 仓库查看这部分的支持情况。下面就以 Collections 库做一个简单的讲解。

  • Google Maven: https://maven.google.com/web/index.html#androidx.datastore:datastore-core-iosx64:1.1.0-dev01

Jetpack Multiplatform(JMP) 本身还是基于 Kotlin Multiplatform(KMP) 的开发规范来实现的。想要了解 JMP 的一些底层实现,就需要先了解 KMP 的一些基本概念。

Kotlin 多平台实现步骤

多平台绕不过去的一个点就是:如何使用同一的 API 来提供多个平台的具体逻辑实现。KMP 的定义也是相对简单,使用 expect、actual 两个关键字就能搞定,也是比较好理解:

  • expect:期望的意思,也就是接口定义的部分,可以修饰类与函数;

  • actual:实际的意思,也就是在各个平台上对 expect 的具体实现,可以修饰类与函数;

在 Android 与 iOS 上的定义大致如下:


Kotlin 多平台实现示例

我们以一个具体例子来详细讲解下,比如我们要实现一个多平台的 UUID 方法。那么首先 common 层的定义如下:

  1. // Common
  2. expect fun randomUUID(): String
 
 
Android 侧的实现如下:
 
  1. // Android
  2. import java.util.*
  3. actual fun randomUUID() = UUID.randomUUID().toString()

iOS 侧实现如下:
 

  1. // iOS
  2. import platform.Foundation.NSUUID
  3. actual fun randomUUID(): String = NSUUID().UUIDString()
 

整体架构图如下:


工程结构如下: 

 

其中 commonMain 是接口定义的部分,androidMain 是 Android 侧的具体实现,iosMain 是 iOS 侧的具体实现。

其实androidMain、iosMain 除了写 actual 的具体实现外,也可以写单端的特有业务逻辑。比如在 iosMain 中可以定义普通的类及函数,这里定义的内容在 Android App 中就无法访问,反之亦然。所以通用的业务逻辑还是需要定义在 commonMain 目录中。

除了 Android 和 iOS 平台之间共享代码之外,其他平台也是通过相同的方式进行代码共享。


更多的匹配规则可以到 Kotlin 官网就行查看。

Kotlin 官网:https://kotlinlang.org/docs/multiplatform-share-on-platforms.html#use-target-shortcuts

3

KMM 与 Flutter 的对比

如果使用 Flutter 实现上述摇骰子的游戏的话,那么大致的核心类以及架构如下图(右侧)

 

Flutter 整体实现思路大致如下:

  1. UI 层中使用 Flutter 方式实现 Android 与 iOS 双端的 UI 绘制;

  2. Data Layer 中的 Repository 也是使用 Dart 来进行编写,也是双端只实现一份;

  3. Data Layer 中的 DataSource 是双平台特有的 API,需要使用 platform-channels 来实现,首先需要在 plugin 模块中定义对应的方法、传参及返回值,然后在双端各自实现对应的协议。这部分采用的接口约定的方式,编译器并不能检查是否实现以及实现是否正确。当然,这部分仍然是可以使用一些三方库来解决。

从上述逻辑来看,单纯从共享代码的占比来看,Flutter 整体上是优于 KMM 的。

platform-channels

https://flutter.cn/docs/development/platform-integration/platform-channels

除了复用程度之外,两者在实现平台特有 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。个人感觉,目前来看两者的社区状态是差不多的。

  • 排行榜:

    https://hellogithub.com/report/tiobe/

  • pub.dev

    https://pub.dev

  • kmm-awesome

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

4

 总结

我们从 Jetpack 支持多平台引出 KMP 的基本开发流程:

  • 将通用的业务逻辑写在 commonMain 目录中,各个平台特有的内容写在自己平台中,如 androidMain、iosMain 等;

  • 涉及到平台差异的部分,可以在 commonMain 中定义 expect 修饰的类或函数,然后分别在各自平台的目录中进行实现并添加 actual修饰;

针对 KMM 开发,Android 也给出了一个使用 Jetpack Multiplatform 组件 DataStore 进行持久化的示例。整体架构如下:

 

下面讲一下我对 Jetpack 支持 Kotlin Multiplatform 的一点理解,个人观点,欢迎讨论。

自从 2017 年 Android 宣布 Kotlin First 以来,Kotlin 语言本身、Jetpack 中的 ktx 库以及 Compose 等都取得了一些不错的反响。

反观 JetBarins 的 Kotlin Multiplatform Mobile 现在才刚刚发布第一个 Beta 版本[1],相比之下节奏确实有点慢。

Android 想要做这件事情,思路也是比较简单,把自己成功的经验复制一下就可以了。

把自己当时怎么在 Android 上“扶持” Kotlin 的,现在就怎么“扶持” Kotlin Multiplatform Mobile。除了这套成功方法论之外,也是基于目前 KMM 的现状来决定的,现在的 KMM 只是一个基础的通信平台,至于在这个平台上怎么通信,并没有好的规范及解决方案,所以也导致社区中对这块儿也是处于一个“百家争鸣”的阶段。

这样就导致只有一些相对的激进的开发者才有兴趣去尝试 KMM 技术,发展自然也就慢了下来。

Android 想要解决这个问题就比较简单了,那就是制定一套规范并且提供一些开箱即用的 SDK,尽可能降低开发者使用 KMM 的门槛。那这套规范目前虽然没有,但是 Android 可以抄自己的作业呀,Android 上就有一套现成的开发规范,那就是 Jetpack 组件。那让 Jetpack 组件支持 KMM 也是顺理成章的事情了。

关于 KMP 的更多内容可查看以下链接:

  • Kotlin 官方文档[2]

  • Announcing an Experimental Preview of Jetpack Multiplatform Libraries[3]

  • Compose for Multiplatform - 王鹏[4]

  • 《Kotlin 移动端跨平台技术的当下及未来》乔禹昂[5]

  • Getting started with Kotlin Multiplatform Mobile | KMM Beta[6]

  • kmm-awesome[7]

参考资料

[1]第一个 Beta 版本: https://blog.jetbrains.com/kotlin/2022/10/kmm-beta/
[2]Kotlin 官方文档: https://kotlinlang.org/docs/multiplatform-mobile-getting-started.html
[3]Announcing an Experimental Preview of Jetpack Multiplatform Libraries: https://android-developers.googleblog.com/2022/10/announcing-experimental-preview-of-jetpack-multiplatform-libraries.html
[4]Compose for Multiplatform - 王鹏: https://www.bilibili.com/video/BV13a411k7dh/?spm_id_from=333.880.my_history.page.click&vd_source=58eb190f223c4b26e5c62cf334bc1d3f
[5]《Kotlin 移动端跨平台技术的当下及未来》乔禹昂: https://www.bilibili.com/video/BV1bD4y1k7Mm/?spm_id_from=333.999.header_right.history_list.click&vd_source=58eb190f223c4b26e5c62cf334bc1d3f
[6]Getting started with Kotlin Multiplatform Mobile | KMM Beta: https://www.youtube.com/watch?v=2yd6rVJdICU&ab_channel=KotlinbyJetBrains
[7]kmm-awesome: https://github.com/terrakok/kmm-awesome

转自:什么,Jetpack 支持跨平台了!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/你好赵伟/article/detail/266962
推荐阅读
相关标签
  

闽ICP备14008679号