Jetpack Compose 动画实战:高仿微博长按点赞彩虹
基于 Jetpack Compose 提供的 Animtable 等动画 API 实现高仿微博长按点赞彩虹动画的效果,
Jetpack MVVM 错误用法(二)在 launchWhenX 中启动协程
Jetpack MVVM 使用常见错误 :在 launchWhenX 中启动协程可能会隐藏隐患,应该用 repeatOnLifecycle 替代
Jetpack MVVM 错误用法(三)在 onViewCreated 中加载数据
Jetpack 的 MVVM 本身没有错,错在开发者的某些使用不当。本系列将分享那些 AAC 中常见的错误用法,以帮助大家打造更健康的应用架构:聊一聊MVVM中 ViewModel数据的首次加载时机
Jetpack MVVM 使用错误(五):ViewModel 接口暴露不合理
Jetpack 提倡单向数据流架构,ViewModel 对外暴露的接口如果不合理,将破坏数据流的单向流动。
Jetpack MVVM 常见错误用法(四) 使用 LiveData/StateFlow 发送 Event
在 MVVM 架构中,使用 LiveData 或者 StateFlow 很适合用来向 UI 侧发送更新后的状态,但是用来发送事件就不妥了
Fragivity:像使用Activity一样使用Fragment
近年来,SPA,即单Activity架构逐渐开始受到欢迎,随之而生了很多优秀的三方库,大部分是基于Fragment作为实现方案,Fragivity 使用 Fragment + Navigatiion 打造最好用的 SPA 框架
Jetpack MVVM 常见错误(六)在 Repository 中使用 LiveData
由于 LiveData 简单好用再加上官网早期的推荐,很多人会将 LiveData 用在 Domain 甚至 Data 层等非 UI 场景,这样的用法并不合理,也已经不再被官方推荐。
在 Compose 中使用 Jetpack 组件库
Jeptack Compose 主要目的是提高 UI 层的开发效率,但一个完整项目还少不了逻辑层、数据层的配合。幸好 Jetpack 中不少组件库已经与 Compose 进行了适配。
Jetpack MVVM 常见错误用法(一) 拿Fragment当LifecycleOwner
Jetpack 的 MVVM 本身没有错,错在开发者的某些使用不当。本系列将分享那些 AAC 中常见的错误用法,指导大家打造更健康的应用架构

深入理解 Jetpack Compose:SlotTable 系统

引言Compose 的绘制有三个阶段,组合 > 布局 > 绘制。后两个过程与传统视图的渲染过程相近,唯独组合是 Compose 所特有的。Compose 通过组合生成渲染树,这是 Compose 框架的核心能力,而这个过程主要是依赖 SlotTable 实现的,本文就来介绍一下 SlotTable 系统。1. 从 Compose 渲染过程说起基于 Android 原生视图的开发过程,其本质就是构建一棵基于 View 的渲染树,当帧信号到达时从根节点开始深度遍历,依次调用 measure/layout/draw,直至完成整棵树的渲染。对于 Compose 来说也存在这样一棵渲染树,我们将其称为 Compositiion,树上的节点是 LayoutNode,Composition 通过 LayoutNode 完成 measure/layout/draw 的过程最终将 UI 显示到屏幕上。Composition 依靠 Composable 函数的执行来创建以及更新,即所谓的组合和重组。例如上面的 Composable 代码,经过执行后会生成右侧的 Composition。一个函数经过执行是如何转换成 LayoutNode 的呢?深入 Text 的源码后发现其内部调用了 Layout, Layout 是一个可以自定义布局的 Composable,我们直接使用的各类 Composable 最终都是通过调用 Layout 来实现不同的布局和显示效果。//Layout.kt @Composable inline fun Layout( content: @Composable () -> Unit, modifier: Modifier = Modifier, measurePolicy: MeasurePolicy val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current val viewConfiguration = LocalViewConfiguration.current ReusableComposeNode<ComposeUiNode, Applier<Any>>( factory = ComposeUiNode.Constructor, update = { set(measurePolicy, ComposeUiNode.SetMeasurePolicy) set(density, ComposeUiNode.SetDensity) set(layoutDirection, ComposeUiNode.SetLayoutDirection) set(viewConfiguration, ComposeUiNode.SetViewConfiguration) skippableUpdate = materializerOf(modifier), content = content }Layout 内部通过 ReusableComposeNode 创建 LayoutNode。factory 就是创建 LayoutNode 的工厂update 用来记录会更新 Node 的状态用于后续渲染继续进入 ReusableComposeNode ://Composables.kt inline fun <T, reified E : Applier<*>> ReusableComposeNode( noinline factory: () -> T, update: @DisallowComposableCalls Updater<T>.() -> Unit, noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit, content: @Composable () -> Unit //... $composer.startReusableNode() //... $composer.createNode(factory) //... Updater<T>(currentComposer).update() //... $composer.startReplaceableGroup(0x7ab4aae9) content() $composer.endReplaceableGroup() $composer.endNode() }我们知道 Composable 函数经过编译后会传入 Composer, 代码中基于传入的 Composer 完成了一系列操作,主逻辑很清晰:Composer#createNode 创建节点Updater#update 更新 Node 状态content() 继续执行内部 Composable,创建子节点。此外,代码中还穿插着了一些 startXXX/endXXX ,这样的成对调用就好似对一棵树进行深度遍历时的压栈/出栈startReusableNode NodeData // Node数据 startReplaceableGroup GroupData //Group数据 ... // 子Group endGroup endNode不只是 ReusableComposeNode 这样的内置 Composable,我们自己写的 Composable 函数体经过编译后的代码也会插入大量的 startXXX/endXXX,这些其实都是 Composer 对 SlotTable 访问的过程,Composer 的职能就是通过对 SlotTable 的读写来创建和更新 Composition。下图是 Composition,Composer 与 SlotTable 的关系类图2. 初识 SlotTable前文我们将 Composable 执行后生成的渲染树称为 Compositioin。其实更准确来说,Composition 中存在两棵树,一棵是 LayoutNode 树,这是真正执行渲染的树,LayoutNode 可以像 View 一样完成 measure/layout/draw 等具体渲染过程;而另一棵树是 SlotTable,它记录了 Composition 中的各种数据状态。 传统视图的状态记录在 View 对象中,在 Compose 面向函数编程而不面向对象,所以这些状态需要依靠 SlotTable 进行管理和维护。Composable 函数执行过程中产生的所有数据都会存入 SlotTable, 包括 State、CompositionLocal,remember 的 key 与 value 等等 ,这些数据不随函数的出栈而消失,可以跨越重组存在。Composable 函数在重组中如果产生了新数据则会更新 SlotTable。SlotTable 的数据存储在 Slot 中,一个或多个 Slot 又归属于一个 Group。可以将 Group 理解为树上的一个个节点。说 SlotTable 是一棵树,其实它并非真正的树形数据结构,它用线性数组来表达一棵树的语义,从 SlotTable 的定义中可以看到这一点://SlotTable.kt internal class SlotTable : CompositionData, Iterable<CompositionGroup> { * An array to store group information that is stored as groups of [Group_Fields_Size] * elements of the array. The [groups] array can be thought of as an array of an inline * struct. var groups = IntArray(0) private set * An array that stores the slots for a group. The slot elements for a group start at the * offset returned by [dataAnchor] of [groups] and continue to the next group's slots or to * [slotsSize] for the last group. When in a writer the [dataAnchor] is an anchor instead of * an index as [slots] might contain a gap. var slots = Array<Any?>(0) { null } private setSlotTable 有两个数组成员,groups 数组存储 Group 信息,slots 存储 Group 所辖的数据。用数组替代结构化存储的好处是可以提升对“树”的访问速度。 Compose 中重组的频率很高,重组过程中会不断的对 SlotTable 进行读写,而访问数组的时间复杂度只有 O(1),所以使用线性数组结构有助于提升重组的性能。groups 是一个 IntArray,每 5 个 Int 为一组构成一个 Group 的信息key : Group 在 SlotTable 中的标识,在 Parent Group 范围内唯一Group info: Int 的 Bit 位中存储着一些 Group 信息,例如是否是一个 Node,是否包含 Data 等,这些信息可以通过位掩码来获取。Parent anchor: Parent 在 groups 中的位置,即相对于数组指针的偏移Size: Group: 包含的 Slot 的数量Data anchor:关联 Slot 在 slots 数组中的起始位置slots 是真正存储数据的地方,Composable 执行过程中可以产生任意类型的数据,所以数组类型是 Any?。每个 Gorup 关联的 Slot 数量不定,Slot 在 slots 中按照所属 Group 的顺序依次存放。groups 和 slots 不是链表,所以当容量不足时,它们会进行扩容。3. 深入理解 GroupGroup 的作用SlotTable 的数据存储在 Slot 中,为什么充当树上节点的单位不是 Slot 而是 Group 呢?因为 Group 提供了以下几个作用:构建树形结构: Composable 首次执行过程中,在 startXXXGroup 中会创建 Group 节点存入 SlotTable,同时通过设置 Parent anchor 构建 Group 的父子关系,Group 的父子关系是构建渲染树的基础。识别结构变化: 编译期插入 startXXXGroup 代码时会基于代码位置生成可识别的 $key(parent 范围内唯一)。在首次组合时 $key 会随着 Group 存入 SlotTable,在重组中,Composer 基于 $key 的比较可以识别出 Group 的增、删或者位置移动。换言之,SlotTable 中记录的 Group 携带了位置信息,故这种机制也被称为 Positional Memoization。Positional Memoization 可以发现 SlotTable 结构上的变化,最终转化为 LayoutNode 树的更新。重组的最小单位: Compose 的重组是“智能”的,Composable 函数或者 Lambda 在重组中可以跳过不必要的执行。在 SlotTtable 上,这些函数或 lambda 会被包装为一个个 RestartGroup ,因此 Group 是参与重组的最小单位。Group 的类型Composable 在编译期会生成多种不同类型的 startXXXGroup,它们在 SlotTable 中插入 Group 的同时,会存入辅助信息以实现不同的功能:startXXXGroup说明startNode/startReusableNode插入一个包含 Node 的 Group。例如文章开头 ReusableComposeNode 的例子中,显示调用了 startReusableNode ,而后调用 createNode 在 Slot 中插入 LayoutNode。startRestartGroup插入一个可重复执行的 Group,它可能会随着重组被再次执行,因此 RestartGroup 是重组的最小单元。startReplaceableGroup插入一个可以被替换的 Group,例如一个 if/else 代码块就是一个 ReplaceableGroup,它可以在重组中被插入后者从 SlotTable 中移除。startMovableGroup插入一个可以移动的 Group,在重组中可能在兄弟 Group 之间发生位置移动。startReusableGroup插入一个可复用的 Group,其内部数据可在 LayoutNode 之间复用,例如 LazyList 中同类型的 Item。当然 startXXXGroup 不止用于插入新 Group,在重组中也会用来追踪 SlotTable 的已有 Group,与当前执行中的代码情况进行比较。接下来我们看下几种不同类型的 startXXXGroup 出现在什么样的代码中。4. 编译期生成的 startXXXGroup前面介绍了 startXXXGroup 的几种类型,我们平日在写 Compose 代码时,对他们毫无感知,那么他们分别是在何种情况下生成的呢?下面看几种常见的 startXXXGroup 的生成时机:startReplaceableGroup前面提到过 Positional Memoization 的概念,即 Group 存入 SlotTable 时,会携带基于位置生成的 $key,这有助于识别 SlotTable 的结构变化。下面的代码能更清楚地解释这个特性@Composable fun ReplaceableGroupTest(condition: Boolean) { if (condition) { Text("Hello") //Text Node 1 } else { Text("World") //Text Node 2 }这段代码,当 condition 从 true 变为 false,意味着渲染树应该移除旧的 Text Node 1 ,并添加新的 Text Node 2。源码中我们没有为 Text 添加可辨识的 key,如果仅按照源码执行,程序无法识别出 counditioin 变化前后 Node 的不同,这可能导致旧的节点状态依然残留,UI 不符预期。Compose 如何解决这个问题呢,看一下上述代码编译后的样子(伪代码):@Composable fun ReplaceableGroupTest(condition: Boolean, $composer: Composer?, $changed: Int) { if (condition) { $composer.startReplaceableGroup(1715939608) Text("Hello") $composer.endReplaceableGroup() } else { $composer.startReplaceableGroup(1715939657) Text("World") $composer.endReplaceableGroup() }可以看到,编译器为 if/else 每个条件分支都插入了 RestaceableGroup ,并添加了不同的 $key。这样当 condition 发生变化时,我们可以识别 Group 发生了变化,从而从结构上变更 SlotTable,而不只是更新原有 Node。if/else 内部即使调用了多个 Composable(比如可能出现多个 Text) ,它们也只会包装在一个 RestartGroup ,因为它们总是被一起插入/删除,无需单独生成 Group 。startMovableGroup@Composable fun MoveableGroupTest(list: List<Item>) { Column { list.forEach { Text("Item:$it") }上面代码是一个显示列表的例子。由于列表的每一行在 for 循环中生成,无法基于代码位置实现 Positional Memoization,如果参数 list 发生了变化,比如插入了一个新的 Item,此时 Composer 无法识别出 Group 的位移,会对其进行删除和重建,影响重组性能。针对这类无法依靠编译器生成 $key 的问题,Compose 给了解决方案,可以通过 key {...} 手动添加唯一索引 key,便于识别 Item 的新增,提升重组性能。经优化后的代码如下://Before Compiler @Composable fun MoveableGroupTest(list: List<Item>) { Column { list.forEach { key(izt.id) { //Unique key Text("Item:$it") }上面代码经过编译后会插入 startMoveableGroup:@Composable fun MoveableGroupTest(list: List<Item>, $composer: Composer?, $changed: Int) { Column { list.forEach { key(it.id) { $composer.startMovableGroup(-846332013, Integer.valueOf(it)); Text("Item:$it") $composer.endMovableGroup(); }startMoveableGroup 的参数中除了 GroupKey 还传入了一个辅助的 DataKey。当输入的 list 数据中出现了增/删或者位移时,MoveableGroup 可以基于 DataKey 识别出是否是位移而非销毁重建,提升重组的性能。startRestartGroupRestartGroup 是一个可重组单元,我们在日常代码中定义的每个 Composable 函数都可以单独参与重组,因此它们的函数体中都会插入 startRestartGroup/endRestartGroup,编译前后的代码如下:// Before compiler (sources) @Composable fun RestartGroupTest(str: String) { Text(str) // After compiler @Composable fun RestartGroupTest(str: String, $composer: Composer<*>, $changed: Int) { $composer.startRestartGroup(-846332013) // ... Text(str) $composer.endRestartGroup()?.updateScope { next -> RestartGroupTest(str, next, $changed or 0b1) }看一下 startRestartGroup 做了些什么//Composer.kt fun startRestartGroup(key: Int): Composer { start(key, null, false, null) addRecomposeScope() return this private fun addRecomposeScope() { //... val scope = RecomposeScopeImpl(composition as CompositionImpl) invalidateStack.push(scope) updateValue(scope) //... }这里主要是创建 RecomposeScopeImpl 并存入 SlotTable 。RecomposeScopeImpl 中包裹了一个 Composable 函数,当它需要参与重组时,Compose 会从 SlotTable 中找到它并调用 RecomposeScopeImpl#invalide() 标记失效,当重组来临时 Composable 函数被重新执行。RecomposeScopeImpl 被缓存到 invalidateStack,并在 Composer#endRestartGroup() 中返回。updateScope 为其设置需要参与重组的 Composable 函数,其实就是对当前函数的递归调用。注意 endRestartGroup 的返回值是可空的,如果 RestartGroupTest 中不依赖任何状态则无需参与重组,此时将返回 null。可见,无论 Compsoable 是否有必要参与重组,生成代码都一样。这降低了代码生成逻辑的复杂度,将判断留到运行时处理。5. SlotTable 的 Diff 与遍历SlotTable 的 Diff声明式框架中,渲染树的更新都是通过 Diff 实现的,比如 React 通过 VirtualDom 的 Diff 实现 Dom 树的局部更新,提升 UI 刷新的性能。SlotTable 就是 Compose 的 “Virtual Dom”,Composable 初次执行时在 SlotTable 中插入 Group 和对应的 Slot 数据。 当 Composable 参与重组时,基于代码现状与 SlotTable 中的状态进行 Diff,发现 Composition 中需要更新的状态,并最终应用到 LayoutNode 树。这个 Diff 的过程也是在 startXXXGroup 过程中完成的,具体实现都集中在 Composer#start() ://Composer.kt private fun start(key: Int, objectKey: Any?, isNode: Boolean, data: Any?) { //... if (pending == null) { val slotKey = reader.groupKey if (slotKey == key && objectKey == reader.groupObjectKey) { // 通过 key 的比较,确定 group 节点没有变化,进行数据比较 startReaderGroup(isNode, data) } else { // group 节点发生了变化,创建 pending 进行后续处理 pending = Pending( reader.extractKeys(), nodeIndex //... if (pending != null) { // 寻找 gorup 是否在 Compositon 中存在 val keyInfo = pending.getNext(key, objectKey) if (keyInfo != null) { // group 存在,但是位置发生了变化,需要借助 GapBuffer 进行节点位移 val location = keyInfo.location reader.reposition(location) if (currentRelativePosition > 0) { // 对 Group 进行位移 recordSlotEditingOperation { _, slots, _ -> slots.moveGroup(currentRelativePosition) startReaderGroup(isNode, data) } else { //... val startIndex = writer.currentGroup when { isNode -> writer.startNode(Composer.Empty) data != null -> writer.startData(key, objectKey ?: Composer.Empty, data) else -> writer.startGroup(key, objectKey ?: Composer.Empty) //... }start 方法有四个参数:key: 编译期基于代码位置生成的 $keyobjectKey: 使用 key{} 添加的辅助 keyisNode:当前 Group 是否是一个 Node,在 startXXXNode 中,此处会传入 truedata:当前 Group 是否有一个数据,在 startProviders 中会传入 providersstart 方法中有很多对 reader 和 writer 的调用,稍后会对他们作介绍,这里只需要知道他们可以追踪 SlotTable 中当前应该访问的位置,并完成读/写操作。上面的代码已经经过提炼,逻辑比较清晰:基于 key 比较 Group 是否相同(SlotTable 中的记录与代码现状),如果 Group 没有变化,则调用 startReaderGroup 进一步判断 Group 内的数据是否发生变化如果 Group 发生了变化,则意味着 start 中 Group 需要新增或者位移,通过 pending.getNext 查找 key 是否在 Composition 中存在,若存在则表示需要 Group 需要位移,通过 slot.moveGroup 进行位移如果 Group 需要新增,则根据 Group 类型,分别调用不同的 writer#startXXX 将 Group 插入 SlotTableGroup 内的数据比较是在 startReaderGroup 中进行的,实现比较简单private fun startReaderGroup(isNode: Boolean, data: Any?) { //... if (data != null && reader.groupAux !== data) { recordSlotTableOperation { _, slots, _ -> slots.updateAux(data) //... }reader.groupAux 获取当前 Slot 中的数据与 data 做比较如果不同,则调用 recordSlotTableOperation 对数据进行更新。注意对 SlotTble 的更新并非立即生效,这在后文会作介绍。SlotReader & SlotWriter上面看到,start 过程中对 SlotTable 的读写都需要依靠 Composition 的 reader 和 writer 来完成。writer 和 reader 都有对应的 startGroup/endGroup 方法。对于 writer 来说 startGroup 代表对 SlotTable 的数据变更,例如插入或删除一个 Group ;对于 reader 来说 startGroup 代表着移动 currentGroup 指针到最新位置。currentGroup 和 currentSlot 指向 SlotTable 当前访问中的 Group 和 Slot 的位置。看一下 SlotWriter#startGroup 中插入一个 Group 的实现:private fun startGroup(key: Int, objectKey: Any?, isNode: Boolean, aux: Any?) { //... insertGroups(1) // groups 中分配新的位置 val current = currentGroup val currentAddress = groupIndexToAddress(current) val hasObjectKey = objectKey !== Composer.Empty val hasAux = !isNode && aux !== Composer.Empty groups.initGroup( //填充 Group 信息 address = currentAddress, //Group 的插入位置 key = key, //Group 的 key isNode = isNode, //是否是一个 Node hasDataKey = hasObjectKey, //是否有 DataKey hasData = hasAux, //是否包含数据 parentAnchor = parent, //关联Parent dataAnchor = currentSlot //关联Slot地址 //... val newCurrent = current + 1 this.parent = current //更新parent this.currentGroup = newCurrent //... }insertGroups 用来在 groups 中分配插入 Group 用的空间,这里会涉及到 Gap Buffer 概念,我们在后文会详细介绍。initGroup:基于 startGroup 传入的参数初始化 Group 信息。这些参数都是在编译期随着不同类型的 startXXXGroup 生成的,在此处真正写入到 SlotTable 中最后更新 currentGroup 的最新位置。再看一下 SlotReader#startGroup 的实现:fun startGroup() { //... parent = currentGroup currentEnd = currentGroup + groups.groupSize(currentGroup) val current = currentGroup++ currentSlot = groups.slotAnchor(current) //... }代码非常简单,主要就是更新 currentGroup,currentSlot 等的位置。SlotTable 通过 openWriter/openReader 创建 writer/reader,使用结束需要调用各自的 close 关闭。reader 可以 open 多个同时使用,而 writer 同一时间只能 open 一个。为了避免发生并发问题, writer 与 reader 不能同时执行,所以对 SlotTable 的 write 操作需要延迟到重组后进行。因此我们在源码中看到很多 recordXXX 方法,他们将写操作提为一个 Change 记录到 ChangeList,等待组合结束后再一并应用。6. SlotTable 变更延迟生效Composer 中使用 changes 记录变动列表//Composer.kt internal class ComposerImpl { //... private val changes: MutableList<Change>, //... private fun record(change: Change) { changes.add(change) }Change 是一个函数,执行具体的变动逻辑,函数签名即参数如下://Composer.kt internal typealias Change = ( applier: Applier<*>, slots: SlotWriter, rememberManager: RememberManager ) -> Unitapplier: 传入 Applier 用于将变化应用到 LayoutNode 树,在后文详细介绍 Applierslots:传入 SlotWriter 用于更新 SlotTablerememberManger:传入 RememberManager 用来注册 Composition 生命周期回调,可以在特定时间点完成特定业务,比如 LaunchedEffect 在首次进入 Composition 时创建 CoroutineScope, DisposableEffect 在从 Composition 中离开时调用 onDispose ,这些都是通过在这里注册回调实现的。记录 Change我们以 remember{} 为例看一下 Change 如何被记录。remember{} 的 key 和 value 都会作为 Composition 中的状态记录到 SlotTable 中。重组中,当 remember 的 key 发生变化时,value 会重新计算 value 并更新 SlotTable。//Composables.kt @Composable inline fun <T> remember( key1: Any?, calculation: @DisallowComposableCalls () -> T ): T { return currentComposer.cache(currentComposer.changed(key1), calculation) //Composer.kt @ComposeCompilerApi inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T { @Suppress("UNCHECKED_CAST") return rememberedValue().let { if (invalid || it === Composer.Empty) { val value = block() updateRememberedValue(value) value } else it } as T 如上是 remember 的源码Composer#changed 方法中会读取 SlotTable 中存储的 key 与 key1 进行比较Composer#cache 中,rememberedValue 会读取 SlotTable 中缓存的当前 value。如果此时 key 的比较中发现了不同,则调用 block 计算并返回新的 value,同时调用 updateRememberedValue 将 value 更新到 SlotTable。updateRememberedValue 最终会调用 Composer#updateValue,看一下具体实现://Composer.kt internal fun updateValue(value: Any?) { //... val groupSlotIndex = reader.groupSlotIndex - 1 //更新位置Index recordSlotTableOperation(forParent = true) { _, slots, rememberManager -> if (value is RememberObserver) { rememberManager.remembering(value) when (val previous = slots.set(groupSlotIndex, value)) {//更新 is RememberObserver -> rememberManager.forgetting(previous) is RecomposeScopeImpl -> { val composition = previous.composition if (composition != null) { previous.composition = null composition.pendingInvalidScopes = true //... //记录更新 SlotTable 的 Change private fun recordSlotTableOperation(forParent: Boolean = false, change: Change) { realizeOperationLocation(forParent) record(change) //记录 Change }这里关键代码是对 recordSlotTableOperation 的调用:将 Change 加入到 changes 列表,这里 Change 的内容是通过 SlotWriter#set 将 value 更新到 SlotTable 的指定位置,groupSlotIndex 是计算出的 value 在 slots 中的偏移量。previous 返回 remember 的旧 value ,可用来做一些后处理。从这里也可以看出, RememberObserver 与 RecomposeScopeImpl 等也都是 Composition 中的状态。RememberObserver 是一个生命周期回调,RememberManager#forgetting 对其进行注册,当 previous 从 Composition 移除时,RememberObserver 会收到通知RecomposeScopeImpl 是可重组的单元,pendingInvalidScopes = true 意味着此重组单元从 Composition 中离开。除了 remember,其他涉及到 SlotTable 结构的变化,例如删除、移动节点等也会借助 changes 延迟生效(插入操作对 reader 没有影响不大故会立即应用)。例子中 remember 场景的 Change 不涉及 LayoutNode 的更新,所以 recordSlotTableOperation 中没有使用到 Applier 参数。但是当种族造成 SlotTable 结构发生变化时,需要将变化应用到 LayoutNoel 树,这时就要使用到 Applier 了。应用 Change前面提到,被记录的 changes 等待组合完成后再执行。当 Composable 首次执行时,在 Recomposer#composeIntial 中完成 Composable 的组合//Composition.kt override fun setContent(content: @Composable () -> Unit) { //... this.composable = content parent.composeInitial(this, composable) //Recomposer.kt internal override fun composeInitial( composition: ControlledComposition, content: @Composable () -> Unit //... composing(composition, null) { composition.composeContent(content) //执行组合 //... composition.applyChanges() //应用 Changes //... }可以看到,紧跟在组合之后,调用 Composition#applyChanges() 应用 changes。同样,在每次重组发生后也会调用 applyChanges。override fun applyChanges() { val manager = ... //... applier.onBeginChanges() // Apply all changes slotTable.write { slots -> val applier = applier changes.fastForEach { change -> change(applier, slots, manager) hanges.clear() applier.onEndChanges() //... }在 applyChanges 内部看到对 changes 的遍历和执行。 此外还会通过 Applier 回调 applyChanges 的开始和结束。7. UiApplier & LayoutNodeSlotTable 结构的变化是如何反映到 LayoutNode 树上的呢?前面我们将 Composable 执行后生成的渲染树称为 Composition。其实 Composition 是对这一棵渲染树的宏观认知,准确来说 Composition 内部通过 Applier 维护着 LayoutNode 树并执行具体渲染。SlotTable 结构的变化会随着 Change 列表的应用反映到 LayoutNode 树上。像 View 一样,LayoutNode 通过 measure/layout/draw 等一系列方法完成具体渲染。此外它还提供了 insertAt/removeAt 等方法实现子树结构的变化。这些方法会在 UiApplier 中调用://UiApplier.kt internal class UiApplier( root: LayoutNode ) : AbstractApplier<LayoutNode>(root) { override fun insertTopDown(index: Int, instance: LayoutNode) { // Ignored override fun insertBottomUp(index: Int, instance: LayoutNode) { current.insertAt(index, instance) override fun remove(index: Int, count: Int) { current.removeAt(index, count) override fun move(from: Int, to: Int, count: Int) { current.move(from, to, count) override fun onClear() { root.removeAll() }UiApplier 用来更新和修改 LayoutNode 树:down()/up() 用来移动 current 的位置,完成树上的导航。insertXXX/remove/move 用来修改树的结构。其中 insertTopDown 和 insertBottomUp 都用来插入新节点,只是插入的方式有所不同,一个是自下而上一个是自顶而下,针对不同的树形结构选择不同的插入顺序有助于提高性能。例如 Android 端的 UiApplier 主要依靠 insertBottomUp 插入新节点,因为 Android 的渲染逻辑下,子节点的变动会影响父节点的重新 measure,自此向下的插入可以避免影响太多的父节点,提高性能,因为 attach 是最后才进行。Composable 的执行过程只依赖 Applier 抽象接口,UiApplier 与 LayoutNode 只是 Android 平台的对应实现,理论上我们通过自定义 Applier 与 Node 可以打造自己的渲染引擎。例如 Jake Wharton 有一个名为 Mosaic 的项目,就是通过自定义 Applier 和 Node 实现了自定义的渲染逻辑。Root Node的创建Android 平台下,我们在 Activity#setContent 中调用 Composable://Wrapper.android.kt internal fun AbstractComposeView.setContent( parent: CompositionContext, content: @Composable () -> Unit ): Composition { //... val composeView = ... return doSetContent(composeView, parent, content) private fun doSetContent( owner: AndroidComposeView, parent: CompositionContext, content: @Composable () -> Unit ): Composition { //... val original = Composition(UiApplier(owner.root), parent) val wrapped = owner.view.getTag(R.id.wrapped_composition_tag) as? WrappedComposition ?: WrappedComposition(owner, original).also { owner.view.setTag(R.id.wrapped_composition_tag, it) wrapped.setContent(content) return wrapped }doSetContent 中创建 Composition 实例,同时传入了绑定 Root Node 的 Applier。Root Node 被 AndroidComposeView 持有,来自 View 世界的 dispatchDraw 以及 KeyEvent,touchEvent 等就是从这里通过 Root Node 传递到了 Compose 世界。WrappedComposition 是一个装饰器,也是用来为 Composition 与 AndroidComposeView 建立连接,我们常用的很多来自 Android 的 CompositionLocal 就是这里构建的,比如 LocalContext,LocalConfiguration 等等。8. SlotTable 与 Composable 生命周期Composable 的生命周期可以概括为以下三阶段,现在认识了 SlotTable 之后,我们也可以从 SlotTable 的角度对其进行解释:Enter:startRestartGroup 中将 Composable 对应的 Group 存入 SlotTableRecompose:SlotTable 中查找 Composable (by RecomposeScopeImpl) 重新执行,并更新 SlotTableLeave:Composable 对应的 Group 从 SlotTable 中移除。在 Composable 中使用副作用 API 可以充当 Composable 生命周期回调来使用DisposableEffect(Unit) { //callback when entered the Composition & recomposed onDispose { //callback for leaved the Composition }我们以 DisposableEffect 为例,看一下生命周期回调是如何基于 SlotTable 系统完成的。 看一下 DisposableEffect 的实现,代码如下:@Composable @NonRestartableComposable fun DisposableEffect( key1: Any?, effect: DisposableEffectScope.() -> DisposableEffectResult remember(key1) { DisposableEffectImpl(effect) } private class DisposableEffectImpl( private val effect: DisposableEffectScope.() -> DisposableEffectResult ) : RememberObserver { private var onDispose: DisposableEffectResult? = null override fun onRemembered() { onDispose = InternalDisposableEffectScope.effect() override fun onForgotten() { onDispose?.dispose() onDispose = null override fun onAbandoned() { // Nothing to do as [onRemembered] was not called. }可以看到,DisposableEffect 的本质就是使用 remember 向 SlotTable 存入一个 DisposableEffectImpl,这是一个 RememberObserver 的实现。 DisposableEffectImpl 随着父 Group 进入和离开 SlotTable ,将接收到 onRemembered 和 onForgotten 的回调。还记得前面讲过的 applyChanges 吗,它发生在重组完成之后override fun applyChanges() { val manager = ... // 创建 RememberManager //... // Apply all changes slotTable.write { slots -> //... changes.fastForEach { change -> //应用 changes, 将 ManagerObserver 注册进 RememberMananger change(applier, slots, manager) //... //... manager.dispatchRememberObservers() //分发回调 }前面也提到,SlotTable 写操作中发生的 changes 将在这里统一应用,当然也包括了 DisposableEffectImpl 插入/删除时 record 的 changes,具体来说就是对 ManagerObserver 的注册,会在后面的 dispatchRememberObservers 中进行回调。重组是乐观的官网文档中在介绍重组有这样一段话:重组是“乐观”的When recomposition is canceled, Compose discards the UI tree from the recomposition. If you have any side-effects that depend on the UI being displayed, the side-effect will be applied even if composition is canceled. This can lead to inconsistent app state. Ensure that all composable functions and lambdas are idempotent and side-effect free to handle optimistic recomposition.https://developer.android.com/jetpack/compose/mental-model#optimistic很多人初看这段话会不明所以,但是在解读了源码之后相信能够理解它的含义了。这里所谓 “乐观” 是指 Compose 的重组总是假定不会被中断,一旦发生了中断,Composable 中执行的操作并不会真正反映到 SlotTable,因为通过源码我们知道了 applyChanges 发生在 composiiton 成功结束之后。如果组合被中断,你在 Composable 函数中读取的状态很可能和最终 SlotTable 中的不一致。因此如果我们需要基于 Composition 的状态进行一些副作用处理,必须要使用 DisposableEffect 这样的副作用 API 包裹,因为通过源码我们也知道了 DisposableEffect 的回调是 applyChanges 执行的,此时可以确保重组已经完成,获取的状态与 SlotTable 相一致。9. SlotTable 与 GapBuffer前面介绍过,startXXXGroup 中会与 SlotTable 中的 Group 进行 Diff,如果比较不相等,则意味着 SlotTable 的结构发生了变化,需要对 Group 进行插入/删除/移动,这个过程是基于 Gap Buffer 实现的。Gap Buffer 概念来自文本编辑器中的数据结构,可以将它理解为线性数组中可滑动、可伸缩的缓存区域,具体到 SlotTable 中,就是 groups 中的未使用的区域,这段区域可以在 groups 移动,提升 SlotTble 结构变化时的更新效率,以下举例说明:@Composable fun Test(condition: Boolean) { if (condition) { Node1() Node2() Node3() Node4() }SlotTable 初始只有 Node3,Node4,而后根据状态变化,需要插入 Node1,Node2,这个过程中如果没有 Gap Buffer,SlotTable 的变化如下图所示:每次插入新 Node 都会导致 SlotTable 中已有 Node 的移动,效率低下。再看一下引入 Gap Buffer 之后的行为:当插入新 Node 时,会将数组中的 Gap 移动到待插入位置,然后再开始插入新 Node。再插入 Node1,Node2 甚至它们的子 Node,都是在填充 Gap 的空闲区域,不会影响造成 Node 的移动。看一下移动 Gap 的具体实现,相关代码如下://SlotTable.kt private fun moveGroupGapTo(index: Int) { //... val groupPhysicalAddress = index * Group_Fields_Size val groupPhysicalGapLen = gapLen * Group_Fields_Size val groupPhysicalGapStart = gapStart * Group_Fields_Size if (index < gapStart) { groups.copyInto( destination = groups, destinationOffset = groupPhysicalAddress + groupPhysicalGapLen, startIndex = groupPhysicalAddress, endIndex = groupPhysicalGapStart //... }Index 是要插入 Group 的位置,即需要将 Gap 移动到此处Group_Fields_Size 是 groups 中单位 Group 的长度,目前是常量 5。几个临时变量的含义也非常清晰:groupPhysicalAddress: 当前需要插入 group 的地址groupPhysicalGapLen: 当前Gap 的长度groupPhysicalGapStart:当前Gap 的起始地址当 index < gapState 时,需要将 Gap 前移到 index 位置为新插入做准备。从后面紧跟的 copyInto 的参数可知,Gap 的前移实际是通过 group 后移实现的,即将 startIndex 处的 Node 复制到 Gap 的新位置之后 ,如下图:这样我们不需要真的移动 Gap,只要将 Gap 的 start 的指针移动到 groupPyhsicalAddress 即可,新的 Node1 将在此处插入。当然,groups 移动之后,anchor 等关联信息也要进行相应的更新。最后再看一下删除 Node 时的 Gap 移动情况,原理也是类似的:将 Gap 移动到待删除 Group 之前,然后开始删除 Node,这样,删除过程其实就是移动 Gap 的 end 位置而已,效率很高而且保证了 Gap 的连续。10. 总结SlotTable 系统是 Compose 从组合到渲染到屏幕,整个过程中的最重要环节,结合下面的图我们回顾一下整个流程:Composable 源码在编译期会被插入 startXXXGroup/endXXXGroup 模板代码,用于对 SlotTable 的树形遍历。Composable 首次组合中,startXXXGroup 在 SlotTable 中插入 Group 并通过 $key 识别 Group 在代码中的位置重组中,startXXXGroup 会对 SlotTable 进行遍历和 Diff,并通过 changes 延迟更新 SlotTable,同时应用到 LayoutNode 树渲染帧到达时,LayoutNode 针对变更部分进行 measure > layout > draw,完成 UI 的局部刷新。

Google I/O 2022: Android Jetpack 最新进展

今年的 I/O 大会既是谷歌各种新产品发布会,同时也是谷歌开发者们的技术交流会。不少 Android 开发者希望通过本次 I/O 了解到有关 Jetpack 的最新动态。 Jetpack 早已成为我们日常开发中的必备工具,根据本次大会上发布的数据,目前 GooglePlay 排名前 1000 的应用中,使用至少两个以上 Jetpack 库的占比从 79% 提升到 90%。接下来,本文将从 Architecture,UI,Performance 和 Compose 等四个方向带大家了解 Jetpack 在本次大会上又有哪些新变化。1. Architecture1.1 Room 2.4/2.5Room 最新版本进入到 2.5。 2.5 没有新功能的引入,最大变化就是使用 Kotlin 进行了重写,借助 Kotlin 空安全等特性,代码将更加稳定可靠。未来还会有更多 Jetpack 库逐渐迁移至 Kotlin。在功能方面,Room 自 2.4 以来引入了不少新特性:KSP:新的注解处理器Room 将注解处理方式从 KAPT 升级为 KSP(Kotlin Symbol Processing)。 KSP 作为新一代 Kotlin 注解处理器,1.0 版目前已正式发布,功能更加稳定,可以帮助你极大缩短项目的构建时间。KSP 的启用非常简单,只要像 KAPT 一样地配置即可:plugins { //enable kapt id 'kotlin-kapt' //enable ksp id("com.google.devtools.ksp") dependencies { //... // use kapt kapt "androidx.room:room-compiler:$room_version" // use ksp ksp "androidx.room:room-compiler:$room_version" //... }Multi-map Relations:返回一对多数据以前,Room 想要返回一对多的实体关系,需要额外增加类型定义,并通过 @Relatioin 进行关联,现在可以直接使用 Multi-map 返回,代码更加精简://before data class ArtistAndSongs( ` @Embedded val artist: Artist, @Relation(...) val songs: List<Song> @Query("SELECT * FROM Artist") fun getArtistAndSongs(): List<ArtistAndSongs> //now @Query("SELECT * FROM Artist JOIN Song ON Artist.artistName = Song.songArtistName") fun getAllArtistAndTheirSongsList(): Map<Artist, List<Song>>AutoMigrations:自动迁移以前,当数据库表结构变化时,比如字段名之类的变化,需要手写 SQL 完成升级,而最近新增的 AutoMigrations 功能可以检测出两个表结构的区别,完成数据库字段的自动升级。 @Database( version = MusicDatabase.LATEST_VERSION, entities = { Song.class, Artist.class }, autoMigrations = { @AutoMigration ( from = 1, to = 2 exportSchema = true public abstract class MusicDatabase extends RoomDatabase { }1.2 Paging3Paging3 相对于 Paging2 在使用方式上发生了较大变化。首先它提升了 Kotlin 协程的地位, 将 Flow 作为首选的分页数据的监听方案,其次它提升了 API 的医用型,降低了理解成本,同时它有着更丰富的能力,例如支持设置 Header 和 Footer等,建议大家尽可能地将项目中的 Paging2 升级到 Paging3。简单易用的数据源Paging2 的数据源有多种实现,PageKeyedDataSource, PositionalDataSource, ItemKeyedDataSource 等,需要我们根据场景做出不同选择 ,而 Paging3 在使用场景上进行了整合和简化,只提供一种数据源类型 PagingSource:class MyPageDataSource(private val repo: DataRepository) : PagingSource<Int, Post>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Data> { try { val currentLoadingPageKey = params.key ?: 1 // 从 Repository 拉去数据 val response = repo.getListData(currentLoadingPageKey) val prevKey = if (currentLoadingPageKey == 1) null else currentLoadingPageKey - 1 // 返回分页结果,并填入前一页的 key 和后一页的 key return LoadResult.Page( data = response.data, prevKey = prevKey, nextKey = currentLoadingPageKey.plus(1) } catch (e: Exception) { return LoadResult.Error(e) }上面例子是一个自定义的数据源, Paging2 数据源中 load 相关的 API 有多个,但是 Paging3 中都统一成唯一的 load 方法,我们通过 LoadParams 获取分页请求的参数信息,并根据请求结果的成功与否,返回 LoadResult.Page() ,LoadResult.Invalid 或者 LoadResult.Error,方法的的输入输出都十分容理解。支持 RxJava 等主流三方库在 Paging3 中我们通过 Pager 类订阅分页请求的结果,Pager 内部请求 PagingSource 返回的数据,可以使用 Flow 返回一个可订阅结果class MainViewModel(private val apiService: APIService) : ViewModel() { val listData = Pager(PagingConfig(pageSize = 6)) { PostDataSource(apiService) }.flow.cachedIn(viewModelScope) }除了默认集成的 Flow 方式以外,通过扩展 Pager 也可返回 RxJava,Guava 等其他可订阅类型implementation "androidx.paging:paging-rxjava2:$paging_version" implementation "androidx.paging:paging-guava:$paging_version"例如,paging-rxjava2 中提供了将 Pager 转成 Observable 的方法:val <Key : Any, Value : Any> Pager<Key, Value>.observable: Observable<PagingData<Value>> get() = flow.conflate().asObservable()新增的事件监听Paging3 通过 PagingDataDiffer 检查列表数据是否有变动,如果提交数据与并无变化则 PagingDataAdapter 并不会刷新视图。 因此 Paging3 为 PagingDataDiffer 中新增了 addOnPagesUpdatedListener 方法,通过它可以监听提交数据是否确实更新到了屏幕。配合 Room 请求本地数据源通过 room-paging ,Paging3 可以配合 Room 实现本地数据源的分页加载implementation "androidx.room:room-paging:2.5.0-alpha01"room-paging 提供了一个开箱即用的数据源 LimitOffsetPagingSource/** * An implementation of [PagingSource] to perform a LIMIT OFFSET query * This class is used for Paging3 to perform Query and RawQuery in Room to return a PagingSource * for Pager's consumption. Registers observers on tables lazily and automatically invalidates * itself when data changes. @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) abstract class LimitOffsetPagingSource<Value : Any>( private val sourceQuery: RoomSQLiteQuery, private val db: RoomDatabase, vararg tables: String, ) : PagingSource<Int, Value>() 在构造时,基于 SQL 语句创建 RoomSQLiteQuery 并连同 db 实例一起传入即可。更多参考:https://proandroiddev.com/paging-3-easier-way-to-pagination-part-1-584cad1f4f611.3 Navigation 2.4Multiple back stacks 多返回栈Navigation 2.4.0 增加了对多返回栈的支持。当下大部分移动应用都带有多 Tab 页的设计。由于所有 Tab 页共享同一个 NavHostFramgent 返回栈,因此 Tab 页内的页面跳转状态会因 Tab 页的切换而丢失,想要避免此问题必须创建多个 NavHostFragment。implementation "androidx.navigation:navigation-ui:$nav_version"在 2.4 中通过 navigation-ui 提供的 Tab 页相关组件,可以实现单一 NavHostFragment 的多返回栈class MainActivity : AppCompatActivity() { private lateinit var navController: NavController private lateinit var appBarConfiguration: AppBarConfiguration override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val navHostFragment = supportFragmentManager.findFragmentById( R.id.nav_host_container ) as NavHostFragment //获取 navController navController = navHostFragment.navController // 底部导航栏设置 navController val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottom_nav) bottomNavigationView.setupWithNavController(navController) // AppBar 设置 navController appBarConfiguration = AppBarConfiguration( setOf(R.id.titleScreen, R.id.leaderboard, R.id.register) val toolbar = findViewById<Toolbar>(R.id.toolbar) setSupportActionBar(toolbar) toolbar.setupWithNavController(navController, appBarConfiguration) override fun onSupportNavigateUp(): Boolean { return navController.navigateUp(appBarConfiguration) }如上,通过 navigation-ui 的 setupWithNavController 为 BottomNavigationView 或者 AppBar 设置 NavController,当 Tab 页来回切换时依然可以保持 Tab 内部的返回栈状态。升级到 2.4.0 即可,无需其他代码上的修改。更多参考:https://medium.com/androiddevelopers/navigation-multiple-back-stacks-6c67ba41952fTwo pane layout 双窗格布局在平板等大屏设备下,为应用采用双窗格布局将极大提升用户的使用体验,比较典型的场景就是左屏列展示表页,右屏展示点击后的详情页。SlidingPaneLayout 可以为开发者提供这种水平的双窗格布局Navigation 2.4.0 提供了AbstractListDetailFragment,内部通过继承 SlidingPaneLayout ,实现两侧 Fragment 单独显示,而详情页部分更是可以实现独立的页面跳转:class TwoPaneFragment : AbstractListDetailFragment() { override fun onCreateListPaneView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return inflater.inflate(R.layout.list_pane, container, false) //创建详情页区域的 NavHost override fun onCreateDetailPaneNavHostFragment(): NavHostFragment { return NavHostFragment.create(R.navigation.two_pane_navigation) override fun onListPaneViewCreated(view: View, savedInstanceState: Bundle?) { super.onListPaneViewCreated(view, savedInstanceState) val recyclerView = view as RecyclerView recyclerView.adapter = TwoPaneAdapter(map.keys.toTypedArray()) { map[it]?.let { destId -> openDetails(destId) } private fun openDetails(destinationId: Int) { //获取详情页区域的 NavController 实现详情页的内容切换 val detailNavController = detailPaneNavHostFragment.navController detailNavController.navigate( destinationId, null, NavOptions.Builder() .setPopUpTo(detailNavController.graph.startDestinationId, true) .apply { if (slidingPaneLayout.isOpen) { setEnterAnim(R.anim.nav_default_enter_anim) setExitAnim(R.anim.nav_default_exit_anim) .build() slidingPaneLayout.open() companion object { val map = mapOf( "first" to R.id.first_fragment, "second" to R.id.second_fragment, "third" to R.id.third_fragment, "fourth" to R.id.fourth_fragment, "fifth" to R.id.fifth_fragment }支持 ComposeNavigation 通过 navigation-compose 支持了 Compose 的页面导航,这对于一个 Compose first 的项目非常重要。implementation "androidx.navigation:navigation-compose:$nav_version"navigation-compose 中,Composable 函数替代 Fragment 成为页面导航的 Destination,我们使用 DSL 定义基于 Composable 的 NavGraph:val navController = rememberNavController() Scaffold { innerPadding -> NavHost(navController, "home", Modifier.padding(innerPadding)) { composable("home") { // This content fills the area provided to the NavHost HomeScreen() dialog("detail_dialog") { // This content will be automatically added to a Dialog() composable // and appear above the HomeScreen or other composable destinations DetailDialogContent() }如上, composable 方法配置导航中的 Composable 页面,dialog 配置对话框,而 navigation-fragment 中各种常见功能,比如 Deeplinks,NavArgs,甚至对 ViewModel 的支持在 Compose 项目中同样可以使用。1.4 Fragment每次 I/O 大会几乎都有关于 Fragment 的分享,因为它是我们日常开发中重度使用的工具。本次大会没有带来 Fragment 的新功能,相反对 Framgent 的功能进行了大幅“削减”。不必惊慌,这并非是从代码上删减了功能,而是对 Fragment 使用方式的重定义。随着 Jetpack 组件库的丰富,Fragment 的很多职责已经被其他组件所分担,所以谷歌希望开发者能够重新认识这个老朋友,对使用场景的必要性进行更合理评估。Fragmen 在最早的设计中作为 Activity 的代理者出现,因此它承担了很多来自 Activity 回调,例如 Lifecycle,SaveInstanceState,onActivityResult 等等以前:各种职责现在:职责外移 而如今这些功能已经有了更好的替代方案,生命周期可以提供 Lifecycle 组件感知,数据的保存恢复也可以通过 ViewModel 实现,因此 Fragment 只需要作为页面侧承载着持有 View 即可,而随着 Navigation 对 Compose 的支持,Fragment 作为页面载体的职责也变得不在必要。尽管如此,我们也并不能彻底抛弃 Fragment,在很多场景中 Fragment 仍然是最佳选择,比如我们可以借助它的 ResultAPI 实现更简单的跨页面通信:当我们需要通知一些一次性结果时,ResulAPI 比共享 ViewModel 的通信方式将更加简单安全,它像普通回调一般的使用方式极其简单:// 在 FramgentA 中监听结果 setFragmentResultListener("requestKey") { requestKey, bundle -> // 通过约定的 key 获取结果 val result = bundle.getString("bundleKey") // ... // FagmentB 中返回结果 button.setOnClickListener { val result = "result" // 使用约定的 key 发送结果 setFragmentResult("requestKey", bundleOf("bundleKey" to result)) } 总结起来,Fragment 仍然是我们日常开发中的重要手段,但是它的角色正在发生变化。2. Performance2.1 JankStats 卡顿检测JankStats 用来追踪和分析应用性能,发现 Jank 卡顿问题,它最低向下兼容到 API 16,可以在绝大多数机器设备上使用,有了它我们不必再求助 BlockCanery 等三方工具了。implementation "androidx.metrics:metrics-performance:1.0.0-alpha01"我们需要为每个 Window 创建一个 JankStats 实例,并通过 OnFrameListener 回调获取包含是否卡顿在内的帧信息,示例如下:class JankLoggingActivity : AppCompatActivity() { private lateinit var jankStats: JankStats override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... // metricsStateHolder可以收集环境信息,跟随帧信息返回 val metricsStateHolder = PerformanceMetricsState.getForHierarchy(binding.root) // 基于当前 Window 创建 JankStats 实例 jankStats = JankStats.createAndTrack( window, Dispatchers.Default.asExecutor(), jankFrameListener, // 设置 Activity 名字到环境信息 metricsStateHolder.state?.addState("Activity", javaClass.simpleName) // ... private val jankFrameListener = JankStats.OnFrameListener { frameData -> // 监听到的帧信息 Log.v("JankStatsSample", frameData.toString()) }PerformanceMetricsState 用来收集你希望跟随 frameData 一起返回的状态信息,比如上面例子中设置了当前 Activity 名称,下面是 frameData 的打印日志:JankStats.OnFrameListener: FrameData(frameStartNanos=827233150542009, frameDurationUiNanos=27779985, frameDurationCpuNanos=31296985, isJank=false, states=[Activity: JankLoggingActivity])更多参考:https://medium.com/androiddevelopers/jankstats-goes-alpha-8aff942255d52.2 Baseline Profiles 基准配置Android 8.0 之后默认开启 ART 虚拟机。ART 最初版本在安装应用时会对全部代码进行 AOT 预编译,将字节码转换为机器码存在本地,这提升了运行时的速度,但是会导致安装过程变慢。因此后来 ART 改进为 JIT 和 AOT 相结合的方式,在应用安装时只将热点代码编译成机器码,缩短安装时间。Baselin Profiles 基准配置文件允许我们配置哪些代码成为热点代码。基准配置文件将在 APK 的 assets/dexopt/baseline.prof 中编译为二进制形式,例如如果我们想提升首帧的性能,可以将应用启动或帧渲染期间使用的方法配置到 prof 文件中。prof 文件可以通过自动或手动方式生成,我们可以编写 JUnit4 测试用例,通过执行 BaselineProfileRule 在测试中发现待优化的瓶颈代码,并生成对应的 prof 文件@ExperimentalBaselineProfilesApi @RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { @get:Rule val baselineProfileRule = BaselineProfileRule() @Test fun startup() = baselineProfileRule.collectBaselineProfile(packageName = "com.example.app") { pressHome() startActivityAndWait() }我们也可以手动创建 prof 文件,只需遵循一些简单的语法规则。例如下面展示了 Jetpack Compose 库中包含的一些 Prof 规则,HSPLandroidx/compose/runtime/ComposerImpl;->updateValue(Ljava/lang/Object;)V HSPLandroidx/compose/runtime/ComposerImpl;->updatedNodeCount(I)I HLandroidx/compose/runtime/ComposerImpl;->validateNodeExpected()V PLandroidx/compose/runtime/CompositionImpl;->applyChanges()V HLandroidx/compose/runtime/ComposerKt;->findLocation(Ljava/util/List;I)I Landroidx/compose/runtime/ComposerImpl;上述配置遵循 [FLAGS][CLASS_DESCRIPTOR]->[METHOD_SIGNATURE] 格式,其中 FLAGS 中的 H/S/P 代表方法的调用实际,比如是否是启动时调用等。更多参考:https://android-developers.googleblog.com/2022/01/improving-app-performance-with-baseline.html2.3 Benchmark 基准测试Jetpack 当前提供了两套 Benchmark 库,Microbenchmark 和 Macrobenchmark (微基准和宏基准),分别用于不同场景下的基准测试。Mircobenchmark 的测试对象是代码块,它的依赖如下:androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.1.0-beta03'我们可以在 JUnit4 中应用 BenchmarkRule,示例如下:@RunWith(AndroidJUnit4::class) class SampleBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() @Test fun benchmarkSomeWork() { benchmarkRule.measureRepeated { doSomeWork() //执行待测试代码 }Macrobenchmark 通常面向更大粒度的场景测试,例如一个 Activity 启动或者一个用户操作等。由于 Macrobenchmark 不进行代码级别测试,我们可以创建独立于业务代码的单独模块进行测试:下面展示了使用 MacrobenchmarkRule 测试一个 Activity 的启动: @get:Rule val benchmarkRule = MacrobenchmarkRule() @Test fun startup() = benchmarkRule.measureRepeated( packageName = "mypackage.myapp", metrics = listOf(StartupTimingMetric()), iterations = 5, startupMode = StartupMode.COLD ) { // this = MacrobenchmarkScope pressHome() val intent = Intent() intent.setPackage("mypackage.myapp") intent.setAction("mypackage.myapp.myaction") startActivityAndWait(intent) }配合 2021.1.1 或更高版本的 Android Studio ,Benchmark 的测试结果会直接显示在 IDE 窗口中。当然,测试结果也可以导出为 JSON 格式更多参考:https://medium.com/androiddevelopers/measure-and-improve-performance-with-macrobenchmark-560abd0aa5bb2.4 Tracing 事件追踪Tracing 用来在代码添加 trace 信息,trace 信息可以显示在 Systrace 和 Perfetto 等工具中。implementation "androidx.tracing:tracing:1.1.0-beta01"下面的例子汇总,我们通过 Trace 类的 benginSection/endSection 方法追踪 onCreateViewHolder 和 onBindViewHolder 方法执行的起始点class MyAdapter : RecyclerView.Adapter<MyViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { return try { Trace.beginSection("MyAdapter.onCreateViewHolder") MyViewHolder.newInstance(parent) } finally { //endSection 放到 finally 里,当出现异常时也会调用 Trace.endSection() override fun onBindViewHolder(holder: MyViewHolder, position: Int) { Trace.beginSection("MyAdapter.onBindViewHolder") try { try { Trace.beginSection("MyAdapter.queryDatabase") val rowItem = queryDatabase(position) dataset.add(rowItem) } finally { Trace.endSection() holder.bind(dataset[position]) } finally { Trace.endSection() }需要注意 benginSection/endSection 必须成对出现,且必须在同一线程中。我们 Trace 的 section 会作为新增的自定义事件出现在 Perfetto 等工具视图中:3. UI3.1 WindowManager这并非系统 WMS 获取的那个 WindowManager,它是 Jetpack 的新成员,当前刚刚迈入 1.1.0。implementation "androidx.window:window:1.1.0-alpha02"它可以帮助我们适配日益增多的可折叠设备,满足多窗口环境下的开发需求。可折叠设备通常分为两类:单屏可折叠设备(一个整体的柔性屏幕)和双屏可折叠设备(两个屏幕由合页相连)。目前单屏可折叠设备正逐渐成为主流,但无论哪种设备都可以通过 WindowManager 感知当前的屏幕显示特性,例如当前折叠的状态和姿势等。获取折叠状态多屏设备下,一个窗口可能会跨越物理屏幕显示,这样窗口中会出现铰链等不连续部分,FoldingFeature (DisplayFeature 的子类)对铰链这类的物理部件进行抽象,从中可以获取铰链在窗口中的准确位置,帮助我们避免将关键交互按钮布局在其中。另外 FoldingFeature 还提供了可以感知感知当前折叠状态的 API,我们可以根据这些状态改变应用的布局://铰链处于半开状态且位置水平,适合切换到平板模式 fun isTableTopMode(foldFeature: FoldingFeature) = foldFeature.isSeparating && foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL //铰链处于半开状态且位置垂直,适合切换到阅读模式 fun isBookMode(foldFeature: FoldingFeature) = foldFeature.isSeparating && foldFeature.orientation == FoldingFeature.Orientation.VERTICALWindowManager 允许我们通过 Flow 持续观察显示特性的变化。lifecycleScope.launch(Dispatchers.Main) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { WindowInfoTracker.getOrCreate(this@SampleActivity) .windowLayoutInfo(this@SampleActivity) .collect { newLayoutInfo -> // Use newLayoutInfo to update the layout. }如上,当显示特性变化时,我们能获取 newLayoutInfo ,它是一个 WindowLayoutInfo 类型,内部持有了 FoldingFeature 信息。感知窗口大小变化应用窗口可能跟随设备配置变化时(例如折叠屏的展开、旋转,或窗口在多窗口模式下调整大小)发生变化,我们可以通过 WIndowManger 的 WindowMetrics 获取窗口大小,我们有两种获取当前 WindowMetrics 的方式,同步获取和异步监听://异步监听 lifecycleScope.launch(Dispatchers.Main) { windowInfoRepository().currentWindowMetrics.flowWithLifecycle(lifecycle) .collect { windowMetrics: WindowMetrics -> val currentBounds = windowMetrics.bounds val width = currentBounds.width() val height = currentBounds.height() //同步获取 val windowMetrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity) val currentBounds = windowMetrics.bounds val width = currentBounds.width() val height = currentBounds.height()更多参考:https://medium.com/androiddevelopers/unbundling-the-windowmanager-fa060adb3ce93.2 DragAndDropJetpack DragAndDrop 是专门处理拖放手势的库,它除了服务于普通手机设备上的开发,更重要的意义是可以实现折叠设备跨屏幕的拖放implementation 'androidx.draganddrop:draganddrop:1.0.0-alpha02'DragStartHelper 和 DropHelper 是其最核心的 API,可以配置拖防过程中的数据传递、显示效果等,还可以监听手势回调。拖动 DragStartHelperDragStartHelper 负责监测拖动手势的开始时机,包括长按拖动、单击并用鼠标拖动等。我们可以将需要拖动的视图对象包装进来并开启监听,当监听到拖动手势触发时,完成一些简单配置即可。// 使用 DragStartHelper 包装 draggableView 对象 DragStartHelper(draggableView) { view, _ -> // 将需要传递的数据封装到 ClipData 中 val dragClipData = ClipData.newUri(contentResolver, "File", fileUri) // 创建目标拖动时的展示图片,可自定义也可以根据 draggableView 创建默认样式 val dragShadow = View.DragShadowBuilder(view) // 基于数据、拖动效果启动拖动 view.startDragAndDrop( dragClipData, dragShadow, null, // Optional extra local state information // 添加 flag 启动全局拖动 DRAG_FLAG_GLOBAL or DRAG_FLAG_GLOBAL_URI_READ) }.attach()如上,准备好需要拖动数据和样式等,调用 View#startDragAndDrop 启动拖动。例子中拖动的目标是 content: 这类 URI,因此我们可以通过设置 DRAG_FLAG_GLOBAL 实现跨进程的拖动。放置 DropHelperDropHelper 是另一个核心 API,关心拖动数据放下的时机和目标视图。//针对可拖放视图调用 configureView DropHelper.configureView( this,// 当前Activity outerDropTarget, //接收拖放的对象,会根据情况高亮显示 arrayOf(MIMETYPE_TEXT_PLAIN, "image/*"), // 支持的 MIME 类型 DropHelper.Options.Builder() //一些参数配置,例如放下时高亮的颜色,视图范围等 .addInnerEditTexts(innerEditText) .build() ) { _, payload -> // 监听到目标的放下,可以从 ClipData 中取得数据, // 执行上传、显示等处理,当然还可以处理非法拖放时的警告或视图提醒等 }构建 DropHelper.Options 实例的时候,需要调用 addInnerEditTexts(),这样可以确保嵌套的 EditText 控件不会抢夺视图焦点。更多参考:https://medium.com/androiddevelopers/simplifying-drag-and-drop-3713d6ef526e4. Compose今年 I/O 大会上关于 Compose 的主题分享明显增多了,这也表明了谷歌对于 Compose 推广之重视。目前 GooglePlay Top1000 的应用中使用 Compose 的已经超过了 100 个,其中不乏一些领域头部应用,Compose 的稳定性和成熟度也借机得到了验证。让我们看看 Compose 最新的 1.2 Beta 版本带来哪些新内容。4.1 Material 3新增的 Compose.M3 库,可以帮助我们开发符合 Material You 设计规范的的 UI 界面。implementation "androidx.compose.material3:material3:1.0.0-alpha10" implementation "androidx.compose.material3:material3-window-size-class:1.0.0-alpha10"Material3 强调颜色的个性化和动态切换,Compose.M3 引入 ColorScheme 类自定义配色方案:val AppLightColorScheme = lightColorScheme ( primary = Color(...), // secondary、tertiary 等等 // 具有浅色基准值的 ColorScheme 实例 val AppDarkColorScheme = darkColorScheme( // primary、secondary、tertiary 等等 // 具有深色基准值的 ColorScheme 实例 val dark = isSystemInDarkTheme() val colorScheme = if (dark) AppDarkColorScheme else AppLightColorScheme // 将 colorScheme 作为参数传递给 MaterialTheme。 MaterialTheme ( colorScheme = colorScheme, // 字型 // 应用内容 }上面是 MaterialTheme 通过 ColorScheme 配置不同主题颜色的例子,可以看到这与 Compose.M2 中 Colors 用法区别不大, 但是 ColorScheme 可定义的颜色槽(Primary,Secondary,Error 等MD颜色常量)种类更多,而且还可以支持 DynamicColor 动态配色。DynamicColor 是 Material3 的重要特色,在 Android12 及以上设备中,可以实现应用的颜色跟随壁纸变化。如今 Compose 中也可以实现这个效果// Dynamic color is available on Android 12+ val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colorScheme = when { dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) darkTheme -> DarkColorScheme else -> LightColorScheme }如上,Compose 通过 dynamicXXXColorScheme 设置的颜色,无论是亮色还是暗色主题,都可以跟随用户设置的壁纸而变化:![](https://upload-images.jianshu.io/upload_images/26794336-c40fdcd3ad5cf4d5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)更多参考:https://juejin.cn/post/70644108354220196154.2 Nested Scrolling InteropCompose 支持与传统视图控件进行互操作,便于我们阶段性的引入 Compose 到项目中。但是在涉及到带有 Nested Scrolling 事件分发的场景中(例如 CoordinatorLayout ),会发生事件无法正常传递的兼容性问题,在 1.2 中对于此类问题进行了修复,无论是 CoordinatorLayout 内嵌 Composable , 或者在 Composable 中使用 Scrolling View 控件,事件传递都会更加平顺:https://android-review.googlesource.com/c/platform/frameworks/support/+/2004590 https://android-review.googlesource.com/c/platform/frameworks/support/+/20388234.3 Downloadable FontsAndroid 8.0(API level 26)起支持了对可下载的谷歌字体的使用,允许通过代码动态请求一个非内置字体文件。在 Compose 1.2 对此功能也进行了支持,注意这个功能需要基于 GMS 服务。implementation "androidx.compose.ui:ui-text-google-fonts:1.1.1"使用时,首先使用 FontProvider 定义字体请求信息@OptIn(ExperimentalTextApi::class) val provider = GoogleFont.Provider( providerAuthority = "com.google.android.gms.fonts", providerPackage = "com.google.android.gms", certificates = R.array.com_google_android_gms_fonts_certs 然后,使用此 Provider 定义 FontFamily,接着在 Composable 应用即可, val fontName = GoogleFont(“Lobster Two”) val fontFamily = FontFamily( Font(googleFont = GoogleFont(name), fontProvider = provider) Text( fontFamily = fontFamily, text = "Hello World!" )4.4 Lazy GridCompose 1.2 中进一步优化了 LazyRow 和 LazyColumn 的性能,并在此基础上新增了 LazyGrid 用来实现需求中常见的网格布局效果。Lazy Grid 在 1.0.2 就已经引入,如今 1.2 中对 API 进行调整并使之达到稳定。以 LazyVerticalGrid 为例,我们可以通过 GridCells.Fixed 设置每行单元格的数量:val data = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5") LazyVerticalGrid( columns = GridCells.Fixed(3), contentPadding = PaddingValues(8.dp) ) {//this: LazyGridScope items(data.size) { index -> Card( modifier = Modifier.padding(4.dp), backgroundColor = Color.LightGray Text( text = data[index], textAlign = TextAlign.Center }此外,也可以通过 GridCells.Adaptive() 通过制定单元格大小决定每行的数量。此时,所有单元格都会以 Adaptive 中的值设置统一的 width。LazyGridScope 像 LazyListScope 一样也提供了 item, items, itemsIndexed 等方法布局子项。另外 LazyGridState 中的方法也基本上对齐了 LazyListState。4.5 Tools在工具方面,Android Studio 为 Compose 的开发调试提供了更多实用功能。@Preview & Live Edit1.2.0 中的 @Preview 可以作为元注解使用,修饰其他自定义注解@Preview(showBackground = true) @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) annotation class MyDevices() @MyDevices @Composable fun Greeting() { }如上,我们可以通过自定义注解可以复用 @Preview 中的各种配置,减少为了预览而写的模板代码。说到预览,Android Studio 一直致力于提升预览效率,Android Studio Arctic Fox 曾引入 Live literals 功能,对于代码中 Int,String,Color,Dp,Boolean 等常见类型的字面值的修改,无需编译即可在预览画面中实时更新。本次大会上带来了升级版的 Live Edit,它需要使用最新的 Android Studio Electric Eel 中开启。不仅仅是字面值,它可以让任意代码的修改(函数签名变动之类的修改不行),在预览窗口或者你的设备上立即生效,几乎实现了前端一般的开发体验,是本次大会令我惊喜的功能,它将大幅提高 Compose 的开发和调试效率。Layout Inspector & Recomposition Counts我们在传统视图开发中经常使用 Layout Inspector 观察视图结构, Compose 虽然基于 Composable 函数构建 UI ,但同样也得到了 layout Inspector 的支持,它可以帮助我们查看 Composition 视图树的布局。此外,本次 I/O 还介绍了 Layout Inspector 的一个新功能 Recomposition Counts,我们知道不必要的重组会拖慢 Compose UI 的刷新性能,借助这个新工具,我们可以在 IDE 中调试和观察 Composable 重组次数,帮助我们及时发现和优化不符合预期的多余重组。Animation PreviewAndroid Studio 增加了对 Compose 动画效果实时预览。在动画预览窗口中,每个动画的状态值会以多轨道的形式呈现,我们可以查看特定时间点下的每个动画值的确切值,并且可以暂停、循环播放动画、快进或放慢动画,以便在动画过渡过程中调试动画。Compose 的动画 API 数量众多,目前并非所有的 API 都支持预览,IDE 会自动检查代码中可以进行预览的动画,并添加 Start Animation Inspection 图标,便于开发者发现和使用4.6 适应多种屏幕尺寸Compose 正逐渐成为 Android 的首选 UI 开发方案,所以为了覆盖尽可能多的使用场景,Compose 第一时间对各种屏幕尺寸下(手机,平板,电脑,折叠屏)的 UI 开发进行了支持。在具体开发中,我们需要先定义 WindowSizeClass 对各种屏幕类型分类,推荐分为三类: 当屏幕尺寸因为设备折叠等发生变化时,Compose 会自动响应 onConfigurationChanged 触发重组,重组中我们根据当前屏幕尺寸转换为对应的 WindowSizeClass@Composable fun Activity.rememberWindowSizeClass(): Pair<WindowWidthSizeClass, WindowHeightSizeClass> { val configuration = LocalConfiguration.current val windowMetrics = remember(configuration) { WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(this) val windowDpSize = with(LocalDensity.current) { windowMetrics.bounds.toComposeRect().size.toDpSize() val widthWindowSizeClass = when { windowDpSize.width < 600.dp -> WindowWidthSizeClass.Compact windowDpSize.width < 840.dp -> WindowWidthSizeClass.Medium else -> WindowWidthSizeClass.Expanded val heightWindowSizeClass = when { windowDpSize.height < 480.dp -> WindowHeightSizeClass.Compact windowDpSize.height < 900.dp -> WindowHeightSizeClass.Medium else -> WindowHeightSizeClass.Expanded return widthWindowSizeClass to heightWindowSizeClass }接下来,我们就可以面向 WindowSizeClass 进行 Composable 布局了,这样做的好处是,无需关心具体的 width/height 数值,更不需要关心当前设备类型是平板还是手机,因为未来,硬件种类的界限将越来越模糊,所以最合理的分类方式是 WindowSizeClass。@Composable fun MyApp(widthSizeClass: WindowWidthSizeClass) { // 非 Compact 类型屏幕时,不显示 AppBar val showTopAppBar = widthSizeClass != WindowWidthSizeClass.Compact // MyScreen 不依赖 WindowSizeClass,只需要知道是否显示 showTopAppBar,关注点分离 MyScreen( showTopAppBar = showTopAppBar, /* ... */ }当然我们可以使用 Android Studio 便利的预览功能,同时查看多种屏幕尺寸下的显示效果最佳实践: Now In Android最后推荐一个谷歌刚刚开源的新项目 Now In Android。Now in Android 是 Android 官方的技术博客,分享技术文章和视频,如今这个博客有了自己的客户端,并在 Github 进行了开源,https://github.com/android/nowinandroid。开发者通过 App 可以更好地追踪 Android 最新的技术动向,更重要的是它本身就是一个 Android Jetpack 的最佳实践,在技术上它具有以下特点:基于 Jetpack Compose 实现 UI基于 Material3 的视觉样式和主题对不同尺寸的屏幕进行了支持,能够自适应布局整体架构遵循官方文档 UDF 范式基于 Kotlin Flow 实现响应式编程模型遵循 Offline first 设计原则,基于 Room 以及 Proto DataSotre 实现本地数据源,基于 WorkManager 实现远程/本地数据源之间的同步另外,GIthub 上还贴心了附上了架构设计文档,方便你了解它的开发思路,Now in Android 已经预定上架 GooglePlay, 相对于 Jetpack 的其他 Demo,它是更加真实和完善,非常值得大家研究和学习。

关于 Android12 中 Activity 的生命周期变化

前言Android12 有很多令人惊喜的变化,比如基于 Material You 的全新 UI,基于 SplashScreen 的应用启动画面以及更安全的隐私设置等等,此外也有一些需要开发者注意的行为变化,比如这里介绍的 Activity 的 Lifecycle 上的变化点击返回键 Activity 不在 onDestroyAndroid 12 以前,当我们处于 Root Activity 时,点击返回键时,应用返回桌面, Activity 执行 onDestroy,程序结束。 Android 12 起同样场景下 Activity 只会 onStop,不再执行 onDestroy。通过下面代码进行验证:class LifecycleLogObserver : LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { Log.d(source::class.java.simpleName, event.name) class SampleActivity: AppCompatActivity() { init { lifecycle.addObserver(LifecycleLogObserver()) }启动 Activity,按下返回键后,重新打开 App。首先 Android12 之前的设备,Log 如下:// 初次启动 D/SampleActivity: ON_CREATE D/SampleActivity: ON_START D/SampleActivity: ON_RESUME // 返回桌面 D/SampleActivity: ON_PAUSE D/SampleActivity: ON_STOP D/SampleActivity: ON_DESTROY // 再次启动 D/SampleActivity: ON_CREATE D/SampleActivity: ON_START D/SampleActivity: ON_RESUME再开 Android12 之后的设备:// 初次启动 D/SampleActivity: ON_CREATE D/SampleActivity: ON_START D/SampleActivity: ON_RESUME // 返回桌面 D/SampleActivity: ON_PAUSE D/SampleActivity: ON_STOP // 再次启动 D/SampleActivity: ON_START D/SampleActivity: ON_RESUME我们知道 ViewModel 的销毁在 onDestroy 中,这样改动后 ViewModel 中的状态可以保存,再次启动后可以直接使用。对于使用者来说直接感受就是冷启动变为了热启动,启动速度更快。注意:所谓 Root Activity 就是我们在 AndroidManifest 中配置了 IntentFilter 为 ACTION_MAIN 和 CATEGORY_LAUNCHER 的入口 Activity,其他 Activity 点击返回键后行为不变,依然会 onDestroy重新 onBackPressed 时的注意点如果你的应用在 Android12 中没有上述变化,那很有可能是你重写了 onBackPressed 并手动调用了 finish(),为了在行为上符合 Android12 的预期,需要修改如下:class SampleActivity : AppCompatActivity() { private var flag = true override fun onBackPressed() { if (flag) { flag = false TODO("do sth business") return //Don't call finish() super.onBackPressed() }当然,官方已不再推荐重写 onBackPressed 了,更好的做法使用 AndroidX 的 OnBackPressedCallback 重写你的实现,它会自动适配 Android12 的变化。class SampleActivity : AppCompatActivity() { private val onBackPressedCallback: OnBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { TODO("do sth business") //处理自定义业务后,后续返回键交回系统处理 onBackPressedCallback.isEnabled = false }总结随着手机内存的增大,相比起资源的及时释放,用户体验变得更加重要,这也会为什么 Android12 会引入这次的变化。这次变化也让 onStop 的重要性得以提升,我们要更加区分 onStop 与 onDestroy 在使用场景上的不同:onDestroy 负责必要的资源释放,而其余类似活跃状态的切换应该放在 onStart/onStop 中进行,这符合 androidx-lifecycle 的基本思想。

Jetpack Compose 动画实战:高仿微博长按点赞彩虹

引言Compose 在动画方面下足了功夫,提供了种类丰富的 API。但也正由于 API 种类繁多,如果想一气儿学下来,可能会消化不良导致似懂非懂。结合例子学习是一个不错的方法,本文就带大家边学边做,通过高仿微博长按点赞的彩虹动画,学习和实践 Compose 动画的相关技巧。原版:微博长按点赞本文:掘金夏日主题代码地址: https://github.com/vitaviva/AnimatedLike1. Compose 动画 API 概览Compose 动画 API 在使用场景的维度上大体分为两类:高级别 API 和低级别 API。就像编程语言分为高级语言和低级语言一样,这列高级低级指 API 的易用性:高级别 API 主打开箱即用,适用于一些 UI 元素的展现/退出/切换等常见场景,例如常见的 AnimatedVisibility 以及 AnimatedContent 等,它们被设计成 Composable 组件,可以在声明式布局中与其他组件融为一体。//Text通过动画淡入 var editable by remember { mutableStateOf(true) } AnimatedVisibility(visible = editable) { Text(text = "Edit") }低级别 API 使用成本更高但是更加灵活,可以更精准地实现 UI 元素个别属性的动画,多个低级别动画还可以组合实现更复杂的动画效果。最常见的低级别 animateFloatAsState 系列了,它们也是 Composable 函数,可以参与 Composition 的组合过程。//动画改变 Box 透明度 val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f) Modifier.fillMaxSize() .graphicsLayer(alpha = alpha) .background(Color.Red) )处于上层的 API 由底层 API 支撑实现,TargetBasedAnimation 是开发者可直接使用的最低级 API。Animatable 也是一个相对低级的 API,它是一个动画值的包装器,在协程中完成状态值的变化,向上提供对 animate*AsState 的支撑。它与其他 API 不同,是一个普通类而非一个 Composable 函数,所以可以在 Composable 之外使用,因此更具灵活性。本例子的动画主要也是依靠它完成的。// Animtable 包装了一个颜色状态值 val color = remember { Animatable(Color.Gray) } LaunchedEffect(ok) { // animateTo 是个挂起函数,驱动状态之变化 color.animateTo(if (ok) Color.Green else Color.Gray) Box(Modifier.fillMaxSize().background(color.value))无论高级别 API 还是低级别 API ,它们都遵循状态驱动的动画方式,即目标对象通过观察状态变化实现自身的动画。2. 长按点赞动画分解长按点赞的动画乍看之下非常复杂,但是稍加分解后,不难发现它也是由一些常见的动画形式组合而成,因此我们可以对其拆解后逐个实现:彩虹动画:全屏范围内不断扩散的彩虹效果。可以通过半径不断扩大的圆形图案并依次叠加来实现表情动画:从按压位置不断抛出的表情。可以进一步拆解为三个动画:透明度动画,旋转动画以及抛物线轨迹动画。烟花动画:抛出的表情在消失时会有一个烟花炸裂的效果。其实就是围绕中心的八个圆点逐渐消失的过程,圆点的颜色提取自表情本身。传统视图动画可以作用在 View 上,通过动画改变其属性;也可以在 onDraw 中通过不断重绘实现逐帧的动画效果。 Compose 也同样,我们可以在 Composable 中观察动画状态,通过重组实现动画效果(本质是改变 UI 组件的布局属性),也可以在 Canvas 中观察动画状态,只在重绘中实现动画(跳过组合)。这个例子的动画效果也需要通过 Canvas 的不断重绘来实现。Compose 的 Canvas 也可以像 Composable 一样声明式的调用,基本写法如下:Canvas { drawRainbow(rainbowState) //绘制彩虹 drawEmoji(emojiState) //绘制表情 drawFlow(flowState) //绘制烟花 }State 的变化会驱动 Canvas 会自动重绘,无需手动调用 invalidate 之类的方法。那么接下来针对彩虹、表情、烟花等各种动画的实现,我们的工作主要有两个:状态管理:定义相关 State,并在在动画中驱动其变化,如前所述这主要依靠 Animatable 实现。内容绘制:通过 Canvas API 基于当前状态绘制图案3. 彩虹动画3.1 状态管理对于彩虹动画,唯一的动画状态就是圆的半径,其值从 0F 过渡到 screensize,圆形面积铺满至整个屏幕。我们使用 Animatable 包装这个状态值,调用 animateTo 方法可以驱动状态变化:val raduis = Animatable(0f) //初始值 0f radius.animateTo( targetValue = screenSize, //目标值 animationSpec = tween( durationMillis = duration, //动画时长 easing = FastOutSlowInEasing //动画衰减效果 )animationSpec 用来指定动画规格,不同的动画规格决定了了状态值变化的节奏。Compose 中常用的创建动画规格的方法有以下几种,它们创建不同类型的动画规格,但都是 AnimationSpec 的子类:tween:创建补间动画规格,补间动画是一个固定时长动画,比如上面例子中这样设置时长 duration,此外,tween 还能通过 easiing 指定动画衰减效果,后文详细介绍。spring: 弹跳动画:spring 可以创建基于物理特性的弹簧动画,它通过设置阻尼比实现符合物理规律的动画衰减,因此不需要也不能指定动画时长Keyframes:创建关键帧动画规格,关键帧动画可以逐帧设置当前动画的轨迹,后文会详细介绍。AnimatedRainbow要实现上面这样多个彩虹叠加的效果,我们还需有多个 Animtable 同时运行,在 Canvas 中依次对它们进行绘制。绘制彩虹除了依靠 Animtable 的状态值,还有 Color 等其他信息,因此我们定义一个 AnimatedRainbow 类保存包括 Animtable 在内的绘制所需的的状态class AnimatedRainbow( //屏幕尺寸(宽边长边大的一方) private val screenSize: Float, //RainbowColors是彩虹的候选颜色 private val color: Brush = RainbowColors.random(), //动画时长 private val duration: Int = 3000 private val radius = Animatable(0f) suspend fun startAnim() = radius.animateTo( targetValue = screenSize * 1.6f, // 关于 1.6f 后文说明 animationSpec = tween( durationMillis = duration, easing = FastOutSlowInEasing }animatedRainbows 列表我们还需要一个集合来管理运行中的 AnimatedRainbow。这里我们使用 Compose 的 MutableStateList 作为集合容器,MutableStateList 中的元素发生增减时,可以被观察到,而当我们观察到新的 AnimatedRainbow 被添加时,为它启动动画。关键代码如下://MutableStateList 保存 AnimatedRainbow val animatedRainbows = mutableStateListOf<AnimatedRainbow>() //长按屏幕时,向列表加入 AnimtaedRainbow, 意味着增加一个新的彩虹 animatedRainbows.add( AnimatedRainbow( screenHeightPx.coerceAtLeast(screenWidthPx), RainbowColors.random() )我们使用 LaunchedEffect + snapshotFlow 观察 animatedRainbows 的变化,代码如下:LaunchedEffect(Unit) { //监听到新添加的 AnimatedRainbow snapshotFlow { animatedRainbows.lastOrNull() } .filterNotNull() .collect { launch { //启动 AnimatedRainbow 动画 val result = it.startAnim() //动画结束后,从列表移除,避免泄露 if (result.endReason == AnimationEndReason.Finished) { animatedRainbows.remove(it) }LaunchedEffect 和 snapshotFlow 都是 Compose 处理副作用的 API,由于不是本文重点就不做深入介绍了,这里只需要知道 LaunchedEffect 是一个提供了执行副作用的协程环境,而 snapshotFlow 可以将 animatedRainbows 中的变化转化为 Flow 发射给下游。当通过 Flow 收集到新加入的 AnimtaedRainbow 时,调用 startAnim 启动动画,这里充分发挥了挂起函数的优势,同步等待动画执行完毕,从 animatedRainbows 中移除 AnimtaedRainbow 即可。值得一提的是,MutableStateList 的主要目的是在组合中观察列表的状态变化,本例子的动画不发生在组合中(只发生在重绘中),完全可以使用普通的集合类型替代,这里使用 MutableStateList 有两个好处:可以响应式地观察列表变化在 LaunchEffect 中响应变化并启动动画,协程可以随当前 Composable 的生命周期结束而终止,避免泄露。3.2 内容绘制我们在 Canvas 中遍历 animatedRainbows 所有的 AnimtaedRainbow 完成彩虹的绘制。彩虹的图形主要依靠 DrawScope 的 drawCircle 完成,比较简单。一点需要特别注意,彩虹动画结束时也要以一个圆形图案逐渐退出直至漏出底部内容,要实现这个效果,用到一个小技巧,我们的圆形绘制使用空心圆 (Stroke ) 而非 实心圆( Fill )出现彩虹:圆环逐渐铺满屏幕却不能漏出空心。这要求 StrokeWidth 宽度覆盖 ScreenSize,且始终保持 CircleRadius 的两倍结束彩虹:圆环空心部分逐渐覆盖屏幕。此时要求 CircleRadius 减去 StrokeWidth / 2 之后依然能覆盖 ScreenSize基于以上原则,我们为 AnimatedRainbow 添加单个 AnnimatedRainbow 的绘制方法:fun DrawScope.draw() { drawCircle( brush = color, //圆环颜色 center = center, //圆心:点赞位置 radius = radius.value,// Animtable 中变化的 radius 值, style = Stroke((radius.value * 2).coerceAtMost(_screenSize)), }如上,StrokeWidth 覆盖 ScreenSize 之后无需继续增长,而 CircleRadius 的最终尺寸除去 ScreenSize 之外还要将 StrokeWidth 考虑进去,因此前面代码中将 Animtable 的 targetValue 设置为 ScreenSize 的 1.6 倍。4. 表情动画4.1 状态管理表情动画又由三个子动画组成:旋转动画、透明度动画以及抛物线轨迹动画。像 AnimtaedRainbow 一样,我们定义 AnimatedEmoji 管理每个表情动画的状态,AnimatedEmoji 中通过多个 Animatable 分别管理前面提到的几个子动画AnimatedEmojiclass AnimatedEmoji( private val start: Offset, //表情抛点位置,即长按的屏幕位置 private val screenWidth: Float, //屏幕宽度 private val screenHeight: Float, //屏幕高度 private val duration: Int = 1500 //动画时长 //抛出距离(x方向移动终点),在左右一个屏幕之间取随机数 private val throwDistance by lazy { ((start.x - screenWidth).toInt()..(start.x + screenWidth).toInt()).random() //抛出高度(y方向移动终点),在屏幕顶端到抛点之间取随机数 private val throwHeight by lazy { (0..start.y.toInt()).random() private val x = Animatable(start.x)//x方向移动动画值 private val y = Animatable(start.y)//y方向移动动画值 private val rotate = Animatable(0f)//旋转动画值 private val alpha = Animatable(1f)//透明度动画值 suspend fun CoroutineScope.startAnim() { async { //执行旋转动画 rotate.animateTo( 360f, infiniteRepeatable( animation = tween(_duration / 2, easing = LinearEasing), repeatMode = RepeatMode.Restart awaitAll( async { //执行x方向移动动画 x.animateTo( throwDistance.toFloat(), animationSpec = tween(durationMillis = duration, easing = LinearEasing) async { //执行y方向移动动画(上升) y.animateTo( throwHeight.toFloat(), animationSpec = tween( duration / 2, easing = LinearOutSlowInEasing //执行y方向移动动画(下降) y.animateTo( screenHeight, animationSpec = tween( duration / 2, easing = FastOutLinearInEasing async { //执行透明度动画,最终状态是半透明 alpha.animateTo( 0.5f, tween(duration, easing = CubicBezierEasing(1f, 0f, 1f, 0.8f)) }infiniteRepeatable上面代码中,旋转动画的 AnimationSpec 使用 infiniteRepeatable 创建了一个无限循环的动画,RepeatMode.Restart 表示它的从 0F 过渡到 360F 之后,再次重复这个过程。除了旋转动画之外,其他动画都会在 duration 之后结束,它们分别在 async 中启动并行执行,awaitAll 等待它们全部结束。而由于旋转动画不会结束,因此不能放到 awaitAll 中,否则 startAnim 的调用方将永远无法恢复执行。CubicBezierEasing透明度动画中的 easing 指定了一个 CubicBezierEasing。easing 是动画衰减效果,即动画状态以何种速率逼近目标值。Compose 提供了几个默认的 Easing 类型可供使用,分别是://默认的 Easing 类型,以加速度起步,减速度收尾 val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f) //匀速起步,减速度收尾 val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f) //加速度起步,匀速收尾 val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f) //匀速接近目标值 val LinearEasing: Easing = Easing { fraction -> fraction }上图横轴是时间,纵轴是逼近目标值的进度,可以看到除了 LinearEasing 之外,其它的的曲线变化都满足 CubicBezierEasing 三阶贝塞尔曲线,如果默认 Easing 不符合你的使用要求,可以使用 CubicBezierEasing,通过参数,自定义合适的曲线效果。比如例子中曲线如下:这个曲线前半程状态值进度非常缓慢,临近时间结束才快速逼近最终状态。因为我们希望表情动画全程清晰可见,透明度的衰减尽量后置,默认 easiing 无法提供这种效果,因此我们自定义 CubicBezierEasing抛物线动画再来看一下抛物线动画的实现。通常我们可以借助抛物线公式,基于一些动画状态变量计算抛物线坐标来实现动画,但这个例子中我们借助 Easing 更加巧妙的实现了抛物线动画。我们将抛物线动画拆解为 x 轴和 y 轴两个方向两个并行执行的位移动画,x 轴位移通过 LinearEasing 匀速完成,y 轴又拆分成两个过程上升到最高点,使用 LinearOutSlowInEasing 上升时速度加速衰减下落到屏幕底端,使用 FastOutLinearInEasing 下落时速度加速增加上升和下降的 Easing 曲线互相对称,符合抛物线规律animatedEmojis 列表像彩虹动画一样,我们同样使用一个 MutableStateList 集合管理 AnimatedEmoji 对象,并在 LaunchedEffect 中监听新元素的插入,并执行动画。只是表情动画每次会批量增加多个//MutableStateList 保存 animatedEmojis val animatedEmojis = mutableStateListOf<AnimatedEmoji>() //一次增加 EmojiCnt 个表情 animatedEmojis.addAll(buildList { repeat(EmojiCnt) { add(AnimatedEmoji(offset, screenWidthPx, screenHeightPx, res)) //监听 animatedEmojis 变化 LaunchedEffect(Unit) { //监听到新加入的 EmojiCnt 个表情 snapshotFlow { animatedEmojis.takeLast(EmojiCnt) } .flatMapMerge { it.asFlow() } .collect { launch { with(it) { startAnim()//启动表情动画,等待除了旋转动画外的所有动画结束 animatedEmojis.remove(it) //从列表移除 }4.2 内容绘制单个 AnimatedEmoji 绘制代码很简单,借助 DrawScope 的 drawImage 绘制表情素材即可//当前 x,y 位移的位置 val offset get() = Offset(x.value, y.value) //图片topLeft相对于offset的距离 val d by lazy { Offset(img.width / 2f, img.height / 2f) } //绘制表情 fun DrawScope.draw() { rotate(rotate.value, pivot = offset) { drawImage( image = img, //表情素材 topLeft = offset - dCenter,//当前位置 alpha = alpha.value, //透明度 }注意旋转动画实际上是借助 DrawScope 的 rotate 方法实现的,在 block 内部调用 drawImage 指定当前的 alpha 和 topLeft 即可。5. 烟花动画5.1 状态管理烟花动画紧跟在表情动画结束时发生,动画不涉及位置变化,主要是几个花瓣不断缩小的过程。花瓣用圆形绘制,动画状态值就是圆形半径,使用 Animatable 包装。AnimatedFlower烟花的绘制还要用到颜色等信息,我们定义 AnimatedFlower 保存包括 Animtable 在内的相关状态。class AnimatedFlower( private val intial: Float, //花瓣半径初始值,一般是表情的尺寸 private val duration: Int = 2500 //花瓣半径 private val radius = Animatable(intial) suspend fun startAnim() { radius.animateTo(0f, keyframes { durationMillis = duration intial / 3 at 0 with FastOutLinearInEasing intial / 5 at (duration * 0.95f).toInt() }keyframes这里又出现了一种 AnimationSpec,即帧动画 keyframes,相对于 tween ,keyframes 可以更精确指定时间区间内的动画进度。比如代码中 radius / 3 at 0 表示 0 秒时状态值达到 intial / 3 ,相当于以初始值的 1/3 尺寸出现,这是一般的 tween 难以实现的。另外我们希望花瓣可以持久可见,所以使用 keyframe 确保时间进行到 95% 时,radius 的尺寸仍然清晰可见。animatedFlower 列表由于烟花动画设计是表情动画的延续,所以它紧跟表情动画执行,共享 CoroutienScope ,不需要借助 LaunchedEffect ,所以使用普通列表定义 animatedFlower 即可://animatedFlowers 使用普通列表创建 val animatedFlowers = mutableListOf<AnimatedFlower>() launch { with(it) {//表情动画执行 startAnim() animatedEmojis.remove(it) //创建 AnimatedFlower 动画 val anim = AnimatedFlower( center = it.offset, //使用 Palette 从表情图片提取烟花颜色 color = Palette.from(it.img.asAndroidBitmap()).generate().let { arrayOf( Color(it.getDominantColor(Color.Transparent.toArgb())), Color(it.getVibrantColor(Color.Transparent.toArgb())) initial = it.img.run { width.coerceAtLeast(height) / 2 }.toFloat() animatedFlowers.add(anim) //添加进列表 anim.startAnim() //执行烟花动画 animatedFlowers.remove(anim) //移除动画 }5.2 内容绘制烟花的内容绘制,需要计算每个花瓣的位置,一共8个花瓣,各自位置计算如下://计算 sin45 的值 val sin by lazy { sin(Math.PI / 4).toFloat() } val points get() = run { val d1 = initial - radius.value val d2 = (initial - radius.value) * sin arrayOf( center.copy(y = center.y - d1), //0点方向 center.copy(center.x + d2, center.y - d2), center.copy(x = center.x + d1),//3点方向 center.copy(center.x + d2, center.y + d2), center.copy(y = center.y + d1),//6点方向 center.copy(center.x - d2, center.y + d2), center.copy(x = center.x - d1),//9点方向 center.copy(center.x - d2, center.y - d2), }center 是烟花的中心位置,随着花瓣的变小,同时越来越远离中心位置,因此 d1 和 d2 就是偏离 center 的距离,与 radius 大小成反比。最后在 Canvas 中绘制这些 points 即可:fun DrawScope.draw() { points.forEachIndexed { index, point -> drawCircle(color = color[index % 2], center = point, radius = radius.value) }6. 合体效果最后我们定义一个 AnimatedLike 的 Composable ,整合上面代码@Composable fun AnimatedLike(modifier: Modifier = Modifier, state: LikeAnimState = rememberLikeAnimState()) { LaunchedEffect(Unit) { //监听新增表情 snapshotFlow { state.animatedEmojis.takeLast(EmojiCnt) } .flatMapMerge { it.asFlow() } .collect { launch { with(it) { startAnim() state.animatedEmojis.remove(it) //添加烟花动画 val anim = AnimatedFlower( center = it.offset, color = Palette.from(it.img.asAndroidBitmap()).generate().let { arrayOf( Color(it.getDominantColor(Color.Transparent.toArgb())), Color(it.getVibrantColor(Color.Transparent.toArgb())) initial = it.img.run { width.coerceAtLeast(height) / 2 }.toFloat() state.animatedFlowers.add(anim) anim.startAnim() state.animatedFlowers.remove(anim) LaunchedEffect(Unit) { //监听新增彩虹 snapshotFlow { state.animatedRainbows.lastOrNull() } .filterNotNull() .collect { launch { val result = it.startAnim() if (result.endReason == AnimationEndReason.Finished) { state.animatedRainbows.remove(it) //绘制动画 Canvas(modifier.fillMaxSize()) { //绘制彩虹 state.animatedRainbows.forEach { animatable -> with(animatable) { draw() } //绘制表情 state.animatedEmojis.forEach { animatable -> with(animatable) { draw() } //绘制烟花 state.animatedFlowers.forEach { animatable -> with(animatable) { draw() } }我们使用 AnimatedLike 布局就可以为页面添加动画效果了,由于 Canvas 本身是基于 modifier.drawBehind 实现的,我们也可以将 AnimatedLike 改为 Modifier 修饰符使用,这里就不赘述了。最后,复习一下本文例子中的内容:Animatable :包装动画状态值,并且在协程中执行动画,同步返回动画结果AnimationSpec:动画规格,可以配置动画时长、Easing 等,例子中用到了 tween,keyframes,infiniteRepeatable 等多个动画规格Easing:动画状态值随时间变化的趋势,通常使用默认类型即可, 也可以基于 CubicBezierEasing 定制。一个例子不可能覆盖到 Compose 所有的动画 API,但是我们只要掌握了上述几个关键知识点,再学习其他 API 就是水到渠成的事情了。

2022 Google I/0 回顾:Android Jetpack 新变化

5 月的山景城,一年一度的谷歌 I/O 开发者大会如期而至,由于当地疫情管制的放开,今年大会重回线下举行,真心希望国内的疫情也尽早结束。今年的 I/O 大会既是谷歌各种新产品发布会,同时也是谷歌开发者们的技术交流会。不少 Android 开发者希望通过本次 I/O 了解到有关 Jetpack 的最新动态。 Jetpack 早已成为我们日常开发中的必备工具,根据本次大会上发布的数据,目前 GooglePlay 排名前 1000 的应用中,使用至少两个以上 Jetpack 库的占比从 79% 提升到 90%。接下来,本文将从 Architecture,UI,Performance 和 Compose 等四个方向带大家了解 Jetpack 在本次大会上又有哪些新变化。1. Architecture1.1 Room 2.4/2.5Room 最新版本进入到 2.5。 2.5 没有新功能的引入,最大变化就是使用 Kotlin 进行了重写,借助 Kotlin 空安全等特性,代码将更加稳定可靠。未来还会有更多 Jetpack 库逐渐迁移至 Kotlin。在功能方面,Room 自 2.4 以来引入了不少新特性:KSP:新的注解处理器Room 将注解处理方式从 KAPT 升级为 KSP(Kotlin Symbol Processing)。 KSP 作为新一代 Kotlin 注解处理器,1.0 版目前已正式发布,功能更加稳定,可以帮助你极大缩短项目的构建时间。KSP 的启用非常简单,只要像 KAPT 一样地配置即可:plugins { //enable kapt id 'kotlin-kapt' //enable ksp id("com.google.devtools.ksp") dependencies { //... // use kapt kapt "androidx.room:room-compiler:$room_version" // use ksp ksp "androidx.room:room-compiler:$room_version" //... }Multi-map Relations:返回一对多数据以前,Room 想要返回一对多的实体关系,需要额外增加类型定义,并通过 @Relatioin 进行关联,现在可以直接使用 Multi-map 返回,代码更加精简://before data class ArtistAndSongs( ` @Embedded val artist: Artist, @Relation(...) val songs: List<Song> @Query("SELECT * FROM Artist") fun getArtistAndSongs(): List<ArtistAndSongs> //now @Query("SELECT * FROM Artist JOIN Song ON Artist.artistName = Song.songArtistName") fun getAllArtistAndTheirSongsList(): Map<Artist, List<Song>>AutoMigrations:自动迁移以前,当数据库表结构变化时,比如字段名之类的变化,需要手写 SQL 完成升级,而最近新增的 AutoMigrations 功能可以检测出两个表结构的区别,完成数据库字段的自动升级。 @Database( version = MusicDatabase.LATEST_VERSION, entities = { Song.class, Artist.class }, autoMigrations = { @AutoMigration ( from = 1, to = 2 exportSchema = true public abstract class MusicDatabase extends RoomDatabase { }1.2 Paging3Paging3 相对于 Paging2 在使用方式上发生了较大变化。首先它提升了 Kotlin 协程的地位, 将 Flow 作为首选的分页数据的监听方案,其次它提升了 API 的医用型,降低了理解成本,同时它有着更丰富的能力,例如支持设置 Header 和 Footer等,建议大家尽可能地将项目中的 Paging2 升级到 Paging3。简单易用的数据源Paging2 的数据源有多种实现,PageKeyedDataSource, PositionalDataSource, ItemKeyedDataSource 等,需要我们根据场景做出不同选择 ,而 Paging3 在使用场景上进行了整合和简化,只提供一种数据源类型 PagingSource:class MyPageDataSource(private val repo: DataRepository) : PagingSource<Int, Post>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Data> { try { val currentLoadingPageKey = params.key ?: 1 // 从 Repository 拉去数据 val response = repo.getListData(currentLoadingPageKey) val prevKey = if (currentLoadingPageKey == 1) null else currentLoadingPageKey - 1 // 返回分页结果,并填入前一页的 key 和后一页的 key return LoadResult.Page( data = response.data, prevKey = prevKey, nextKey = currentLoadingPageKey.plus(1) } catch (e: Exception) { return LoadResult.Error(e) }上面例子是一个自定义的数据源, Paging2 数据源中 load 相关的 API 有多个,但是 Paging3 中都统一成唯一的 load 方法,我们通过 LoadParams 获取分页请求的参数信息,并根据请求结果的成功与否,返回 LoadResult.Page() ,LoadResult.Invalid 或者 LoadResult.Error,方法的的输入输出都十分容理解。支持 RxJava 等主流三方库在 Paging3 中我们通过 Pager 类订阅分页请求的结果,Pager 内部请求 PagingSource 返回的数据,可以使用 Flow 返回一个可订阅结果class MainViewModel(private val apiService: APIService) : ViewModel() { val listData = Pager(PagingConfig(pageSize = 6)) { PostDataSource(apiService) }.flow.cachedIn(viewModelScope) }除了默认集成的 Flow 方式以外,通过扩展 Pager 也可返回 RxJava,Guava 等其他可订阅类型implementation "androidx.paging:paging-rxjava2:$paging_version" implementation "androidx.paging:paging-guava:$paging_version"例如,paging-rxjava2 中提供了将 Pager 转成 Observable 的方法:val <Key : Any, Value : Any> Pager<Key, Value>.observable: Observable<PagingData<Value>> get() = flow.conflate().asObservable()新增的事件监听Paging3 通过 PagingDataDiffer 检查列表数据是否有变动,如果提交数据与并无变化则 PagingDataAdapter 并不会刷新视图。 因此 Paging3 为 PagingDataDiffer 中新增了 addOnPagesUpdatedListener 方法,通过它可以监听提交数据是否确实更新到了屏幕。配合 Room 请求本地数据源通过 room-paging ,Paging3 可以配合 Room 实现本地数据源的分页加载implementation "androidx.room:room-paging:2.5.0-alpha01"room-paging 提供了一个开箱即用的数据源 LimitOffsetPagingSource/** * An implementation of [PagingSource] to perform a LIMIT OFFSET query * This class is used for Paging3 to perform Query and RawQuery in Room to return a PagingSource * for Pager's consumption. Registers observers on tables lazily and automatically invalidates * itself when data changes. @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) abstract class LimitOffsetPagingSource<Value : Any>( private val sourceQuery: RoomSQLiteQuery, private val db: RoomDatabase, vararg tables: String, ) : PagingSource<Int, Value>() 在构造时,基于 SQL 语句创建 RoomSQLiteQuery 并连同 db 实例一起传入即可。更多参考:https://proandroiddev.com/paging-3-easier-way-to-pagination-part-1-584cad1f4f611.3 Navigation 2.4Multiple back stacks 多返回栈Navigation 2.4.0 增加了对多返回栈的支持。当下大部分移动应用都带有多 Tab 页的设计。由于所有 Tab 页共享同一个 NavHostFramgent 返回栈,因此 Tab 页内的页面跳转状态会因 Tab 页的切换而丢失,想要避免此问题必须创建多个 NavHostFragment。implementation "androidx.navigation:navigation-ui:$nav_version"在 2.4 中通过 navigation-ui 提供的 Tab 页相关组件,可以实现单一 NavHostFragment 的多返回栈class MainActivity : AppCompatActivity() { private lateinit var navController: NavController private lateinit var appBarConfiguration: AppBarConfiguration override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val navHostFragment = supportFragmentManager.findFragmentById( R.id.nav_host_container ) as NavHostFragment //获取 navController navController = navHostFragment.navController // 底部导航栏设置 navController val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottom_nav) bottomNavigationView.setupWithNavController(navController) // AppBar 设置 navController appBarConfiguration = AppBarConfiguration( setOf(R.id.titleScreen, R.id.leaderboard, R.id.register) val toolbar = findViewById<Toolbar>(R.id.toolbar) setSupportActionBar(toolbar) toolbar.setupWithNavController(navController, appBarConfiguration) override fun onSupportNavigateUp(): Boolean { return navController.navigateUp(appBarConfiguration) }如上,通过 navigation-ui 的 setupWithNavController 为 BottomNavigationView 或者 AppBar 设置 NavController,当 Tab 页来回切换时依然可以保持 Tab 内部的返回栈状态。升级到 2.4.0 即可,无需其他代码上的修改。更多参考:https://medium.com/androiddevelopers/navigation-multiple-back-stacks-6c67ba41952fTwo pane layout 双窗格布局在平板等大屏设备下,为应用采用双窗格布局将极大提升用户的使用体验,比较典型的场景就是左屏列展示表页,右屏展示点击后的详情页。SlidingPaneLayout 可以为开发者提供这种水平的双窗格布局Navigation 2.4.0 提供了AbstractListDetailFragment,内部通过继承 SlidingPaneLayout ,实现两侧 Fragment 单独显示,而详情页部分更是可以实现独立的页面跳转:class TwoPaneFragment : AbstractListDetailFragment() { override fun onCreateListPaneView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return inflater.inflate(R.layout.list_pane, container, false) //创建详情页区域的 NavHost override fun onCreateDetailPaneNavHostFragment(): NavHostFragment { return NavHostFragment.create(R.navigation.two_pane_navigation) override fun onListPaneViewCreated(view: View, savedInstanceState: Bundle?) { super.onListPaneViewCreated(view, savedInstanceState) val recyclerView = view as RecyclerView recyclerView.adapter = TwoPaneAdapter(map.keys.toTypedArray()) { map[it]?.let { destId -> openDetails(destId) } private fun openDetails(destinationId: Int) { //获取详情页区域的 NavController 实现详情页的内容切换 val detailNavController = detailPaneNavHostFragment.navController detailNavController.navigate( destinationId, null, NavOptions.Builder() .setPopUpTo(detailNavController.graph.startDestinationId, true) .apply { if (slidingPaneLayout.isOpen) { setEnterAnim(R.anim.nav_default_enter_anim) setExitAnim(R.anim.nav_default_exit_anim) .build() slidingPaneLayout.open() companion object { val map = mapOf( "first" to R.id.first_fragment, "second" to R.id.second_fragment, "third" to R.id.third_fragment, "fourth" to R.id.fourth_fragment, "fifth" to R.id.fifth_fragment }支持 ComposeNavigation 通过 navigation-compose 支持了 Compose 的页面导航,这对于一个 Compose first 的项目非常重要。implementation "androidx.navigation:navigation-compose:$nav_version"navigation-compose 中,Composable 函数替代 Fragment 成为页面导航的 Destination,我们使用 DSL 定义基于 Composable 的 NavGraph:val navController = rememberNavController() Scaffold { innerPadding -> NavHost(navController, "home", Modifier.padding(innerPadding)) { composable("home") { // This content fills the area provided to the NavHost HomeScreen() dialog("detail_dialog") { // This content will be automatically added to a Dialog() composable // and appear above the HomeScreen or other composable destinations DetailDialogContent() }如上, composable 方法配置导航中的 Composable 页面,dialog 配置对话框,而 navigation-fragment 中各种常见功能,比如 Deeplinks,NavArgs,甚至对 ViewModel 的支持在 Compose 项目中同样可以使用。1.4 Fragment每次 I/O 大会几乎都有关于 Fragment 的分享,因为它是我们日常开发中重度使用的工具。本次大会没有带来 Fragment 的新功能,相反对 Framgent 的功能进行了大幅“削减”。不必惊慌,这并非是从代码上删减了功能,而是对 Fragment 使用方式的重定义。随着 Jetpack 组件库的丰富,Fragment 的很多职责已经被其他组件所分担,所以谷歌希望开发者能够重新认识这个老朋友,对使用场景的必要性进行更合理评估。Fragmen 在最早的设计中作为 Activity 的代理者出现,因此它承担了很多来自 Activity 回调,例如 Lifecycle,SaveInstanceState,onActivityResult 等等以前:各种职责现在:职责外移而如今这些功能已经有了更好的替代方案,生命周期可以提供 Lifecycle 组件感知,数据的保存恢复也可以通过 ViewModel 实现,因此 Fragment 只需要作为页面侧承载着持有 View 即可,而随着 Navigation 对 Compose 的支持,Fragment 作为页面载体的职责也变得不在必要。尽管如此,我们也并不能彻底抛弃 Fragment,在很多场景中 Fragment 仍然是最佳选择,比如我们可以借助它的 ResultAPI 实现更简单的跨页面通信:当我们需要通知一些一次性结果时,ResulAPI 比共享 ViewModel 的通信方式将更加简单安全,它像普通回调一般的使用方式极其简单:// 在 FramgentA 中监听结果 setFragmentResultListener("requestKey") { requestKey, bundle -> // 通过约定的 key 获取结果 val result = bundle.getString("bundleKey") // ... // FagmentB 中返回结果 button.setOnClickListener { val result = "result" // 使用约定的 key 发送结果 setFragmentResult("requestKey", bundleOf("bundleKey" to result)) } 总结起来,Fragment 仍然是我们日常开发中的重要手段,但是它的角色正在发生变化。2. Performance2.1 JankStats 卡顿检测JankStats 用来追踪和分析应用性能,发现 Jank 卡顿问题,它最低向下兼容到 API 16,可以在绝大多数机器设备上使用,有了它我们不必再求助 BlockCanery 等三方工具了。implementation "androidx.metrics:metrics-performance:1.0.0-alpha01"我们需要为每个 Window 创建一个 JankStats 实例,并通过 OnFrameListener 回调获取包含是否卡顿在内的帧信息,示例如下:class JankLoggingActivity : AppCompatActivity() { private lateinit var jankStats: JankStats override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... // metricsStateHolder可以收集环境信息,跟随帧信息返回 val metricsStateHolder = PerformanceMetricsState.getForHierarchy(binding.root) // 基于当前 Window 创建 JankStats 实例 jankStats = JankStats.createAndTrack( window, Dispatchers.Default.asExecutor(), jankFrameListener, // 设置 Activity 名字到环境信息 metricsStateHolder.state?.addState("Activity", javaClass.simpleName) // ... private val jankFrameListener = JankStats.OnFrameListener { frameData -> // 监听到的帧信息 Log.v("JankStatsSample", frameData.toString()) }PerformanceMetricsState 用来收集你希望跟随 frameData 一起返回的状态信息,比如上面例子中设置了当前 Activity 名称,下面是 frameData 的打印日志:JankStats.OnFrameListener: FrameData(frameStartNanos=827233150542009, frameDurationUiNanos=27779985, frameDurationCpuNanos=31296985, isJank=false, states=[Activity: JankLoggingActivity])更多参考:https://medium.com/androiddevelopers/jankstats-goes-alpha-8aff942255d52.2 Baseline Profiles 基准配置Android 8.0 之后默认开启 ART 虚拟机。ART 最初版本在安装应用时会对全部代码进行 AOT 预编译,将字节码转换为机器码存在本地,这提升了运行时的速度,但是会导致安装过程变慢。因此后来 ART 改进为 JIT 和 AOT 相结合的方式,在应用安装时只将热点代码编译成机器码,缩短安装时间。Baselin Profiles 基准配置文件允许我们配置哪些代码成为热点代码。基准配置文件将在 APK 的 assets/dexopt/baseline.prof 中编译为二进制形式,例如如果我们想提升首帧的性能,可以将应用启动或帧渲染期间使用的方法配置到 prof 文件中。prof 文件可以通过自动或手动方式生成,我们可以编写 JUnit4 测试用例,通过执行 BaselineProfileRule 在测试中发现待优化的瓶颈代码,并生成对应的 prof 文件@ExperimentalBaselineProfilesApi @RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { @get:Rule val baselineProfileRule = BaselineProfileRule() @Test fun startup() = baselineProfileRule.collectBaselineProfile(packageName = "com.example.app") { pressHome() startActivityAndWait() }我们也可以手动创建 prof 文件,只需遵循一些简单的语法规则。例如下面展示了 Jetpack Compose 库中包含的一些 Prof 规则,HSPLandroidx/compose/runtime/ComposerImpl;->updateValue(Ljava/lang/Object;)V HSPLandroidx/compose/runtime/ComposerImpl;->updatedNodeCount(I)I HLandroidx/compose/runtime/ComposerImpl;->validateNodeExpected()V PLandroidx/compose/runtime/CompositionImpl;->applyChanges()V HLandroidx/compose/runtime/ComposerKt;->findLocation(Ljava/util/List;I)I Landroidx/compose/runtime/ComposerImpl;上述配置遵循 [FLAGS][CLASS_DESCRIPTOR]->[METHOD_SIGNATURE] 格式,其中 FLAGS 中的 H/S/P 代表方法的调用实际,比如是否是启动时调用等。更多参考:https://android-developers.googleblog.com/2022/01/improving-app-performance-with-baseline.html2.3 Benchmark 基准测试Jetpack 当前提供了两套 Benchmark 库,Microbenchmark 和 Macrobenchmark (微基准和宏基准),分别用于不同场景下的基准测试。Mircobenchmark 的测试对象是代码块,它的依赖如下:androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.1.0-beta03'我们可以在 JUnit4 中应用 BenchmarkRule,示例如下:@RunWith(AndroidJUnit4::class) class SampleBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() @Test fun benchmarkSomeWork() { benchmarkRule.measureRepeated { doSomeWork() //执行待测试代码 }Macrobenchmark 通常面向更大粒度的场景测试,例如一个 Activity 启动或者一个用户操作等。由于 Macrobenchmark 不进行代码级别测试,我们可以创建独立于业务代码的单独模块进行测试:下面展示了使用 MacrobenchmarkRule 测试一个 Activity 的启动: @get:Rule val benchmarkRule = MacrobenchmarkRule() @Test fun startup() = benchmarkRule.measureRepeated( packageName = "mypackage.myapp", metrics = listOf(StartupTimingMetric()), iterations = 5, startupMode = StartupMode.COLD ) { // this = MacrobenchmarkScope pressHome() val intent = Intent() intent.setPackage("mypackage.myapp") intent.setAction("mypackage.myapp.myaction") startActivityAndWait(intent) }配合 2021.1.1 或更高版本的 Android Studio ,Benchmark 的测试结果会直接显示在 IDE 窗口中。当然,测试结果也可以导出为 JSON 格式更多参考:https://medium.com/androiddevelopers/measure-and-improve-performance-with-macrobenchmark-560abd0aa5bb2.4 Tracing 事件追踪Tracing 用来在代码添加 trace 信息,trace 信息可以显示在 Systrace 和 Perfetto 等工具中。implementation "androidx.tracing:tracing:1.1.0-beta01"下面的例子汇总,我们通过 Trace 类的 benginSection/endSection 方法追踪 onCreateViewHolder 和 onBindViewHolder 方法执行的起始点class MyAdapter : RecyclerView.Adapter<MyViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { return try { Trace.beginSection("MyAdapter.onCreateViewHolder") MyViewHolder.newInstance(parent) } finally { //endSection 放到 finally 里,当出现异常时也会调用 Trace.endSection() override fun onBindViewHolder(holder: MyViewHolder, position: Int) { Trace.beginSection("MyAdapter.onBindViewHolder") try { try { Trace.beginSection("MyAdapter.queryDatabase") val rowItem = queryDatabase(position) dataset.add(rowItem) } finally { Trace.endSection() holder.bind(dataset[position]) } finally { Trace.endSection() }需要注意 benginSection/endSection 必须成对出现,且必须在同一线程中。我们 Trace 的 section 会作为新增的自定义事件出现在 Perfetto 等工具视图中:3. UI3.1 WindowManager这并非系统 WMS 获取的那个 WindowManager,它是 Jetpack 的新成员,当前刚刚迈入 1.1.0。implementation "androidx.window:window:1.1.0-alpha02"它可以帮助我们适配日益增多的可折叠设备,满足多窗口环境下的开发需求。可折叠设备通常分为两类:单屏可折叠设备(一个整体的柔性屏幕)和双屏可折叠设备(两个屏幕由合页相连)。目前单屏可折叠设备正逐渐成为主流,但无论哪种设备都可以通过 WindowManager 感知当前的屏幕显示特性,例如当前折叠的状态和姿势等。获取折叠状态多屏设备下,一个窗口可能会跨越物理屏幕显示,这样窗口中会出现铰链等不连续部分,FoldingFeature (DisplayFeature 的子类)对铰链这类的物理部件进行抽象,从中可以获取铰链在窗口中的准确位置,帮助我们避免将关键交互按钮布局在其中。另外 FoldingFeature 还提供了可以感知感知当前折叠状态的 API,我们可以根据这些状态改变应用的布局://铰链处于半开状态且位置水平,适合切换到平板模式 fun isTableTopMode(foldFeature: FoldingFeature) = foldFeature.isSeparating && foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL //铰链处于半开状态且位置垂直,适合切换到阅读模式 fun isBookMode(foldFeature: FoldingFeature) = foldFeature.isSeparating && foldFeature.orientation == FoldingFeature.Orientation.VERTICALisTableTopModeisBookModeWindowManager 允许我们通过 Flow 持续观察显示特性的变化。lifecycleScope.launch(Dispatchers.Main) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { WindowInfoTracker.getOrCreate(this@SampleActivity) .windowLayoutInfo(this@SampleActivity) .collect { newLayoutInfo -> // Use newLayoutInfo to update the layout. }如上,当显示特性变化时,我们能获取 newLayoutInfo ,它是一个 WindowLayoutInfo 类型,内部持有了 FoldingFeature 信息。感知窗口大小变化应用窗口可能跟随设备配置变化时(例如折叠屏的展开、旋转,或窗口在多窗口模式下调整大小)发生变化,我们可以通过 WIndowManger 的 WindowMetrics 获取窗口大小,我们有两种获取当前 WindowMetrics 的方式,同步获取和异步监听://异步监听 lifecycleScope.launch(Dispatchers.Main) { windowInfoRepository().currentWindowMetrics.flowWithLifecycle(lifecycle) .collect { windowMetrics: WindowMetrics -> val currentBounds = windowMetrics.bounds val width = currentBounds.width() val height = currentBounds.height() //同步获取 val windowMetrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity) val currentBounds = windowMetrics.bounds val width = currentBounds.width() val height = currentBounds.height()更多参考:https://medium.com/androiddevelopers/unbundling-the-windowmanager-fa060adb3ce93.2 DragAndDropJetpack DragAndDrop 是专门处理拖放手势的库,它除了服务于普通手机设备上的开发,更重要的意义是可以实现折叠设备跨屏幕的拖放implementation 'androidx.draganddrop:draganddrop:1.0.0-alpha02'DragStartHelper 和 DropHelper 是其最核心的 API,可以配置拖防过程中的数据传递、显示效果等,还可以监听手势回调。拖动 DragStartHelperDragStartHelper 负责监测拖动手势的开始时机,包括长按拖动、单击并用鼠标拖动等。我们可以将需要拖动的视图对象包装进来并开启监听,当监听到拖动手势触发时,完成一些简单配置即可。// 使用 DragStartHelper 包装 draggableView 对象 DragStartHelper(draggableView) { view, _ -> // 将需要传递的数据封装到 ClipData 中 val dragClipData = ClipData.newUri(contentResolver, "File", fileUri) // 创建目标拖动时的展示图片,可自定义也可以根据 draggableView 创建默认样式 val dragShadow = View.DragShadowBuilder(view) // 基于数据、拖动效果启动拖动 view.startDragAndDrop( dragClipData, dragShadow, null, // Optional extra local state information // 添加 flag 启动全局拖动 DRAG_FLAG_GLOBAL or DRAG_FLAG_GLOBAL_URI_READ) }.attach()如上,准备好需要拖动数据和样式等,调用 View#startDragAndDrop 启动拖动。例子中拖动的目标是 content: 这类 URI,因此我们可以通过设置 DRAG_FLAG_GLOBAL 实现跨进程的拖动。放置 DropHelperDropHelper 是另一个核心 API,关心拖动数据放下的时机和目标视图。//针对可拖放视图调用 configureView DropHelper.configureView( this,// 当前Activity outerDropTarget, //接收拖放的对象,会根据情况高亮显示 arrayOf(MIMETYPE_TEXT_PLAIN, "image/*"), // 支持的 MIME 类型 DropHelper.Options.Builder() //一些参数配置,例如放下时高亮的颜色,视图范围等 .addInnerEditTexts(innerEditText) .build() ) { _, payload -> // 监听到目标的放下,可以从 ClipData 中取得数据, // 执行上传、显示等处理,当然还可以处理非法拖放时的警告或视图提醒等 }构建 DropHelper.Options 实例的时候,需要调用 addInnerEditTexts(),这样可以确保嵌套的 EditText 控件不会抢夺视图焦点。更多参考:https://medium.com/androiddevelopers/simplifying-drag-and-drop-3713d6ef526e4. Compose今年 I/O 大会上关于 Compose 的主题分享明显增多了,这也表明了谷歌对于 Compose 推广之重视。目前 GooglePlay Top1000 的应用中使用 Compose 的已经超过了 100 个,其中不乏一些领域头部应用,Compose 的稳定性和成熟度也借机得到了验证。让我们看看 Compose 最新的 1.2 Beta 版本带来哪些新内容。4.1 Material 3新增的 Compose.M3 库,可以帮助我们开发符合 Material You 设计规范的的 UI 界面。implementation "androidx.compose.material3:material3:1.0.0-alpha10" implementation "androidx.compose.material3:material3-window-size-class:1.0.0-alpha10"Material3 强调颜色的个性化和动态切换,Compose.M3 引入 ColorScheme 类自定义配色方案:val AppLightColorScheme = lightColorScheme ( primary = Color(...), // secondary、tertiary 等等 // 具有浅色基准值的 ColorScheme 实例 val AppDarkColorScheme = darkColorScheme( // primary、secondary、tertiary 等等 // 具有深色基准值的 ColorScheme 实例 val dark = isSystemInDarkTheme() val colorScheme = if (dark) AppDarkColorScheme else AppLightColorScheme // 将 colorScheme 作为参数传递给 MaterialTheme。 MaterialTheme ( colorScheme = colorScheme, // 字型 // 应用内容 }上面是 MaterialTheme 通过 ColorScheme 配置不同主题颜色的例子,可以看到这与 Compose.M2 中 Colors 用法区别不大, 但是 ColorScheme 可定义的颜色槽(Primary,Secondary,Error 等MD颜色常量)种类更多,而且还可以支持 DynamicColor 动态配色。DynamicColor 是 Material3 的重要特色,在 Android12 及以上设备中,可以实现应用的颜色跟随壁纸变化。如今 Compose 中也可以实现这个效果// Dynamic color is available on Android 12+ val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colorScheme = when { dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) darkTheme -> DarkColorScheme else -> LightColorScheme }如上,Compose 通过 dynamicXXXColorScheme 设置的颜色,无论是亮色还是暗色主题,都可以跟随用户设置的壁纸而变化:更多参考:https://juejin.cn/post/70644108354220196154.2 Nested Scrolling InteropCompose 支持与传统视图控件进行互操作,便于我们阶段性的引入 Compose 到项目中。但是在涉及到带有 Nested Scrolling 事件分发的场景中(例如 CoordinatorLayout ),会发生事件无法正常传递的兼容性问题,在 1.2 中对于此类问题进行了修复,无论是 CoordinatorLayout 内嵌 Composable , 或者在 Composable 中使用 Scrolling View 控件,事件传递都会更加平顺:https://android-review.googlesource.com/c/platform/frameworks/support/+/2004590 https://android-review.googlesource.com/c/platform/frameworks/support/+/20388234.3 Downloadable FontsAndroid 8.0(API level 26)起支持了对可下载的谷歌字体的使用,允许通过代码动态请求一个非内置字体文件。在 Compose 1.2 对此功能也进行了支持,注意这个功能需要基于 GMS 服务。implementation "androidx.compose.ui:ui-text-google-fonts:1.1.1"使用时,首先使用 FontProvider 定义字体请求信息@OptIn(ExperimentalTextApi::class) val provider = GoogleFont.Provider( providerAuthority = "com.google.android.gms.fonts", providerPackage = "com.google.android.gms", certificates = R.array.com_google_android_gms_fonts_certs 然后,使用此 Provider 定义 FontFamily,接着在 Composable 应用即可, val fontName = GoogleFont(“Lobster Two”) val fontFamily = FontFamily( Font(googleFont = GoogleFont(name), fontProvider = provider) Text( fontFamily = fontFamily, text = "Hello World!" )4.4 Lazy GridCompose 1.2 中进一步优化了 LazyRow 和 LazyColumn 的性能,并在此基础上新增了 LazyGrid 用来实现需求中常见的网格布局效果。Lazy Grid 在 1.0.2 就已经引入,如今 1.2 中对 API 进行调整并使之达到稳定。以 LazyVerticalGrid 为例,我们可以通过 GridCells.Fixed 设置每行单元格的数量:val data = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5") LazyVerticalGrid( columns = GridCells.Fixed(3), contentPadding = PaddingValues(8.dp) ) {//this: LazyGridScope items(data.size) { index -> Card( modifier = Modifier.padding(4.dp), backgroundColor = Color.LightGray Text( text = data[index], textAlign = TextAlign.Center }此外,也可以通过 GridCells.Adaptive() 通过制定单元格大小决定每行的数量。此时,所有单元格都会以 Adaptive 中的值设置统一的 width。LazyGridScope 像 LazyListScope 一样也提供了 item, items, itemsIndexed 等方法布局子项。另外 LazyGridState 中的方法也基本上对齐了 LazyListState。4.5 Tools在工具方面,Android Studio 为 Compose 的开发调试提供了更多实用功能。@Preview & Live Edit1.2.0 中的 @Preview 可以作为元注解使用,修饰其他自定义注解@Preview(showBackground = true) @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) annotation class MyDevices() @MyDevices @Composable fun Greeting() { }如上,我们可以通过自定义注解可以复用 @Preview 中的各种配置,减少为了预览而写的模板代码。说到预览,Android Studio 一直致力于提升预览效率,Android Studio Arctic Fox 曾引入 Live literals 功能,对于代码中 Int,String,Color,Dp,Boolean 等常见类型的字面值的修改,无需编译即可在预览画面中实时更新。本次大会上带来了升级版的 Live Edit,它需要使用最新的 Android Studio Electric Eel 中开启。不仅仅是字面值,它可以让任意代码的修改(函数签名变动之类的修改不行),在预览窗口或者你的设备上立即生效,几乎实现了前端一般的开发体验,是本次大会令我惊喜的功能,它将大幅提高 Compose 的开发和调试效率。Layout Inspector & Recomposition Counts我们在传统视图开发中经常使用 Layout Inspector 观察视图结构, Compose 虽然基于 Composable 函数构建 UI ,但同样也得到了 layout Inspector 的支持,它可以帮助我们查看 Composition 视图树的布局。此外,本次 I/O 还介绍了 Layout Inspector 的一个新功能 Recomposition Counts,我们知道不必要的重组会拖慢 Compose UI 的刷新性能,借助这个新工具,我们可以在 IDE 中调试和观察 Composable 重组次数,帮助我们及时发现和优化不符合预期的多余重组。Animation PreviewAndroid Studio 增加了对 Compose 动画效果实时预览。在动画预览窗口中,每个动画的状态值会以多轨道的形式呈现,我们可以查看特定时间点下的每个动画值的确切值,并且可以暂停、循环播放动画、快进或放慢动画,以便在动画过渡过程中调试动画。Compose 的动画 API 数量众多,目前并非所有的 API 都支持预览,IDE 会自动检查代码中可以进行预览的动画,并添加 Start Animation Inspection 图标,便于开发者发现和使用4.6 适应多种屏幕尺寸Compose 正逐渐成为 Android 的首选 UI 开发方案,所以为了覆盖尽可能多的使用场景,Compose 第一时间对各种屏幕尺寸下(手机,平板,电脑,折叠屏)的 UI 开发进行了支持。在具体开发中,我们需要先定义 WindowSizeClass 对各种屏幕类型分类,推荐分为三类: 当屏幕尺寸因为设备折叠等发生变化时,Compose 会自动响应 onConfigurationChanged 触发重组,重组中我们根据当前屏幕尺寸转换为对应的 WindowSizeClass@Composable fun Activity.rememberWindowSizeClass(): Pair<WindowWidthSizeClass, WindowHeightSizeClass> { val configuration = LocalConfiguration.current val windowMetrics = remember(configuration) { WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(this) val windowDpSize = with(LocalDensity.current) { windowMetrics.bounds.toComposeRect().size.toDpSize() val widthWindowSizeClass = when { windowDpSize.width < 600.dp -> WindowWidthSizeClass.Compact windowDpSize.width < 840.dp -> WindowWidthSizeClass.Medium else -> WindowWidthSizeClass.Expanded val heightWindowSizeClass = when { windowDpSize.height < 480.dp -> WindowHeightSizeClass.Compact windowDpSize.height < 900.dp -> WindowHeightSizeClass.Medium else -> WindowHeightSizeClass.Expanded return widthWindowSizeClass to heightWindowSizeClass }接下来,我们就可以面向 WindowSizeClass 进行 Composable 布局了,这样做的好处是,无需关心具体的 width/height 数值,更不需要关心当前设备类型是平板还是手机,因为未来,硬件种类的界限将越来越模糊,所以最合理的分类方式是 WindowSizeClass。@Composable fun MyApp(widthSizeClass: WindowWidthSizeClass) { // 非 Compact 类型屏幕时,不显示 AppBar val showTopAppBar = widthSizeClass != WindowWidthSizeClass.Compact // MyScreen 不依赖 WindowSizeClass,只需要知道是否显示 showTopAppBar,关注点分离 MyScreen( showTopAppBar = showTopAppBar, /* ... */ }当然我们可以使用 Android Studio 便利的预览功能,同时查看多种屏幕尺寸下的显示效果最佳实践: Now In Android最后推荐一个谷歌刚刚开源的新项目 Now In Android。Now in Android 是 Android 官方的技术博客,分享技术文章和视频,如今这个博客有了自己的客户端,并在 Github 进行了开源,https://github.com/android/nowinandroid。开发者通过 App 可以更好地追踪 Android 最新的技术动向,更重要的是它本身就是一个 Android Jetpack 的最佳实践,在技术上它具有以下特点:基于 Jetpack Compose 实现 UI基于 Material3 的视觉样式和主题对不同尺寸的屏幕进行了支持,能够自适应布局整体架构遵循官方文档 UDF 范式基于 Kotlin Flow 实现响应式编程模型遵循 Offline first 设计原则,基于 Room 以及 Proto DataSotre 实现本地数据源,基于 WorkManager 实现远程/本地数据源之间的同步另外,GIthub 上还贴心了附上了架构设计文档,方便你了解它的开发思路,Now in Android 已经预定上架 GooglePlay, 相对于 Jetpack 的其他 Demo,它是更加真实和完善,非常值得大家研究和学习。参考视频-What's new in Android: https://www.youtube.com/watch?v=JhFRpxmWzEE&t=305s&ab_channel=AndroidDevelopers视频-What's new in Jetpack: https://www.youtube.com/watch?v=jTd82lcuHTU&ab_channel=AndroidDevelopersNow in Android:https://android-developers.googleblog.com/2022/05/now-in-android-sample-app-alpha.html

Jetpack MVVM 使用错误(五):ViewModel 接口暴露不合理

在 Jetpack 架构规范中, ViewModel 与 View 之间应该遵循单向数据流的通信方式,Events 永远从 View 流向 VM ,而 State 从 VM 流向 View。如果 ViewModel 对 View 暴露的接口类型不合理很容易会破坏数据的单向流动。不合理的接口常见于以下两点:暴露 Mutable 状态暴露 Suspend 方法不合理1:暴露 Mutable 状态ViewModel 对外暴露的数据状态,无论是 LiveData 或是 StateFlow 都应该使用 Immutable 的接口类型进行暴露而非 Mutable 的具体实现。View 只能单向订阅这些状态的变化,避免对状态反向更新。class MyViewModel: ViewModel() { private val _loading = MutableLiveData<Boolean>() val loading: LiveData<Boolean> get() = _loading }未来避免暴露 Mutable 类型,我们需要像上面这样处理,将 loading 的具体实现定义为一个 private 的 Mutable 类型,便于内部更新。private val _loading : MutableStateFlow<Boolean?> = MutableStateFlow(null) val loading = _loading.asStateFlow()StateFlow 的写法也类似,但是通过 asStateFlow 可以少写一个类型声明,但是要注意此时不要使用 custom get(), 不然 asStateFlow 会执行多次。每次都要多声明一个带划线的私有变量会让代码显得有些累赘,也正因如此,有 issue 希望 Kotlin 增加类似下面的语法使得对外对内可以暴露不同类型。//https://youtrack.jetbrains.com/issue/KT-14663 private val loading = MutableLiveData<Boolean>() public get(): LiveData<Boolean>在新语法还未出现的当下,一个让代码变整洁的思路是为 ViewModel 提取对外暴露的抽象类:abstract class MyViewModel: ViewModel() { abstract val loading: LiveData<Boolean> class MyViewModelImpl: MyViewModel() { override val loading = MutableLiveData<Boolean>() fun doSomeWork() { // ... loading.value = true }如上, MyViewModelImpl 内重写的 loading 可以作为 Mutable 类型使用。虽然这种做法会增加了一个抽象类代码量不减反增,但是它使 MyViewModelImpl 内的代码更加简洁,而且对外可以隐藏更多 ViewModel 的实现细节,封装性更好。但是需要特别注意的是,为了创建 MyViewModel 必须使用自定义 Factory:val vm : MyViewModel by viewModels { MyViewModelFactory() }如果你的工程引入了 Hilt ,那么可以通过 @Bind 绑定 ViewModel 的接口与实现,无需自定义 Factory 了,写法跟以前一样,直接使用 by viewModels() 即可@Module @InstallIn(ViewModelComponent::class) abstract class MyViewModule { @Binds abstract fun MyViewModel(instance: MyViewModelImpl): MyViewModel @HiltViewModel class MyViewModelImpl @Inject constructor() : MyViewModel()不合理2:暴露 Suspend 方法相对于暴露 Mutable 状态,暴露 Suspend 方法的错误则更为常见。按照单向数据流的思想 ViewModel 需要提供 API 给 View 用于发送 Events,我们在定义 API 时需要注意避免使用 Suspend 函数,理由如下:来自 ViewModel 的数据应该通过订阅 UiState 获取,因此 ViewModel 的其他方法方法不应该有返回值,而 suspend 函数会鼓励返回值的出现。理想的 MVVM 中 View 的职责仅仅是渲染 UI,业务逻辑尽量移动到 ViewModel 执行,利于单元测试的同时,ViewModelScope 可以保证一些耗时任务的稳定执行。如果暴露挂起函数给 View,则协程需要在 lifecycleScope 中启动,在横竖屏等场景中会中断任务的进行。因此,ViewModel 为 View 暴露的 API 应该是非挂起且无法返回值的方法,以下是官网的代码实例:// DO create coroutines in the ViewModel class LatestNewsViewModel( private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase ) : ViewModel() { private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading) val uiState: StateFlow<LatestNewsUiState> = _uiState fun loadNews() { viewModelScope.launch { val latestNewsWithAuthors = getLatestNewsWithAuthors() _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors) // Prefer observable state rather than suspend functions from the ViewModel class LatestNewsViewModel( private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase ) : ViewModel() { // DO NOT do this. News would probably need to be refreshed as well. // Instead of exposing a single value with a suspend function, news should // be exposed using a stream of data as in the code snippet above. suspend fun loadNews() = getLatestNewsWithAuthors() }代码中建议暴露一个普通的无返回值的 loadNews ,而 latestNewsWithAuthors 的信息应该通过订阅 LatestNewsUiState 获得 。有一点让人迷惑的是,官方文档上有这么一句话:Suspend functions in the ViewModel can be useful if instead of exposing state using a stream of data, only a single value needs to be emitted. <br/> https://developer.android.com/kotlin/coroutines/coroutines-best-practices#viewmodel-coroutines对于单发数据的请求允许使用挂起函数返回。但我建议大家忘掉这句话,理由有两点:挂起函数的口子一开就容易不分场景的滥用,如果整体数据流结构造成破坏反而因小失大,索性应该从源头禁止理论上来说,UI 上不存在单发数据请求的必要性,完全可以通过良好的设计转化成 UiState ,这也更符合响应式的编程模型。更多阅读Jetpack MVVM 七宗罪之一: 拿 Fragment 当 LifecycleOwnerJetpack MVVM 七宗罪之二: 在 launchWhenX 中启动协程Jetpack MVVM 七宗罪之三: 在 onViewCreated 中加载数据Jetpack MVVM 七宗罪之四: 使用 LiveData/StateFlow 发送 EventsJetpack MVVM 七宗罪之五: 在 Repository 中使用 LiveData

Jetpack MVVM 常见错误(六)在 Repository 中使用 LiveData

前言现在的 Android 项目中几乎少不了对 LiveData 的使用。MVP 时代我们需要定义各种 IXXXView 实现与 Presenter 的通信,而现在已经很少见到类似的接口定义了,大家早已习惯了用响应式的思想设计表现层与逻辑层之间的通信,这少不了 LiveData 的功劳, 因为它够简单好用。但如果将它用在 Domain 甚至 Data 层中就不合适了,但是现实中确实有不少人会这么用。1. 为什么有人在 Repository 中使用 LiveData ?当我在同事代码中发现并指出 Repository 中不应使用 LiveData 时,对方会理直气壮的拿官方文档反击我。这可能就是为什么不少人喜欢这样用的原因,因为这曾经是官方文档的推荐做法: <br/> https://developer.android.com/jetpack/guide <br/> https://github.com/android/architecture-components-samples/blob/master/GithubBrowserSample上面这些都是曾经在官网文档和Sample中出现过的代码,而且连 Jetpack Room 也对 LiveData 进行了支持,可以为 DAO 生成 LiveData 接口的 API。可见,正是由于官方的推荐,这样的用法才深入人心。如今的态度时过境迁,如今官方已经不再这样推荐,而且在最新的文档中对 LiveData 的使用范围做了明确限制,其中特别强调了应该避免在 Repo 中的使用LiveDatais not designed to handle asynchronous streams of data layer. Even though you can use LiveData transformations and MediatorLiveData to achieve this, this approach has drawbacks: the capability to combine streams of data is very limited and all LiveData objects are observed on the main thread. <br/> https://developer.android.com/topic/libraries/architecture/livedata#livedata-in-architecture就连在 Room 中使用 LiveData 也已经认为是一个错误 <br/> https://github.com/cashapp/sqldelight/pull/13812. Repo 中使用 LiveData 的弊端Google 曾经希望基于 LiveData 实现 MVVM 中 VM 与 M 之间的响应式通信但 LiveData 的设计初衷只是服务于 View 与 ViewModel 的通信场景,正因为它的职责聚焦所以能力也有限,不适合非 UI 场景下工作,这主要体现在两个方面:不支持线程切换重度依赖 Lifecycle不支持线程切换虽然 LiveData 是个可订阅的对象,但它不像 RxJava 或者 Coroutine Flow 那样具有线程切换的操作符,查看 LiveData 的源码可以发现 observe 只能主线程调用。当我们在 ViewModel 中订阅 Repo 的 LiveData 后,只能在 UI 线程接收数据并进行后续处理。但 ViewModel 更多的是负责逻辑处理,不应该占用主线程宝贵的资源,如果 VM 的逻辑中一旦有耗时操作就会造成 UI 的卡顿。题外话:VM 中耗时处理本身就是一个不合理的事情,标准的 MVVM 中 VM 的职责应该尽可能简单,更多的业务逻辑应该放到 Model 层或者 Domain 层完成。Model 层不只是简单 API 定义某些业务逻辑中,我们可能要借助 Transformations#map 和 Transformations#swichMap 等对 LiveData 做转换处理,而这些默认也是在主线程执行的class UserRepository { // DON'T DO THIS! LiveData objects should not live in the repository. fun getUsers(): LiveData<List<User>> { fun getNewPremiumUsers(): LiveData<List<User>> { return TransformationsLiveData.map(getUsers()) { users -> // This is an expensive call being made on the main thread and may // cause noticeable jank in the UI! users .filter { user -> user.isPremium .filter { user -> val lastSyncedTime = dao.getLastSyncedTime() user.timeCreated > lastSyncedTime 如上,map { } 在主线程执行,当里面有 getLastSyncedTime 这样的 IO 操作时可能发生 ANR 虽然 LiveData 可以提供了异步 postValue 的能力,但是很多复杂的业务场景中往往需要对数据流进行多段处理。如果要实现所谓的高性能编程,就要求每段处理都能单独指定线程,类似 RxJava 的 observeOn 以及 Flow 的 flowOn 这样的能力,这是 LiveData 所不具备的。重度依赖 LifecycleLiveData 依赖 Lifecycle,而 Lifecycle 是 Android UI 的属性,在非 UI 的场景中使用要么需要自定义 Lifecycle (例如有人会自定义是所谓的 LifecycleAwareViewModel ), 要么使用 LiveData#observerForever(这会造成泄露的风险), Jose Alcérreca 还曾经在 《ViewModels and LiveData: Patterns + AntiPatterns》 一文中推荐使用 Transformations#switchMap 来规避缺少 Lifecycle 的问题。在我看来这些都不是好的方法,我们不应该对 Lifecycle 有所妥协,在 MVVM 中无论 ViewModel 还是 Model 都应该专注于平台无关的业务逻辑。一个好的 ViewModel 或者 Repository 应该是一个纯 Java 或 Kotlin 类,不依赖包括 Lifecycle 在内的各种 Andorid 类库,更不应该持有 Context ,这样的代码才更具有通用性和平台无关性。3. 为 Repo 提供响应式接口既然 LiveData 不能用,那么如何为 Repo 提供响应式的 API 呢? 从前最常用的当属 RxJava,包括 Retrofit 等常用的三方库对 RxJava 也有友好的支持,如今进入 Kotlin 时代了,我更推荐使用协程。Repo 中常见的数据请求有两类单发请求流式请求单发请求例如常见的 HTTP 请求中 request 与 response 一一对应。此时可以使用 suspend 函数定义 API,例如使用 LiveData Builder 将其转化为 LiveDataLiveData Builder 需要引入 lifecyce-livedata-ktximplementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"LiveData Builder 可以在定义 LiveData 的同时提供了调用挂起函数的 CoroutineScopeclass UserViewModel(private val userRepo: UserRepository): ViewModel() { val user = liveData { //CoroutineScope emit(userRepo.getUser(10)) }当 LiveData 的 Observer 首次进入 active 状态时协程被启动,当不再有 active 的 Observer 时协程会自动取消,避免泄露。 LiveData Builder 还可以指定 timeoutInMs 参数,延长协程的存活时间由于 Activity 退到后台造成的 Observer 短时间 inactive,只要不超过 timeoutInMs 协程便不会取消,这保证后台任务的持续执行的同时又避免资源浪费。Jose Alcérreca 在 《Migrating from LiveData to Kotlin’s Flow》 一文中还推荐了用 StateFlow 替换 ViewModel 的 LiveData 的做法:class UserViewModel(private val userRepo: UserRepository): ViewModel() { val user = flow { //CoroutineScope emit(userRepo.getUser(10)) }.stateIn(viewModelScope) }使用 Flow Builder 构建一个 Flow, 然后使用 stateIn 操作符将其转化为 StateFlow。流式请求流式请求常见于观察一个可变的数据源,比如监听数据库的变化等,此时可以使用 Flow 定义响应式 APIViewModel 中,我们可以将 Repo 中的 Flow 通过 lifecyce-livedata-ktx 的 Flow#asLiveData 转换为一个 LiveDataval user = userRepo .getUserLikes() .onStart { // Emit first value .asLiveData()如果 ViewModel 不使用 LiveData, 那么跟单发请求一样使用 stateIn 转成 StateFlow 即可。总结由于 LiveData 简单好用再加上官网早期的推荐,很多人会将 LiveData 用在 Domain 甚至 Data 层等非 UI 场景,这样的用法并不合理,也已经不再被官方推荐。正确做法是应该尽量使用挂起函数或者 Flow 定义 Repo 的 API ,然后在 ViewModel 中合理的调用它们,转成 LiveData 或者 StateFlow 供 UI 层订阅。更多系列文章Jetpack MVVM 七宗罪之一: 拿 Fragment 当 LifecycleOwnerJetpack MVVM 七宗罪之二: 在 launchWhenX 中启动协程Jetpack MVVM 七宗罪之三: 在 onViewCreated 中加载数据Jetpack MVVM 七宗罪之四: 使用 LiveData/StateFlow 发送 Events

Jetpack MVVM 常见错误用法(四) 使用 LiveData/StateFlow 发送 Event

前言在 MVVM 架构中,我们通常使用 LiveData 或者 StateFlow 实现 ViewModel 与 View 之间的数据通信,它们具备的响应式机制非常适合用来向 UI 侧发送更新后的状态(State),但是同样用它们来发送事件(Event),当做 EventBus 使用就不妥了1. “状态” 与 “事件”虽然“状态”和“事件”都可以通过响应式的方式通知到 UI 侧,但是它们的消费场景不同:状态(State):是需要 UI 长久呈现的内容,在新的状态到来之前呈现的内容保持不变。比如显示一个Loading框或是显示一组请求的数据集。事件(Event):是需要 UI 即时执行的动作,是一个短期行为。比如显示一个 Toast 、 SnackBar,或者完成一次页面导航等。我们从覆盖性、时效性、幂等性等三个维度列举状态和事件的具体区别 状态事件覆盖性新状态会覆盖旧状态,如果短时间内发生多次状态更新,可以抛弃中间态只保留最新状态即可。这也是为什么 LiveData 连续 postValue 时会出现数据丢失。新事件不应该覆盖旧事件,订阅者按照发送顺序接收到所有事件,中间的事件不能遗漏。时效性最新状态是需要长久保持的,可以被时刻访问到,因此状态一般是“粘性的”,在新的订阅出现时为其发送最新状态。事件只能被消费一次,消费后应该丢弃。因此事件一般不是“粘性”的,避免多次消费。幂等性状态是幂等的,唯一状态决定唯一UI,同样的状态无需响应多次。因此 StateFlow 在 setValue 时会对新旧数据进行比较,避免重复发送。订阅者需要对发送的每个事件进行消费,即使是同一类事件发送多次。2. 基于 LiveData 的事件处理鉴于事件与状态的诸多差异,如果直接使用 LiveData 或 StateFlow 发送事件,会出现不符合预期的行为。其中最常见的可能就是所谓“数据倒灌”问题。我平常不太喜欢使用 “数据倒灌” 这个词,主要是“倒”这个字与单向数据流思想相违背,容易引起误解,我猜测词汇发明者更多的是想用它强调一种“被动”接收吧。“数据倒灌”问题的发生源于 LiveData 的 "粘性" 设计,同一个订阅者每次订阅 LiveData 都会收到最近的一个事件,因为事件应该具有“时效性”,对于已消费过的事件我们不希望再次响应。Jose Alcérreca 在 《LiveData with SnackBar, Navigation and other events》 一文中首次讨论了 LiveData 如何处理事件的话题,并在 architecture-sample-todoapp 中给出了 SingleLiveEvent ) 的解决思路。受到这篇文章的启发,陆续又有不少大佬给出了更优的解决方案,修补了 SingleLiveEvent 中的一些缺陷 - 例如不支持多订阅者等,但主要的解决思路上大体相同:通过增加标记位来记录事件是否被消费,对于已消费的事件则不会在订阅时再次发送。这里贴一个相对完善的解决方案:open class LiveEvent<T> : MediatorLiveData<T>() { private val observers = ArraySet<ObserverWrapper<in T>>() @MainThread override fun observe(owner: LifecycleOwner, observer: Observer<in T>) { observers.find { it.observer === observer }?.let { _ -> // existing return val wrapper = ObserverWrapper(observer) observers.add(wrapper) super.observe(owner, wrapper) @MainThread override fun observeForever(observer: Observer<in T>) { observers.find { it.observer === observer }?.let { _ -> // existing return val wrapper = ObserverWrapper(observer) observers.add(wrapper) super.observeForever(wrapper) @MainThread override fun removeObserver(observer: Observer<in T>) { if (observer is ObserverWrapper && observers.remove(observer)) { super.removeObserver(observer) return val iterator = observers.iterator() while (iterator.hasNext()) { val wrapper = iterator.next() if (wrapper.observer == observer) { iterator.remove() super.removeObserver(wrapper) break @MainThread override fun setValue(t: T?) { observers.forEach { it.newValue() } super.setValue(t) private class ObserverWrapper<T>(val observer: Observer<T>) : Observer<T> { private var pending = false override fun onChanged(t: T?) { if (pending) { pending = false observer.onChanged(t) fun newValue() { pending = true }代码很清晰,我们使用 ObserverWrapper 对 Observer 进行封装后,可以使用 pending 针对单个消费者记录事件的消费,避免二次消费。简单介绍了 LiveData 的事件处理,接下来重点看一下 Flow 如何进行事件处理,因为随着 lifecycle-runtime-ktx 对 Coroutine 的支持, Flow 将会成为主流的数据通信方式,Flow 将会成为主流的数据通信方式。3. 基于 SharedFlow 的事件处理StateFlow 和 LiveData 一样具备“粘性”特性,同样有“数据倒灌”的问题,甚至更有过之还会出现“数据丢失”的问题,因为 StateFlow 进行 updateState 时会过滤对新旧数据进行比较,同样类型的事件有可能被丢弃。Roman Elizarov 曾在 《Shared flows, broadcast channels》 一文中提出用 SharedFlow 实现 EventBus 的做法:class BroadcastEventBus { private val _events = MutableSharedFlow<Event>() val events = _events.asSharedFlow() // read-only public view suspend fun postEvent(event: Event) { _events.emit(event) }SharedFlow 确实一个不错的选择,它的很多特性与事件消费方式比较贴合:首先,它可以有多个收集器(订阅者),多个收集器“共享”事件,实现事件的广播,如下图所示:其次,SharedFlow 的数据会以流的形式发送,不会丢失,新事件不会覆盖旧事件;最后,它的数据不是粘性的,消费一次就不会再次出现。但是,SharedFlow 存在一个问题,接收器无法接收到 collect 之前发送的事件,看下面例子:class MainViewModel : ViewModel(), DefaultLifecycleObserver { private val _toast = MutableSharedFlow<String>() val showToast = _toast.asSharedFlow() init { viewModelScope.launch { delay(1000) _toast.emit("Toast") //Fragment side viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { mainViewModel.showToast.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }例子中,我们使用 repeatOnLifecycle 保证了事件收集在 STARTD 之后开始,如果此时注释掉 delay(1000) 的代码,emit 早于 collect,所以 toast 将无法显示。 有些时候我们在订阅出现之前就发出事件,并希望订阅者出现时执行响应这个事件,比如完成一个初始化任务等,注意这并非一种“数据倒灌”,因为这它只被允许消费一次,一旦消费就不再发送,所以 SharedFlow 的 replay 参数不能使用,因为 repaly 不能保证只消费一次。4. 基于 Channel 的处理事件针对 SharedFlow 的这个不足, Roman Elizarov 也给了解决方案,即使用 Channel。class SingleShotEventBus { private val _events = Channel<Event>() val events = _events.receiveAsFlow() // expose as flow suspend fun postEvent(event: Event) { _events.send(event) // suspends on buffer overflow }当 Channel 没有订阅者时,向其发送的数据会挂起,保证订阅者出现时第一时间接收到这个数据,类似于阻塞队列的原理。 Channel 本身也是 Flow 实现的基础,所以通过 receiveAsFlow 可以转成一个 Flow 暴露给订阅者。回看前面的例子,改为 Channel 后如下:class MainViewModel : ViewModel(), DefaultLifecycleObserver { private val _toast = Channel<String>() val showToast = _toast.receiveAsFlow() init { viewModelScope.launch { _toast.send("Toast") //Fragment side viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { mainViewModel.showToast.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }UI 侧仍然针对 Flow 订阅,代码不做任何改动,但是在 STATED 之后也可以接受到已发送的事件。需要注意,Channel 也有一个使用上的限制,当 Channel 有多个收集器时,它们不能共享 Channel 传输的数据,每个数据只能被一个收集器独享,因此 Channel 更适合一对一的通信场景。综上,SharedFlow 和 Channel 在事件处理上各有特点,大家需要根据实际场景灵活选择: SharedFlowChannel订阅者数量订阅者共享通知,可以实现一对多的广播每个消息只有一个订阅者可以收到,用于一对一的通信事件接受collect 之前的事件会丢失第一个订阅者可以收到 collect 之前的事件为了在更正确的时机接受事件,通常会配合 lifecycle-runtime-ktx 完成事件订阅,例如前面例子中使用的 repeatOnLifecycle ( 可以参考 Jetpack MVVM 七宗罪之二: 在 launchWhenX 中启动协程),这里提供一个避免模板代码的方法,仅供参考inline fun <reified T> Flow<T>.observeWithLifecycle( lifecycleOwner: LifecycleOwner, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, noinline action: suspend (T) -> Unit ): Job = lifecycleOwner.lifecycleScope.launch { flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action) inline fun <reified T> Flow<T>.observeWithLifecycle( fragment: Fragment, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, noinline action: suspend (T) -> Unit ): Job = fragment.viewLifecycleOwner.lifecycleScope.launch { flowWithLifecycle(fragment.viewLifecycleOwner.lifecycle, minActiveState).collect(action) }如上,observeWithLifecycle 作为 Flow 的扩展方法,在指定生命周期进行订阅,这样在 UI 侧的代码可以简写如下了:viewModel.events .observeWithLifecycle(fragment = this, minActiveState = Lifecycle.State.RESUMED) { // do things viewModel.events .observeWithLifecycle(lifecycleOwner = viewLifecycleOwner, minActiveState = Lifecycle.State.RESUMED) { // do things 本来文章到这里就该结束了,但突然发现近日 Google 对架构规范进行了更新,其中特别对 MVVM 的事件处理给了新的推荐做法:https://developer.android.com/jetpack/guide/ui-layer/events#handle-viewmodel-events,因此又有了下面一节内容......5. 关于 Google 最新 Guide这里仅针对 Guide 中关于事件处理部分做一个摘要,可以总结为以下三条:凡是发送给 View 的事件都应该涉及 UI 变动,与 UI 无关的事件不应该由 View 监听既然是涉及 UI 的事件,可以跟随 UI 状态一起发送(基于 StateFlow 或 LiveData ),不必另建新的渠道。View 在处理完事件后,需要告知 ViewModel 事件已处理,ViewModel 更新状态避免再次消费这三条汇总成一句话就是:像 “状态” 一样管理 “事件”结合官方的实例代码,体会一下具体实现:// Models the message to show on the screen. data class UserMessage(val id: Long, val message: String) // Models the UI state for the Latest news screen. data class LatestNewsUiState(     val news: List<News> = emptyList(),     val isLoading: Boolean = false,     val userMessages: List<UserMessage> = emptyList() )如上,List<UserMessge> 作为消息事件列表,跟 UiState 放在一起管理。class LatestNewsViewModel(/* ... */) : ViewModel() {     private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))     val uiState: StateFlow<LatestNewsUiState> = _uiState     fun refreshNews() {         viewModelScope.launch {             // If there isn't internet connection, show a new message on the screen.             if (!internetConnection()) {                 _uiState.update { currentUiState ->                     val messages = currentUiState.userMessages + UserMessage(                         id = UUID.randomUUID().mostSignificantBits,                         message = "No Internet connection"                     currentUiState.copy(userMessages = messages)                 return@launch             // Do something else. }如上,ViewModel 在 refreshNews 中请求最新的数据,如果网络未连接,则增加一条 userMessage 跟随状态一起发送给 View 。class LatestNewsActivity : AppCompatActivity() {     private val viewModel: LatestNewsViewModel by viewModels()     override fun onCreate(savedInstanceState: Bundle?) {         /* ... */         lifecycleScope.launch {             repeatOnLifecycle(Lifecycle.State.STARTED) {                 viewModel.uiState.collect { uiState ->                     uiState.userMessages.firstOrNull()?.let { userMessage ->                         // TODO: Show Snackbar with userMessage.                         // Once the message is displayed and                         // dismissed, notify the ViewModel.                         viewModel.userMessageShown(userMessage.id) }View 侧订阅 UiState 的状态变化,收到状态变化通知时,处理其中的 UserMessage 事件,例如这里是显示一条 SnackBar ,事件处理后,调用 viewModel.userMessageShown 方法,通知 ViewModel 处理结束。    fun userMessageShown(messageId: Long) {         _uiState.update { currentUiState ->             val messages = currentUiState.userMessages.filterNot { it.id == messageId }             currentUiState.copy(userMessages = messages)     }最后看一下 userMessageShown 的实现,从消息列表中删除相关信息,表示消息已被消费。其实 Jose Alcérreca 早在 《LiveData with SnackBar, Navigation and other events》 一文中就提到过这种处理思路,并予以了否定,With this approach you add a way to indicate from the View that you already handled the event and that it should be reset. <br/>The problem with this approach is that there’s some boilerplate (one new method in the ViewModel per event) and it’s error prone; it’s easy to forget the call to the ViewModel from the observer.否定的理由是这会增加模板代码,而且容易遗漏 View -> ViewModel 的反向通知。虽说 Jose 的文章只代表个人,但由于文章已经深入人心,如今 Google 的反向推荐难免让人感觉有些打脸。不过细细想来,这种做法也确实有它的意义:它避免了 SharedFlow ,Channel 等更多工具的引入,技术栈更加简洁。弱化 “事件” 的概念,强化 “状态” 的概念,实则就是命令式逻辑为状态驱动的思考方式让路,这也与 Compose 的理念更加贴近,有利于声明式 UI 的进一步推广像 “状态” 一样管理 “事件”,事件处理有回执、可追踪,也为事件增加了“后处理”的机会当然这里也存在隐患,比如在事件处理结束并给出回执之前,如果有新的状态通知到来,此时由于事件列表中没有清空当前事件,是否会造成重复消费? 这个还有待进一步验证。6. 总结本文介绍了 MVVM 事件处理的多种方案,没有十全十美的方案,需要大家结合具体场景做出选择:如果你的项目仍然在使用 LiveData,那么需要对事件的消费做记录,避免事件二次消费,可以参考本文中 LiveEvent 的例子如果你的代码大部分是 Kotlin ,那么推荐优先使用 Coroutine 实现 MVVM 数据通信,此时可以使用 SharedFlow 处理事件,如果你希望接收到 collect 之前的事件则可以选择 Channel有条件的话可以考虑采用 Google 最新的架构规范,虽然它在写法上略显冗余,而且增加了 View 的负担,所以能否得到开发者的最终认可还有待检验。其实最有效的事件处理方式就是尽量避免定义 “事件”,尝试用 “状态” 替换 “事件” 来设计你的数据通信,这才更贴合数据驱动的架构思想。参考[1] Jose Alcérreca , LiveData with SnackBar, Navigation and other events[2] Hadi Lashkari Ghouchani , LiveData with single events[3] Roman Elizarov , Shared flows, broadcast channels[4] Michael Ferguson , Android SingleLiveEvent Redux with Kotlin Flow

Jetpack MVVM 错误用法(二)在 launchWhenX 中启动协程

Flow vs LiveData自 StateFlow/ SharedFlow 出现后, 官方开始推荐在 MVVM 中使用 Flow 替换 LiveData。 ( 见文章:从 LiveData 迁移到 Kotlin 数据流 )Flow 基于协程实现,具有丰富的操作符,通过这些操作符可以实现线程切换、处理流式数据,相比 LiveData 功能更加强大。 但唯有一点不足,无法像 LiveData 那样感知生命周期。感知生命周期为 LiveData 至少带来以下两个好处:避免泄漏:当 lifecycleOwner 进入 DESTROYED 时,会自动删除 Observer节省资源:当 lifecycleOwner 进入 STARTED 时才开始接受数据,避免 UI 处于后台时的无效计算。Flow 也需要做到上面两点,才能真正地替代 LiveData。lifecycleScopelifecycle-runtime-ktx 库提供了 lifecycleOwner.lifecycleScope 扩展,可以在当前 Activity 或 Fragment 销毁时结束此协程,防止泄露。Flow 也是运行在协程中的,lifecycleScope 可以帮助 Flow 解决内存泄露的问题:lifecycleScope.launch { viewMode.stateFlow.collect { updateUI(it) }虽然解决了内存泄漏问题, 但是 lifecycleScope.launch 会立即启动协程,之后一直运行直到协程销毁,无法像 LiveData 仅当 UI 处于前台才执行,对资源的浪费比较大。 因此,lifecycle-runtime-ktx 又为我们提供了 LaunchWhenStarted 和 LaunchWhenResumed ( 下文统称为 LaunchWhenX )launchWhenX 的利与弊LaunchWhenX 会在 lifecycleOwner 进入 X 状态之前一直等待,又在离开 X 状态时挂起协程。 lifecycleScope + launchWhenX 的组合终于使 Flow 有了与 LiveData 相媲美的生命周期可感知能力:避免泄露:当 lifecycleOwner 进入 DESTROYED 时, lifecycleScope 结束协程节省资源:当 lifecycleOwner 进入 STARTED/RESUMED 时 launchWhenX 恢复执行,否则挂起。但对于 launchWhenX 来说, 当 lifecycleOwner 离开 X 状态时,协程只是挂起协程而非销毁,如果用这个协程来订阅 Flow,就意味着虽然 Flow 的收集暂停了,但是上游的处理仍在继续,资源浪费的问题解决地不够彻底。资源浪费举一个资源浪费的例子,加深理解fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> { val callback = object : LocationCallback() { override fun onLocationResult(result: LocationResult?) { result ?: return try { offer(result.lastLocation) } catch(e: Exception) {} // 持续获取最新地理位置 requestLocationUpdates( createLocationRequest(), callback, Looper.getMainLooper()) }如上,使用 callbackFlow 封装了一个 GoogleMap 中获取位置的服务,requestLocationUpdates 实时获取最新位置,并通过 Flow 返回class LocationActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 进入 STATED 时,collect 开始接收数据 // 进入 STOPED 时,collect 挂起 lifecycleScope.launchWhenStarted { locationProvider.locationFlow().collect { // Update the UI }当 LocationActivity 进入 STOPED 时, lifecycleScope.launchWhenStarted 挂起,停止接受 Flow 的数据,UI 也随之停止更新。但是 callbackFlow 中的 requestLocationUpdates 仍然还在持续,造成资源的浪费。因此,即使在 launchWhenX 中订阅 Flow 仍然是不够的,无法完全避免资源的浪费解决办法:repeatOnLifecyclelifecycle-runtime-ktx 自 2.4.0-alpha01 起,提供了一个新的协程构造器 lifecyle.repeatOnLifecycle, 它在离开 X 状态时销毁协程,再进入 X 状态时再启动协程。从其命名上也可以直观地认识这一点,即围绕某生命周期的进出反复启动新协程。使用 repeatOnLifecycle 可以弥补上述 launchWhenX 对协程仅挂起而不销毁的弊端。因此,正确订阅 Flow 的写法应该如下(以在 Fragment 中为例):onCreateView(...) { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) { viewMode.stateFlow.collect { ... } }当 Fragment 处于 STARTED 状态时会开始收集数据,并且在 RESUMED 状态时保持收集,最终在 Fragment 进入 STOPPED 状态时结束收集过程。需要注意 repeatOnLifecycle 本身是个挂起函数,一旦被调用,将走不到后续代码,除非 lifecycle 进入 DESTROYED。冷流 or 热流顺道提一点,前面举得地图SDK的例子是个冷流的例子,对于热流(StateFlow/SharedFlow)是否有必要使用 repeatOnLifecycle 呢? 个人认为热流的使用场景中,像前面例子那样的情况会少一些,但是在 StateFlow/SharedFlow 的实现中,需要为每个 FlowCollector 分配一些资源,如果 FlowCollector 能即使销毁也是有利的,同时为了保持写法的统一,无论冷流热流都建议使用 repeatOnLifecycle最后:Flow.flowWithLifecycle当我们只有一个 Flow 需要收集时,可以使用 flowWithLifecycle 这样一个 Flow 操作符的形式来简化代码lifecycleScope.launch { viewMode.stateFlow .flowWithLifecycle(this, Lifecycle.State.STARTED) .collect { ... } }当然,其本质还是对 repeatOnLifecycle 的封装:public fun <T> Flow<T>.flowWithLifecycle( lifecycle: Lifecycle, minActiveState: Lifecycle.State = Lifecycle.State.STARTED ): Flow<T> = callbackFlow { lifecycle.repeatOnLifecycle(minActiveState) { this@flowWithLifecycle.collect { send(it) close() }<br/>系列文章Jetpack MVVM七宗罪之一: 拿 Fragment 当 LifecycleOwner

Jetpack MVVM 常见错误用法(一) 拿Fragment当LifecycleOwner

首先承认这个系列有点标题党,Jetpack 的 MVVM 本身没有错,错在开发者的某些使用不当。本系列将分享那些 AAC 中常见的错误用法,指导大家打造更健康的应用架构Fragment 作为 LifecycleOwner 的问题MVVM 的核心是数据驱动UI,在 Jetpack 中,这一思想体现在以下场景:Fragment 通过订阅 ViewModel 中的 LiveData 以驱动自身 UI 的更新关于订阅的时机,一般会选择放到 onViewCreated 中进行,如下:override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.liveData.observe(this) { // Warning : Use fragment as the LifecycleOwner updateUI(it) }我们知道订阅 LiveData 时需要传入 LifecycleOwner 以防止泄露,此时一个容易犯的错误是使用 Fragment 作为这个 LifecycleOwner,某些场景下会造成重复订阅的Bug。做个实验如下:val handler = Handler(Looper.getMainLooper()) class MyFragment1 : Fragment() { val data = MutableLiveData<Int>() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) tv.setOnClickListener { parentFragmentManager.beginTransaction() .replace(R.id.container, MyFragment2()) .addToBackStack(null) .commit() handler.post{ data.value = 1 } data.observe(this, Observer { Log.e("fragment", "count: ${data.value}") }当跳转到 MyFragment2 然后再返回 MyFragment1 中时,会打出输出两条logE/fragment: count: 1 E/fragment: count: 1原因分析LiveData 之所以能够防止泄露,是当 LifecycleOwner 生命周期走到 DESTROYED 的时候会 remove 调其关联的 Observer//LiveData.java @Override public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) { if (mOwner.getLifecycle().getCurrentState() == DESTROYED) { removeObserver(mObserver); return; activeStateChanged(shouldBeActive()); }前面例子中,基于 FragmentManager#replace 的页面跳转,使得 MyFragment1 发生了从 BackStack 的出栈/入栈,由于 Framgent 实例被复用并没有发生 onDestroy, 但是 Fragment的 View 的重建导致重新 onCreateView, 这使得 Observer 被 add 了两次,但是没有对应的 remove。所以归其原因, 是由于 Fragment 的 Lifecycle 与 Fragment#mView 的 Lifecycle 不一致导致我们订阅 LiveData 的时机和所使用的 LivecycleOwner 不匹配,所以在任何基于 replace 进行页面切换的场景中,例如 ViewPager、Navigation 等会发生上述bug解决方法明白了问题原因,解决思路也就清楚了:必须要保证订阅的时机和所使用的LifecycleOwner相匹配,即要么调整订阅时机,要么修改LifecycleOwner在 onCreate 中订阅思路一是修改订阅时机,讲订阅提前到 onCreate, 可以保证与 onDestory 的成对出现,但不幸的是这会带来另一个问题。当 Fragment 出入栈造成 View 重建时,我们需要重建后的 View 也能显示最新状态。但是由于 onCreate 中的订阅的 Observer 已经获取过 LiveData 的最新的 Value,如果 Value 没有新的变化是无法再次通知 Obsever 的在 LiveData 源码中体现在通知 Obsever 之前对 mLastVersion 的判断://LiveData.java private void considerNotify(ObserverWrapper observer) { if (!observer.mActive) { return; if (!observer.shouldBeActive()) { observer.activeStateChanged(false); return; if (observer.mLastVersion >= mVersion) {// Value已经处于最新的version return; observer.mLastVersion = mVersion; //noinspection unchecked observer.mObserver.onChanged((T) mData); }正是为了保证重建后的 View 也能刷新最新的数据, 我们才在 onViewCreated 中完成订阅。因此只能考虑另一个思路,替换 LifecycleOwner使用 ViewLifecycleOwnerSupport-28 或 AndroidX-1.0.0 起,Fragment 新增了 getViewLifecycleOwner 方法。顾名思义,它返回一个与 Fragment#mView 向匹配的 LifecycleOwner,可以在 onDestroyView 的时候走到 DESTROYED ,删除 onCreateView 中注册的 Observer, 保证了 add/remove 的成对出现。看一下源码,原理非常简单//Fragment.java void performCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { //... mViewLifecycleOwner = new LifecycleOwner() { @Override public Lifecycle getLifecycle() { if (mViewLifecycleRegistry == null) { mViewLifecycleRegistry = new LifecycleRegistry(mViewLifecycleOwner); return mViewLifecycleRegistry; mViewLifecycleRegistry = null; mView = onCreateView(inflater, container, savedInstanceState); if (mView != null) { // Initialize the LifecycleRegistry if needed mViewLifecycleOwner.getLifecycle(); // Then inform any Observers of the new LifecycleOwner mViewLifecycleOwnerLiveData.setValue(mViewLifecycleOwner); //mViewLifecycleOwnerLiveData在后文介绍 } else { //... }基于 mViewLifecycleRegistry 创建 mViewLifecycleOwner, @CallSuper public void onViewStateRestored(@Nullable Bundle savedInstanceState) {// called when onCreateView if (mView != null) { mViewLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); @CallSuper public void onDestroyView() { if (mView != null) { mViewLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); }然后在 onCreateView 和 onDestroyView 时,推进到合适的生命周期。getViewLifecycleOwnerLiveData顺道提一下,与 getViewLifecycleOwner 同时新增的还有 getViewLifecycleOwnerLiveData。 从前面贴的源码中对 mViewLifecycleOwnerLiveData 的使用,应该可以猜出它的作用: 它是前文讨论的思路1的实现方案,即使在 onCreate 中订阅,由于在 onCreateView 中对 LiveData 进行了重新设置,所以重建后的 View 也可以更新数据。 // Then inform any Observers of the new LifecycleOwner mViewLifecycleOwnerLiveData.setValue(mViewLifecycleOwner);需要特别注意的是,根据 MVVM 最佳实践,我们希望由 ViewModel 而不是 Fragment 持有 LiveData,所以不再推荐使用 getViewLifecycleOwnerLiveData最后: StateFlow 与 lifecycleScope前面都是以 LiveData 为例介绍对 ViewLifecycleOwner 的使用, 如今大家也越来越多的开始使用协程的 StateFlow , 同样要注意不要错用 LifecycleOwner订阅 StateFlow 需要 CoroutineScope, AndroidX 提供了基于 LifecycleOwner 的扩展方法val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope get() = lifecycle.coroutineScope当我们在 Fragment 中获取 lifecycleScope 时,切记要使用 ViewLifecycleOwnerclass MyFragment : Fragment() { val viewModel: MyViewModel by viewModel() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) //使用 viewLifecycleOwner 的 lifecycleScope viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.someDataFlow.collect { updateUI(it) }注意此处出现了一个 repeatOnLifecycle(...), 这跟本文无关,但是将涉及到第二宗罪的剧情,敬请期待。

10个问题带你了解 Compose Multiplatform 1.0

近日 JetBrains 正式发布了 Compose Multiplatform 1.0 版,这标志其在生产环境中使用的时机已经成熟。相信有不少人对它还不太熟悉,本文通过下面 10 个热门问题带大家认识这一最新的跨平台技术。FAQ:与 Jetpack Compose 的关系? <br/>是否会取代 Flutter ?<br/>有何技术优势?1.0是否已稳定?<br/>Android Studio 还能使用吗?<br/>性能怎么样?<br/>生态建设如何?<br/>桌面应用开发是否要引入 JVM ?<br/>Web 端开发是否已经成熟?<br/>未来是否支持 iOS ?<br/>Jetpack 是否会跨平台? <br/>正文开始前先统一一下文中的用语:compose-jb:Compose Multiplatform 简称,包含下面三者compose-android:Jetpack Composecompose-desktop:Compose for Desktopcompose-web: Compose for Web1. 与 Jetpack Compose 的关系?Jetpack Compose 是 Google 针对 Android 推出的新一代声明式 UI 工具包,完全基于 Kotlin 打造,天然具备了跨平台的使用基础。JetBrains 以 Jetpack Compose(后文简称 compose-android)为基础,相继发布了 compose-desktop 和 compose-web ,使 Compose 可以运行在更多不同平台。Compose Multiplatform (后文简称 compose-jb)本质上是将 compose-desktop,compose-web 以及 compose-android 三者进行了整合,开发者可以在单个工程中使用同一套 Artifacts 开发出运行在 Android,Desktop(Windows, macOS, LInux)以及 Web 等多端的应用程序,工程中可以实现大部分代码的共享以此达到跨平台开发的目的。所以在概念上 compose-jb 可以看做是 compose-android 的超集;在具体实现上 compose-jb 则是在 fork 了 compose-android 的源码基础上增加了对 Desktop 和 Web 侧的 API。compose-jb 与 compose-android 同步更新,compose-jb 的 1.0 版本目前对应到 compose-android 1.1.0-beta02,因此在通用的 API 上 compose-jb 与 compose-android 时刻保持一致,不同的只是包名发生了变化,所以你可以将你的 compose-android 代码低成本地迁移到 compose-jb 工程中。Jetpack Compose( compose-android )Compose Multiplatform(compose-jb)androidx.compose.runtime:runtimeorg.jetbrains.compose.runtime:runtimeandroidx.compose.ui:uiorg.jetbrains.compose.ui:uiandroidx.compose.material:materialorg.jetbrains.compose.material:materialandroidx.compose.fundation:fundationorg.jetbrains.compose.fundation:fundation2. 是否会取代 Flutter ?compose-jb 虽由 JetBrains 发布,但是作为 Flutter 的开发者 Google 对其也是乐见其成,因为 Compose 与 Flutter 虽然都是跨平台技术,但是两者定位不同所以不存在直接竞争关系。Flutter 的定位就是移动端跨平台解决方案,它的一切能力建设都是围绕如何更好地“一次编写、随处运行”,首要目标就是为了降低移动应用的开发成本(虽然最近也扩展到 Desktop 以及 Desktop)。compose-jb 的首要定位是一个声明式 UI 工具包,它的目标是通过更先进的开发范式提升 UI 开发效率。由于声明式开发思想适应性广泛,所以借助 Kotlin 成为一个跨平台框架便是水到渠成的事情。 如果说是 Flutter 成就了 Dart,那么 Kotlin 则成就了 Compose ,借助 Kotlin 近年来持续高涨的的人气,Compose 的未来也充满想象空间。compose-jb 的受众主要有两类,首先是熟悉 Kotlin 与 Compose 的 Android 开发者,他们可以把自己的产品交付至更多平台;其次是 Kotlin 经验者,他们可以使用熟悉的语言更高效地开发包含 UI 的应用程序,像 JetBrains 这样的 IDE 公司就属于后者,他们迫切希望使用 Compose 替换 Swing 和 AWT 等基于 Java 的陈旧的技术栈,这也正是 compose-desktop 诞生的初衷。3. 有何技术优势?1.0是否已稳定?应用开发无非关注三件事:数据获取,状态管理,界面渲染。JetBrains 推出 Kotlin Multiplatform Mobile (简称 KMM) 实现了数据获取部分的跨平台,而 compose-jb 将跨平台的范围进一步覆盖到状态管理甚至界面渲染(基于 Skia)。在一个 compose-jb 工程中,逻辑层(状态管理)以及数据层的代码在几乎可以完全共享。在表现层,常用的组件和布局例如 Text,Button,Column/Row 等都可以跨越 compose-android 与 compsose-desktop 通用,此外 compose-desktop 针对桌面系统的特性还提供了专用能力,比如可以感知鼠标行为和窗口大小、创建 Scrollbars,Tooltips,Tray 等fun main() { Window { var windowSize by remember { mutableStateOf(IntSize.Zero) } var windowLocation by remember { mutableStateOf(IntOffset.Zero) } AppWindowAmbient.current?.apply { events.onResize = { windowSize = it } events.onRelocate = { windowLocation = it } Text(text = "Location: ${windowLocation}\nSize: ${windowSize}") }compose-desktop 还提供了 SwingPanel 用来嵌入使用既有的 Swing 组件。compose-desktop 在能力上完全可以替代 AWT 和 Swing 等现有 UI 框架。compose-web 为 Web 开发者提供了专门的 DOM API,针对常用的 HTML 标签实现了对应的 Composable 组件,例如 Div,P,A 等等 ,同时提供了 attrs 方法以 key-value 的形式设置标签属性,一些常用属性也有专属方法;另外,基于 CSS-in-JS 技术 compose-web 允许开发者基于 DSL 定义 Style 样式。fun main() { renderComposable("root") { var platform by remember { mutableStateOf("a platform") } Text("Welcome to Compose for $platform! ") Button(attrs = { onClick { platform = "Web" } }) { Text("...for what?") A("https://www.jetbrains.com/lp/compose-web") { Text("Learn more!") }compose-web 拥有 HTML 或 JSX 那样的结构化的表现力,同时有具备了响应式状态管理能力,在 compose-jb 中还可以与 Desktop 和 Android 侧共享逻辑层代码。稳定性方面,compose-jb 的大部分代码来自 Jetpack Compose,在 Android 端已经有上千款 App 接入,这足以保证其在 Android 端上的稳定性。JetBrains 在几个月之前就将 Toolbox 应用从 C++ 和 Electron 迁移到了 compose-jb,并一直平稳运行,服务着超过 100 万的月活用户,常规的 UI 开发得到了检验。但是一些复杂功能可能还不够稳定,目前还有不少 issue 有待解决。4. Android Studio 还能使用吗?compose-jb 1.0 可以运行在 IntelliJ IDEA 2021.1 之后的版本中,IDEA 专门为其提供了工程向导和项目模板,指导开发者快速新建一个 compose-jb 项目。开发者还可以通过插件市场下载 compose-desktop 侧的专用预览插件,在 Desktop 端实时预览添加 @Preview 的 Composalbe,提高开发效率。Andorid Studio 作为 IntelliJ 平台下的 IDE ,自然也可以用于 compose-jb 项目的开发( IDEA 2021.1 对应 Android Studio Bumblebee 之后的版本)。AS 自带 Andoid 侧的预览能力,可以实时预览 UI 代码效果。此外 AS 对 Compose 的代码提示也更友好,比如非法调用 @Composable 函数时, IDE 会标红提示错误,而 IDEA 则只能在编译时发现错误。5. 性能怎么样?compose-android 和 compose-desktop 都使用 Skia 这一开源图形库进行渲染。Skia 在 Chrome,Flutter 等多个项目中广泛使用,性能方面得到了验证。Skia 还能支持平台特有的硬件加速技术,例如 DirectX,Metal 和 OpenGL 等,compose-jb 为没有硬件加速的设备也提供了优化的软件渲染方案。曾经有人将 compose-desktop 与 JavaFX 进行过实际对比测试,在通常情况下两者渲染性能相当,尽在极端情况下会略逊于 JavaFX,不过已经足够优秀了。https://dev.to/gz_k/jetpack-compose-desktop-rendering-performances-4992Web 侧 compose-jb 会通过 Kotlin/JS 编译器将代码仍然编译成 JS 代码在浏览器运行,所以理论上与传统的 Web 开发方式在性能上没有区别,由于 CSS-in-JS 实现都是在运行时生成 CSS,仅仅在这一点上相对于直接使用 CSS 前端项目可能略有一点性能损耗。而在逻辑代码由于使用 Kotlin 作为开发语言,代码的执行效率要明显优于基于 Node 的 Electron 等同类跨平台框架,Kotlin/JVM 也保证了在桌面侧至少有与 Java 同样的运行时性能。6. 生态建设如何?compose-jb 依托 Kotin Multiplatform 的丰富类库,满足各种层面的能力开发,比如在架构、网络、数据存储等各方面都有不少优秀的的解决方案,一些代表性的项目如下:CategoryLibraryDescriptionArchitectureDecomposeKotlin Multiplatform lifecycle-aware business logic components (aka BLoCs) with routing functionality and pluggable UI (Jetpack Compose, SwiftUI, JS React, etc.), inspired by Badoos RIBs fork of the Uber RIBs framework MVIKotlinExtendable MVI framework for Kotlin Multiplatform with powerful debugging tools (logging and time travel), inspired by Badoo MVICore library redux-kotlinRedux implementation for Kotlin (supports multiplatform JVM, native, JS, WASM)NetworkKtorFramework for quickly creating connected applications in Kotlin with minimal effort rsocket-kotlinRSocket Kotlin multi-platform implementationStoragesqldelightSQLDelight - Generates typesafe Kotlin APIs from SQL Kodein-DBMultiplatform NoSQL database multiplatform-settingsA Kotlin Multiplatform library for saving simple key-value dataUtils & OthersReaktiveKotlin multi-platform implementation of Reactive Extensions koinA pragmatic lightweight dependency injection framework for Kotlin kotlinx-datetimeKotlinX multiplatform date/time library kotlin-loggingLightweight logging framework for Kotlin. A convenient and performant logging library wrapping slf4j with Kotlin extensionsMore libraries:https://libs.kmp.icerock.dev/7. 桌面应用开发是否要引入 JVM ?compose-jb 在桌面端需要支持 Windows,macOS,Linux 等多套操作系统,基于 Kotlin/Native 的实现成本较高,因此现阶段 compose-desktop 仍然依赖 Kotlin/JVM 编译成 Java 字节码后再发布到各桌面系统。compose-desktop 提供了专用的 Gradle 插件可以基于 jpackage 将 JVM 一同打包进各种格式的安装包,例如 Mac 的 dmg, Windows 的 msi,exe 以及 Linnux 的 deb 等,使用者无需额外安装 JDK ,可以像二进制程序一样开箱即用,此外还通过使用 jlink 技术只对 Java 模块的最小依赖进行打包以最大限度降低包体积。$ ./gradlew package > Task :packageDmg WARNING: Using incubator modules: jdk.incubator.jpackage The distribution is written to build/compose/binaries/main/dmg/DesktopApp-1.0.0.dmg BUILD SUCCESSFUL in 11s 5 actionable tasks: 3 executed, 2 up-to-date目前对 Kotlin/Native 编译器的适配工作也在进行中,未来 compose-desktop 有望通过切换到 Kotlin/Native 进一步提高应用的执行速度。8. Web 端开发是否已经成熟?compose-jb 中整合 compose-web 的最主要意义是帮助 Kotlin 开发者扩大应用的发布场景。如果你已经有一个 compose-desktop 或者 compose-android 项目,那么基于 compose-web 可以把产品快速发布到 Web 端,并共享其中大部分的逻辑代码。compose-web 提供的声明式 DOM API 相对于 HTML+JS+CSS 这一传统技术栈在开发范式上更加先进。但如果你已经有 React 等前端框架的使用经验那么此项优势就不存在了。而且 compose-web 在建设速度上要落后于 compose-desktop,部分 HTML 标签以及属性还缺少对应的 DSL 实现,所以从单纯开发一个前端应用的角度看,如果你对 Kolin 没有执念的话,更推荐使用 React 等已有的成熟框架。即使从跨平台的角度看, desktop-web 也仍然有改善空间。desktop-web 在 API 设计上尊重原有的 HTML 开发习惯,这也导致其 DSL 上与 compose-android 和 compose-desktop 的差异较大,不利于 UI 代码的共享。据悉 JetBrains 团队已经着手开发与其他两端风格一致的 DSL ,届时可以通过 HTML5 Canvas 实现 UI 统一绘制,提高跨平台的开发体验。9. 未来是否支持 iOS?compose-jb 目前没有对 iOS 端的支持,这是其成长为主流跨平台框架道路上的一个严重阻碍,因此可以大胆猜想 compose-jb 在未来一定会增加对 iOS 的支持,而且有迹象表明 JetBrains 已经偷偷开始了这方面工作。有人就曾经在 compose-jb 工程中也发现过针对 iOS 的开发分支,而且 compose-ui 依赖的 Skiko 库也已经增加了对 iOS 的支持,理论上完全可以实现 iOS 侧渲染。由于 iOS 不使用 Kotlin/JVM ,所以在 Kotlin/Native 编译器以及 iOS 工具链等方面存在大量适配工作,毕竟 KMM 本身也还处于 alpha 阶段,iOS 端的开发体验还不理想,这可能就是 compose-ios 迟迟未发布的原因,但是未来是可以期待的。那么在 compose-ios 还未出现的当下,如果你的应用有发布到 iOS 侧的需求,作为一个过渡方案可以先借助 KMM 推荐 D-KMP 架构实现逻辑层和数据层的代码共享,UI 侧现阶段可以使用 Swift-UI 等平台语言进行开发,等待 compose-ios 真正到来时再对 UI 代码进行迁移。https://github.com/dbaroncelli/D-KMP-sample10. Jetpack 是否会跨平台?很多 Android 开发者习惯于 Compose 搭配 Android 的 Jetpack 系列组件一同使用,所以当一个 compose-android 项目被迁移到 compose-jb 时,不少人希望有对应的 KMP 版本 Jetpack 库可供使用。在前不久 Android Dev Summit 上 Andorid Jetpack 团队就这个问题进行了回答,答案是暂时没有相关计划。https://www.youtube.com/watch?v=QreLkok3Euk&t=565s首先 Jetpack 中一些重度依赖 Andorid 平台特性的组件不适合发布 KMP 版本,而一些平台无关的组件例如 Hilt,Room 等,虽然具备 KMP 化的基础但仍然会谨慎启动,因为当前首要任务还是保证其在 Android 端的稳定使用。虽然当前没有计划但是 Jetpack 团队也表示了未来会尝试对部分 Jetpack 库进行 KMP 改造,他们很乐于帮助开发者能更低成本地完成项目向 KMP 的迁移。对于开发者来说,如果你的项目近期有跨平台的需求,那么在技术选型上就要避免过度依赖 Jetpack ,而要像 KMP 的 Library 倾向。比如在逻辑层优先使用 Flow 而非 LiveData 等;在数据层也可以考虑使用 SQLDelight 替代 Room 。

使用 Google MLKit 进行图像识别

MLKit 是 Google 提供的移动端机器学习库。工程师仅通过少量代码就能在 Andorid 或 iOS 上实现各种 AI 能力,例如图像、文字、人脸识别等等,借助 TensorFlow Lite 其中很多能力可以在设备上离线完成。https://developers.google.com/ml-kit本文带大家在 Android 上体验 MLKit 的以下功能:1. 图像识别(Image Labeling)2. 目标检测(Object Detection)3. 目标追踪(Object Tracking)<br/>1. 图像识别(Image Labeling)图像识别是计算机视觉的一个重要领域,简单说就是帮你提取图片中的有效信息。MLKit 提供了 ImageLabeling 功能,可以识别图像信息并进行分类标注。比如输入一张包含猫的图片,ImageLabeling 能识别出图片中的猫元素,并给出一个猫的标注,除了最显眼的猫 ImageLabeling还能识别出花、草等图片中所有可识别的事物,并分别给出出现的概率和占比,识别的结果以 List<ImageLabel> 返回。 基于预置的默认模型,ImageLabeling可以对图像元素进行超过 400 种以上的标注分类,当然你可以使用自己训练的模型扩充更多分类。默认模型当前支持的分类标注:<br/> https://developers.google.com/ml-kit/vision/image-labeling/label-mapAndroid 中引入 MLKit 的 ImageLabeliing 很简单,在 gradle 中添加相关依赖即可implementation 'com.google.mlkit:image-labeling:17.0.5'接下来写一个 Android 的 Demo 来展示使用效果。我们使用 Compose 为 Demo 写一个简单的 UI:@Composable fun MLKitSample() { Column { var imageLabel by remember { mutableStateOf("") } //Load Image val context = LocalContext.current val bmp = remember(context) { context.assetsToBitmap("cat.png")!! Image(bitmap = bmp.asImageBitmap(), contentDescription = "") val coroutineScope = rememberCoroutineScope() Button( onClick = { //TODO : 图像识别具体逻辑,见后文 { Text("Image Labeling") } Text(imageLabel, Modifier.fillMaxWidth(), textAlign = TextAlign.Center) }将图片资源放入 /assets,并加载为 Bitmapfun Context.assetsToBitmap(fileName: String): Bitmap? = assets.open(fileName).use { BitmapFactory.decodeStream(it) }点击 Button 后,对 Bitmap 进行识别,获取识别后的信息更新 imageLabel 。看一下 onClick 内的内容:val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS) val image = InputImage.fromBitmap(bmp, 0) labeler.process(image).addOnSuccessListener { labels : List<ImageLabel> -> // Task completed successfully imageLabel = labels.scan("") { acc, label -> acc + "${label.text} : ${label.confidence}\n" }.last() }.addOnFailureListener { // Task failed with an exception 首先创建 ImageLabeler 处理器,InputImage.fromBitmap 将 Bitmap 处理为 ImageLabeler 可接受的资源类型,处理结果通过 Listener 返回。处理成功,返回 ImageLabel 的列表,ImageLabel 代表每一个种类的标注信息,图像经识别后获得一组这样de 标注,包含每一种类的名字以及其出现概率,这些信息可以在图像检索等场景中作为权重使用。<br/>2. 目标检测(Object Detection)目标检测也是计算机视觉的一个基础研究方向。这里需要注意 “检测” 和 “识别” 的区别:检测(Detecting):关注的是 Where is,即目标在哪里识别(Lebeling):关注的是 What is,即目标是什么ImageLebeling 可以识别图像中的事物分类,但是无法确定哪个事物在哪里。而目标检测可以确定有几个事物分别在哪里,但是事物的分类信息不清晰。ObjectDetection 虽然也提供了一定的识别能力,但是其默认的模型文件只能识别有限的几个种类,无法像 ImageLebeling 那样精确分类。想要识别更准确的信息需要借助额外的模型文件。但是我们可以将上述两套 API 配合使用,各取所长以达到目标检测的同时进行准确的识别和分类。首先添加 ObjectDetection 依赖implementation 'com.google.mlkit:object-detection:16.2.7'接下来在上面例子中,增加一个 Button 用于点击后的目标检测@Composable fun MLKitSample() { Column(Modifier.fillMaxSize()) { val detctedObject = remember { mutableStateListOf<DetectedObject>() } //Load Image val context = LocalContext.current val bmp = remember(context) { context.assetsToBitmap("dog_cat.jpg")!! Canvas(Modifier.aspectRatio( bmp.width.toFloat() / bmp.height.toFloat())) { drawIntoCanvas { canvas -> canvas.withSave { canvas.scale(size.width / bmp.width) canvas.drawImage( // 绘制 image image = bmp.asImageBitmap(), Offset(0f, 0f), Paint() detctedObject.forEach { canvas.drawRect( //绘制目标检测的边框 it.boundingBox.toComposeRect(), Paint().apply { color = Color.Red.copy(alpha = 0.5f) style = PaintingStyle.Stroke strokeWidth = bmp.width * 0.01f if (it.labels.isNotEmpty()) { canvas.nativeCanvas.drawText( //绘制物体识别信息 it.labels.first().text, it.boundingBox.left.toFloat(), it.boundingBox.top.toFloat(), android.graphics.Paint().apply { color = Color.Green.toArgb() textSize = bmp.width * 0.05f Button( onClick = { //TODO : 目标检测具体逻辑,见后文 { Text("Object Detect") } }由于我们要在图像上绘制目标边界的信息,所以这次采用 Canvas 绘制 UI,包括以下内容:drawImage:绘制目标图片drawRect:MLKit 检测成功后会返回 List<DetectedObject> 信息,基于 DetectedObject 绘制目标边界drawText:基于 DetectedObject 绘制目标的分类标注点击 Button 后进行目标检测,具体实现如下:val options = ObjectDetectorOptions.Builder() .setDetectorMode(ObjectDetectorOptions.SINGLE_IMAGE_MODE) .enableMultipleObjects() .enableClassification() .build() val objectDetector = ObjectDetection.getClient(options) val image = InputImage.fromBitmap(bmp, 0) objectDetector.process(image) .addOnSuccessListener { detectedObjects -> // Task completed successfully coroutineScope.launch { detctedObject.clear() detctedObject.addAll(getLabels(bmp, detectedObjects).toList()) .addOnFailureListener { e -> // Task failed with an exception // ... }通过 ObjectDetectorOptions 我们对检测处理进行配置。可使用 Builder 进行多个配置:setDetectorMode : ObjectDetection 有多种目标检测方式,这里使用的是最简单的一种 SINGLE_IMAGE_MODE 即针对单张图片的检测。此外还有针对视频流的检测等其他方式,后文介绍。enableMultipleObjects:可以只检测最突出的事物或是检测所有可事物,我们这里启动多目标检测,检测所有可检测的事物。enableClassification: ObjectDetection 在图像识别上的能力有限,默认模型只能识别 5 个种类,且都是比较宽泛的分类,比如植物、动物等。enableClassification 可以开启图像识别能力。开启后,其识别结果会存入 DetectedObject.labels。由于这个识别结果没有意义,我们在例子中会替换为使用 ImageLebeling 识别后的标注信息基于 ObjectDetectorOptions 创建 ObjectDetector 处理器,传入图片后开始检测。getLabels 是自定义方法,基于 ImageLebeling 添加图像识别信息。检测的最终结果更新至 detctedObject 这个 MutableStateList,刷新 Compose UI。private fun getLabels( bitmap: Bitmap, objects: List<DetectedObject> ) = flow { val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS) for (obj in objects) { val bounds = obj.boundingBox val croppedBitmap = Bitmap.createBitmap( bitmap, bounds.left, bounds.top, bounds.width(), bounds.height() emit( DetectedObject( obj.boundingBox, obj.trackingId, getLabel(labeler, croppedBitmap).map { //转换为 DetectedObject.Label DetectedObject.Label(it.text, it.confidence, it.index) }首先根据 DetectedObject 的边框信息 boundingBox 将 Bitmap 分解为小图片,然后对其调用 getLabel 获取标注信息补充进 DetectedObject 实例(这里实际是重建了一个实例)getLabel 中的 ImageLebeling 是一个异步过程,为了调用方便,定义为一个挂起函数:suspend fun getLabel(labeler: ImageLabeler, image: Bitmap): List<ImageLabel> = suspendCancellableCoroutine { cont -> labeler.process(InputImage.fromBitmap(image, 0)) .addOnSuccessListener { labels -> // Task completed successfully cont.resume(labels) }<br/>3. 目标追踪(Object Tracking)目标追踪就是通过对视频逐帧进行 ObjectDetection ,以达到连续捕捉的效果。接下来的例子中我们启动一个相机预览,对拍摄到图像进行 ObjectTracking。我们使用 CameraX 启动相机,因为 CameraX 封装的 API 更易用。implementation "androidx.camera:camera-camera2:1.0.0-rc01" implementation "androidx.camera:camera-lifecycle:1.0.0-rc01" implementation "androidx.camera:camera-view:1.0.0-alpha20" implementation "com.google.accompanist:accompanist-permissions:0.16.1"如上,引入 CameraX 相关类库,同时引入 accompanist-permissions 用来动态申请相机权限。CameraX 的预览需要使用 androidx.camera.view.PreviewView,我们通过 AndroidView 集成到 Composable 中,AndroidView 上方覆盖 Canvas ,Canvas 绘制目标边框。整个 UI 布局如下:val detectedObjects = mutableStateListOf<DetectedObject>() Box { CameraPreview(detectedObjects) Canvas(modifier = Modifier.fillMaxSize()) { drawIntoCanvas { canvas -> detectedObjects.forEach { canvas.scale(size.width / 480, size.height / 640) canvas.drawRect( //绘制边框 it.boundingBox.toComposeRect(), Paint().apply { color = Color.Red style = PaintingStyle.Stroke strokeWidth = 5f canvas.nativeCanvas.drawText( // 绘制文字 "TrackingId_${it.trackingId}", it.boundingBox.left.toFloat(), it.boundingBox.top.toFloat(), android.graphics.Paint().apply { color = Color.Green.toArgb() textSize = 20f }detectedObjects 是 ObjectDetection 逐帧实时检测的结果。CameraPreview 中集成了相机预览的 AndroidView,并实时更新 detectedObjects 。drawRect 和 drawText 在前面例子中也出现过,但需要注意这里 drawText 绘制的是 trackingId 。 视频的 ObjectDetection 会为 DetectedObject 添加 trackingId 信息, 视频目标的边框位置会不断变换,但是 trackingId 是不变的,这便于在多目标中更好地锁定个体。@Composable private fun CameraPreview(detectedObjects: SnapshotStateList<DetectedObject>) { val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } val coroutineScope = rememberCoroutineScope() val objectAnalyzer = remember { ObjectAnalyzer(coroutineScope, detectedObjects) } AndroidView( factory = { ctx -> val previewView = PreviewView(ctx) val executor = ContextCompat.getMainExecutor(ctx) val imageAnalyzer = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { it.setAnalyzer(executor, objectAnalyzer) cameraProviderFuture.addListener({ val cameraProvider = cameraProviderFuture.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) val cameraSelector = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_BACK) .build() cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, cameraSelector, preview, imageAnalyzer }, executor) previewView modifier = Modifier.fillMaxSize(), }CameraPreview 主要是关于 CameraX 的使用,本文不会逐行说明 CameraX 的使用,只关注与主题相关的代码: CameraX 可以设置 ImageAnalyzer 用于对视频帧进行解析,这正是用于我们的需求,这里自定义了 ObjectAnalyzer 做目标检测。最后看一下 ObjectAnalyzer 的实现class ObjectAnalyzer( private val coroutineScope: CoroutineScope, private val detectedObjects: SnapshotStateList<DetectedObject> ) : ImageAnalysis.Analyzer { private val options = ObjectDetectorOptions.Builder() .setDetectorMode(ObjectDetectorOptions.STREAM_MODE) .build() private val objectDetector = ObjectDetection.getClient(options) @SuppressLint("UnsafeExperimentalUsageError") override fun analyze(imageProxy: ImageProxy) { val frame = InputImage.fromMediaImage( imageProxy.image, imageProxy.imageInfo.rotationDegrees coroutineScope.launch { objectDetector.process(frame) .addOnSuccessListener { detectedObjects -> // Task completed successfully with(this@ObjectAnalyzer.detectedObjects) { clear() addAll(detectedObjects) .addOnFailureListener { e -> // Task failed with an exception // ... .addOnCompleteListener { imageProxy.close() }ObjectAnalyzer 中获取相机预览的视频帧对其进行 ObjectDetection,检测结果更新至 detectedObjects 。注意此处 ObjectDetectorOptions 设置为 STREAM_MODE 专门处理视频检测。虽然把每一帧都当做 SINGLE_IMAGE_MODE 处理理论上也是可行的,但只有 STREAM_MODE 的检测结果才带有 trackingId 的值,而且 STREAM_MODE 下的边框位置经过防抖处理,位移更加顺滑。<br/>最后本文为了参加平台的活动,以喵为例介绍了 MLKit 图像识别的能力, MLKit 还有很多实用功能,比如人脸检测相较于 Android 自带的 android.media.FaceDetector 无论性能还是识别率都有质的飞跃。此外国内不少 AI 大厂也有很多不错的解决方案,比如旷视 。相信随着 AI 技术的发展,未来在移动端上的应用场景也会越来越多。本文代码:https://github.com/vitaviva/JetpackComposePlayground/tree/main/mlkit_exp

Kotin 1.6 新特性一览

Kotlin 1.6 这个版本中都有哪些新的语法特性?更安全的when语句(exhaustive when statements)挂起函数类型可作父类 (suspending functions as supertypes )普通函数转挂起函数(suspend conversion)Builder函数更加易用递归泛型的类型推导注解相关的一些优化1. 更安全的 when 语句Kotlin 的 when 关键字允许我们在 case 分支中写表达式或者语句。1.6 之前在 case 分支写语句时存在安全隐患:// 定义枚举 enum class Mode { ON, OFF } val x: Mode = Mode.ON // when表达式 val result = when(x) { Mode.ON -> 1 // case 中是一个表达式 Mode.OFF -> 2 // when语句 when(x) { Mode.ON -> println("ON") // case 是一个语句 Mode.OFF -> println("OFF") }下表说明了编译器针对 when 关键字的检查内容x 的类型枚举、密封类/接口、Bool型等(可穷举类型)不可穷举类型when表达式case 必须穷举所有分支,或者添加 else,否则编译出错Case 分支必须包含 else,否则编译出错when语句case 可以不穷举所有分支,不会报错同上可见,当 x 是可穷举类型时,编译器对when表达式的检查比较严谨,如果 case 不能穷举所有分支或者缺少 else,编译器会报错如下:ERROR: 'when' expression must be exhaustive, add necessary 'is TextMessage' branch or 'else' branch instead 但编译器对于 when语句 的检查却不够严谨,即使没有穷举所有分支也不会报错,不利于开发者写出安全的代码:// when语句 when(x) { // WARNING: [NON_EXHAUSTIVE_WHEN] 'when' expression on enum is recommended to be exhaustive, add 'OFF' branch or 'else' branch instead Mode.ON -> println("ON") // case 是一个语句 }Kotlin 1.6 起,当你在 When语句 中是可穷举类型时必须处理所有分支,不能遗漏。考虑到历史代码可能很多,为了更平稳的过渡,1.6 对 when语句 中没有穷举的 case 会首先给出 Warning,从 1.7 开始 Warning 将变为 Error 要求开发者强制解决。2. 挂起函数类型可作父类Kotlin 中一个函数类型可以作为父类被继承。class MyFun<T>(var param: P): () -> Result<T> { override fun invoke(): Result<T> { // 基于成员 param 自定义逻辑 fun <T> handle(handler: () -> Result<T>) { //... }Kotlin 代码中大量使用各种函数类型,许多方法都以函数类型作为参数。当你需要调用这些方法时,需要传入一个函数类型的实例。而当你想在实例中封装一些可复用的逻辑时,可以使用函数类型作为父类创建子类。但是这种做法目前不适用于挂起函数,你无法继承一个 suspend 函数类型的父类class C : suspend () -> Unit { // Error: Suspend function type is not allowed as supertypes C().startCoroutine(completion = object : Continuation<Unit> { override val context: CoroutineContext get() = TODO("Not yet implemented") override fun resumeWith(result: Result<Unit>) { TODO("Not yet implemented") })但是以挂起函数作为参数或者 recevier 的方法还挺多的,所以 Kotlin 1.5.30 在 Preveiw 中引入了此 feature,这次 1.6 将其 Stable。class MyClickAction : suspend () -> Unit { override suspend fun invoke() { TODO() } fun launchOnClick(action: suspend () -> Unit) {}如上,你可以现在可以像这样调用了 launchOnClick(MyClickAction())。需要注意普通函数类型作为父类是可以多继承的class MyClickAction : () -> Unit, (View) -> Unit { override fun invoke() { TODO("Not yet implemented") override fun invoke(p1: View) { TODO("Not yet implemented") }但是目前挂起函数作为父类不支持多继承,父类列表中,既不能出现多个 suspend 函数类型,也不能有普通函数类型和suspend函数类型共存。3. 普通函数转挂起函数这个 feature 也是与函数类型有关。Kotlin 中为一个普通函数添加 suspend 是无害的,虽然编译器会提示你没必要这么做。当一个函数签名有一个 suspend 函数类型参数,但是也允许你传入一个普通函数,在某些场景下是非常方便的。//combine 的 transform 参数是一个 suspend 函数 public fun <T1, T2, R> combine( flow: Flow<T1>, flow2: Flow<T2>, transform: suspend (a: T1, b: T2) -> R): Flow<R> = flow.combine(flow2, transform) suspend fun before4_1() { combine( flowA, flowB ) { a, b -> a to b }.collect { (a: Int, b: Int) -> println("$a and $b") }如上述代码所示,flow 的 combine 方法其参数 transform 类型是一个 suspend 函数,我们希望再次完成一个 Pair 的创建。这个简单的逻辑本无需使用 suspend ,但在 1.4 之前只能像上面这样写。Kotlin 1.4 开始,普通函数的引用可以作为 suspend 函数传参,所以 1.4 之后可以改成下面的写法,代码更简洁:suspend fun from1_4() { combine( flowA, flowB, ::Pair ).collect { (a: Int, b: Int) -> println("$a and $b") }1.4 之后仍然有一些场景中,普通函数不能直接转换为 suspend 函数使用fun getSuspending(suspending: suspend () -> Unit) {} fun suspending() {} fun test(regular: () -> Unit) { getSuspending { } // OK getSuspending(::suspending) // OK from 1.4 getSuspending(regular) // NG before 1.6 }比如上面 getSuspending(regular) 会报错如下:ERROR:The feature "suspend conversion" is disabled Kotlin 1.6 起,所有场景的普通函数类型都可以自动转换为 suspend 函数传参使用,不会再看到上述错误。4. Builder 函数更加易用我们在构建集合时会使用一些 Builder函数,比如 buildList,buildMap 之类。@ExperimentalStdlibApi @kotlin.internal.InlineOnly public inline fun <E> buildList(@BuilderInference builderAction: MutableList<E>.() -> Unit): List<E> { contract { callsInPlace(builderAction, InvocationKind.EXACTLY_ONCE) } return buildListInternal(builderAction) @kotlin.ExperimentalStdlibApi val list = buildList<String> { add("a") add("b") }buildList 的实现中使用 @BuilderInterface 注解了 builderAction 这个 lambda 。这样可以在调用时 buildList 通过 builderAction 内部的方法调用智能推导出泛型参数的类型,从而减少模板代码//<String> 可省略 val list = buildList { add("a") add("b") //<String> 不可省略 val list = buildList<String> { add("a") add("b") val x = get(1) }但是 BuilderInterface 的类型推导限制比较多,比如 lambda 中调用的方法的签名要求比较严格,必须参数是泛型且返回值没有泛型,破坏了规则,类型推导失败了。所以上面代码中 lambda 有 get() 调用时,就必须清楚的标记泛型类型。这使得集合类的 builder 函数使用起来不那么灵活。Kotlin 1.6 起 BuilderInterface 没有了类似限制,对我们来说最直观好处就是 Builder 函数内怎样的调用都不会受限制,使用更加自由val list = buildList { add("a") add("b") set(1, null) //OK val x = get(1) //OK if (x != null) { removeAt(1) //OK val map = buildMap { put("a", 1) //OK put("b", 1.1) //OK put("c", 2f) //OK }此 feature 在 1.5.30 也可以通过 添加 -Xunrestricted-builder-inference 编译器选项生效,1.6 已经是默认生效了。5. 递归泛型的类型推导这个 feature 我们平常需求比较少。Java 或者 Kotlin 中我们可以像下面这样定义有递归关系的泛型,即泛型的上限是它本身public class PostgreSQLContainer<SELF extends PostgreSQLContainer<SELF>> extends JdbcDatabaseContainer<SELF> { //... }这种情况下的类型推导比较困难,Kotlin 1.5.30 开始可以只基于泛型的上线进行类型推导。// Before 1.5.30 val containerA = PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres:13-alpine")).apply { withDatabaseName("db") withUsername("user") withPassword("password") withInitScript("sql/schema.sql") // With compiler option in 1.5.30 or by default starting with 1.6.0 val containerB = PostgreSQLContainer(DockerImageName.parse("postgres:13-alpine")) .withDatabaseName("db") .withUsername("user") .withPassword("password") .withInitScript("sql/schema.sql")1.5.30 支持此 feature 需要添加 -Xself-upper-bound-inference 编译选项, 1.6 开始默认支持。6. 注解相关的一些优化Kotlin 1.6 中对注解进行了诸多优化,在编译器注解处理过程中将发挥作用支持注解的实例化annotation class InfoMarker(val info: String) fun processInfo(marker: InfoMarker) = ... fun main(args: Array<String>) { if (args.size != 0) processInfo(getAnnotationReflective(args)) processInfo(InfoMarker("default")) }Java 的注解本质是实现了 Annotation 的接口,可以被继承使用@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface JavaClassAnno { String[] value(); public interface JavaClassAnno extends Annotation{ //... class MyAnnotation implements JavaClassAnno { // <--- works in Java //... }但是在 Kotlin 中无法继承使用,这导致有一些接受注解类的 API 在 Kotlin 侧无法调用。class MyAnnotationLiteral : JavaClassAnno { // <--- doesn't work in Kotlin (annotation can not be inherited) //... }注解类可以实例化之后,可以调用接收注解类参数的 API,能够与 Java 代码进行更好地兼容泛型参数可添加注解@Target(AnnotationTarget.TYPE_PARAMETER) annotation class BoxContent class Box<@BoxContent T> {}Kotlin 1.6 之后可以为泛型参数添加注解,这将为 KAPT / KSP 等注解处理器中提供方便。可重复的运行时注解Jdk 1.8 引入了 @java.lang.annotation.Repetable 元注解,允许同一个注解被添加多次。 Kotlin 也相应地引入了 @kotlin.annotation.Repeatable ,不过 1.6之前只能注解 @Retention(RetentionPolicy.SOURCE) 的注解,当非 SOURCE 的注解出现多次时,会报错ERROR: [NON_SOURCE_REPEATED_ANNOTATION] Repeatable annotations with non-SOURCE retention are not yet supported此外,Kotlin 侧代码也不能使用 Java 的 @Repeatable 注解来注解多次。Kotlin1.6 开始,取消了只能用在 SOURCE 类注解的限制,任何类型的注解都可以出现多次,而且 Kotlin 侧支持使用 Java 的 @Repeatable 注解@Repeatable(AttributeList.class) @Target({ElementType.TYPE}) @Retentioin(RetentionPolicy.RUNTIME) //虽然是 RUNTIME 注解 annotation class Attribute(val name: String) @Attribute("attr1") //OK @Attribute("attr2") //OK class MyClass {}最后上述介绍的是 Kotlin1.6 在语法方面的一些新特性,大部分在 1.5.30 中作为 preview 功能已经出现过,这次在 1.6 中进行了转正。除了新的语法特性,1.6 在各平台 Compiler 上有诸多新内容,我们在平日开发中接触不到本文就不介绍了。更多内容参考:https://kotlinlang.org/docs/whatsnew16.html

展望2022:Android 开发最新技术动向

Android Dev Summit 2021: https://developer.android.com/events/dev-summit今年 Android Dev Summit 活动在线上如期举行。今年的 Slogan 是 “Excellent apps,across devices” , 即使用 Jetpack 等 MAD Skill (Modern Android Development) 开发出更优秀的应用,并通过 Android 系统落地到更多种类的智能设备。本次活动围绕这一主旨做了 30 多场技术分享(视频),涉及多个方向:Android 1212LBuilding across screensKotlinJetpackJetpack ComposeAndroid StudioAGP<br/>Android 12Material YouAndroid12 在10月进行了正式推送。Android12 的最大亮点就是基于 Material You 设计语言对原生系统 UI 进行了重新设计。 Material You 是 Material Design 的第3个版本,距离上一代 M2 已经过去了4年跟上一代 M2 相比 M3 的元素面积更大、更便于用户点击;同时圆角的角度更大使得并排的元素之间的间隔更清晰。个性化是 M3 最大的特点,这也是 "You" 的命名来源。Android12 遵循了 M3 的 Dynamic Color 设计原则,系统可以从用户的壁纸中抓取颜色,然后色阶化应用到你开发的应用中,应用跟随主题的不同和变换颜色,千人千面。Stretch OverScrollhttps://developer.android.com/training/gestures/scroll#implement-stretch-overscrollAndroid12 中加入了 Stretch OverScroll Effect ,相对于以前的水波纹效果,滚动反馈更加真实自然。开发者可以使用新增的 getDistance() 和 onPullDistance() API 来控制 OverScoll 的强度,当然你也可以通过 XML 中设置 android:overScrollMode="never" 来屏蔽此效果。App Splash Screenhttps://developer.android.com/guide/topics/ui/splash-screen/migrateAndroid12 增加了 Splash Screen API,可以在进入 App 主页之前自动插入开屏页,当然它的目的是为了让应用减少白屏的等待时间而非广告植入。Spash Screen 默认使用 App 的 Icon 作为开屏图案,开发者也可以使用系统提供的 API 自定义开屏图案甚至动画。如果在非 Android12 设备上也想使用Splash Screen功能,则可以使用 Jetpack 也提供了同名 SplashScreen 库,适配到了低至 Android 6(APP 23)的设备。需要注意,如果你的项目中通过 android:windowBackground 或者 CustomActivity 的方式自定义了开屏页,则需要进行适配,避免在 Android12 中出现两次开屏Foreground service restrictionshttps://developer.android.com/guide/components/foreground-services#background-start-restrictionsAndroid8 出于隐私保护的考虑,禁止了 Service 的后台启动,本次 Android12 中的限制进一步加强,除了一些特殊情况外,Foreground Service 也不允许在后台启动,否则会抛出 ForegroundServiceStartNotAllowedException 异常。 Service 的存在越来越鸡肋,或将逐渐被 WorkManager 所替代Compatibility Test每一个新版本的 Android 系统升级都会带来不少 API 的行为变动,Android12 也不例外。为了确保你的 APP 在这些变动下行为正常,一般需要修改 targetSDKVersion 进行针对性的测试。 Android11 起提供了兼容性测试工具,在不重新编译 APK 的情况下可以针对变动的 API 进行测试、提高测试效率。在 Developer options > App compatibility changes 中可以找到测试工具<br/>12L (Android 12 Large Screens)https://developer.android.com/about/versions/12/12L近年来,搭载 Android 系统的大屏设备增长迅速,除了平板类产品以外又出现了折叠屏手机这一新兴门类,目前已经有超过250万部大屏幕设备上运行着 Android 系统。为提高大屏设备的使用体验。 Android12 即将推出一个专门为大屏优化的版本,命名 12L。12L 针对大屏设备和折叠屏对界面进行了优化,例如当屏幕宽度大于 600dp 时将默认显示两列内容、引入了类似 Chrome OS 的 Dock 栏等,同时支持拖拽分屏等功能,同时在不同窗口中启动多个应用WindowManagerhttps://medium.com/androiddevelopers/using-workmanager-on-android-12-f7d483ca0ecb为应对更多种类屏幕的出现,Jetpack 提供了 WindowManager 库,便于 App 更好地适配不同屏幕的尺寸。多窗口模式下的 App 不能再依赖 Display.getRealMetrics() 获取窗口尺寸,当屏幕状态变化导致,OnConfigurationChanged 发生时,使用 WindowManager 的 WindowMetrics 获取准确的窗口尺寸,再根据 WindowSizeClass 以最合适的布局显示当前 UI。Jetpack Compose 能更好地以响应式的方式处理 OnConfigurationChanged 时的 UI 变化,非常适合配合在 12L 的设备上使用。enum class WindowSizeClass { COMPACT, MEDIUM, EXPANDED } @Composable fun Activity.rememberWindowSizeClass() { val configuration = LocalConfiguration.current val windowMetrics = remember(configuration) { WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(this) val windowDpSize = with(LocalDensity.current) { windowMetrics.bounds.toComposeRect().size.toDpSize() val widthWindowSizeClass = when { windowDpSize.width < 600.dp -> WindowSizeClass.COMPACT windowDpSize.width < 840.dp -> WindowSizeClass.MEDIUM else -> WindowSizeClass.EXPANDED val heightWindowSizeClass = when { windowDpSize.height < 480.dp -> WindowSizeClass.COMPACT windowDpSize.height < 900.dp -> WindowSizeClass.MEDIUM else -> WindowSizeClass.EXPANDED // Use widthWindowSizeClass and heightWindowSizeClass }本次活动中分享的不少新技术都第一时间适配了 Compose ,这也反映出 Android 将 Compose 作为首选的 UI 解决方案的决心。Activity embedding除了可以多窗口中打开多个应用,12L 还可以借助 XML 的配置或者调用 WindowManager 提供的 API 实现同一应用下多个 Activity 的并排显示。<br/>Building across screensAndroid WareCompose 技术栈采用了分层设计的思想,只要替换局部组件就可以迁移到不同平台中使用,例如 WareOs 中只需要替换 Material 和 Navigation 的便可以实现穿戴设备 UI 的开发。以一个卡片为例,除了新增个别 Composable 以外,与手机端的写法别无二致AppCard( appImage = { Image(painter = painterResource(id = R.drawable.ic_message), ... ) appName = { Text("Messages") }, time = { Text("12m") }, title = { Text("Kim Green") }, onClick = { ... }, content = { Column(modifier = Modifier.fillMaxWidth()) { Text("On my way!") )Android for CarsAndroid 提供了两套车机系统 Android Auto 以及 Android Automotive OS。Android Auto 提供了针对驾驶员优化的应用体验,用户在 Android Auto 上创建连接手机的服务,手机应用可以以更优化的界面显示在车机上。Android Automotive OS 是一款基于 Android 的车载信息娱乐系统。车载系统是专为提升驾驶体验而优化的独立 Android 设备。相对于 Android Auto,它无需借助手机,用户可以将应用直接安装到车载系统上。开发者可以跨平台的工程结构开发车机应用:car_app_common 是共享部分automotive_os 和 andorid_auto 是两个 build target<br/>KotlinKotlin Flowhttps://medium.com/androiddevelopers/migrating-from-livedata-to-kotlins-flow-379292f419fbKotlin方面,本次活动上重点推荐了 Kotlin Flow 在 MVVM 架构中的应用。基于 Jetpack 的 lifecycle-ktx 扩展库 Flow 可以转变为一个 lifeycle-aware 组件,较好地替代现有的 LiveData 的使用场景。你可以只在 Model 层使用 Flow,在 View 层仍然使用 LiveData,通过 Flow.asLiveData 将 Flow 转换为 LiveData:// import androidx.lifecycle.asLiveData class MessagesViewModel(repository: MessageRepository) : ViewModel() { val userMessage = repository.userMessage.asLiveData() }当然 View 层也可以直接使用 Flow,在 lifecycleScope.launch { } 或 lifecycleScope.launchWheStart { } 中收集 Flow 的数据避免泄露,但是从性能出发更推荐使用 repeatOnLifecycle://imprort androidx.lifecycle.repeatOnLifecycle class MessagesActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.userMessages.collect { messages -> listAdapter.submitList(messages) } 当 MessagesActivity 离开 STARTED 时,协程及时取消节省资源。此外使用 stateIn 可以将 Flow 转化为一个 StateFlow 以热流的形式确保数据的下游共享。 活动期间有网友在直播中询问是否还有 Flow 无法取代 LiveData 的场景,官方的回答是 LiveData 除了 API 更简单以外(相应的功能也比较弱),已经完全可以被 Flow 替代。KSPhttps://android-developers.googleblog.com/2021/09/accelerated-kotlin-build-times-with.htmlKSP (Kotlin Symbol Processing) 于9月份发布了 1.0 正式版。相比较于 KAPT 需要生成 Java Stub 后再基于 APT 处理注解的流程,KSP 底层基于基于 Kotlin Compiler Plugin ,省去了 Java Stub 的生成,编译速度可以提高2倍以上,未来在 Kotlin Multiplatform Project 中也可使用,如果你的项目代码已经迁移到 Kotlin,那么未来的注解处理应该首选 KSP。apply plugin: 'com.google.devtools.ksp' dependencies { implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" ksp "androidx.room:room-compiler:$room_version" }将 KAPT 替换为 KSP 的配置非常简单,目前已经有包括 Room 在内的许多常见框架对 KSP 进行了支持,未来 Dagger,Hilt 等也将接入 KSP 以加速注解处理。<br/>JetpackRoomhttps://medium.com/androiddevelopers/room-auto-migrations-d5370b0ca6eb10月份 Room 发布 2.4.0 Beta 01,主要新增了 Auto Migratioins 和 Multi-map Relations 两个新 Features,同时支持使用 KSP 进行注解处理。当数据库表结构发生变化时,需要通过数据库迁移保证数据的不丢失,例如字段名变化之类的变更,需要手写 SQL 才能完成升级,而基于 Auto Migrations 可以检测出两个表结构的区别,完成自动升级。 @Database( version = MusicDatabase.LATEST_VERSION, entities = { Song.class, Artist.class }, autoMigrations = { @AutoMigration ( from = 1, to = 2 exportSchema = true public abstract class MusicDatabase extends RoomDatabase { }之前的版本中 Room 使用 @Relatioin 进行外键关联,为了避免多写 SQL 需要单独额外定义 Relatioin Class,其实对于 SQL 的态度没必要谈虎色变,适当地活用 SQL 有助于更简单地定义一对多的实体关系。//Room Relations data class ArtistAndSongs( ` @Embedded val artist: Artist, @Relation(...) val songs: List<Song> @Query("SELECT * FROM Artist") fun getArtistAndSongs(): List<ArtistAndSongs> //Room Multimap @Query("SELECT * FROM Artist JOIN Song ON Artist.artistName = Song.songArtistName") fun getAllArtistAndTheirSongsList(): Map<Artist, List<Song>>Map<Artist, List<Song>> 这样的数据结构使用起来也更简单WorkManagerhttps://medium.com/androiddevelopers/using-workmanager-on-android-12-f7d483ca0ecbWorkManager 已经不单单是一个简单的异步任务处理框架,更是一整套强大的任务调度方案,可以有效替代 Service,更可靠地运行长时间的任务。最低可以向后兼容到 6.0,覆盖了市场绝多大数的机型。WorkManager 2.6 支持 Multi-Process,借助 RemoteListenableWorker 或者 RemoteCoroutineWorker 可以将任务运行在任意指定进程,实现跨进程的监听;为应对 Android12 的 Foreground Service 的启动限制,WorkManager 2.7 新增了 setExpedited API,可以高优的立即启动相关任务,不受后台启动的约束。val request = OneTimeWorkRequestBuilder<HighPriorityWorker>() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() WorkManager.getInstance(context).enqueue(request)由于 CoroutineWorker.setForeground() 和 ListenableWorker.setForegroundAsync() 方法由 Foreground Service 提供支持,在一些禁止后台启动的场景中一旦被调用,会发生 ForegroundServiceStartNotAllowedException 异常,这是在开发中需要特别注意的。More Components此外,Jetpack 的其他一些库近期也都有新版本的发布。Navigation 2.4.0 beta 增加了多栈返回的支持,不同 NavHostFragment 的返回栈可以各自管理;DataStore 发布 1.0 可以更安全地替代 SharedPreferences 的使用;CameraX 1.1.0-alpha10 增加了 VideoCapture 视频截图和曝光补偿等实用功能; Benchmark 1.1.0-alpha11 增加了 Frame Timing,性能测试更加精准,并向后兼容到 API 23。<br/>Jetpack ComposeCompose 新增 androidx.compose.material3 库,支持开发 Material You 主题风格的 UI。Material3Compose.M3 通过 ColorScheme 来自定义配色方案,支持了 Material You 的 color scheme 设计规范。private val Blue40 = Color(0xff1e40ff) private val DarkBlue40 = Color(0xff3e41f4) private val Yellow40 = Color(0xff7d5700) // Remaining colors from tonal palettes private val LightColorScheme = lightColorScheme( primary = Blue40, secondary = DarkBlue40, tertiary = Yellow40, // error, primaryContainer, onSecondary, etc. private val DarkColorScheme = darkColorScheme( primary = Blue80, secondary = DarkBlue80, tertiary = Yellow80, // error, primaryContainer, onSecondary, etc. )如上,定义 light 和 dark 两种 scheme,然后参数传递给 MaterialThemeval darkTheme = isSystemInDarkTheme() MaterialTheme( colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme // M3 app content }Dynamic ColorDynamic color 是 Material You 的最主要特色,在 Android12 及其后续设备可以通过设置 Dynamic ColoScheme 实现动态颜色切换:// Dynamic color is available on Android 12+ val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colorScheme = when { dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) darkTheme -> DarkColorScheme else -> LightColorScheme }如上,当应用了 Dynamic ColorScheme 后,选择红色或者蓝色墙纸后 App 的 UI 呈现对应的主题颜色<br/>Android StudioAndroid Studio Arctic Fox 正式版发布Ancroid Studio Bumblebe 进入 Beta 阶段而最新的 Canary 版本是 Chipmunk。这近几个版本的迭代中 Android Studio 面向如何提高开发者的编码和调试效率增加了一系列新功能。Compose @preview最近的 Andorid Studio 版本中对 Compose 的预览功能进行了多项强化:像原生视图那样,支持对 Compose UI 进行 3D 布局预览;对于一些字面值变量的修改无需重新编译即可实现预览的实时更新:新增 Preview Configuration 面板,对 @Preview 注解中的参数修改更加快捷;Jank Detection在 Performance Profile 中新增了 Frames 视图,可以监控每一帧的耗时情况,更好地调试和发现 Jank 一类的问题。此外,Android Studio 对模拟器进行了不少强化,模拟器模拟更多真实设备的使用场景,例如重力感应等。<br/>AGP(Android Gradle Plugin)Non-transitive R classAGP 7.0 以来针对编译速度的提升下了不少功夫,例如对 KSP 以及 Non-transitive R class 的支持。 Non-transitive R class 通过显示指定资源文件的完整包名,避免了 R 文件的隐式传递依赖、提升了编译速度,AGP 配合新的 Androi Studio 可以对工程进行 Non-transitive R class 的一键重构Incremental LintAGP 7.0 引入 Lint 增量检查,大幅提升了 Lint 的检查速度Configuration cachehttps://medium.com/androiddevelopers/configuration-caching-deep-dive-bcb304698070AGP 通过 Gradle 配置缓存的开启,可以显著提升各种情况下的编译速度在 Android Studio 的 gradle.properties 中增加一下配置即可启动 Configuration Cacheorg.gradle.unsafe.configuration-cache=true<br/>SummaryAndroid Dev Summit 的分享主题涉及了 Android 领域的方方面面,开发者无需了解,更重要的是从这些分享中洞察到未来的技术的发展趋势,比如未来的 App 可能需要适配更多而屏幕尺寸、Jetpack Compose 在 UI 开发上的先进性正逐渐凸显;Kotlin Flow 对 LiveData 以及 WorkManager 对 Service 的替代趋势也逐渐清晰。。

@OnLifecycleEnvent 被废弃,替代方案更简单

近期 androidx.lifecycle 发布了 2.4.0 版本,此次更新中 @OnLifecycleEvent 注解被废弃,官方建议使用 LifecycleEventObserver 或者 DefaultLifecycleObserver 替代现代的 Android 应用中都少不了 Lifecycle 的身影,正是各种 lifecycle-aware 组件的存在保证了程序的健壮性。Lifecycle 本质是一个观察者模式的最佳实践,通过实现 LifecycleObserver 接口,开发者可以自自定 lifecycle-aware 组件,感知 Activity 或 Fragment 等 LifecycleOwner 的生命周期回调。趁新版本发布之际,我们再回顾一下 Lifecycle 注解的使用以及废弃后的替代方案Lifecycle Events & StatesLifecyce 使用两组枚举分别定义了 Event 和 State。EventsON_CREATEON_STARTON_RESUMEON_PAUSEON_STOPON_DESTROYON_ANYStatesINITIALIZEDCREATEDSTARTEDRESUMEDDESTROYEDEvents 对应了 Activity 等原生系统组件的生命后期回调, 每当 Event 发生时意味着这些 LifecycleOwner 进入到一个新的 State。 作为 观察者的 LifecycleObserver 可以感知到 被观察者的 LifecycleOwner 其生命周期 State 变化时的 Event。定义一个 LifecycleObserver 常用以下两种方式:实现 LifecycleEventObserver 接口使用 @OnLifecycleEvent 注解实现 LifecycleEventObserverpublic interface LifecycleEventObserver extends LifecycleObserver { void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event); }LifecycleEventObserver 是一个单方法接口,在 Kotlin 中可转为写法更简洁的 Lambda 进行声明val myEventObserver = LifecycleEventObserver { source, event -> when(event) { Lifecycle.Event.ON_CREATE -> TODO() Lifecycle.Event.ON_START -> TODO() else -> TODO() }LifecycleEventObserver 本身就是 LifecycleObserver 的派生,使用时直接 addObserver 到 LivecycleOwner 的 Lifecycle 即可。需要在 onStateChanged 中写 swich / case 自己分发事件。相对于习惯重写 Activity 或者 Fragment 的 onCreate, onResume 等方法,稍显啰嗦。因此 Lifecycle 给我们准备了 @OnLifecycleEvent 注解使用 @OnLifecycleEvent 注解使用方法很简单,继承 LifecycleObserver 接口,然后在成员方法上添加注解即可val myEventObserver = object : LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) fun onStart() { TODO() @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) fun onCreat() { TODO() }添加注册后,到 LifecycleOwner 的 Event 分发时,会自动回调注解匹配的成员方法,由于省去了手动 switch/case 的过程,深受开发者喜欢注解解析过程Event 分发时,怎么就会回到到注解对应的方法的?通过 addObserver 添加的 LifecycleObserver ,都会转为一个 LifecycleEventObserver ,LifecycleOwner 通过调用其 onStateChanged 分发 Event在 Lifecycling#lifecycleEventObserver 中处理转换public class Lifecycling { @NonNull static LifecycleEventObserver lifecycleEventObserver(Object object) { boolean isLifecycleEventObserver = object instanceof LifecycleEventObserver; boolean isFullLifecycleObserver = object instanceof FullLifecycleObserver; // 观察者是 FullLifecycleObserver if (isLifecycleEventObserver && isFullLifecycleObserver) { return new FullLifecycleObserverAdapter((FullLifecycleObserver) object, (LifecycleEventObserver) object); // 观察者是 LifecycleEventObserver if (isFullLifecycleObserver) { return new FullLifecycleObserverAdapter((FullLifecycleObserver) object, null); if (isLifecycleEventObserver) { return (LifecycleEventObserver) object; final Class<?> klass = object.getClass(); int type = getObserverConstructorType(klass); // 观察者是通过 apt 产生的类 if (type == GENERATED_CALLBACK) { List<Constructor<? extends GeneratedAdapter>> constructors = sClassToAdapters.get(klass); if (constructors.size() == 1) { GeneratedAdapter generatedAdapter = createGeneratedAdapter( constructors.get(0), object); return new SingleGeneratedAdapterObserver(generatedAdapter); GeneratedAdapter[] adapters = new GeneratedAdapter[constructors.size()]; for (int i = 0; i < constructors.size(); i++) { adapters[i] = createGeneratedAdapter(constructors.get(i), object); return new CompositeGeneratedAdaptersObserver(adapters); // 观察者需要通过反射生成一个 wrapper return new ReflectiveGenericLifecycleObserver(object); public static String getAdapterName(String className) { return className.replace(".", "_") + "_LifecycleAdapter"; }逻辑很清晰,根据 LifecycleObserver 类型不用转成不同的 LifecycleEventObserver, 用一段伪代码梳理如下:if (lifecycleObserver is FullLifecycleObserver) { return FullLifecycleObserverAdapter // 后文介绍 } else if (lifecycleObserver is LifecycleEventObserver) { return this } else if (type == GENERATED_CALLBACK) { return GeneratedAdaptersObserver } else {// type == REFLECTIVE_CALLBACK return ReflectiveGenericLifecycleObserver }注解有两种使用用途。场景一:runtime 时期使用反射生成 wrapperclass ReflectiveGenericLifecycleObserver implements LifecycleEventObserver { private final Object mWrapped; private final CallbackInfo mInfo; ReflectiveGenericLifecycleObserver(Object wrapped) { mWrapped = wrapped; mInfo = ClassesInfoCache.sInstance.getInfo(mWrapped.getClass()); @Override public void onStateChanged(LifecycleOwner source, Event event) { mInfo.invokeCallbacks(source, event, mWrapped); }CallbackInfo 是关键,通过反射收集当前 LifecycleObserver 的回调信息。onStateChanged 中通过反射调用时,不会因为因为缺少 method 报错。场景二:编译时使用 apt 生成 className + _LifecycleAdapter除了利用反射, Lifecycle 还提供了 apt 方式处理注解。添加 gradle 依赖:dependencies { // java 写法 annotationProcessor "androidx.lifecycle:lifecycle-compiler:2.3.1" // kotlin 写法 kapt "androidx.lifecycle:lifecycle-compiler:2.3.1" }这样在编译器就会根据 LifecyceObserver 类名生成一个添加 _LifecycleAdapter 后缀的类。 比如我们加了 onCreat 和 onStart 的注解,生成的代码如下:public class MyEventObserver_LifecycleAdapter implements GeneratedAdapter { final MyEventObserver mReceiver; MyEventObserver_LifecycleAdapter(MyEventObserver receiver) { this.mReceiver = receiver; @Override public void callMethods(LifecycleOwner owner, Lifecycle.Event event, boolean onAny, MethodCallsLogger logger) { boolean hasLogger = logger != null; if (onAny) { return; if (event == Lifecycle.Event.ON_CREATE) { if (!hasLogger || logger.approveCall("onCreate", 1)) { mReceiver.onCreate(); return; if (event == Lifecycle.Event.ON_START) { if (!hasLogger || logger.approveCall("onStart", 1)) { mReceiver.onStart(); return; }apt 减少了反射的调用,性能更好,当然会牺牲一些编译速度。为什么要使用注解生命周期的 Event 种类很多,我们往往不需要全部实现,如过不使用注解,可能需要实现所有方法,产生额外的无用代码上面代码中的 FullLifecycleObserver 就是一个全部方法的接口interface FullLifecycleObserver extends LifecycleObserver { void onCreate(LifecycleOwner owner); void onStart(LifecycleOwner owner); void onResume(LifecycleOwner owner); void onPause(LifecycleOwner owner); void onStop(LifecycleOwner owner); void onDestroy(LifecycleOwner owner); }从接口不是 public 的( java 代码 ) 可以看出,官方也无意让我们使用这样的接口,增加开发者负担。遭废弃的原因既然注解这么好,为什么又要废弃呢?This annotation required the usage of code generation or reflection, which should be avoided.从官方文档的注释可以看到,注解要么依赖反射降低运行时性能,要么依靠 APT 降低编译速度,不是完美的方案。我们之所引入注解,无非是不想多实现几个空方法。早期 Android 工程不支持 Java8 编译,接口没有 default 方法, 现如今 Java8 已经是默认配置,可以为接口添加 default 方法,此时注解已经失去了存在的意义。如今官方推荐使用 DefaultLifecycleObserver 接口来定义你的 LifecycleObserverpublic interface DefaultLifecycleObserver extends FullLifecycleObserver { @Override default void onCreate(@NonNull LifecycleOwner owner) { @Override default void onStart(@NonNull LifecycleOwner owner) { @Override default void onResume(@NonNull LifecycleOwner owner) { @Override default void onPause(@NonNull LifecycleOwner owner) { @Override default void onStop(@NonNull LifecycleOwner owner) { @Override default void onDestroy(@NonNull LifecycleOwner owner) { }FullLifecycleObserverAdapter, 无脑回调 FullLifecycleObserver 即可class FullLifecycleObserverAdapter implements GenericLifecycleObserver { private final FullLifecycleObserver mObserver; FullLifecycleObserverAdapter(FullLifecycleObserver observer) { mObserver = observer; @Override public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) { switch (event) { case ON_CREATE: mObserver.onCreate(source); break; case ON_START: mObserver.onStart(source); break; case ON_RESUME: mObserver.onResume(source); break; case ON_PAUSE: mObserver.onPause(source); break; case ON_STOP: mObserver.onStop(source); break; case ON_DESTROY: mObserver.onDestroy(source); break; case ON_ANY: throw new IllegalArgumentException("ON_ANY must not been send by anybody"); }需要注意 DefaultLifecycleObserver 在 2.4.0 之前也是可以使用的, 存在于 androidx.lifecycle.lifecycle-common-java8 这个库中, 2.4.0 开始 统一移动到 androidx.lifecycle.lifecycle-common 了 ,已经没有 java8 单独的扩展库了。

Compose Mutiplatform 实战联机小游戏

1. 认识 Compose MultiplatformJetpack Compose 作为 Android 端的新一代UI开发工具,得益于 Kotlin 优秀的语法特性,代码写起来十分简洁,广受开发者好评。作为 Kotlin 的开发方,JetBrains 在 Compose 的研发过程中也给与了大量帮助,可以说 Compose 是 Google 和 JetBrains 合作的产物。在参与合作的过程中,JetBrains 也看到了 Compose 在跨平台方面的潜力,Compose 良好的分层设计使得其除了渲染层以外的的大部分代码都是平台无关的,依托 Kotlin Multiplatform (KMP), Compose 可以低成本地化身为一个跨平台框架。JetBrains 一年多前开始基于 Jetpack 源码开发更多的 Compose 应用场景:DateMilestone2020/11发布 Compose for Desktop,Compose 可以支持 MacOS、Windows、Linux 等桌面端 UI 的开发,并在后续的几个 Milestone 中持续扩展新能力2021/05发布 Compose for Web,Compose 基于 Kotlin/JS 实现前端 UI 的开发2021/08JetBranins 将 Android/Desktop/Web 等各端的 Compose 版本统一,发布 Compose Multiplatform (CMP),使用同一套 ArtifactId 就可以开发跨端的 UI 。虽然 CMP 尚处于 alpha 阶段,由于它 fork 了 Jetpack 稳定版的分支进行开发,API 已经稳定,乐观预计年内就会发布 1.0 版 。jetbrains compose:https://github.com/JetBrains/androidx/tree/jb-main/compose接下来通过一个例子感受一下如何使用 CMP 开发一个跨端应用:Sample:跨端联机五子棋地址:https://github.com/vitaviva/cmp-gobang设计目标:通过 CMP 实现 APP 同时运行在移动端和桌面端,代码尽量复用通过 Kotlin Multiplatform 实现逻辑层和数据层的代码基于单向数据流实现 UI层/逻辑层/数据层的关注点分离2. 新建工程IDE:IntelliJ IDEA or Android StudioCMP 可以像普通 KMP 项目一样使用 IntelliJ IDEA 开发( >= 2020.3),当然 Anroid Studio 作为 IDEA 的另一个发行版也可以使用的Anroid Studio 和 IDEA 的对应关系: https://juejin.cn/post/7018370795759992839AS 编辑器对 Compose 的支持更友好,比如在非 @Composable 函数中调用 @Composable 函数时 IDE 自动标红提示错误, IDEA 则只能在编译时才能发现错误。所以个人更推荐使用 AS 开发。IDE Plugin 实现预览AS 自带对 @Preview 注解进行预览, IDEA 也可以通过安装插件实现预览插件安装后,IDE中遇到 @Preview 注解时左侧会出现 Compose logo 的小图标,点击后右侧窗口可以像 AS 一样进行预览。需要注意的是,此插件只能针对 desktop 工程进行预览 ,对 android 工程无效。反之使用 AS 自带的预览也无法预览 desktop 。所以在 AS 中开发,想要预览 desktop 效果仍然要安装此插件。接下来让我们看一下 CMP 工程的文件结构是怎样的4. 工程结构如上,整个工程第一级目录由三个 module 构成,/android, /common, /desktop:目录说明/android一个 android 工程,用来打包成 android 应用/desktop一个 jvm 工程,用来打包成 desktop 应用/common这是一个 KMP 工程,内部通过 sourceSet 划分 /androidMain,/desktopMain,/commonMain 等多个目录, /commonMain 中存放可共享的 Kotlin 代码当未来添加 web 端工程时,目录也照例添加。虽然第一级的 /android 目录中可以存放差异性代码,但这毕竟不是一个 KMP 工程,无法识别 actual 等关键字,因此需要在 /common 中在开辟 /androidMain 这样差异性目录,既可以依赖 Android 类库,又可以被 commonMain 通过 expect 关键字调用/common重点看一下 common/build.gradle.kts ,通过 sourceSet 为 xxxMain 目录分别指定不同依赖,保证平台差异性:plugins { kotlin("multiplatform") // KMP插件 id("org.jetbrains.compose") // Compose 插件 id("com.android.library") // android 插件 kotlin { android() jvm("desktop") { compilations.all { kotlinOptions.jvmTarget = "11" sourceSets { //配置 commonMain 和各平台的 xxMain 的依赖 val commonMain by getting { dependencies { api(compose.runtime) api(compose.foundation) api(compose.material) api(compose.ui) val androidMain by getting { dependencies { api("androidx.appcompat:appcompat:1.3.0") api("androidx.core:core-ktx:1.3.1") api("androidx.compose.ui:ui-tooling:1.0.4") val desktopMain by getting }Compose 的 gradle插件版本依赖 classpath 指定buildscript { dependencies { classpath("org.jetbrains.compose:compose-gradle-plugin:1.0.0-alpha2") }如果 gradle 工程使用 .kts,也可省略 classpath ,直接在声明插件时指定 <br/>id("org.jetbrains.compose") version "1.0.0-alpha2" 得益于 CMP 对 ArtifactId 的统一, commonMain 可以通过 api() 完成所有 compose 公共库的依赖, androidMain 和 destopMain 通过 commonMain 传递依赖 compose。CMP 的 Gradle 依赖相对于 Jetpack, GroupID 发生变化:jetpackjetbrainsandroidx.compose.runtime:runtimeorg.jetbrains.compose.runtime:runtimeandroidx.compose.ui:uiorg.jetbrains.compose.ui:uiandroidx.compose.material:materialorg.jetbrains.compose.material:materialandroidx.compose.fundation:fundationorg.jetbrains.compose.fundation:fundation/android/android 目录就是一个标准 Android 工程,这里就不赘述了/desktop最后看一下 /desktop/.build.gradle.ktsplugins { kotlin("multiplatform") //KMP插件 id("org.jetbrains.compose") // CMP插件 kotlin { jvm { //Kotlin/jVM compilations.all { kotlinOptions.jvmTarget = "11" sourceSets { val jvmMain by getting { dependencies { implementation(project(":common")) //依赖common下的desktopMain implementation(compose.desktop.currentOs)// compose.desktop 依赖 compose.desktop { application { mainClass = "MainKt" // 应用入口 nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "jvm" packageVersion = "1.0.0" }jvmMain{...} :作为一个 jvm 工程,依赖 :common 以及 compose.desktop.{$currentOs)compose.desktop {...} :配置入口等桌面端应用信息/desktop 针对不同桌面系统提供了差异性依赖,可复用代码在公共库 desktop:desktop 中。object DesktopDependencies { val common = composeDependency("org.jetbrains.compose.desktop:desktop") val linux_x64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-linux-x64") val linux_arm64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-linux-arm64") val windows_x64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-windows-x64") val macos_x64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-macos-x64") val macos_arm64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-macos-arm64") val currentOs by lazy { composeDependency("org.jetbrains.compose.desktop:desktop-jvm-${currentTarget.id}") }值得注意的是,/desktop 作为一个 kotlin/jvm 项目,却可以支持 MacOS、Windows、Linux 等多个桌面端的应用开发。为了降低多个桌面平台的适配成本,CMP 借助 KMP 的 Skiko 库实现了渲染的统一,Skiko 顾名思义是一个经过 Kotlin 封装的 Skia 库,其内部通过不同的动态链接库调用各平台的渲染能力,向上提供统一的 Kotlin API,Skiko 为 kotlin/jvm 项目提供了跨平台渲染能力。5. 工程代码接下来具体看一下工程的业务代码,从上到下逐层介绍UI:Compose Graphic五子棋小游戏的 UI 部分比较简单,大部分依靠 Compose 的 Canvas API 完成Box(modifier) { with(LocalDensity.current) { val (linePaint, framePaint) = remember { Paint().apply { color = Color.Black isAntiAlias = true strokeWidth = BOARD_LINE_WIDTH_DP.dp.toPx() } to Paint().apply { color = Color.Black isAntiAlias = true strokeWidth = BOARD_FRAME_WIDTH_DP.dp.toPx() Canvas(modifier = Modifier.fillMaxSize().pointerInput(Unit) { scope.launch { detectTapGestures { viewModel.placeStone(convertPoint(it.x, it.y)) drawLines(linePaint, framePaint) drawBlackPoints(BOARD_POINT_RADIUS_DP.dp.toPx()) drawStones(boardData) }drawLines, drawBlackPoints, drawStones 分别用来绘制围棋棋盘的网格线,交叉点,以及棋子,绘制棋子的 borderData 作为全局 State 存储在 ViewModel 中,后文介绍。游戏的交互非常简单:点击、落子。 通过pointerInput 的 Modifer 实现 Compose 手势点击即可,这个事件同样可以响应 desktop 侧的鼠标单击事件。compose.desktop 针对鼠标和键盘等输入设备提供了更多专用的API, 比如接收鼠标右击事件等,如果有这方面需求,可以在 desktopMain 中实现:参考 https://github.com/JetBrains/compose-jb/tree/master/tutorials/Mouse_Eventsprivate fun DrawScope.drawLines(linePaint: Paint, framePaint: Paint) { drawIntoCanvas { canvas -> fun drawLines(linePoints: FloatArray, paint: Paint) { for (i in linePoints.indices step 4) { canvas.drawLine( Offset( linePoints[i], linePoints[i + 1] Offset( linePoints[i + 2], linePoints[i + 3] paint canvas.withSave { with(BoardDrawInfo) { drawLines(HorizontalLinePoints, linePaint) drawLines(VerticalLinePoints, linePaint) drawLines(BoardFramePoints, framePaint) }以 drawLines 为例,通过 drawIntoCanvas 获取绘制网格线所需的 Canvas 和 Paint 对象,这些都是平台无关的抽象接口,所以基于 Canvas 的绘制代码可以跨端复用。需要注意 CMP 无法通过 native.canvas 获取 Android 的 Canvas 对象,而 Compose Canvas 没有提供 drawText 的方法,所以暂时没有找到绘制文字的方法差异性处理涉及到平台相关的代码,需要利用 KMP 的 actual/expect 进行差异化处理。以绘制围棋棋子为例,涉及到资源文件的读取和 Bitmap 的创建,各平台处理方式不同,需要各自实现。Compose 提供了统一的的 ImageBitmap 类型,我们在 /commonMain 中定义 ImageBimmap 类型的棋子图片commonMain/platform/Bitmap.ktexpect val BlackStoneBmp : ImageBitmap expect val WhiteStoneBmp : ImageBitmapandroid 侧的图片资源存放在 /res 目录,通过 resource id 获取:androidMain/platform/Bitmap.ktactual val BlackStoneBmp: ImageBitmap by lazy { ImageBitmap.imageResource(resources, blackStoneResId) actual val WhiteStoneBmp: ImageBitmap by lazy { ImageBitmap.imageResource(resources, whiteStoneResId) }resources 和 resId 由 android 的 application 在 onCreate 时注入desktopMain/platform/Bitmap.ktdesktop 侧将图片资源放在 /resources 目录中,通过 compose.desktop 的 useResouce 获取actual val BlackStoneBmp: ImageBitmap by lazy { useResource("stone_black.png", ::loadImageBitmap) actual val WhiteStoneBmp: ImageBitmap by lazy { useResource("stone_white.png", ::loadImageBitmap) }注意 actual 和 expect 的代码文件路径需要保持一致之后,我们就可以在 commonMain 的代码中通过 ImageBitmap 进行绘制了。 此外,像 dialog 的处理在各端也有所不同(andorid 和 desktop 各有各的 window 系统),也需要进行差异化处理。Logic:自定义ViewModelCMP 无法使用 Jetpack 的 ViewModel、LiveData 等组件,只能手动实现,或者使用 KMP 的一些三方库,例如 Decompose 、MVIKotlin 等。 下游戏的逻辑比较简单,我们自己实现一个 ViewModel,管理 UI 所需的状态 以 boardData 的处理为例, boardData 记录了整个棋牌棋子的状态class AppViewModel { private val _board = MutableStateFlow(Array(BOARD_SIZE) { IntArray(BOARD_SIZE) }) val boardData: Flow<BoardData> get() = _board * place stone fun placeStone(offset: IntOffset) { val cur = _board.value if (cur[offset.x][offset.y] != STONE_NONE) { return _board.value = cur.copyOf().also { it[offset.x][offset.y] = if (isWhite) STONE_WHITE else STONE_BLACK * clear stones fun clearStones() { _board.value = _board.value.copyOf().also { for (row in 0 until LINE_COUNT) { for (col in 0 until LINE_COUNT) { it[col][row] = STONE_NONE typealias BoardData = Array<IntArray> 通过 Array<IntArray> 二维数组定义棋盘坐标信息。Int型 表示某坐标的三种状态:黑子,白子,无子。 UI 接收到用户输入后,通过 placeStone 等方法更新 boardData 从而驱动 Compose 刷新。如果想像 Jetpack ViewModel 那样对 State 进行持久化,可以使用 rememberSaveable {} ,Savable 在 CMP 也是可以使用的。数据通信层:Rsocket可联机对弈是这个游戏的特色。网络通信的方案有多种选择,比如蓝牙、Wifi直连等,但是越依靠低层设备就越容易出现差异化代码,所以这里选择了应用层协议 WebSocket 进行通信。 RSocket 是一种响应式的通讯协议,其 KMP 的实现 rocket-kotlin 在 Ktor 的基础上提供了 Rxjava, Flow 等响应式接口,与我们的单向数据流架构非常契合。在游戏整体设计上,桌面端和移动端采取点对点通信。 RSocket 支持多种通信方式,其中 request/channel 可以提供全双工通信,非常适合 IM、 网络游戏之类的场景,可以用来完成我们点对点通信的需求。我们在 commonMain 定义 API 层实现 P2P 的通信, P2P 的双端没有主次之分object Api { suspend fun connect() = initWsConnect() //接收消息 fun receiveMessage(): Flow<Message> = receiveFromRemote().map { when (it.metadata!!.readText()) { TypePlaceStone -> { val (x, y) = it.data.readText().split(",") Message.PlaceStone(IntOffset(x.toInt(), y.toInt())) TypeChooseColor -> Message.ChooseColor(it.data.readText().toBoolean()) TypeGameQuit -> Message.GameQuit TypeGameReset -> Message.GameReset TypeGameLog -> Message.GameLog(it.data.readText()) else -> error("Unknown message !") //发送消息 suspend fun sendMessage(message: Message) = sendToRemote(buildPayload { metadata(message.type) data("$message") }P2P的双端互发消息,角色平等,因此 API 层代码也实现了复用。 回到前面 ViewModel 中,在摆放棋子后,通过 API 顺便给对端发送一个同步消息,完成通信。 fun placeStone(offset: IntOffset) { //... coroutineScope.launch { Api.sendMessage(Message.PlaceStone(offset)) //发送消息给对端 }差异化处理虽然 API 基于点对点抽象了接口,但是 WebSocket 的实现仍然需要有 Server 和 Client 之分,即便他们是全双工通信。 这又涉及到差异化处理,我们以 desktop 为 server , android 为 client 建立通信 (反之亦可)commonMain/Socket.ktexpect suspend fun initWsConnect() // 建立 WebSocket 连接 expect fun receiveFromRemote(): Flow<Payload> //通过 Flow 获取对方消息 expect suspend fun sendToRemote(payload: Payload)// 相对端发送消息destkopMain/Socket.kt :private lateinit var _requestFlow: Flow<Payload> private lateinit var _responseFlow: MutableSharedFlow<Payload> actual suspend fun initWsConnect() { startServer().let { _requestFlow = it.first _responseFlow = it.second actual fun receiveFromRemote(): Flow<Payload> = _requestFlow.onStart { emit(buildPayload { metadata(Message.TypeGameLog) data("waiting pair ...") actual suspend fun sendToRemote(payload: Payload) = _responseFlow.emit(payload)desktopMain 侧在 initWsConnect 中启动 WebSocket Server,等待来自客户端的连接后,返回 request/response 的 Flow,用来收发消息。 startServer() 内部使用 RSocket 建立 Server,不是本文重点,介绍略过。androiMain/platform/Socket.kt//connect to some url private lateinit var rSocket: RSocket private lateinit var _requestFlow: MutableSharedFlow<Payload> private lateinit var _responseFlow: Flow<Payload> actual suspend fun initWsConnect() { rSocket = client.rSocket(host = serverHost, port = 9000, path = "/rsocket") if (!::_requestFlow.isInitialized) { _requestFlow = MutableSharedFlow() _responseFlow = rSocket.requestChannel(buildPayload { data("Init") }, _requestFlow) } else { throw RuntimeException("duplicated init") actual fun receiveFromRemote(): Flow<Payload> = _responseFlow actual suspend fun sendToRemote(payload: Payload) = _requestFlow.emit(payload)client 是 RSocket 创建的 WebSocket 客户端,通过 requetChannel 与服务端建立全双工通信。同样返回 request/response 的两个 Flow 用于收发对端的消息。6. 总结与思考通过上面例子,大家初步了解了 CMP 的工程结构以及如何在 CMP 中完成差异化开发,KMP 提供了很多诸如 rsocket-kotlin 这样的三方库来满足我们的常见的开发需求。除了 desktop, CMP 也支持 Web 端开发,在 DSL 上稍有差别,后续有机会单独介绍。 最后讨论几个大家关心的问题:桌面端应用还有市场吗?ToC 的市场已近饱和、 ToB 成为新风口的今天,PC 的使用场景会触底反弹,未来的产品会更加重视移动端和桌面端的打通,越来越多像 JetBrains 这样的小而美的公司愿意聚焦到桌面端的新技术上。虽然桌面端已经有了 Electron 这样优秀的解决方案,但是 JS 的性能距离 JVM 仍有不小差距,像飞书这样日渐成熟的产品,其开发原则也已经由早期的效率第一转为体验第一、为了性能开始向 native 切换。如果 Kotlin 能像 JS 一样低成本开发跨端应用,那为什么不选择呢?与 Flutter 如何取舍?Compose Multiplatform 是 JetBrains Compose 而非 Jetpack Compose,Flutter 仍然是目前 Google 唯一的跨平台解决方案,更侧重移动端生态;CMP 则是以扩大 Kotlin 的使用场景为出发点,他的“格局"更大,不追求 DSL 的完全一致,更强调开发范式的统一,结合平台特性、打造包括桌面端在内的 UI 通用解决方案。Data source: Google Trends (https://www.google.com/trends)近年来 Kotlin 的热度不断增高,与 “因为要用 Flutter 所以学习 Dart" 不同," 因为掌握 Kotlin,所以用 CMP " 的的选型逻辑更加合理。Google 对 CMP 的态度也是乐见其成的,借助 Compose 能够拓宽 Android 开发者的能力边际,将有助于吸引更多的开发者加入 Android 阵营。凭借先发优势 Flutter 仍然是当前移动端跨平台方案的首选,但是 CMP 更具想象空间,随着功能的进一步完善(Skiko 也已支持了 iOS 侧渲染)未来大有可期。 如果你是一个 Kotlin First 的程序员,那么感谢 CMP 让你已经具备了开发跨平台应用的能力。sample : https://github.com/vitaviva/cmp-gobangcmp:https://github.com/JetBrains/compose-jb欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章

Android数 据库框架该如何选?

大家在 Android 上做数据持久化经常会用到数据库。除了借助 SQLiteHelper 以外,业界也有不少成熟的三方库供大家使用。本文就这些三方库做一个横向对比,供大家在技术选型时做个参考。RoomRelamGreenDAOObjectBoxSQLDelight以 Article 类型的数据存储为例,我们如下设计数据库表:Field NameTypeLengthPrimaryDescriptionidLong20yes文章idauthorText10 作者titleText20 标题descText50 摘要urlText50 文章链接likesInt10 点赞数updateDateText20 更新日期1. RoomRoom 是 Android 官方推出的 ORM 框架,它提供了一个基于 SQLite 抽象层,屏蔽了 SQLite 的访问细节,更容易与官方推荐的 AAC 组件搭配实现单一事件来源(Single Source of Truth)。https://developer.android.com/training/data-storage/room工程依赖implementation "androidx.room:room-runtime:$latest_version" implementation "androidx.room:room-ktx:$latest_version" kapt "androidx.room:room-compiler:$latest_version" // 注解处理器Entity 定义数据库表结构Room 使用 data class 定义 Entity 代表 db 的表结构, @PrimaryKey 标识主键, @ColumnInfo 定义属性在 db 中的字段名@Entity data class Article( @PrimaryKey val id: Long, val author: String, val title: String, val desc: String, val url: String, val likes: Int, @ColumnInfo(name = "updateDate") @TypeConverters(DateTypeConverter::class) val date: Date, )Room 底层基于 SQLite 所以只能存储基本型数据,任何对象类型必须通过 TypeConvert 转化为基本型:class Converters { @TypeConverter fun fromString(value: String?): Date? { return format.parse(value) @TypeConverter fun dateToString(date: Date?): String? { return SimpleDateFormat("yyyy-MM-dd", Locale.US).format(date) }DAORoom 的最主要特点是基于注解生成 CURD 代码,减少手写代码的工作量。首先通过 @Dao 创建 DAO@Dao interface ArticleDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveArticls(vararg articles: Article) @Query("SELECT * FROM Article") fun getArticles(): Flow<List<Article>> }然后通过 @Insert, @Update, @Delete 等定义相关方法用来更新数据;定义 @Query 方法从数据库读取信息,SELECT 的 SQL 语句作为其注解的参数。@Query 方法支持 RxJava 或者 Coroutine Flow 类型的返回值,KAPT 会根据返回值类型生成相应代码。当 db 的数据更新造成 query 的 Observable 或者 Flow 结果发生变化时,订阅方会自动收到新的数据。注意:虽然 Room 也支持 LiveData 类型的返回值,LiveData 是一个 Androd 平台对象。一个比较理想的 MVVM 架构,其数据层最好是 Android 无关的,所以不推荐使用 LiveData 作为返回值类型AppDatabase 实例最后,通过创建个 Database 实例来获取 DAO@Database(entities = [Article::class], version = 1) // 定义当前db的版本以及数据库表(数组可定义多张表) @TypeConverters(value = [DateTypeConverter::class]) // 定义使用到的 type converters abstract class AppDatabase : RoomDatabase() { abstract fun articleDao(): ArticleDao companion object { @Volatile private var instance: AppDatabase? = null fun getInstance(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: buildDatabase(context).also { instance = it } private fun buildDatabase(context: Context): AppDatabase = Room.databaseBuilder(context, AppDatabase::class.java, "ArticleDb") .fallbackToDestructiveMigration() // 数据库升级策略 .build() }2. RealmRealm 是一个专门针对移动端设计的数据库,不同于 Room 等其他 ORM 框架,Realm 底层并不依赖 SQLite,有自己的一套基于零拷贝的存储引擎,在速度上明显优于其他 ORM 框架。https://docs.mongodb.com/realm/sdk/android/工程依赖//root build.gradle dependencies { classpath "io.realm:realm-gradle-plugin:$realmVersion" // module build.gradle apply plugin: 'com.android.application' apply plugin: 'realm-android'EntityRealm 要求 Entity 必须要有一个空构造函数,所以不能使用 data class 定义。 Entity 必须继承自 RealmObjectopen class RealmArticle : RealmObject() { @PrimaryKey val id: Long = 0L, val author: String = "", val title: String = "", val desc: String = "", val url: String = "", val likes: Int = 0, val updateDate: Date = Date(), }除了整形、字符串等基本型,Realm 也支持存储例如 Date 这类的常见的对象类型,Realm 内部会做兼容处理。你也可以在 Entity 中使用自定义类型,但需要保证这个类也是 RealmObject 的派生类。初始化要使用 Realm 需要传入 Application 进行初始化Realm.init(context)DAO定义 DAO 的关键是获取一个 Realm 实例,然后通过 executeTransactionAwait 开启事务,在内部完成 CURD 操作。class RealmDao() { private val realm: Realm = Realm.getDefaultInstance() suspend fun save(articles: List<Article>) { realm.executeTransactionAwait { r -> // open a realm transaction for (article in articles) { if (r.where(RealmArticle::class.java).equalTo("id", article.id).findFirst() != null) { continue val realmArticle = r.createObject(Article::class.java, article.id) // create object (table) // save data realmArticle.author = article.author realmArticle.desc = article.desc realmArticle.title = article.title realmArticle.url = article.url realmArticle.likes = article.likes realmArticle.updateDate = article.updateDate fun getArticles(): Flow<List<Article>> = callbackFlow { // wrap result in callback flow `` realm.executeTransactionAwait { r -> val articles = r.where(RealmArticle::class.java).findAll() articles.forEach { offer(it) awaitClose { println("End Realm") } }除了获取默认配置的 Realm ,还可以基于自定义配置获取实例val config = RealmConfiguration.Builder() .name("default-realm") .allowQueriesOnUiThread(true) .allowWritesOnUiThread(true) .compactOnLaunch() .inMemory() .build() // set this config as the default realm Realm.setDefaultConfiguration(config)3. GreenDAOgreenDao 是 Android 平台上的开源框架,跟 Room 一样也是一套基于 SQLite 的轻量级 ORM 解决方案。greenDAO 针对 Android 平台进行了优化,运行时的内存开销非常小。https://github.com/greenrobot/greenDAO工程依赖//root build.gradle buildscript { repositories { jcenter() mavenCentral() // add repository dependencies { classpath 'org.greenrobot:greendao-gradle-plugin:3.3.0' // greenDao 插件 //module build.gradle //添加 GreenDao插件 apply plugin: 'org.greenrobot.greendao' dependencies { //GreenDao依赖添加 implementation 'org.greenrobot:greendao:latest_version' greendao { // 数据库版本号 schemaVersion 1 // 生成数据库文件的目录 targetGenDir 'src/main/java' // 生成的数据库相关文件的包名 daoPackage 'com.sample.greendao.gen' EntitygreenDAO 的 Entity 定义和 Room 类似,@Property 用来定义属性在 db 中的名字@Entity data class Article( @Id(assignable = true) val id: Long, val author: String, val title: String, val desc: String, val url: String, val likes: Int, @Property(nameInDb = "updateDate") @Convert(converter = DateConvert::class.java, columnType = String.class) val date: Date, )greenDAO 只支持基本型数据,复杂类型通过 PropertyConverter 进行类型转换class DateConverter : PropertyConverter<Date, String>{ @Override fun convertToEntityProperty(value: Integer): Date { return format.parse(value) @Override fun convertToDatabaseValue(date: Date): String { return SimpleDateFormat("yyyy-MM-dd", Locale.US).format(date) }生成 DAO 相关文件定义 Entity 后,编译工程会在我们配置的 com.sample.greendao.ge 目录下生成 DAO 相关的三个文件:DaoMaster,DaoSessiion,ArticleDao ,DaoMaster: 管理数据库连接,内部持有着数据库对象 SQLiteDatabase,DaoSession:每个数据库连接可以开放多个 session,而 session 的开销很小,无需反复创建 connectionXXDao:通过 DaoSessioin 获取访问具体 XX 实体的 DAO初始化 DaoSession 的过程如下:fun initDao(){ val helper = DaoMaster.DevOpenHelper(this, "test") //创建的数据库名 val db = helper.writableDb daoSession = DaoMaster(db).newSession() // 创建 DaoMaster 和 DaoSession }数据读写 //插入一条数据,数据类型为 Article 实体类 fun insertArticle(article: Article){ daoSession.articleDao.insertOrReplace(article) //返回全部文章 fun getArticles(): List<Article> { return daoSession.articleDao.queryBuilder().list() //按名字查找一条数据,并返回List fun getArticle(name :String): List<Article> { return daoSession.articleDao.queryBuilder() .where(ArticleDao.Properties.Title.eq(name)) .list() 通过 daoSession 获取 ArticleDao,而后可以通过 QueryBuilder 添加条件进行调价查询。4.ObjectBoxObjectBox 是专为小型物联网和移动设备打造的 NoSQL 数据库,它是一个键值存储数据库,非列式存储,在非关系型数据的存储场景中性能上更具优势。ObjectBox 和 GreenDAO 使用一个团队。https://docs.objectbox.io/kotlin-support工程依赖//root build.gradle dependencies { classpath "io.objectbox:objectbox-gradle-plugin:$latest_version" // module build.gradle apply plugin: 'com.android.application' apply plugin: 'io.objectbox' dependencies { implementation "io.objectbox:objectbox-kotlin:$latest_version" }Entity@Entity data class Article( @Id(assignable = true) val id: Long, val author: String, val title: String, val desc: String, val url: String, val likes: Int, @NameInDb("updateDate") val date: Date, )ObjectBox 的 Entity 和自家的 greenDAO 很像,只是个别注解的名字不同,例如使用 @NameInDb 替代 @Property 等BoxStore需要为 ObjectBox 创建一个 BoxStore来管理数据object ObjectBox { lateinit var boxStore: BoxStore private set fun init(context: Context) { boxStore = MyObjectBox.builder() .androidContext(context.applicationContext) .build() }BoxStore 的创建需要使用 Application 实例ObjectBox.init(context)DAOObjectBox 为实体类提供 Box 对象, 通过 Box 对象实现数据读写class ObjectBoxDao() : DbRepository { // 基于 Article 创建 Box 实例 private val articlesBox: Box<Article> = ObjectBox.boxStore.boxFor(Article::class.java) override suspend fun save(articles: List<Article>) { articlesBox.put(articles) override fun getArticles(): Flow<List<Article>> = callbackFlow { // 将 query 结果转换为 Flow val subscription = articlesBox.query().build().subscribe() .observer { offer(it) } awaitClose { subscription.cancel() } }ObjectBox 的 query 可以返回 RxJava 的结果, 如果要使用 Flow 等其他形式,需要自己做一个转换。5. SQLDelightSQLDelight 是 Square 家的开源库,可以基于 SQL 语句生成类型安全的 Kotlin 以及其他平台语言的 API。https://cashapp.github.io/sqldelight/android_sqlite/工程依赖//root build.gradle dependencies { classpath "com.squareup.sqldelight:gradle-plugin:$latest_version" // module build.gradle apply plugin: 'com.android.application' apply plugin: 'com.squareup.sqldelight' dependencies { implementation "com.squareup.sqldelight:android-driver:$latest_version" implementation "com.squareup.sqldelight:coroutines-extensions-jvm:$delightVersion" }.sq 文件DqlDelight 的工程结构与其他框架有所不同,需要在 src/main/java 的同级创建 src/main/sqldelight 目录,并按照包名建立子目录,添加 .sq 文件# Article.sq import java.util.Date; CREATE TABLE Article( id INTEGER PRIMARY KEY, author TEXT, title TEXT, desc TEXT, url TEXT, likes INTEGER, updateDate TEXT as Date selectAll: #label: selectAll SELECT * FROM Article; insert: #label: insert INSERT OR IGNORE INTO Article(id, author, title, desc, url, likes, updateDate) VALUES ?; Article.sq 中对 SQL 语句添加 label 会生成对应的 .kt 文件 ArticleQueries.kt。 我们创建的 DAO 也是通过 ArticleQueries 完成 SQL 的 CURD## DAO首先需要创建一个 SqlDriver 用来进行 SQL 数据库的连接、事务等管理,Android平台需要传入 Context, 基于 SqlDriver 获取 ArticleQueries 实例 class SqlDelightDao() { // 创建SQL驱动 private val driver: SqlDriver = AndroidSqliteDriver(Database.Schema, context, "test.db") // 基于驱动创建db实例 private val database = Database(driver, Article.Adapter(DateAdapter())) // 获取 ArticleQueries 实例 private val queries = database.articleQueries override suspend fun save(artilces: List<Article>) { artilces.forEach { article -> queries.insert(article) // insert 是 Article.sq 中的定义的 label override fun getArticles(): Flow<List<Article>> = queries.selectAll() // selectAll 是 Article.sq 中的定义的 label .asFlow() // convert to Coroutines Flow .map { query -> query.executeAsList().map { article -> Article( id = article.id, author = article.author desc = article.desc title = article.title url = article.url likes = article.likes updateDate = article.updateDate }类似于 Room 的 TypeConverter,SQLDelight 提供了 ColumnAdapter 用来进行数据类型的转换:class DateAdapter : ColumnAdapter<Date, String> { companion object { private val format = SimpleDateFormat("yyyy-MM-dd", Locale.US) override fun decode(databaseValue: String): Date = format.parse(databaseValue) ?: Date() override fun encode(value: Date): String = format.format(value) }6. 总结前文走马观花地介绍了各种数据库的基本使用,更详细的内容还请移步官网。各框架在 Entity 定义以及 DAO 的生成上各具特色,但是设计目的殊途同归:减少对 SQL 的直接操作,更加类型安全的读写数据库。最后,通过一张表格总结一下各种框架的特点: 出身存储引擎RxJavaCoroutine附件文件数据类型RoomGoogle亲生SQLite支持支持编译期代码生成基本型 + TypeConverterRealm三方C++ Core支持部分支持无支持复杂类型GreenDAO三方SQLite不支持不支持编译期代码生成基本型+ PropertyConverterObjectBox三方Json支持不支持无支持复杂类型SQLDelight三方SQLite支持支持手写.sq基本型 + ColumnAdapter关于性能方面的比较可以参考下图,横坐标是读写的数据量,纵坐标是耗时:从实验结果可知 Room 和 GreenDAO 底层都是基于 SQLite,性能接近,在查询速度上 GreenDAO 表现更好一些; Realm 自有引擎的数据拷贝效率高,复杂对象也无需做映射,在性能表现上优势明显; ObjectBox 作为一个 KV 数据库,性能由于 SQL 也是预期中的。 图片缺少 SQLDelight 的曲线,实际性能与 GreeDAO 相近,在查询速度上优于 Room。空间性能方面可参考上图( 50K 条记录的内存占用情况)。 Realm 需要加载 so 同时为了提高性能缓存数据较多,运行时内存占用最大,SQLite 系的数据库依托平台服务,内存开销较小,其中 GreenDAO 在运行时内存的优化是最好的。 ObjectBox 介于 SQLite 与 Realm 之间。数据来源: https://proandroiddev.com/android-databases-performance-crud-a963dd7bb0eb选型建议上述个框架目前都在维护中,都存在不少用户,大家在选型上可以遵循以下原则:Room 虽然在性能上不具优势,但是作为 Google 的亲儿子,与 Jetpack 全家桶兼容最好,而且天然支持协程,如果你的项目只用在 Android 平台上且对性能不敏感,首推 Room ;如果你的项目是一个 KMM 或其他跨平台应用,那么建议选择 SQLDelight ;如果你对性能有比较高的需求,那么 Realm 无疑是更好的选择 ;如果对查询条件没有过多要求,那么可以考虑 KV 型数据库的 ObjectBox,如果只用在 Android 平台,那么前不久 stable 的 DataStore 也是不错的选择。

Jetpack Compose 实现波浪加载效果

受到 波浪动画很常见,但这个波浪组件绝对不常见 这篇文章的启发,我为 Compose 写了一个波浪效果的进度加载库,API 的设计上符合 Compose 的开发规范,使用非常简便。1. 使用方式在 root 的 build.gradle 中引入 jitpack,allprojects { repositories { maven { url 'https://jitpack.io' } }在 module 的 build.gradle 中引入 ComposeWaveLoading 的最新版本dependencies { implementation 'com.github.vitaviva:ComposeWaveLoading:$latest_version' }2. API 设计思想 Box { WaveLoading ( progress = 0.5f // 0f ~ 1f Image( painter = painterResource(id = R.drawable.logo_tiktok), contentDescription = "" 传统的 UI 开发方式中,设计这样一个波浪控件,一般会使用自定义 View 并将 Image 等作为属性传入。 而在 Compose 中,我们让 WaveLoading 和 Image 以组合的方式使用,这样的 API 更加灵活,WaveLoding 的内部可以是 Image,也可以是 Text 亦或是其他 Composable。波浪动画不拘泥于某一特定 Composable, 任何 Composable 都可以以波浪动画的形式展现, 通过 Composable 的组合使用,扩大了 “能力” 的覆盖范围。3. API 参数介绍@Composable fun WaveLoading( modifier: Modifier = Modifier, foreDrawType: DrawType = DrawType.DrawImage, backDrawType: DrawType = rememberDrawColor(color = Color.LightGray), @FloatRange(from = 0.0, to = 1.0) progress: Float = 0f, @FloatRange(from = 0.0, to = 1.0) amplitude: Float = defaultAmlitude, @FloatRange(from = 0.0, to = 1.0) velocity: Float = defaultVelocity, content: @Composable BoxScope.() -> Unit ) { ... }参数说明如下:参数说明progress加载进度foreDrawType波浪图的绘制类型: DrawColor 或者 DrawImagebackDrawType波浪图的背景绘制amplitude波浪的振幅, 0f ~ 1f 表示振幅在整个绘制区域的占比velocity波浪移动的速度content子Composalble接下来重点介绍一下 DrawType。DrawType波浪的进度体现在前景(foreDrawType)和后景(backDrawType)的视觉差,我们可以为前景后景分别指定不同的 DrawType 改变波浪的样式。sealed interface DrawType { object None : DrawType object DrawImage : DrawType data class DrawColor(val color: Color) : DrawType }如上,DrawType 有三种类型:None: 不进行绘制DrawColor:使用单一颜色绘制DrawImage:按照原样绘制以下面这个 Image 为例, 体会一下不同 DrawType 的组合效果indexbackDrawTypeforeDrawType说明1DrawImageDrawImage背景灰度,前景原图2DrawColor(Color.LightGray)DrawImage背景单色,前景原图3DrawColor(Color.LightGray)DrawColor(Color.Cyan)背景单色,前景单色4NoneDrawColor(Color.Cyan)无背景,前景单色如下图中,第二排是前景原图,第三排是前景单色下图展示无背景色的情况注意 backDrawType 设置为 DrawImage 时,会显示为灰度图。4. 原理浅析简单介绍一下实现原理。为了便于理解,代码经过简化处理,完整代码可以在 github 查看这个库的关键是可以将 WaveLoading {...} 内容取出,加以波浪动画的形式显示。所以需要将子 Composalbe 转成 Bitmap 进行后续处理。4.1 获取 Bitmap我在 Compose 中没找到获取位图的办法,所以用了一个 trick 的方式, 通过 Compose 与 Android 原生视图良好的互操作性,先将子 Composalbe 显示在 AndroidView 中,然后通过 native 的方式获取 Bitmap:@Composable fun WaveLoading (...) Box { var _bitmap by remember { mutableStateOf(Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565)) AndroidView( factory = { context -> // Creates custom view object : AbstractComposeView(context) { @Composable override fun Content() { Box(Modifier.wrapContentSize(){ content() override fun dispatchDraw(canvas: Canvas?) { val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas2 = Canvas(source) super.dispatchDraw(canvas2) _bitmap = bmp WaveLoadingInternal(bitmap = _bitmap) }AndroidView 是一个可以绘制 Composable 的原生控件,我们将 WaveLoading 的子 Composable 放在其 Content 中,然后在 dispatchDraw 中绘制时,将内容绘制到我们准备好的 Bitmap 中。4.2 绘制波浪线我们基于 Compose 的 Canvas 绘制波浪线,波浪线通过 Path 承载定义 WaveAnim 用来进行波浪线的绘制internal data class WaveAnim( val duration: Int, val offsetX: Float, val offsetY: Float, val scaleX: Float, val scaleY: Float, private val _path = Path() //绘制波浪线 internal fun buildWavePath( dp: Float, width: Float, height: Float, amplitude: Float, progress: Float ): Path { var wave = (scaleY * amplitude).roundToInt() //计算拉伸之后的波幅 _path.reset() _path.moveTo(0f, height) _path.lineTo(0f, height * (1 - progress)) // 通过正弦曲线绘制波浪 if (wave > 0) { var x = dp while (x < width) { _path.lineTo( height * (1 - progress) - wave / 2f * Math.sin(4.0 * Math.PI * x / width) .toFloat() x += dp _path.lineTo(width, height * (1 - progress)) _path.lineTo(width, height) _path.close() return _path }如上,波浪线 Path 通过正弦函数绘制。4.3 波浪填充有了 Path ,我们还需要填充内容。填充的内容前文已经介绍过,或者是 DrawColor 或者 DrawImage。 绘制 Path 需要定义 Paint val forePaint = remember(foreDrawType, bitmap) { Paint().apply { shader = BitmapShader( when (foreDrawType) { is DrawType.DrawColor -> bitmap.toColor(foreDrawType.color) is DrawType.DrawImage -> bitmap else -> alphaBitmap Shader.TileMode.CLAMP, Shader.TileMode.CLAMP } Paint 使用 Shader 着色器绘制 Bitmap, 当 DrawType 只绘制单色时, 对位图做单值处理:/** * 位图单色化 fun Bitmap.toColor(color: androidx.compose.ui.graphics.Color): Bitmap { val bmp = Bitmap.createBitmap( width, height, Bitmap.Config.ARGB_8888 val oldPx = IntArray(width * height) //用来存储原图每个像素点的颜色信息 getPixels(oldPx, 0, width, 0, 0, width, height) //获取原图中的像素信息 val newPx = oldPx.map { color.copy(Color.alpha(it) / 255f).toArgb() }.toTypedArray().toIntArray() bmp.setPixels(newPx, 0, width, 0, 0, width, height) //将处理后的像素信息赋给新图 return bmp }4.4 波浪动画最后通过 Compose 动画让波浪动起来 val transition = rememberInfiniteTransition() val waves = remember(Unit) { listOf( WaveAnim(waveDuration, 0f, 0f, scaleX, scaleY), WaveAnim((waveDuration * 0.75f).roundToInt(), 0f, 0f, scaleX, scaleY), WaveAnim((waveDuration * 0.5f).roundToInt(), 0f, 0f, scaleX, scaleY) val animates : List<State<Float>> = waves.map { transition.animateOf(duration = it.duration) } 为了让波浪更有层次感,我们定义三个 WaveAnim 以 Set 的形式做动画最后,配合 WaveAnim 将波浪的 Path 绘制到 Canvas 即可 Canvas{ drawIntoCanvas { canvas -> //绘制后景 canvas.drawRect(0f, 0f, size.width, size.height, backPaint) //绘制前景 waves.forEachIndexed { index, wave -> canvas.withSave { val maxWidth = 2 * scaleX * size.width / velocity.coerceAtLeast(0.1f) val maxHeight = scaleY * size.height canvas.drawPath ( wave.buildWavePath( width = maxWidth, height = maxHeight, amplitude = size.height * amplitude, progress = progress ), forePaint }源码:https://github.com/vitaviva/ComposeWaveLoading

【Android开发小技巧】扔掉这坑人的 Handler

1. 坑人的 Handler大家都知道 Handler 特别坑,使用不当会造成各种问题:Activity 中使用 Handler 有可能会造成 Context 内存泄漏;Handler() 默认构造函数会因为缺少 Looper 而崩溃(虽然已标位 deprecated ) ;View.post/postDelayed 基于 Handler 实现,在 View 已经 detached 时可能仍在执行,造成异常诸如上述这些问题让开发者们防不胜防,但是 Handler 便利性又让开发者们难以割舍。大家希望寻找一种同样方便但更安全的替代方案。如今借助 Kotlin Coroutine + Lifecycle 我们可以做到这一切,思路很简单:利用协程执行异步任务,同时绑定 Lifecycle ,在必要的时候终止任务2. 替代 Handler.post/postDelayed项目中添加 coroutie 和 lifecycle 依赖:implementation "andoridx.lifecycle:lifecycle-runtime-ktx:2.3.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"代码如下fun LifecycleOwner.postDelayedOnLifecycle( duration: Long, dispatcher: CoroutineDispatcher = Dispatchers.Main, block: () -> Unit ): Job = lifecycleScope.launch(dispatcher) { delay(duration) block() }因为 Handler.post 运行在 UI 线程, 所以 Dispatcher 默认使用 Dispatchers.Main,postDelayed 的延时使用 delay 实现。使用效果如下,在 Activity 或 Fragment 中无需再依赖 Handler 了class MainActivity : AppCompatActivity() { postDelayedOnLifecycle(500L) { //Do something }3. 替代 View.post/postDelayed我们还可以借助 lifecycle-ktx 提供的 View 的扩展方法 findViewTreeLifecycleOwner(),替代 View.post / View.postDelayed , findViewTreeLifecycleOwner 可以从当前 View 最近的 Fragment 或者 Activity 获取 LifecycleOwner。代码如下:fun View.postDelayedOnLifecycle( duration: Long, dispatcher: CoroutineDispatcher = Dispatchers.Main, block:() -> Unit ) : Job? = findViewTreeLifecycleOwner()?.let { lifecycleOwner -> lifecycleOwner.lifecycleScope.launch(dispatcher) { delay(duration) block() }所以, Handler 是不是可以彻底 Deprecated 了 ?

使用PlantUML画UML(下) 时序图

UML 序列图序列图是仅次于类图的最常用 UML 图。 序列图将交互关系表示为一个二维图,纵向是时间轴,时间沿竖线向下延伸;横向轴代表了在协作中各个角色,一般是一个 Class 的对象,用一条虚线代表各角色的生命线,生命线上用矩形竖条表示是否处于活跃状态。对象之间可以发送同步或异步消息。相对于类图,序列图可能更能体现 PlantUML 的价值同步消息@startuml Alice -> Bob: Hi Bob --> Alice: Hi Alice -> Bob: Is this a pen? Bob --> Alice: No! This is an apple!! @enduml序列图基本构成: <角色> <消息类型> <角色> : <消息内容>消息类型中 -> 表示同步消息--> 虚线表示返回消息异步消息@startuml Alice ->> Bob: Hi Alice ->> Bob: Is this a pen? Alice ->> Bob: Is this a pen?? Alice ->> Bob: Is this a pen??? Alice ->> Bob: Is this a pen???? Bob -> Alice: This is an apple!!! @enduml--> 代表异步消息角色生命线@startuml participant Alice participant Bob participant Carol Carol -> Bob: Who is Alice? Bob -> Alice: Are you Alice? @enduml多个participant 会按照从左往右的顺序显示各角色生命线如果没有任何 participant, 则会角色出现的顺序显示从左往右显示其生命线角色图例@startuml actor Actor boundary Boundary control Control entity Entity database Database collections Collections @enduml除了 participant 之外, 使用其他关键字可以表示特殊的角色类型发给自己的消息@startuml Aclie -> Aclie: do something by yourself Aclie -> Aclie: do something by yourself Aclie -> Aclie: do something by yourself Aclie -> Aclie: do something by yourself @enduml消息序号@startuml Alice -> Bob: Hi autonumber Bob -> Carol: Hi Carol -> Dave: Hi Bob -> Dave: Hi @enduml有时候需要为消息添加序号以表示顺序,可以在第一个消息前添加 autonumber,后续消息自动添加序号。起始序号与增量@startuml autonumber 3 Alice -> Bob: Hi Bob -> Carol: Hi autonumber 2 3 Carol -> Dave: Hi Bob -> Dave: Hi @endumlautonumber <开始序号> <增量> 用来指定其实序号和序号递增的增量消息序号暂停@startuml autonumber Alice -> Bob: Hi autonumber stop Bob -> Carol: Hi Carol -> Dave: Hi autonumber resume Bob -> Dave: Hi Carol -> Dave: Hi @endumlautonumber stop: 自动序号暂停autonumber resume: 自动序号继续消息组@startuml Alice -> Bob: Is this a pen? alt yes Alice <-- Bob: Yes! This is a pen!! else no Alice <-- Bob: No! This is an apple!!!!! @enduml有时候需要多个消息表示一组相关的逻辑,此时可以使用预置的关键字来表示各种逻辑,例如alt/elseoptloopparbreakcritical关键词之后添加表示逻辑的文字,例如 yes, no等消息信息的缩进不是必须的,但是加上可读性更好消息组嵌套消息组内可以嵌套其他消息组,如下:@startuml Alice -> Bob: Is this a pen? alt yes Alice <-- Bob: Yes! This is a pen!! else no Alice <-- Bob: Noooooooo! This is an apple!!!!! loop ∞ Alice -> Bob: Oh sorry! By the way, is this a pen? Alice <-- Bob: No!!!! @enduml自定义消息组除了使用预置关键字的消息组,还可以使用任意名字自定义一个消息组@startuml group copy Alice -> Bob: Is this a pen? Alice <-- Bob: No! This is an apple!! @endumlgroup 之后添加消息组的名字生命线活跃状态@startuml activate Alice Alice -> Bob activate Bob Bob -> Carol activate Carol Bob <-- Carol deactivate Carol Alice <-- Bob deactivate Bob @endumlactivate <name> 指定name的生命线进入活跃状态deactive <name> 指定name的生命线退出活跃状态嵌套活跃状态@startuml activate Alice Alice -> Bob activate Bob Bob -> Bob activate Bob Bob -> Carol activate Carol Bob <-- Carol deactivate Carol Alice <-- Bob deactivate Bob @endumlactivate 中继续 activate 可以嵌套活跃状态创建角色和生命线@startuml Alice -> Bob create Carol Bob -> Carol: new Bob -> Carol Bob <-- Carol Alice <-- Bob @endumlcreate <name> 用来创建一个角色和其生命线,此时消息箭头会执行角色图例参考、引用@startuml Alice -> Bob ref over Bob, Carol: ... Alice <-- Bob ref over Alice end ref @enduml可以在时序图中添加参考信息ref over <生命线名称> : <内容> : reference 的范围和参考内容ref over ... end ref: 可以换行写参考内容边界线@startuml == Foo == Alice -> Bob Alice <-- Bob == Bar == Bob -> Carol Bob <-- Carol @enduml== <name> == 添加边界线,跨越所有角色的生命线外部消息@startuml [-> Alice: Hello Alice ->]: Hello @enduml消息箭头的前后使用 [ , ] ,表示一个来自外部或者指向外部的消息消息间隔@startuml Alice -> Bob Alice <-- Bob Alice -> Bob Alice <-- Bob Alice -> Bob Alice <-- Bob ||80|| Alice -> Bob Alice <-- Bob @enduml消息之间加 ||| , 会适当拉开消息间隔||<pixel>||:pixel可以指定具体间隔的像素数备注@startuml Alice -> Bob note left: Hello Alice <-- Bob note right: World Alice -> Alice note left Hello World end note @enduml消息后紧跟 note left 或者 note right 表示在相应位置添加备注,注意note 不能指定 top 或 bottomnote <left|right> ... end note 可以换行写备注备注内容支持 Creole 格式,Creole 的语法类似 MarkdownCreole 语法示例@startuml note left --标题-- = 标题1 == 标题2 === 标题3 --列表-- * 列表1 * 列表2 ** 列表2-1 # 有序列表1 # 有序列表2 ## 有序列表2-1 --字体-- * **粗体** * //斜体// * ""等宽字体(monospace)"" * --删除线-- * __下划线__ --表格-- |= |= Column1 |= Column2 | |1 |Value1-1 |Value1-2 | |2 |Value2-1 |Value2-2 | --HTML-- * <color:red>设置颜色</color> * <color:#00FF00>色号</color> * <back:skyblue>背景色</back> * <size:18>字号</size> * <b>粗体</b> --目录-- |_build.gradle |_src |_main |_java |_... |_... |_test end note @enduml姊妹篇: 使用PlantUML画UML (上) 类图

使用 PlantUML 画 UML(上)类图

大家平日在写技术文档时,往往都有画 UML 图的需要,很多人使用 PrecessOn 或者 darw.io 等来绘制 UML ,勉强可用但是不够专业。这里为大家推荐一个专门画UML的工具: PlantUML1. PlantUMLPlantUML 诞生于 2009 年,知道的人多但是使用的人少。因为它使用特殊的 DSL 进行画图,相较与其他工具,PlantUML 的图不是“画”出来的而是“写”出来的。虽然有一定学习成本,但是却可以画出更专业的UML图,而且文本格式也便于保存。本文总结 PlantUML 的基本用法,帮助大家快速入门。安装环境PlantUML 是一个 java 程序,所以有 JDK 就能跑。可以从官网直接下载 jar 文件执行,当然它也提供了 IDEA 和 VSCode 的插件。需要注意的是,PlantUML 的本地渲染依赖 Graphviz ,需要提前安装并配置环境变量。如果你使用 VSCode 插件,也可以借助云端渲染,只需要作如下配置:之后就可以在 VSCode 中一边“写” UML 一边预览了。接下来我们学习如何写出漂亮的 UML ,像学习其他语言一样 从 Hello World 开始。2. Hello World//hello.pu @startuml Hello <|-- World @endumlPlantUML 文件通常以 .pu 为后缀,命令行指定 pu,java -jar plantuml.jar hello.pu在当前目录下会将 @startuml 与 @enduml 之间的部分生成生成同名的 png除了.pu以外,各种源码文件中的 @startuml 与 @enduml 也能识别并生成 png,例如 .c,.c++, .htm, .java 等等,因此我们可以将源码配套的 UML 写入到注释中:/** * @startuml * class JavaSource { * - String message * @enduml public class JavaSource {}需要注意,一组@startuml/@enduml 对应一张 png,如果一个文件中有多组,则生成的 png 文件名会添加自增数字后缀。 此外也可以紧跟在 @startuml 之后指定文件名@startuml foo class Foo @enduml @startuml bar class Bar @enduml @startuml baz class Baz @enduml3. 基本语法注释单引号后面跟随的内容是注释@startuml no-scale ' 这是注释 Hello <|-- World @endumlTitletitle后跟标题@startuml title Hello Title Hello <|-- World @enduml多行 titletitle 与 end title 之间的输入可以换行@startuml title Hello Title end title Hello <|-- World @enduml标题样式PlantUML 支持使用 Creole 这种标记语言,来丰富文字样式,Creole 的用法类似 markdown。参考 :https://en.wikipedia.org/wiki/Creole_%28markup%29@startuml title * __Hello__ * **World** end title Hello <|-- World @enduml图注caption之后跟的内容显示为图注@startuml caption 图1 Hello <|-- World @endumlheader/footer@startuml header Hello Hello <|-- World footer World @endumlheader footer可以在头部和尾部追加注释。 默认 header 右对齐, footer 居中对齐。对齐方式@startuml left header Hello Hello <|-- World right footer World @enduml在 header footer 前加 left center right 可以设置对齐方式多行 header/footer跟 title 一样, header ... end header , footer ... end footer@startuml header Hello Header end header Hello <|-- World footer World Footer end footer @enduml放大率@startuml no-scale Hello <|-- World @enduml @startuml scale-1.5 scale 1.5 Hello <|-- World @enduml @startuml scale-0.5 scale 0.5 Hello <|-- World @endumlscale 可以为UML设置防大率4. 类图Class@startuml class Hello class World @endumlclass 指定类Interface@startuml interface Hello interface World @endumlinterface 指定接口抽象类@startuml abstract class Hello @endumlabstract class 指定抽象类枚举@startuml enum HelloWorld { THREE @endumlenum 指定枚举, { ... } 定义枚举值类型关系UML中类型之间有六大关系:泛化(Generalization)实现(Realization)关联(Association)聚合(Aggregation)组合(Composition)依赖(Dependency)接下来逐一说明:泛化泛化关系就是类的继承,java 中对应 extends 关键字。@startuml Child --|> Parent Parent2 <|-- Child2 @enduml<|-- --|> 指定继承关系实现实现关系,对应 implements 关键字@startuml Plane ..|> Flyable Flyable <|.. Plane @enduml..|>, <|.. , 圆点表示虚线依赖依赖表示使用关系,java中, 被依赖的对象/类, 以方法参数, 局部变量和静态方法调用的形式出现。比如, 厨师在烹饪的时候看了一眼菜谱, 厨师"使用"了菜谱, 照着它炒完菜后,这种使用关系就结束了(临时性).@startuml Chef ..> Recipe @enduml关联关联关系,表示"拥有"。 相比依赖关系的临时性和单向性,关联关系具有长期性、平等性(可双向),所以关联表示的关系比依赖更强。比如现实生活中的夫妻, 师生等关系。长期存在并且是相互的关系。 此外关联可以表示一对一,一对多,多对一,多对多等各种关系。@startuml Address <-- Husband Husband <--> Wife Husband2 -- Wife2 @enduml因为比依赖关系更强, 所以是实线+箭头。 双向关联可以省略箭头。后面两种关系 "聚合" 和 "组合",都属于关联关系, 用来表示关联关系中整体与部分的关系。java 中 一个 Class 与其成员变量 Class 类型之间就是这种整体与部分的关联关系。聚合聚合关系相对于组合弱一些,整体与部分是可分离的。 比如部门与员工,部门有许多员工,员工离职了部门仍然存在,不受影响。反之部门解散了,员工可以去其他部门(整体与部分可分离)@startuml Department o-- Employee @endumlo 表示空心菱形组合组合关系中,整体与部分是不可分离的,整体与部分的生命周期保持一致,少了对方自己的存在无意义。例如人体是有四肢组成的,四肢不能脱离人体存在,人体少了四肢也难言完整@startuml Body "1" *-- "2" Arm Body "1" *-- "2" Leg @enduml* 表示实心菱形同时也看到了一对多时的数字表示方法,双引号" 包裹,放在线段与Class之间。 多对多也同样。最后再总结一下六大关系 继承实现依赖关联聚合组合关系含义功能扩展功能实现使用拥有整体-部分(has-a)整体-部分(contains-a)关系特性--临时性,单向性长期性,可双向(平等性)整体与部分可分离整体与部分不可分离,生命周期一致java语法extendsimplements方法参数,局部变量,静态方法调用成员变量成员变量成员变量关系强弱强强弱较强较强非常强现实事例父子飞机/鸟可以飞厨师使用菜谱夫妻,师生部门-员工人体-四肢图形指向箭头指向父类箭头指向接口箭头指向被使用者指向被拥有者,可双向箭头指向部分, 菱形指向整体箭头指向部分,菱形指向整体综合运用@startuml interface One interface Two interface Three extends Two interface Four class Five implements One, Three class Six extends Five implements Four { field: String method(): void @enduml 成员变量、成员方法@startuml class Hello { one: String three(param1: String, param2: int): boolean String two int four(List<String> param) @endumlclass定义后跟大括号,声明成员,然后按照 变量名:类型 的顺序声明,类型后置。方法和成员的顺序上可以混在一起,最终成图是,会自动分为两组成员可见性UML 使用以下符号表示可见性CharacterVisibility-private#protected~package private+public但是 PlantUML 将这种文字符合进一步图形化:@startuml class Hello { - privateField: int # protectedField: int ~ packagePrivateField: int + publicField: int - privateMethod(): void # protectedMethod(): void ~ packagePrivateMethod(): void + publicMethod(): void @enduml当然,也可以关闭这种图形化符合,继续使用文字符号@startuml skinparam classAttributeIconSize 0 class Hello { - privateField: int # protectedField: int ~ packagePrivateField: int + publicField: int - privateMethod(): void # protectedMethod(): void ~ packagePrivateMethod(): void + publicMethod(): void @enduml通过 skinparam classAttributeIconSize 0 关闭图形化符号抽象方法@startuml class Hello { {abstract} one: int {abstract} two(): int @enduml成员前面加 {abstract} 标记位抽象成员静态方法@startuml class Hello { {static} ONE: int {static} two(): int @enduml添加 {static} 表示静态方法泛型@startuml class Hello<H> class World<W> @enduml类名后跟<泛型>包图@startuml package one.two { class Hello package three.four { World -- Hello @endumlpackage <name> {...} 中可以写类 UML 图包图中的声明顺序@startuml package three.four { World -- Hello package one.two { class Hello @enduml包图的顺序很重要,如上图 one.two 中的类被 three.four 依赖,所以应该写到先面, 以为 Hello 会声明在第一个出现的包中。备注(note)@startuml class Fizz note left: fizz class Buzz note right: buzz class Foo note top: foo class Bar note bottom: bar @enduml使用 note <top|bottom|left|right>: <备注> 为 UML 图添加备注, 备注内容可以是 Creole 语法指定目标类@startuml Fizz -- Buzz note left of Fizz: fizz note right of Buzz: buzz @endumlnote <位置> of <目标>: <备注>用来为指定目标 Class 生成备注为类关系进行备注@startuml Fizz -- Buzz note on link: fizz-buzz note left: buzz @endumlnote on link: <备注> 可以在类图的关系中添加备注给备注加名字@startuml note "Hello World" as n1 Hello -- n1 World .. n1 note "Fizz Buzz" as n2 @endumlnote "<备注>" as <名字>用来给备注设置名字,有了名字后,可以通过名字将一个备注关联到多个Class多行备注@startuml class Hello note left Hello World end note Fizz -- Buzz note on link end note note left of Fizz end note note as n1 end note @endumlend note 用来结束多行的备注

我在 Android 上做了一个 1 : 1 高达

最近看到一个新闻,一个 1:1 的自由高达落户在上海金桥。 作为高达爱好者,我一直想去现场感受一下真实高达的压迫感,无奈一直没机会成行。不过这难不倒我,我决定自己动手做一个 1:1 高达来体验一番。借助 AR 技术我实现了这个效果, 怎么样,不比上海金桥的差吧 ~什么是 AR (Augemented Reality)AR(增强现实)是近几年新兴的技术,他可以将3D模型等虚拟元素模拟仿真后应用到现实世界中,虚拟与现实,两种信息互为补充,从而实现对真实世界的“增强”。不少人会将 AR(增强现实) 与 VR(虚拟现实)相混淆,两者的区别在于虚拟元素的占比:VR:看到的场景和人物全是假的,是把你的意识代入一个虚拟的世界。AR:看到的场景和人物一部分是真一部分是假,是把虚拟的信息带入到现实世界中。VR 技术的研究已经有30多年的历史了,而 AR 则年轻得多,随着智能手机以及智能穿戴设备的普及而逐渐被人们熟知。相对于 VR,AR 的开发门槛低得多,只要有一台智能手机,借助 Google 提供的 ARCore,人人都可以开发出自己的 AR 应用。ARCore 是 Google 提供的 AR 解决方案,为开发者提供 API,可以通过 Android , iOS 等手机平台感知周边环境,打造沉浸式的 AR 体验。 ARCore 为开发者提供了三大能力:ARCore 能力示意图动作追踪<br/>通过识别相机图像中的可视特征点来跟踪拍摄者的位置,从而决定虚拟元素的相对位置变化环境理解<br/>识别常见水平或垂直表面(如表格或墙壁)上的特征点群集,还可以确定平面边界,将虚拟对象放置在平面上光线预测<br/> 预测当前场景的光照条件,可以使用此照明信息来照亮虚拟 AR 对象,模拟物体在现实世界的影子ARCore 为 AR 提供了周边环境的感知能力,但一个完整的 AR 应用还需要处理 3D 模型的渲染,这要借助 OpenGL ES 来完成,学习成本很高。官方意识到这个问题,在 2017 年推出 ARCore 之后,紧跟着 2018 年的 IO 大会上推出了 Sceneform 这个在 Android 上的 3D 图像渲染库。.obj , .fbx 或 .gltf 等常见的 3D 模型文件格式,虽然可以在主流的 3D 软件中通用,但在 Android 中,我们只能通过 OpenGL 代码对其进行渲染。而 Sceneform 可以将这些格式的模型文件,连同所依赖的资源文件(.mtl, .bin, .png 等) 转换为 .sfa 和 .sfb 格式。 后者是可供 Sceneform 渲染的二进制模型文件, 前者是具有可读性的摘要文件,用来描述后者。相比于 OpenGL , Sceneform 的使用要简单得多,而且 sfb 还可以通过 Sceneform 提供的 AS 插件在 IDE 中进行模型预览。接下来,通过 Sceneform 和 ARCore 来实现我的 1:1 高达1. Gradle 添加依赖新建 AndroidStudio 工程,在 root 的 build.gradle 中添加 Sceneform 插件dependencies { classpath 'com.google.ar.sceneform:plugin:1.17.1' }接着在 app 的 build.gradle 中依赖 ARCore 和 Sceneform 的 aardependencies { implementation 'com.google.ar:core:1.26.0' implementation 'com.google.ar.sceneform.ux:sceneform-ux:1.17.1' implementation 'com.google.ar.sceneform:core:1.17.1' }2. Manifest 申请权限 <uses-permission android:name="android.permission.CAMERA"/> <!-- 此 App 在 GooglePlay 中只会对支持 ARCore 的设备可见 --> <uses-feature android:name="android.hardware.camera.ar" android:required="true"/> <application …> <!-- 当安装 App 时,如果设备没有安装 ARCore,GooglePlay 会自动为其安装 --> <meta-data android:name="com.google.ar.core" android:value="required" /> </application>3. 布局文件ARFragment 可以用来承载 AR 场景、响应用户行为,Android 中显示虚拟元素的最简答的方法就是在布局中添加一个 ARFRagment :<?xml version="1.0" encoding="utf-8"?> frameLabelStart--frameLabelEnd 4. 制作 sfb 模型文件3D 模型一般是通过 Maya 或 3D Max 等专业软件制作的,不少 3D 建模爱好者会将自己的作品上传到一些设计网站供大家免费或有偿下载。我们可以在网站上下载常见格式的 3D 模型文件。以 .obj 为例,obj 文件中描述了多边形的顶点和片段信息等, 此外还有颜色、材质等信息存储在配套的 .mtl 文件中 , 我们将下载的 obj/mtl/png 等模型文件拷贝到非 assets 目录下,这样可以避免打入 apk。例如 app/sampledata我们在 build.gtadle 通过 sceneform.asset(...) 添加 obj > sfb 的配置如下sceneform.asset('sampledata/msz-006_zeta_gundam/scene.obj', 'default', 'sampledata/msz-006_zeta_gundam/scene.sfa', 'src/main/assets/scene')sampledata/msz-006_zeta_gundam/scene.obj 是 obj 源文件位置, src/main/assets/scene 是生成的 sfb 目标路径,我们将目标文件生成在 assets/ 中,打入 apk ,便于在运行时加载。gradle 配置完后,sync 并 build 工程,build 过程中,会在 assets/ 中生成同名 sfb 文件5. 加载、渲染模型//MainActivity.kt override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) arFragment = supportFragmentManager.findFragmentById(R.id.ux_fragment) as ArFragment arFragment.setOnTapArPlaneListener { hitResult, plane, motionEvent -> if (plane.type != Plane.Type.HORIZONTAL_UPWARD_FACING || state != AnchorState.NONE) { return@setOnTapArPlaneListener val anchor = hitResult.createAnchor() placeObject(ux_fragment, anchor, Uri.parse("scene.sfb")) ARFragment 能够响应在 AR 场景中的用户点击行为,在点击的位置中添加虚拟元素,Uri.parse("scene.sfb") 用来获取 assets 中生成的模型文件。 private fun placeObject(fragment: ArFragment, anchor: Anchor, model: Uri) { ModelRenderable.builder() .setSource(fragment.context, model) .build() .thenAccept { addNodeToScene(fragment, anchor, it) .exceptionally { throwable : Throwable -> Toast.makeText(arFragment.getContext(), "Error:$throwable.message", Toast.LENGTH_LONG).show(); return@exceptionally null Sceneform 提供 ModelRenderable 用于模型渲染。 通过 setSource 加载 sfb 模型文件 private fun addNodeToScene(fragment: ArFragment, anchor: Anchor, renderable: Renderable) { val anchorNode = AnchorNode(anchor) val node = TransformableNode(fragment.transformationSystem) node.renderable = renderable node.setParent(anchorNode) fragment.arSceneView.scene.addChild(anchorNode) node.select() }ARSceneView 持有一个 Scene, Scene 是一个树形数据结构,作为 AR 场景的根节点,各种虚拟元素将作为其子节点被添加到场景中进行渲染val node = TransformableNode(fragment.transformationSystem) node.renderable = renderable node.setParent(anchorNode)所以,渲染 3D 模型,其实就是添加一个 Node 并为其设置 Renderable 的过程。 hitResult 是用户点击的位置信息,Anchor基于 hitResult 创建锚点,这个锚点作为子节点被添加到 Scene 根节点中,同时又作为 TransformableNode 的父节点。 TransformableNode 用来承载 3D 模型, 它可以接受手势进行拖拽或者放大缩小, 添加到 Archor 就相当于把 3D 模型放置到点击的位置上。6. 完整代码class MainActivity : AppCompatActivity() { lateinit var arFragment: ArFragment override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!checkIsSupportedDeviceOrFinish(this)) return setContentView(R.layout.activity_main) arFragment = supportFragmentManager.findFragmentById(R.id.ux_fragment) as ArFragment arFragment.setOnTapArPlaneListener { hitresult: HitResult, plane: Plane, motionevent: MotionEvent? -> if (plane.type != Plane.Type.HORIZONTAL_UPWARD_FACING) return@setOnTapArPlaneListener val anchor = hitresult.createAnchor() placeObject(arFragment, anchor, R.raw.cube) private fun placeObject(arFragment: ArFragment, anchor: Anchor, uri: Int) { ModelRenderable.builder() .setSource(arFragment.context, uri) .build() .thenAccept { modelRenderable: ModelRenderable -> addNodeToScene(arFragment, anchor, modelRenderable) } .exceptionally { throwable: Throwable -> Toast.makeText(arFragment.getContext(), "Error:$throwable.message", Toast.LENGTH_LONG).show(); return@exceptionally null private fun addNodeToScene(arFragment: ArFragment, anchor: Anchor, renderable: Renderable) { val anchorNode = AnchorNode(anchor) val node = TransformableNode(arFragment.transformationSystem) node.renderable = renderable node.setParent(anchorNode) arFragment.arSceneView.scene.addChild(anchorNode) node.select() private fun checkIsSupportedDeviceOrFinish(activity: Activity): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { Toast.makeText(activity, "Sceneform requires Android N or later", Toast.LENGTH_LONG).show() activity.finish() return false val openGlVersionString = (activity.getSystemService<Any>(Context.ACTIVITY_SERVICE) as ActivityManager) .deviceConfigurationInfo .glEsVersion if (openGlVersionString.toDouble() < MIN_OPENGL_VERSION) { Toast.makeText(activity, "Sceneform requires OpenGL ES 3.0 or later", Toast.LENGTH_LONG) .show() activity.finish() return false return true companion object { private const val MIN_OPENGL_VERSION = 3.0 checkIsSupportedDeviceOrFinish 用来检测可运行环境,通过实现可知, Sceneform 的运行条件是 AndroidN 以及 OpenGL 3.0 以上。以上就是全部代码了,虽然代码很少,效果很哇塞 最后Sceneform 配合 ARCore 可以快速搭建 AR 应用,除了加载静态的 3D 模型以外,Sceneform 还可以加载带动画的模型。随着 “元宇宙” 概念的兴起,Google,Facebook 等巨头必将加大在 AR 乃至 VR 技术上的研究投入,虚拟现实技术或将成为移动互联网之后的新一代社交、娱乐场景,想象空间巨大。今天就写到这里吧, 我要和刚认识的小姐姐玩耍去了 最后推荐一个网站,大家可以在那里下载一些有趣的 3D 模型 ~https://sketchfab.com/(完)# 文末惊喜来了 ! 衷心感谢各位读者大大的关注,欢迎大家就本文内容进行评论和吐槽!本人将选取最热门的前两条评论用户,送出小礼品:掘金徽章一枚 热门评论排序规则 :点赞数 + 评论数 (作者本人的评论除外)统计截止时间: 9月10日 24点之后我将联系获奖用户寄送礼物,感谢掘金平台对本次活动的大力支持!

一道面试题: Kotlin 中处理生产者/消费者问题的几种方式

生产者和消费者问题是线程模型中的经典问题:生产者和消费者在同一时间段内共用同一个缓冲区(Buffer),生产者往 Buffer 中添加产品,消费者从 Buffer 中取走产品,当 Buffer 为空时,消费者阻塞,当 Buffer 满时,生产者阻塞。Kotlin 中有多种方法可以实现多线程的生产/消费模型(大多也适用于Java)SynchronizedReentrantLockBlockingQueueSemaphorePipedXXXStreamRxJavaCoroutineFlow1. SynchronizedSynchronized 是最最基本的线程同步工具,配合 wait/notify 可以实现实现生产消费问题val buffer = LinkedList<Data>() val MAX = 5 //buffer最大size val lock = Object() fun produce(data: Data) { sleep(2000) // mock produce synchronized(lock) { while (buffer.size >= MAX) { // 当buffer满时,停止生产 // 注意此处使用while不能使用if,因为有可能是被另一个生产线程而非消费线程唤醒,所以要再次检查buffer状态 // 如果生产消费两把锁,则不必担心此问题 lock.wait() buffer.push(data) // notify方法只唤醒其中一个线程,选择哪个线程取决于操作系统对多线程管理的实现。 // notifyAll会唤醒所有等待中线程,哪一个线程将会第一个处理取决于操作系统的实现,但是都有机会处理。 // 此处使用notify有可能唤醒的是另一个生产线程从而造成死锁,所以必须使用notifyAll lock.notifyAll() fun consume() { synchronized(lock) { while (buffer.isEmpty()) lock.wait() // 暂停消费 buffer.removeFirst() lock.notifyAll() sleep(2000) // mock consume @Test fun test() { // 同时启动多个生产、消费线程 repeat(10) { Thread { produce(Data()) }.start() repeat(10) { Thread { consume() }.start() <br/>2. ReentrantLockLock 相对于 Synchronized 好处是当有多个生产线/消费线程时,我们可以通过定义多个 condition 精确指定唤醒哪一个。下面的例子展示 Lock 配合 await/single 替换前面 Synchronized 写法val buffer = LinkedList<Data>() val MAX = 5 //buffer最大size val lock = ReentrantLock() val condition = lock.newCondition() fun produce(data: Data) { sleep(2000) // mock produce lock.lock() while (buffer.size >= 5) condition.await() buffer.push(data) condition.signalAll() lock.unlock() fun consume() { lock.lock() while (buffer.isEmpty()) condition.await() buffer.removeFirst() condition.singleAll() lock.unlock() sleep(2000) // mock consume } <br/>3. BlockingQueue (阻塞队列)BlockingQueue在达到临界条件时,再进行读写会自动阻塞当前线程等待锁的释放,天然适合这种生产/消费场景val buffer = LinkedBlockingQueue<Data>(5) fun produce(data: Data) { sleep(2000) // mock produce buffer.put(data) //buffer满时自动阻塞 fun consume() { buffer.take() // buffer空时自动阻塞 sleep(2000) // mock consume 注意 BlockingQueue 的有三组读/写方法,只有一组有阻塞效果,不要用错方法说明add(o)/remove(o)add方法在添加元素的时候,若超出了度列的长度会直接抛出异常offer(o)/poll(o)offer在添加元素时,如果发现队列已满无法添加的话,会直接返回falseput(o)/take(o)put向队尾添加元素的时候发现队列已经满了会发生阻塞一直等待空间,以加入元素<br/>4. Semaphore(信号量)Semaphore 是 JUC 提供的一种共享锁机制,可以进行拥塞控制,此特性可用来控制 buffer 的大小。// canProduce: 可以生产数量(即buffer可用的数量),生产者调用acquire,减少permit数目 val canProduce = Semaphore(5) // canConsumer:可以消费数量,生产者调用release,增加permit数目 val canConsume = Semaphore(5) // 控制buffer访问互斥 val mutex = Semaphore(0) val buffer = LinkedList<Data>() fun produce(data: Data) { if (canProduce.tryAcquire()) { sleep(2000) // mock produce mutex.acquire() buffer.push(data) mutex.release() canConsume.release() //通知消费端新增加了一个产品 fun consume() { if (canConsume.tryAcquire()) { sleep(2000) // mock consume mutex.acquire() buffer.removeFirst() mutex.release() canProduce.release() //通知生产端可以再追加生产 } <br/>5. PipedXXXStream (管道)Java里的管道输入/输出流 PipedInputStream / PipedOutputStream 实现了类似管道的功能,用于不同线程之间的相互通信,输入流中有一个缓冲数组,当缓冲数组为空的时候,输入流 PipedInputStream 所在的线程将阻塞val pis: PipedInputStream = PipedInputStream() val pos: PipedOutputStream by lazy { PipedOutputStream().apply { pis.connect(this) //输入输出流之间建立连接 fun produce(data: ContactsContract.Data) { while (true) { sleep(2000) pos.use { // Kotlin 使用 use 方便的进行资源释放 it.write(data.getBytes()) it.flush() fun consume() { while (true) { sleep(2000) pis.use { val byteArray = ByteArray(1024) it.read(byteArray) @Test fun Test() { repeat(10) { Thread { produce(Data()) }.start() repeat(10) { Thread { consume() }.start() <br/>6. RxJavaRxJava 从概念上,可以将 Observable/Subject 作为生产者, Subscriber 作为消费者, 但是无论 Subject 或是 Observable 都缺少 Buffer 溢出时的阻塞机制,难以独立实现生产者/消费者模型。 Flowable 的背压机制,可以用来控制 buffer 数量,并在上下游之间建立通信, 配合 Atomic 可以变向实现单生产者/单消费者场景,(不适用于多生产者/多消费者场景)。class Producer : Flowable<Data>() { override fun subscribeActual(subscriber: org.reactivestreams.Subscriber<in Data>) { subscriber.onSubscribe(object : Subscription { override fun cancel() { //... private val outStandingRequests = AtomicLong(0) override fun request(n: Long) { //收到下游通知,开始生产 outStandingRequests.addAndGet(n) while (outStandingRequests.get() > 0) { sleep(2000) subscriber.onNext(Data()) outStandingRequests.decrementAndGet() class Consumer : DefaultSubscriber<Data>() { override fun onStart() { request(1) override fun onNext(i: Data?) { sleep(2000) //mock consume request(1) //通知上游可以增加生产 override fun onError(throwable: Throwable) { //... override fun onComplete() { //... @Test fun test_rxjava() { try { val testProducer = Producer) val testConsumer = Consumer() testProducer .subscribeOn(Schedulers.computation()) .observeOn(Schedulers.single()) .blockingSubscribe(testConsumer) } catch (t: Throwable) { t.printStackTrace() }<br/>7. Coroutine Channel协程中的 Channel 具有拥塞控制机制,可以实现生产者消费者之间的通信。可以把 Channel 理解为一个协程版本的阻塞队列,capacity 指定队列容量。 val channel = Channel<Data>(capacity = 5) suspend fun produce(data: ContactsContract.Contacts.Data) = run { delay(2000) //mock produce channel.send(data) suspend fun consume() = run { delay(2000)//mock consume channel.receive() @Test fun test_channel() { repeat(10) { GlobalScope.launch { produce(Data()) repeat(10) { GlobalScope.launch { consume() 此外,Coroutine 提供了 produce 方法,在声明 Channel 的同时生产数据,写法上更简单,适合单消费者单生产者的场景:fun CoroutineScope.produce(): ReceiveChannel<Data> = produce { repeat(10) { delay(2000) //mock produce send(Data()) @Test fun test_produce() { GlobalScope.launch { produce.consumeEach { delay(2000) //mock consume 8. Coroutine FlowFlow 跟 RxJava 一样,因为缺少 Buffer 溢出时的阻塞机制,不适合处理生产消费问题,其背压机制也比较简单,无法像 RxJava 那样收到下游通知。 但是 Flow 后来发布了 SharedFlow, 作为带缓冲的热流,提供了 Buffer 溢出策略,可以用作生产者/消费者之间的同步。val flow : MutableSharedFlow<Data> = MutableSharedFlow( extraBufferCapacity = 5 //缓冲大小 , onBufferOverflow = BufferOverflow.SUSPEND // 缓冲溢出时的策略:挂起 @Test fun test() { GlobalScope.launch { repeat(10) { delay(2000) //mock produce sharedFlow.emit(Data()) GlobalScope.launch { sharedFlow.collect { delay(2000) //mock consume }注意 SharedFlow 也只能用在单生产者/单消费者场景<br/>总结生产者/消费者问题,其本质核心还是多线程读写共享资源(Buffer)时的同步问题,理论上只要具有同步机制的多线程框架,例如线程锁、信号量、阻塞队列、协程 Channel等,都是可以实现生产消费模型的。另外,RxJava 和 Flow 虽然也是多线程框架,但是缺少Buffer溢出时的阻塞机制,不适用于生产/消费场景,更适合在纯响应式场景中使用。

100 行写一个 Compose 版华容道

之前写过几个 Compose 的 demo,但一直没使用到 Gesture, Theme 等特性,于是写了一个华容道的小程序来展示上述这些特性。写完后又一次被 Compose 的生产力所折服,整个程序的完成不足百行代码,这在传统开发方式中是难以想象的。代码地址:https://github.com/vitaviva/compose-huarongdao基本思路游戏逻辑比较简单,所以没有使用 MVI 之类的框架,但是整体仍然遵从数据驱动UI的设计思想:定义游戏的状态基于状态的UI绘制用户输入触发状态变化1. 定义游戏状态游戏的状态很简单,即当前各棋子(Chees)的摆放位置,所以可以将一个棋子的 List 作为承载 State 的数据结构1.1 棋子定义先来看一下单个棋子的定义data class Chess( val name: String, //角色名称 val drawable: Int //角色图片 val w: Int, //棋子宽度 val h: Int, //棋子长度 val offset: IntOffset = IntOffset(0, 0) //偏移量 )通过 w,h 可以确定棋子的形状,offset 确定在棋牌中的当前位置1.2 开局棋子摆放接下来我们定义各个角色的棋子,并按照开局的状态摆放这些棋子val zhang = Chess("张飞", R.drawable.zhangfei, 1, 2) val cao = Chess("曹操", R.drawable.caocao, 2, 2) val huang = Chess("黄忠", R.drawable.huangzhong, 1, 2) val zhao = Chess("赵云", R.drawable.zhaoyun, 1, 2) val ma = Chess("马超", R.drawable.machao, 1, 2) val guan = Chess("关羽", R.drawable.guanyu, 2, 1) val zu = buildList { repeat(4) { add(Chess("卒$it", R.drawable.zu, 1, 1)) } }各角色的定义中明确棋子形状,比如“张飞”的长宽比是 2:1,“曹操” 的长宽比是2:2。接下来定义一个游戏开局:val gameOpening: List<Triple<Chess, Int, Int>> = buildList { add(Triple(zhang, 0, 0)); add(Triple(cao, 1, 0)) add(Triple(zhao, 3, 0)); add(Triple(huang, 0, 2)) add(Triple(ma, 3, 2)); add(Triple(guan, 1, 2)) add(Triple(zu[0], 0, 4)); add(Triple(zu[1], 1, 3)) add(Triple(zu[2], 2, 3)); add(Triple(zu[3], 3, 4)) }Triple 的三个成员分别表示棋子以及其在棋盘中的偏移,例如 Triple(cao, 1, 0) 表示曹操开局处于(1,0)坐标。最后通过下面代码,将 gameOpening 转化为我们所需的 State, 即一个 List<Chess>:const val boardGridPx = 200 //棋子单位尺寸 fun ChessOpening.toList() = map { (chess, x, y) -> chess.moveBy(IntOffset(x * boardGridPx, y * boardGridPx)) 2. UI渲染,绘制棋局有了 List<Chess> 之后,依次绘制棋子,从而完成整个棋局的绘制。@Composable fun ChessBoard (chessList: List<Chess>) { Modifier .width(boardWidth.toDp()) .height(boardHeight.toDp()) chessList.forEach { chess -> Image( //棋子图片 Modifier .offset { chess.offset } //偏移位置 .width(chess.width.toDp()) //棋子宽度 .height(chess.height.toDp())) //棋子高度 painter = painterResource(id = chess.drawable), contentDescription = chess.name }Box 确定棋盘的范围,Image 绘制棋子,并通过 Modifier.offset{ } 将其摆放到正确的位置。到此为止,我们使用 Compose 绘制了一个静态的开局,接下来就是让棋子跟随手指动起来,这就涉及到 Compose Gesture 的使用了3. 拖拽棋子,触发状态变化Compose 的事件处理也是通过 Modifier 设置的, 例如 Modifier.draggable(), Modifier.swipeable() 等可以做到开箱即用。 华容道的游戏场景中,可以使用 draggable 监听拖拽3.1 监听手势1) 使用 draggable 监听手势棋子可以x轴、y轴两个方向进行拖拽,所以我们分别设置两个 draggable :@Composable fun ChessBoard ( chessList: List<Chess>, onMove: (chess: String, x: Int, y: Int) -> Unit Image( modifier = Modifier .draggable(//监听水平拖拽 orientation = Orientation.Horizontal, state = rememberDraggableState(onDelta = { onMove(chess.name, it.roundToInt(), 0) .draggable(//监听垂直拖拽 orientation = Orientation.Vertical, state = rememberDraggableState(onDelta = { onMove(chess.name, 0, it.roundToInt()) }orientation 用来指定监听什么方向的手势:水平或垂直。 rememberDraggableState保存拖动状态,onDelta 指定手势的回调。 我们通过自定义的 onMove 将拖拽手势的位移信息抛出。此时有人会问了,draggable 只能监听或者水平或者垂直的拖拽,那如果想监听任意方向的拖拽呢,此时可以使用 detectDragGestures2) 使用 pointerInput 监听手势draggable , swipeable 等,其内部都是通过调用 Modifier.pointerInput() 实现的,基于 pointerInput 可以实现更复杂的自定义手势:fun Modifier.pointerInput( key1: Any?, block: suspend PointerInputScope.() -> Unit ) : Modifier = composed (...) { }pointerInput 提供了 PointerInputScope,在其中可以使用suspend函数对各种手势进行监听。例如,可以使用 detectDragGestures 监听任意方向的拖拽:suspend fun PointerInputScope.detectDragGestures( onDragStart: (Offset) -> Unit = { }, onDragEnd: () -> Unit = { }, onDragCancel: () -> Unit = { }, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit )detectDragGestures 也提供了水平、垂直版本供选择,所以在华容道的场景中,也可以使用以下方式进行水平和垂直方向的监听:@Composable fun ChessBoard ( chessList: List<Chess>, onMove: (chess: String, x: Int, y: Int) -> Unit Image( modifier = Modifier .pointerInput(Unit) { scope.launch {//监听水平拖拽 detectHorizontalDragGestures { change, dragAmount -> change.consumeAllChanges() onMove(chess.name, 0, dragAmount.roundToInt()) scope.launch {//监听垂直拖拽 detectVerticalDragGestures { change, dragAmount -> change.consumeAllChanges() onMove(chess.name, 0, dragAmount.roundToInt()) }需要注意 detectHorizontalDragGestures 和 detectVerticalDragGestures 是挂起函数,所以需要分别启动协程进行监听,可以类比成多个 flow 的 collect。3.2 棋子的碰撞检测获取了棋子拖拽的位移信息后,可以更新棋局状态并最终刷新UI。但是在更新状态之前需要对棋子的碰撞进行检测,棋子的拖拽是有边界的。碰撞检测的原则很简单:棋子不能越过当前移动方向上的其他棋子。1) 相对位置判定首先,需要确定棋子之间的相对位置。 可以使用下面方法,判定棋子A在棋子B的上方:val Chess.left get() = offset.x val Chess.right get() = left + width infix fun Chess.isAboveOf(other: Chess) = (bottom <= other.top) && ((left until right) intersect (other.left until other.right)).isNotEmpty()拆解上述条件表达式,即 棋子A的下边界位于棋子B上边界之上 且 在水平方向上棋子A与棋子B的区域有交集:比如上面的棋局中,可以得到如下判定结果:曹操 位于 关羽 之上关羽 位于 卒1 黄忠 之上卒1 位于 卒2 卒3 之上虽然位置上 关羽位于卒2的上方,但是从碰撞检测的角度看,关羽 和 卒2 在x轴方向没有交集,因此 关羽 在y轴方向上的移动不会碰撞到 卒2,guan.isAboveOf(zu1) == false同理,其他几种位置关系如下:infix fun Chess.isToRightOf(other: Chess) = (left >= other.right) && ((top until bottom) intersect (other.top until other.bottom)).isNotEmpty() infix fun Chess.isToLeftOf(other: Chess) = (right <= other.left) && ((top until bottom) intersect (other.top until other.bottom)).isNotEmpty() infix fun Chess.isBelowOf(other: Chess) = (top >= other.bottom) && ((left until right) intersect (other.left until other.right)).isNotEmpty()2) 越界检测接下来,判断棋子移动时是否越界,即是否越过了其移动方向上的其他棋子或者出界例如,棋子在x轴方向的移动中检查是否越界: // X轴方向移动 fun Chess.moveByX(x: Int) = moveBy(IntOffset(x, 0)) //检测碰撞并移动 fun Chess.checkAndMoveX(x: Int, others: List<Chess>): Chess { others.filter { it.name != name }.forEach { other -> if (x > 0 && this isToLeftOf other && right + x >= other.left) return moveByX(other.left - right) else if (x < 0 && this isToRightOf other && left + x <= other.right) return moveByX(other.right - left) return if (x > 0) moveByX(min(x, boardWidth - right)) else moveByX(max(x, 0 - left)) }上述逻辑很清晰:当棋子在x轴方向正移动时,如果碰撞到其右侧的棋子则停止移动;否则继续移动,直至碰撞棋盘边界为止 ,其他方向同理。3.3 更新棋局状态综上,获取手势位移信息后,检测碰撞并移动到正确位置,最后更新状态,刷新UI:val chessList: List<Chess> by remember { mutableStateOf(opening.toList()) ChessBoard(chessList = chessState) { cur, x, y -> // onMove回调 chessState = chessState.map { //it: Chess if (it.name == cur) { if (x != 0) it.checkAndMoveX(x, chessState) else it.checkAndMoveY(y, chessState) } else { it } }4. 主题切换,游戏换肤最后,再来看一下如何为游戏实现多套皮肤,用到的是 Compose 的 Theme。Compose 的 Theme 的配置简单直观,这要得益于它是基于 CompositionLocal 实现的。可以把 CompositionLocal 看做是一个 Composable 的父容器,它有两个特点:其子 Composable 可以共享 CompositionLocal 中的数据,避免了层层参数传递。当 CompositionLocal 的数据发生变化时,子 Composable 会自动重组以获取最新数据。通过 CompositionLocal 的特点,我们可以实现 Compose 的动态换肤:4.1 定义皮肤首先,我们定义多套皮肤,也就是棋子的多套图片资源object DarkChess : ChessAssets { override val huangzhong = R.drawable.huangzhong override val caocao = R.drawable.caocao override val zhaoyun = R.drawable.zhaoyun override val zhangfei = R.drawable.zhangfei override val guanyu = R.drawable.guanyu override val machao = R.drawable.machao override val zu = R.drawable.zu object LightChess : ChessAssets { //...同上,略 object WoodChess : ChessAssets { //...同上,略 }4.2 创建 CompositionLocal然后创建皮肤的 CompositionLocal, 我们使用 compositionLocalOf 方法创建internal var LocalChessAssets = compositionLocalOf<ChessAssets> { DarkChess }此处的 DarkChess 是默认值,但通常不会直接使用,一般我们会通过 CompositionLocalProvider 为 CompositionLocal 创建 Composable 容器,同时设置当前值:CompositionLocalProvider(LocalChessAssets provides chess) { //... }其内部的子Composable共享当前设置的值。4.3 跟随 Theme 变化切换皮肤这个游戏中,我们希望将棋子的皮肤加入到整个游戏主题中,并跟随 Theme 变化而切换:@Composable fun ComposehuarongdaoTheme( theme: Int = 0, content: @Composable() () -> Unit val (colors, chess) = when (theme) { 0 -> DarkColorPalette to DarkChess 1 -> LightColorPalette to LightChess 2 -> WoodColorPalette to WoodChess else -> error("") CompositionLocalProvider(LocalChessAssets provides chess) { MaterialTheme( colors = colors, typography = Typography, shapes = Shapes, content = content }定义 theme 的枚举值, 根据枚举获取不同的 colors 以及 ChessAssets, 将 MaterialTheme 置于 LocalChessAssets 内部,MaterialTheme 内的所有 Composalbe 可以共享 MaterialTheme 和 LocalChessAssets 的值。最后,为 LocalChessAssets 定一个 MaterialTheme 的扩展函数,val MaterialTheme.chessAssets @Composable @ReadOnlyComposable get() = LocalChessAssets.current可以像访问 MaterialTheme 的其他属性一样,访问 ChessAssets。最后本文主要介绍了如何使用 Compose 的 Gesture, Theme 等特性快速完成一个华容道小游戏,更多 API 的实现原理,可以参考以下文章:[使用Jetpack Compose完成自定义手势处理](https://juejin.cn/post/6979777894104956935) [主题配置繁琐?Compose帮你轻松搞定!](https://juejin.cn/post/6964633825318010917)代码地址:https://github.com/vitaviva/compose-huarongdao

不止 Android!Compose Multiplatform 来了

7月底 Compose for Android 1.0 刚刚发布,紧接着 8月4日 JetBrains 就宣布了 Compose Multiplatform 的最新进展,目前已进入 alpha 阶段。Compose 作为一个声明式UI框架,除了渲染部分需借助平台能力以外,其他大部分特性可以做到平台无关。尤其是 Kotlin 这样一门跨平台语言,早就为日后的 UI 跨平台奠定了基础。Compose Multiplatform 将整合现有的三个 Compose 项目:Android、Desktop、Web,未来可以像 Kotlin Multiplatform Project 一样,在一个工程下开发跨端应用,统一的声明式范式让代码在最大程度上实现复用,真正做到write once,run anywhere 。如今进入 alpah 阶段标志着其 API 也日渐成熟,相信不久的未来正式版就会与大家见面。我们通过官方 todoapp 的例子,提前体验一下 Compose Multiplatform 的魅力https://github.com/JetBrains/compose-jb/tree/master/examples/todoapptodoapp 工程todoappcommon:平台无关代码compose-ui :UI层可复用代码(兼容 Android 与 Desktop)main:逻辑层可复用代码(首页)edit:逻辑层可复用代码(编辑)root:逻辑层入口、导航管理( main 与 eidt 间页面跳转)utils:工具类database:数据库android:平台相关代码,Activity 等desktop:平台相关代码,application 等web:平台相关,index.html 等ios:compose-ui 尚不支持 ios,但通过KMM配合SwiftUI可以实现iOS端代码项目基于 Model-View-Intent(aka MVI) 打造,Model层、ViewModel层 代码几乎可以 100% 复用,View层在 desktop 和 Android 也可实现大部分复用,web 有一定特殊性需要单独适配。 除了 Jetpack Compose 以外,项目中使用了多个基于 KM 的三方框架,保证了上层的开发范式在多平台上的一致体验:KM三方库说明Decompose数据通信(BLoC)MVIKotlin跨平台MVIRektive异步响应式库SQLDelight数据库todoapp 代码平台入口代码对比一下 Android端 与 Desktop端 的入口代码//todoapp/android/src/main/java/example/todo/android/MainActivity.kt class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val root = todoRoot(defaultComponentContext()) setContent { ComposeAppTheme { Surface(color = MaterialTheme.colors.background) { TodoRootContent(root) private fun todoRoot(componentContext: ComponentContext): TodoRoot = TodoRootComponent( componentContext = componentContext, storeFactory = LoggingStoreFactory(TimeTravelStoreFactory(DefaultStoreFactory())), database = DefaultTodoSharedDatabase(TodoDatabaseDriver(context = this)) }//todoapp/desktop/src/jvmMain/kotlin/example/todo/desktop/Main.kt fun main() { overrideSchedulers(main = Dispatchers.Main::asScheduler) val lifecycle = LifecycleRegistry() val root = todoRoot(DefaultComponentContext(lifecycle = lifecycle)) application { val windowState = rememberWindowState() LifecycleController(lifecycle, windowState) Window( onCloseRequest = ::exitApplication, state = windowState, title = "Todo" Surface(modifier = Modifier.fillMaxSize()) { MaterialTheme { DesktopTheme { TodoRootContent(root) private fun todoRoot(componentContext: ComponentContext): TodoRoot = TodoRootComponent( componentContext = componentContext, storeFactory = DefaultStoreFactory(), database = DefaultTodoSharedDatabase(TodoDatabaseDriver()) )TodoRootContent:根Composable,View层入口TodoRootComponent:根状态管理器,ViewModel层入口DefaultStoreFactory:创建 Store,管理状态DefaultTodoShareDatabase:M层,数据管理TodoRootContent 和 TodoRootComponent 分别是 View 层和 ViewModel 层的入口,TodoRootComponent 管理着全局状态,即页面导航状态。可以看到,Android 与 Desktop 在 View 、 VM 、M等各层都进行了大面积复用,VM层代码MVI 中虽然没有 ViewModel,但是有等价概念,从习惯出发我们暂且称之为 VM 层。 VM层其实就是状态的管理场所,我们以首页的 mian 为例//todoapp/common/main/src/commonMain/kotlin/example/todo/common/main/integration/TodoMainComponent.kt class TodoMainComponent( componentContext: ComponentContext, storeFactory: StoreFactory, database: TodoSharedDatabase, private val output: Consumer<Output> ) : TodoMain, ComponentContext by componentContext { private val store = instanceKeeper.getStore { TodoMainStoreProvider( storeFactory = storeFactory, database = TodoMainStoreDatabase(database = database) ).provide() override val models: Value<Model> = store.asValue().map(stateToModel) override fun onItemClicked(id: Long) { output(Output.Selected(id = id)) override fun onItemDoneChanged(id: Long, isDone: Boolean) { store.accept(Intent.SetItemDone(id = id, isDone = isDone)) override fun onItemDeleteClicked(id: Long) { store.accept(Intent.DeleteItem(id = id)) override fun onInputTextChanged(text: String) { store.accept(Intent.SetText(text = text)) override fun onAddItemClicked() { store.accept(Intent.AddItem) }了解 MVI 的朋友对上面的代码应该非常熟悉,store 管理状态并通过 models 对UI暴露,所有数据流单向流动。 Value<Model> 是 Decompose 库中的类型,可以理解为跨平台的 LiveDataView层代码@Composable fun TodoRootContent(component: TodoRoot) { Children(routerState = component.routerState, animation = crossfadeScale()) { when (val child = it.instance) { is Child.Main -> TodoMainContent(child.component) is Child.Edit -> TodoEditContent(child.component) }TodoRootContent内部很简单,就是根据导航切换不同的页面。具体看一下TodoMainContent@Composable fun TodoMainContent(component: TodoMain) { val model by component.models.subscribeAsState() Column { TopAppBar(title = { Text(text = "Todo List") }) Box(Modifier.weight(1F)) { TodoList( items = model.items, onItemClicked = component::onItemClicked, onDoneChanged = component::onItemDoneChanged, onDeleteItemClicked = component::onItemDeleteClicked TodoInput( text = model.text, onAddClicked = component::onAddItemClicked, onTextChanged = component::onInputTextChanged }subscribeAsState() 在 Composable 中订阅了 Models 的状态,从而驱动 UI 刷新。Column 、Box 等 Composalbe 在 Descktop 和 Android 端会分别进行平台渲染。web端代码最后看一下web端实现。Compose For Web 的 Composalbe 大多基于 DOM 设计,无法像 Android 和 Desktop 的 Composable 那样复用,但是 VM 和 M 层仍然可以大量复用://todoapp/web/src/jsMain/kotlin/example/todo/web/App.kt fun main() { val rootElement = document.getElementById("root") as HTMLElement val lifecycle = LifecycleRegistry() val root = TodoRootComponent( componentContext = DefaultComponentContext(lifecycle = lifecycle), storeFactory = DefaultStoreFactory(), database = DefaultTodoSharedDatabase(todoDatabaseDriver()) lifecycle.resume() renderComposable(root = rootElement) { Style(Styles) TodoRootUi(root) }将 TodoRootComponent 传给 UI, 协助进行导航管理@Composable fun TodoRootUi(component: TodoRoot) { Card( attrs = { style { position(Position.Absolute) height(700.px) property("max-width", 640.px) top(0.px) bottom(0.px) left(0.px) right(0.px) property("margin", auto) val routerState by component.routerState.subscribeAsState() Crossfade( target = routerState.activeChild.instance, attrs = { style { width(100.percent) height(100.percent) position(Position.Relative) left(0.px) top(0.px) ) { child -> when (child) { is TodoRoot.Child.Main -> TodoMainUi(child.component) is TodoRoot.Child.Edit -> TodoEditUi(child.component) }TodoMainUi 的实现如下:@Composable fun TodoMainUi(component: TodoMain) { val model by component.models.subscribeAsState() attrs = { style { width(100.percent) height(100.percent) display(DisplayStyle.Flex) flexFlow(FlexDirection.Column, FlexWrap.Nowrap) attrs = { style { width(100.percent) property("flex", "0 1 auto") NavBar(title = "Todo List") attrs = { style { width(100.percent) margin(0.px) property("flex", "1 1 auto") property("overflow-y", "scroll") model.items.forEach { item -> Item( item = item, onClicked = component::onItemClicked, onDoneChanged = component::onItemDoneChanged, onDeleteClicked = component::onItemDeleteClicked attrs = { style { width(100.percent) property("flex", "0 1 auto") TodoInput( text = model.text, onTextChanged = component::onInputTextChanged, onAddClicked = component::onAddItemClicked }最后在 Jetpack Compose Runtime : 声明式 UI 的基础 一文中,我曾介绍过 Compose 跨平台的技术基础,如今配合各种 KM 三方库,使得开发生态更加完整。 Compose Multiplatform 全程基于 Kotlin 打造,上下游同构,相对于 Flutter 和 RN 更具优势,未来可期。

FragmentFactory :功能详解&使用场景

1. FragmentFactory的意义?关于Fragment的使用约定有Fragment使用经验的人都知道,Fragment必须有有一个空参的构造函数,否则编译时会出现一下错误:This fragment should provide a default constructor (a public constructor with no arguments)但即使添加了空参的构造器,如果定义了任何带参数构造器,仍然会被亲切的提醒:Avoid non-default constructors in fragments: use a default constructor plus Fragment#setArguments(Bundle) instead [ValidFragment]可见 Android 对于 Framgent携带构造参数唯恐避之不及。当系统发生 Configuration Change 时(例如横竖屏旋转等)Fragment 会恢复重建,此时系统不知道该选择哪个构造函数,所以系统与开发者约定,统一使用默认的空参构造函数构建,然后通过setArgments设置初始化值。以往的处理方式:静态工厂为此,一个常见做法是通过静态方法,避免直接使用带参数的构造函数。 如下,静态方法 getInstance(String str) 中,先空参构造 Fragment,然后通过 setArgments 初始化。public class MainFragment extends BaseFragment { private static final String MY_ARG = "my_arg"; private String arg = ""; public static MainFragment getInstance(String str) { MainFragment fragment = new MainFragment(); Bundle bundle = new Bundle(); bundle.putString(MY_ARG, str); fragment.setArguments(bundle); return fragment; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (getArguments() != null) { arg = getArguments().getString(MY_ARG); }后续便可以使用此静态方法构建 Fragment 了MainFragment fragment = MainFragment.getInstance("Hello world!!");Fragment恢复重建过程中,系统会调用静态方法 Fragment.instantiate(在 onCreate和onActivityCreated 之间)@NonNull public static Fragment instantiate(@NonNull Context context, @NonNull String fname, @Nullable Bundle args) { try { Class extends Fragment> clazz = FragmentFactory.loadFragmentClass( context.getClassLoader(), fname); Fragment f = clazz.getConstructor().newInstance(); if (args != null) { args.setClassLoader(f.getClass().getClassLoader()); f.setArguments(args); return f; } catch (java.lang.InstantiationException e) {我们先前通过 setArguments 传递的 bundle(随着 onSaveInstanceState 保存),会被系统传递给 instantiate ,以协助 fragment 的恢复重建。新的处理方案:FragmentFactory以上关于 Fragment 空参构造函数的约定,随着 androidx.fragment:fragment-1.1.0-alpha01 的发布成为了历史。新版本中 Fragment.instantiate已经被@Deprecated,推荐使用FragmentManager.getFragmentFactory和FragmentFactory.instantiate (ClassLoader, String)替代。FragmentFactory 允许开发者按照需要自由定义其构造函数,摆脱了空参构造的束缚。<br/>2. FragmentFactory如何使用?假设我们的 MainFragment 需要两个参数,那么使用 FragmentFactory 如何构造呢?定义FragmentFactory首先,需要定义自己的 FragmentFactory 。主要是重写 instantiate 方法,注意跟以前比,已经不支持传入 Bundle args 作为参数了。即使你想使用 bundle 传参,也推荐在这里手动 setArgument ,而非借助系统的设置。class MyFragmentFactory extends FragmentFactory { private final AnyArg anyArg1; private final AnyArg anyArg2; public MyFragmentFactory(AnyArg arg1, AnyArg arg2) { this.anyArg1 = arg1; this.anyArg2 = arg2; @NonNull @Override public Fragment instantiate(@NonNull ClassLoader classLoader, @NonNull String className) { Class extends Fragment> clazz = loadFragmentClass(classLoader, className); if (clazz == MainFragment.class) { return new MainFragment(anyArg1, anyArg2); } else { return super.instantiate(classLoader, className); }有了 FragmentFactory 的加持 Framgent 直接使用构造函数传参即可:protected MainFragment(AnyArg arg1, AnyArg arg2) { this.arg1 = arg1; this.arg2 = arg2; }设置Factory接下来需要在 Activity 的onCreate中为 FragmentManager 设置此 FactoryMyFragmentFactory fragmentFactory = new MyFragmentFactory( someObject1, someObject2); @Override public void onCreate(Bundle savedInstanceState) { getSupportFragmentManager().setFragmentFactory(fragmentFactory); super.onCreate(savedInstanceState); FragmentManager fragmentManager = getSupportFragmentManager(); FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction() .replace( R.id.fragment_container, MainFragment.class); if (addToBackStack) { fragmentTransaction.addToBackStack(tag); fragmentTransaction.commit(); }后续 FragmentManager 在创建/恢复 fragment 时,会使用此 factory 创建实例。需要特别注意的是,setFragmentFactory一定要在super.onCreate之前调用,因为在super.onCreate中会进行fragment的重建是需要被使用到。<br/>3. 应用场景:设置 LayoutIdandroidx.annotation:annotation-1.1.0-alpha01 起引入了@ContentView 注解用来为Fragment 设置默认布局文件,但时隔不久,androidx.fragment:fragment-1.1.0-alpha05 起,@ContentView 从class注解变为构造函数注解,fragment多了一个带参数的构造函数:支持使用构造函设置LayoutId: /** * Alternate constructor that can be used to provide a default layout * that will be inflated by {@link #onCreateView(LayoutInflater, ViewGroup, Bundle)}. * @see #Fragment() * @see #onCreateView(LayoutInflater, ViewGroup, Bundle) @ContentView //以前是用在Class上的注解 public Fragment(@LayoutRes int contentLayoutId) { this(); mContentLayoutId = contentLayoutId; }Note:Activity自 androidx.activity:activity-1.0.0-alpha06 起也支持通过构造函数设置LayoutId构造函数中将传入的LayoutId 存于mContentLayoutId,onCreateView 中根据 mConentLayoutId 自动创建 ContentView : @Nullable public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { if (mContentLayoutId != 0) { return inflater.inflate(mContentLayoutId, container, false); return null; }也就是说使用构造函数设置LayoutId 就无需重写 onCreateView 了。Fragment(@LayoutRes int contentLayoutId) + FragmentFactory你也许会问这跟 FragmentFactory 有什么关系呢?因为这里使用了构造函数传递参数,当 ConfigurationChanged 发生时,默认调用无参构造函数进行 fragment 的恢复重建,mContentLayoutId 信息会丢失,onCreateView无法正常创建视图。因此当使用构造函数设置 LayoutId 时,如果要考虑恢复重建的场景,必须配套设置一个 FragmentFactory。可能是踩坑的人太多了,在 1.1.0 之后的 javadoc 中特别强调了这一点:You must set a custom FragmentFactory if you want to use a non-default constructor to ensure that your constructor is called when the fragment is re-instantiated.所以,综合来看,你觉得通过构造函数设置 LayoutId 到底方不方便呢?<br/>4. 应用场景: 依赖注入FragmentFactory 允许自定义构造参数创建 Fragment, 这在 dagger、koin 等 DI 框架的使用场景中也能发挥更大作用。以 Koin 中 FragmentFactory 的使用为例 (对 Koin 的基本知识不做介绍): //定义fragmentModules private val fragmentModules = module {     fragment { HomeFragment() }     fragment { DetailsFragment(get()) } //通过get()获取依赖的参数 private val viewModelsModule = module {     viewModel { DetailsViewModel(get()) } //启动Koin override fun onCreate() {     super.onCreate()     startKoin {         androidContext(this@App)         fragmentFactory() // 添加 KoinFragmentFactory         loadKoinModules(listOf(viewModdules, fragmentModules, ...)) //装在fragmentModules }如上,DetailsFrament 参数依赖 DetailsViewModelKoinFragmentFactoryKoin 使用 KoinFragmentFactory 为其注入这个参数依赖, 其本质就是一个 FragmentFactoryclass KoinFragmentFactory : FragmentFactory() {     override fun instantiate(classLoader: ClassLoader, className: String): Fragment {         val clazz = Class.forName(className).kotlin         val instance = getKoin().getOrNull<Fragment>(clazz) //通过 Koin 创建 Fragment         return instance ?: super.instantiate(classLoader, className) }Koin 通过 KoinFragmentFactory 创建 Fragment,构造函数中允许有参数,可以通过 koin 的依赖注入获取FragmentFactory 需要被设置到 FragmentManager 中使用。 KoinFragmentFactory 也同样。需要在 Activity#onCreate 或 Fragment#onCreate 中调用 setupKoinFragmentFactory(), 将其添加到当前 FragmentManager 中override fun onCreate(savedInstanceState: Bundle?) {     setupKoinFragmentFactory() // 设置到 FragmentManager     super.onCreate(savedInstanceState)     setContentView(R.layout.activity_main) }fun FragmentActivity.setupKoinFragmentFactory(scope: Scope? = null) {     if (scope == null) {         supportFragmentManager.fragmentFactory = get()     } else {         supportFragmentManager.fragmentFactory = KoinFragmentFactory(scope) }需要注意,这个调用必须在 super.onCreate 之前完成,因为 super.onCreate 中会进行 fragment 的重建, 此时就需要用到 FragmentFactory 了Koin 配置完成后,就可以想往常一样将 DetailFagment 添加到 Activity 中了supportFragmentManager.beginTransaction()     .replace(R.id.container, DetailsFragment::class.java, null)     .commit()FragmentTransaction 会自动调用 KoinFragmentFactory#instantiate() 创建DetailsFragment::class.java 对应的 Fragment, 很方便吧?

Android AAB 格式介绍

Google 自8月起要求 Google Play 上架的应用必须采用 AAB 的新格式,对我来说这并非新闻,早在去年12月份官方就提前做了通知:https://android-developers.googleblog.com/2020/11/new-android-app-bundle-and-target-api.html令我惊讶的是,这样一条“旧闻”最近却被炒得沸沸扬扬,原来竟还是因为蹭了鸿蒙的热度:要知道 AAB 的首次亮相是在2018年的 GoogleI/O 上,难道彼时谷歌就遇见到鸿蒙的出现了?不过客观来说,AAB 虽然早已出现,但在国内很少被提及,因此造成部分媒体的错误解读也有情可原。那么本文就为大家做一个关于 AAB 的科普,打消鸿蒙支持者们的顾虑。Android App BundleAndroid App Bundle(简称AAB) 是 Google 2018年推出的一种动态化的打包方式。当应用程序以 AAB 的格式上传 Google Play(或其他支持 AAB 的应用市场)后,可以根据不同用户实现 features 或者 resources 的按需下发。Google Play (简称GP) 目前提供的动态化服务都是基于 AAB 实现的(不少文章说这些服务是 AAB 的,这种说法不严谨,准确的说是 GP 的)Play Feature Delivery(PFD) :借助 AAB 实现 Feature 的按需动态加载,这类似于国内流行的“插件化”技术Play Asset Delivery (PAD) :借助 AAB 实现一些资源素材的按需动态下载,这特别适合一些游戏类APP,无需为了适配所有机型保留全部游戏素材除了游戏资源以外,对于常规资源,AAB 也可以做到按需下发。例如无需同时存在 hdpi、xhdpi 等多套图片,不少 APP 因此在包大小方面有显著提高:更小的包体积意味着更高的装机率,这在用户推广成本激高的今天至关重要:App Bundle 文件格式我们先来看一下 AAB 的文件格式,与传统的 APK 有何不同解压后的 AAB 中的内容和 APK 很相似,但又有不少区别:aab fielsdescriptionsbase/feature1/feature2base 是应用的基本功能,feature 承载各 DynamicFeature 的内容(后文介绍)manifest.xmlAPK 中只有一个 manifest 且是二进制格式,AAB 会存在于每个模块中e中,且使用 ProtoBuf(pb)格式,便于处理dex与 APK 不同,AAB 将每个模块的 dex 文件存储在各自目录中res/assets/libs该目录与 APK 中相同,当上传 AAB 时,GP 会检查这些目录并仅打包满足目标设备需要的最小文件resources.pb类似于 resource.arsc 文件,是一个资源索引表,其中描述了应用程序内部存在的资源和目标的细节,可用于 GP 针对不同设备配置 APK。assets.pb相当于应用程序 assets 的资源表,可用于 GP 针对不同设备配置 APK。例如将 assets 资源放到 assets/languages#lang_xx 或 assets/i18n#lang_xx 路径下,则会根据语言配置下发 assets 资源。native.pb 这相当于native库的资源表,可用于 GP 针对不同设备配置 APK后三个.bp文件是 AAB 格式的重要部分,它们描述了 APP 的不同服务目标,动态下发根据这些目标从 drawable/hdpi、lib/armeabi-v7a 或者 values/es 等路径中组织不同资源进行下发。Split APKsSplit APKs 机制是 AAB 实现动态下发的基础,它允许将一个庞大的 APK 按不同维度拆分成独立的 APK,当用户在 GP 下载应用时,Android Framework 通过 IPC 与 GP 通信,为当前设备匹配并下载最小构成的 APK, 这只在 Android 5.0 以上的设备才有效。AAB 上传后,GP 通过分析找出所有设备的共同资源, 生成一个 Base APK,当用户下载应用时,Base APK 将被首先安装。GP 又根据language、density、abi 等三个维度,分别生成 Configuration APKs(Splits), Splits 与 Base 共享 versionCode 、packageName等,在进程管理器中以一个应用的形式存在。当用户从市场下载应用时,GP 根据设备类型,为其下发不同的 Splits,实现最小化下发。如下图,针对三种不同设备下发不同 Splits当用户的设备发生 Configuration Changed (比如切换了系统语言)时,GP 会下发新的 Splits 到手机,如果此时手机不在线会等待下次上线时自动下发。Split APKs 的这种动态下发只能用于 Android 5.0 以上设备,对于更旧的设备,AAB 会根据 这些 Splits 的矩阵生成多个 Standalone 的 APK,虽然缺少了动态下发的能力必须一次安装到位,但是相对于传统 APK 仍然减小了一定包大小。作为开发者,我们无需关心这些具体的下发策略,只需要向市场上传一个 AAB ,后续就交给 FW 和 GP 去处理了。创建 App Bundle打包 AAB使用 Android Studio 可以方便地打包 AAB此外,也可以使用 Gradle 命令打包,这更适用于一些 CI 流程中。如下使用 gradle 打包一个 debug 版的 AAB./gradlew :base:bundleDebug如果要生成 release 的 AAB 需要配置签名,与 APK 的配置方式是一样的。AAB 默认会为三种 Configurations 都生成 Splits,当然你可以根据需求自己配置:bundle { language { enableSplit = false density { enableSplit = true abi { enableSplit = true }上传应用市场生成 AAB 后就可以上传应用市场了,GP 中上传 AAB 和 APK 的入口在一起,当然 8 月以后就没有 APK 的上传入口了。AAB 上传后,通过后台可以查看其详细信息例如可以查看 AAB 支持的屏幕密度,以及包体积的减少等信息Bundle ToolAAB 是无法直接安装到手机的,如果想本地对 AAB 做测试,需要将 AAB 转成 APK,这需要使用 Google 官方提供的 Bundletool 工具Bundletool 可以获取当前设备信息bundletool get-device-spec --output=/tmp/device-spec.json设备的 Configurations 信息输出到指定 json 中{ "supportedAbis": ["arm64-v8a", "armeabi-v7a", "armeabi"], "supportedLocales": ["zh-CN"], "deviceFeatures" : // ... "screenDensity": 480, "sdkVersion": 28 }Bundletool 根据 json 生成 .apks 中间文件bundletool build-apks --bundle=/MyApp/my_app.aab --output=/MyApp/my_app.apks --ks=/MyApp/keystore.jks --ks-pass=file:/MyApp/keystore.pwd --ks-key-alias=MyKeyAlias --key-pass=file:/MyApp/key.pwd --device-spec=file:device-spec.json apks 的产物分为 splits 和 standalones 两个目录,splits 是按照 Configuration维度拆分的 Split APKs,必须依赖 base.apk 一起安装;standalone 必须独立安装,这是为了兼容 Android 5.0 以下的版本。toc.pb 是 apks 的存档清单,包含 APK 集合信息的描述文件然后再根据 json 文件,从 apks 中提取 apk :bundletool extract-apks --apks=${apksPath} --device-spec={deviceSpecJsonPath} --output-dir={outputDirPath} 最后,通过 Bundletool 将 apk 安装到手机上。 注意该命令实际安装 apk 并非 apksbundletool install-apks --apks=/MyApp/my_app.apks 总结一下 Bundletool 生成 APK 的整体流程:创建 Dynamic Feature除了下发 Configuration APKs,还可以以业务模块为单元“插件化”地动态下发,也就是所谓的 Dynamic Features(简称 DF)IDE 中选择 New 一个 DF 的 Module:点击 next,选择 DF 的安装时机,例如一次安装到位或是按需安装创建好的 DynamicFeature Module, 目录和一个普通的 Gadle Module 类似但是 build.gradle 中 plugin 有所不同:com.android.dynamic-featureplugins { id 'com.android.dynamic-feature' id 'kotlin-android' }build.gradle 中也无需配置 versionCode、versionName、signConfig等,DF 本质上也是 Split APKs,所以共享 Base APK 的这些信息。此时再打开 app/ 的 build.gradle,会发现多了如下配置dynamicFeatures = [':dynamicfeature']这是 APP 当前支持的所有 DF 的声明最后,DF 的 Manifeset 也发生了变化:<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:dist="http://schemas.android.com/apk/distribution" package="com.github.dynamicfeature"> <dist:module dist:instant="false" dist:title="@string/title_dynamicfeature"> <dist:delivery> <dist:on-demand /> </dist:delivery> <dist:fusing dist:include="true" /> </dist:module> </manifest>dist:delivery: 在创建 Dynmaic Feature 的 Module 时选择的下发方式, onDemand 表示方式为按需下发title:当用户确认下载 Module 时,标识相关名称fusing include:设为 ture,意味着 5.0 以下的设备可以以 multi-APK 的形式安装此 Feature,此时必须设置为 onDemand 方式。安装 Dynamic Feature当应用支持 DF 之后,我可以按需的请求并安装这些 Features,这需要集成 Play Core SDKimplementation 'com.google.android.play:core:$latest_version'Play Core 允许用户通过交互的方式请求 DF 的下载安装,并监听下载状态发起下载请求DF 的下载需要借助 SplitInstallManagerSplitInstallManager splitInstallManager = SplitInstallManagerFactory.create(context);创建 SplitInstallRequest, 请求下载 Module//动态请求模块 SplitInstallRequest request = SplitInstallRequest .newBuilder() .addModule("someDynamicModule") .build();addModule() 可以多次调用,添加多个请求的 DF使用 SplitInstallManager 启动 Request 进行请求,并设置回调监听为下载状态splitInstallManager .startInstall(request) .addOnSuccessListener { } .addOnFailureListener { } .addOnCompleteListener { }startInstall() 调用后会立即发起请求。另外还可以使用 deferredInstall 延迟请求, 当应用切到后台启动时才开始请求。splitInstallManager .deferredInstall(Arrays.asList("someDynamicModule"));除了请求指定 DF 以外,也可以请求指定的资源,比如安装语言资源SplitInstallRequest request = SplitInstallRequest.newBuilder() .addLanguage(Locale.forLanguageTag(sharedPrefs.getString(LANGUAGE_SELECTION))) .build();发起请求后,会返回一个 Int 值作为 session ID,通过调用 cancelInstall(Int), 可以取消当前的下载。发起请求后,可能无法正常建立链接,此时会返回错误信息如下ErrorDescriptionsACCESS_DENIED鉴于当前设备的某些原因,无法下载ACTIVE_SESSIONS_LIMIT_EXCEEDED当前应用的请求 session 太多API_NOT_AVAILABLE请求 API 目前无法使用INCOMPATIBLE_WITH_EXISTING_SESSION请求的 session 中包含了已经请求中的 DFINTERNAL_ERROR内部错误INVALID_REQUEST无效请求MODULE_UNAVAILABLE请求的 DF 不存在NETWORK_ERROR网络错误NO_ERROR无法获得错误信息SERVICE_DIED服务无响应SESSION_NOT_FOUND无法获取被请求的 session下载安装成功建立了连接后,便进入下载、安装阶段。使用 SplitInstallStateUpdatedListener 能够监听下载安装的状态,可以根据这些状态为对下载进度等进行用户提示val stateListener = SplitInstallStateUpdatedListener { state -> when (state.status()) { PENDING -> { } DOWNLOADING -> { } DOWNLOADED -> { } INSTALLED -> { } INSTALLING -> { } REQUIRES_USER_CONFIRMATION -> { } FAILED -> { } CANCELING -> { } CANCELED -> { } splitInstallManager.registerListener(stateListener)StateDescriptionCANCELED下载被取消CANCELING下载取消中DOWNLOADED下载完成,但是尚未安装DOWNLOADING下载即将完成FAILED下载或安装失败INSTALLED成功安装INSTALLING安装中PENDING下载等待中REQUIRES_USER_CONFIRMATION等待用户确认UNKNOWN未知卸载模块成功安装后,通过 getInstalledModules 可以获取所有已安装的 Moduleval installedModules = splitInstallManager.installedModules另外,通过 deferredUninstall 可以对 DF 进行指定卸载splitInstallManager .deferredUninstall(listOf("someDynamicModule")) .addOnSuccessListener { } .addOnFailureListener { } .addOnCompleteListener { } AAB 使用效果根据 Google 官方的数据,AAB 比 APK 的包大小平均会减小 20% ,这同时意味着节省了 20% 的下载流量。 以 Twitter 为例,采用 AAB 之后language 相关资源节省 95%density 相关的 Splits 节省 45%abi 相关资源节省 20%除了包大小方面的优势以外,使用 AAB 在开发效率上也有收益,无需再针对不同目标点设备,配置多个 Flavor、生成多个 APK 并分别上传,只要上传一个 AAB,剩下的事情交由应用市场去做就好了。国内的 AAB 使用Qigsaw 是爱奇艺提供的一套基于 Android App Bundle 的动态化方案,无需 Google Play Service 即可在国内体验 Android App Bundle 开发工具。它支持动态下发插件 APK,采用单类加载器方式,让应用能够在不重新安装的情况下实现动态安装插件。此外,华为应用市场也早就支持了 AAB 的上传和动态下发,所以不要再说 AAB 是打压华为的产物了 https://developer.huawei.com/consumer/cn/doc/distribution/app/agc-help-releasebundle-0000001100316672

面试必备:Kotlin 线程同步的 N 种方法

面试的时候经常会被问及多线程同步的问题,例如:“ 现有 Task1、Task2 等多个并行任务,如何等待全部执行完成后,执行 Task3。”在 Kotlin 中我们有多种实现方式,本文将所有这些方式做了整理,建议收藏。1. Thread.join <br/>2. Synchronized <br/>3. ReentrantLock<br/>4. BlockingQueue<br/>5. CountDownLatch<br/>6. CyclicBarrier<br/>7. CAS<br/>8. Future<br/>9. CompletableFuture<br/>10. Rxjava<br/>11. Coroutine<br/>12. Flow我们先定义三个Task,模拟上述场景, Task3 基于 Task1、Task2 返回的结果拼接字符串,每个 Task 通过 sleep 模拟耗时:val task1: () -> String = { sleep(2000) "Hello".also { println("task1 finished: $it") } val task2: () -> String = { sleep(2000) "World".also { println("task2 finished: $it") } val task3: (String, String) -> String = { p1, p2 -> sleep(2000) "$p1 $p2".also { println("task3 finished: $it") } }1. Thread.join()Kotlin 兼容 Java,Java 的所有线程工具默认都可以使用。其中最简单的线程同步方式就是使用 Thread 的 join() :@Test fun test_join() { lateinit var s1: String lateinit var s2: String val t1 = Thread { s1 = task1() } val t2 = Thread { s2 = task2() } t1.start() t2.start() t1.join() t2.join() task3(s1, s2) }2. Synchronized使用 synchronized 锁进行同步 @Test fun test_synchrnoized() { lateinit var s1: String lateinit var s2: String Thread { synchronized(Unit) { s1 = task1() }.start() s2 = task2() synchronized(Unit) { task3(s1, s2) }但是如果超过三个任务,使用 synchrnoized 这种写法就比较别扭了,为了同步多个并行任务的结果需要声明n个锁,并嵌套n个 synchronized。3. ReentrantLockReentrantLock 是 JUC 提供的线程锁,可以替换 synchronized 的使用 @Test fun test_ReentrantLock() { lateinit var s1: String lateinit var s2: String val lock = ReentrantLock() Thread { lock.lock() s1 = task1() lock.unlock() }.start() s2 = task2() lock.lock() task3(s1, s2) lock.unlock() }ReentrantLock 的好处是,当有多个并行任务时是不会出现嵌套 synchrnoized 的问题,但仍然需要创建多个 lock 管理不同的任务,4. BlockingQueue阻塞队列内部也是通过 Lock 实现的,所以也可以达到同步锁的效果 @Test fun test_blockingQueue() { lateinit var s1: String lateinit var s2: String val queue = SynchronousQueue<Unit>() Thread { s1 = task1() queue.put(Unit) }.start() s2 = task2() queue.take() task3(s1, s2) }当然,阻塞队列更多是使用在生产/消费场景中的同步。5. CountDownLatchJUC 中的锁大都基于 AQS 实现的,可以分为独享锁和共享锁。ReentrantLock 就是一种独享锁。相比之下,共享锁更适合本场景。 例如 CountDownLatch,它可以让一个线程一直处于阻塞状态,直到其他线程的执行全部完成: @Test fun test_countdownlatch() { lateinit var s1: String lateinit var s2: String val cd = CountDownLatch(2) Thread() { s1 = task1() cd.countDown() }.start() Thread() { s2 = task2() cd.countDown() }.start() cd.await() task3(s1, s2) }共享锁的好处是不必为了每个任务都创建单独的锁,即使再多并行任务写起来也很轻松6. CyclicBarrierCyclicBarrier 是 JUC 提供的另一种共享锁机制,它可以让一组线程到达一个同步点后再一起继续运行,其中任意一个线程未达到同步点,其他已到达的线程均会被阻塞。与 CountDownLatch 的区别在于 CountDownLatch 是一次性的,而 CyclicBarrier 可以被重置后重复使用,这也正是 Cyclic 的命名由来,可以循环使用 @Test fun test_CyclicBarrier() { lateinit var s1: String lateinit var s2: String val cb = CyclicBarrier(3) Thread { s1 = task1() cb.await() }.start() Thread() { s2 = task1() cb.await() }.start() cb.await() task3(s1, s2) }7. CASAQS 内部通过自旋锁实现同步,自旋锁的本质是利用 CompareAndSwap 避免线程阻塞的开销。因此,我们可以使用基于 CAS 的原子类计数,达到实现无锁操作的目的。 @Test fun test_cas() { lateinit var s1: String lateinit var s2: String val cas = AtomicInteger(2) Thread { s1 = task1() cas.getAndDecrement() }.start() Thread { s2 = task2() cas.getAndDecrement() }.start() while (cas.get() != 0) {} task3(s1, s2) }while 循环空转看起来有些浪费资源,但是自旋锁的本质就是这样,所以 CAS 仅仅适用于一些cpu密集型的短任务同步。volatile看到 CAS 的无锁实现,也许很多人会想到 volatile, 是否也能实现无锁的线程安全? @Test fun test_Volatile() { lateinit var s1: String lateinit var s2: String Thread { s1 = task1() cnt-- }.start() Thread { s2 = task2() cnt-- }.start() while (cnt != 0) { task3(s1, s2) }注意,这种写法是错误的volatile 能保证可见性,但是不能保证原子性,cnt-- 并非线程安全,需要加锁操作8. Future上面无论有锁操作还是无锁操作,都需要定义两个变量s1、s2记录结果非常不方便。 Java 1.5 开始,提供了 Callable 和 Future ,可以在任务执行结束时返回结果。@Test fun test_future() { val future1 = FutureTask(Callable(task1)) val future2 = FutureTask(Callable(task2)) Executors.newCachedThreadPool().execute(future1) Executors.newCachedThreadPool().execute(future2) task3(future1.get(), future2.get()) }通过 future.get(),可以同步等待结果返回,写起来非常方便9. CompletableFuturefuture.get() 虽然方便,但是会阻塞线程。 Java 8 中引入了 CompletableFuture ,他实现了 Future 接口的同时实现了 CompletionStage 接口。 CompletableFuture 可以针对多个 CompletionStage 进行逻辑组合、实现复杂的异步编程。 这些逻辑组合的方法以回调的形式避免了线程阻塞:@Test fun test_CompletableFuture() { CompletableFuture.supplyAsync(task1) .thenCombine(CompletableFuture.supplyAsync(task2)) { p1, p2 -> task3(p1, p2) }.join() }<br/>10. RxJavaRxJava 提供的各种操作符以及线程切换能力同样可以帮助我们实现需求:zip 操作符可以组合两个 Observable 的结果;subscribeOn 用来启动异步任务@Test fun test_Rxjava() { Observable.zip( Observable.fromCallable(Callable(task1)) .subscribeOn(Schedulers.newThread()), Observable.fromCallable(Callable(task2)) .subscribeOn(Schedulers.newThread()), BiFunction(task3) ).test().awaitTerminalEvent() }11. Coroutine前面讲了那么多,其实都是 Java 的工具。 Coroutine 终于算得上是 Kotlin 特有的工具了:@Test fun test_coroutine() { runBlocking { val c1 = async(Dispatchers.IO) { task1() val c2 = async(Dispatchers.IO) { task2() task3(c1.await(), c2.await()) }写起来特别舒服,可以说是集前面各类工具的优点于一身。12. FlowFlow 就是 Coroutine 版的 RxJava,具备很多 RxJava 的操作符,例如 zip: @Test fun test_flow() { val flow1 = flow<String> { emit(task1()) } val flow2 = flow<String> { emit(task2()) } runBlocking { flow1.zip(flow2) { t1, t2 -> task3(t1, t2) }.flowOn(Dispatchers.IO) .collect() }flowOn 使得 Task 在异步计算并发射结果。总结上面这么多方式,就像茴香豆的“茴”字的四种写法,没必要都掌握。作为结论,在 Kotlin 上最好用的线程同步方案首推协程!

告别KAPT!使用 KSP 为 Kotlin 编译提速

今年初 Android 发布了 Kotlin Symbol Processing(KSP)的首个 Alpha 版,几个月过去,KSP 已经更新到 Beta3 了, 目前 API 已经基本稳定,相信距离稳定版发布也不会很远了。为什么使用 KSP ?不少人吐槽 Kotlin 的编译速度,KAPT 便是拖慢编译的元凶之一。很多库都会使用注解简化模板代码,例如 Room、Dagger、Retrofit 等,Kotlin 代码使用 KAPT 处理注解。 KAPT 本质上是基于 APT 工作的,APT 只能处理 Java 注解,因此需要先生成 APT 可解析的 stub (Java代码),这拖慢了 Kotlin 的整体编译速度。KSP 正是在这个背景下诞生的,它基于 Kotlin Compiler Plugin(简称KCP) 实现,不需要生成额外的 stub,编译速度是 KAPT 的 2 倍以上KSP 与 KCPKotlin Compiler Plugin 在 kotlinc 过程中提供 hook 时机,可以再次期间解析 AST、修改字节码产物等,Kotlin 的不少语法糖都是 KCP 实现的,例如 data class、 @Parcelize、kotlin-android-extension 等, 如今火爆的 Compose 其编译期工作也是借助 KCP 完成的。理论上 KCP 的能力是 KAPT 的超集,可以替代 KAPT 以提升编译速度。但是 KCP 的开发成本太高,涉及 Gradle Plugin、Kotlin Plugin 等的使用,API 涉及一些编译器知识的了解,一般开发者很难掌握。一个标准 KCP 的开发涉及以下诸多内容:Plugin:Gradle 插件用来读取 Gradle 配置传递给 KCP(Kotlin Plugin)Subplugin:为 KCP 提供自定义 KP 的 maven 库地址等配置信息CommandLineProcessor:将参数转换为 KP 可识别参数ComponentRegistrar:注册 Extension 到 KCP 不同流程中Extension:实现自定义的 KP 功能KSP 简化了上述流程,开发者无需了解编译器工作原理,处理注解等成本像 KAPT 一样低。KSP 与 KAPTKSP 顾名思义,在 Symbols 级别对 Kotlin 的 AST 进行处理,访问类、类成员、函数、相关参数等类型的元素。可以类比 PSI 中的 Kotlin AST一个 Kotlin 源文件经 KSP 解析后的结果如下:KSFile packageName: KSName fileName: String annotations: List<KSAnnotation> (File annotations) declarations: List<KSDeclaration> KSClassDeclaration // class, interface, object simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration classKind: ClassKind primaryConstructor: KSFunctionDeclaration superTypes: List<KSTypeReference> // contains inner classes, member functions, properties, etc. declarations: List<KSDeclaration> KSFunctionDeclaration // top level function simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration functionKind: FunctionKind extensionReceiver: KSTypeReference? returnType: KSTypeReference parameters: List<KSVariableParameter> // contains local classes, local functions, local variables, etc. declarations: List<KSDeclaration> KSPropertyDeclaration // global variable simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration extensionReceiver: KSTypeReference? type: KSTypeReference getter: KSPropertyGetter returnType: KSTypeReference setter: KSPropertySetter parameter: KSVariableParameter KSEnumEntryDeclaration // same as KSClassDeclaration这是 KSP 中的 Kotlin AST 抽象。 类似的, APT/KAPT 中有对 Java 的 AST 抽象,其中能找到一些对应关系,比如 Java 使用 Element 描述包、类、方法或者变量等, KSP 中使用 DeclarationJava/APTKotlin/KSPDescriptionPackageElementKSFile表示一个包程序元素。提供对有关包及其成员的信息的访问ExecuteableElementKSFunctionDeclaration表示某个类或接口的方法、构造方法或初始化程序(静态或实例),包括注释类型元素TypeElementKSClassDeclaration表示一个类或接口程序元素。提供对有关类型及其成员的信息的访问。注意,枚举类型是一种类,而注解类型是一种接口VariableElementKSVariableParameter / KSPropertyDeclaration表示一个字段、enum 常量、方法或构造方法参数、局部变量或异常参数Declaration 之下还有 Type 信息 ,比如函数的参数、返回值类型等,在 APT 中使用 TypeMirror 承载类型信息 ,KSP 中详细的能力由 KSType 实现。KSP 的开发流程和 KAPT 类似:解析源码AST生成代码生成的代码与源码一起参与 Kotlin 编译需要注意 KSP 不能用来修改原代码,只能用来生成新代码KSP 入口:SymbolProcessorProviderKSP 通过 SymbolProcessor 来具体执行。SymbolProcessor 需要通过一个 SymbolProcessorProvider 来创建。因此 SymbolProcessorProvider 就是 KSP 执行的入口interface SymbolProcessorProvider { fun create(environment: SymbolProcessorEnvironment): SymbolProcessor }SymbolProcessorEnvironment 获取一些 KSP 运行时的依赖,注入到 Processorinterface SymbolProcessor { fun process(resolver: Resolver): List<KSAnnotated> // Let's focus on this fun finish() {} fun onError() {} }process() 提供一个 Resolver , 解析 AST 上的 symbols。 Resolver 使用访问者模式去遍历 AST。如下,Resolver 使用 FindFunctionsVisitor 找出当前 KSFile 中 top-level 的 function 以及 Class 成员方法:class HelloFunctionFinderProcessor : SymbolProcessor() { val functions = mutableListOf<String>() val visitor = FindFunctionsVisitor() override fun process(resolver: Resolver) { //使用 FindFunctionsVisitor 遍历访问 AST resolver.getAllFiles().map { it.accept(visitor, Unit) } inner class FindFunctionsVisitor : KSVisitorVoid() { override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { //访问 Class 节点 classDeclaration.getDeclaredFunctions().map { it.accept(this, Unit) } override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) { // 访问 function 节点 functions.add(function) override fun visitFile(file: KSFile, data: Unit) { //访问 file file.declarations.map { it.accept(this, Unit) } }KSP API 示例举几个例子看一下 KSP 的 API 是如何工作的访问类中的所有成员方法fun KSClassDeclaration.getDeclaredFunctions(): List<KSFunctionDeclaration> { return this.declarations.filterIsInstance<KSFunctionDeclaration>() }判断一个类或者方法是否是局部类或局部方法fun KSDeclaration.isLocal(): Boolean { return this.parentDeclaration != null && this.parentDeclaration !is KSClassDeclaration }判断一个类成员是否对其他Declaration可见fun KSDeclaration.isVisibleFrom(other: KSDeclaration): Boolean { return when { // locals are limited to lexical scope this.isLocal() -> this.parentDeclaration == other // file visibility or member this.isPrivate() -> { this.parentDeclaration == other.parentDeclaration || this.parentDeclaration == other this.parentDeclaration == null && other.parentDeclaration == null && this.containingFile == other.containingFile this.isPublic() -> true this.isInternal() && other.containingFile != null && this.containingFile != null -> true else -> false }获取注解信息// Find out suppressed names in a file annotation: // @file:kotlin.Suppress("Example1", "Example2") fun KSFile.suppressedNames(): List<String> { val ignoredNames = mutableListOf<String>() annotations.forEach { if (it.shortName.asString() == "Suppress" && it.annotationType.resolve()?.declaration?.qualifiedName?.asString() == "kotlin.Suppress") { it.arguments.forEach { (it.value as List<String>).forEach { ignoredNames.add(it) } return ignoredNames }代码生成的示例最后看一个相对完整的例子,用来替代APT的代码生成@IntSummable data class Foo( val bar: Int = 234, val baz: Int = 123 )我们希望通过KSP处理@IntSummable,生成以下代码public fun Foo.sumInts(): Int { val sum = bar + baz return sum }Dependencies开发 KSP 需要添加依赖:plugins { kotlin("jvm") version "1.4.32" repositories { mavenCentral() google() dependencies { implementation(kotlin("stdlib")) implementation("com.google.devtools.ksp:symbol-processing-api:1.5.10-1.0.0-beta01") }IntSummableProcessorProvider我们需要一个入口的 Provider 来构建 Processorimport com.google.devtools.ksp.symbol.* class IntSummableProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { return IntSummableProcessor( options = environment.options, codeGenerator = environment.codeGenerator, logger = environment.logger }通过 SymbolProcessorEnvironment 可以为 Processor 注入了 options、CodeGenerator、logger 等所需依赖IntSummableProcessorclass IntSummableProcessor() : SymbolProcessor { private lateinit var intType: KSType override fun process(resolver: Resolver): List<KSAnnotated> { intType = resolver.builtIns.intType val symbols = resolver.getSymbolsWithAnnotation(IntSummable::class.qualifiedName!!).filterNot{ it.validate() } symbols.filter { it is KSClassDeclaration && it.validate() } .forEach { it.accept(IntSummableVisitor(), Unit) } return symbols.toList() } builtIns.intType 获取到 kotlin.Int 的 KSType, 在后面需要使用。getSymbolsWithAnnotation 获取注解为 IntSummable 的 symbols 列表当 symbol 是 Class 时,使用 Visitor 对其进行处理IntSummableVisitorVisitor 的接口一般如下,D 和 R 代表 Visitor 的输入和输出,interface KSVisitor<D, R> { fun visitNode(node: KSNode, data: D): R fun visitAnnotated(annotated: KSAnnotated, data: D): R // etc. }我们的需求没有输入输出,所以实现KSVisitorVoid即可,本质上是一个 KSVisitor<Unit, Unit>:inner class Visitor : KSVisitorVoid() { override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { val qualifiedName = classDeclaration.qualifiedName?.asString() //1. 合法性检查 if (!classDeclaration.isDataClass()) { logger.error( "@IntSummable cannot target non-data class $qualifiedName", classDeclaration return if (qualifiedName == null) { logger.error( "@IntSummable must target classes with qualified names", classDeclaration return //2. 解析Class信息 //... //3. 代码生成 //... private fun KSClassDeclaration.isDataClass() = modifiers.contains(Modifier.DATA) }如上,我们判断这个Class是不是data class、其类名是否合法解析Class信息接下来需要获取 Class 中的相关信息,用于我们的代码生成:inner class IntSummableVisitor : KSVisitorVoid() { private lateinit var className: String private lateinit var packageName: String private val summables: MutableList<String> = mutableListOf() override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { //1. 合法性检查 //... //2. 解析Class信息 val qualifiedName = classDeclaration.qualifiedName?.asString() className = qualifiedName packageName = classDeclaration.packageName.asString() classDeclaration.getAllProperties() .forEach { it.accept(this, Unit) if (summables.isEmpty()) { return //3. 代码生成 //... override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) { if (property.type.resolve().isAssignableFrom(intType)) { val name = property.simpleName.asString() summables.add(name) }通过 KSClassDeclaration 获取了className, packageName,以及 Properties 并将其存入 summablesvisitPropertyDeclaration 中确保 Property 必须是 Int 类型,这里用到了前面提到的 intType代码生成收集完 Class 信息后,着手代码生成。 我们引入 KotlinPoet 帮助我们生成 Kotlin 代码dependencies { implementation("com.squareup:kotlinpoet:1.8.0") }override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { //1. 合法性检查 //... //2. 解析Class信息 //... //3. 代码生成 if (summables.isEmpty()) { return val fileSpec = FileSpec.builder( packageName = packageName, fileName = classDeclaration.simpleName.asString() ).apply { addFunction( FunSpec.builder("sumInts") .receiver(ClassName.bestGuess(className)) .returns(Int::class) .addStatement("val sum = ${summables.joinToString(" + ")}") .addStatement("return sum") .build() }.build() codeGenerator.createNewFile( dependencies = Dependencies(aggregating = false), packageName = packageName, fileName = classDeclaration.simpleName.asString() ).use { outputStream -> outputStream.writer() .use { fileSpec.writeTo(it) }使用 KotlinPoet 的 FunSpec 生成 function 代码前面SymbolProcessorEnvironment 提供的CodeGenerator用来创建文件,并写入生成的FileSpec代码总结通过 IntSummable 的例子可以看到 KSP 完全可以替代 APT/KAPT 进行注解处理,且性能更出色。目前,已有不少使用 APT 的三方库增加了对 KSP 的支持LibraryStatusTracking issue for KSPRoomExperimentally supported MoshiExperimentally supported KotshiExperimentally supported LyricistExperimentally supported Auto FactoryNot yet supportedLinkDaggerNot yet supportedLinkHiltNot yet supportedLinkGlideNot yet supportedLinkDeeplinkDispatchNot yet supportedLink将 KAPT 替换为 KSP 也非常简单,以 Moshi 为例当然,也可以在项目中同时使用 KAPT 和 KSP ,他们互不影响。KSP 取代 KAPT 的趋势越来越明显,果你的项目也处理注解的需求,不妨试试 KSP ?https://github.com/google/ksp

详解 RxJava 的 Disposable

关于 Disposable任何订阅者模式的代码,都需要注意注册与注销的配对出现,否则会出现内存泄漏。RxJava2 提供了 Disposable( RxJava1 中是 Subscription),在适当时机取消订阅、截断数据流。当在 Android 中使用时尤其要注意,避免内存泄露。private CompositeDisposable compositeDisposable = new CompositeDisposable(); @Override public void onCreate() { compositeDisposable.add(backendApi.loadUser() .subscribe(this::displayUser, this::handleError)); @Override public void onDestroy() { compositeDisposable.clear(); }上面例子展示了在 Activity 等 LifecycleOwner 中的一般做法:使用 CompositeDisposable 收集所有的 Disposable 句柄,而后在 onDestroy 中调用 clear 统一注销。clear 最终调用的是各个 Disposable 的 dispose 方法:public interface Disposable { void dispose(); boolean isDisposed(); }当然,除了手动调用 dispose,也有一些自动框架可供使用, 如 RxLifecycle 、uber 的 AutoDispose 等, 但最终都要调用到 Disposable 的 dispose() 。dispose 实现原理先看一段代码:Disposable disposable = Observable.create( (ObservableOnSubscribe<Integer>) observableEmitter -> { for (int i = 1; i <= 3; i++) { observableEmitter.onNext(i); .takeUntil(integer -> integer < 3) .subscribe();当调用 disposable.dispose(); 时,代码如何执行?先卖个关子,文章最后揭晓答案Disposable 是一个 Observer调用 Observable.subscribe(...) 返回的 Disposable 本质是一个 LambdaObserverpublic final Disposable subscribe( @NonNull Consumer<? super T> onNext, @NonNull Consumer<? super Throwable> onError, @NonNull Action onComplete) { LambdaObserver<T> ls = new LambdaObserver<>( onNext, onError, onComplete, Functions.emptyConsumer()); subscribe(ls); return ls; //return as a Disposable }LambdaObserver 集众多接口于一身public final class LambdaObserver<T> extends AtomicReference<Disposable> implements Observer<T>, Disposable首先,是一个 Observer,被subscribe()后,通过onNext发射数据;其次,是一个 Disposable,对外提供 dispose 方法;最后,通过 AtomicReference,确保 dispose 线程安全的执行@Override public void dispose() { DisposableHelper.dispose(this); public static boolean dispose(AtomicReference<Disposable> field) { Disposable current = field.get(); Disposable d = DISPOSED; if (current != d) { current = field.getAndSet(d); if (current != d) { if (current != null) { current.dispose(); return true; return false; }原子地设置 DISPOSED, 确保 AtomicReference 中的 Disposable 的 dispose 一定被调用,有且仅有一次。onSubscribe 中传递 DisposableAtomicReference 的 value 是在 Observer.onSubscribe 中被赋值的:@Override public void onSubscribe(Disposable d) { if (DisposableHelper.setOnce(this, d)) { //设置 value try { onSubscribe.accept(this); } catch (Throwable ex) { }那么 Observer.onSubscribe 又是何时被调用呢?RxJava 的操作符都是一个 Observable 实现。操作符链式调用的本质就创建 Observable 并通过 subscribe 依次订阅。 subscribe 内部会用 subscribeActual ,这是每个操作符都必须实现的方法。看一下 Observabel.create 的 subscribeActual :调用 Observer.onSubscrie(), 将当前 Disposable 作为 parent 传递给下游protected void subscribeActual(Observer<? super T> observer) { CreateEmitter<T> parent = new CreateEmitter<>(observer); // CreateEmitter是一个Diposable observer.onSubscribe(parent); // Observer.onSubscrie() try { source.subscribe(parent); } catch (Throwable ex) { }Observer 关联上下游除 create、subscribe 这样的终端操作符以外,大部分的操作符的 Observer 结构如下: /** The downstream subscriber. */ protected final Observer<? super R> downstream; /** The upstream subscription. */ protected Disposable upstream; public final void onSubscribe(Disposable d) { this.upstream = d; downstream.onSubscribe(this); public void dispose() { upstream.dispose(); Observer 持有上下游对象:upstream 和 downstreamonSubscribe 向下递归调用dipose 向上递归调用在链式订阅中,向下游订阅 Observer 的同时,也关联了上游的 Disposable(Observer)我们在最下端调用 subscribe 时,调用链上的 Observer 会建立上下游关联,当我们在下游调用 dispose 时,最终会递归调用到顶端(create)的 dispose再看takeUntil的例子根据上述分析,在回顾一下最初 takeUntil 的例子。前面说过所有的操作符都是 Observable:takeUntil 对应的Observable: ObservableTakeUntilPredicate;create 对应的Observable: ObservableCreatesubscribe 调用链如下:随着 onSubscribe 的调用,Disposable 也建立了如下引用链:当我们调用 dispose 方法时,通过引用链递会最终调用到 CreateEmitter 的 dispose。由于 CreateEmitter 将 AtomicReference 的 value 设为 DISPOSED后续,onNext 中判断状态,当为 DISPOSED 时,数据流停止发射@Override public void onNext(T t) { if (!isDisposed()) { //是否为DISPOSED observer.onNext(t); }关于onComplete通过下面的测试发现当 onComplete 调用后会,会自动调用 dispose。@Test public void testDisposed(){ boolean isDisposed = false; TestObserver<Integer> to = Observable.<Integer>create(subscriber -> { subscriber.setDisposable(new Disposable() { @Override public boolean isDisposed() { return isDisposed; @Override public void dispose() { isDisposed = true; subscriber.onComplete(); }).test(); to.assertComplete(); assertTrue(isDisposed); }果然,ObservableEmitter 的 onComplete 中调用了 dispose: public void onComplete() { if (!isDisposed()) { try { observer.onComplete(); } finally { dispose(); }关于内存泄漏调用dipose确实可以终止数据流,但是不等于没有内存泄露。查看 ObservableCreate 的源码可知,dispose只是简单地设置了 DISPOSED 状态,Observe 中关联的上下游对象并没有释放。所以当订阅了静态的 Observable 时,无法避免内存泄漏。但是当订阅一个 Subject 时,dispose 确实可以有效释放对象,避免内存泄漏:public void dispose() { if (super.tryDispose()) { parent.remove(this); //对象删除 }关于 dispose 的实时性前面分析知道,对于终端操作符 create、subscribe 等,其 Observer 在 dispose 时会标记当前状态为 DISPOSED。但对于其他操作符的 dispose 只是递归向上调用 parent 的 dispose 而已,并没有 DISPOSED 状态的设置,也就不会拦截发射中的数据。调用dispose后,RxJava数据流不一定会立即停止,大部分操作符在调用 dispose 后,数据依然会发射给下游关于 dispose 的实时性测试,下文可供参考https://medium.com/stepstone-tech/the-curious-case-of-rxjava-disposables-e64ff8a06879

一道面试题:介绍一下 Fragment 间的通信方式?

Fragment 间的通信可以借助以下几种方式实现:EventBusActivity(or Parent Fragment)ViewModelResult API1. 基于 EventBus 通信EventBus 的优缺点都很突出。 优点是限制少可随意使用,缺点是限制太少使用太随意。因为 EventBus 会导致开发者在架构设计上“不思进取”,随着项目变复杂,结构越来越混乱,代码可读性变差,数据流的变化难以追踪。所以,规模越大的项目 EvenBus 的负面效果越明显,因此很多大厂都禁止 EventBus 的使用。所以这道题千万不要把 EventBus 作为首选答案,比较得体的回答是:“ EventBus 具备通信能力,但是缺点很突出,大量使用 EventBus 会造成项目难以维护、问题难以定位,所以我不建议在项目中使用 EventBus 进行通信。 ”2. 基于 Activity 或父 Fragment 通信为了迭代更加敏捷,Fragment 从 AOSP 迁移到了 AndroidX ,这导致同时存在着两种包名的 Fragment:android.app.Fragment 和 andoridx.fragment.app.Fragment。虽然前者已经被废弃,但很多历史代码中尚存, 对于老的Fragment,经常依赖基于 Activity 的通信方式,因为其他通信方式大都依赖 AndroidX 。class MainActivity : AppCompatActivity() { val listFragment: ListFragment by lazy { ListFragment() val CreatorFragment: CreatorFragment by lazy { // 构建Fragment的时候设置 Callback,建立通信 CreatorFragment().apply { setOnItemCreated { listFragment.addItem(it) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) supportFragmentManager.beginTransaction().apply { add(R.id.fragmentContainer, listFragment) commit() }如上,在 Activity 或父 Fragment 中创建子Fragment,同时为其设置 Callback此时,Fragment 的创建依赖手动配置,无法在 ConfigurationChangeed 的时候自动恢复重建,所以除了用来处理 android.app.Fragment 的历史遗留代码之外,不推荐使用。3. 基于 ViewModel 通信ViewModel 是目前使用最广泛的通信方式之一,在 Kotlin 中使用时,需要引入fragment-ktxclass ListViewModel : ViewModel() { private val originalList: LiveData<List<Item>>() = ... val itemList: LiveData<List<Item>> = ... fun addItem(item: Item) { //更新 LiveData class ListFragment : Fragment() { // 借助ktx,使用activityViewModels()代理方式获取ViewModel private val viewModel: ListViewModel by activityViewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewModel.itemList.observe(viewLifecycleOwner, Observer { list -> // Update the list UI class CreatorFragment : Fragment() { private val viewModel: ListViewModel by activityViewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { button.setOnClickListener { val item = ... viewModel.addItem(item) 如上,通过订阅 ViewModel 的 LiveData,接受数据变通的通知。因为两个 Fragment 需要共享ViewModel,所以 ViewModel 必须在 Activity 的 Scope 中创建关于 ViewModel 的实现原理,相关文章很多,本文不做赘述了。接下来重点看一下 Result API:4. 基于 Resutl API 通信从Fragment 1.3.0-alpha04起,FragmentManager 新增了 FragmentResultOwner接口,顾名思义 FragmentManager 成为了 FragmentResult 的持有者,可以进行 Fragment 之间的通信。假设需要在 FragmentA 监听 FragmentB 返回的数据,首先在 FragmentA 设置监听override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // setFragmentResultListener 是 fragment-ktx 提供的扩展函数 setFragmentResultListener("requestKey") { requestKey, bundle -> // 监听key为“requestKey”的结果, 并通过bundle获取 val result = bundle.getString("bundleKey") // ... // setFragmentResultListener 是Fragment的扩展函数,内部调用 FragmentManger 的同名方法 public fun Fragment.setFragmentResultListener( requestKey: String, listener: ((requestKey: String, bundle: Bundle) -> Unit) parentFragmentManager.setFragmentResultListener(requestKey, this, listener) 当从 FragmentB 返回结果时:button.setOnClickListener { val result = "result" setFragmentResult("requestKey", bundleOf("bundleKey" to result)) //setFragmentResult 也是 Fragment 的扩展函数,其内部调用 FragmentManger 的同名方法 public fun Fragment.setFragmentResult(requestKey: String, result: Bundle) { parentFragmentManager.setFragmentResult(requestKey, result) }上面的代码可以用下图表示:Result API的原理非常简单,FragmentA 通过 Key 向 FragmentManager 注册 ResultListener,FragmentB 返回 result 时, FM 通过 Key 将结果回调给FragmentA 。需要特别注意的是只有当 FragmentB 返回时,result才会被真正回传,如果 setFragmentResult 多次,则只会保留最后一次结果。生命周期可感知通过梳理源码可以知道Result API是LifecycleAware的源码基于 androidx.fragment:fragment:1.3.0setFragmentResultListener 实现://FragmentManager.java private final Map<String, LifecycleAwareResultListener> mResultListeners = Collections.synchronizedMap(new HashMap<String, LifecycleAwareResultListener>()); public final void setFragmentResultListener(@NonNull final String requestKey, @NonNull final LifecycleOwner lifecycleOwner, @NonNull final FragmentResultListener listener) { final Lifecycle lifecycle = lifecycleOwner.getLifecycle(); LifecycleEventObserver observer = new LifecycleEventObserver() { if (event == Lifecycle.Event.ON_START) { // once we are started, check for any stored results Bundle storedResult = mResults.get(requestKey); if (storedResult != null) { // if there is a result, fire the callback listener.onFragmentResult(requestKey, storedResult); // and clear the result clearFragmentResult(requestKey); if (event == Lifecycle.Event.ON_DESTROY) { lifecycle.removeObserver(this); mResultListeners.remove(requestKey); lifecycle.addObserver(observer); LifecycleAwareResultListener storedListener = mResultListeners.put(requestKey, new LifecycleAwareResultListener(lifecycle, listener, observer)); if (storedListener != null) { storedListener.removeObserver(); listener.onFragmentResult 在 Lifecycle.Event.ON_START 的时候才调用,也就是说只有当 FragmentA 返回到前台时,才会收到结果,这与 LiveData 的逻辑的行为一致,都是 LifecycleAware 的当多次调用 setFragmentResultListener 时, 会创建新的 LifecycleEventObserver 对象, 同时旧的 observer 会随着 storedListener.removeObserver() 从 lifecycle 中移除,不能再被回调。也就是说,对于同一个 requestKey 来说,只有最后一次设置的 listener 有效,这好像也是理所应当的,毕竟不叫 addFragmentResultListener 。setFragmentResult 实现: private final Map<String, Bundle> mResults = Collections.synchronizedMap(new HashMap<String, Bundle>()); public final void setFragmentResult(@NonNull String requestKey, @NonNull Bundle result) { // Check if there is a listener waiting for a result with this key LifecycleAwareResultListener resultListener = mResultListeners.get(requestKey); // if there is and it is started, fire the callback if (resultListener != null && resultListener.isAtLeast(Lifecycle.State.STARTED)) { resultListener.onFragmentResult(requestKey, result); } else { // else, save the result for later mResults.put(requestKey, result); }setFragmentResult 非常简单, 如果当前是 listener 处于前台,则立即回调 setFragmentResult(), 否则,存入 mResults, 等待 listener 切换到前台时再回调。一个 listener 为什么有前台/后台的概念呢,这就是之前看到的 LifecycleAwareResultListener 了, 生命周期可感知是因为其内部持有一个 Lifecycle, 而这个 Lifecycle 其实就是设置 listener 的那个 Fragment private static class LifecycleAwareResultListener implements FragmentResultListener { private final Lifecycle mLifecycle; private final FragmentResultListener mListener; private final LifecycleEventObserver mObserver; LifecycleAwareResultListener(@NonNull Lifecycle lifecycle, @NonNull FragmentResultListener listener, @NonNull LifecycleEventObserver observer) { mLifecycle = lifecycle; mListener = listener; mObserver = observer; public boolean isAtLeast(Lifecycle.State state) { return mLifecycle.getCurrentState().isAtLeast(state); @Override public void onFragmentResult(@NonNull String requestKey, @NonNull Bundle result) { mListener.onFragmentResult(requestKey, result); public void removeObserver() { mLifecycle.removeObserver(mObserver); }可恢复重建mResult 中的数据是会随着 Fragment 的重建可以恢复的,所以 FragmentA 永远不会丢失 FragmentB 返回的结果。当然,一旦 Result 被消费,就会从 mResult 中清除mResults 的保存//FragmentManager.java void restoreSaveState(@Nullable Parcelable state) { //... ArrayList<String> savedResultKeys = fms.mResultKeys; if (savedResultKeys != null) { for (int i = 0; i < savedResultKeys.size(); i++) { mResults.put(savedResultKeys.get(i), fms.mResults.get(i)); }mResults 的恢复Parcelable saveAllState() { // FragmentManagerState implements Parcelable FragmentManagerState fms = new FragmentManagerState(); //... fms.mResultKeys.addAll(mResults.keySet()); fms.mResults.addAll(mResults.values()); //... return fms; }如何选择?Result API 与 ViewModelResultAPI 与 ViewModel + LiveData 有一定相似性,都是生命周期可感知的,都可以在恢复重建时保存数据,那这两种通信方式该如何选择呢?对此,官方给的建议如下:The Fragment library provides two options for communication: a shared ViewModel and the Fragment Result API. The recommended option depends on the use case. To share persistent data with any custom APIs, you should use a ViewModel. For a one-time result with data that can be placed in a Bundle, you should use the Fragment Result API.ResultAPI 主要适用于那些一次性的通信场景(FragmentB返回结果后结束自己)。如果使用 ViewModel,需要上提到的 Fragment 共同的父级 Scope,而 Scope 的放大不利于数据的管理。非一次性的通信场景,由于 FragmentA 和 FragmentB 在通信过程中共存,推荐通过共享 ViewModel 的方式,再借助 LiveData 等进行响应式通信。5. 跨Activity的通信最后看一下,跨越不同 Activity 的 Fragmnet 间的通信跨 Activity 的通信主要有两种方式:startActivityResultActivity Result APIstartActivityResultResult API出现之前,需要通过 startActivityResult 完成通信,这也是 android.app.Fragment 唯一可选的方式。通信过程如下:FragmentA 调用 startActivityForResult() 方法之后,跳转到 ActivityB 中,ActivityB 把数据通过 setArguments() 设置给 FragmentBFragmentB 调用 getActivity().setResult() 设置返回数据,FragmentA 在 onActivityResult() 中拿到数据此时,有两点需要特别注意:不要使用 getActivity().startActivityForResult() , 而是在Fragment中直接调用startActivityForResult()activity 需要重写 onActivityResult,其必须调用 super.onActivityResult(requestCode, resultCode, data)以上两点如果违反,则 onActivityResult 只能够传递到 activity 的,无法传递到 FragmentResult API自1.3.0-alpha02起,Fragment 支持 registerForActivityResult() 的使用,通过 Activity 的 ResultAPI 实现跨 Activity 通信。 FragmentA 设置回调:class FragmentA : Fragment() { private val startActivityLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { } else if (it.resultCode == Activity.RESULT_CANCELED) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) startActivityLauncher.launch(Intent(requireContext(), ActivityB::class.java)) }FragmentB 返回结果button.setOnClickListener { val result = "result" // Use the Kotlin extension in the fragment-ktx artifact setFragmentResult("requestKey", bundleOf("bundleKey" to result)) }了解 Activity Result API 的同学对上述过程应该很熟悉。简单看一下源码。源码基于 androidx.fragment:fragment:1.3.0我们在 FragmentA 中通过创建一个 ActivityResultLauncher,然后调用 launch 启动目标 ActivityB//Fragment # prepareCallInternal return new ActivityResultLauncher<I>() { @Override public void launch(I input, @Nullable ActivityOptionsCompat options) { ActivityResultLauncher<I> delegate = ref.get(); if (delegate == null) { throw new IllegalStateException("Operation cannot be started before fragment " + "is in created state"); delegate.launch(input, options); //... 可以看到,内部调用了delegate.launch, 我们追溯一下 delegate 的出处,即 ref 中设置的 value//Fragment # prepareCallInternal registerOnPreAttachListener(new OnPreAttachedListener() { @Override void onPreAttached() { //ref中注册了一个launcher,来自 registryProvider 提供的 ActivityResultRegistry final String key = generateActivityResultKey(); ActivityResultRegistry registry = registryProvider.apply(null); ref.set(registry.register(key, Fragment.this, contract, callback)); public final <I, O> ActivityResultLauncher<I> registerForActivityResult( @NonNull final ActivityResultContract<I, O> contract, @NonNull final ActivityResultCallback<O> callback) { return prepareCallInternal(contract, new Function<Void, ActivityResultRegistry>() { @Override public ActivityResultRegistry apply(Void input) { //registryProvider 提供的 ActivityResultRegistry 来自 Activity if (mHost instanceof ActivityResultRegistryOwner) { return ((ActivityResultRegistryOwner) mHost).getActivityResultRegistry(); return requireActivity().getActivityResultRegistry(); }, callback); }上面可以看到 ref 中设置的 ActivityResultLauncher 来自 Activity 的 ActivityResultRegistry ,也就说 Fragment 的 launch,最终是由其 mHost 的 Activity 代理的。后续也就是 Activity 的 Result API 的流程了,我们知道 Activity Result API 本质上是基于 startActivityForResult 实现的,具体可以参考这篇文章,本文不再赘述了总结本文总结了 Fragment 通信的几种常见方式,着重分析了 Result API 实现原理。fragment-1.3.0以后,对于一次性通信推荐使用 Result API 替代旧有的 startActivityForResult;响应式通信场景则推荐使用 ViewModel + LiveData (or StateFlow) , 尽量避免使用 EventBus 这类工具进行通信。

Jetpack Compose Runtime : 声明式 UI 的基础

目前有一个正在进行的 Jetpack Compose中文手册 项目,旨在帮助开发者更好的理解和掌握Compose框架,目前仍还在开荒中,欢迎大家关注与加入! 本文已经收录到该手册中,欢迎查阅compose.runtimeJetpack Compose 不只是一个 UI 框架,更是一个通用的 NodeTree 管理引擎。本文介绍 compose.runtime 如何通过 NodeTree 为compose.ui 提供支持。大家知道 Jetpack Compose 不仅限于在 Android 中使用 ,Compose For Desktop、 Compose For Web 等项目也已相继发布,未来也许还会出现 Compose For iOS 。Compose 能够在不同平台上实现相似的声明式UI开发体验,这得益于其分层的设计。Compose 在代码上自下而上依次分为6层:ModulesDescriptioncompose.compiler基于 Kotlin compiler plugin 对 @Composable 进行编译期代码生成和优化compose.runtime提供 NodeTree管理、State管理等,声明式UI的基础运行时compose.uiAndroid设备相关的基础UI能力,例如 layout、measure、drawing、input 等compose.foundation通用的UI组件,包括 Column、Row 等容器、以及各种 Shape 等compose.animation负责动画的实现、提升用户体验compose.material提供符合 Material Design 标准的UI组件其中 compose.runtime 和 compose.compiler 最为核心,它们是支撑声明式UI的基础。Jake Wharton 在他的博客提到 :What this means is that Compose is, at its core, a general-purpose tool for managing a tree of nodes of any type. Well a “tree of nodes” describes just about anything, and as a result Compose can target just about anything.<br/> - https://jakewharton.com/a-jetpack-compose-by-any-other-name/compose.runtime 提供了 NodeTree 管理等基础能力,此部分与平台无关,在此基础上各平台只需实现UI的渲染就是一套完整的声明式UI框架。而 compose.compiler 通过编译期的优化,帮助开发者书写更简单的代码调用 runtime 的能力。<br/>从 Composable 到 NodeTree“Compose 也好,React、Flutter 也好,其代码本质上都是对一颗树形结构的描述。”所谓“数据启动UI",就是当state变化时,重建这颗树型结构并基于这棵NodeTree刷新UI。 当然,处于性能考虑,当 NodeTree 需要重建时,各框架会使用 VirtualDom 、GapBuffer(或称SlotTable) 等不同技术对其进行“差量”更新,避免“全量”重建。compose.runtime 的重要工作之一就是负责 NodeTree 的创建与更新。如上,React 基于 VDOM "差量" 更新右侧的DOM树。Compose 中的 NodeTree对于 OOP 语言,我们通常使用如下方式描述一颗树:fun TodoApp(items: List<TodoItem>): Node { return Stack(Orientation.Vertical).apply { for (item in items) { children.add(Stack(Orientation.Horizontal).apply { children.add(Text(if (item.completed) "x" else " ")) children.add(Text(item.title)) }TodoApp 返回 Node 对象,可以被父 Node 继续 add,循环往复构成一棵完整的树。但是 OOP 的写法模板代码多,不够简洁,且缺乏安全性。返回值 Node 成为句柄被随意引用甚至修改,这破坏了声明式UI中 “不可变性” 的原则,如果 UI 可以随意修改,diff 算法的准确性将无法保证。因此,为了保证 UI 的不可变性,我们设法抹去返回值 Node:fun Composer.TodoApp(items: List<TodoItem>) { Stack(Orientation.Vertical) { for (item in items) { Stack(Orientation.Horizontal) { Text(if (item.completed) "x" else " ") Text(item.title) fun Composer.Stack(orientation:Int, content: Composer.() -> Unit) { emit(StackNode(orientation)) { content() fun Composer.Text() { }通过 Composer 提供的上下文, 将创建的 Node emit 到树上的合适位置。interface Composer { // add node as a child to the current Node, execute // `content` with `node` as the current Node fun emit(node: Node, content: () -> Unit = {}) }Composer.Stack() 作为一个无返回值的函数,使得 NodeTree 的构建从 OOP 方式变为了 FP(函数式编程) 方式。Compose Compiler 的加持compose.compiler 的意义是让 FP 的写法进一步简单,添加一个 @Composable 注解, TodoApp 不必定义成 Composer 的扩展函数, 但是在编译期会修改 TodoApp 的签名,添加 Composer 参数。@Composable fun TodoApp { Stack { for (item in items) { Stack(Orientation.Horizontal){ Text(if (item.completed) "x" else " ") Text(item.title)) }在 Compiler 的加持下,我们可以使用 @Composable 高效地写代码。 抛开语言上的差异不讲,Compose 比 Flutter 写起来要舒服得多。但无论写法上有多少差别,其归根结度还是会转换为对 NodeTree 的操作<br/>NodeTree操作:Applier、ComposeNode、CompositionCompose 的 NodeTree 管理涉及 Applier、Composition 和 Compose Nodes 的工作:Composition 作为起点,发起首次的 composition,通过 Composalbe 的执行,填充 Slot Table,并基于 Table 创建 NodeTree。渲染引擎基于 Compose Nodes 渲染 UI, 每当 recomposition 发生时,都会通过 Applier 对 NodeTree 进行更新。 因此“Composable 的执行过程就是创建 Node 并构建 NodeTree 的过程。 ”Applier:变更 NodeTree 的节点前文提到,出于性能考虑,NodeTree 会使用 “差量” 方式自我更新,而这正是基于 Applier 实现的。 Applier 使用 Visitor 模式遍历树上的 Node ,每种 NodeTree 的运算都需要配套一个 Applier。Applier 提供回调,基于回调我们可以对 NodeTree 进行自定义修改:interface Applier<N> { val current: N // 当前处理的节点 fun onBeginChanges() {} fun onEndChanges() {} fun down(node: N) fun up() fun insertTopDown(index: Int, instance: N) // 添加节点(自顶向下) fun insertBottomUp(index: Int, instance: N)// 添加节点(自底向上) fun remove(index: Int, count: Int) //删除节点 fun move(from: Int, to: Int, count: Int) // 移动节点 fun clear() }insertTopDown 与 insertBottomUp 都用来添加节点,针对不同的树形结构选择不同的添加顺序有助于提高性能。 参考: insertTopDown)insertTopDown(自顶向下)insertBottomUp(自底向上)我们可以实现自定义的 NodeApplier, 如下:class Node { val children = mutableListOf<Node>() class NodeApplier(node: Node) : AbstractApplier<Node>(node) { override fun onClear() {} override fun insertBottomUp(index: Int, instance: Node) {} override fun insertTopDown(index: Int, instance: Node) { current.children.add(index, instance) // `current` is set to the `Node` that we want to modify. override fun move(from: Int, to: Int, count: Int) { current.children.move(from, to, count) override fun remove(index: Int, count: Int) { current.children.remove(index, count) }Applier 需要在 composition/recomposition 过程中被调用。composition 是通过 Composition 中对 Root Composable 的调用发起的,进而调用全部 Composalbe 最终形成NodeTree。Composition:Composalbe 执行的起点fun Composition(applier: Applier<*>, parent: CompositionContext) 创建 Composition对象,参数传入 Applier 和 Recomposerval composition = Composition( applier = NodeApplier(node = Node()), parent = Recomposer(Dispatchers.Main) composition.setContent { // Composable function calls }Recomposer 非常重要,他负责 Compose 的 recomposiiton 。当 NodeTree 首次创建之后,与 state 建立关联,监听 state 的变化发生重组。这个关联的建立是通过 Recomposer 的 “快照系统” 完成的。重组后,Recomposer 通过调用 Applier 完成 NodeTree 的变更 。关于 “快照系统” 以及 Recomposer 的原理可以参考:https://compose.net.cn/principle/snapshot/https://compose.net.cn/principle/recompose_working_principle/Composition#setContent 为后续 Compodable 的调用提供了容器: interface Composition { val hasInvalidations: Boolean val isDisposed: Boolean fun dispose() fun setContent(content: @Composable () -> Unit) }ComposeNode:创建 UiNode 并进行更新理论上每个 Composable 的执行都对应一个 Node 的创建, 但是由于 NodeTree 无需全量重建,所以也不是每次都需要创建新 Node。大多的 Composalbe 都会调用 ComposeNode() 接受一个 factory,仅在必要的时候创建 Node。以 Layout 的实现为例,@Composable inline fun Layout( content: @Composable () -> Unit, modifier: Modifier = Modifier, measurePolicy: MeasurePolicy val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current ComposeNode<ComposeUiNode, Applier<Any>>( factory = ComposeUiNode.Constructor, update = { set(measurePolicy, ComposeUiNode.SetMeasurePolicy) set(density, ComposeUiNode.SetDensity) set(layoutDirection, ComposeUiNode.SetLayoutDirection) skippableUpdate = materializerOf(modifier), content = content }factory:创建 Node 的工厂update:接受 receiver 为 Updater<T>的 lambda,用来更新当前 Node 的属性content:调用子 ComposableComposeNode() 的实现非常简单:inline fun <T, reified E : Applier<*>> ComposeNode( noinline factory: () -> T, update: @DisallowComposableCalls Updater<T>.() -> Unit, noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit, content: @Composable () -> Unit if (currentComposer.applier !is E) invalidApplier() currentComposer.startNode() if (currentComposer.inserting) { currentComposer.createNode(factory) } else { currentComposer.useNode() Updater<T>(currentComposer).update() SkippableUpdater<T>(currentComposer).skippableUpdate() currentComposer.startReplaceableGroup(0x7ab4aae9)//在编译期决定真正的GroupId content() currentComposer.endReplaceableGroup() currentComposer.endNode() }在 composition 过程中,通过 Composer上下文,更新 SlotTable, content()递归创建子 NodeSlotTable 在更新过程中,通过 diff 决定是否需要对 Node 进行 add/update/remove 等操作。 此处的 startNode,useNode,endNode 等就是对 SlotTable 的遍历过程。有关 SlotTable(GapBuffer) 的介绍,可以参考文章:https://compose.net.cn/principle/gap_buffer/SlotTable 的 diff 结果通过 Applier 的回调处理 NodeTree 结构的变化;通过调用 Updater<T>.update() 来处理 Node 属性的变化<br/>Jake wharton 的实验项目 Mosica基于 compose.runtime 可以实现任意一套声明式UI框架。 J神有一个实验性的项目 Mosica,就很好地展示了这一点 :https://github.com/JakeWharton/mosaicfun main() = runMosaic { var count by mutableStateOf(0) setContent { Text("The count is: $count") for (i in 1..20) { delay(250) count = i }上面是 Mosica 中的一个 Counter 的例子。Mosica CompositionrunMosaic() 创建 Composition、Recomposer 和 Applierfun runMosaic(body: suspend MosaicScope.() -> Unit) = runBlocking { //... val job = Job(coroutineContext[Job]) val composeContext = coroutineContext + clock + job val rootNode = BoxNode() //根节点Node val recomposer = Recomposer(composeContext) //Recomposer val composition = Composition(MosaicNodeApplier(rootNode), recomposer) //Composition coroutineScope { val scope = object : MosaicScope, CoroutineScope by this { override fun setContent(content: @Composable () -> Unit) { composition.setContent(content)//调用@Composable hasFrameWaiters = true //... val snapshotObserverHandle = Snapshot.registerGlobalWriteObserver(observer) try { scope.body()//CoroutineScope中执行setContent{} } finally { snapshotObserverHandle.dispose() }而后,在 Composition 的 setContent{} 中,调用 @Composable。Mosaic Node看一下 Mosaic 中的 @Composalbe 和其对应的 Node@Composable private fun Box(flexDirection: YogaFlexDirection, children: @Composable () -> Unit) { ComposeNode<BoxNode, MosaicNodeApplier>( factory = ::BoxNode, update = { set(flexDirection) { yoga.flexDirection = flexDirection content = children, }@Composable fun Text( value: String, color: Color? = null, background: Color? = null, style: TextStyle? = null, ComposeNode<TextNode, MosaicNodeApplier>(::TextNode) { set(value) { this.value = value set(color) { this.foreground = color set(background) { this.background = background set(style) { this.style = style }ComposeNode 通过泛型关联对应的 Node 和 Applier 类型Box 和 Text 内部都使用 ComposeNode() 创建对应的 Node 对象。其中 Box 是容器类的 Composalbe,在 conent 中进一步创建子 Node。 Box 和 Text 在Updater<T>.update()中更新 Node 属性 。看一下 BoxNode:internal class BoxNode : MosaicNode() { val children = mutableListOf<MosaicNode>() override fun renderTo(canvas: TextCanvas) { for (child in children) { val childYoga = child.yoga val left = childYoga.layoutX.toInt() val top = childYoga.layoutY.toInt() val right = left + childYoga.layoutWidth.toInt() - 1 val bottom = top + childYoga.layoutHeight.toInt() - 1 child.renderTo(canvas[top..bottom, left..right]) override fun toString() = children.joinToString(prefix = "Box(", postfix = ")") internal sealed class MosaicNode { val yoga: YogaNode = YogaNodeFactory.create() abstract fun renderTo(canvas: TextCanvas) fun render(): String { val canvas = with(yoga) { calculateLayout(UNDEFINED, UNDEFINED) TextSurface(layoutWidth.toInt(), layoutHeight.toInt()) renderTo(canvas) return canvas.toString() BoxNode 继承自 MosaicNode, MosaicNode 在 render() 中,通过 yoga 实现UI的绘制。通过调用 renderTo() 在 Canvas中 递归绘制子 Node,类似 AndroidView 的绘制逻辑。理论上需要在首次 composition 或者 recomposition 时,调用 Node 的 render() 进行 NodeTree 的绘制, 为简单起见,Mosica 只是使用了定时轮询的方式调用 render() launch(context = composeContext) { while (true) { if (hasFrameWaiters) { hasFrameWaiters = false output.display(rootNode.render()) delay(50) //counter的state变化后,重新setContent,hasFrameWaiters更新后,重新render coroutineScope { val scope = object : MosaicScope, CoroutineScope by this { override fun setContent(content: @Composable () -> Unit) { composition.setContent(content) hasFrameWaiters = true }MosaicNodeApplier最后看一下 MosaicNodeApplier:internal class MosaicNodeApplier(root: BoxNode) : AbstractApplier<MosaicNode>(root) { override fun insertTopDown(index: Int, instance: MosaicNode) { // Ignored, we insert bottom-up. override fun insertBottomUp(index: Int, instance: MosaicNode) { val boxNode = current as BoxNode boxNode.children.add(index, instance) boxNode.yoga.addChildAt(instance.yoga, index) override fun remove(index: Int, count: Int) { val boxNode = current as BoxNode boxNode.children.remove(index, count) repeat(count) { boxNode.yoga.removeChildAt(index) override fun move(from: Int, to: Int, count: Int) { val boxNode = current as BoxNode boxNode.children.move(from, to, count) val yoga = boxNode.yoga val newIndex = if (to > from) to - count else to if (count == 1) { val node = yoga.removeChildAt(from) yoga.addChildAt(node, newIndex) } else { val nodes = Array(count) { yoga.removeChildAt(from) nodes.forEachIndexed { offset, node -> yoga.addChildAt(node, newIndex + offset) override fun onClear() { val boxNode = root as BoxNode // Remove in reverse to avoid internal list copies. for (i in boxNode.yoga.childCount - 1 downTo 0) { boxNode.yoga.removeChildAt(i) }MosaicNodeApplier 实现了对 Node 的 add/move/remove, 最终都反映到了对 YogaNode 的操作上,通过 YogaNode 刷新 UI<br/>基于 AndroidView 的声明式UI参考 Moscia 的示范,我们可以使用 compose.runtime 打造一个基于 Android 原生 View 的声明式 UI 框架。LinearLayout & TextView Node@Composable fun TextView( text: String, onClick: () -> Unit = {} val context = localContext.current ComposeNode<TextView, ViewApplier>( factory = { TextView(context) update = { set(text) { this.text = text set(onClick) { setOnClickListener { onClick() } @Composable fun LinearLayout(children: @Composable () -> Unit) { val context = localContext.current ComposeNode<LinearLayout, ViewApplier>( factory = { LinearLayout(context).apply { orientation = LinearLayout.VERTICAL layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, update = {}, content = children, ViewApplierViewApplier 中只实现 addclass ViewApplier(val view: FrameLayout) : AbstractApplier<View>(view) { override fun onClear() { (view as? ViewGroup)?.removeAllViews() override fun insertBottomUp(index: Int, instance: View) { (current as? ViewGroup)?.addView(instance, index) override fun insertTopDown(index: Int, instance: View) { override fun move(from: Int, to: Int, count: Int) { // NOT Supported TODO() override fun remove(index: Int, count: Int) { (view as? ViewGroup)?.removeViews(index, count) }创建 Composition创建 Root Composable: AndroidViewApp@Composable private fun AndroidViewApp() { var count by remember { mutableStateOf(1) } LinearLayout { TextView( text = "This is the Android TextView!!", repeat(count) { TextView( text = "Android View!!TextView:$it $count", onClick = { count++ 最后在 content 调用 AndroidViewAppfun runApp(context: Context): FrameLayout { val composer = Recomposer(Dispatchers.Main) GlobalSnapshotManager.ensureStarted() val mainScope = MainScope() mainScope.launch(start = CoroutineStart.UNDISPATCHED) { withContext(coroutineContext + DefaultMonotonicFrameClock) { composer.runRecomposeAndApplyChanges() mainScope.launch { composer.state.collect { println("composer:$it") val rootDocument = FrameLayout(context) Composition(ViewApplier(rootDocument), composer).apply { setContent { CompositionLocalProvider(localContext provides context) { AndroidViewApp() return rootDocument }效果展示:TL;DR当 State 变化时触发 recomposition,Composable 重新执行Composable 在执行中,通过 SlotTable 的 diff,找出待变更的 Node通过 Applier 更新 TreeNode,并在 UI 层渲染这棵树。基于 compose.runtime ,我们可以实现自己的声明式UI

在 Compose 中使用 Jetpack 组件库

目前有一个正在进行的 Jetpack Compose中文手册 项目,旨在帮助开发者更好的理解和掌握Compose框架,目前仍还在开荒中,欢迎大家关注与加入! 本文已经收录到该手册中,欢迎查阅。Jetpack + Compose前不久 Google I/O 2021 上公布了 Jetpack Compose 1.0 将于 7月份发布的消息,这意味着 Compose 已经具备了在实际项目中应用的可能。 除了使用 Compose 开发 UI , Jetpack 中不少组件库也与 Compose 进行了适配,开发者可以使用这些组件库开发 UI 以外的功能。Bloom 是一个 Compose 最佳实践的 Demo App,主要用来展示各种植物列表以及详细信息。接下来以 Bloom 为例,看一下如何在 Compose 中使用 Jetpack 进行开发<br/>1. 整体架构:App Architecture在架构上,Bloom 完全基于 Jetpack + Compose 搭建从下往上依次用到的 Jetpack 组件如下:Room: 作为数据源提供数据持久化能力Paging: 分页加载能力。分页请求 Room 的数据并进行显示Corouinte Flow:响应式能力。UI层通过 Flow 订阅 Paging 的数据变化ViewModel:数据管理能力。ViewModel 管理 Flow 类型的数据供 UI 层订阅Compose:UI 层完全使用 Compose 实现Hilt:依赖注入能力。ViewModel 等依赖 Hilt 来构建Jetpack MVVM 指导我们将 UI层、逻辑层、数据层进行了很好地解耦。上图除了 UI 层的 Compose 以外,与一个常规的 Jetpack MVVM 项目并无不同。接下来通过代码,看看 Compose 如何配合各 Jetpack 完成 HomeScreen 和 PlantDetailScreen 的实现。<br/>2. 列表页:HomeScreenHomeScreen 在布局上主要由三部分组成,最上面的搜索框,中间的轮播图,以及下边的的列表ViewModel + Compose我们希望 Composable 只负责UI,状态管理放到 ViewModel 中。 HomeScreen 作为入口的 Composable 一般在 Activity 或者 Fragment 中调用。viewmodel-compose 可以方便地从当前 ViewModelStore 中获取 ViewModel:<br/> implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha04"@Composable fun HomeScreen() { val homeViewModel = viewModel<HomeViewModel>() //... }Stateless Composable持有 ViewModel 的 Composalbe 相当于一个 “Statful Composalbe” ,这样的 ViewModel 很难复用和单测,而且携带 ViewModel 的 Composable 也无法在 IDE 中预览。 因此,我们更欢迎 Composable 是一个 "Stateless Composable"。 创建 StatelessComposable 的常见做法是将 ViewModel 上提,ViewModel 的创建委托给父级,仅作为参数传入,这可以使得 Composalbe 专注 UI@Composable fun HomeScreen( homeViewModel = viewModel<HomeViewModel>() //... }当然,也可以直接将 State 作为参数传入,可以进一步摆脱对 ViewModel 具体类型的依赖。接下来看一下 HomeViewModel 的实现,以及其内部 State 的定义<br/>3. HomeViewModelHomeViewModel 是一个标准的 Jetpack ViewModel 子类, 可以在ConfigurationChanged时保持数据。@HiltViewModel class HomeViewModel @Inject constructor( private val plantsRepository: PlantsRepository ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState(loading = true)) val uiState: StateFlow<HomeUiState> = _uiState val pagedPlants: Flow<PagingData<Plant>> = plantsRepository.plants init { viewModelScope.launch { val collections = plantsRepository.getCollections() _uiState.value = HomeUiState(plantCollections = collections) 添加了 @AndroidEntryPoint 的 Activity 或者 Fragment ,可以使用 Hilt 为 Composalbe 创建 ViewModel。 Hilt 可以帮助 ViewModel 注入 @Inject 声明的依赖。例如本例中使用的 PlantsRepositorypagedPlants 通过 Paging 向 Composable 提供分页加载的列表数据,数据源来自 Room 。分页列表以外的数据在 HomeUiState 中集中管理,包括轮播图中所需的植物集合以及页面加载状态等信息:data class HomeUiState( val plantCollections: List<Collection<Plant>> = emptyList(), val loading: Boolean = false, val refreshError: Boolean = false, val carouselState: CollectionsCarouselState = CollectionsCarouselState(emptyList()) //轮播图状态,后文介绍 )HomeScreen 中通过 collectAsState() 将 Flow 转换为 Composalbe 可订阅的 State:@Composable fun HomeScreen( homeViewModel = viewModel<HomeViewModel>() val uiState by homeViewModel.uiState.collectAsState() if (uiState.loading) { //... } else { //... }LiveData + Compose此处的 Flow 也可以替换成 LiveDatalivedata-compose 将 LiveData 转换为 Composable 可订阅的 state :<br/> implementation "androidx.compose.runtime:runtime-livedata:$compose_version"@Composable fun HomeScreen( homeViewModel = viewModel<HomeViewModel>() val uiState by homeViewModel.uiState.observeAsState() //uiState is a LiveData //... }此外,还有 rxjava-compose 可供使用,功能类似。<br/>4. 分页列表:PlantListPlantList 分页加载并显示植物列表。@Composable fun PlantList(plants: Flow<PagingData<Plant>>) { val pagedPlantItems = plants.collectAsLazyPagingItems() LazyColumn { if (pagedPlantItems.loadState.refresh == LoadState.Loading) { item { LoadingIndicator() } itemsIndexed(pagedPlantItems) { index, plant -> if (plant != null) { PlantItem(plant) } else { PlantPlaceholder() if (pagedPlantItems.loadState.append == LoadState.Loading) { item { LoadingIndicator() } Paging + Composepaging-compose 提供了 pagging 的分页数据 LazyPagingItems:<br/> implementation "androidx.paging:paging-compose:1.0.0-alpha09"注意此处的 itemsIndexed 来自paging-compoee,如果用错了,可能无法loadMorepublic fun <T : Any> LazyListScope.itemsIndexed( lazyPagingItems: LazyPagingItems<T>, itemContent: @Composable LazyItemScope.(index: Int, value: T?) -> Unit items(lazyPagingItems.itemCount) { index -> itemContent(index, lazyPagingItems.getAsState(index).value) }itemsIndexed 接受 LazyPagingItems 参数, LazyPagingItems#getAsState 中从 PagingDataDiffer 中获取数据,当 index 处于列表尾部时,触发 loadMore 请求,实现分页加载。<br/>5. 轮播图:CollectionsCarouselCollectionsCarousel 是显示轮播图的 Composable。 在下面页面中都有对轮播图的使用,因此我们要求 CollectionsCarousel 具有可复用性。Reusable Composable对于有复用性要求的 Composable,我们需要特别注意:可复用组件不应该通过 ViewModel 管理 State。 因为 ViewModel 在 Scope 内是共享的,但是在同一 Scope 内复用的 Composable 需要独享其 State 实例。因此 CollectionsCarousel 不能使用 ViewModel 管理 State,必须通过参数传入状态以及事件回调。@Composable fun CollectionsCarousel( // State in, // Events out // ... }参数传递的方式使得 CollectionsCarousel 将自己的状态委托给了父级 Composable。CollectionsCarouselState既然委托到了父级, 为了方便父级的使用,可以对 State 进行一定封装,被封装后的 State 与 Composable 配套使用。这在 Compose 中也是常见的做法,比如 LazyColumn 的 LazyListState ,或者 Scallfold 的 ScaffoldState 等对于 CollectionsCarousel 我们有这样一个需求:点击某一 Item 时,轮播图的布局会展开由于不能使用 ViewModel, 所以使用常规 Class 定义 CollectionsCarouselState 并实现 onCollectionClick 等相关逻辑data class PlantCollection( val name: String, @IdRes val asset: Int, val plants: List<Plant> class CollectionsCarouselState( private val collections: List<PlantCollection> private var selectedIndex: Int? by mutableStateOf(null) val isExpended: Boolean get() = selectedIndex != null privat var plants by mutableStateOf(emptyList<Plant>()) val selectPlant by mutableStateOf(null) private set //... fun onCollectionClick(index: Int) { if (index >= collections.size || index < 0) return if (index == selectedIndex) { selectedIndex = null } else { plants = collections[index].plants selectedIndex = index }然后将其定义为 CollectionsCarousel 的参数@Composable fun CollectionsCarousel( carouselState: CollectionsCarouselState, onPlantClick: (Plant) -> Unit // ... }为了进一步方便父级调用,可以提供rememberCollectionsCarouselState()方法, 效果相当于remember { CollectionsCarouselState() }最后,父Composalbe 访问 CollectionsCarouselState 时,可以将它放置父级的 ViewModel 中保存,以支持 ConfigurationChanged 。例如本例中会放到 HomeUiState 中管理。<br/>6. 详情页:PlantDetailScreen & PlantViewModelPlantDetailScreen 中除了复用 CollectionsCarousel 以外,大部分都是常规布局,比较简单。重点说明一下 PlantViewModel, 通过 id 从 PlantsRepository 中获取详情信息。class PlantViewModel @Inject constructor( plantsRepository: PlantsRepository, id: String ) : ViewModel() { val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails(id) }此处的 id 该如何传入呢?一个做法是借助 ViewModelProvider.Factory 构造 ViewModel 并传入 id@Composable fun PlantDetailScreen(id: String) { val plantViewModel : PlantViewModel = viewModel(id, remember { object : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { return PlantViewModel(PlantRepository, id) 这种构造方式成本较高,而且按照前文介绍的,如果想保证 PlantDetailScreen 的可复用性和可测试性,最好将 ViewModel 的创建委托到父级。除了委托到父级创建,我们还可以配合 Navigation 和 Hilt 更合理的创建 PlantViewModel,这将在后文中介绍。<br/>7. 页面跳转:Navigation在 HomeScreen 列表中点击某 Plant 后跳转 PlantDetailScreen。实现多个页面之间跳转,其中一个常见思路是为 Screen 包装一个 Framgent,然后借助 Navigation 实现对 Fragment 的跳转@AndroidEntryPoint class HomeFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ) = ComposeView(requireContext()).apply { setContent { HomeScreen(...) }Navigation 将回退栈中的节点抽象成一个 Destination , 所以这个 Destination 不一定非要用 Fragment 实现, 没有 Fragment 也可以实现 Composable 级别的页面跳转。Navigation + Composenavigation-compose 可以将 Composalbe 作为 Destination 在 Navigation 中使用 <br/> implementation "androidx.navigation:navigation-compose:$version"因此,我们摆脱 Framgent 实现页面跳转:@AndroidEntryPoint class BloomAcivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { setContent { val navController = rememberNavController() Scaffold( bottomBar = {/*...*/ } NavHost(navController = navController, startDestination = "home") { composable(route = "home") { HomeScreen(...) { plant -> navController.navigate("plant/${plant.id}") composable( route = "plant/{id}", arguments = listOf(navArgument("id") { type = NavType.IntType }) PlantDetailScreen(...) }Navigaion 的使用依靠两个东西: NavController 和 NavHost:NavController 保存了当前 Navigation 的 BackStack 信息,因此是一个携带状态的对象,需要像 CollectionsCarouselState 那样,跨越 NavHost 的 Scope 之外创建。NavHost 是 NavGraph 的容器, 将 NavController 作为参数传入。 NavGraph 中的Destinations(各Composable)将 NavController 作为 SSOT(Single Source Of Truth) 监听其变化。NavGraph不同于传统的 XML 方式, navigation-compose 则使用 Kotlin DSL 定义 NavGraph:comosable(route = “$id”) { //... }route 设置 Destination 的索引 id。 HomeScreen 使用 “home” 作为唯一id; 而 PlantDetailScreen 使用 “plant/{id}” 作为id。 其中 {id}中的 id 来自前一页面跳转时携带的 URI 中的参数 key。 本例中就是 plant.id:HomeScreen(...) { plant -> navController.navigate("plant/${plant.id}") }composable( route = "plant/{id}", arguments = listOf(navArgument("id") { type = NavType.IntType }) ) { //it: NavBackStackEntry val id = it.arguments?.getString("id") ?: "" }navArgument可以将 URI 中的参数转化为 Destination 的 arguments , 并通过 NavBackStackEntry 获取如上所述,我们可以利用 Navigation 进行 Screen 之间的跳转并携带一些基本参数。此外, Navigation 帮助我们管理回退栈,大大降低了开发成本。Hilt + Compose前文中介绍过,为了保证 Screen 的独立复用,我们可以将 ViewModel 创建委托到父级 Composable。 那么在 Navigation 的 NavHost 中我们该如何创建 ViewModel 呢?hilt-navigation-compose 允许我们在 Navigation 中使用 Hilt 构建 ViewModel:<br/> implementation "androidx.hilt:hilt-navigation-compose:$version"NavHost(navController = navController, startDestination = "home", route = "root" // 此处为 NavGraph 设置 id。 composable(route = "home") { val homeViewModel: HomeViewModel = hiltNavGraphViewModel() val uiState by homeViewModel.uiState.collectAsState() val plantList = homeViewModel.pagedPlants HomeScreen(uiState = uiState) { plant -> navController.navigate("plant/${plant.id}") composable( route = "plant/{id}", arguments = listOf(navArgument("id") { type = NavType.IntType }) val plantViewModel: PlantViewModel = hiltNavGraphViewModel() val plant: Plant by plantViewModel.plantDetails.collectAsState(Plant(0)) PlantDetailScreen(plant = plant) Navigation 中,每个 Destination 都是一个 ViewModelStore, 因此 ViewModel 的 Scope 可以限制在 Destination 内部而不用放大到整个 Activity,更加合理。而且,当 Destination 从 BackStack 弹出时, 对应的 Screen 从视图树上卸载,同时 Scope 内的 ViewModel 被清空,避免泄露。hiltNavGraphViewModel() : 可以获取 Destination Scope 的 ViewModel,并使用 Hilt 构建。hiltNavGraphViewModel("root") : 指定 NavHost 的 routeId,则可以在 NavGraph Scope 内共享ViewModelScreen 的 ViewModel 被代理到 NavHost 中进行, 不持有 ViewModel 的 Screen 具有良好的可测试性。再看一看 PlantViewModel@HiltViewModel class PlantViewModel @Inject constructor( plantsRepository: PlantsRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails( savedStateHandle.get<Int>("id")!! SavedStateHandle 实际上是一个键值对的 map。 当使用 Hilt 在构建 ViewModel 时,此 map 会被自动填充 NavBackStackEntry 中的 arguments,之后被参数注入 ViewModel。 此后在 ViewModel 内部可以通过 get(xxx) 获取键值。至此, PlantViewModel 通过 Hilt 完成了创建,相比与之前的 ViewModelProvider.Factory 简单得多。<br/>8. Recap:一句话总结各 Jetpack 库为 Compose 带来的能力:viewmodel-compose 可以从当前 ViewModelStore 中获取 ViewModellivedate-compose 将 LiveData 转换为 Composable 可订阅的 state 。paging-compose 提供了 pagging 的分页数据 LazyPagingItemsnavigation-compose 可以将 Composalbe 作为 Destination 在 Navigation 中使用hilt-navigation-compose 允许我们在 Navigation 中使用 Hilt 构建 ViewModel此外,还有几点设计规范需要遵守:将 Composable 的 ViewModel 上提,有利于保持其可复用性和可测试性当 Composable 在同一 Scope 内复用时,避免使用 ViewModel 管理 State参考 : https://www.youtube.com/watch?v=0z_dwBGQQWQ

一道面试题:介绍一下 LiveData 的 postValue ?

很多面试官喜欢会就一个问题不断深入追问。例如一个小小的 LiveData 的 postValue,就可能会问出一连串问题:postValue 与 setValuepostValue 与 setValue 一样都是用来更新 LiveData 数据的方法:setValue 只能在主线程调用,同步更新数据postValue 可在后台线程调用,其内部会切换到主线程调用 setValueliveData.postValue("a"); liveData.setValue("b");上面代码,a 在 b 之后才被更新。postValue 收不到通知postValue 使用不当,可能发生接收到数据变更的通知:If you called this method multiple times before a main thread executed a posted task, only the last value would be dispatched.如上,源码的注释中明确记载了,当连续调用 postValue 时,有可能只会收到最后一次数据更新通知。梳理源码可以了解其中原由:protected void postValue(T value) { boolean postTask; synchronized (mDataLock) { postTask = mPendingData == NOT_SET; mPendingData = value; if (!postTask) { return; ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable); }mPendingData 被成功赋值 value 后,post 了一个 RunnablemPostValueRunnable 的实现如下:private final Runnable mPostValueRunnable = new Runnable() { @SuppressWarnings("unchecked") @Override public void run() { Object newValue; synchronized (mDataLock) { newValue = mPendingData; mPendingData = NOT_SET; setValue((T) newValue); };postValue 将数据存入 mPendingData,mPostValueRunnable 在UI线程消费mPendingData。在 Runnable 中 mPendingData 值还没有被消费之前,即使连续 postValue , 也不会 post 新的 RunnablemPendingData 的生产 (赋值) 和消费(赋 NOT_SET) 需要加锁这也就是当连续 postValue 时只会收到最后一次通知的原因。源码梳理过了,但是为什么要这样设计呢?为什么 Runnable 只 post 一次?当 mPenddingData 中有数据不断更新时,为什么 Runnable 不是每次都 post,而是等待到最后只 post 一次?一种理解是为了兼顾性能,UI只需显示最终状态即可,省略中间态造成的频发刷新。这或许是设计目的之一,但是一个更为合理的解释是:即使 post 多次也没有意义,所以只 post 一次即可我们知道,对于 setValue 来说,连续调用多次,数据会依次更新:如下,订阅方一次收到 a b 的通知liveData.setValue("a"); liveData.setValue("b");通过源码可知,dispatchingValue() 中同步调用 Observer#onChanged(),依次通知订阅方://setValue源码 @MainThread protected void setValue(T value) { assertMainThread("setValue"); mVersion++; mData = value; dispatchingValue(null); }但对于 postValue,如果当 value 变化时,我们立即post,而不进行阻塞protected void postValue(T value) { mPendingData = value; ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable); private final Runnable mPostValueRunnable = new Runnable() { public void run() { setValue((T) mPendingData); };liveData.postValue("a") liveData.postValue("b")由于线程切换的开销,连续调用 postValue,收到通知只能是b、b,无法收到a。因此,post 多次已无意义,一次即可。为什么要加读写锁?前面已经知道,是否 post 取决于对 mPendingData 的判断(是否为 NOT_SET)。因为要在多线程环境中访问 mPendingData ,不加读写锁无法保证其线程安全。protected void postValue(T value) { boolean postTask = mPendingData == NOT_SET; // --1 mPendingData = value; // --2 if (!postTask) { return; ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable); private final Runnable mPostValueRunnable = new Runnable() { public void run() { Object newValue = mPendingData; mPendingData = NOT_SET; // --3 setValue((T) newValue); };如上,如果在 1 和 2 之间,执行了 3,则 2 中设置的值将无法得到更新使用RxJava替换LiveData如何避免在多线程环境下不漏掉任何一个通知? 比较好的思路是借助 RxJava 这样的流式框架,任何数据更新都以数据流的形式发射出来,这样就不会丢失了。fun <T> Observable<T>.toLiveData(): LiveData<T> = RxLiveData(this) class RxLiveData<T>( private val observable: Observable<T> ) : LiveData<T>() { private var disposable: Disposable? = null override fun onActive() { disposable = observable .observeOn(AndroidSchedulers.mainThread()) .subscribe({ setValue(it) setValue(null) override fun onInactive() { disposable?.dispose() }最后想要保证事件在线程切换过程中的顺序性和完整性,需要使用RxJava这样的流式框架。有时候面试官会使用追问的形式来挖掘候选人的技术深度,所以大家在准备面试时要多问自己几个问什么,知其然并知其所以然。当然,我也不赞同这种刨根问底式的拷问方式,尤其是揪着一些没有实用价值的细枝末节不放。所以本文也是提醒广大面试官,挖掘深度的同时要注意分寸,不能以将候选人难倒为目标来问问题。

Jetpack Compose 架构如何选? MVP, MVVM, MVI

Jetpack Compose 在 API 层面已趋于稳定,但真正要在项目中落地还少不了一套合理的应用架构。传统 Android 开发中的 MVP、MVVM 等架构在声明式UI这一新物种中是否还依旧可用呢?本文以一个简单的业务场景为例,试图找出一种与 Compose 最契合的架构模式Sample : Wanandroid SearchApp基本功能:用户输入关键字,在 wanandroid 网站中搜索出相关内容并展示功能虽然简单,但是集合了数据请求、UI展示等常见业务场景,可用来做UI层与逻辑层的解耦实验。前期准备:Model层其实无论 MVX 中 X 如何变化, Model 都可以用同一套实现。我们先定义一个 DataRepository ,用于从 wanandroid 获取搜索结果。 后文Sample中的 Model 层都基于此 Repo 实现@ViewModelScoped class DataRepository @Inject constructor(){ private val okhttpClient by lazy { OkHttpClient.Builder().build() private val apiService by lazy { Retrofit.Builder() .baseUrl("https://www.wanandroid.com/") .client(okhttpClient) .addConverterFactory(GsonConverterFactory.create()) .build().create(ApiService::class.java) suspend fun getArticlesList(key: String) = apiService.getArticlesList(key) }Compose为什么需要架构?首先,先看看不借助任何架构的 Compose 代码是怎样的?不使用架构的情况下,逻辑代码将与UI代码偶合在一起,在Compose中这种弊端显得尤为明显。常规 Android 开发默认引入了 MVC 思想,XML的布局方式使得UI层与逻辑层有了初步的解耦。但是 Compose 中,布局和逻辑同样都使用Kotlin实现,当布局中夹了杂逻辑,界限变得更加模糊。此外,Compose UI中混入逻辑代码会带来更多的潜在隐患。由于 Composable 会频繁重组,逻辑代码中如果涉及I/O 就必须当做 SideEffect{} 处理、一些不能随重组频繁创建的对象也必须使用 remember{} 保存,当这些逻辑散落在UI中时,无形中增加了开发者的心智负担,很容易发生遗漏。Sample 的业务场景特别简单,UI中出现少许 remember{} 、LaunchedEffect{} 似乎也没什么不妥,对于一些相对简单的业务场景出现下面这样的代码没有问题:@Composable fun NoArchitectureResultScreen( answer: String val isLoading = remember { mutableStateOf(false) } val dataRepository = remember { DataRepository() } var result: List<ArticleBean> by remember { mutableStateOf(emptyList()) } LaunchedEffect(Unit) { isLoading.value = true result = withContext(Dispatchers.IO) { dataRepository.getArticlesList(answer).data.datas } isLoading.value = false SearchResultScreen(result, isLoading.value , answer) }但是,当业务足够复杂时,你会发现这样的代码是难以忍受的。这正如在 React 前端开发中,虽然 Hooks 提供了处理逻辑的能力,但却依然无法取代 Redux。MVP、MVVM、MVI 是 Android中的一些常见架构模式,它们的目的都是服务于UI层与逻辑层的解耦,只是在解耦方式上有所不同,如何选择取决于使用者的喜好以及项目的特点“没有最好的架构,只有最合适的架构。”那么在 Compose 项目中何种架构最合适呢?<br/>MVPMVP 主要特点是 Presenter 与 View 之间通过接口通信, Presenter 通过调用 View 的方法实现UI的更新。这要求 Presenter 需要持有一个 View 层对象的引用,但是 Compose 显然无法获得这种引用,因为用来创建 UI 的 Composable 必须要求返回 Unit,如下:@Composable fun HomeScreen() { Column { Text("Hello World!") }官方文档中对无返回值的要求也进行了明确约束:The function doesn’t return anything. Compose functions that emit UI do not need to return anything, because they describe the desired screen state instead of constructing UI widgets.https://developer.android.com/jetpack/compose/mental-modelCompose UI 既然存在于 Android 体系中,必定需要有一个与 Android 世界连接的起点,起点处可能是一个 Activity 或者 Fragment,用他们做UI层的引用句柄不可以吗?理论上可以,但是当 Activity 接收 Presenter 通知后,仍然无法在内部获取局部引用,只能设法触发整体Recomposition,这完全丧失了 MVP 的优势,即通过获取局部引用进行精准刷新。通过分析可以得到结论: “MVP 这种依赖接口通信的解耦方式无法在 Compose 项目中使用”<br/>MVVM(without Jetpack)相对于 MVP 的接口通信 ,MVVM 基于观察者模式进行通信,当 UI 观察到来自 ViewModle 的数据变化时自我更新。 UI层是否能返回引用句柄已不再重要,这与 Compose 的工作方式非常契合。自从 Android 用 ViewModel 命名了某 Jetpack 组件后,在很多人心里,Jetpack 似乎就与 MVVM 画上了等号。这确实客观推动了 MVVM 的普及,但是 Jetpack 的 ViewModel 并非只能用在 MVVM 中(比如如后文介绍的 MVI 也可以使用 ); 反之,没有 Jetpack ,照样可以实现 MVVM。先来看看不借助 Jetpack 的情况下,MVVM 如何实现?Activity 中创建 ViewModel首先 View 层创建 ViewModel 用于订阅class MvvmActivity : AppCompatActivity() { private val mvvmViewModel = MvvmViewModel(DataRepository()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ComposePlaygroundTheme { MvvmApp(mvvmViewModel) //将vm传给Composable }Compose 项目一般使用单 Activity 结构, Activity 作为全局入口非常适合创建全局 ViewModel。 子 Compoable 之间需要基于 ViewModel 通信,所以构建 Composable 时将 ViewModel 作为参数传入。Sample 中我们在 Activity 中创建的 ViewModel 仅仅是为了传递给 MvvmApp 使用,这种情况下也可以通过传递 Lazy<MvvmViewModel>,将创建延迟到真正需要使用的时候以提高性能。定义 NavGraph当涉及到 Compose 页面切换时,navigation-compose 是一个不错选择,Sample中也特意设计了SearchBarScreen 和 SearchResultScreen 的切换场景// build.gradle implementation "androidx.navigation:navigation-compose:$latest_version" @Composable fun MvvmApp( mvvmViewModel: MvvmViewModel val navController = rememberNavController() LaunchedEffect(Unit) { mvvmViewModel.navigateToResults .collect { navController.navigate("result") //订阅VM路由事件通知,处理路由跳转 NavHost(navController, startDestination = "searchBar") { composable("searchBar") { MvvmSearchBarScreen( mvvmViewModel, composable("result") { MvvmSearchResultScreen( mvvmViewModel, 在 root-level 的 MvvmApp 中定义 NavGraph, composable("$dest_id"){} 中构造路由节点的各个子 Screen,构造时传入 ViewModel 用于 Screen 之间的通信每个 Composable 都有一个 CoroutineScope 与其 Lifecycle 绑定,LaunchedEffect{} 可以在这个 Scope 中启动协程处理副作用。 代码中使用了一个只执行一次的 Effect 订阅 ViewModel 的路由事件通知当然我们可以将 navConroller 也传给 MvvmSearchBarScreen ,在其内部直接发起路由跳转。但在较复杂的项目中,跳转逻辑与页面定义应该尽量保持解耦,这更利于页面的复用和测试。我们也可以在 Composeable 中直接 mutableStateOf() 创建 state 来处理路由跳转,但是既然选择使用 ViewModel 了,那就应该尽可能将所有 state 集中到 ViewModle 管理。注意: 上面例子中的处理路由跳转的 navigateToResults 是一个“事件”而非“状态”,关于这部分区别,在后文在详细阐述定义子 Screen接下来看一下两个 Screen 的具体实现@Composable fun MvvmSearchBarScreen( mvvmViewModel: MvvmViewModel, SearchBarScreen { mvvmViewModel.searchKeyword(it) @Composable fun MvvmSearchResultScreen( mvvmViewModel: MvvmViewModel val result by mvvmViewModel.result.collectAsState() val isLoading by mvvmViewModel.isLoading.collectAsState() SearchResultScreen(result, isLoading, mvvmViewModel.key.value) }大量逻辑都抽象到 ViewModel 中,所以 Screen 非常简洁SearchBarScreen 接受用户输入,将搜索关键词发送给 ViewModelMvvmSearchResultScreen 作为结果页显示 ViewModel 发送的数据,包括 Loading 状态和搜索结果等。collectAsState 用来将 Flow 转化为 Compose 的 state,每当 Flow 接收到新数据时会触发 Composable 重组。 Compose 同时支持 LiveData、RxJava 等其他响应式库的collectAsStateUI层的更多内容可以查阅 SearchBarScreen 和 SearchResultScreen 的源码。经过逻辑抽离后,这两个 Composable 只剩余布局相关的代码,可以在任何一种 MVX 中实现复用。ViewModel 实现最后看一下 ViewModel 的实现class MvvmViewModel( private val searchService: DataRepository, private val coroutineScope = MainScope() private val _isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _result: MutableStateFlow<List<ArticleBean>> = MutableStateFlow(emptyList()) val result = _result.asStateFlow() private val _key = MutableStateFlow("") val key = _key.asStateFlow() //使用Channel定义事件 private val _navigateToResults = Channel<Boolean>(Channel.BUFFERED) val navigateToResults = _navigateToResults.receiveAsFlow() fun searchKeyword(input: String) { coroutineScope.launch { _isLoading.value = true _navigateToResults.send(true) _key.value = input val result = withContext(Dispatchers.IO) { searchService.getArticlesList(input) } _result.emit(result.data.datas) _isLoading.value = false }接收到用户输入后,通过 DataRepository 发起搜索请求搜索过程中依次更新 loading(loading显示状态)、navigateToResult(页面跳转事件)、 key(搜索关键词)、result(搜索结果)等内容,不断驱动UI刷新所有状态集中在 ViewModel 管理,甚至页面跳转、Toast弹出等事件也由 ViewModel 负责通知,这对单元测试非常友好,在单测中无需再 mock 各种UI相关的上下文。<br/>Jetpack MVVMJeptack 的意义在于降低 MVVM 在 Android平台的落地成本。引入 Jetpack 后的代码变化不大,主要变动在于 ViewModel 的创建。Jetpack 提供了多个组件,降低了 ViewModel 的使用成本:通过 hilt 的 DI 降低 ViewModel 构造成本,无需手动传入 DataRepository 等依赖任意 Composable 都可以从最近的 Scope 中获取 ViewModel,无需层层传参。@HiltViewModel class JetpackMvvmViewModel @Inject constructor( private val searchService: DataRepository // DataRepository 依靠DI注入 ) : ViewModel() { }@Composable fun JetpackMvvmApp() { val navController = rememberNavController() NavHost(navController, startDestination = "searchBar", route = "root") { composable("searchBar") { JetpackMvvmSearchBarScreen( viewModel(navController, "root") //viewModel 可以在需要时再获取, 无需实现创建好并通过参数传进来 composable("result") { JetpackMvvmSearchResultScreen( viewModel(navController, "root") //可以获取跟同一个ViewModel实例 }@Composable inline fun <reified VM : ViewModel> viewModel( navController: NavController, graphId: String = "" ): VM = //在 NavGraph 全局范围使用 Hilt 创建 ViewModel hiltNavGraphViewModel( backStackEntry = navController.getBackStackEntry(graphId) )Jetpack 甚至提供了 hilt-navigation-compose 库,可以在 Composable 中获取 NavGraph Scope 或 Destination Scope 的 ViewModel,并自动依赖 Hilt 构建。Destination Scope 的 ViewModel 会跟随 BackStack 的弹出自动 Clear ,避免泄露。// build.gradle implementation androidx.hilt:hilt-navigation-compose:$latest_versioin“未来 Jetpack 各组件之间协同效应会变得越来越强。”<br/>MVIMVI 与 MVVM 很相似,其借鉴了前端框架的思想,更加强调数据的单向流动和唯一数据源,可以看做是 MVVM + Redux 的结合。MVI 的 I 指 Intent,这里不是启动 Activity 那个 Intent,而是一种对用户操作的封装形式,为避免混淆,也可唤做 Action 等其他称呼。 用户操作以 Action 的形式送给 Model层 进行处理。代码中,我们可以用 Jetpack 的 ViewModel 负责 Intent 的接受和处理,因为 ViewModel 可以在 Composable 中方便获取。在 SearchBarScreen 用户输入关键词后通过 Action 通知 ViewModel 进行搜索@Composable fun MviSearchBarScreen( mviViewModel: MviViewModel, onConfirm: () -> Unit SearchBarScreen { mviViewModel.onAction(MviViewModel.UiAction.SearchInput(it)) }通过 Action 通信,有利于 View 与 ViewModel 之间的进一步解耦,同时所有调用以 Action 的形式汇总到一处,也有利于对行为的集中分析和监控@Composable fun MviSearchResultScreen( mviViewModel: MviViewModel val viewState by mviViewModel.viewState.collectAsState() SearchResultScreen( viewState.result, viewState.isLoading, viewState.key }MVVM 的 ViewModle 中分散定义了多个 State ,MVI 使用 ViewState 对 State 集中管理,只需要订阅一个 ViewState 便可获取页面的所有状态,相对 MVVM 减少了不少模板代码。相对于 MVVM,ViewModel 也有一些变化class MviViewModel( private val searchService: DataRepository, private val coroutineScope = MainScope() private val _viewState: MutableStateFlow<ViewState> = MutableStateFlow(ViewState()) val viewState = _viewState.asStateFlow() private val _navigateToResults = Channel<OneShotEvent>(Channel.BUFFERED) val navigateToResults = _navigateToResults.receiveAsFlow() fun onAction(uiAction: UiAction) { when (uiAction) { is UiAction.SearchInput -> { coroutineScope.launch { _viewState.value = _viewState.value.copy(isLoading = true) val result = withContext(Dispatchers.IO) { searchService.getArticlesList(uiAction.input) } _viewState.value = _viewState.value.copy(result = result.data.datas, key = uiAction.input) _navigateToResults.send(OneShotEvent.NavigateToResults) _viewState.value = _viewState.value.copy(isLoading = false) data class ViewState( val isLoading: Boolean = false, val result: List<ArticleBean> = emptyList(), val key: String = "" sealed class OneShotEvent { object NavigateToResults : OneShotEvent() sealed class UiAction { class SearchInput(val input: String) : UiAction() }页面所有的状态都定义在 ViewState 这个 data class 中,状态的修改只能在 onAction 中进行, 其余场所都是 immutable 的, 保证了数据流只能单向修改。 反观 MVVM ,MutableStateFlow 对外暴露时转成 immutable 才能保证这种安全性,需要增加不少模板代码且仍然容易遗漏。事件则统一定义在 OneShotEvent中。 Event 不同于 State,同一类型的事件允许响应多次,因此定义事件使用 Channel 而不是 StateFlow。Compose 鼓励多使用 State 少使用 Event, Event 只适合用在弹 Toast 等少数场景中通过浏览 ViewModel 的 ViewState 和 Aciton 定义就可以理清 ViewModel 的职责,可以直接拿来作为接口文档使用。<br/>页面路由Sample 中之所以使用事件而非状态来处理路由跳转,一个主要原因是由于使用了 Navigation。Navigation 有自己的 backstack 管理,当点击 back 键时会自动帮助我们返回前一页面。倘若我们使用状态来描述当前页面,当点击 back时,没有机会更新状态,这将造成 ViewState 与 UI 的不一致。关于路由方案的建议:简单项目使用事件控制页面跳转没有问题,但是对于复杂项目,推荐使用状态进行页面管理,有利于逻辑层时刻感知到当前的UI状态。我们可以将 NavController 的 backstack 状态 与 ViewModel 的状态建立同步: class MvvmViewModel( private val searchService: DataRepository, //使用 StateFlow 描述页面 private val _destination = MutableStateFlow(DestSearchBar) val destination = _destination.asStateFlow() fun searchKeyword(input: String) { coroutineScope.launch { _destination.value = DestSearchResult fun bindNavStack(navController: NavController) { //navigation 的状态时刻同步到 viewModel navController.addOnDestinationChangedListener { _, _, arguments -> run { _destination.value = requireNotNull(arguments?.getString(KEY_ROUTE)) 如上,当 navigation 状态变化时,会及时同步到 ViewModel ,这样就可以使用 StateFlow 而非 Channel 来描述页面状态了。@Composable fun MvvmApp( mvvmViewModel: MvvmViewModel val navController = rememberNavController() LaunchedEffect(Unit) { with(mvvmViewModel) { bindNavStack(navController) //建立同步 destination .collect { navController.navigate(it) }在入口处,为 NavController 和 ViewModel 建立同步绑定即可。<br/>Clean Architecture更大型的项目中,会引入 Clean Architecture ,通过 Use Case 将 ViewModel 内的逻辑进一步分解。 Compose 只是个 UI 框架,对于 ViewModle 以下的逻辑层的治理方式与传统的 Andorid 开发没有区别。所以 Clean Architecture 这样的复杂架构仍然可以在 Compose 项目中使用<br/>总结比较了这么多种架构,那种与 Compose 最契合呢? Compose 的声明式UI思想来自 React,所以同样来自 Redux 思想的 MVI 应该是 Compose 的最佳伴侣。当然 MVI 只是在 MVVM 的基础上做了一些改良,如果你已经有了一个 MVVM 的项目,只是想将 UI 部分改造成 Compose ,那么没必要为了改造成 MVI 而进行重构,MVVM 也可以很好地配合 Compose 使用的。 但是如果你想将一个 MVP 项目改造成 Compose 可能成本就有点大了。关于 Jetpack,如果你的项目只用于 Android,那么 Jetpack 无疑是一个好工具。但是 Compose 未来的应用场景将会很广泛,如果你有预期未来会配合 KMP 开发跨平台应用,那么就需要学会不依赖 Jetpack 的开发方式,这也是本文为什么要介绍非 Jetpack 下的 MVVM 的一个初衷。Sample代码https://github.com/vitaviva/JetpackComposePlayground/tree/main/architecture

Kotlin 1.5 新特性:密封接口比密封类强在哪?

Kotlin 1.5 推出了密封接口(Sealed Interface),这与密封类(Sealed Class)有什么区别呢?在开始聊密封接口之前先回顾一下密封类的进化史。密封类的进化史密封类可以约束子类的类型,类似于枚举类,但相对于枚举更加灵活:Enum Class:每个枚举都是枚举类的实例,可以直接使用Sealed Class:密封类约束的子类只是一个类型,你可以为不同子类定义方法和属性,并对齐动态实例化Kotlin 1.0早期 Kotlin 1.0 中的密封类,子类型必须是密封类的内部类://编程语言 sealed class ProgrammingLang { object Assembly : ProgrammingLang() class Java(ver: String) : ProgrammingLang() class JavaScript(ver: String) : ProgrammingLang() }这可以防止在在不编译密封类的前提下为其创建新的派生类。任何派生类的添加都必须重新编译密封类本身,外部调用方能时刻同步所有的子类类型,确保 when 语句的合法://获取指定语言的排名 val ranking = when (val item: ProgrammingLang = getProgramLang()) { Assembly -> TODO() is Java -> TODO() is JavaScript -> TODO() }另一个潜在的好处是子类必须连同父类名字一起出现,例如 ProgrammingLang.Java,这有助于明确其namespace。Kotlin 1.1Kotlin 1.1 取消了子类必须在密封类内部定义的约束,密封类的子类可以声明在文件的 Top-Level。但是为了保证编译的同步,仍然需要在同一文件内。sealed class ProgrammingLang object Assembly : ProgrammingLang() class Java(ver: String) : ProgrammingLang() class JavaScript(ver: String) : ProgrammingLang()Kotlin 1.5到了Kotlin 1.5,约束进一步放宽,允许子类定义在不同的文件中,只要保证子类和父类在同一个 Gradle module 且是同一个包名下即可。在一个 module 可以保证整个所有文件同时参与编译,仍然可以保证编译的同步。// Lang.kt sealed class ProgrammingLang // Compiled.kt class Java(ver: String) : ProgrammingLang() class Cpp(ver: String) : ProgrammingLang() // Interpreted.kt class JavaScript(ver: String) : ProgrammingLang() class Lua(ver: String) : ProgrammingLang() // LowLevel.kt object Assembly : ProgrammingLang() 放宽约束后,有利于子类按文件归类,同时,较长的子类拆分为单独文件也便于阅读。如果违反了同Module、同包名的限制,编译会报错:e: Inheritance of sealed classes or interfaces from different module is prohibitede: Inheritor of sealed class or interface must be in package where base class is declared密封接口 Sealed InterfaceKotlin 1.5 除了进一步放宽了对密封类的使用限制,还引入了密封接口。 通常引入接口最主要的目的无非就是对外隐藏实现,但是1.5的密封类已经可以通过分割文件隐藏子类了,密封接口存在的意义是什么?在以下几个场景中密封接口可以弥补密封类的不足:1. "final" 的 interface有时,我们虽然对外暴露了interface,但是并不希望外界去实现它。比如kotlinx.coroutines 的 Jobpublic interface Job : CoroutineContext.Element { public fun start(): Boolean public fun cancel(): Unit }Job 作为一个接口,外界可以对它任意实现,但显然这不是 kotlinx.coroutines 希望出现的。因为未来随着协程功能的迭代,Job 中的共有属性和方法或许会出现变化和增减,如果外部有其派生类很容易出现二进制兼容问题。如果把 Job 定义为一个密封接口,就可以很好地避免上述问题。可以大胆猜测,未来某版本的协程中 Job 会以密封接口的形式出现。我们在自己的 library 中也可以考虑使用密封接口避免暴露的接口被随意实现。2. “可嵌套”的枚举枚举和密封类功能上很相近,除了文章开头介绍的一些区别外,还有一个容易被忽略的点就是枚举类无法继承其他类。枚举类的本质都是 Enum 的子类:enum class JvmLang { Java, Kotlin, Scala }反编译 class 后会发现,JvmLang 继承自 Enum。public final class JvmLang extends Enum{ private JvmLang(String s,int i){ super(s,i); public static final JvmLang Java; public static final JvmLang Kotlin; public static final JvmLang Scala; static{ Java = new Action("Java",0); Kotlin = new Action("Kotlin",1); Scala = new Action("Scala",2); }由于单继承的限制,枚举类无法继承 Enum 以外的其他 Class:e: Enum class cannot inherit from classes但有时候,、我们又需要枚举能实现嵌套以处理更复杂的分类逻辑。此时密封接口就成了唯一选择sealed interface Language enum class HighLevelLang : Language { Java, Kotlin, CPP enum class MachineLang : Language { ARM, X86 object AssemblyLang : Language如上,我们通过密封接口实际上定义了一组“可嵌套”的枚举。之后就可以通过多级 when 语句进行分类处理了: when (lang) { is Machine -> when (lang) { MachineLang.ARM -> TODO() MachineLang.X86 -> TODO() is HighLevel -> when (lang) { HighLevelLang.CPP -> TODO() HighLevelLang.Java -> TODO() HighLevelLang.Kotlin -> TODO() else -> TODO() 3. 多继承的密封类前两个密封接口的使用场景和密封类没有太多关系, 但其实密封接口也可以扩大密封类的使用场景:比如上图中对编程语言的分类,就很难用单继承的密封类进行描述。比如,当我们像下面这样定义密封类时sealed class JvmLang { object Java : JvmLang() object Kotlin : JvmLang() object Groovy : JvmLang() sealed class CompiledLang { object Java : CompiledLang() object Kotlin : CompiledLang() object Groovy : CompiledLang() object Cpp : CompiledLang() }Java 不能同时继承自 CompiledLang 与 JvmLang ,所以无法在两个密封类中复用,需要重复定义。此时可能有人会说,密封类是可以被继承的,可以让 JvmLang 继承 CompiledLangsealed class JvmLang : CompiledLang object Java : JvmLang() object Kotlin : JvmLang() object Groovy : JvmLang() object Cpp : CompiledLang() 如上,Java 同时是 CompiledLang 和 JvmLang 的子类,且没有违反单继承结构。但这只是因为 Java 的语言特性还不够“复杂”罢了。Groovy 除了是一个编译性语言,同时具有解释性语言的特性,可以同时归类为CompiledLang 和 InterpretedLang, 此时单继承结构很难维系,需要解除接口实现多继承:sealed interface CompiledLang sealed interface InterpretedLang sealed interface FunctionalLang sealed interface JvmLang : CompiledLang object Java : JvmLang object Kotlin : JvmLang, FunctionalLang object Groovy : JvmLang, FunctionalLang, InterpretedLang object JavaScript: InterpretedLang object Cpp : CompiledLang, FunctionalLang //编程语言的市场份额 fun shareOfCompiledLang(lang: CompiledLang) = when(lang) { Java -> TODO() Kotlin -> TODO() Groovy -> TODO() Cpp -> TODO() fun shareOfInterpretedLang(lang: InterpretedLang) = when(lang) { JavaScript -> TODO() Groovy -> TODO() 无论处理 InterpretedLang 还是 CompiledLang, Groovy只需要定义一次。当然,为了更清晰的显示每种 Lang 的所有属性,可以将 interface 之间的继承关系下放: sealed interface CompiledLang sealed interface InterpretedLang sealed interface FunctionalLang sealed interface JvmLang object Java : JvmLang, CompiledLang object Kotlin : JvmLang, CompiledLang, FunctionalLang object Groovy : JvmLang, CompiledLang, FunctionalLang, InterpretedLang object JavaScript: InterpretedLang object Cpp : CompiledLang, FunctionalLang与 Java 的兼容性JDK15 开始,Java 也引入了密封类和密封接口,所以 JDK15 以上,Kotlin 和 Java 之间的密封类和密封接口可以比较好的映射和互操作。即使在 JDK15 以下,由于密封类在字节码中的构造函数加了 prevate 修饰,可以防止 Java 代码的继承//kotlin sealed class ProgrammingLang//java class Java extends ProgrammingLang当试图在 Java 侧继承密封类 ProgrammingLang 时,编译器报错如下:e: There is no default constructor available in 'ProgrammingLang' Java class cannot be a part of Kotlin sealed hierarchy 但是对于密封接口,JDK15 以下,Java 代码可以随意实现,这个需要特别注意还好 JetBrains 宣布在IDE层面会给与警告,如果使用 IntelliJ IDEA 系列的 IDE,当 Java侧实现密封接口时同样会给出编译报错:e: Java class cannot be a part of Kotlin sealed hierarchy不管怎样,还是建议尽量少在 Java 中访问带有 Kotlin 语法特性的相关代码。总结Kotlin 1.5 进一步解除了对密封类的使用限制,同时还引进了密封接口,为我们带来如下便利:定义“final”的interface定义“可嵌套”的枚举帮助密封类实现多继承未来,没有任何成员定义的密封类应该尽量使用密封接口替代,另外,当一个Library对外提供服务时,也可以更多地虑使用密封接口防止被外部滥用,可以预见密封接口的应用场景会越来越多。

Compose 的重组会影响性能吗?聊一聊 recomposition scope

不少初学 Compose 的同学都会对 Composable 的 Recomposition(官方文档译为"重组")心生顾虑,担心大范围的重组是否会影响性能。其实这种担心大可不必, Compose 编译器在背后做了大量工作来保证 recomposition 范围尽可能小,从而避免了无效开销:Recomposition skips as much as possible <br/>When portions of your UI are invalid, Compose does its best to recompose just the portions that need to be updated. <br/> https://developer.android.com/jetpack/compose/mental-model#skips那么当重组发生时,其代码执行的范围究竟是怎样的呢?我们通过一个例子来测试一下:@Composable fun Foo() { var text by remember { mutableStateOf("") } Log.d(TAG, "Foo") Button(onClick = { text = "$text $text" }.also { Log.d(TAG, "Button") }) { Log.d(TAG, "Button content lambda") Text(text).also { Log.d(TAG, "Text") } }如上,当点击 button 时,State 的变化会触发 recomposition。请大家思考一下此时的日志输出是怎样的,你可以在文章末尾找到答案,与你的判断是否一致呢?<br/>Compose 如何确定重组范围?经 Compose 编译器处理后的 Composable 代码其内部对 state 进行读取时会自动与其建立绑定,当 state 变化时,Compose 会找到这些关联的 Composable 函数或者 lambda 并将这些代码块并标记为 Invalid 。在下一渲染帧到来之前 Compose 会触发 recomposition,并在重组过程中执行 invalid 代码块。Invalid 代码块即 State 变化时的重组范围。能够被标记为 Invalid 的代码必须是非 inline 且无返回值的 @Composalbe function/lambda,必须遵循 重组范围最小化 原则。为何是 非 inline 且无返回值(返回 Unit)?对于 inline 函数,由于在编译期会在调用处中展开,因此无法在下次重组时找到合适的调用入口,只能共享调用方的重组范围。而对于有返回值的函数,由于返回值的变化会影响调用方,因此无法单独重组,而必须连同调用方一同参与重组,因此它不能作为入口被标记为 invalid范围最小化原则只有会受到 state 变化影响的代码块才会参与到重组,不依赖 state 的代码不参与重组。在了解 Compose 重绘范围的基本规则之后,我们再回看文章开头的例子,并尝试回答下面的问题:为什么不只是 Text 参与重组?当点击 button 后,MutableState 发生变化,代码中唯一访问这个 state 的地方是 Text(...) ,为什么重组范围不只是 Text(...) ,而是 Button {...} 的整个花括号?首先要理解出现在 Text(...) 参数中的 text 实际上是一个表达式下面两中写法在执行顺序上是等价的println(“hello” + “world”)val arg = “hello” + “world” println(arg)总是 “hello” + “world” 作为表达式先执行,然后才是 println 方法的调用。回到前面的例子,参数 text 作为表达式执行的调用处是 Button 的尾lambda,而后才作为参数传入 Text()。 所以此时最小重组范围是 Button 的 尾lambda 而非 Text()Foo 是否参加重组 ?按照范围最小化原则, Foo 中没有任何对 state 的访问,所以很容易知道 Foo 不应该参与重组。有一点需要注意的是,例子中 Foo 通过 by 的代理方式声明 text,如果改为 = 直接为 text 赋值呢?@Composable fun Foo() { val text: MutableState<String> = remember { mutableStateOf("") } Button(onClick = { text = "$text $text" Text(text.value) }答案是一样的,仍然不会参与重组。第一,Compose 关心的是代码块中是否有对 state 的 read,而不是 write。第二,这里的 = 并不意味着 text 会被赋值新的对象,因为 text 指向的 MutableState 实例是永远不会变的,变的只是内部的 value<br/>为什么 Button 不参与重组?这个很好解释,Button 的调用方 Foo 不参与重组,Button 自然也不会参与重组,只有尾 lambda 参与重组即可。Button 的 onClick是否参与重组?重组范围必须是 @Composable 的 function/lambda ,onClick 是一个普通 lambda,因此与重组逻辑无关。注意!重组中的 Inline 陷阱!前面讲了,只有 非inline函数 才有资格成为重组的最小范围,理解这点特别重要!我们将代码稍作改动,为 Text() 包裹一个 Box{...}@Composable fun Foo() { var text by remember { mutableStateOf("") } Button(onClick = { text = "$text $text" }) { Log.d(TAG, "Button content lambda") Box { Log.d(TAG, "Box") Text(text).also { Log.d(TAG, "Text") } }日志如下:D/Compose: Button content lambda D/Compose: Boxt D/Compose: Text为什么重组范围不是从Box开始?Column、Row、Box 乃至 Layout 这种容器类 Composable 都是 inline 函数,因此它们只能共享调用方的重组范围,也就是 Button 的 尾lambda如果你希望通过缩小重组范围提高性能怎么办?@Composable fun Foo() { var text by remember { mutableStateOf("") } Button(onClick = { text = "$text $text" }) { Log.d(TAG, "Button content lambda") Wrapper { Text(text).also { Log.d(TAG, "Text") } @Composable fun Wrapper(content: @Composable () -> Unit) { Log.d(TAG, "Wrapper recomposing") Box { Log.d(TAG, "Box") content() }如上,自定义非 inline 函数,使之满足 Compose 重组范围最小化条件。<br/>结论Just don't rely on side effects from recomposition and compose will do the right thing-- Compose Team关于重组范围的具体规则,官方文档中没有做详细说明。因为开发者只需要牢记 Compose 通过编译期优化保证了recomposition 永远按照最合理的方式运行,以最自然的方式开发就好了,无需针对这些具体规则付出额外的学习成本。尽管如此,作为开发者仍要谨记一点:不要直接在 Composable 中写包含副作用(SideEffect)的逻辑!副作用不能跟随 recomposition 反复执行,所以我们需要保证 Composable 的“纯洁性”。你不能预设某个 function/lambda 一定不参与重组,因而在里面侥幸的埋了一些副作用代码,使其变得不纯洁。因为我们无法确定这里是否存在 “inline陷阱”,即使能确定也不保证现在的优化规则在未来不会改变。所以最安全的做法是,将副作用写到 LaunchedEffect{}、DisposableEffect{}、SideEffect{} 中,并且使用 remeber{}、derivedStateOf{} 处理那些耗时的计算。开头例子的运行结果:D/Compose: Button content lambda D/Compose: Text

一道面试题:ViewPager中的Framgent如何实现懒加载?

问:ViewPager中的Fragment如何实现懒加载?当被问到上述问题时,很多人可能首先会想到借助setUserVisiblity实现如下,当Fragment可见时调用 onVisible 从而实现异步加载@Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); if (getUserVisibleHint()) { isVisible = true; onVisible(); } else { isVisible = false; onInVisible(); }放在两年前,这个答案是OK的,但是2021年的今天还这么回答可能就不过关了。https://android-review.googlesource.com/c/platform/frameworks/support/+/945776AndroidX 自 1.1.0-alpha07 起, 为 FragmentTransaction 增加了新的方法 setMaxLifeCycle, 官方建议开发者以此取代setUserVisibleHint,这将带来如下好处:基于 Lifecycle 的懒加载更加科学,可以配合 Livedata 等组件在MVVM架构中使用setMaxLifeCycle 无需额外定义 Fragment 基类,使用起来更加无侵使用 setMaxLifecycle 进行懒加载FragmentPagerAdapter 的构造方法新增了一个 behavior 参数,当被设置为FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT时,会通过setMaxLifecycle 来限制 Fragment 的生命周期:只有当 Fragment 显示在屏幕中时才执行onResume()。这样就可以把加载数据等处理放在 onResume() 中从而实现懒加载了。代码如下:class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { super.onCreate(savedInstanceState, persistentState) setContentView(R.layout.activity_main) val viewPager: ViewPager = findViewById(R.id.viewpager) val fragmentList: MutableList<Fragment> = ArrayList() fragmentList.add(Fragment1()) fragmentList.add(Fragment2()) fragmentList.add(Fragment3()) // 为MyPagerAdapter适配器设置FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT 参数 val myPagerAdapter: MyPagerAdapter = MyPagerAdapter( getSupportFragmentManager(), FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT, fragmentList viewPager.setAdapter(myPagerAdapter) // 设置预加载为3页,来测试懒加载是否成功 viewPager.offscreenPageLimit = 3 class MyPagerAdapter( fm: FragmentManager, behavior: Int, val fragmentList: List<Fragment> FragmentPagerAdapter(fm, behavior) { override fun getCount() = fragmentList.size override fun getItem(position: Int) = fragmentList[position] }FragmentPagerAdapter 在创建 Fragment后,根据 behavior 调用了setMaxLifecycle。//FragmentPagerAdapter.java public FragmentPagerAdapter(@NonNull FragmentManager fm, @Behavior int behavior) { mFragmentManager = fm; mBehavior = behavior; @Override public Object instantiateItem(@NonNull ViewGroup container, int position) { if (fragment != mCurrentPrimaryItem) { fragment.setMenuVisibility(false); // mBehaviour为1的时候走新逻辑 if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { // 初始化item时将其生命周期限制为STARTED mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED); } else { // 兼容旧版逻辑 fragment.setUserVisibleHint(false); return fragment; @Override public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) { Fragment fragment = (Fragment)object; if (fragment != mCurrentPrimaryItem) { if (mCurrentPrimaryItem != null) { mCurrentPrimaryItem.setMenuVisibility(false); if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { // 滑走的会变成非主item, 设置其Lifecycle为STARTED mCurTransaction.setMaxLifecycle(mCurrentPrimaryItem, Lifecycle.State.STARTED); } else { mCurrentPrimaryItem.setUserVisibleHint(false); fragment.setMenuVisibility(true); if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { // 设置新滑到的主item的Lifecycle为RESUMED mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED); } else { fragment.setUserVisibleHint(true); mCurrentPrimaryItem = fragment; }不借助 behavior,在自定义Adapter中构建 Framgent时直接调用setMaxLifecycle 也是等价的。setMaxLifecycle 实现原理setMaxLifecycle 使用方法很简单,接下来通过梳理源码了解一下实现原理(基于1.3.0-rc01),即使面试官追问其原理你也能沉着应对。OP_SET_MAX_LIFECYCLE我们知道 FramgentTransition 对 Fragment 的所有操作都将转换为一个Op,针对setMaxLifecycle也同样增加了一个新的Op -- OP_SET_MAX_LIFECYCLE, 专门用来设置生命周期的上限。@NonNull public FragmentTransaction setMaxLifecycle(@NonNull Fragment fragment, @NonNull Lifecycle.State state) { addOp(new Op(OP_SET_MAX_LIFECYCLE, fragment, state)); return this; }当 FramgentTransition 对 Frament 添加了 OP_SET_MAX_LIFECYCLE 后,在实现类 BackStackRecord 中, FragmentManager 会遍历 Transaction 的 Op 列表void executeOps() { final int numOps = mOps.size(); for (int opNum = 0; opNum < numOps; opNum++) { final Op op = mOps.get(opNum); final Fragment f = op.mFragment; //... switch (op.mCmd) { //... // 新引入的这个Op类型, 在这里会给这个Fragment设置允许的生命周期上限 case OP_SET_MAX_LIFECYCLE: mManager.setMaxLifecycle(f, op.mCurrentMaxState); break; //... }当遇到 OP_SET_MAX_LIFECYCLE 时,通过调用 FragmentManager 的 setMaxLifeCycle 方法设置 fragment 的 mMaxState,以标记其生命周期上限void setMaxLifecycle(@NonNull Fragment f, @NonNull Lifecycle.State state) { //... f.mMaxState = state; }FragmentStateManagerFragmentManager 通过 FragmentStateManager 推进 Fragment 的生命周期。 推进过程中根据 mMaxState 对生命周期 值得一提的是,FragmentStateManager 是 1.3.0-alpha08 之后新增的类,将原来和 State 相关的逻辑从FragmentManager 抽离了出来, 降低了与 Fragment 的耦合, 职责更加单一。看一下在 FragmentStateManager 中具体是如何推进 Fragment 生命周期的:void moveToExpectedState() { try { // 循环计算声明周期是否可以推进 while ((newState = computeExpectedState()) != mFragment.mState) { if (newState > mFragment.mState) { // 生命周期向前推进 int nextStep = mFragment.mState + 1; //... switch (nextStep) { //... case Fragment.ACTIVITY_CREATED: //... case Fragment.STARTED: start(); break; //... case Fragment.RESUMED: resume(); break; } else { // 如果应有的生命周期小于当前, 后退 int nextStep = mFragment.mState - 1; //... switch (nextStep) { // 与上面的switch类似 //... }int computeExpectedState() { // 其他计算expected state的逻辑, 算出maxState //... // mMaxState 对生命周期做出限制 switch (mFragment.mMaxState) { case RESUMED: break; case STARTED: maxState = Math.min(maxState, Fragment.STARTED); break; case CREATED: maxState = Math.min(maxState, Fragment.CREATED); break; default: maxState = Math.min(maxState, Fragment.INITIALIZING); // 其他计算expected state的逻辑, 算出 maxState // ... return maxState; }整体流程图如下最后除了使用默认的 BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT,我们甚至可以在自定义 Adapter 的instantiateItem 中为将 Fragment的 MaxLifecycle 设置为 CREATED, 这样可以让 Fragment 只走到onCreate 从而延迟更多操作,比如在 onCreateView 中的 inflate 以及 onViewCreated 中的一些操作。 Fragment 1.3.0-rc01 已经支持设置最大生命周期为 INITIALIZED

鸿蒙HarmonyOS应用开发初体验

1. 开发环境搭建下载安装IDE(当前版本 2.1 Beta3)华为为Harmony应用开发提供了配套的IDE:DevEco Studio(内心比较排斥这种带Eco字眼儿的命名,PPT怎么吹无所谓,开发工具咱能不能务实一点儿?)下载IDE需要登录Huawei账号,我安装的是Mac版,下载后的安装过程还是比较顺畅的启动界面显示DevEco Studio仍然是基于IntelliJ的定制IDE下载SDK跟Android一样,IDE启动第一件事情是下载Harmony SDK每个版本的SDK中都提供了三套API用来开发Java、Js、C++代码,版本上需要保持一致。不同的华为设备对SDK版本有不同要求,比如在测试中发现,我的API4的代码无法运行在P40上,改为API5就OK了关于SDK源码需要注意,目前无法通过SDKManager打包下载源码,源码需要通过gitee单独下载https://gitee.com/openharmony这为代码调试带来障碍,不知道后期是否可以像Andoird那样与SDK一起打包下载源码创建项目Harmony主打多端协同,所以很重视设备多样性,可面向不同设备创建模板项目相比AndroidStudio,Harmony提供了更加丰富的项目模板,模板中除了UI以外还提供了部分数据层代码,基本上是一个可以二次开发的APP。<br/>2. 鸿蒙项目结构IDE界面试着创建了一个News Feature Ability(新闻流)的模板项目,成功在IDE中打开:IDE窗口与AndroidStudio类似,值得一提的Harmony右边提供的Preview窗口,可以对xml或者Ablitiy文件进行预览,有点Compose的Preview的感觉,但是只能静态预览,无法交互工程文件工程文件和Android类似,甚至可以找到一一对应的关系HarmonyAndroid说明entryapp默认启动模块(主模块),相当于app_moduleMyApplicationXXXApplication鸿蒙的MyApplication是AbilityPackage的子类MainAbilityMainActivity入口页。鸿蒙中将四大组件的概念统一成AbilityMainAbilityListSliceXXXFragmentSlice类似Fragment,UI的基本组成单元ComponentViewComponent类相当于View,后文介绍config.jsonAndroidManifest.xml鸿蒙使用json替代xml进行Manifest配置,配置项目差不多resources/base/...res/...包括Layout文件在内的各种资源文件依旧使用xmlresources/rawfile/assets/rawfile存储任意格式原始资源,相当于assetsbuild.gradlebuild.gradle编译脚本,两者一样build/outpus/.../*.hapbuild/outputs/.../*.apk鸿蒙的产物是hap(harmony application package) <br/> 解压后里面有一个同名的.apk文件, <br/> 这后续是因为鸿蒙需要同时支持apk安装的兼容方案AbilityAbility是应用所具备能力的抽象,Harmony支持应用以Ability为单位进行部署。一个应用由一个或多个FA(Feature Ability)或PA(Particle Ability)组成。FA有UI界面,提供与用户交互的能力;而PA无UI界面,提供后台运行任务的能力以及统一的数据访问抽象FA支持Page Ability:Page Ability用于提供与用户交互的能力。一个Page可以由一个或多个AbilitySlice构成,AbilitySlice之间可以进行页面导航PA支持Service Ability和Data Ability:Service Ability:用于提供后台运行任务的能力。Data Ability:用于对外部提供统一的数据访问抽象。可以感觉到,各种Ability可以对照Android的四大组件来理解HarmonyAndroidPage Ability (FA)ActivityService Ability (PA)ServiceData Ability(PA)ContentProviderAbilitySliceFragment代码一览MainAbility以预置的News Feature Ability为例子,这是一个拥有两个Slice的Page Ability,通过Router注册两个Slicepublic class MainAbility extends Ability { @Override public void onStart(Intent intent) { super.onStart(intent); super.setMainRoute(MainAbilityListSlice.class.getName()); //添加路由:ListSlice addActionRoute("action.detail", MainAbilityDetailSlice.class.getName());//DetailSlice }以下是在模拟器中运行两个Slice的页面效果MainAbilityListSliceMainAbilityDetailSliceMainAbilityListSlice主要看一下列表的显示逻辑public class MainAbilityListSlice extends AbilitySlice { @Override public void onStart(Intent intent) { super.onStart(intent); super.setUIContent(ResourceTable.Layout_news_list_layout); initView(); initData(); //加载数据 initListener(); newsListContainer.setItemProvider(newsListAdapter); //Adatper设置到View newsListAdapter.notifyDataChanged(); //刷新数据 private void initListener() { newsListContainer.setItemClickedListener((listContainer, component, i, l) -> { //路由跳转"action.detail" LogUtil.info(TAG, "onItemClicked is called"); Intent intent = new Intent(); Operation operation = new Intent.OperationBuilder() .withBundleName(getBundleName()) .withAbilityName("com.example.myapplication.MainAbility") .withAction("action.detail") .build(); intent.setOperation(operation); startAbility(intent); private void initData() { totalNewsDatas = new ArrayList<>(); newsDatas = new ArrayList<>(); initNewsData();//填充newsDatas newsListAdapter = new NewsListAdapter(newsDatas, this);//设置到Adapter }类似ListView的用法,通过Adatper加载数据; setItemClickedListener中通过路由跳转MainAbilityDetailSlice。Layout_news_list_layout布局文件定义如下,ListContainer即ListView,是Comopnent的一个子类,Component就是HarmonyOS中的View<?xml version="1.0" encoding="utf-8"?> <DirectionalLayout xmlns:ohos="http://schemas.huawei.com/res/ohos" ohos:height="match_parent" ohos:width="match_parent" ohos:orientation="vertical"> <ListContainer ohos:id="$+id:selector_list" ohos:height="40vp" ohos:width="match_parent" ohos:orientation="horizontal" /> <Component ohos:height="0.5vp" ohos:width="match_parent" ohos:background_element="#EAEAEC" /> <ListContainer ohos:id="$+id:news_container" ohos:height="match_parent" ohos:width="match_parent"/> </DirectionalLayout>看一下Adapter的实现, 继承自BaseItemProvider/** * News list adapter public class NewsListAdapter extends BaseItemProvider { private List<NewsInfo> newsInfoList; private Context context; public NewsListAdapter(List<NewsInfo> listBasicInfo, Context context) { this.newsInfoList = listBasicInfo; this.context = context; @Override public int getCount() { return newsInfoList == null ? 0 : newsInfoList.size(); @Override public Object getItem(int position) { return Optional.of(this.newsInfoList.get(position)); @Override public long getItemId(int position) { return position; @Override public Component getComponent(int position, Component componentP, ComponentContainer componentContainer) { ViewHolder viewHolder = null; Component component = componentP; if (component == null) { component = LayoutScatter.getInstance(context).parse(ResourceTable.Layout_item_news_layout, null, false); viewHolder = new ViewHolder(); Component componentTitle = component.findComponentById(ResourceTable.Id_item_news_title); Component componentImage = component.findComponentById(ResourceTable.Id_item_news_image); if (componentTitle instanceof Text) { viewHolder.title = (Text) componentTitle; if (componentImage instanceof Image) { viewHolder.image = (Image) componentImage; component.setTag(viewHolder); } else { if (component.getTag() instanceof ViewHolder) { viewHolder = (ViewHolder) component.getTag(); if (null != viewHolder) { viewHolder.title.setText(newsInfoList.get(position).getTitle()); viewHolder.image.setScaleMode(Image.ScaleMode.STRETCH); return component; * ViewHolder which has title and image private static class ViewHolder { Text title; Image image; }基本上就是标准的ListAdatper,把View替换成Component而已。关于模拟器代码完成后可以再模拟器中运行。关于模拟器有几点想说的:Harmony的模拟器启动非常快,无需下载镜像,因为这个模拟器并非本地运行,而只是一个远端设备的VNC,因此必须在线使用,而且不够流畅时有丢帧现象。虽然真机调试效果更好,但不是人人都买得起P40的模拟器嵌入到IDE窗口显示(像Preview窗口一样),非独立窗口,这会带来一个问题,当同时打开多个IDE时,模拟器可能会显示在另一个IDE中(就像Logcat跑偏一样)。想使用模拟器必须进过开发者认证,官方推荐使用银行卡认证。模拟器远端链接的是一台真实设备,难道是为未来租用设备要计费??记得以前看过一篇文章,如果是来自国外地区的注册账号可以免认证使用模拟器,但是懒得折腾了<br/>3. 开发JS应用除了Java,鸿蒙还支持基于JS开发应用,借助前端技术完善其跨平台能力。鸿蒙为JS工程提供了多种常用UI组件,但是没有采用当下主流的react、vue那样JS组件,仍然是基于CSS3/HTML5/JS这种传统方式进行开发。JS工程结构如下目录说明common可选,用于存放公共资源文件,如媒体资源、自定义组件和JS文档等i18n可选,用于存放多语言的json文件pages/index/index.hmlhml文件定义了页面的布局结构,使用到的组件,以及这些组件的层级关系pages/index/index.csscss文件定义了页面的样式与布局,包含样式选择器和各种样式属性等pages/index/index.jsjs文件描述了页面的行为逻辑,此文件里定义了页面里所用到的所有的逻辑关系,比如数据、事件等resources可选,用于存放资源配置文件,比如:全局样式、多分辨率加载等配置文件app.js全局的JavaScript逻辑文件和应用的生命周期管理。<br/>4. 跨设备迁移通过前面的介绍,可能感觉和Android大同小异,但是HarmonyOS最牛逼之处是多端协作能力,例如可以将Page在同一用户的不同设备间迁移,实现无缝切换。以Page从设备A迁移到设备B为例,迁移动作主要步骤如下:设备A上的Page请求迁移。HarmonyOS回调设备A上Page的保存数据方法,用于保存迁移必须的数据。HarmonyOS在设备B上启动同一个Page,并回调其恢复数据方法。通过调用continueAbility()请求迁移。如下,获取设备列表,配对成功后请求迁移doConnectImg.setClickedListener( clickedView -> { // 通过FLAG_GET_ONLINE_DEVICE标记获得在线设备列表 List deviceInfoList = DeviceManager.getDeviceList(DeviceInfo.FLAG_GET_ONLINE_DEVICE); if (deviceInfoList.size() < 1) { WidgetHelper.showTips(this, "无在网设备"); } else { DeviceSelectDialog dialog = new DeviceSelectDialog(this); // 点击后迁移到指定设备 dialog.setListener( deviceInfo -> { LogUtil.debug(TAG, deviceInfo.getDeviceName()); LogUtil.info(TAG, "continue button click"); try { // 开始任务迁移 continueAbility(); LogUtil.info(TAG, "continue button click end"); } catch (IllegalStateException | UnsupportedOperationException e) { WidgetHelper.showTips(this, ResourceTable.String_tips_mail_continue_failed); dialog.hide(); dialog.show(); Page迁移涉及到数据传递,此时需要借助IAbilityContinuation进行通信。跨设备通信 IAbilityContinuation跨设备迁移的Page需要实现IAbilityContinuation接口。Note: 一个应用可能包含多个Page,仅需要在支持迁移的Page中通过以下方法实现IAbilityContinuation接口。同时,此Page所包含的所有AbilitySlice也需要实现此接口。public class MainAbility extends Ability implements IAbilityContinuation { @Override public void onCompleteContinuation(int code) {} @Override public boolean onRestoreData(IntentParams params) { return true; @Override public boolean onSaveData(IntentParams params) { return true; @Override public boolean onStartContinuation() { return true; public class MailEditSlice extends AbilitySlice implements IAbilityContinuation { @Override public boolean onStartContinuation() { LogUtil.info(TAG, "is start continue"); return true; @Override public boolean onSaveData(IntentParams params) { LogUtil.info(TAG, "begin onSaveData:" + mailData); LogUtil.info(TAG, "end onSaveData"); return true; @Override public boolean onRestoreData(IntentParams params) { LogUtil.info(TAG, "begin onRestoreData"); LogUtil.info(TAG, "end onRestoreData, mail data: " + cachedMailData); return true; @Override public void onCompleteContinuation(int i) { LogUtil.info(TAG, "onCompleteContinuation"); terminateAbility(); }onStartContinuation(): Page请求迁移后,系统首先回调此方法,开发者可以在此回调中决策当前是否可以执行迁移,比如,弹框让用户确认是否开始迁移。onSaveData(): 如果onStartContinuation()返回true,则系统回调此方法,开发者在此回调中保存必须传递到另外设备上以便恢复Page状态的数据。onRestoreData(): 源侧设备上Page完成保存数据后,系统在目标侧设备上回调此方法,开发者在此回调中接受用于恢复Page状态的数据。注意,在目标侧设备上的Page会重新启动其生命周期,无论其启动模式如何配置。且系统回调此方法的时机在onStart()之前。onCompleteContinuation(): 目标侧设备上恢复数据一旦完成,系统就会在源侧设备上回调Page的此方法,以便通知应用迁移流程已结束。开发者可以在此检查迁移结果是否成功,并在此处理迁移结束的动作,例如,应用可以在迁移完成后终止自身生命周期。以Page从设备A迁移到设备B为例,详细的流程如下:设备A上的Page请求迁移。系统回调设备A上Page及其AbilitySlice栈中所有AbilitySlice实例的IAbilityContinuation.onStartContinuation()方法,以确认当前是否可以立即迁移。如果可以立即迁移,则系统回调设备A上Page及其AbilitySlice栈中所有AbilitySlice实例的IAbilityContinuation.onSaveData()方法,以便保存迁移后恢复状态必须的数据。如果保存数据成功,则系统在设备B上启动同一个Page,并恢复AbilitySlice栈,然后回调IAbilityContinuation.onRestoreData()方法,传递此前保存的数据;此后设备B上此Page从onStart()开始其生命周期回调。系统回调设备A上Page及其AbilitySlice栈中所有AbilitySlice实例的IAbilityContinuation.onCompleteContinuation()方法,通知数据恢复成功与否。<br/>5. 总结和感想从SDK到IDE与Android都高度相似,任何Android开发者基本上就是一个准鸿蒙程序员AndroidStudio的功能迭代很快,DevEco Studio在功能上还存在较大差距需要实名认证开发者之后才能使用IDE的各种完整功能,内心抗拒源码需要另外下载,对调试不友好当前还不支持Kotlin。大势所趋,所以未来一定会支持,而且Kotlin是开源的问题不大JS UI框架的技术架构同样有些过时杀手锏是对多端协作的支持,但这可能需要更多的厂商加入才能真正发挥威力目前人们对于鸿蒙的态度呈现两极化,有的人追捧有的人贬低,我觉得都大可不必,多给鸿蒙一些空间和耐心,静观其变、乐见其成。当然首选需要华为做到自己不主动炒作,真正静下心来打磨鸿蒙,只要华为有决心有耐心,作为开发者的我们为什么不支持呢?Harmony线上挑战赛伴随开发者活动日,鸿蒙还举办了多轮线上挑战赛活动(目前还在进行中),难度不高参与即能完成,且中奖率很高(亲测),有兴趣可以参与一下,希望我的好运传递给你活动详情:https://mp.weixin.qq.com/s/3IrZGZkm1GGNNWzJvytegw相关链接HarmonyOS官方 https://www.harmonyos.com/cn/homeHarmonyOS开发者文档 https://developer.harmonyos.com/cn/documentationHarmonyOS源码 https://gitee.com/openharmony

一道面试题:ViewModel为什么横竖屏切换时不销毁?

往年面试中有关Jetpack的考察可以算是加分项,随着官方对Modern Android development (MAD) 的大力推广,今年基本上都是必选题了。很多候选人对Jetpack各组件的功能及用法如数家珍,但一问及到原理往往卡壳。原理不清虽不影响API的使用,但也正因为如此,如果能对源码有一定了解,也许可以脱颖而出得到加分。本文分享一个入门级的源码分析,也是在面试中经常被问到的问题ViewModelViewModel是Android Jetpack中的重要组件,其优势是具有下图这样的生命周期、不会因为屏幕旋转等Activity配置变化而销毁,是实现MVVM架构中UI状态管理的重要基础。class MyActivity : AppCompatActivity { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d(TAG, "onCreate") val activity: FragmentActivity = this val factory: ViewModelProvider.Factory = ViewModelProvider.NewInstanceFactory() // Activity由于横竖品切换销毁重建,此处的viewModel 仍然是重建前的实例 val viewModel = ViewModelProvider(activity, factory).get(MyViewModel::class.java) // 如果直接new实例则会创建新的ViewModel实例 // val viewModel = MyViewModel() Log.d(TAG, " - Activity :${this.hashCode()}") Log.d(TAG, " - ViewModel:${viewModel.hashCode()}") }上面代码在横竖屏切换时的log如下:#Activity初次启动 onCreate - Activity :132818886 - ViewModel:249530701 onStart onResume #屏幕旋转 onPause onStop onRetainNonConfigurationInstance onDestroy onCreate - Activity :103312713 #Activity实例不同 - ViewModel:249530701 #ViewModel实例相同 onStart onResume下面代码是保证屏幕切换时ViewModel不销毁的关键,我们依次为入口看一下源码val viewModel = ViewModelProvider(activity, factory).get(MyViewModel::class.java)ViewModelProviderViewModelProvider源码很简单,分别持有一个ViewModelProvider.Factory和ViewModelStore实例package androidx.lifecycle; public class ViewModelProvider { public interface Factory { @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass); private final Factory mFactory; private final ViewModelStore mViewModelStore; public ViewModelProvider(@NonNull ViewModelStoreOwner owner, @NonNull Factory factory) { this(owner.getViewModelStore(), factory); public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) { mFactory = factory; this.mViewModelStore = store; }get()返回ViewModel实例package androidx.lifecycle; public class ViewModelProvider { public <T extends ViewModel> T get(@NonNull Class<T> modelClass) { String canonicalName = modelClass.getCanonicalName(); return get(DEFAULT_KEY + ":" + canonicalName, modelClass); public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) { ViewModel viewModel = mViewModelStore.get(key); if (modelClass.isInstance(viewModel)) { //noinspection unchecked return (T) viewModel; } else { //noinspection StatementWithEmptyBody if (viewModel != null) { // TODO: log a warning. viewModel = mFactory.create(modelClass); mViewModelStore.put(key, viewModel); //noinspection unchecked return (T) viewModel; }逻辑非常清晰:ViewModelProvider通过ViewModelStore获取ViewModel若获取失败,则通过ViewModelProvider.Factory创建ViewModelViewModelStorepackage androidx.lifecycle; public class ViewModelStore { private final HashMap<String, ViewModel> mMap = new HashMap<>(); final void put(String key, ViewModel viewModel) { ViewModel oldViewModel = mMap.put(key, viewModel); if (oldViewModel != null) { oldViewModel.onCleared(); final ViewModel get(String key) { return mMap.get(key); public final void clear() { for (ViewModel vm : mMap.values()) { vm.onCleared(); mMap.clear(); }可见,ViewModelStore就是一个对Map的封装。val viewModel = ViewModelProvider(activity, factory).get(FooViewModel::class.java)上面代码ViewModelProvider()构造参数1中传入的FragmentActivity(基类是ComponentActivity)实际上是ViewModelStoreOwner的一个实现。package androidx.lifecycle; public interface ViewModelStoreOwner { @NonNull ViewModelStore getViewModelStore(); }ViewModelProvider中的ViewModelStore正是来自ViewModelStoreOwner。public class ViewModelProvider { private final ViewModelStore mViewModelStore; public ViewModelProvider(@NonNull ViewModelStoreOwner owner, @NonNull Factory factory) { this(owner.getViewModelStore(), factory); public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) { this.mViewModelStore = store; }Activity在onDestroy会尝试对ViewModelStore清空。如果是由于ConfigurationChanged带来的Destroy则不进行清空,避免横竖屏切换等造成ViewModel销毁。//ComponentActivity.java getLifecycle().addObserver(new LifecycleEventObserver() { @Override public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { if (event == Lifecycle.Event.ON_DESTROY) { // Clear out the available context mContextAwareHelper.clearAvailableContext(); // And clear the ViewModelStore if (!getLifecycle().addObserver(new LifecycleEventObserver() { @Override public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { if (event == Lifecycle.Event.ON_DESTROY) { // Clear out the available context mContextAwareHelper.clearAvailableContext(); // And clear the ViewModelStore if (!isChangingConfigurations()) { getViewModelStore().clear(); });()) { getViewModelStore().clear(); });<br/>FragmentActivity#getViewModelStore()FragmentActivity实现了ViewModelStoreOwner的getViewModelStore方法package androidx.fragment.app; public class FragmentActivity extends ComponentActivity implements ViewModelStoreOwner ... { private ViewModelStore mViewModelStore; @NonNull @Override public ViewModelStore getViewModelStore() { if (mViewModelStore == null) { NonConfigurationInstances nc = (NonConfigurationInstances) getLastNonConfigurationInstance(); if (nc != null) { // Restore the ViewModelStore from NonConfigurationInstances mViewModelStore = nc.viewModelStore; if (mViewModelStore == null) { mViewModelStore = new ViewModelStore(); return mViewModelStore; static final class NonConfigurationInstances { Object custom; ViewModelStore viewModelStore; FragmentManagerNonConfig fragments; }通过getLastNonConfigurationInstance() 获取 NonConfigurationInstances 实例,从而得到真正的viewModelStore,getLastNonConfigurationInstance()又是什么?<br/>Activity#getLastNonConfigurationInstance()package android.app; public class Activity extends ContextThemeWrapper implements ... { /* package */ NonConfigurationInstances mLastNonConfigurationInstances; @Nullable public Object getLastNonConfigurationInstance() { return mLastNonConfigurationInstances != null ? mLastNonConfigurationInstances.activity : null; }Retrieve the non-configuration instance data that was previously returned by onRetainNonConfigurationInstance(). This will be available from the initial onCreate(Bundle) and onStart() calls to the new instance, allowing you to extract any useful dynamic state from the previous instance.通过官方文档我们知道,屏幕旋转前通过onRetainNonConfigurationInstance()返回的Activity实例,屏幕旋转后可以通过getLastNonConfigurationInstance()获取,因此屏幕旋转前后不销毁的关键就在onRetainNonConfigurationInstanceActivity#onRetainNonConfigurationInstance()#Activity初次启动 onCreate - Activity :132818886 - ViewModel:249530701 onStart onResume #屏幕旋转 onPause onStop onRetainNonConfigurationInstance onDestroy onCreate - Activity :103312713 #Activity实例不同 - ViewModel:249530701 #ViewModel实例相同 onStart onResume屏幕旋转时,onRetainNonConfigurationInstance()在onStop和onDestroy之间调用package android.app; public class Activity extends ContextThemeWrapper implements ... { public Object onRetainNonConfigurationInstance() { return null; }onRetainNonConfigurationInstance在Activity中只有空实现,在FragmentActivity中被重写package androidx.fragment.app; public class FragmentActivity extends ComponentActivity implements ViewModelStoreOwner, ... { @Override public final Object onRetainNonConfigurationInstance() { Object custom = onRetainCustomNonConfigurationInstance(); FragmentManagerNonConfig fragments = mFragments.retainNestedNonConfig(); if (fragments == null && mViewModelStore == null && custom == null) { return null; NonConfigurationInstances nci = new NonConfigurationInstances(); nci.custom = custom; nci.viewModelStore = mViewModelStore; nci.fragments = fragments; return nci; static final class NonConfigurationInstances { Object custom; ViewModelStore viewModelStore; FragmentManagerNonConfig fragments; }FragmentActivity 通过 onRetainNonConfigurationInstance() 返回 了存放ViewModelStore的NonConfigurationInstances 实例。值得一提的是onRetainNonConfigurationInstance提供了一个hook时机:onRetainCustomNonConfigurationInstance,允许我们像ViewModel一样使得自定义对象不被销毁NonConfigurationInstances会在attach中由系统传递给新重建的Activity:final void attach(Context context, ActivityThread aThread, Instrumentation instr, IBinder token, int ident, Application application, Intent intent, ActivityInfo info, CharSequence title, Activity parent, String id, NonConfigurationInstances lastNonConfigurationInstances, Configuration config, String referrer, IVoiceInteractor voiceInteractor, Window window, ActivityConfigCallback activityConfigCallback, IBinder assistToken) 然后在onCreate中,通过getLastNonConfigurationInstance()获取NonConfigurationInstances中的ViewModelStorepackage androidx.fragment.app; public class FragmentActivity extends ComponentActivity implements ViewModelStoreOwner ... { private ViewModelStore mViewModelStore; @SuppressWarnings("deprecation") @Override protected void onCreate(@Nullable Bundle savedInstanceState) { mFragments.attachHost(null /*parent*/); super.onCreate(savedInstanceState); NonConfigurationInstances nc = (NonConfigurationInstances) getLastNonConfigurationInstance(); if (nc != null && nc.viewModelStore != null && mViewModelStore == null) { mViewModelStore = nc.viewModelStore; }总结Activity首次启动FragmentActivity#onCreate()被调用此时 FragmentActivity 的 mViewModelStore 尚为 nullHogeActivity的onCreate() 被调用ViewModelProvider 实例创建FragmentActivity#getViewModelStore() 被调用,mViewModelStore被创建并赋值发生屏幕旋转FragmentActivity#onRetainNonConfigurationInstance() 被调用持有mViewModelStore 的NonConfigurationInstances 实例被返回Activity重建FragmentActivity#onCreate() 被调用从Activity#getLastNonConfigurationInstance() 获取 NonConfigurationInstances 实例NonConfigurationInstances 中保存了屏幕旋转前的 FragmentActivity 的 mViewModelStore,将其赋值给重建后的FragmentActivity 的 mViewModelStoreHogeActivity#onCreate() 被调用通过ViewModelProvider#get() 获取 ViewModel 实例

线程切换哪家强?RxJava与Flow的操作符对比

Flow作为Coroutine版的RxJava,同RxJava一样可以方便地进行线程切换。本文针对两者在多线程场景中的使用区别进行一个简单对比。1. RxJava我们先来回顾一下RxJava中的线程切换如上,RxJava使用subscriberOn与observeOn进行线程切换subscribeOnsubscribeOn用来决定在哪个线程进行订阅,对于Cold流来说即决定了数据的发射线程。使用中有两点注意:当调用链上只有一个subscribeOn时,可以出现在任意位置上面两种写法效果是一样的:都是在io线程订阅后发射数据当调用链上有多个subscribeOn时,只有第一个生效:上面第二个subscribeOn没有意义observeOnobserveOn用来决定在哪个线程上响应:observeOn决定调用链上下游操作符执行的线程上面绿线部分的代码将会运行在主线程与subscribeOn不同,调用链上允许存在多个observeOn且每个都有效上面蓝色绿色部分因为observeOn的存在分别切换到了不同线程执行justRxJava的初学者经常会犯的一个错误是在Observable.just(...)里做耗时任务。 just并不是接受lambda,所以是立即执行的,不受subscribeOn的影响如上,loadDataSync()不会在io执行,想要在io执行,需要使用Observable.deffer{}flatMap结合上面介绍的RxJava的线程切换,看下面这段代码如果我们希望loadData(id)并发执行,那么上面的写法是错误的。subscribe(io())意味着其上游的数据在单一线程中串行发射。因此虽然flatMap{}返回多个Observable, 都是都在单一线程中订阅,多个loadData始终运行在同一线程。代码经过一下修改后,可以达到并发执行的效果:当订阅flatMap返回的Observable时,通过subscribeOn分别指定订阅线程。其他类似flatMap这种涉及多个Observable订阅的操作符(例如merge、zip等),需要留意各自的subscribeOn的线程,以防不符合预期的行为出现。2. Flow接下来看一下 Flow的线程切换 。Flow是基于CoroutineContext进行线程切换,所以这部分内容需要你对Croutine事先有基本的了解。flowOn类似于RxJava的subscribeOn,Flow中没有对应observeOn的操作符,因为collect是一个suspend函数,必须在CoroutineScope中执行,所以响应线程是由CoroutineContext决定的。例如你在main中执行collect,那么响应线程就是Dispatcher.MainflowOn说flowOn类似于subscribeOn,因为它们都可以用来决定上游线程上面代码中,flowOn前面代码将会在IO执行。与subscribeOn不同的是,flowOn允许出现多次,每个都会影响其前面的操作上面代码,根据颜色可以看出来flowOn影响的范围launchIncollect是suspend函数,所以后续代码因为协程挂起不会继续执行所以上面代码可能会不符合预期,因为第一个collect不走完第二个走不到。正确的写法是为每个collect单独起一个协程或者使用launchIn,写法更加优雅launchIn不会挂起协程,所以与RxJava的subscribe更加接近。通过名字可以感觉出来launchIn只不过是之前例子中launch的一个链式调用的语法糖。flowOfflowOf类似于Observable.just(),需要注意flowOf内的内容是立即执行的,不受flowOn影响希望calculate()运行在IO,可以使用flow{ }flatMapMergeflatMapMerge类似RxJava的flatMap如上,2个item各自flatMap成2个item,即一共发射了4条数据,日志输出如下:inner: pool-2-thread-2 @coroutine#4 inner: pool-2-thread-3 @coroutine#5 inner: pool-2-thread-3 @coroutine#5 inner: pool-2-thread-2 @coroutine#4 collect: pool-1-thread-2 @coroutine#2 collect: pool-1-thread-2 @coroutine#2 collect: pool-1-thread-2 @coroutine#2 collect: pool-1-thread-2 @coroutine#2通过日志我们发现flowOn虽然写在flatMapMerge外面,inner的日志却可以打印在多个线程上(都来自pool2线程池),这与flatMap是不同的,同样场景下flatMap只能运行在线程池的固定线程上。如果将flowOn写在flatMapMerge内部结果如下:inner: pool-2-thread-2 @coroutine#6 inner: pool-2-thread-1 @coroutine#7 inner: pool-2-thread-2 @coroutine#6 inner: pool-2-thread-1 @coroutine#7 collect: pool-1-thread-3 @coroutine#2 collect: pool-1-thread-3 @coroutine#2 collect: pool-1-thread-3 @coroutine#2 collect: pool-1-thread-3 @coroutine#2inner仍然打印在多个线程,flowOn无论写在flatMapMerge内部还是外部,对flatMapMerge内的处理没有区别。但是flatMapMerge之外还是有区别的,看下面两段代码通过颜色可以知道flowOn影响的范围,向上追溯到flowOf为止3. SummaryRxJava的Observable与Coroutine的Flow都支持线程切换,相关API的对比如下: 线程池调度线程操作符数据源同步创建异步创建并发执行RxJavaSchedulers (io(), computation(), mainThread())subscribeOn, observeOnjustdeffer{}flatMap(inner subscribeOn)FlowDispatchers (IO, Default, Main)flowOnflowOfflow{}flatMapMerge(inner or outer flowOn)最后通过一个例子看一下如何将代码从RxJava迁移到FlowRxJavaRxJava代码如下:使用到的Schedulers定义如下:代码执行结果:1: pool-1-thread-1 1: pool-1-thread-1 1: pool-1-thread-1 2: pool-3-thread-1 2: pool-3-thread-1 2: pool-3-thread-1 inner 1: pool-4-thread-1 inner 1: pool-4-thread-2 inner 1: pool-4-thread-1 inner 1: pool-4-thread-1 inner 1: pool-4-thread-2 inner 1: pool-4-thread-2 inner 1: pool-4-thread-3 inner 2: pool-5-thread-1 inner 2: pool-5-thread-2 3: pool-5-thread-1 inner 2: pool-5-thread-2 inner 1: pool-4-thread-3 inner 2: pool-5-thread-2 inner 2: pool-5-thread-3 3: pool-5-thread-1 3: pool-5-thread-1 3: pool-5-thread-1 end: pool-6-thread-1 end: pool-6-thread-1 inner 1: pool-4-thread-3 end: pool-6-thread-1 3: pool-5-thread-1 inner 2: pool-5-thread-1 3: pool-5-thread-1 inner 2: pool-5-thread-3 inner 2: pool-5-thread-1 end: pool-6-thread-1 3: pool-5-thread-3 3: pool-5-thread-3 end: pool-6-thread-1 inner 2: pool-5-thread-3 3: pool-5-thread-3 end: pool-6-thread-1 end: pool-6-thread-1 end: pool-6-thread-1 end: pool-6-thread-1代码较长,通过颜色标记法帮我们理清线程关系上色后一目了然了,需要特别注意的是由于flatMap中切换了数据源的同时切换了线程,所以打印 3的线程不是s2 而是 s4Flow首相创建对应的Dispatcher然后将代码换成Flow的写法,主要遵循下列原则RxJava通过observeOn切换后续代码的线程Flow通过flowOn切换前置代码的线程打印结果如下:1: pool-1-thread-1 @coroutine#6 1: pool-1-thread-1 @coroutine#6 1: pool-1-thread-1 @coroutine#6 2: pool-2-thread-2 @coroutine#5 2: pool-2-thread-2 @coroutine#5 2: pool-2-thread-2 @coroutine#5 inner 1: pool-3-thread-1 @coroutine#10 inner 1: pool-3-thread-2 @coroutine#11 inner 1: pool-3-thread-3 @coroutine#12 inner 1: pool-3-thread-2 @coroutine#11 inner 1: pool-3-thread-3 @coroutine#12 inner 2: pool-4-thread-3 @coroutine#9 inner 1: pool-3-thread-1 @coroutine#10 inner 1: pool-3-thread-3 @coroutine#12 inner 1: pool-3-thread-2 @coroutine#11 inner 2: pool-4-thread-1 @coroutine#7 inner 2: pool-4-thread-2 @coroutine#8 inner 2: pool-4-thread-1 @coroutine#7 inner 2: pool-4-thread-3 @coroutine#9 inner 1: pool-3-thread-1 @coroutine#10 3: pool-4-thread-1 @coroutine#3 inner 2: pool-4-thread-3 @coroutine#9 inner 2: pool-4-thread-2 @coroutine#8 end: pool-5-thread-1 @coroutine#2 3: pool-4-thread-1 @coroutine#3 inner 2: pool-4-thread-2 @coroutine#8 3: pool-4-thread-1 @coroutine#3 end: pool-5-thread-1 @coroutine#2 3: pool-4-thread-1 @coroutine#3 end: pool-5-thread-1 @coroutine#2 end: pool-5-thread-1 @coroutine#2 3: pool-4-thread-1 @coroutine#3 3: pool-4-thread-1 @coroutine#3 end: pool-5-thread-1 @coroutine#2 end: pool-5-thread-1 @coroutine#2 3: pool-4-thread-1 @coroutine#3 3: pool-4-thread-1 @coroutine#3 end: pool-5-thread-1 @coroutine#2 end: pool-5-thread-1 @coroutine#2 inner 2: pool-4-thread-1 @coroutine#7 3: pool-4-thread-1 @coroutine#3 end: pool-5-thread-1 @coroutine#2从日志可以看到,1、2、3的时序性以及inner1和inner2的并发性与RxJava的一致。4. FINFlow在线程切换方面可以完全取代RxJava的能力,而且将subscribeOn和observeOn两个操作符合二为一成flowOn,学习成本更低。随着flow的操作符种类日趋完善,未来在Android/Kotlin开发中可以跟RxJava说再见了

AppCompat 用了这么久,你真的了解吗?

为了能够让低版本的Android系统能够运行新特性,AppCompat框架自Support时代就已推出。但随着AndroidX的一统江湖,AppCompat的相关类则一并迁移到了AndroidX库里。Android开发者应该都不陌生,在Android Studio上创建的项目默认采用AppCompatActivity作为Activity的基类。可以说,这个类是整个AppCompat框架里最重要的类,也是我们今天研究AppCompat的起点。<br/>AppCompatActivityAppCompatActivity间接继承自Activity,之间还继承了其他Activity特色类,可以使得低版本上运行的Activity也能拥有ToolBar和暗黑主题等新功能。classDiagram Activity <|-- androidx_core_app_ComponentActivity androidx_core_app_ComponentActivity <| -- androidx_activity_ComponentActivity androidx_activity_ComponentActivity <|-- FragmentActivity FragmentActivity <|-- AppCompatActivity Parent ClassDescriptioinFragmentActivity采用FragmentController类对AndroidX的Fragment新组件提供支撑,比如提供了咱们常用的getSupportFragmentManager() API。androidx.activity.ComponentActivity实现了ViewModel接口,和Lifecycle框架进行配合以支撑ViewModel框架的运行。androidx.core.app.ComponentActivity实现了Lifecycle接口并通过ReportFragment支撑Lifecycle框架的运行。先来感受一下AppCompatActivity和Activity在UI上的表现。从对比图上看并没有太大区别,但从UI的树形图上看是有些区别的。比如AppCompatActivity的content区域的上方多了一个LinearLayout和ViewStub控件,再比如AppCompatActivity下面的是AppCompatTextView而不是TextView。那这些差异是如何实现的,有什么用意?谈到AppCompatActivity实现的话不得不提幕后的大管家AppCompatDelegate类,其承载了AppCompatActivity几乎所有的实现工作。比如AppCompatActivity复写了setContentView()的逻辑,交由大管家AppCompatDelegate去实现其特有的UI结构。AppCompatDelegate重点介绍下大管家的头号工作setContentView(),具体分为如下几个小任务。ensureSubDecor(): 确保ActionBar的特有UI结构创建完毕removeAllViews() : 确保ContentView的所有Child全部被移除干净inflate() :将画面的内容布局解析并添加到ContentView下第一步ensureSubDecor()的内容比较多,又分为几个子任务,包括调用createSubDecor()创建ActionBar特有布局,setWindowTitle()将Activity标题反映到ToolBar上以及applyFixedSizeWindow()去调整DecorView尺寸。核心内容在于createSubDecor()这个子任务。它需要确保ActionBar的特有布局创建出来并和Window的DecorView产生联系。ensureWindow() :获取Activity所属的Window引用并添加window相关回调getDecorView() :告知Window去创建DecorView,这里要提一下PhoneWindow的generateLayout(),其将依据主题的创建不同的布局结构,比如AppCompatActivity的话将解析screen_simple.xml得到DecorView的基本结构,其包括根布局LinearLayout,用来映射actionmode布局的viewstub以及承载App内容的id为ContentViewinflate() :获取ActionBar的布局,主要是abc_screen_toolbar.xml和abc_screen_content_include.xml两个文件removeViewAt()和addView() :将ContentView的子View迁移至ActionBar布局下。具体方法是将其所有child移除并add到ActionBar布局下id为action_bar_activity_content的ViewGroup下面,并将原有ContentView的id置空,同时将该目标ViewGroup的id设置为Content。意味着它将成为AppCompatActivity画面承载内容区域的父布局公开的API除了setContentView()在打造布局结构上的差异,AppCompatActivity还提供了些Activity所没有的API供开发者使用。getSupportActionBar() :用以获取AppCompat特有的ActionBar组件供开发者定制ActionBargetDelegate() : 获取AppCompatActivity内部实现的大管家AppCompatDelegate的实例(实际上将通过静态的create()获取实现类AppCompatDelegateImpl的实例)getDrawerToggleDelegate() :获取抽屉导航布局DrawerLayout的代理类ActionBarDrawableToggleImpl的实例,用来和ActionBar进行UI的交互onNightModeChanged() :不同于配置了uiMode的外部配置变更后才能收到主题变化的通知,本API可以在暗黑主题的适配模式(比如跟随系统设置模式和跟随电量设置模式等)发生变化后得到回调,可利用这个时机做些补充处理使用上的注意AppCompatActivity的注释上有如下说明,推荐采用Theme.AppCompat主题。You can add an ActionBar to your activity when running on API level 7 or higher by extending this class for your activity and setting the activity theme to Theme.AppCompat or a similar theme.经过验证如果我们使用了别的主题就会得到如下的crash。You need to use a Theme.AppCompat theme (or descendant) with this activity.原理在于上面自己的大管家AppCompatDelegate在创建ActionBar布局的时候有意地确保Activity是否采用了AppCompatTheme主题,尤其是如果没有指定AppCompat定义的windowActionBar的属性的话,将抛出如上的异常。// AppCompatThemeImpl.java private ViewGroup createSubDecor() { TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme); if (!a.hasValue(R.styleable.AppCompatTheme_windowActionBar)) { a.recycle(); throw new IllegalStateException( "You need to use a Theme.AppCompat theme (or descendant) with this activity."); 至于为什么用异常来确保AppCompatTheme的采用,因为后续的处理跟AppCompatTheme息息相关,如果没有采用后面的很多处理将失效。<br/>AppCompatDialog除了使用极高的AppCompatActivity以外,AppCompatDialog的曝光率也不低。其实现原理和AppCompatActivity企划一致,都是依赖大管家AppCompatDelegate进行实现。一样是为了在Dialog的基础上扩<br/>AppCompatThemeAppCompatTheme主要分为两个主题:Theme.AppCompat : 继承自Base.V7.Theme.AppCompat主题,指定AppCompatViewInflater为widget等class的解析类,并设置AppCompatTheme所定义的基本属性,其顶级主题仍旧是老牌的主题Theme.HoloTheme.AppCompat.DayNight :能够自动适配暗黑主题。其继承自Base.V7.Theme.AppCompat.Light,与Theme.AppCompat的区别主要在于其默认情况下采用了light系的主题,比如colorPrimary采用primary_material_light,而Theme.AppCompat则采用primary_material_dark颜色App采用了该主题就可以自动适配暗黑模式,这是如何做到的?<br/>Dark Theme 暗黑模式AppCompatActivity在绑定BaseContext的时候会通过AppCompatDelegate的applyDayNight()去解析App设置的暗黑主题模式并做出一些相应的配置工作。比如常用的跟随省电模式,其指的是设备的省电模式开启后将自动进入暗黑主题,降低功耗。反之关闭之后返回到白天主题。具体实现是AppCompatDelegate将注册监听省电模式变化的广播(ACTION_POWER_SAVE_MODE_CHANGED)。当省电模式开启/关闭时,广播接收器将自动回调updateForNightMode()去更新对应的主题。 private boolean applyDayNight(final boolean allowRecreation) { @NightMode final int nightMode = calculateNightMode(); @ApplyableNightMode final int modeToApply = mapNightMode(nightMode); final boolean applied = updateForNightMode(modeToApply, allowRecreation); if (nightMode == MODE_NIGHT_AUTO_BATTERY) { // 注册监听省电模式的广播接收器 getAutoBatteryNightModeManager().setup(); abstract class AutoNightModeManager { void setup() { if (mReceiver == null) { mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { // 省电模式变化后的回调 onChange(); mContext.registerReceiver(mReceiver, filter); private class AutoBatteryNightModeManager extends AutoNightModeManager { @Override public void onChange() { // 省电模式变化后回调主题切换方法更新主题 applyDayNight(); @Override IntentFilter createIntentFilterForBroadcastReceiver() { if (Build.VERSION.SDK_INT >= 21) { IntentFilter filter = new IntentFilter(); filter.addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED); return filter; return null; }更新主题的处理则是如下关键代码。 private boolean updateForNightMode(final int mode, final boolean allowRecreation) { // 如果Activity的BaseContext尚未初始化则直接适配新的主题值 if ((sAlwaysOverrideConfiguration || newNightMode != applicationNightMode) && !mBaseContextAttached ...) { try { ((android.view.ContextThemeWrapper) mHost).applyOverrideConfiguration(conf); handled = true; final int currentNightMode = mContext.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; // 如果Activity的BaseContext已经创建, // 且App没有声明要处理暗黑主题变化的话,将重绘Activity if (!handled ...) { ActivityCompat.recreate((Activity) mHost); handled = true; // 假使App声明了处理暗黑主题变化的话, // 那么将新的主题值更新到Configuration的uiMode属性 // 并回调Activity#onConfigurationChanged(),等待App的自行处理 if (!handled && currentNightMode != newNightMode) { updateResourcesConfigurationForNightMode(newNightMode, activityHandlingUiMode); handled = true; // 最后检查是否要通知App暗黑主题模式发生变化 // (注意这里指的是App设置的暗黑主题切换的策略发生变更, // 比如由跟随系统设置变更为固定暗黑模式等) if (handled && mHost instanceof AppCompatActivity) { ((AppCompatActivity) mHost).onNightModeChanged(mode); }我们平常在AppCompatActivity的布局里使用的控件,最终得到的类名称里会多上AppCompat的前缀。比如声明的是TextView控件最后得到的是AppCompatTextView类的实例。这是怎么做到的,为什么这么做?这就离不开AppCompatViewInflater的默默付出。<br/>AppCompatViewInflater核心功能就是将布局里的控件切换为AppCompat版本。在调用LayoutInflater解析App布局的阶段,大管家AppCompatDelegate将调用AppCompatViewInflater将布局中的控件逐个替换。 final View createView(View parent, final String name, @NonNull Context context...) { switch (name) { case "TextView": view = createTextView(context, attrs); verifyNotNull(view, name); break; case "ImageView": view = createImageView(context, attrs); verifyNotNull(view, name); break; return view; protected AppCompatTextView createTextView(Context context, AttributeSet attrs) { return new AppCompatTextView(context, attrs); }除了上面提到的AppCompatTextView,AppCompat的widget目录下有很多为了兼容新特性扩展的控件。以AppCompatTextView和另一个常用的AppCompatImageView来一探究竟。<br/>AppCompatTextView由代码注释就可以看出来该控件在TextView的基础上增加了Dynamic Tint和Auto Size两大特性。先看下这两特性大体是什么效果。可以看到第二个TextView对背景着上了更深的绿色,并对icon着上了白色,使得它内部的icon和文字相较第一个TextView看起来更清楚。这是通过AppCompatTextView提供的backgroundTint和drawableTint属性实现的,这种给背景和icon动态着色的功能就是Dynamic Tint特性。另外可以看到最下面TextView的文本内容正好铺满整个屏幕没有在末尾出现省略,而上面那个TextView的字体尺寸较大且在尾部用省略号表示。这种自动适配字体尺寸的效果同样是依赖AppCompatTextView提供的相关属性来完成。此为Auto Size特性。Dynamic Tint主要依赖AppCompatBackgroundHelper和AppCompatDrawableManager实现,包括反映静态配置和动态修改的Tint属性。主要经历这几步:loadFromAttributes() 解析布局里配置的Tint属性,核心处理在于能够将设置的Tint资源解析成ColorStateList实例。// ColorStateListInflaterCompat.java private static ColorStateList inflate(Resources r, XmlPullParser parser) { while ((type = parser.next()) != XmlPullParser.END_DOCUMENT && ((depth = parser.getDepth()) >= innerDepth || type != XmlPullParser.END_TAG)) { final int color = modulateColorAlpha(baseColor, alphaMod); colorList = GrowingArrayUtils.append(colorList, listSize, color); stateSpecList = GrowingArrayUtils.append(stateSpecList, listSize, stateSpec); listSize++; return new ColorStateList(stateSpecs, colors); }setInternalBackgroundTint()和applySupportBackgroundTint() 负责管理和区分Tint颜色的取自静态配置的属性还是外部动态配置的参数tintDrawable()负责着色,本质在于调用Drawable#setColorFilter()去刷新颜色的绘制// ResourceManagerInternal.java static void tintDrawable(Drawable drawable, TintInfo tint, int[] state) { if (tint.mHasTintList || tint.mHasTintMode) { drawable.setColorFilter(createTintFilter( tint.mHasTintList ? tint.mTintList : null, tint.mHasTintMode ? tint.mTintMode : DEFAULT_MODE, state)); } else { drawable.clearColorFilter(); }Auto Size需要解决的问题是对Text内容依据最大宽度和当前size计算自适应的最佳字体尺寸,依赖AppCompatTextHelper和AppCompatTextViewAutoSizeHelper实现。1. 解析AutoSize相关属性的配置并设定是否需要自动适配字体尺寸的Flag。 // AppCompatTextViewAutoSizeHelper.java void loadFromAttributes(AttributeSet attrs, int defStyleAttr) { if (a.hasValue(R.styleable.AppCompatTextView_autoSizeTextType)) { mAutoSizeTextType = a.getInt(R.styleable.AppCompatTextView_autoSizeTextType, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE); if (supportsAutoSizeText()) { if (mAutoSizeTextType == TextViewCompat.AUTO_SIZE_TEXT_TYPE_UNIFORM) { setupAutoSizeText(); private boolean setupAutoSizeText() { if (supportsAutoSizeText() && mAutoSizeTextType == TextViewCompat.AUTO_SIZE_TEXT_TYPE_UNIFORM) { if (!mHasPresetAutoSizeValues || mAutoSizeTextSizesInPx.length == 0) { for (int i = 0; i < autoSizeValuesLength; i++) { autoSizeTextSizesInPx[i] = Math.round( mAutoSizeMinTextSizeInPx + (i * mAutoSizeStepGranularityInPx)); mAutoSizeTextSizesInPx = cleanupAutoSizePresetSizes(autoSizeTextSizesInPx); mNeedsAutoSizeText = true; }在文本内容初始化或变化的时候计算合适的字体尺寸并反映到UI上。// AppCompatTextView.java protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { if (mTextHelper != null && !PLATFORM_SUPPORTS_AUTOSIZE && mTextHelper.isAutoSizeEnabled()) { mTextHelper.autoSizeText(); // AppCompatTextHelper.java void autoSizeText() { mAutoSizeTextHelper.autoSizeText(); // AppCompatTextViewAutoSizeHelper.java void autoSizeText() { if (mNeedsAutoSizeText) { synchronized (TEMP_RECTF) { // 计算最佳size final float optimalTextSize = findLargestTextSizeWhichFits(TEMP_RECTF); // 如果和预设的size不一致的话更新size if (optimalTextSize != mTextView.getTextSize()) { setTextSizeInternal(TypedValue.COMPLEX_UNIT_PX, optimalTextSize); }<br/>AppCompatImageView和AppCompatTextView一样扩展了针对background和src的Dynamic Tint功能。与AppCompatTextView不同的是AppCompatImageView对icon着色采用的属性不是attr#drawableTint是attr#tint***。由AppCompatImageHelper和ImageViewCompat类实现,原理大同小异,不再赘述。<br/>辅助类AppCompat框架的开发人员在实现AppCompat扩展控件等特性的时候用到很多辅助类,大家可以自行研究下其细节,学习下一些巧妙的实现思路。AppCompatBackgroundHelperAppCompatDrawableManagerAppCompatTextHelperAppCompatTextViewAutoSizeHelperAppCompatTextClassifierHelperAppCompatResourcesAppCompatImageHelper…<br/>类图最后上一下AppCompat框架的简易类图,帮助大家有个整体上的认识。<br/>最后AppCompat框架整体比较简单,但是因为司空见惯,容易被大家忽视。作为Jetpack系列里的入口,了解一下很有必要。

使用Jetpack Compose Theme 为 App 轻松换肤

#AndroidDevChallenge Week 3整个开发过程中,除了会用到Layout、Modifier等基本技术以外,最大的体会就是Compose的Theme太好用了!,这也是Google想在这个题目中考察和传达的重点。虽然不使用Theme也可以完成上面三个页面,但无疑开发效率会大大折扣。<br/>2. Compose Theme传统Android开发中也需要配置Theme,即主题。Theme可以为UI控件提供统一的颜色和样式等,保证App视觉的一致性。主要区别在与:传统Theme依赖xml,而Compose完全基于Kotlin,类型更安全、性能更优秀、使用更简单!Kotlin的优势当我们在AndroidStudio新建一个Compose模板工程时,IDE会自动创建theme文件夹Color.kt、Shape.kt、Type.kt中通过Kotlin的常量分别定义各种样式,Theme.kt中将这些样式应用到全局主题://Thmem.kt private val DarkColorPalette = darkColors( primary = purple200, primaryVariant = purple700, secondary = teal200 private val LightColorPalette = lightColors( primary = purple500, primaryVariant = purple700, secondary = teal200 @Composable fun MyAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { //根据theme的不同设置不同颜色 val colors = if (darkTheme) { DarkColorPalette } else { LightColorPalette MaterialTheme( colors = colors, typography = typography, shapes = shapes, content = content }如上,使用Kotlin定义和切换theme都是如此简单,在Composable中基于if语句选择配置,然后静等下次composition生效就好了。Theme工作原理每个工程都提供${app name}Theme,用于自定义主题。例如MyAppTheme,最终会调用MaterialTheme,通过一些列Provider将配置映射为环境变量:@Composable fun MaterialTheme( colors: Colors = MaterialTheme.colors, typography: Typography = MaterialTheme.typography, shapes: Shapes = MaterialTheme.shapes, content: @Composable () -> Unit val rememberedColors = remember { colors }.apply { updateColorsFrom(colors) } val rippleIndication = rememberRipple() val selectionColors = rememberTextSelectionColors(rememberedColors) CompositionLocalProvider( LocalColors provides rememberedColors, LocalContentAlpha provides ContentAlpha.high, LocalIndication provides rippleIndication, LocalRippleTheme provides MaterialRippleTheme, LocalShapes provides shapes, LocalTextSelectionColors provides selectionColors, LocalTypography provides typography ProvideTextStyle(value = typography.body1, content = content) }后续的UI都创建在MyAppTheme的content中,共享Provider提供的配置class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyAppTheme { // A surface container using the 'background' color from the theme }当需要使用主题配置时,通过MaterialTheme静态对象访问,如下:@Composable fun Scaffold( drawerShape: Shape = MaterialTheme.shapes.large, drawerBackgroundColor: Color = MaterialTheme.colors.surface, backgroundColor: Color = MaterialTheme.colors.background, content: @Composable (PaddingValues) -> Unit )MaterialTheme从Provider中获取当前配置。object MaterialTheme { val colors: Colors @Composable @ReadOnlyComposable get() = LocalColors.current val typography: Typography @Composable @ReadOnlyComposable get() = LocalTypography.current val shapes: Shapes @Composable @ReadOnlyComposable get() = LocalShapes.current }<br/>3. 实战ThemeBloom是这次挑战赛项目的名字,借助于Compose的Theme,我基本还原了设计稿的要求。以下是完成效果,代码地址:Bloom定义Theme根据设计稿中的要求,我们在代码中定义Theme:Color首先在Color.kt中定义相关常量//Color.kt val pink100 = Color(0xFFFFF1F1) val pink900 = Color(0xFF3f2c2c) val gray = Color(0xFF232323) val white = Color.White val whit850 = Color.White.copy(alpha = .85f) val whit150 = Color.White.copy(alpha = .15f) val green900 = Color(0xFF2d3b2d) val green300 = Color(0xFFb8c9b8)然后通过lightColors定义白天的颜色private val LightColorPalette = lightColors( primary = pink100, primaryVariant = purple700, secondary = pink900, background = white, surface = whit850, onPrimary = gray, onSecondary = white, onBackground = gray, onSurface = gray, )其中,primary等的定义来自MaterialDesign设计规范,根据颜色的使用场景频次等进行区分。有兴趣的可以参考MD的设计规范。onPrimary等表示对应的背景色下的默认前景色,例如text,icon的颜色等:相应的,夜间主题定义如下:private val DarkColorPalette = darkColors( primary = green900, primaryVariant = purple700, secondary = green300, background = gray, surface = whit150, onPrimary = white, onSecondary = gray, onBackground = white, onSurface = whit850, )Type//type.kt val typography = Typography( h1 = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 18.sp, h2 = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 14.sp, letterSpacing = 0.15.sp subtitle1 = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Light, fontSize = 16.sp body1 = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Light, fontSize = 11.sp body2 = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Light, fontSize = 12.sp button = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, letterSpacing = 1.sp caption = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, fontSize = 12.sp )Typography定义文字样式。h1、body1等也是来自MaterialDesign中对于文字用途的定义。Shape//Shape.kt val shapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(26.dp), large = RoundedCornerShape(0.dp) )使用Theme接下来,在代码中通过MaterialTheme获取当前配置就OK了,无需关心当前究竟是何主题。以欢迎页的Beautiful home garden solutions的Text为例,文字颜色需要根据主题(Light or Dart)变化。如下,通过MaterialTheme设置Color可以避免if语句的出现Text( "Beautiful home graden solutions", style = MaterialTheme.typography.subtitle1, // color = MaterialTheme.colors.onPrimary, //可省略 modifier = Modifier.align(Alignment.CenterHorizontally), )前文介绍过,当背景色为primary时,前景默认会使用onPrimary,所以此处即使不设置Color,也会自动选择最合适的颜色。再看下面Create account 的Button, Button( onClick = {}, modifier = Modifier .height(48.dp) .fillMaxWidth() .padding(start = 16.dp, end = 16.dp) .clip(MaterialTheme.shapes.medium), //.background(MaterialTheme.colors.secondary),//Modifier设置背景色 colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.secondary Text( "Create account", // style = MaterialTheme.typography.button // 可省略 }文字需要以typography.button的样式显示,Button内部的text默认套用button样式,所以此处也可以省略。Note:需要注意Button有专用的颜色设置字段,使用Modifier设置background无效由于Button设置了backgroundColor为MaterialTheme.colors.secondary,所以,内部的Text的颜色自动应用onSecondary,无需额外指定。可见,Theme不仅有利于样式的统一配置,还可以节省不少代码量。<br/>4. 活用@Preview视觉的调教有时需要反复确认,如果每次都要安装到设备查看效果将非常耗时。相对于传统xml布局鸡肋的预览效果,Compose提供的@Preview可以达到与真机无异的预览效果,而且还可以同屏预览多个主题、多种分辨率,便于对比。@Preview(widthDp = 360, heightDp = 640) @Composable fun PreviewWelcomeLight() { MyTheme(darkTheme = false) { Surface(color = MaterialTheme.colors.background) { WelcomeScreen(darkTheme = false) @Preview(widthDp = 360, heightDp = 640) @Composable fun PreviewWelcomeDark() { MyTheme(darkTheme = true) { Surface(color = MaterialTheme.colors.background) { WelcomeScreen(darkTheme = true) }如上,分别对DarkTheme和LightTheme进行预览,@Preview中设置分辨率,然后就可以实时看到预览效果了。@Preview是通过Composabl的实际运行实现真实的预览效果的,因此预览之前需要build,但是相对于安装到设备查看的方式已经快多了。基于runtime的preview还有好处就是连交互也可以预览,点击右上角“手指”icon可以与preview进行交互;点击“手机”icon可以将预览画面部署到真机查看。需要注意的是,因为预览需要保证Composable是可运行的,所以Preview只能接受无参的Composable。对于携带参数的Composable可以通过@PreviewParameter进行mock,但是mock数据本身也有成本,所以我们在设计Composable接口签名时要考虑对Preview是否友好,是否可以减少不必要的参数传递,或者为其提供默认值。<br/>5. 最后最后对Theme的功能以及使用心得做一些总结:Compose的Theme相对于xml方式更加高效、方便合理地使用Theme还有助于减少代码量建议在项目开始之前,要求PM或者设计出具详细的Theme定义,提高RD开发效率为Composable创建配套的@Preview,将大大提高UI的开发体验参考AndroidDevChallenge #BloomTheming in Compose

Jetpack Compose 打造炫酷的倒计时 App

TikTik项目中使用的都是Compose最基础的API,花时间不多,但完成效果还比较满意,可见Compose确实有助于提升UI开发效率,这里简单与大家分享一下实现过程。<br/>App实现1. 画面构成app由两个画面构成:输入画面(InputScreen) : <br/>通过数字软键盘输入时间,当新输入数字时,所有数字左移;backspace回退最近一次输入时,所有数字右移。类似计算器app的输入和显示逻辑。倒计时画面(CountdownScreen):<br/> 显示当前剩余时间并配有动画效果;根据剩余时间的不同,文字格式和大小会做出变化:最后10秒倒计时的文字也有更醒目的缩放动画。more than 1hmore than 1m & less than 1hless than 1mstate控制页面跳转页面之间的跳转逻辑:InputScreen完成输入后,点击底部Next,跳转到CountdownScreen进入倒计时CountdownScreen点击底部Cancel,返回InputScreenCompose没有Activity、Fragment这样的页面管理单元,所谓的页面只不过是一个全屏的Composable,通常可以使用state实现。复杂的页面场景可以借助navigation-composeenum class Screen { Input, Countdown @Composable fun MyApp() { var timeInSec = 0 Surface(color = MaterialTheme.colors.background) { var screen by remember { mutableStateOf(Screen.Input) } Crossfade(targetState = screen) { when (screen) { Screen.Input -> InputScreen { screen = Screen.CountdownScreen Screen.Countdown -> CountdownScreen(timeInSec) { screen = Screen.Input screen: 使用state保存并监听当前页面的变化,Crossfade:Crossfade可以淡入淡出的切换内部布局;内部根据screen切换不同页面。timeInSec:InputScreen的输入存入timeInSec,并携带到CountdownScreen2. 输入画面(InputScreen)InputScreen包括以下元素:输入结果:input-value回退:backspace软键盘:softkeyboard底部:next根据当前的输入结果,画面各元素会发生变化。当有输入结果时:next显示、backspace激活、input-value高亮;反之,next隐藏、backspace禁用、input-value低亮state驱动UI刷新如果用传统写法会比较啰嗦,需要在影响输入结果的地方设置监听,例如本例中需要分别监听backspace和next。当输入变化时命令式地去修改相关元素,页面复杂度会随着页面元素增多呈指数级增长。使用Compose则简单得多,我们只需要将输入结果包装成state并监听,当state变化时,所有Composable重新执行、更新状态。即使元素增多也不会影响已有代码,复杂度不会增加。var input by remember { mutableStateOf(listOf<Int>()) val hasCountdownValue = remember(input) { input.isNotEmpty() }mutableStateOf创建一个可变化的state,通过by代理进行订阅,当state变化时当前Composable会重新执行。由于Composable会反复执行,使用remember{}可以避免由于Composable的执行反复而反复创建state实例。当remember的参数变化时,block会重新执行,上面例子中,当input变化时,判断input是否为空并保存在hasCountdownValue中,供其他Composable参照。Column() { Modifier .fillMaxWidth() .height(100.dp) .padding(start = 30.dp, end = 30.dp) //Input-value listOf(hou to "h", min to "m", sec to "s").forEach { DisplayTime(it.first, it.second, hasCountdownValue) //Backspace Image( imageVector = Icons.Default.Backspace, contentDescription = null, colorFilter = ColorFilter.tint( Color.Unspecified.copy( //根据hasCountdownValue显示不同亮度 if (hasCountdownValue) 1.0f else 0.5f //根据hasCountdownValue,是否显示next if (hasCountdownValue) { Image( imageVector = Icons.Default.PlayCircle, contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colors.primary) }如上,声明UI的同时加入hasCountdownValue的判断逻辑,然后等待再次刷新就OK,无需像传统写法那样设置监听并命令式地更新UI。3. 倒计时画面(CountdownScreen)CountdownScreen主要包括以下元素:文字部分:显示hour、second、minutes 以及ms氛围部分:多个不同类型的圆形动画底部Cancel使用animation计算倒计时如何准确地计算倒计时呢?最初的方案是使用flow计算倒计时,然后将flow转成state,驱动UI刷新:private fun interval(sum: Long, step: Long): Flow<Long> = flow { while (sum > 0) { delay(step) sum -= step emit(sum) }但是经过测试发现,由于协程切换也有开销,使用delay处理倒计时并不精确。经过思考决定使用animation处理倒计时var trigger by remember { mutableStateOf(timeInSec) } val elapsed by animateIntAsState( targetValue = trigger * 1000, animationSpec = tween(timeInSec * 1000, easing = LinearEasing) DisposableEffect(Unit) { trigger = 0 onDispose { } }Compose的动画也是通过state驱动的, animateIntAsState定义动画、计算动画估值并转成state。动画由targetValue的变化触发启动。animationSpec用来配置动画类型,例如这里通过tween配置一个线性的补间动画。duration设置为timeInSec * 1000 ,也就是倒计时时长的ms。DisposableEffect用来在纯函数中执行副作用。如果参数发生变化,block中的逻辑会在每次重绘(Composition)时执行。 DisposableEffect(Unit)由于参数永远不会变化,意味着block只会在第一次上屏时只执行一次。trigger初始状态为timeInSec(倒计时总时长),然后在第一次上屏时设置为0,targetValue变化触发了动画:从timeInSec*1000 执行到 0 ,duration为timeInSec*1000 ,动画结束时就是倒计时的结束,而且绝对精确,没有误差。接下来只需要将elapsed换算成合适的文字显示就OK了val (hou, min, sec) = remember(elapsed / 1000) { val elapsedInSec = elapsed / 1000 val hou = elapsedInSec / 3600 val min = elapsedInSec / 60 - hou * 60 val sec = elapsedInSec % 60 Triple(hou, min, sec) 字体动态变化剩余时间的变化,带来文字内容和字体大小不同。这个实现非常简单,只要Composable中设置size的时候判断剩余时间就好了。 //根据剩余时间设置字体大小 val (size, labelSize) = when { hou > 0 -> 40.sp to 20.sp min > 0 -> 80.sp to 30.sp else -> 150.sp to 50.sp Row() { if (hou > 0) {//当剩余时间不足一小时时,不显示h DisplayTime( hou.formatTime(), fontSize = size, labelSize = labelSize if (min > 0) {//剩余时间不足1分钟,不显示m DisplayTime( min.formatTime(), fontSize = size, labelSize = labelSize DisplayTime( sec.formatTime(), fontSize = size, labelSize = labelSize }氛围动画氛围动画对提高App质感很重要,app中使用了如下几种动画烘托氛围:正圆呼吸灯效果:1次/2秒半圆环跑马灯效果:1次/1秒雷达动画:倒计时结束时扫描进度100%文字缩放:倒计时10秒缩放,1次/1秒这里使用transition同步多个动画 val transition = rememberInfiniteTransition() var trigger by remember { mutableStateOf(0f) } //线性动画实现雷达动画 val animateTween by animateFloatAsState( targetValue = trigger, animationSpec = tween( durationMillis = durationMills, easing = LinearEasing //infiniteRepeatable+restart实现跑马灯 val animatedRestart by transition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Restart) //infiniteRepeatable+reverse实现呼吸灯 val animatedReverse by transition.animateFloat( initialValue = 1.05f, targetValue = 0.95f, animationSpec = infiniteRepeatable(tween(2000), RepeatMode.Reverse) //infiniteRepeatable+reverse实现文字缩放 val animatedFont by transition.animateFloat( initialValue = 1.5f, targetValue = 0.8f, animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse) rememberInfiniteTransition创建了一个repeatable的transition,transition通过animateXXX创建多个动画(state),同一个transition创建的动画保持同步。app中创建了3个动画:animatedRestart、animatedReverse、animatedFonttransition中也可以设置animationSpec。app中配置的infiniteRepeatable是一个repeat动画,可以通过参数设置duration以及RepeatMode绘制圆环图形接下来就可以基于上面创建的动画state绘制各种圆形的氛围了,通过不断地compoition实现动画效果。Canvas( modifier = Modifier .align(Alignment.Center) .padding(16.dp) .size(350.dp) val diameter = size.minDimension val radius = diameter / 2f val size = Size(radius * 2, radius * 2) //跑马灯半圆 drawArc( color = color, startAngle = animatedRestart, sweepAngle = 150f, size = size, style = Stroke(15f), //呼吸灯整圆 drawCircle( color = secondColor, style = strokeReverse, radius = radius * animatedReverse //雷达扇形 drawArc( startAngle = 270f, sweepAngle = animateTween, brush = Brush.radialGradient( radius = radius, colors = listOf( purple200.copy(0.3f), teal200.copy(0.2f), Color.White.copy(0.3f) useCenter = true, style = Fill, }Canvas{}可以绘制自定义图形。drawArc用来绘制一个带角度的弧形,startAngle和sweepAngle设置弧在圆上的 其实位置,这里设置startAngle为animatedRestart,根据state的变化实现动画效果。style设置为Stroke表示只绘制边框,设置为Fill则表示填充这个弧形区域形成扇形。drawCircle用来绘制一个正圆,这里通过animatedReverse,改变半径实现呼吸灯效果Note: 关于Compose动画的更多内容可以参考 《一文学会使用Jetpack Compose Animations》<br/>总结Compose的核心是State驱动UI刷新,animation也是依靠state来实现动画。因此除了服务于视觉效果,animation还可以用来计算state。到这时才恍然大悟组织方在题目描述中提示可能会用到animation,其更主要的目的是用来精确计算countdown的最新状态。项目地址:TikTik

一文带你学会使用Jetpack Compose Animations

Node:本文基于Jetpack Compose 1.0.0-beta011. Animation是由state驱动的Compose的核心思想状态驱动UI刷新,这一思想同样体现在动画上。UI = f(state)Compose动画主要是通过不断计算最新的state值来刷新UI,这类似于传统的ValueAnimator,根据动画的插值器和估值器计算当前value,在映射到View的对应属性。Compose天然是基于state驱动的,相关API变得更加简单、合理。2. AnimateAsState:属性动画AnimateAsState提供了传统的属性动画的能力。 如下代码,点击Button可以改变Box的颜色@Preview @Composable fun AnimateAsStateDemo() { var blue by remember { mutableStateOf(true) } val color = if (blue) Blue else Red, Column(Modifier.padding(16.dp)) { Text("AnimateAsStateDemo") Spacer(Modifier.height(16.dp)) Button( onClick = { blue = !blue } Text("Change Color") Spacer(Modifier.height(16.dp)) Modifier .preferredSize(128.dp) .background(color) }如果想让Color以动画的方式切换,可以借助用animateColorAsState@Composable fun AnimateAsStateDemo() { var blue by remember { mutableStateOf(true) } val color by animateColorAsState( if (blue) Blue else Red, animationSpec = spring(Spring.StiffnessVeryLow) //... }animateColorAsState将Color的变化过程转换为一个可订阅的state;animationSpec用来进行动画配置,比如例子总配置了一个弹簧动画效果animationSped还可以监听动画结束的回调val color by animateColorAsState( if (blue) Blue else Red, animationSpec = spring(stiffness = Spring.StiffnessVeryLow), finishedListener = { blue = !blue )如上,可以实现折返动画的效果除了AnimateColorAsState以外,还支持其他各类型的动画:3. updateTransition:多个动画同步如果想同时进行多个属性的动画,并保持同步,需要使用updateTransition,类似使用AnimationSet组合多个动画 private sealed class BoxState(val color: Color, val size: Dp) { operator fun not() = if (this is Small) Large else Small object Small : BoxState(Blue, 64.dp) object Large : BoxState(Red, 128.dp) @Composable fun UpdateTransitionDemo() { var boxState: BoxState by remember { mutableStateOf(BoxState.Small) } val transition = updateTransition(targetState = boxState) Column(Modifier.padding(16.dp)) { Text("UpdateTransitionDemo") Spacer(Modifier.height(16.dp)) val color by transition.animateColor { boxState.color val size by transition.animateDp(transitionSpec = { if (targetState == BoxState.Large) { spring(stiffness = Spring.StiffnessVeryLow) } else { spring(stiffness = Spring.StiffnessHigh) boxState.size Button( onClick = { boxState = !boxState } Text("Change Color and size") Spacer(Modifier.height(16.dp)) Modifier .preferredSize(size) .background(color) }updateTransition根据targetState创建一个Transition,然后通过Transition的扩展函数可以创建各种属性动画所需的state。需要注意,Transition的codename容易跟传统的位移动画混淆,其实完全没有联系,这里的Transition是用来同步多个动画的工具。transitionSpec可以进行动画配置,上面例子中,在Box放大和缩小过程中,分别设置不同的弹性动画效果。同样,Transition根据属性类型的不同,有多种扩展函数4. AnimateVisibility:可见性动画在View的可见性发生变化时做动画是一个常见需求。传统的View体系中,一般使用alpha值变化实现fadeIn/fadeOut,或者通过transitionX/Y的变化,实现slideIn/slideOutCompose中则使用AnimatedVisibility@OptIn(ExperimentalAnimationApi::class) @Composable fun AnimateVisibilityDemo() { var visible by remember { mutableStateOf(true) } Column(Modifier.padding(16.dp)) { Text("AnimateVisibilityDemo") Spacer(Modifier.height(16.dp)) Button( onClick = { visible = !visible } Text(text = if (visible) "Hide" else "Show") Spacer(Modifier.height(16.dp)) AnimatedVisibility(visible) { Modifier .preferredSize(128.dp) .background(Blue) }如上,默认的可见性动画效果是淡入/淡出 + 收缩/放大:5. AnimateContentSize : 布局大小动画animateContentSize是Modifier的扩展方法,添加这个方法的Composable,会监听子Composable大小的变化,并以动画方式作成相应调整@Composable fun AnimateContentSizeDemo() { var expend by remember { mutableStateOf(false) } Column(Modifier.padding(16.dp)) { Text("AnimateContentSizeDemo") Spacer(Modifier.height(16.dp)) Button( onClick = { expend = !expend } Text(if (expend) "Shrink" else "Expand") Spacer(Modifier.height(16.dp)) Modifier .background(Color.LightGray) .animateContentSize() Text( text = "animateContentSize() animates its own size when its child modifier (or the child composable if it is already at the tail of the chain) changes size. " + "This allows the parent modifier to observe a smooth size change, resulting in an overall continuous visual change.", fontSize = 16.sp, textAlign = TextAlign.Justify, modifier = Modifier.padding(16.dp), maxLines = if (expend) Int.MAX_VALUE else 2 }animateContentSize同样可以配置animSpec以及endListener:6. Crossfade : 布局切换动画Crossfade 本身是一个Composable,其内部的子布局发生切换时,可以添加淡入淡出效果:@Composable fun CrossfadeDemo() { var scene by remember { mutableStateOf(DemoScene.Text) } Column(Modifier.padding(16.dp)) { Text("AnimateVisibilityDemo") Spacer(Modifier.height(16.dp)) Button(onClick = { scene = when (scene) { DemoScene.Text -> DemoScene.Icon DemoScene.Icon -> DemoScene.Text Text("toggle") Spacer(Modifier.height(16.dp)) Crossfade( current = scene, animation = tween(durationMillis = 1000) when (scene) { DemoScene.Text -> Text(text = "Phone", fontSize = 32.sp) DemoScene.Icon -> Icon( imageVector = Icons.Default.Phone, null, modifier = Modifier.preferredSize(48.dp) }参考Sample Code

一个例子学会使用 Jetpack Compose Modifier

Modifier是Compose中的重要概念,能够让UI呈现更加专业、好看的视觉效果。1. 为什么使用Modifier?常规的View体系中,控件以实例对象的形式存在,控件可以在实例化之后再动态配置属性,但是Composable本质上是函数,只能在调用的同时通过参数传递进行配置,如果没有Modifier,参数签名会变得很长(虽然Kotlin支持默认参数)。使用Modiifer可以很好地解决这个问题,它就像Composable的配置文件,可以在此对Composable的样式和行为进行统一配置。2. Modifier是一组有序的链式调用Modifier通过链式调用“装饰”我们的Composable,为其添加Background、Padding、onClick事件等。链上的每个操作符都创建一个Element,整个调用链是一组Element的有序执行单元:Text( "Hello, World!", Modifier.padding(16.dp) // Outer padding; outside background .background(color = Color.Green) // Solid element background color .padding(16.dp) // Inner padding; inside background, around text )如上,调用链上的两个padding不是覆盖关系,而是按照顺序发挥作用。padding创建PaddingModifier,class PaddingModifier(val start: Dp, val top : d'p...) : Modifier.Element fun Modifier.padding(all: Dp) = this.then(PaddingModifier(start = all, top = all, xxx))then用来组合两个Modifier并保持顺序执行。open infix fun then(other: Modifier): ModifierModifier类似RxJava的Observable,基于函数式编程思想,将操作符串联成一组可执行函数,在Composable渲染的时候才执行。3. 使用Modifier装饰ComposableModifier的操作符(API)虽然数量多,但是语义明确,上手不难。下面通过一个例子带大家体验一下如何使用Modifier装饰我们的Composable。我们试着用Compose实现一个关注列表的Item,如下@Composable fun Plain() { Row(modifier = Modifier.fillMaxWidth()) { Image( modifier = Modifier.size(40.dp), bitmap = imageResource(id = R.drawable.miku), contentDescription = null, // decorative Column(modifier = Modifier.weight(1f)) { Text(text = name, maxLines = 1) Text(text = desc, maxLines = 1) Text("Follow", Modifier.padding(6.dp)) }接下来,我们一步步通过Modifier对其进行装饰,在文章最后,UI将达到下面第二张图片的效果。3.1 整体布局Modifier.padding我们使用Row、Column对Item内的元素进行了基本布局,但是元素之间缺少间距Compose通过Modifier在Composable之间添加Padding@Composable fun Decorated() { modifier = Modifier .fillMaxWidth() .preferredHeightIn(min = 64.dp) .padding(8.dp) //外间隙 .border(1.dp, Color.LightGray, RoundedCornerShape(4.dp)) .padding(8.dp) //内间隙 }如上,我们对Item整体添加Padding。border前后各有一个padding,分别表示对外和对内的间距。相对于传统布局有Margin和Padding区分,Modifier中只有padding,根据调用链中的位置不同发挥不同作用,使用更加简单。Modifier.borderborder用来定义边框,RoundedCornerShape是一个Shape类型,用来指定边框的形状为圆角矩形。我们还可以调用两次background来实现border的效果:modifier = Modifier .background(Color.LightGray) .padding(1.dp) //两个backgound之间形成边框 .background(Color.White)Modifier.preferredHeight / Modifier.preferredHeightInpreferedXXX等用来设置初始的size,例如preferedHeight可以设置Composable的默认高度,这个值可能被其他约束覆盖,若想要高度不被覆盖,就使用Modifier.height设置固定值本例中使用preferedHeightIn,可以设置minHeight和maxHeight。Modifier.fillMaxWidthfillMaxWidth表示填充整个父容器,相当于传统布局的match_parent3.2 参数中传入Modifier填充Row中的内容,从左往右依次是,头像、文字、按钮@Composable fun Decorated() { modifier = Modifier .fillMaxWidth() .preferredHeight(64.dp) .padding(8.dp) .border(1.dp, Color.LightGray, RoundedCornerShape(4.dp)) .padding(8.dp) Avatar( //头像部分 modifier = Modifier .padding(4.dp) .align(Alignment.CenterVertically) Info( //文字部分 Modifier .weight(1f) .align(Alignment.CenterVertically) FollowBtn( //按钮 Modifier.align(Alignment.CenterVertically) }我们将具体实现抽成独立的Composable,在Row中调用并传入Modifier。在Compose中定义Composable时,为Modifier预留参数位置是一个好习惯Modifier为调用方提供了修改子元素样式的机会,但更重要的是有一些操作符只能在外部调用。Modifier.alignModifier的操作符都是扩展函数,并不是定义在一起。操作符定义在不同的空间中,可以限制某些操作符只能在特定父Comopsable中使用,避免误用。interface RowScope { fun Modifier.align(alignment: Alignment.Vertical) }如上,align只能在Row中调用,用来设置子元素在垂直方向如何对齐。子元素不关心其在父容器中如何对齐,因此在外部设置align(Alignment.CenterVertically)后,传给子元素继续使用。Modifier.weightweight同样只能在Row中调用,为子元素分配在Row中的占比,类似于LinearLayout的layout_weight。本例中让中间的文字部分占据所有所有空间3.3 头像图片我们对头像图片做圆形处理并添加边框,提升整体视觉效果。@Composable fun Avatar(modifier: Modifier) { Image( modifier = modifier .size(50.dp) .clip(CircleShape) .border( shape = CircleShape, border = BorderStroke( width = 2.dp, brush = Brush.linearGradient( colors = listOf(blue, teal200, green200, orange), start = Offset( 0f, 0f), end = Offset(100f,100f) .border( shape = CircleShape, border = BorderStroke(4.dp, SolidColor(Color.White)) bitmap = imageResource(id = R.drawable.miku), contentDescription = null, // decorative }Modifier.size首先size(50.dp) 设置图片的整体大小Modifier.clipclip用来将图片裁剪成指定形状,例子中clip(CircleShape)将图片裁剪成圆形Modifier.border调用顺序图片的边框由两部分组成,外层带颜色的部分,和内层的白色边框,因此调用链中出现了两个border()。两个border的调用顺序需要特备注意,border表示为后面的调用添加边框,所以在前面调用的后生效。所以例子中的border调用顺序如下:Modifier .border() //2dp 颜色边框 .border() //4dp 白色边框BorderStroke & Brushborder使用BorderStroke填充边框颜色。外边框使用Brush.linearGradient填充多种颜色组成的渐变色,start和end表示颜色范围BroderStroke( brush = Brush.linearGradient( colors = listOf(blue, teal200, green200, orange), start = Offset( 0f, 0f), end = Offset(100f,100f) )内边框使用SolidColor填充固定颜色BorderStroke(brush = SolidColor(Color.White))3.4 文字部分@Composable fun Info(modifier: Modifier) { Column(modifier = modifier) { Text( text = name, color = Color.Black, maxLines = 1, style = TextStyle( fontWeight = FontWeight.Bold, fontSize = 16.sp, letterSpacing = 0.15.sp Text( text = desc, color = Color.Black.copy(alpha = 0.75f), maxLines = 1, style = TextStyle( // here fontWeight = FontWeight.Normal, fontSize = 14.sp, letterSpacing = 0.25.sp }许多字体的样式不借助Modifier,而是通过Text自身的属性以及TextStyle设置文字颜色color设置文字颜色,Compose的Color类功能强大, 例如这里可以设置透明度:Color.Black.copy(alpha = 0.75f)TextStyleTextStyle可以设置字体、字号等,例子中通过fontWeight设置了粗体textDecoration虽然本例中没有使用,但是Text还有一个重要属性textDecoration,对文字进行更有针对性的“装饰”,例如添加下划线、删除线等textDecoration = TextDecoration.Underline3.5 按钮虽然有Compose提供了专门的Button实现按钮,使用Text同样可以实现按钮,而且可定制性更高。@Composable fun FollowBtn(modifier: Modifier) { val backgroundShape: Shape = RoundedCornerShape(4.dp) Text( text = "Follow", style = typography.body1.copy(color = Color.White), textAlign = TextAlign.Center, modifier = modifier .preferredWidth(80.dp) .clickable(onClick = {}) .shadow(3.dp, shape = backgroundShape) .clip(backgroundShape) .background( brush = Brush.verticalGradient( colors = listOf( Red500, orange700, startY = 0f, endY = 80f .padding(6.dp) }Modifier.background为按钮添加了渐变的背景色以及阴影后,显得更加拟物、有质感background中同样通过Brush添加渐变色Modifier.shadowshadow添加阴影,需要特别shadow在调用链中的位置,阴影本身也是占用面积的,所以要在background之前调用,避免阴影进入背景色区域中Modifier.clickText之所以可以替代Button实现按钮,是因为Modifier提供了click,可以让Composable处理onClick事件4. 最后通过上面的例子,相信大家已经掌握了Modifier的基本使用方式,Modifier还有很多高级的API,后续有机会陆续分享给大家。Modifier在设计上吸取了装饰器模式、FP等多种编程思想,思路巧妙,值得大家学习。<br/>【参考】Sample CodeMore Modifiers API Reference

Jetpack Compose Side Effect:如何处理副作用

1. 副作用与纯函数程序开发中的副作用是伴随函数式编程产生的重要概念。In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the main effect) to the invoker of the operation.-- wikipedia用一句话概括副作用:一个函数的执行过程中,除了返回函数值之外,对调用方还会带来其他附加影响,例如修改全局变量或修改参数等。Composable是纯函数与之相对的就是纯函数,纯函数即没有副作用的函数,纯函数只能通过返回值对外产生影响。此外,纯函数是幂等的,唯一输入(参数)决定唯一输出(返回值),不会因为运行次数的增加导致返回值的不同。这对于React、Compose这类的声明式UI框架至关重要,因为它们都是通过函数(组件)的反复执行来渲染UI的,函数执行的时机和次数都不可控,但是函数的执行结果必须可控,因此,我们要求这些函数组件必须用纯函数实现。合理的副作用虽然我们不希望函数执行中出现副作用,但现实情况是有一些逻辑只能作为副作用来处理。例如一些IO操作、计时、日志埋点等,这些都是会对外界或收到外界影响的逻辑,不能无限制的反复执行。所以React、Compose等框架需要能够合理地处理一些副作用:副作用的执行时机是明确的,例如在Recomposition时,或者在onMount时等副作用的执行次数是可控的,不应该随着函数反复执行。副作用不会造成泄露,例如对于注册要提供适当的时机进行反注册2. 处理Effect的APIComposable的生命周期副作用必须在合适的时机执行,首先需要明确一个Composable的生命周期:onActive(or onEnter):当Composable首次进入组件树时onCommit(or onUpdate):UI随着recomposition发生更新时onDispose(or onLeave):当Composable从组件树移除时大多数围绕着Composable的副作用会在其生命周期内执行和结束,当然也有一些与具体Comosable无关的副作用不受生命周期的限制。DisposableEffect副作用译自Side Effect或简称Effect,当看到带有Effect关键字API一般就是处理副作用的了,例如React Hook的useEffect。 Compose中最常用的API就是DisposableEffect与SideEffect了。Compose目前还处于alpha版(当前最新版本1.0.0-alpha12),API仍然在不断调整中。DisposableEffect相当于早期API中的 onCommit + onDispose,顾名思义,DisposableEffect可以感知onCommit和onDipose的生命周期回调,在里面进行Effect处理。@Composable fun backPressHandler(onBackPressed: () -> Unit, enabled: Boolean = true) { val dispatcher = BackPressedDispatcherAmbient.current.onBackPressedDispatcher val backCallback = remember { object : OnBackPressedCallback(enabled) { override fun handleOnBackPressed() { onBackPressed() DisposableEffect(dispatcher) { // dispose/relaunch if dispatcher changes dispatcher.addCallback(backCallback) onDispose { backCallback.remove() // avoid leaks! }如上,我们通过Effect对外部的回调进行注册和反注册:DisposableEffect在onCommit的时候调用仅当参数dispatcher发生变化时,block内容才会执行,避免无效的多次执行DisposableEffect需要一个onDispose的block作为结尾,例子中在onDispose的时候注销回调,避免泄露DisposableEffect默认在每次onCommit时都会执行,我们还可以通过添加参数,让其仅在onActive时执行:DisposableEffect(true)或DisposableEffect(Unit)SideEffectSideEffect相当于DisposableEffect的简化版,当不需要onDispose、不需要参数控制(即每次onCommit都执行)时使用,等价于DisposableEffect() { // TODO:handle some side effects onDispose{/*do nothing*/} }SideEffect可以用来更新外部的状态,如下当drawerState发生变化时,通知到外部TouchHandler,由于TouchHandler是进程唯一单例,所以不需要考虑泄露的问题。@Composable fun MyScreen(drawerTouchHandler: TouchHandler) { val drawerState = rememberDrawerState(DrawerValue.Closed) SideEffect { drawerTouchHandler.enabled = drawerState.isOpen // ... }rememberremember{...}是Compose中的重要API,用来包装一些计算成本较高的逻辑或一些state数据,避免随函数反复执行。remember存储那些跨越函数栈存在的state数据,所以也可以当做处理Effect的工具使用。state本身就是一种副作用@Composable fun ExpandingCard(title: String, body: String) { // expanded is "internal state" for ExpandingCard var expanded by remember { mutableStateOf(false) } Card { Column() { Text(text = title) // content of the card depends on the current value of expanded if (expanded) { // TODO: show body & collapse icon } else { // TODO: show expand icon }mutableStateOf创建的初始状态false后,放入remember中就可以避免跟随函数多次执行而反复初始化(下图中蓝色部分不会随绿色反复执行)remember经常配合state的创建一起使用,remember { mutableStateOf(false) } 相当于React的useStaterememberCoroutineScopeCompose中有很多remember开头的API,底层都是基于remember实现,用来创建并获取某个跨越函数栈存在的数据。rememberCoroutineScope便是这样一个API:@Composable fun SearchScreen() { val scope = rememberCoroutineScope() var currentJob by remember { mutableStateOf<Job?>(null) } var items by remember { mutableStateOf<List<Item>>(emptyList()) } Column { Row { TextInput( afterTextChange = { text -> currentJob?.cancel() currentJob = scope.async { delay(threshold) items = viewModel.search(query = text) Row { ItemsVerticalList(items) } }rememberCoroutineScope创建CoroutineScope,CoroutineScope不会随函数的执行反复创建Scope与Composable生命周期一致,随着onDispose而cancel,避免泄露currentJob保存Coroutine的句柄,并在适当的实际cancelDispatcher通常为AndroidUiDispatcher.Main上面是一段常见逻辑:我们希望在输入变化时,立即开启新的查询,终止前面的结果回调。以往是使用Handler#postDelayed配合View实现,如今在Compose中我们使用remember配合Coroutine可以实现同样的功能。LaunchedEffectLaunchedEffect对rememberCoroutineScope+launch组合的简化,顾名思义,可以用来处理包含suspend调用的Effect@Composable fun SpeakerList(eventId: String) { var speakers by remember { mutableStateOf<List<Speaker>>(emptyList()) } LaunchedEffect(eventId) { // cancelled / relaunched when eventId varies speakers = viewModel.loadSpeakers(eventId) // suspended effect ItemsVerticalList(speakers) }LaunchedEffect在onCommit时执行LaunchedEffect提供CoroutineScope便于处理带有suspend调用的Effect仅当eventId变化时才会进行cancel/relaunch,有助于跨越recomposition使用produceStateproduceState基于LaunchedEffect实现,进一步简化了state的初始化和更新逻辑@Composable fun SearchScreen(eventId: String) { val uiState = produceState(initialValue = emptyList<Speaker>(), eventId) { viewModel.loadSpeakers(eventId) // suspended effect ItemsVerticalList(uiState.value) }当eventId变更时,会重新loadSpeakers并更新state3. 借助Effect处理三方库适配状态的订阅/注销是Compose的核心概念,也是最常见的Effect。Compose除了提供State,也可以配合常见的三方库(LiveDat、RxJava、Coroutine Flow等)实现状态订阅逻辑:implementation "androidx.compose.runtime:runtime-livedata:$compose_version" implementation "androidx.compose.runtime:runtime-rxjava2:$compose_version" implementation "androidx.compose.runtime:runtime-flow:$compose_version"Compose提供了对三方库的适配,让订阅最终转化为State来处理LiveDataclass MyComposableVM : ViewModel() { private val _user = MutableLiveData(User("John")) val user: LiveData<User> = _user //... @Composable fun MyComposable() { val viewModel = viewModel<MyComposableVM>() val user by viewModel.user.observeAsState() Text("Username: ${user?.name}") }observeAsState自动订阅LiveData并转换成State,实现如下:@Composable fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> { val lifecycleOwner = AmbientLifecycleOwner.current val state = remember { mutableStateOf(initial) } DisposableEffect(this, lifecycleOwner) { val observer = Observer<T> { state.value = it } observe(lifecycleOwner, observer) onDispose { removeObserver(observer) } return state }在DisposableEffect中实现LiveData的订阅和注销RxJavaclass MyComposableVM : ViewModel() { val user: Observable<ViewState> = Observable.just(ViewState.Loading) //... @Composable fun MyComposable() { val viewModel = viewModel<MyComposableVM>() val uiState by viewModel.user.subscribeAsState(ViewState.Loading) when (uiState) { ViewState.Loading -> TODO("Show loading") ViewState.Error -> TODO("Show Snackbar") is ViewState.Content -> TODO("Show content") }susbcribeAsState()的实现就不赘述了,参考observeAsStateCoroutine Flowclass MyComposableVM : ViewModel() { val user: Flow<ViewState> = flowOf(ViewState.Loading) //... @Composable fun MyComposable() { val viewModel = viewModel<MyComposableVM>() val uiState by viewModel.user.collectAsState(ViewState.Loading) when (uiState) { ViewState.Loading -> TODO("Show loading") ViewState.Error -> TODO("Show Snackbar") is ViewState.Content -> TODO("Show content") }collectAsState的实现如下:@Composable fun <T : R, R> Flow<T>.collectAsState( initial: R, context: CoroutineContext = EmptyCoroutineContext ): State<R> = produceState(initial, this, context) { if (context == EmptyCoroutineContext) { collect { value = it } } else withContext(context) { collect { value = it } }collect的执行依靠CoroutineScope,所以基于produceState实现(便于在CoroutineScope中直接设置state)而没有使用LaunchedEffect。invalidate基于订阅者的逻辑多使用在MVVM架构中,但也有一些使用MVP的架构项目使用接口回调的方式更新UI。Compose可以使用currentRecomposeScope.invalidate(),在不借助state的情况下刷新UI。常规Android开发中,invalidate用来触发layout和draw的再执行以刷新UI(常用来处理动画),Compose中的作用也是类似的,可以触发当前Composable的recompositioninterface Presenter { fun loadUser(after: @Composable () -> Unit): User @Composable fun MyComposable(presenter: Presenter) { val user = presenter.loadUser { invalidate() } // not a State! Text(text = "The loaded user: ${user.name}") }我们可以在Presenter的回调中调用invalidate。作为state的替换方案,你知道有这个东西就好了,但是不推荐过多的使用,声明式UI框架更适合使用订阅者逻辑刷新UI。4. 结语Compose提供了一系列处理Effect的APIs,能够进行suspend调用、state管理、以方便地进行三方库适配,关联Composable的生命周期还能有效避免内存泄漏。作为开发者,我们力求以纯函数的方式实现Composable,同时也要面对各种可能出现的副作用并合理处置。

相似度99%?Jetpack Compose 与 React Hooks API对比

众所周知Jetpack Compose设计理念甚至团队成员很多都来自React,在API方面参考了很多React(Hooks) 的设计,通过与React进行对比可以更好地熟悉Compose的相关功能。Compose目前处于alpha版,虽然API还会调整,但是从功能上已经基本对齐了React,不会有大变化,本文基于1.0.0-alpha11。<br/>React Component vs ComposableReact中Component成为分割UI的基本单元,特别是16.8之后Hooks引入的函数组件,相对于类组件更利于UI与逻辑解耦。函数组件是一个接受Props作为参数并返回JSX node的函数:function Greeting(props) { return <span>Hello {props.name}!</span>; }Compose同样使用函数作为组件:添加了@Composable注解的函数。而且借助Kotlin的DSL实现声明式语法,而无需额外引入JSX等其他标记语言,相对于React更加简洁:@Composable fun Greeting(name: String) { Text(text = "Hello $name!") }<br/>JSX vs DSLDSL相对于JSX更加简洁,可以直接使用原生语法表示各种逻辑。loop例如在JSX中实现一个循环逻辑,需要两种语言混编function NumberList(props) { return ( <ul> {props.numbers.map((number) => ( <ListItem value={number} /> </ul> }DSL中的循环就是普通的for循环@Composable fun NumberList(numbers: List<Int>) { Column { for (number in numbers) { ListItem(value = number) }If statementJSX 使用三元运算符表示条件function Greeting(props) { return ( <span> {props.name != null ? `Hello ${props.name}!` : 'Goodbye.'} </span> }DSL直接使用IF表达式@Composable fun Greeting(name: String?) { Text(text = if (name != null) { "Hello $name!" } else { "Goodbye." }key componentReact和Compose都可以通过key来标记列表中的特定组件,缩小重绘范围。JSX使用key属性<ul> {todos.map((todo) => ( <li key={todo.id}>{todo.text}</li> </ul>DSL使用key组件来标识ComponentColumn { for (todo in todos) { key(todo.id) { Text(todo.text) } }<br/>Children Prop vs Children Composable前面提到,React与Compose都使用函数组件创建UI,区别在于一个使用DSL,另一个依靠JSX。React中,子组件通过props的children字段传入function Container(props) { return <div>{props.children}</div>; <Container> <span>Hello world!</span> </Container>;Compose中,子组件以@Composable函数的形式传入@Composable fun Container(children: @Composable () -> Unit) { Box { children() Container { Text("Hello world"!) }<br/>Context vs Ambient(CompositionLocal)对于函数组件来说,建议使用props/parameter传递数据,但是允许一些全局数据在组件间共享。React使用Context存放全局数据,Compose使用Ambient(alpha12中已改名CompositionLocal)存放全局数据createContext : ambientOfReact使用createContext创建Context:const MyContext = React.createContext(defaultValue);Compose使用ambientOf创建Ambient:val myValue = ambientOf<MyAmbient>()Provider : ProviderReact和Compose中都使用Provider注入全局数据,供子组件访问<MyContext.Provider value={myValue}> <SomeChild /> </MyContext.Provider>Providers(MyAmbient provides myValue) { SomeChild() }useContext : Ambient.currentReact中子组件使用useContext hook访问Contextconst myValue = useContext(MyContext);Compose中子组件通过单例对象访问Ambientval myValue = MyAmbient.current<br/>useState vs State无论React还是Compose,状态管理都是至关重要的。React使用useState hook创建Stateconst [count, setCount] = useState(0); <button onClick={() => setCount(count + 1)}> You clicked {count} times </button>Compose使用mutableStateOf创建一个state,还可以通过by代理的方式获取val count = remember { mutableStateOf(0) } Button(onClick = { count.value++ }) { Text("You clicked ${count.value} times") }还可以通过解构分别获取get/setval (count, setCount) = remember { mutableStateOf(0) } Button(onClick = { setCount(count + 1) }) { Text("You clicked ${count} times") }或者通过by代理var count : Int by remember { mutableStateOf(false) } Button(onClick = { count++ }) { Text("You clicked ${count} times") }Compose创建state时往往会remeber{ } 避免重绘时的反复创建state,相当于useMemo<br/>useMemo vs rememberReact使用useMemo hook用来保存那些不能随重绘反复计算的值,只有参数变化时才会重新计算。const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);Compose中同样功能使用remember实现,同样通过参数作为重新计算的判断条件val memoizedValue = remember(a, b) { computeExpensiveValue(a, b) }<br/>useEffect vs SideEffect函数组件满足纯函数的要求:无副作用、无状态、即使多次运行也不会产生影响。但是总有一些逻辑不能以纯函数执行,例如 生命周期回调、日志、订阅、计时等,只能在特定时机执行,不能像一个纯函数那样可以执行多次而不产生副作用。React中,useEffect 提供一个hook点,会在每次render时执行。注意 这不同于直接写在外面,当diff没有变化时不需要重新render,就不需要执行useEffect了useEffect(() => { sideEffectRunEveryRender(); });Compose中使用SideEffect处理副作用(早期版本是onCommit{ })SideEffect { sideEffectRunEveryComposition() }useEffect(callback, deps) :DisposableEffect跟useMemo一样可以接受参数,每次render时,只有当参数变化时才执行:useEffect(() => { sideEffect(); }, [dep1, dep2]);只在第一次render时执行的逻辑(相当于onMount),可以使用如下形式处理:useEffect(() => { sideEffectOnMount(); }, []);Compose中使用DisposableEffect:DisposableEffect( key1 = "", onDispos{} }Clean-up function : onDisposeuseEffect通过返回一个function进行后处理useEffect(() => { const subscription = source.subscribe(); return () => { subscription.unsubscribe(); });DisposableEffect通过一个DisposableEffectDisposable进行后处理:DisposableEffect() { val dispose = source.subscribe() onDispose { //返回DisposableEffectDisposable dispose.dispose() }<br/>Hook vs EffectReact允许自定义Hooks封装可复用逻辑。Hooks可以调用useState、useEffect等其他hooks放方法,在特定的生命周期完成逻辑。自定义Hooks都使用useXXX的形式来命名function useFriendStatus(friendID) { const [isOnline, setIsOnline] = useState(null); useEffect(() => { function handleStatusChange(status) { setIsOnline(status.isOnline); ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange); return isOnline; }Compose没有命名上的要求,任何一个@Composable函数即可被用来实现一段可复用的处理Effect的逻辑:@Composable fun friendStatus(friendID: String): State<Boolean?> { val isOnline = remember { mutableStateOf<Boolean?>(null) } DisposableEffect { val handleStatusChange = { status: FriendStatus -> isOnline.value = status.isOnline ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange) onDispose { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange) return isOnline

一道面试题:Activity是如何实现LifecycleOwner的?

我们都知道Activity可作为LifecycleOwner为LiveData的使用提供条件,那么Activity是如何实现LifecycleOwner的呢?Activity虽然实现了LifecycleOwner接口,但是并没有实现相关处理,而是通过添加一个Fragment来代理Lifecycle的分发。这种通过Fragment代理Activity行为的设计在其他一些库也经常出现,相对来说更加无侵和优雅。SupportActivityActivity通过继承SupportActivity实现LifecycleOwner接口。注意在AndroidX中SupportActivity改名为ComponentActivitypublic class SupportActivity extends Activity implements LifecycleOwner { private LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); @Override protected void onSaveInstanceState(Bundle outState) { mLifecycleRegistry.markState(Lifecycle.State.CREATED); super.onSaveInstanceState(outState); @Override public Lifecycle getLifecycle() { return mLifecycleRegistry; }SupportActivity声明了mLifecycleRegistry对象,但是没有直接使用其进行生命周期的分发,而是被ReportFragment通过activity.getLifecycle()获取使用。ReportFragmentSupportActivity在onCreate为自己添加了ReportFragment:@RestrictTo(LIBRARY_GROUP) public class SupportActivity extends Activity implements LifecycleOwner { // ... @Override @SuppressWarnings("RestrictedApi") protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); ReportFragment.injectIfNeededIn(this); // ... }injectIfNeededIn是ReportFragment的静态方法 public static void injectIfNeededIn(Activity activity) { // ProcessLifecycleOwner should always correctly work and some activities may not extend // FragmentActivity from support lib, so we use framework fragments for activities android.app.FragmentManager manager = activity.getFragmentManager(); if (manager.findFragmentByTag(REPORT_FRAGMENT_TAG) == null) { manager.beginTransaction().add(new ReportFragment(), REPORT_FRAGMENT_TAG).commit(); // Hopefully, we are the first to make a transaction. manager.executePendingTransactions(); }低版本Activity兼容LifecycleSupportActivity是伴随Lifecycle才出现的,android.arch.lifecycle:extensions为早期还没有继承SupportActivity的Activity也提供了支持,通过LifecycleDispatcher实现ReportFragment的注入:class LifecycleDispatcher { static void init(Context context) { if (sInitialized.getAndSet(true)) { return; ((Application) context.getApplicationContext()) .registerActivityLifecycleCallbacks(new DispatcherActivityCallback()); static class DispatcherActivityCallback extends EmptyActivityLifecycleCallbacks { private final FragmentCallback mFragmentCallback; DispatcherActivityCallback() { mFragmentCallback = new FragmentCallback(); @Override public void onActivityCreated(Activity activity, Bundle savedInstanceState) { if (activity instanceof FragmentActivity) { ((FragmentActivity) activity).getSupportFragmentManager() .registerFragmentLifecycleCallbacks(mFragmentCallback, true); ReportFragment.injectIfNeededIn(activity); }之前还疑惑为什么ReportFragment的实现不写到SupportActivity中去,看到这里终于理解了其存在的意义了吧。LifecycleDispatcher并不需要在Application中调用,他通过ContentProvider实现初始化public class ProcessLifecycleOwnerInitializer extends ContentProvider { @Override public boolean onCreate() { LifecycleDispatcher.init(getContext()); ProcessLifecycleOwner.init(getContext()); return true; }在android.arch.lifecycle:extensionsaar的AndroidManifest中注册:<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="android.arch.lifecycle.extensions" > <uses-sdk android:minSdkVersion="14" /> <application> <provider android:name="android.arch.lifecycle.ProcessLifecycleOwnerInitializer" android:authorities="${applicationId}.lifecycle-trojan" android:exported="false" android:multiprocess="true" /> </application> </manifest>${applicationId}占位符,避免authroities冲突。可见在无侵这件事情上做到了极致,这种无侵的初始化方法非常值得我们借鉴和使用。两种Fragment通过上面分析,我们知道Activity是通过ReportFragment代理了LifecycleOwner的实现。那么在Activity中添加的LifecycleOwner与Activity的Fragment的生命周期是否一致呢?答案是否定的Android中存在两种Fragment有两种:ADK自带的android.app.FragmentSupport包中的android.support.v4.app.Fragment(AndroidX也归为此类)由于前者已经被@Deprecated,所以现在普遍使用的是后者,也就是Support或者AndroidX的Fragment。而出于低版本兼容性的考虑,ReportFragment是前者。Activity对于两种Fragment生命周期回调的实际并不相同,以onResume和onStart为例,Activity回调的实际如下表: onStartonResumeandroid.app.fragmentActivity.performStart(2)Activity.onResume(3)support fragmentActivity.onStart(1)Activity.onPostResume(4)上面表格中()中的数字表示依次执行的顺序,所以你会发现,adk fragment的onStart晚于support fragment,而onResume却更早执行Activity的LifecycleOwner虽然是基于Fragment实现的,但是同一个Activity的LifecycleOwner与Fragment的生命周期回调实际并不一致。这在我们的开发重要特备注意,不要视图让Fragment和LifecycleOwner的生命周期中的处理产生时序上的依赖关系。总结通过源码分析Activity对于LifecycleOwner的实现后,我们得到以下结论Activity不直接调用HandleLifecycleEvent进行生命周期的分发,而是通过ReportFragment实现ReportFragment的注入和过程全程无侵,值得我们借鉴和学习同一个Activity,其LifecycleOwner与Fragment的生命周期回调实际并不一致,需要特别注意

【Android Jetpack】Room数据库的使用及原理详解

Android Jetpack的出现统一了Android开发生态,各种三方库逐渐被官方组件所取代。Room也同样如此,逐渐取代竞品成为最主流的数据库ORM框架。这当然不仅仅因为其官方身份,更是因为其良好的开发体验,大大降低了SQLite的使用门槛。1. 基本介绍框架特点相对于SQLiteOpenHelper等传统方法,使用Room操作SQLite有以下优势:编译期的SQL语法检查开发高效,避免大量模板代码API设计友好,容易理解可以与RxJava、 LiveData 、 Kotlin Coroutines等进行桥接添加依赖dependencies { implementation "androidx.room:room-runtime:2.2.5" kapt "androidx.room:room-compiler:2.2.5" }基本组件Room的使用,主要涉及以下3个组件Database: 访问底层数据库的入口Entity: 代表数据库中的表(table),一般用注解Data Access Object (DAO): 数据库访问者这三个组件的概念也出现在其他ORM框架中,有过使用经验的同学理解起来并不困难: 通过Database获取DAO,然后通过DAO查询并获取entities,最终通过entities对数据库table中数据进行读写DatabaseDatabase是我们访问底层数据库的入口,管理着真正的数据库文件。我们使用@Database定义一个Database类:派生自RoomDatabase关联其内部数据库table对应的entities提供获取DAO的抽象方法,且不能有参数@Database(entities = arrayOf(User::class), version = 1) abstract class UserDatabase : RoomDatabase() { abstract fun userDao(): UserDao }运行时,我们可以通过Room.databaseBuilder()或者 Room.inMemoryDatabaseBuilder()获取Database实例val db = Room.databaseBuilder( applicationContext, UserDatabase::class.java, "users-db" ).build()创建Databsse的成本较高,推荐使用单例的Database,避免反复创建实例带来的开销Entity一个Entity代表数据库中的一张表(table)。我们使用@Entity定义一个Entiry类,类中的属性对应表中的Column@Entity data class User( @PrimaryKey val uid: Int, @ColumnInfo(name = "first_name") val firstName: String?, @ColumnInfo(name = "last_name") val lastName: String? )所有的属性必须是public、或者有get、set方法属性中至少有一个主键,使用@PrimaryKey表示单个主键,也可以像下面这样定义多主键@Entity(primaryKeys = arrayOf("firstName", "lastName"))当主键值为null时,autoGenerate可以帮助自动生成键值@PrimaryKey(autoGenerate = true) val uid : Int默认情况下使用类名作为数据库table名,也可使用tableName指定@Entity(tableName = "users")Entity中的所有属性都会被持久化到数据库,除非使用@Ignore@Ignore val picture: Bitmap?可以使用indices指定数据库索引,unique设置其为唯一索引@Entity(indices = arrayOf(Index(value = ["last_name", "address"]))) @Entity(indices = arrayOf(Index(value = ["first_name", "last_name"], unique = true)))Data Access Object (DAO)DAO提供了访问DB的API,我们使用@Dao定义DAO类,使用@Query 、@Insert、 @Delete定义CRUD方法@Dao interface UserDao { @Query("SELECT * FROM user") fun getAll(): List<User> @Query("SELECT * FROM user WHERE uid IN (:userIds)") fun loadAllByIds(userIds: IntArray): List<User> @Insert fun insertAll(vararg users: User) @Delete fun delete(user: User) }DAO的方法调用都在当前线程进行,所以要避免在UI线程直接访问Type Converters有时,需要将自定义类型的数据持久化到DB,此时需要借助Converters进行转换class Converters { @TypeConverter fun fromTimestamp(value: Long?): Date? { return value?.let { Date(it) } @TypeConverter fun dateToTimestamp(date: Date?): Long? { return date?.time?.toLong() }在声明Database时,指定此Converters@Database(entities = arrayOf(User::class), version = 1) @TypeConverters(Converters::class) abstract class UserDatabase : RoomDatabase() { abstract fun userDao(): UserDao }2. Data Access Objects(DAO)Room中使用Data Access Objects(DAO)对数据库进行读写,相对于SQL语句直接查询,DAO可以定义更加友好的API。DAO中可以自定义CURD方法,还可以方便地与RxJava、LiveData等进行集成。我们可以使用接口或者抽象类定一个DAO,如果使用抽象类,可以选择性的为其定义构造函数,并接受Database作为唯一参数。Room在编译期会基于定义的DAO生成具体实现类,实现具体CURD方法。@Insert 插入@Insert注解插入操作,编译期生成的代码会将所有的参数以单独的事务更新到DB。@Dao interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertUsers(vararg users: User) @Insert fun insertBothUsers(user1: User, user2: User) @Insert fun insertUsersAndFriends(user: User, friends: List<User>) }onConflict设置当事务中遇到冲突时的策略OnConflictStrategy.REPLACE : 替换旧值,继续当前事务OnConflictStrategy.ROLLBACK : 回滚当前事务OnConflictStrategy.ABORT : 结束当前事务、回滚OnConflictStrategy.FAIL : 当前事务失败、回滚OnConflictStrategy.NONE : 忽略冲突,继续当前事务最新代码中ROLLBACK 和 FAIL 已经deprecated了,使用ABORT替代@Update 更新@Update注解定义更新操作,根据参数对象的主键更新指定row的数据@Dao interface UserDao { @Update(onConflict = OnConflictStrategy.REPLACE) fun updateUsers(vararg users: User) @Update fun update(user: User) }@Delete 删除@Delete定义删除操作,根据主键删除指定row@Dao interface UserDao { @Delete fun deleteUsers(vararg users: User) }@Query 查询@Query注解定义查询操作。@Query中的SQL语句以及返回值类型等会在编译期进行检查,更早的暴露问题@Dao interface UserDao { @Query("SELECT * FROM users") fun loadAllUsers(): Array<User> }指定参数可以用参数指定@Query中的where条件:@Dao interface UserDao { @Query("SELECT * FROM users WHERE age BETWEEN :minAge AND :maxAge") fun loadAllUsersBetweenAges(minAge: Int, maxAge: Int): Array<User> @Query("SELECT * FROM users WHERE first_name LIKE :search " + "OR last_name LIKE :search") fun findUserWithName(search: String): List<User> }返回子集返回的结果可以是所有column的子集:data class NameTuple( @ColumnInfo(name = "first_name") val firstName: String?, @ColumnInfo(name = "last_name") val lastName: String? )@Dao interface UserDao { @Query("SELECT first_name, last_name FROM users") fun loadFullName(): List<NameTuple> }返回Cursor返回Cursor,可以基于Cursor进行进一步操作@Dao interface UserDao { @Query("SELECT * FROM users") fun loadAllUsers(): Cursor }多表查询@Dao interface BookDao { @Query( "SELECT * FROM book " + "INNER JOIN loan ON loan.book_id = book.id " + "INNER JOIN user ON user.id = loan.user_id " + "WHERE users.name LIKE :userName" fun findBooksBorrowedByNameSync(userName: String): List<Book> }SQL可以写任何语句,包括多表连接等返回类型Room可以返回Coroutine、RxJava等多个常用库的类型结果,便于在异步、响应式开发中使用3.实体与数据表关系对于关系型数据库来说,最重要的是如何将数据拆分为有相关关系的多个数据表。SQLite作为关系型数据库,允许entits之间可以有多种关系,Room提供了多种方式表达这种关系。@Embedded内嵌对象@Embedded注解可以将一个Entity作为属性内嵌到另一Entity,我们可以像访问Column一样访问内嵌Entity内嵌实体本身也可以包括其他内嵌对象data class Address( val street: String?, val state: String?, val city: String?, val postCode: Int @Entity data class User( @PrimaryKey val id: Int, val firstName: String?, @Embedded val address: Address? )如上,等价于User表包含了 id, firstName, street, state, city, postCode等column如果内嵌对象中存在同名字段,可以使用prefix指定前缀加以区分@Embedded通过把内嵌对象的属性解包到被宿主中,建立了实体的连接。此外还可以通过@Relation 和 foreignkeys来描述实体之间更加复杂的关系。我们至少可以描述三种实体关系一对一一对多或多对一多对多一对一主表(Parent Entity)中的每条记录与从表(Child Entity)中的每条记录一一对应。设想一个音乐app的场景,用户(User)和曲库(Library)有如下关系:一个User只有一个Library一个Library只属于唯一User@Entity data class User( @PrimaryKey val userId: Long, val name: String, val age: Int @Entity(foreignKeys = @ForeignKey(entity = User.class, parentColumns = "userId", childColumns = "userOwnerId", onDelete = CASCADE)) data class Library( @PrimaryKey val libraryId: Long, val title: String, val userOwnerId: Long data class UserAndLibrary( @Embedded val user: User, @Relation( parentColumn = "userId", entityColumn = "userOwnerId" val library: Library )如上,User和Library之间属于一对一的关系。foreignkeysforeignkeys作为@Relation的属性用来定义外键约束。外键只能在从表上,从表需要有字段对应到主表的主键(Library的userOwnerId对应到User的userId)。外键约束属性:当有删除或者更新操作的时候发出这个约束通过外键约束,对主表的操作会受到从表的影响。例如当在主表(即外键的来源表)中删除对应记录时,首先检查该记录是否有对应外键,如果有则不允许删除。@Relation为了能够对User以及关联的Library进行查询,需要为两者之间建立一对一关系:通过UserAndLibrary定义这种关系,包含两个成员分别是主表和从表的实体为从表添加@Relation注解parentColumn:主表主键entityColumn:从表外键约束的字段然后,可以通过UserAndLibrary进行查询@Transaction @Query("SELECT * FROM User") fun getUsersAndLibraries(): List<UserAndLibrary>此方法要从两个表中分别进行两次查询,所以@Transaction确保方法中的多次查询的原子性一对多主表中的一条记录对应从表中的零到多条记录。在前面音乐APP的例子中,有如下一对多关系:一个User可以创建多个播放列表(Playlist)每个Playlist只能有唯一的创作者(User)@Entity data class User( @PrimaryKey val userId: Long, val name: String, val age: Int @Entity(foreignKeys = @ForeignKey(entity = User.class, parentColumns = "userId", childColumns = "userCreatorId", onDelete = CASCADE)) data class Playlist( @PrimaryKey val playlistId: Long, val userCreatorId: Long, val playlistName: String data class UserWithPlaylists( @Embedded val user: User, @Relation( parentColumn = "userId", entityColumn = "userCreatorId" val playlists: List<Playlist> )可以看到,一对多关系的UserWithPlaylists与一对一类似, 只是playlists需要是一个List表示从表中的记录不止一个。查询方法如下:@Transaction @Query("SELECT * FROM User") fun getUsersWithPlaylists(): List<UserWithPlaylists>多对多主表中的一条记录对应从表中的零活多个,反之亦然:每个Playlist中可以有很多首歌曲(Song)每个Song可以归属不同的Playlist因此,Playlist与Song之间是多对多的关系@Entity data class Playlist( @PrimaryKey val id: Long, val playlistName: String @Entity data class Song( @PrimaryKey val id: Long, val songName: String, val artist: String @Entity(primaryKeys = ["playlistId", "songId"], foreignKeys = { @ForeignKey(entity = Playlist.class, parentColumns = "id", childColumns = "playlistId"), @ForeignKey(entity = Song.class, parentColumns = "id", childColumns = "songId") data class PlaylistSongCrossRef( val playlistId: Long, val songId: Long )多对多关系中,Song和Playlist之间没有明确的外键约束关系,需要定义一个 associative entity(又或者称作交叉连接表):PlaylistSongCrossRef,然后分别与Song和Playlist建立外键约束。交叉连接的结果是Song与Playlist的笛卡尔积,即两个表中所有记录的组合。基于交叉连接表,我们可以获取一首Song与其包含它的所有Playlist,又或者一个Playlist与其包含的所有Song。如果使用SQL获取指定Playlist与其包含的Song,需要两条查询:# 查询playlist信息 SELECT * FROM Playlist # 查询Song信息 SELECT Song.id AS songId, Song.name AS songName, _junction.playlistId PlaylistSongCrossRef AS _junction INNER JOIN Song ON (_junction.songId = Song.id) # WHERE _junction.playlistId IN (playlistId1, playlistId2, …)如果使用Room,则需要定义PlaylistWithSongs类,并告诉其使用PlaylistSongCrossRef作为连接:data class PlaylistWithSongs( @Embedded val playlist: Playlist, @Relation( parentColumn = "playlistId", entityColumn = "songId", associateBy = @Junction(PlaylistSongCrossRef::class) val songs: List<Song> 同理,也可定义SongWithPlaylistsdata class SongWithPlaylists( @Embedded val song: Song, @Relation( parentColumn = "songId", entityColumn = "playlistId", associateBy = @Junction(PlaylistSongCrossRef::class) val playlists: List<Playlist> )查询与前面类似,很简单:@Transaction @Query("SELECT * FROM Playlist") fun getPlaylistsWithSongs(): List<PlaylistWithSongs> @Transaction @Query("SELECT * FROM Song") fun getSongsWithPlaylists(): List<SongWithPlaylists>4. 实现原理通过例子了解一下Room的底层实现原理。Database定义一个UserDatabase,只有一个实体User:@Database(entities = [User::class], version = 1) abstract class UserDatabase : RoomDatabase() { abstract fun userDao(): UserDao }EntityUser有三个字段(Column):@Entity(tableName = USERS_TABLE) data class User( @PrimaryKey val uid: Int, @ColumnInfo(name = FIRST_NAME_COLUMN) val firstName: String?, @ColumnInfo(name = LAST_NAME_COLUMN) val lastName: String? )UserDao通过接口定义UserDao@Dao interface UserDao { @Query("SELECT * FROM $USERS_TABLE") fun getAll(): List<User> @Insert fun insertAll(vararg users: User) @Delete fun delete(user: User) }源码分析Room在编译期通过kapt处理@Dao和@Database注解,并生成DAO和Database的实现类,UserDatabase_Impl和UserDao_Impl。kapt生成的代码在 build/generated/source/kapt/UserDatabase_Implpublic final class UserDatabase_Impl extends UserDatabase { private volatile UserDao _userDao; //RoomDataBase的init中调用 @Override protected SupportSQLiteOpenHelper createOpenHelper(DatabaseConfiguration configuration) { final SupportSQLiteOpenHelper.Callback _openCallback = new RoomOpenHelper(configuration, new RoomOpenHelper.Delegate(1) { @Override public void createAllTables(SupportSQLiteDatabase _db) { //Implementation @Override protected void onCreate(SupportSQLiteDatabase _db) { //Implementation final SupportSQLiteOpenHelper.Configuration _sqliteConfig = SupportSQLiteOpenHelper.Configuration.builder(configuration.context) .name(configuration.name) .callback(_openCallback) .build(); final SupportSQLiteOpenHelper _helper = configuration.sqliteOpenHelperFactory.create(_sqliteConfig); return _helper; @Override protected InvalidationTracker createInvalidationTracker() { final HashMap<String, String> _shadowTablesMap = new HashMap<String, String>(0); HashMap<String, Set<String>> _viewTables = new HashMap<String, Set<String>>(0); return new InvalidationTracker(this, _shadowTablesMap, _viewTables, "users"); @Override public void clearAllTables() { super.assertNotMainThread(); final SupportSQLiteDatabase _db = super.getOpenHelper().getWritableDatabase(); try { super.beginTransaction(); _db.execSQL("DELETE FROM `users`"); super.setTransactionSuccessful(); } finally { super.endTransaction(); _db.query("PRAGMA wal_checkpoint(FULL)").close(); if (!_db.inTransaction()) { _db.execSQL("VACUUM"); @Override public UserDao userDao() { //实现见后文 }createOpenHelper: Room.databaseBuilder().build()创建Database时,会调用实现类的createOpenHelper()创建SupportSQLiteOpenHelper,此Helper用来创建DB以及管理版本createInvalidationTracker :创建跟踪器,确保table的记录修改时能通知到相关回调方clearAllTables:清空table的实现userDao:创建UserDao_ImplUserDao_Implpublic final class UserDao_Impl implements UserDao { private final RoomDatabase __db; private final EntityInsertionAdapter<User> __insertionAdapterOfUser; private final EntityDeletionOrUpdateAdapter<User> __deletionAdapterOfUser; public UserDao_Impl(RoomDatabase __db) { this.__db = __db; this.__insertionAdapterOfUser = new EntityInsertionAdapter<User>(__db) { //Implementation this.__deletionAdapterOfUser = new EntityDeletionOrUpdateAdapter<User>(__db) { //Implementation @Override public void insertAll(final User... users) { //Implementation @Override public void delete(final User user) { //Implementation @Override public List<User> getAll() { //Implementation @Override public List<User> loadAllByIds(final int[] userIds) { //Implementation @Override public User findByName(final String first, final String last) { //Implementation }UserDao_Impl 主要有三个属性:__db:RoomDatabase的实例__insertionAdapterOfUser :EntityInsertionAdapterd实例,用于数据insert。上例中,将在installAll()中调用__deletionAdapterOfUser:EntityDeletionOrUpdateAdapter实例,用于数据的update/delete。 上例中,在delete()中调用RoomDatabase.BuilderRoom通过Build模式创建Database实例val userDatabase = Room.databaseBuilder( applicationContext, UserDatabase::class.java, "users-db" ).build()Builder的好处时便于对Database进行配置createFromAsset()/createFromFile() :从SD卡或者Asset的db文件创建RoomDatabase实例addMigrations() :添加一个数据库迁移(migration),当进行数据版本升级时需要allowMainThreadQueries() :允许在UI线程进行数据库查询,默认是不允许的fallbackToDestructiveMigration() :如果找不到migration则重建数据库表(会造成数据丢失)除上面以外,还有其他很多配置。调用build()后,创建UserDatabase_Impl,并调用init(),内部会调用createOpenHelper()。userDao()@Override public UserDao userDao() { if (_userDao != null) { return _userDao; } else { synchronized(this) { if(_userDao == null) { _userDao = new UserDao_Impl(this); return _userDao; }通过构造参数,向UserDao_Impl传入RoomDatabaseinsertAll()@Override public void insertAll(final User... users) { __db.assertNotSuspendingTransaction(); __db.beginTransaction(); try { __insertionAdapterOfUser.insert(users); __db.setTransactionSuccessful(); } finally { __db.endTransaction(); }使用__db开启事务,使用__insertionAdapterOfUser执行插入操作delete()@Override public void delete(final User user) { __db.assertNotSuspendingTransaction(); __db.beginTransaction(); try { __deletionAdapterOfUser.handle(user); __db.setTransactionSuccessful(); } finally { __db.endTransaction(); }同insertAll()getAll()@Override public List<User> getAll() { final String _sql = "SELECT * FROM users"; final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 0); __db.assertNotSuspendingTransaction(); final Cursor _cursor = DBUtil.query(__db, _statement, false, null); try { final int _cursorIndexOfUid = CursorUtil.getColumnIndexOrThrow(_cursor, "uid"); final int _cursorIndexOfFirstName = CursorUtil.getColumnIndexOrThrow(_cursor, "first_name"); final int _cursorIndexOfLastName = CursorUtil.getColumnIndexOrThrow(_cursor, "last_name"); final List<User> _result = new ArrayList<User>(_cursor.getCount()); while(_cursor.moveToNext()) { final User _item; final int _tmpUid; _tmpUid = _cursor.getInt(_cursorIndexOfUid); final String _tmpFirstName; _tmpFirstName = _cursor.getString(_cursorIndexOfFirstName); final String _tmpLastName; _tmpLastName = _cursor.getString(_cursorIndexOfLastName); _item = new User(_tmpUid,_tmpFirstName,_tmpLastName); _result.add(_item); return _result; } finally { _cursor.close(); _statement.release(); }基于@Query注解的sql语句创建RoomSQLiteQuery,然后创建cursor进行后续操作5. 数据库升级当数据库的表结构发生变化时,我们需要通过数据库迁移(Migrations)升级表结构,避免数据丢失。例如,我们想要为User表增加age字段| uid | first_name | last_name |↓↓| uid | first_name | last_name | age |数据迁移需要使用Migration类:val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE users ADD COLUMN age INTEGER") }Migration通过startVersion和endVersion表明当前是哪个版本间的迁移,然后在运行时,按照版本顺序调用各Migration,最终迁移到最新的Version创建Database时设置Migration:Room.databaseBuilder( applicationContext, UserDatabase::class.java, "users-db" ).addMigrations(MIGRATION_1_2) .build()迁移失效迁移中如果找不到对应版的Migration,会抛出IllegalStateException:java.lang.IllegalStateException: A migration from 1 to 2 is necessary. Please provide a Migration in the builder or call fallbackToDestructiveMigration in the builder in which case Room will re-create all of the tables.可以添加降级处理,避免crash:Room.databaseBuilder( applicationContext, UserDatabase::class.java, "users-db" ).fallbackToDestructiveMigration() .build()fallbackToDestructiveMigration:迁移失败时,重建数据库表fallbackToDestructiveMigrationFrom:迁移失败时,基于某版本重建数据库表fallbackToDestructiveMigrationOnDowngrade:迁移失败,数据库表降级到上一个正常版本6. 集成三方库(LiveData、RxJava等)作为Jetpack生态的成员,Room可以很好地兼容Jetpack的其他组件以及ACC推荐的三方库,例如LiveData、RxJava等。使用LiveDataDAO可以定义LiveData类型的结果,Room内部兼容了LiveData的响应式逻辑。可观察的查询通常的Query需要命令式的获取结果,LiveData可以让结果的更新可被观察(Observable Queries)。@Dao interface UserDao { @Query("SELECT * FROM users") fun getAllLiveData(): LiveData<List<User>> }当DB的数据发生变化时,Room会更新LiveData:@Override public LiveData<List<User>> getAllLiveData() { final String _sql = "SELECT * FROM users"; final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 0); return __db.getInvalidationTracker().createLiveData(new String[]{"users"}, false, new Callable<List<User>>() { @Override public List<User> call() throws Exception { final Cursor _cursor = DBUtil.query(__db, _statement, false, null); try { final int _cursorIndexOfUid = CursorUtil.getColumnIndexOrThrow(_cursor, "uid"); final int _cursorIndexOfFirstName = CursorUtil.getColumnIndexOrThrow(_cursor, "first_name"); final int _cursorIndexOfLastName = CursorUtil.getColumnIndexOrThrow(_cursor, "last_name"); final List<User> _result = new ArrayList<User>(_cursor.getCount()); while(_cursor.moveToNext()) { final User _item; final int _tmpUid; _tmpUid = _cursor.getInt(_cursorIndexOfUid); final String _tmpFirstName; _tmpFirstName = _cursor.getString(_cursorIndexOfFirstName); final String _tmpLastName; _tmpLastName = _cursor.getString(_cursorIndexOfLastName); _item = new User(_tmpUid,_tmpFirstName,_tmpLastName); _result.add(_item); return _result; } finally { _cursor.close(); @Override protected void finalize() { _statement.release(); }__db.getInvalidationTracker().createLiveData() 接受3个参数tableNames:被观察的表inTransaction:查询是否基于事务computeFunction:表记录变化时的回调computeFunction的call中执行真正的sql查询。当Observer首次订阅LiveData时,或者表数据发生变化时,便会执行到这里。使用RxJava添加依赖使用RxJava需要添加以下依赖dependencies { def room_version = "2.2.5" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" // RxJava support for Room implementation "androidx.room:room-rxjava2:$room_version" }响应式的查询DAO的返回值类型可以是RxJava2的各种类型:@Query注解的方法:返回 Flowable 或 Observable.@Insert/@Update/@Delete注解的方法: 返回Completable, Single, and Maybe(Room 2.1.0以上)@Dao interface UserDao { @Query("SELECT * from users where uid = :id LIMIT 1") fun loadUserById(id: Int): Flowable<User> @Insert fun insertUsers(vararg users: User): Completable @Delete fun deleteAllUsers(users: List<User>): Single<Int> }@Override public Completable insertLargeNumberOfUsers(final User... users) { return Completable.fromCallable(new Callable<Void>() { @Override public Void call() throws Exception { __db.beginTransaction(); try { __insertionAdapterOfUser.insert(users); __db.setTransactionSuccessful(); return null; } finally { __db.endTransaction(); }@Override public Single<Integer> deleteAllUsers(final List<User> users) { return Single.fromCallable(new Callable<Integer>() { @Override public Integer call() throws Exception { int _total = 0; __db.beginTransaction(); try { _total +=__deletionAdapterOfUser.handleMultiple(users); __db.setTransactionSuccessful(); return _total; } finally { __db.endTransaction(); }@Override public Flowable<User> loadUserById(final int id) { final String _sql = "SELECT * from users where uid = ? LIMIT 1"; final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 1); int _argIndex = 1; _statement.bindLong(_argIndex, id); return RxRoom.createFlowable(__db, false, new String[]{"users"}, new Callable<User>() { @Override public User call() throws Exception { //Implementation @Override protected void finalize() { _statement.release(); }如上,使用fromCallable{...}创建Completable与Single; RxRoom.createFlowable{...}创建Flowable。call()里执行真正的sql操作使用协程Coroutine添加依赖使用Coroutine需要添加额外依赖:dependencies { def room_version = "2.2.5" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" // Kotlin Extensions and Coroutines support for Room implementation "androidx.room:room-ktx:$room_version" }挂起函数定义DAO为UserDao中的CURD方法添加suspend@Dao interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertUsers(vararg users: User) @Update suspend fun updateUsers(vararg users: User) @Delete suspend fun deleteUsers(vararg users: User) @Query("SELECT * FROM users") suspend fun loadAllUsers(): Array<User> }CoroutinesRoom.execute 中进行真正的sql语句,并通过Continuation将callback变为Coroutine的同步调用@Override public Object insertUsers(final User[] users, final Continuation<? super Unit> p1) { return CoroutinesRoom.execute(__db, true, new Callable<Unit>() { @Override public Unit call() throws Exception { __db.beginTransaction(); try { __insertionAdapterOfUser.insert(users); __db.setTransactionSuccessful(); return Unit.INSTANCE; } finally { __db.endTransaction(); }, p1); }可以对比一下普通版本的insertUsers:@Override public void insertUsers(final User... users) { __db.assertNotSuspendingTransaction(); __db.beginTransaction(); try { __insertionAdapterOfUser.insert(users); __db.setTransactionSuccessful(); } finally { __db.endTransaction(); }区别很明显,添加了suspend后,生成代码中会使用CoroutinesRoom.execute封装协程。