协程是什么

协程是我们在 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#registerandroidx.lifecycle.LifecycleCoroutineScopeImpl#onStateChanged 我们可以看出 lifecycleScope 使用的生命周期如下

// 开始
override fun onCreate(…)// 结束
override fun onDestroy()

3. Fragment 的 viewLifecycleScope

Fragment 其实并没有 viewLifecycleScope 的拓展属性,这里的 viewLifecycleScope 是指在 FragmentViewLifecycleScope,因为 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,从而导致 mViewLifecycleOwnernull 而复写了就会,所以我们不应该在没有 ViewFragment 中使用 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);}}}

从上面源码可以看出,viewModelScopelazy 的,调用的时候进行初始化,而 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 的几个属性:isActiveisCancelledisCompleted

描述 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 cancelJob 会跟着 cancelJob cancelCoroutineScope 未必需要 cancelCoroutineScope cancelJob 就不活跃了。
Jobcancel 场景其中要注意的有:例如我们 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 回调里面继续执行下一步。
而我们在协程外是无法调用的,这里可以看出因为需要传递一个 NotNullContinuation

切线程原理

接下来讲下切线程,在项目开发中,遇到切线程的比较多的做法 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 协程使用到原理详解相关推荐

  1. libco协程库上下文切换原理详解

    缘起 libco 协程库在单个线程中实现了多个协程的创建和切换.按照我们通常的编程思路,单个线程中的程序执行流程通常是顺序的,调用函数同样也是 "调用--返回",每次都是从函数的入 ...

  2. android universal image loader 缓冲原理详解

    1. 功能介绍 1.1 Android Universal Image Loader Android Universal Image Loader 是一个强大的.可高度定制的图片缓存,本文简称为UIL ...

  3. Android -- Annotation(注解)原理详解及常见框架应用

    1,我们在上一篇讲到了EventBus源码及3.0版本的简单使用,知道了我们3.0版本是使用注解方式标记事件响应方法的,这里我们就有一个疑问了,为什么在一个方法加上类似于"@Subscrib ...

  4. 【android】插件化技术原理详解

      作为移动端的黑科技,插件化技术一直受大厂的青睐.插件化技术有减少宿主Apk体积,可以独立更新,模块化开发等优点,让宿主APP极具扩展性.那么,现在就来聊聊其中的技术实现,国际惯例,先上效果图 这篇 ...

  5. 【胖虎的逆向之路】02——Android整体加壳原理详解实现

    [胖虎的逆向之路](02)--Android整体加壳原理详解&实现 Android Apk的加壳原理流程及详解 文章目录 [胖虎的逆向之路](02)--Android整体加壳原理详解& ...

  6. 【Android架构师java原理详解】二;反射原理及动态代理模式

    前言: 本篇为Android架构师java原理专题二:反射原理及动态代理模式 大公司面试都要求我们有扎实的Java语言基础.而很多Android开发朋友这一块并不是很熟练,甚至半路初级底子很薄,这给我 ...

  7. Android面试Hash原理详解二

    Hash系列目录 Android面试Hash原理详解一 Android面试Hash原理详解二 Android面试Hash常见算法 Android面试Hash算法案例 Android面试Hash原理详解 ...

  8. Android涂鸦画板原理详解——从初级到高级(二)

    前言 前面写了<Android涂鸦画板原理详解--从初级到高级(一)>,讲了涂鸦原理初级和中级的应用,现在讲解高级应用.如果没有看过前面一篇文章的同学,建议先去看看哈. 准备 高级涂鸦涉及 ...

  9. Android 多线程之IntentService 完全详解

    转载请注明出处(万分感谢!): http://blog.csdn.net/javazejian/article/details/52426425 出自[zejian的博客] 关联文章: Android ...

最新文章

  1. 隐藏Nginx版本号的安全性与方法
  2. 使用postMan测试erp系统登录接口
  3. 96根电极每秒测量3万次,大脑植入物首次帮助瘫患者控制肌肉!
  4. json key 命名规范_jsonapi
  5. Effective Java读书笔记一:并发
  6. Android构建boot.img:root目录与ramdisk.img的生成
  7. python增量爬虫_python爬虫Scrapy框架之增量式爬虫
  8. BellmanFord
  9. 如何删除旧的和未使用的Docker映像
  10. github开源的流程-慕课网教程学习笔记
  11. 如何批量压缩图片体积大小kb?
  12. gmp计算机分类,GMP附录——计算机化系统汇总.pptx
  13. 使用pem文件进行ssh登录
  14. python3--输入厘米转为英寸英寸
  15. SQL语句进阶学习一(where、通配符、正则表达式、计算字段、数据处理函数、分组数据)
  16. C#,基于视频的目标识别算法(Moving Object Detection)的原理、挑战及其应用
  17. js用正则表达式完成邮箱验证
  18. 两个处理IP好用的Python库ipaddr和netaddr
  19. 信道容量、码率、带宽、频谱利用率
  20. 古埃及靠砍手、数“断掌“换取黄金,我先砍为敬

热门文章

  1. Ubuntu下android刷机教程
  2. Android开源项目xUtils HttpUtils模块分析
  3. 简单直观 DataGrip使Redis可视化的第三方插件
  4. Android学习——xUtils框架
  5. java 实现秒杀基本步骤
  6. 关于copy树形结构
  7. sed awk之sed实战
  8. [Unity]SqlCilent.SqlException(0x80131904):对象名‘dbo. ‘无效
  9. OLED数字时钟---FPGA实现
  10. MYCCL复合特征码定位器及其使用教程