揭秘 Jetpack Compose 快照系统 | 开发者说·DTalk
本文原作者: fundroid, 原文 发布于: AndroidPub
引言
Compose 通过名为 "快照 (Snapshot)" 的系统支撑状态管理与重组机制的运行。快照作为一个底层设施,在我们的日常开发中很少直接接触,本文就为大家揭开快照的神秘面纱。我们在开头先抛出几个问题,希望在文章结束时大家能够找到答案,对快照也就算有了初步了解了。
-
快照能做什么?
-
快照与状态的关系?
-
快照与线程的关系?
-
快照与重组的关系?
注意: 本文出现的源码基于版本 1.2.0-alpha06。本文重在帮助大家建立认知,对源码的介绍只是点到为止,请放松阅读。
我们知道 Compose 库从上到下分为多层: Material > UI > Runtime > Compiler。快照系统位于 Runtime 层 androidx/compose/runtime/snapshots 。它自成体系,可以脱离 Compose UI 甚至 Compiler 单独使用,只依赖 Runtime 即可使用快照功能,本文中的 Sample 代码均可以不依赖 UI 运行。
implementation "androidx.compose.runtime:runtime:$compose_version"
快照的基本操作
快照并非 Compose Runtime 的原创概念,它其实是一个 MVCC 系统的实现, MVCC 全称 Multiversion Concurrency Control (多版本并发控制) ,常用于数据库管理系统,实现事务并发,提升数据库性能,其模型与 Git 分支管理系统也有点类似,因此我们可以类比数据库的事务或者 Git 的分支来理解快照机制。
快照的创建
先看下面的例子:
fun test() {
// 创建状态(主线开发)
val state = mutableStateOf(1)
// 创建快照(开分支)
val snapshot = Snapshot.takeSnapshot()
// 修改状态(主线修改状态)
state.value = 2
println(state.value) // 打印1
snapshot.enter {//进入快照(切换分支)
// 读取快照状态(分支状态)
println(state.value) // 打印1
}
// 读取状态(主线状态)
println(state.value) // 打印2
// 废弃快照(删除分支)
snapshot.dispose()
}
例子中展示了快照的基本功能: 隔离访问 。 Snapshot.takeSnapshot() 创建了一个快照,通过调用其 enter() 进入此快照。在快照上只能看到快照被创建时刻的最新状态,看不到此后的变化。
将快照类比成 Git 系统,程序默认处于 GlobalSnapshot 全局快照中,这相当于 Git 的 Main 分支。从全局快照上创建并进入子快照,就如同在 Main 上创建并切换分支,分支代码保持分支创建时的状态,看不到主线或其他分支的修改。当然 Git 的隔离对象是代码,而快照的隔离对象是 "状态",也就是 mutableStateOf 创建的一个 StateObject 实例。
使用下面这些方法都可以创建 StateObject 对象,它们都可以被快照隔离:
-
mutableStateOf/MutableState
-
mutableStateListOf/SnapshotStateList
-
mutableStateMapOf/SnapshotStateMap
-
derivedStateOf
-
rememberUpdatedState
-
collect*AsState
快照的修改 & 提交
上面的例子中 enter() 内只是读取了快照状态,如果我们试图更新状态则会抛出异常。 takeSnapshot() 创建的是一个只读快照,不允许对状态有写操作。如果需要更新状态,需要使用 takeMutableSnapshot() 创建可写的快照:
// 创建可写的快照
val snapshot = Snapshot.takeMutableSnapshot()
snapshot.enter {
// 对快照状态进行变更
state.value = 2
println(state.value) // 打印2
}
// snaphot之外看不到对快照状态的修改。
println(state.value) // 打印1
如上,我们对状态的修改同样会被快照隔离。快照中的状态修改只对当前快照可见,在快照之外看不到,如果我们希望快照的修改通知到全局,可以使用 apply 提交这个修改。类比到 Git 就好似通过 merge 将分支合并回了主线。
snapshot.enter {
// ...
}
// 提交snapshot中的状态修改
snapshot.apply()
// 快照外可以看到snapshot中的修改
println(state.value) // 打印2
我们还可以使用 withMutableSnapshot 简化代码,它可以在 "切换回主线" 时自动提交变更。
Snapshot.withMutableSnapshot {
state.value = 2
}
println(state.value) // 打印2
注意: git merge 可以在任意分支之间进行合并,而快照的 apply 永远是从当前快照提交到 "父快照"。快照上允许嵌套创建快照,因此快照存在父子关系。
访问隔离的实现原理
前面介绍了快照的基本功能是对状态的访问隔离。Compose 状态本质上是一个 StateObject 实例,为什么在不同快照下访问同一个 StateObject 实例,却能读取到不同结果呢?研究源码后会发现,与其说是 快照隔离了状态,倒不如说是状态关联了快照 。
状态关联快照
StateObject 内部维护了一个 StateRecord 链表。
所有快照在创建时都会被赋予一个全局递增的 id,即 SnapshotId, StateObject 被写入的状态值会关联当前快照的 snapshotId ,然后保存在 StateRecord 中。当我们在不同快照下访问 StateObject 时,通过遍历 SatateRecord 链表只能看到 当前快照允许看到的值 。
可见,Compose 的 State 天生支持在快照中访问,所以 Compose 的状态也经常被称为快照状态 (Snapshot State),快照状态通过 snapshotId 实现 "多版本并发控制" 的目的。
管理 SnapshotId
那么 "当前快照允许看到的值" 是如何确定的呢?到这里大家应该很容易想到,其实就是比较访问中的 StateRecord 与当前快照的 snapshotId 。当我们在快照上读取 StateObject 时,会走到 Snapshot.kt 的 readable 中:
//androidx/compose/runtime/snapshots/Snapshot.kt
//遍历链表,根据 snapshotId 返回符合当前快照读取条件的 StateRecord
private fun <T : StateRecord> readable(r: T, id: Int, invalid: SnapshotIdSet): T? {
var current: StateRecord? = r
var candidate: StateRecord? = null
//while 循环中遍历链表
while (current != null) {
//valid 方法检查 StateRecord 是否符合条件
if (valid(current, id, invalid)) {
// 符合条件且 snapshotId 最大的 StateRecord 作为结果返回。
candidate = if (candidate == null) current
else if (candidate.snapshotId < current.snapshotId) current else candidate
}
current = current.next
}
if (candidate != null) {
@Suppress("UNCHECKED_CAST")
return candidate as T
}
return null
}
/**
* 检查 StateRecord 是否可以被读取:
* 1. StateRecord#snapshotId != INVALID_SNAPSHOT。
* 2. StateRecord#snapshotId 不大于当前快照 id。
* 3. StateRecord#snapshotId 不在 invalid 集合中
*/
private fun valid(currentSnapshot: Int, candidateSnapshot: Int, invalid: SnapshotIdSet): Boolean {
return candidateSnapshot != INVALID_SNAPSHOT && candidateSnapshot <= currentSnapshot &&
!invalid.get(candidateSnapshot)
}
代码很清晰,如大家所料,这里通过 snapshotId 的比较来决定 StateRecord 是否可读。因为快照被赋予了全局自增 id,理论上小于当前 snapshotId 的状态值是快照创建前被写入的,所以应该对当前快照可见。我们注意到除了 snapshotId 的比较之外,还要求 StateRecord#snapshotId 不能位于 invalid 集合中。
//androidx/compose/runtime/snapshots/Snapshot.kt
open class MutableSnapshot internal constructor(
id: Int, // 快照id
invalid: SnapshotIdSet, //快照黑名单
override val readObserver: ((Any) -> Unit)?, // 读回调,后文介绍
override val writeObserver: ((Any) -> Unit)? // 写回调,后文介绍
) : Snapshot(id, invalid)
MutableSnapshot 的定义如上,其中 invalid 成员代表一个快照黑名单。处于黑名单中的 id,即使比当前快照 id 小,也视为不可见内容。我们前面介绍过快照的提交,在子快照未提交之前,即使它的 id 小于全局快照也不应该被全局看见,因此在正式提交之前会被加入全局快照的这个黑名单。
创建/提交快照时的 id 变化如上图所示:
-
我们在 GlobalSnapshot 中创建子快照,id 赋值为 2;
-
为了让子快照中访问不到父快照后续的状态变化,子快照创建后 GlobalSnapshot 的 id 升级至 3;
-
为了让 GlobalSnapshot 看不到子快照的状态变化,将 2 加入 invalid;
-
子快照提交后,GlobalSnapshot 的 invalid 中移除 2,子快照状态全局可见。
上面过程中出现了 id 升级的概念,可见快照提交的本质就是通过 升级父快照 id 让子快照状态全局可见 。这与 git merge 之后移动分支的 head 位置也有着异曲同工之处。
状态读写感知
快照系统 除了对状态的读写进行隔离,还可以对状态的读写进行感知 ,前面 MutableSnapshot 的定义中看到 readObserver 和 writeObserver 成员,它们就是快照上对状态进行读写操作时的回调。
val state = mutableStateOf(1)
// 监听状态读操作
val readObserver: (Any) -> Unit = { readState ->
if (readState == state) {
println("readObserver: $readState") // 打印 2
}
}
// 监听状态写操作
val writeObserver: (Any) -> Unit = { writtenState ->
if (writtenState == state) {
println("writeObserver: $writtenState") // 打印 2
}
}
val snapshot = Snapshot.takeMutableSnapshot(
readObserver = readObserver,
writeObserver = writeObserver
)
snapshot.enter {
// 写操作,触发 writeObserver 回调
state.value = 2
// 读操作,触发 readObserver 回调
val value = state.value
println(value) // 打印 2
}
snapshot.apply()
snapshot.dispose()
上面代码中,我们在创建快照时传入读写回调,快照中读写状态时依次触发回调,因此上面代码的日志输出如下:
writeObserver: 2
readObserver: 2
2
快照对状态读写的感知是 Compose 状态更新后自动触发重组的基础,我们在后文会详细介绍。
全局快照
我们知道 GlobalSnapshot 是程序所处的默认快照,它也是所有快照的 Root。由于不再存在父快照,所以全局快照上对状态的修改不需要追加提交操作 (apply),作为 Root 它更重要的职责是 "被提交"。全局快照上的状态变化通常是通过子快照的提交发生的,就如同 Main 上的代码变动大多来自各分支的 MR。
监听全局状态变化
子快照上的状态修改最终会通过 apply 提交到父快照。 registerApplyObserver 可以监听子快照提交后的状态变化。Compose 组合阶段的代码都执行在子快照上,所以组合阶段的状态变化都可以通过 ApplyObserver 获取。
提示: Composae 渲染分有三个阶段: 组合,布局,绘制,文中提到的组合就是其中第一个阶段
http://developer.android.google.cn/jetpack/compose/phases
有些状态变化发生在组合阶段之外,比如 onClick 或者一个异步请求的返回都可能触发状态变化,组合之外的代码不执行在子快照,因此它们会直接在全局快照上修改状态。全局快照上没有 apply 操作,但是我们通过主动调用 Snapshot.sendApplyNotifications() 同样可以向 ApplyObserver 发送通知获知全局状态的修改。 sendApplyNotifications 通过升级全局快照 id 来确定需要通知哪些状态的变化,即自上次升级 id 以来的所有状态。
ApplyObserver 的通知可能来自子快照的提交,也可能来自 sendApplyNotifications 的直接调用,但用途都是为了监听全局状态的变化。
下面的例子展示了 sendApplyNotifications 的使用效果:
val state = mutableStateOf(1)
Snapshot.registerApplyObserver { set, _ ->
// 将响应 sendApplyNotifications 的调用
// 获取有变更的状态
println("$set") // [MutableState(value=3)]
}
state.value = 2
state.value = 3 // 向 ApplyObserver 通知最后一次变化
// 通知变化
Snapshot.sendApplyNotifications()
除了使用 ApplyObserver 监听全局变化,我们还可以监听全局快照上对单个状态的写操作,由于全局快照不使用 takeSnapshot 创建,无法通过传入 writeObserver 注册回调,全局快照的写回调通过使用 Snapshot.registerGlobalWriteObserver 注册:
val state = mutableStateOf(1)
val observer = Snapshot.registerGlobalWriteObserver { writtenState ->
// MutableState(value=2) 和 MutableState(value=3) 都会收到
println("$writtenState")
}
state.value = 2
state.value = 3
observer.dispose()
每次状态修改都可以通过 registerGlobalWriteObserver 监听。注意全局快照不提供读操作的回调注册,因为 Compose 只会在组合阶段追踪对状态的读取,所以在子快照监听足以。
非 Compose 中使用快照
文章开头就提到,Compose 快照系统可以脱离 Compose UI 单独使用。下面的例子中,我们通过监听全局快照的状态,实现基于 View 的状态管理。
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var counter by mutableStateOf(0)
private val observer = Snapshot.registerGlobalWriteObserver {
Snapshot.sendApplyNotifications()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
lifecycleScope.launch {
snapshotFlow {
// 将 Counter 的变化更新至 TextView
binding.textCounter.text = "$counter"
}.collect()
}
binding.buttonIncrement.setOnClickListener {
counter++
}
binding.buttonDecrement.setOnClickListener {
counter--
}
}
override fun onDestroy() {
super.onDestroy()
observer.dispose()
}
}
snapshotFlow 是 Compose 提供的状态管理 API,可以监听全局快照的状态变化并转化为 Flow 发送出去。具体实现我们就不看了,只需要知道它内部通过 ApplyObserver 观察状态变化,因此我们通过 registerGlobalWriteObserver 监听到状态修改后,通过 sendApplyNotifications 发送通知。
这段代码同时也揭示了 Compose 的 State 可以像 RxJava/LiveData/Flow 那样成为一种通用的响应式工具,而且还可以省掉冗余的 subscribe/observe/collect 代码, snapshotFlow { } 中会自动追踪所有被读取的状态,当它们发生变化时,block 会触发执行,响应式逻辑更加简洁。
并发与冲突解决
前面的例子都是跑在单线程中的,而作为一个 MVCC 系统,只有在并发场景中使用才更有意义。通常并发环境下对数据访问,为了保证线程安全需要添加各种读写锁,而 快照系统通过访问隔离实现无锁操作,提高并发性能 。此外快照的提交机制也保证了容错性,进一步套用数据库事务的说法就是 保证了 ACID 中的原子性、隔离性和一致性 。
多线程下的快照保存
当快照在多线程环境下使用时,当前快照信息保存在 ThreadLocal 中。Compose 在组合执行过程中,通过 currentSnapshot() 获取当前快照。
//androidx.compose.runtime.SnapshotThreadLocal
//如果不存在当前快照,则返回全局快照
internal fun currentSnapshot(): Snapshot =
threadSnapshot.get() ?: currentGlobalSnapshot.get()
private val threadSnapshot = SnapshotThreadLocal<Snapshot>()
//使用 ThreadLocal 管理快照
internal actual class SnapshotThreadLocal<T> {
private val map = AtomicReference<ThreadMap>(emptyThreadMap)
private val writeMutex = Any()
@Suppress("UNCHECKED_CAST")
actual fun get(): T? = map.get().get(Thread.currentThread().id) as T?
actual fun set(value: T?) {
val key = Thread.currentThread().id
synchronized(writeMutex) {
val current = map.get()
if (current.trySet(key, value)) return
map.set(current.newWith(key, value))
}
}
}
单线程中同时只有一个快照处于活动中,活动中的快照通过 SnapshotThreadLocal 保存在 ThreadLocal 中,Compose 在组合阶段通过 currentSnapshot() 可以获取当前线程的活动快照。活动快照 dispose 后从 ThreadLocal 移走,之前非活动的快照进入活动状态。
从 Snapshot#enter 方法的实现可知, 进入快照的本质就是将快照存入 SnapshotThreadLocal :
inline fun <T> enter(block: () -> T): T {
val previous = makeCurrent()
try {
return block()
} finally {
restoreCurrent(previous)
}
}
internal open fun makeCurrent(): Snapshot? {
val previous = threadSnapshot.get()
threadSnapshot.set(this)
return previous
}
mergeRecords 解决冲突
并发环境必然要考虑冲突的发生。当我们在子线程快照中修改了某 StateObject ,同时它在父快照中也发生了变化,那么当提交子快照时就会遇到冲突,此时就要像 git merge 冲突一样,要么放弃提交,要么对冲突进行解决。记得前面 StateObject 的类图中曾经出现了一个 mergeRecords 方法, StateObject 就是用它来处理状态冲突的:
//androidx/compose/runtime/SnapshotState.kt
override fun mergeRecords(
previous: StateRecord, // 子快照创建之前的全局状态
current: StateRecord, // 全局快照最新状态
applied: StateRecord // 待提交的子快照状态
): StateRecord? {
val previousRecord = previous as StateStateRecord<T>
val currentRecord = current as StateStateRecord<T>
val appliedRecord = applied as StateStateRecord<T>
//父快照与待提交子快照的状态比较
return if (policy.equivalent(currentRecord.value, appliedRecord.value))
current
else {//如果状态不相等,进行merge操作
val merged = policy.merge(
previousRecord.value,
currentRecord.value,
appliedRecord.value
)
if (merged != null) {//merge成功则返回merge结果
appliedRecord.create().also {
(it as StateStateRecord<T>).value = merged
}
} else {
null
}
}
}
当子快照提交时,对全局快照的 previous 与 current 会进行比较,如果不相等则意味着本次提交有冲突的可能,此时会通过 mergeRecords 解决冲突,进入上面的代码。逻辑很清晰,重点是对 policy 的两个方法调用, equivalent 用来比较 current 与 applied ,如果不相等则调用 merge 进行合并操作,解决冲突。
Policy 是一个 SnapshotMutationPolicy 对象,代表快照冲突时的解决策略,我们使用 mutableStateOf 创建状态时可以传入自定义 Policy,Compose 也提供了三个默认 Policy,它们的区别主要是 equivalent 的不同:
-
structuralEqualityPolicy: 结构化比较,即通过 == 比较状态值是否相等,这也是 SnapshotState 目前默认的策略
-
referentialEqualityPolicy – 引用比较,通过 === 比较,只有同一实例才相等
-
neverEqualPolicy: 永远判定为不相等
以上无论哪种 Policy 在 merge 的默认实现上都一样,即不合并,状态提交失败。因为 merge 本身属于业务范畴,很难给出默认实现,需要开发者根据需要自己实现。
注意: 当我们更新 StateObject 时,需要判断是否发生变化以决定是否应该重组,这个判断也是使用 SnapshotMutationPolicy#equivalent 完成的。
如何支持 Compose 重组?
前面讲的那么多,基本都是围绕快照系统自身的工作原理在做介绍,甚至展示了快照在非 Compose 场景的使用。那么回归 Compose 的主题,快照是如何对 Compose UI 提供帮助的呢?快照对于 Compose UI 的最主要意义是支持了重组机制的运行,这得益于也正是得益于前文介绍过的两个特点: 读写感知 & 读写隔离 。
读写感知: 标记 RecomposeScope
我们知道 Compose 通过状态变化驱动重组进而完成 UI 的刷新,而且 Compose 的重组是 "智能的",遵循 范围最小化原则 。每个返回 Unit 的 @Composable 函数 (或 lambda) 都是一个 RecomposeScope ,Scope 会追踪内部访问的状态,当状态发生变化时该 Scope 会参与重组,如果状态无变化则会跳过重组。这整个过程正是依靠快照读写感知的机制实现的。
Compose 通过调用 Recomposer#composing 方法完成组合。
//androidx.compose.runtime.Recomposer
private inline fun <T> composing(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?,
block: () -> T
): T {
//创建快照
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(composition), writeObserverOf(composition, modifiedValues)
)
try {
// 进入快照
return snapshot.enter(block)
} finally {
applyAndCheck(snapshot)
}
}
可以看到,组合开始时先创建了一个可变快照,并调用 readObserverOf 和 writeObserverOf 创建状态读写回调传入快照。接着调用 enter 进入快照执行组合阶段的 Composable 函数,所以 Composalbe 在快照上的状态读写都会被监听到。
Composable 中读取状态时触发回调,最终调用到 recordReadOf ,将修改的 StateObject 连同 currentRecomposeScope 一并注册到 observations , observations 记录了哪些 Scope 访问了哪些 State。
override fun recordReadOf(value: Any) {
if (!areChildrenComposing) {
composer.currentRecomposeScope?.let {
it.used = true
observations.add(value, it)
...
}
}
}
当 Composable 对状态进行写入时调用 recordWriteOf 方法,从 observations 中找到关联的 Scope 标记为 invalid。
override fun recordWriteOf(value: Any) = synchronized(lock) {
invalidateScopeOfLocked(value)
derivedStates.forEachScopeOf(value) {
invalidateScopeOfLocked(it)
}
}
private fun invalidateScopeOfLocked(value: Any) {
observations.forEachScopeOf(value) { scope ->
if (scope.invalidateForResult(value) == InvalidationResult.IMMINENT) {
observationsProcessed.add(value, scope)
}
}
}
在下次帧信号到达时,invalid 的 scope 会在重组中执行,基于最新状态完成组合,同时重复上述过程,设置监听感知状态的下一次变化。
全局快照上的状态修改发生在组合阶段以外,但同样可以确定 RecomposeScope ,这是通过前面讲 registerApplyObserver 实现的。当全局快照中发生状态写操作时, GlobalSnapshotManager 会发送 SendApplyNotification 。
//androidx.compose.runtime.Recomposer#recompositionRunner
val unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ ->
synchronized(stateLock) {
if (_state.value >= State.Idle) {
snapshotInvalidations += changed
deriveStateLocked()
} else null
}?.resume(Unit)
}
如上, Recomposer 在 ApplyObserver 中获得变化的状态 changed ,然后调用 deriveStateLocked() 方法,最终也会执行 invalidateForResult 找到 changed 关联的 Scope 并标记为 invalid。
读写隔离: 支持重组并行化
官方文档告诉我们重组是并行的:
Compose can optimize recomposition by running composable functions in parallel. This lets Compose take advantage of multiple cores.
但截至目前重组仍然跑在单线程上,并行化还在开发中,但是依托快照系统并行化重组随时可能开启,所以我们现在就需要带着并行的意识开发自己的代码,避免届时出现 Bug。重组的并行化得益于快照的隔离机制,重组在执行过程中,不会受到其它线程对状态修改的影响,杜绝并发异常的发生。
结合下面的时序图,我们梳理一下 Compose 重组的整个过程,看看快照在其中是如何发挥作用的。假定场景是在 onClick 中修改了某个状态,且并行化已启动。如前文所述, onClick 的状态修改发生在全局快照。
注意: 图中的箭头并非源码中真实的方法调用,只表示一个依赖关系。
-
全局快照的状态变化会通过 sendApplyNotifications 通知出来;
-
Recomposer 接收到变化的状态,在下一帧到来之前将需要重组的 Scope 标记为 invalid;
-
当帧信号达到时,Recomposer 查找 invalid 的 Scope,获取空闲子线程并创建快照,在快照上执行 Scope 代码;
-
Scope 代码执行中如果读取了某状态,则作为状态的观察者记录到 observations;
-
Scope 内部如果对某状态进行了修改,则从 observations 查找观察者状态,标记为 invalid;
-
Scope 执行结束后,如果期间状态有修改,则通过快照提交,将状态变化同步给全局;
-
全局状态变化通过 ApplyObserver 回调 Recomposer,然后重复过程 2。
回顾&总结
以上就是快照的基本工作原理以及其支持重组的整个过程。最后让我们回顾一下本文开头的几个问题,巩固所学的内容:
-
快照能做什么?
Compose 快照是一个可以感知状态读写的 MVCC 系统,它主要功能是隔离和感知状态的变化。
-
快照与状态的关系?
快照隔离和感知的对象是状态,状态通过 snapshotId 与快照建立关联,实现访问隔离。
-
快照与线程的关系?
快照可以在单线程下运行,但是它更适合在并发环境下使用,快照帮助多线程任务实现线程安全。
-
快照与重组的关系?
Compose 的重组借助快照实现了并发执行,同时通过快照的读写感知确定参与下次重组的范围。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向 中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行 谷歌开发技术专家 (GDE) 的推荐。
点击屏末 | 阅读原文 | 即刻报名参与 " 开发者说 · DTalk"
- 共码未来 | 持续赋能开发者和初创生态
- 要近万元买iPhone 14 Pro才能玩灵动岛?Android 开发者:别急,我给你自制了一个 App
- 揭秘 Jetpack Compose 快照系统 | 开发者说·DTalk
- 轻松学习,考取证书 | 商品详情系列内容第十讲
- 是时候让所有人能一起聊个痛快了!
- 在 Jetpack Compose 中安全地使用数据流
- 共码未来 | 助力打造现代、高效、流畅的开发体验
- 共码未来丨2022 Google 谷歌开发者大会主旨演讲亮点回顾
- 京东金融客户端用户触达方式的探索与实践
- 微信安卓测试版 8.0.28 开发者更新内容公布:统一文件相关接口错误码,升级地图 SDK 等
- 让您的游戏在 PC 闪耀 | Google Play Games Beta 版现已开放更多市场
- 打造卓越的 Android 游戏体验
- Android 老手翻车了,竟拿不到 Application Context?| 开发者说·DTalk
- 2022 OPPO 开发者大会拉开帷幕,这些重点你不容错过!
- Firebase Crashlytics 近期更新 | 在 Android Studio 中更好地调试应用
- 谷歌为 Pixel 6 系列推出 Android 13 降级 12 方案:绕过防回滚机制,只面向开发者
- 提升效率,减少错误 | Twitter 采用 Jetpack Compose 进行功能开发
- Android Jetpack: 利用 Palette 进行图片取色 | 开发者说·DTalk
- Android 官方现代 App 架构指南 | 开发者说·DTalk
- Smule 借助 Oboe 音频库提升用户体验,助力音乐创作 | Android 开发者故事