Android 协程使用到原理详解
协程是什么
协程是我们在 Android
上进行异步编程的推荐解决方案之一,通过挂起和恢复让状态机状态流转实现把层层嵌套的回调代码变成像同步代码那样直观、简洁,协程的出现很好的避免了回调地狱的出现。
所谓挂起,是指挂起协程,而非挂起线程,并且这个操作对线程是非阻塞式的。当线程执行到协程的 suspend
函数的时候,对于线程而言,线程会被回收或者再利用执行其他工作,就像主线程其实是会继续 UI
刷新工作。而对于协程本身,会根据 withContext
传入的 Dispatchers
所指定的线程去执行任务。
关于恢复,当挂起函数执行完毕后,会自动根据 CoroutineContext
切回原来的线程往下执行。
协程怎样集成
dependencies {// -----1----// Kotlinimplementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.30"// -----2----// 协程核心库implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1"// 协程 Android 支持库implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1"// -----3----// lifecycle 对于协程的扩展封装implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1"implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1"
}
其中 part 3
主要是对写 view
层的一些库,lifecycle
对于协程的扩展封装在业务开发上非常重要。
下面,介绍一些使用上的一些基本概念
CoroutineScope
CoroutineScope 是指协程作用域,它其实是一个接口,作用是使得协程运行在其范围内
public interface CoroutineScope {public val coroutineContext: CoroutineContext
}public fun CoroutineScope(context: CoroutineContext): CoroutineScope =ContextScope(if (context[Job] != null) context else context + Job())
执行协程代码块的还有
runBlocking
,其只有当内部相同作用域的所有协程都运行结束后,声明在runBlocking
之后的代码才能执行,即runBlocking
会阻塞其所在线程,但其内部运行的协程又是非阻塞的,由于对线程有阻塞行为,日常开发中一般不会用到,多用于做单元测试,在此不展开说了。
下面看看官方自带的几种 CoroutineScope
1. GlobalScope
public object GlobalScope : CoroutineScope {override val coroutineContext: CoroutineContextget() = EmptyCoroutineContext
}public object EmptyCoroutineContext : CoroutineContext, Serializable {...
}
从源码可以看出,GlobalScope
是一个单例,该实例所用的 CoroutineContext
是一个 EmptyCoroutineContext
实例,且 EmptyCoroutineContext
也是一个单例,GlobalScope
对象没有和 view
的生命周期组件相关联,是全局协程作用域,需要自己管理 GlobalScope
所创建的 Coroutine
,所以一般而言我们不直接使用 GlobalScope
来创建 Coroutine
。
2. Fragment/Activity 的 lifecycleScope
// LifecycleOwner.kt
public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScopeget() = lifecycle.coroutineScope// Lifecycle.kt
public val Lifecycle.coroutineScope: LifecycleCoroutineScopeget() {while (true) {...val newScope = LifecycleCoroutineScopeImpl(this,SupervisorJob() + Dispatchers.Main.immediate)if (...) {newScope.register()return newScope}}}// Lifecycle.kt
internal class LifecycleCoroutineScopeImpl(override val lifecycle: Lifecycle,override val coroutineContext: CoroutineContext
) : LifecycleCoroutineScope(), LifecycleEventObserver {...fun register() {launch(Dispatchers.Main.immediate) {if (lifecycle.currentState >= Lifecycle.State.INITIALIZED) {lifecycle.addObserver(this@LifecycleCoroutineScopeImpl)} else {coroutineContext.cancel()}}}override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {if (lifecycle.currentState <= Lifecycle.State.DESTROYED) {lifecycle.removeObserver(this)coroutineContext.cancel()}}
}
从上面的 androidx.lifecycle.LifecycleCoroutineScopeImpl#register
和 androidx.lifecycle.LifecycleCoroutineScopeImpl#onStateChanged
我们可以看出 lifecycleScope
使用的生命周期如下
// 开始
override fun onCreate(…)// 结束
override fun onDestroy()
3. Fragment 的 viewLifecycleScope
Fragment
其实并没有 viewLifecycleScope
的拓展属性,这里的 viewLifecycleScope
是指在 Fragment
对 View
的 LifecycleScope
,因为 Fragment
可以没有 View
我们可以给 Fragment
写一个拓展属性
val Fragment.viewLifecycleScope get() = viewLifecycleOwner.lifecycleScope
这里我们可以看看 viewLifecycleOwner
是什么
// Fragment.javavoid performCreateView(...) {mViewLifecycleOwner = new FragmentViewLifecycleOwner(this, getViewModelStore());mView = onCreateView(inflater, container, savedInstanceState);if (mView != null) {// Initialize the view lifecyclemViewLifecycleOwner.initialize();} else {if (mViewLifecycleOwner.isInitialized()) {throw new IllegalStateException("Called getViewLifecycleOwner() but "+ "onCreateView() returned null");}mViewLifecycleOwner = null;}}public LifecycleOwner getViewLifecycleOwner() {if (mViewLifecycleOwner == null) {throw new IllegalStateException("Can't access the Fragment View's LifecycleOwner when "+ "getView() is null i.e., before onCreateView() or after onDestroyView()");}return mViewLifecycleOwner;}
而 performCreateView
的调用是在创建 View
的时候,可以看出,如果我们没有复写 onCreateView
,那么 mView
就会为 null
,从而导致 mViewLifecycleOwner
为 null
而复写了就会,所以我们不应该在没有 View
的 Fragment
中使用 viewLifecycleScope
,否则在 getViewLifecycleOwner
的时候就会抛异常。所以可以看看在复写 View
时候 viewLifecycleScope
使用的生命周期为
// 开始
override fun onCreateView(…): View?// 结束
override fun onDestroyView()
4. ViewModel 的 viewModelScope
// ViewModel.kt
public val ViewModel.viewModelScope: CoroutineScopeget() {val scope: CoroutineScope? = this.getTag(JOB_KEY)if (scope != null) {return scope}return setTagIfAbsent(JOB_KEY,CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))}internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {override val coroutineContext: CoroutineContext = contextoverride fun close() {coroutineContext.cancel()}
}//----------------------------------------------------// ViewModel.java<T> T setTagIfAbsent(String key, T newValue) {...synchronized (mBagOfTags) {previous = (T) mBagOfTags.get(key);if (previous == null) {mBagOfTags.put(key, newValue);}}...return result;}final void clear() {...if (mBagOfTags != null) {synchronized (mBagOfTags) {for (Object value : mBagOfTags.values()) {// see comment for the similar call in setTagIfAbsentcloseWithRuntimeException(value);}}}onCleared();}private static void closeWithRuntimeException(Object obj) {if (obj instanceof Closeable) {try {((Closeable) obj).close();} catch (IOException e) {throw new RuntimeException(e);}}}
从上面源码可以看出,viewModelScope
是 lazy
的,调用的时候进行初始化,而 ViewModel#clear
方法是在 ViewModel
销毁的时候调用的,从而最终走到 CloseableCoroutineScope#close
,使得协程被 cancel
,所以可以得出,viewModelScope
的使用周期在 ViewModel
的生命周期内
Coroutine Builders
Coroutine Builders
是指 kotlinx.coroutines.Builders.kt
,其内部有 CoroutineScope
的一些拓展方法等,下面介绍一下 Builders
类中两个重要的拓展方法的作用
1. launch
public fun CoroutineScope.launch(context: CoroutineContext = EmptyCoroutineContext,start: CoroutineStart = CoroutineStart.DEFAULT,block: suspend CoroutineScope.() -> Unit
): Job {val newContext = newCoroutineContext(context)val coroutine = if (start.isLazy)LazyStandaloneCoroutine(newContext, block) elseStandaloneCoroutine(newContext, active = true)coroutine.start(start, coroutine, block)return coroutine
}
context
:协程的上下文
start
:协程的启动方式,默认值为 CoroutineStart.DEFAULT
,即协程会在声明的同时就立即进入等待调度的状态,即可以立即执行的状态,CoroutineStart.LAZY
能实现延迟启动
block
:协程的执行体
返回值为 Job
,指当前协程任务的句柄
我们在 view
层进行执行协程时候,一般会这样用
viewLifecycleScope.launchWhenStarted {...
}
这其实就是个 launch,我们看看源码
/// Lifecycle.ktpublic fun launchWhenStarted(block: suspend CoroutineScope.() -> Unit): Job = launch {lifecycle.whenStarted(block)}
2. async
public fun <T> CoroutineScope.async(context: CoroutineContext = EmptyCoroutineContext,start: CoroutineStart = CoroutineStart.DEFAULT,block: suspend CoroutineScope.() -> T
): Deferred<T> {val newContext = newCoroutineContext(context)val coroutine = if (start.isLazy)LazyDeferredCoroutine(newContext, block) elseDeferredCoroutine<T>(newContext, active = true)coroutine.start(start, coroutine, block)return coroutine
}
async
返回值 Deferred
继承于 Job
接口,其主要是在 Job
的基础上扩展了 await
方法,是返回协程的执行结果,而 launch
返回的 Job
是不携带结果的
public interface Deferred<out T> : Job {public suspend fun await(): T public val onAwait: SelectClause1<T>public fun getCompleted(): Tpublic fun getCompletionExceptionOrNull(): Throwable?
}
CoroutineContext
协程的上下文,使用以下元素集定义协程的行为
Job
:控制协程的生命周期CoroutineDispatcher
:将任务分发给适当的线程CoroutineName
:协程的名称,可用于辅助CoroutineExceptionHandler
:处理未捕获的异常
1. Job
在源码注释中,Job 有这样的描述
描述 1
State | [isActive] | [isCompleted] | [isCancelled] |
---|---|---|---|
New (optional initial state) |
false
|
false
|
false
|
Active (default initial state) |
true
|
false
|
false
|
Completing (transient state) |
true
|
false
|
false
|
Cancelling (transient state) |
false
|
false
|
true
|
Cancelled (final state) |
false
|
true
|
true
|
Completed (final state) |
false
|
true
|
false
|
描述的是一个任务的状态:新创建 (New)、活跃 (Active)、完成中 (Completing)、已完成 (Completed)、取消中 (Cancelling) 和已取消 (Cancelled)。但我们无法直接方位这些状态,可以通过方位 Job 的几个属性:isActive
、isCancelled
和 isCompleted
描述 2
wait children
+-----+ start +--------+ complete +-------------+ finish +-----------+
| New | -----> | Active | ---------> | Completing | -------> | Completed |
+-----+ +--------+ +-------------+ +-----------+| cancel / fail || +----------------+| |V V+------------+ finish +-----------+| Cancelling | --------------------------------> | Cancelled |+------------+ +-----------+
描述的是状态的流转,举个状态流转例子:当任务创建(New)后,协程处于活跃状态(Active),协程运行出错或者调用 job.cancel()(cancel / fail)
都会将当前任务置为取消中 (Cancelling) 状态 (isActive = false, isCancelled = true)
,当所有的子协程都完成后,协程会进入已取消 (Cancelled) 状态,此时 isCompleted = true
。
我们再来认识一下 Job 的几个常用的方法
/// Job.kt/*** 启动 Coroutine, 当前 Coroutine 还没有执行调用该函数返回 true* 如果当前 Coroutine 已经执行或者已经执行完毕,则调用该函数返回 false*/public fun start(): Boolean/*** 取消当前任务,可以指定原因异常信息*/public fun cancel(cause: CancellationException? = null)/*** 这个 suspend 函数会暂停当前所处的 Coroutine 直到该 Coroutine 执行完成。* 所以 join 函数一般用来在另外一个 Coroutine 中等待 job 执行完成后继续执行。* 当 Job 执行完成后,job.join 函数恢复,这个时候 job 这个任务已经处于完成状态* 调用 job.join 的 Coroutine 还继续处于 activie 状态*/public suspend fun join()/*** 通过这个函数可以给 Job 设置一个完成通知*/public fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle
1.1 Deferred
Deferred
继承自 Job
,是我们使用 async
创建协程的返回值,我们看看 Deferred
基于 Job
拓展的几个方法
public interface Deferred<out T> : Job {/*** 用来等待这个 Coroutine 执行完毕并返回结果*/public suspend fun await(): T/*** 用来获取Coroutine执行的结果* 如果Coroutine还没有执行完成则会抛出 IllegalStateException* 如果任务被取消了也会抛出对应的异常* 所以在执行这个函数前可以通过 isCompleted 来判断一下当前任务是否执行完毕了*/@ExperimentalCoroutinesApipublic fun getCompleted(): T/*** 获取已完成状态的 Coroutine 异常信息* 如果任务正常执行完成了,则不存在异常信息,返回 null*/@ExperimentalCoroutinesApipublic fun getCompletionExceptionOrNull(): Throwable?
}
1.2 SupervisorJob
public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)
SupervisorJob
是一个顶层函数,里面的子 Job
不相互影响,一个子 Job
失败了,不影响其他子 Job
,可以看到有个 parent
入参,如果指定了这个参数,则所返回的 Job
就是参数 parent
的子 Job
。
2. CoroutineDispatcher
定义任务的线程
Dispatchers.Default
默认的调度器,适合处理后台计算,是一个CPU密集型任务调度器,使用一个共享的后台线程池来运行里面的任务,任务执行在子线程Dispatchers.IO
和 Default 共用一个共享的线程池来执行里面的任务,区别在最大并发数不同,用途在阻塞 IO 操作Dispatchers.Unconfined
未定义线程池,所以执行的时候默认在启动线程,也就是在哪个线程启动就在哪个线程执行Dispatchers.Main
主线程
协程项目使用场景
1. 回调变协程
以执行多个动画为例,场景是点击某个按钮要切换到其他图标。
首先将 suspendCancellableCoroutine
封装一下,这个方法的作用是将回调变协程,但是我们需要控制其释放
class ContinuationHolder<T>(continuation: CancellableContinuation<T>) {var continuation: CancellableContinuation<T>?private setinit {this.continuation = continuationcontinuation.invokeOnCancellation {this.continuation = null}}
}/*** 避免continuation泄漏*/
suspend inline fun <T> suspendCancellableCoroutineRefSafe(crossinline block: (ContinuationHolder<T>) -> Unit
): T = suspendCancellableCoroutine {val continuationHolder = ContinuationHolder(it)block(continuationHolder)
}
接下来就可以使用 suspendCancellableCoroutineRefSafe
,看看怎样来将一个回调处理改装成协程
private suspend fun viewScaleAnimator(view: View, duration: Long, vararg values: Float): Boolean {return suspendCancellableCoroutineRefSafe { holder ->val animatorSet = AnimatorSet()animatorSet.play(ObjectAnimator.ofFloat(view, "scaleX", *values)).with(ObjectAnimator.ofFloat(view, "scaleY", *values))animatorSet.duration = durationanimatorSet.addListener(object : Animator.AnimatorListener {override fun onAnimationStart(animation: Animator?) {}override fun onAnimationEnd(animation: Animator?) {holder.continuation?.resume(true)}override fun onAnimationCancel(animation: Animator?) {holder.continuation?.resume(false)}override fun onAnimationRepeat(animation: Animator?) {}})animatorSet.start()}}
viewScaleAnimator
方法是将一个缩放动画变成协程的处理,返回动画执行的结果
这样,我们就可以顺序的执行多个动画了
val animator1End = viewScaleAnimator(imageView, 100, 1f, 0.75f)
if (animator1End) {imageView.setImageResource(nextImage)val animator2End = viewScaleAnimator(imageView, 100, 0.75f, 1f)if (animator2End) {onAllAnimationEnd.invoke()}
}
2. IO 异步处理
以下载了文件后需要解压为例
/*** 异步解压文件*/
suspend fun unZipFolderAsync(zipFileString: String, outPathString: String) = withContext(Dispatchers.IO) {unZipFolder(zipFileString, outPathString)
}
internal fun unZipFolder(zipFileString: String, outPathString: String) {// FileInputStream、ZipInputStream 等的一些操作...
}
5. 自定义 CoroutineScope
官方的 CoroutineScope
并不能满足所有场景,所以这时候我们可以自定义 CoroutineScope
。
class MyRepository {private var mScope: CoroutineScope? = null/*** 打开的时候调用*/fun initScope() {mScope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())}/*** 操作*/private fun handle() {mScope?.launch {...}}/*** 退出时候调用*/fun exit() {mScope?.cancel()mScope = null...}
}
协程项目踩坑案例
1. 在 Fragment 中,lifecycleScope 和 viewLifecycleScope 分不清用哪个
viewLifecycleScope
强调的是 View
生命周期内的协程执行范围
- 在无
UI
的逻辑fragment
中使用viewLifecycleScope
会抛异常 - 在不考虑
View
回收,如横竖屏切换,需要keep
住一些状态可以使用lifecycleScope
- 需要跟
Fragment
生命周期的用lifecycleScope
- 跟
View
创建回收时机有关系的用viewLifecycleScope
- 大多数情况下使用
viewLifecycleScope
2. CoroutineScope 和 Job 的 cancel 问题
CoroutineScope cancel
了 Job
会跟着 cancel
,Job cancel
了 CoroutineScope
未必需要 cancel
,CoroutineScope cancel
后 Job
就不活跃了。
Job
的 cancel
场景其中要注意的有:例如我们 collect
一个返回值为 StateFlow
的方法,其实该方法在执行了 trymit
处理完状态后,该协程并未执行完毕,而是始终在等待中,所以我们可以在 collect
内部检测到任务执行完了,就主动将当前 Job cancel
掉,可以避免浪费内存开销。结合上面提到的回调变协程,例子如下
private fun ...(...) {...mAnimatorJob = mAnimatorScope?.launch {val animator1End = viewScaleAnimator(imageView, 100, 1f, 0.75f)if (animator1End) {imageView.setImageResource(nextImage)val animator2End = viewScaleAnimator(imageView, 100, 0.75f, 1f)if (animator2End) {onAllAnimationEnd.invoke()}// 注意!这里做了 Job 的 cancelmAnimatorJob?.cancel()mAnimatorJob = null}}}override fun onDestroy() {...// fragment 销毁,未处理完任务也应该销毁mAnimatorJob?.cancel()mAnimatorJob = nullmAnimatorScope?.cancel()mAnimatorScope = null}
挂起和切线程的原理
挂起原理
前面介绍挂起的时候提到挂起操作是非阻塞式的,那么我们来看看协程是怎样做到的。
我们先看看一个小例子
class TestClass {suspend fun test1() {test2()}suspend fun test2() {}
}
我们看看这个类的字节码
public final class TestClass {@Nullablepublic final Object test1(@NotNull Continuation $completion) {Object var10000 = this.test2($completion);return var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED() ? var10000 : Unit.INSTANCE;}@Nullablepublic final Object test2(@NotNull Continuation $completion) {return Unit.INSTANCE;}
}
可以看到,挂起函数主要用到了 Continuation
public interface Continuation<in T> {public val context: CoroutineContextpublic fun resumeWith(result: Result<T>)
}
这么看,实际挂起函数用到了类似于 callback
的逻辑了,resumeWith
相当于 callback
中一个回调函数,其作用是执行接下来要执行的代码,可以理解成在 resumeWith
回调里面继续执行下一步。
而我们在协程外是无法调用的,这里可以看出因为需要传递一个 NotNull
的 Continuation
。
切线程原理
接下来讲下切线程,在项目开发中,遇到切线程的比较多的做法 withContext
,下面讲述其中原理
public suspend fun <T> withContext(context: CoroutineContext,block: suspend CoroutineScope.() -> T
): T { return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->// 创建新的contextval oldContext = uCont.contextval newContext = oldContext + context....// 使用新的Dispatcher,覆盖外层val coroutine = DispatchedCoroutine(newContext, uCont)coroutine.initParentJob()//DispatchedCoroutine作为了complete传入block.startCoroutineCancellable(coroutine, coroutine)coroutine.getResult()}
}private class DispatchedCoroutine<in T>(context: CoroutineContext,uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {// 在complete时会会回调override fun afterCompletion(state: Any?) {afterResume(state)}override fun afterResume(state: Any?) {// uCont就是父协程,context 仍是老版 context, 因此可以切换回原来的线程上uCont.intercepted().resumeCancellableWith(recoverResult(state, uCont))}
}
传入的新的 CoroutineContext
会覆盖原来所在的 CoroutineContextDispatchedCoroutine
作为 complete: Continuation
传入协程体的创建函数中,因此协程体执行完成后会回调到 afterCompletion
中,DispatchedCoroutine
中传入的 uCont
是父协程,它的拦截器仍是外层的拦截器,因此会切换回原来的线程中
后话
思考:协程设计思想
- 我认为,协程可以使得一个复杂的操作变得可追踪结果,如果这个复杂操作既涉及到异步操作场景,更为显著,将一个完整的操作变得可追踪,业务逻辑上很清晰。
参考:
- https://developer.android.com/kotlin/coroutines?hl=zh-cn
- https://juejin.cn/post/6950616789390721037
Android 协程使用到原理详解相关推荐
- libco协程库上下文切换原理详解
缘起 libco 协程库在单个线程中实现了多个协程的创建和切换.按照我们通常的编程思路,单个线程中的程序执行流程通常是顺序的,调用函数同样也是 "调用--返回",每次都是从函数的入 ...
- android universal image loader 缓冲原理详解
1. 功能介绍 1.1 Android Universal Image Loader Android Universal Image Loader 是一个强大的.可高度定制的图片缓存,本文简称为UIL ...
- Android -- Annotation(注解)原理详解及常见框架应用
1,我们在上一篇讲到了EventBus源码及3.0版本的简单使用,知道了我们3.0版本是使用注解方式标记事件响应方法的,这里我们就有一个疑问了,为什么在一个方法加上类似于"@Subscrib ...
- 【android】插件化技术原理详解
作为移动端的黑科技,插件化技术一直受大厂的青睐.插件化技术有减少宿主Apk体积,可以独立更新,模块化开发等优点,让宿主APP极具扩展性.那么,现在就来聊聊其中的技术实现,国际惯例,先上效果图 这篇 ...
- 【胖虎的逆向之路】02——Android整体加壳原理详解实现
[胖虎的逆向之路](02)--Android整体加壳原理详解&实现 Android Apk的加壳原理流程及详解 文章目录 [胖虎的逆向之路](02)--Android整体加壳原理详解& ...
- 【Android架构师java原理详解】二;反射原理及动态代理模式
前言: 本篇为Android架构师java原理专题二:反射原理及动态代理模式 大公司面试都要求我们有扎实的Java语言基础.而很多Android开发朋友这一块并不是很熟练,甚至半路初级底子很薄,这给我 ...
- Android面试Hash原理详解二
Hash系列目录 Android面试Hash原理详解一 Android面试Hash原理详解二 Android面试Hash常见算法 Android面试Hash算法案例 Android面试Hash原理详解 ...
- Android涂鸦画板原理详解——从初级到高级(二)
前言 前面写了<Android涂鸦画板原理详解--从初级到高级(一)>,讲了涂鸦原理初级和中级的应用,现在讲解高级应用.如果没有看过前面一篇文章的同学,建议先去看看哈. 准备 高级涂鸦涉及 ...
- Android 多线程之IntentService 完全详解
转载请注明出处(万分感谢!): http://blog.csdn.net/javazejian/article/details/52426425 出自[zejian的博客] 关联文章: Android ...
最新文章
- 隐藏Nginx版本号的安全性与方法
- 使用postMan测试erp系统登录接口
- 96根电极每秒测量3万次,大脑植入物首次帮助瘫患者控制肌肉!
- json key 命名规范_jsonapi
- Effective Java读书笔记一:并发
- Android构建boot.img:root目录与ramdisk.img的生成
- python增量爬虫_python爬虫Scrapy框架之增量式爬虫
- BellmanFord
- 如何删除旧的和未使用的Docker映像
- github开源的流程-慕课网教程学习笔记
- 如何批量压缩图片体积大小kb?
- gmp计算机分类,GMP附录——计算机化系统汇总.pptx
- 使用pem文件进行ssh登录
- python3--输入厘米转为英寸英寸
- SQL语句进阶学习一(where、通配符、正则表达式、计算字段、数据处理函数、分组数据)
- C#,基于视频的目标识别算法(Moving Object Detection)的原理、挑战及其应用
- js用正则表达式完成邮箱验证
- 两个处理IP好用的Python库ipaddr和netaddr
- 信道容量、码率、带宽、频谱利用率
- 古埃及靠砍手、数“断掌“换取黄金,我先砍为敬