一文看懂 Jetpack Compose 快照系统

网友投稿 799 2022-09-27

一文看懂 Jetpack Compose 快照系统

一文看懂 Jetpack Compose 快照系统

1. 引言

Compose 通过名为“快照(Snapshot)”的系统支撑状态管理与重组机制的运行。快照作为一个底层设施,在我们的日常开发中很少直接接触,本文就为大家揭开快照的神秘面纱。我们在开头先抛出几个问题,希望在文章结束时大家能够找到答案,对快照也就算有了初步了解了。

快照能做什么?快照与状态的关系?快照与线程的关系?快照与重组的关系?

注意:本文出现的源码基于版本 1.2.0-alpha06。本文重在帮助大家建立认知,对源码的介绍只是点到为止,请放松阅读。

我们知道 Compose 库从上到下分为多层:Material > UI > Runtime > Compiler 。快照系统位于 Runtime 层 ​​androidx/compose/runtime/snapshots​​。 它自成体系,可以脱离 Compose UI 甚至 Compiler 单独使用,只依赖 Runtime 即可使用快照功能,本文出现的示例代码均可以不依赖 UI 运行。

implementation "androidx.compose.runtime:runtime:$compose_version"

2. 快照的基本操作

快照的创建

先看下面的例子:

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.takeSnapshot()​​​ 创建了一个快照,通过调用其 ​​enter()​​ 进入此快照。在快照上只能看到快照被创建时刻的最新状态,看不到此后的变化。

将快照类比成 Git 系统,程序默认处于 ​​GlobalSnapshot​​​ 全局快照中,这相当于 Git 的 Main 分支。从全局快照上创建并进入子快照,就如同在 Main 上创建并切换分支,分支代码保持分支创建时的状态,看不到主线或其他分支的修改。当然 Git 的隔离对象是代码,而快照的隔离对象是“状态”,也就是 ​​mutableStateOf​​​ 创建的一个 ​​StateObject​​ 实例。

使用下面这些方法都可以创建 StateObject 对象,它们都可以被快照隔离:mutableStateOf/MutableStatemutableStateListOf/SnapshotStateListmutableStateMapOf/SnapshotStateMapderivedStateOfrememberUpdatedStatecollect*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 永远是从当前快照提交到“父快照”。快照上允许嵌套创建快照,因此快照存在父子关系。

3. 访问隔离的实现原理

