什么,Jetpack 也要支持多平台了!

Android 官方的 twitter 账号最近发布了一条音讯:Jetpack 即将支撑 KMM 了,现在现已发布了预览版别。首批的预览版别中仅支撑了 Collections 和 DataStore 两个组件库,而且在 GitHub 上也开源了全新的项目 kotlin-multiplatform-samples ,来协助咱们更好的了解运用 Jetpack Multiplatform。KMM 由于 Jetpack 的参加,后续的迭代速度应该也会上一个台阶,一起也或许会结束 KMM 三方库百家争鸣的局势。

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

项目分析

下面我就以 kotlin-multiplatform-samples 项目讲解下怎样运用 Jetpack Multiplatform 来开发 KMM 项目。示例是一个摇骰子的游戏,能够设置骰子的个数及形状(几面体的骰子),而且能够把上述设置耐久化(运用 DataStore)下来。UI 大致如下:

什么,Jetpack 也要支持多平台了!

项目全体架构

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

什么,Jetpack 也要支持多平台了!

注:带「:」表明的是 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 模块中通用部分的逻辑。

class DiceSettingsRepository(
    private val dataStore: DataStore<Preferences>
) {
    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 中界说了一个通用的函数

 /**
* 获取一个单例的 DataStore 实例,传入的是一个存储文件的途径
*/
fun getDataStore(producePath: () -> String): DataStore<Preferences> =
    synchronized(lock) {
        if (::dataStore.isInitialized) {
                    dataStore
        } else {
                    PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() } )
                        .also { dataStore = it }
        }
    } 

androidMain 中的供给的 getDataStore 函数界说如下:

/**
 * 调用 commonMain 中的办法,传入文件途径,需求调用者传入 Context
 */
fun getDataStore(context: Context): DataStore<Preferences> = getDataStore(
    producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath }
)

其间获取文件存储途径是 Android 渠道特有的 API,是 iOS 渠道不同的。下面便是 iOS 渠道封装这部分差异的逻辑。

iosMain 中的供给的 getDataStore 函数界说如下:

 /**
* 运用 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 层的代码入口完成大致如下:

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))
        )
    }
}

保存按钮相关逻辑如下:

@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 中保存数据逻辑如下:

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 层的代码入口完成大致如下:

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

保存按钮的相关逻辑:

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 中的保存数据的相关逻辑如下:

@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 仅仅支撑了 Collections 和 DataStore 两个组件:

  • Collections :Collections 是一个用 Java 编程言语编写的库示例,它没有特定于 Android 的依赖项,但完成了 Java 调集 API。
  • DataStore:完全用 Kotlin 编写,它在 API 界说和完成中都运用协程

而且 Jetpack Multiplatform 还处于早期的预览阶段,不主张在线上版别运用。其实这两个组件并不是什么全新的库,而是根据现在的 Android Jetpack 版别之上进行迭代开发的,源码也在 androidx 库房中。

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

什么,Jetpack 也要支持多平台了!
什么,Jetpack 也要支持多平台了!

Collections

DataStore

Collections 是全渠道都已完成,DataStore 尽管也现已支撑渠道,可是并没有找到对应的源码信息,能够在 Google Maven 库房查看这部分的支撑状况。下面就以 Collections 库做一个简略的讲解。

Jetpack Multiplatform(JMP) 自身仍是根据 Kotlin Multiplatform(KMP) 的开发标准来完成的。想要了解 JMP 的一些底层完成,就需求先了解 KMP 的一些基本概念。

Kotlin 多渠道完成过程

多渠道绕不过去的一个点便是:怎样运用同一的 API 来供给多个渠道的详细逻辑完成。KMP 的界说也是相对简略,运用 expectactual 两个关键字就能搞定,也是比较好了解:

  • expect:希望的意思,也便是接口界说的部分,能够润饰类与函数;
  • actual:实际的意思,也便是在各个渠道上对 expect 的详细完成,能够润饰类与函数;

在 Android 与 iOS 上的界说大致如下:

什么,Jetpack 也要支持多平台了!

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()

全体架构图如下:

什么,Jetpack 也要支持多平台了!

工程结构如下:

什么,Jetpack 也要支持多平台了!

其间 commonMain 是接口界说的部分,androidMain 是 Android 侧的详细完成,iosMain 是 iOS 侧的详细完成。

其实androidMainiosMain 除了写 actual 的详细完成外,也能够写单端的特有事务逻辑。比方在 iosMain 中能够界说一般的类及函数,这儿界说的内容在 Android App 中就无法访问,反之亦然。所以通用的事务逻辑仍是需求界说在 commonMain 目录中。

除了 Android 和 iOS 渠道之间同享代码之外,其他渠道也是经过相同的方法进行代码同享。

什么,Jetpack 也要支持多平台了!

更多的匹配规则能够到官网就行查看。

KMM 与 Flutter 的对比

假如运用 Flutter 完成上述摇骰子的游戏的话,那么大致的核心类以及架构如下图(右侧)

什么,Jetpack 也要支持多平台了!

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 进行耐久化的示例。全体架构如下:

什么,Jetpack 也要支持多平台了!

下面讲一下我对 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 的更多内容

  • Kotlin 官方文档
  • Announcing an Experimental Preview of Jetpack Multiplatform Libraries
  • Compose for Multiplatform – 王鹏
  • 《Kotlin 移动端跨渠道技能的当下及未来》乔禹昂
  • Getting started with Kotlin Multiplatform Mobile | KMM Beta
  • github.com/terrakok/km…