前面介绍了快照的基本功能是对状态的访问隔离。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 返回符合当前快照读取条件的 StateRecordprivate fun 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.ktopen class MutableSnapshot internal constructor( id: Int, // 快照id invalid: SnapshotIdSet, //快照黑名单 override val readObserver: ((Any) -> Unit)?, // 读回调,后文介绍 override val writeObserver: ((Any) -> Unit)? // 写回调,后文介绍

​​MutableSnapshot​​​ 的定义如上,其中 ​​invalid​​ 成员代表一个快照黑名单。处于黑名单中的 id,即使比当前快照 id 小,也视为不可见内容。我们前面介绍过快照的提交,在子快照未提交之前,即使它的 id 小于全局快照也不应该被全局看见,因此在正式提交前之前会被加入全局快照的这个黑名单。

创建/提交快照时的 id 变化如上图所示:

我们在 GlobalSnapshot 中创建子快照,id 赋值为 2为了让子快照中访问不到父快照后续的状态变化,子快照创建后 GlobalSnapshot 的 id 升级至 3为了让 GlobalSnapshot 看不到子快照的状态变化,将 2 加入 invalid子快照提交后,GlobalSnapshot 的 invalid 中移除 2,子快照状态全局可见。

上面过程中出现了 id 升级的概念,可见快照提交的本质就是通过升级父快照 id 让子快照状态全局可见。这与 git merge 之后移动分支的 head 位置也有着异曲同工之处。

4. 状态读写感知

快照系统除了对状态的读写进行隔离,还可以对状态的读写进行感知,前面 ​​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

上面代码中,我们在创建快照时传入读写回调,快照中读写状态时依次触发回调,因此上面代码的日志输出如下:

writeObserver: 2readObserver: 22

快照对状态读写的感知是 Compose 状态更新后自动触发重组的基础,我们在后文会详细介绍。

5. 全局快照

我们知道 ​​GlobalSnapshot​​ 是程序所处的默认快照,它也是所有快照的 Root。由于不再存在父快照,所以全局快照上对状态的修改不需要追加提交操作(apply),作为 Root 它更重要的职责是“被提交”。全局快照上的状态变化通常是通过子快照的提交发生的,就如同 Main 上的代码变动大多来自各分支的 MR 。

监听全局状态变化

子快照上的状态修改最终会通过 ​​apply​​​ 提交到父快照。​​registerApplyObserver​​ 可以监听子快照提交后的状态变化。Compose 组合阶段的代码都执行在子快照上,所以组合阶段的状态变化都可以通过 ApplyObserver 获取。

提示: Composae 渲染分有三个阶段:组合,布局,绘制,文中提到的组合就是其中第一个阶段

​​developer.android.google-/jetpack/com…​​

有些状态变化发生在组合阶段之外,比如 ​​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 = 2state.value = 3 // 向 ApplyObserver 通知最后一次变化// 通知变化

除了使用 ApplyObserver 监听全局变化,我们还可以监听全局快照上对单个状态的写操作,由于全局快照不使用 ​​takeSnapshot​​​ 创建,无法通过传入 ​​writeObserver​​​ 注册回调,全局快照的写回调通过使用 ​​Snapshot.registerGlobalWriteObserver​​ 注册:

val state = mutableStateOf(1)val observer = Snapshot.registerGlobalWriteObserver { writtenState -> // MutableState(value=2) 和 MutableState(value=3) 都会收到 println("$writtenState")}state.value = 2state.value = 3

每次状态修改都可以通过 ​​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 会触发执行,响应式逻辑更加简洁。

6. 并发与冲突解决

前面的例子都是跑在单线程中的,而作为一个 MVCC 系统,只有在并发场景中使用才更有意义。通常并发环境下对数据访问,为了保证线程安全需要添加各种读写锁,而快照系统通过访问隔离实现无锁操作,提高并发性能。此外快照的提交机制也保证了容错性,进一步套用数据库事务的说法就是保证了 ACID 中的原子性、隔离性和一致性。

多线程下的快照保存

当快照在多线程环境下使用时,当前快照信息保存在 ​​ThreadLocal​​​ 中的。Compose 在组合执行过程中,通过 ​​currentSnapshot()​​ 获取当前快照

//androidx.compose.runtime.SnapshotThreadLocal//如果不存在当前快照,则返回全局快照internal fun currentSnapshot(): Snapshot = threadSnapshot.get() ?: currentGlobalSnapshot.get()private val threadSnapshot = SnapshotThreadLocal()//使用 ThreadLocal 管理快照internal actual class SnapshotThreadLocal { private val map = AtomicReference(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 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

mergeRecords 解决冲突

并发环境必然要考虑冲突的发生。当我们在子线程快照中修改了某 ​​StateObject​​​,同时它在父快照中也发生了变化,那么当提交子快照时就会遇到冲突,此时就要像 git merge 冲突一样,要么放弃提交,要么对冲突进行解决。记得前面 ​​StateObject​​​ 的类图中曾经出现了一个 ​​mergeRecords​​​ 方法,​​StateObject​​ 就是用它来处理状态冲突的:

//androidx/compose/runtime/SnapshotState.ktoverride fun mergeRecords( previous: StateRecord, // 子快照创建之前的全局状态 current: StateRecord, // 全局快照最新状态 applied: StateRecord // 待提交的子快照状态: StateRecord? { val previousRecord = previous as StateStateRecord val currentRecord = current as StateStateRecord val appliedRecord = applied as StateStateRecord //父快照与待提交子快照的状态比较 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).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​​ 完成的。

7. 如何支持 Compose 重组?

前面讲的那么多,基本都是围绕快照系统自身的工作原理在做介绍,甚至展示了快照在非 Compose 场景的使用。那么回归 Compose 的主题,快照是如何对 Compose UI 提供帮助的呢?快照对于 Compose UI 的最主要意义是支持了重组机制的运行,这得益于也正是得益于前文介绍过的两个特点:读写感知 & 读写隔离。

读写感知:标记 RecomposeScope

我们知道 Compose 通过状态变化驱动重组进而完成 UI 的刷新,而且 Compose 的重组是“智能的”,遵循范围最小化原则。每个返回 ​​Unit​​​ 的 ​​@Composable​​​ 函数(或 lambda)都是一个 ​​RecomposeScope​​,Scope 会追踪内部访问的状态,当状态发生变化时该 Scope 会参与重组,如果状态无变化则会跳过重组。这整个过程正是依靠快照读写感知的机制实现的。

Compose 通过调用 ​​Recomposer#composing​​ 方法完成组合。

//androidx.compose.runtime.Recomposerprivate inline fun composing( composition: ControlledComposition, modifiedValues: IdentityArraySet?, block: () -> T: T { //创建快照 val snapshot = Snapshot.takeMutableSnapshot( readObserverOf(composition), writeObserverOf(composition, modifiedValues) ) try { // 进入快照 return snapshot.enter(block) } finally

可以看到,组合开始时先创建了一个可变快照,并调用 ​​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

当 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

在下次帧信号到达时,invalid 的 scope 会在重组中执行,基于最新状态完成组合,同时重复上述过程,设置监听感知状态的下一次变化。

全局快照上的状态修改发生在组合阶段以外,但同样可以确定 ​​RecomposeScope​​​,这是通过前面讲 ​​registerApplyObserver​​​ 实现的。当全局快照中发生状态写操作时,​​GlobalSnapshotManager​​​ 会发送 ​​SendApplyNotification​​

//androidx.compose.runtime.Recomposer#recompositionRunnerval 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 代码执行中如果读取了某状态,则作为状态的观察者记录到 observationsScope 内部如果对某状态进行了修改,则从 observations 查找观察者状态,标记为 invalid。Scope 执行结束后,如果期间状态有修改,则通过快照提交,将状态变化同步给全局。全局状态变化通过 ApplyObserver 回调 Recomposer,然后重复过程 2。

8. 回顾&总结

以上就是快照的基本工作原理以及其支持重组的整个过程。最后让我们回顾一下本文开头的几个问题,巩固所学的内容:

快照能做什么?

Compose 快照是一个可以感知状态读写的 MVCC 系统,它主要功能是隔离和感知状态的变化。

快照与状态的关系?

快照隔离和感知的对象是状态,状态通过 snapshotId 与快照建立关联,实现访问隔离。

快照与线程的关系?

快照可以在单线程下运行,但是它更适合在并发环境下使用,快照帮助多线程任务实现线程安全

快照与重组的关系?

Compose 的重组借助快照实现了并发执行,同时通过快照的读写感知确定参与下次重组的范围。

参考

​​dev.to/zachklipp/i…​​​​juejin-/post/697269…​​​​juejin-/post/697497…​​​​juejin-/post/696418…​​​​blog.chrnie.com/2021/10/10/…​​

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:【Linux中高级运维: 第26天——计算机网络】第4章:网络传输协议&linux网络IP地址&子网划分&DNS解析&子网掩码
下一篇:【K8S运维知识汇总】第4天7: dashboard小彩蛋–heapster
相关文章

 发表评论

暂时没有评论,来抢沙发吧~