一文读懂kotlin协程常用知识点

while (true) {
Log.i(“zx”, “子协程2正在运行”)
}
}
}

子协程 1 异常了,就会导致整个 Job 失败,子协程 2 也不会继续运行。

SupervisorJob()

创建一个处于活动状态的 Job 对象。 该 Job 的子项之间彼此独立,互不影响,子项的失败或取消不会导致主 Job 失败,也不会影响其他子项。

CoroutineScope(Dispatchers.IO + SupervisorJob() + MyExceptionHandler()).launch {
launch {
while (true) {
index++
if (index > 3) {
throw Exception(“子协程1异常了”)
}
Log.i(“zx”, “子协程1正在运行”)
}
}

launch {
while (true) {
Log.i(“zx”, “子协程2正在运行”)
}
}
}

同样的代码,把 Job()换成 SupervisorJob()后,可以发现子协程 2 会一直运行,并不会因为子协程 1 异常而被取消。

我们常见的 MainScope、viewModelScope、lifecycleScope 都是用 SupervisorJob()创建的,所以这些作用域中的子协程异常不会导致根协程退出。 kotlin 提供了一个快捷函数创建一个使用 SupervisorJob 的协程,那就是 supervisorScope。例如:

CoroutineScope(Dispatchers.IO).launch {
supervisorScope {
//这里的子协程代码异常不会导致父协程退出。
}
}

等同于

CoroutineScope(Dispatchers.IO).launch {
launch(SupervisorJob()) {

}
}

Deferred

是 Job 的子接口,是一个带有返回结果的 Job。async 函数创建的协程会返回一个 Deferred,可以通过 Deferred 的await()获取实际的返回值。async 与 await 类似于其他语言(例如 JavaScript)中的 async 与 await,通常用来使两个协程并行执行。 例如如下代码

suspend fun testAsync1(): String = withContext(Dispatchers.Default)
{
delay(2000)
“123”
}

suspend fun testAsync2(): String = withContext(Dispatchers.Default)
{
delay(2000)
“456”
}

lifecycleScope.launch {
val time1 = Date()
val result1 = testAsync1()
val result2 = testAsync2()
Log.i(“zx”, “结果为 r e s u l t 1 + r e s u l t 2 " ) L o g . i ( " z x " , " 耗时 {result1 + result2}") Log.i("zx", "耗时 result1+result2")Log.i("zx","耗时{Date().time - time1.time}”)
}

会输出:

结果为123456
耗时5034

如果改为使用 async,让两个协程并行。代码如下:

lifecycleScope.launch {
val time1 = Date()
val result1 = async { testAsync1() }
val result2 = async { testAsync2() }
Log.i(“zx”, “结果为 r e s u l t 1. a w a i t ( ) + r e s u l t 2. a w a i t ( ) " ) L o g . i ( " z x " , " 耗时 {result1.await() + result2.await()}") Log.i("zx", "耗时 result1.await()+result2.await()")Log.i("zx","耗时{Date().time - time1.time}”)
}

输出

结果为123456
耗时3023

总耗时为两个并行协程中耗时较长的那个时间。

CoroutineDispatcher 调度器

指定了协程运行的线程或线程池,共有 4 种。

  • Dispatchers.Main 运行在主线程,Android 平台就是 UI 线程,是单线程的。
  • Dispatchers.Default 默认的调度器,如果上下文中未指定调度器,那么就是 Default。适合用来执行消耗 CPU 资源的计算密集型任务。它由 JVM 上的共享线程池支持。 默认情况下,此调度器使用的最大并行线程数等于 CPU 内核数,但至少为两个。
  • Dispatchers.IO IO 调度器,使用按需创建的线程共享池,适合用来执行 IO 密集型阻塞操作,比如 http 请求。此调度器默认并行线程数为内核数和 64 这两个值中的较大者。
  • Dispatchers.Unconfined 不限于任何特定线程的协程调度器,不常用。

需要注意的是 Default 和 IO 都是运行在线程池中,两个子协程有可能是在一个线程中,有可能不是一个线程中。例如如下代码:

CoroutineScope(Dispatchers.IO).launch {
launch {
delay(3000)
Log.i(“zx”, “当前线程1-” + Thread.currentThread().name)
}

launch {
Log.i(“zx”, “当前线程2-” + Thread.currentThread().name)
}
}

输出

当前线程2-DefaultDispatcher-worker-2
当前线程1-DefaultDispatcher-worker-5

所以,如果涉及线程的 ThreadLocal 数据时,记得做处理。

如果一不小心用错了 Dispatchers.Default 去发 IO 请求会有什么后果呢?猜测结果:由于 Default 调度器并行线程数远小于 IO 调度器,IO 请求的一个特性就是等待时间很长,而实际的处理时间很短,所以会造成大量请求处于等待分配线程的状态中,造成效率低下。实际情况可以写个程序测试一下,这里就不试了。

CoroutineName 协程名称

传入一个 String 作为协程名称,一般用于调试时日志输出,以区分不同的调度器。

CoroutineExceptionHandler 异常处理器

用于处理协程作用域内所有未捕获的异常。实现 CoroutineExceptionHandler 接口就好了,代码如下:

class MyExceptionHandler : CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler

override fun handleException(context: CoroutineContext, exception: Throwable) {
Log.i(“zx”, “ c o n t e x t [ C o r o u t i n e N a m e ] 中发生异常 , {context[CoroutineName]}中发生异常, context[CoroutineName]中发生异常,{exception.message}”)
}
}

然后用+拼接并设置给作用域。

CoroutineScope(Dispatchers.IO + CoroutineName(“父协程”) + MyExceptionHandler()).launch {
launch(CoroutineName(“子协程1”) + MyExceptionHandler()) {
throw Exception(“完蛋了,异常了”)
}
}

输出内容为

CoroutineName(父协程)中发生异常,完蛋了,异常了

不对呀,明明是子协程 1 抛出的异常,为什么输出的是父协程抛出的异常呢?原来,异常规则就是子协程会将异常一级一级向上抛,直到根协程。那什么是根协程呢?跟协程简单来讲就是最外层协程,还有一个特殊的规则就是,使用 SupervisorJob 创建的协程也视为根协程。比如如下代码:

CoroutineScope(Dispatchers.IO + CoroutineName(“父协程”) + MyExceptionHandler()).launch {
launch(CoroutineName(“子协程1”) + MyExceptionHandler() + SupervisorJob()) {
throw Exception(“完蛋了,异常了”)
}
}

输出内容为

CoroutineName(子协程1)中发生异常,完蛋了,异常了

说起处理异常,大家肯定想到 try / catch,为什么有了 try / catch,协程里还要有一个 CoroutineExceptionHandler 呢?或者说 CoroutineExceptionHandler 到底起什么作用,什么时候用 CoroutineExceptionHandler 什么时候用 try / catch 呢?官方文档是这么描述 CoroutineExceptionHandler 的用于处理未捕获的异常,是用于全局“全部捕获”行为的最后一种机制。 你无法从CoroutineExceptionHandler的异常中恢复。 当调用处理程序时,协程已经完成。,这段文字描述的很清楚了,这是全局(这个全局是指根协程作用域全局)的异常捕获,是最后的一道防线,此时协程已经结束,你只能处理异常,而不能做其他的操作。举个例子吧

CoroutineScope(Dispatchers.IO + CoroutineName(“父协程”) + MyExceptionHandler()).launch {
val test = 5 / 0
Log.i(“zx”, “即使异常了,我也想继续执行协程代码,比如:我要通知用户,让用户刷新界面”)
}

协程体中第一行 5/0 会抛出异常,会在 CoroutineExceptionHandler 中进行处理,但是协程就会直接结束,后续的代码不会再执行,如果想继续执行协程,比如弹出 Toast 通知用户,这里就做不到了。换成 try / catch 肯定就没有问题了。

CoroutineScope(Dispatchers.IO + CoroutineName(“父协程”) + MyExceptionHandler()).launch {
try {
val test = 5 / 0
} catch (e: Exception) {
Log.i(“zx”, “我异常了”)
}
Log.i(“zx”, “继续执行协程的其他代码”)
}

那既然如此,我直接把协程中所有代码都放在 try / catch 里,不用 CoroutineExceptionHandler 不就行了?听起来好像没毛病,那我们就试试吧

inline fun AppCompatActivity.myLaunch(
crossinline block: suspend CoroutineScope.() -> Unit
) {
CoroutineScope(Dispatchers.IO).launch {
try {
block()
} catch (e: Exception) {
Log.e(“zx”, “异常了,” + e.message)
}
}
}

做了一个封装,只要是调用封装的 myLaunch 函数,那所有的协程代码都被 try / catch 包着,这肯定没问题了吧。比如我这样调用

myLaunch {
val test = 5 / 0
}

程序没崩,很好。换个代码继续调用

myLaunch {
launch {
val test = 5 / 0
}
}

APP 崩了,不对呀,这里最外层明明已经包了一层 try / catch,怎么捕获不到呢?想一下之前协程抛异常的规则:子协程会将异常一级一级向上抛,直到根协程。这里用 launch 又新创建了一个子协程,异常代码运行在子协程中,子协程直接把异常抛给了父协程,所以 try / catch 捕获不到。这里父协程又没有指定异常处理器,所以就崩了。有人可能要抬杠了,那我直接在子协程里 try / catch 不就不会崩了?确实不会崩了,这里你记住了加try / catch,那别的地方会不会忘了加呢。所以 CoroutineExceptionHandler 全作用域捕获异常的优势就出来了。所以简单总结一下二者的区别和使用场景吧。

  • CoroutineExceptionHandler 以协程为作用域全局捕获未处理异常,可以捕获子协程的异常,捕获到异常时,协程就已经结束了。适用于做最后的异常处理以保证不崩溃,比如用来记录日志等。
  • try / catch 可以更加精细的捕获异常,精确到一行代码或者一个操作,无法捕获子协程的异常,不会提前结束协程。适用于捕获可以预知的异常。

以下是个人的心得,不一定正确,仅供参考。

CoroutineExceptionHandler 适用于捕获无法预知的异常。try / catch 适用于可以预知的异常。 什么是可以预知的异常和不可预知的异常呢?举个例子:你要往磁盘写文件,可能会没有权限,也可能磁盘写满了,这些异常都是可以预知的,此时应该用 try / catch。不可预知的异常就是指,代码看起来没毛病,但我不知道哪里会不会出错,不知道 try / catch 该往哪里加,try / catch 有没有少加,这个时候就该交给 CoroutineExceptionHandler,毕竟 CoroutineExceptionHandler 是最后一道防线。

CoroutineContext 总结

CoroutineContext 由 Job、CoroutineDispatcher、CoroutineName、CoroutineExceptionHandler 组成。Job 可以控制协程的生命周期,也决定了子项异常时,父Job会不会取消。CoroutineDispatcher决定了协程运行在哪个线程。CoroutineName给协程起名字,用于调试时区分。CoroutineExceptionHandler 用于全作用域捕获并处理异常。子协程会自动继承父协程的CoroutineContext,并可以覆盖。CoroutineContext元素之间可以通过 + 运算符组合,也可以通过对应的key检索出CoroutineContext中的元素。

CoroutineStart 启动模式

上边讲了 launch 和 async 的第二个参数就是 CoroutineStart,也就是协程的启动模式,共分为如下 4 种:

  • DEFAULT-默认模式,立即调度协程;

  • LAZY-仅在需要时才懒惰地启动协程,使用start()启动;

  • ATOMIC-原子地(以不可取消的方式)调度协程,执行到挂起点之后可以被取消;

  • UNDISPATCHED-同样是原子地(以不可取消的方式)执行协程到第一个挂起点。与ATOMIC的区别是:UNDISPATCHED不需要调度,直接执行的,而ATOMIC是需要调度后再执行的;UNDISPATCHED是在父协程指定的线程中执行,到达挂起点之后会切到自己上下文中指定的线程,ATOMIC是在自己的协程上下文中指定的线程执行。

需要注意的是调度(schedules)和执行(executes)是不一样的,调度之后并不一定是立即执行。

分别举例说明。

LAZY 模式:

val job = lifecycleScope.launch(start = CoroutineStart.LAZY) {
Log.i(“zx”, “协程运行了1”)
}

上边的代码,并不会打印出内容,需要手动调用job.start(),才能启动协程并打印出内容。

ATOMIC 模式:

val job = lifecycleScope.launch(start = CoroutineStart.ATOMIC) {
Log.i(“zx”, “协程运行了1”)
delay(2000)
Log.i(“zx”, “协程运行了2”)
}
job.cancel()

由于使用的 ATOMIC 启动模式,执行到挂起点之前(delay 是挂起函数)是不能被取消的,所以无论如何都会打印出 “协程运行了 1”。执行到挂起点之后可以被取消,所以不会打印出第二行。

UNDISPATCHED 模式:

lifecycleScope.launch {
Log.i(“zx”, “父协程,当前线程” + Thread.currentThread().name)

val job = launch(Dispatchers.IO, CoroutineStart.UNDISPATCHED) {
Log.i(“zx”, “子协程,当前线程” + Thread.currentThread().name)
delay(1000)
Log.i(“zx”, “子协程delay后,当前线程” + Thread.currentThread().name)
}
}

上述代码输出

父协程,当前线程main

子协程,当前线程main

子协程delay后,当前线程DefaultDispatcher-worker-1

结果验证了,在到达第一个挂起点之前,都是使用父协程所在线程去执行协程,到达挂起点之后才会使用自己 coroutineContext 中设置的线程。类似于 ATOMIC ,在到达第一个挂起点之前同样是不可取消的。

suspend 与 withContext

前边反复提到挂起点,那什么是挂起点呢?什么又是挂起呢?挂起点实际上就是协程代码执行到 suspend 函数时的点,此时协程会暂停,suspend 函数之后的代码不会再执行,等到 suspend 函数执行完之后,协程代码会自动继续执行。上边用到的 delay 函数就是一个挂起函数,他会暂停(suspend)当前协程代码块,先执行 delay 函数,等 delay 执行完后继续执行原有的代码。先暂停,等代码执行完了在再自动恢复(resume)执行这个特性非常适合处理异步任务。例如如下代码:

private suspend fun getBitmapByHttp(): Bitmap {
Log.i(“zx”, “当前线程” + Thread.currentThread().name)
val url = URL(“https://www.baidu.com/img/flexible/logo/pc/result.png”);
val imageConnection = url.openConnection() as HttpURLConnection
imageConnection.requestMethod = “GET”
imageConnection.connect()
val inputStream: InputStream = imageConnection.inputStream
return BitmapFactory.decodeStream(inputStream)
}

lifecycleScope.launch {
val bitmap = getBitmapByHttp()//第一个行
viewBinding.imageView.setImageBitmap(bitmap)//第二行
}

先定义了一个 suspend 函数,这个函数从网络加载图片获取到 bitmap。然后启动一个 lifecycleScope 的协程,在里边调用这个 suspend 函数。应该如我们所想,第一行是个 suspend 函数,是个挂起点,会等到 getBitmapByHttp 执行完再继续执行第二行 setImageBitmap。然而运行起来之后,先是输出 “当前线程 main” 然后应用崩了,抛出了 NetworkOnMainThreadException 异常,为什么这里的 suspend 函数会运行在主线程呢?因为 suspend 并不知道具体要切到哪个线程,所以依旧运行在主线程。并且上述代码,Android Studio 会提示 Redundant 'suspend' modifier(多于的 suspend 修饰符)。如何让 suspend 函数切换到具体的线程呢?这就要用到 withContext 了。

public suspend fun withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T

这是 withContext 的签名,可以看到 withContext 必须要传入协程上下文以及一个协程代码块。协程上下文中包含了 Dispatchers,它指定了 withContext 将要切到哪个线程中去执行。withContext 也是一个 suspend 挂起函数,所以 withContext 执行时,调用它的协程会先暂停,等到它切到指定的线程并执行完之后,会自动再切回到调用它的协程,并继续执行协程代码。这其实就是挂起,自动切走,执行完了再自动切回来继续之前的操作。同样是之前的代码,加上 withContext 之后就没问题了。

private suspend fun getBitmapByHttp(): Bitmap = withContext(Dispatchers.IO) {
Log.i(“zx”, “当前线程” + Thread.currentThread().name)
val url = URL(“https://www.baidu.com/img/flexible/logo/pc/result.png”);
val imageConnection = url.openConnection() as HttpURLConnection
imageConnection.requestMethod = “GET”
imageConnection.connect()
val inputStream: InputStream = imageConnection.inputStream
BitmapFactory.decodeStream(inputStream)
}

lifecycleScope.launch {
val bitmap = getBitmapByHttp()
viewBinding.imageView.setImageBitmap(bitmap)
}

既然 withContext 可以切走再切回来,那调用时不要最外层的 lifecycleScope.launch {},不启动协程可以吗。试了一下发现 AS 提示错误,编译都过不了,提示"Suspend function ‘getBitmapByHttp’ should be called only from a coroutine or another suspend function",意思是挂起函数只能在另一个挂起函数或者协程里调用,那另一个挂起函数也只能在另另一个挂起函数或者协程里调用,如此套娃,最终就是挂起函数只能在一个协程里调用,这么限制是因为暂停、切走、切回去并恢复执行这些操作是由协程框架完成的,如果不在协程里运行,这些是没法实现的。

如果某个函数比较耗时,我们就可以将其定义为挂起函数,用 withContext 切换到非 UI 线程去执行,这样就不会阻塞 UI 线程。上边的例子也展示了自定义一个挂起函数的过程,那就是给函数加上 suspend 关键字,然后用 withContext 等系统自带挂起函数将函数内容包起来。

试想一下,如果不用 suspend 和 withContext,那我们就需要自己写开启 Thread,并自己用 Handler 去实现线程间通信。有了协程之后,这些都不需要我们考虑了,一下简单了很多,更重要的是,这样不会破坏代码的逻辑结构,两行代码之间就像普通阻塞式代码一样,但是却实现了异步非阻塞式的效果,这也就是非阻塞式的含义

小总结:

  • 挂起 就是一个切走再自动切回来继续执行的线程调度操作,这个操作由协程提供,所以限制了suspend方法只能在协程里调用。
  • withContext 就是协程提供的一个挂起函数,起到的就是切到指定线程执行代码块,执行完再切回来的作用。
  • suspend 仅仅只是一个限制,限制了挂起函数只能在协程中调用,并没有实际的切线程
  • 非阻塞式 写法像普通阻塞式代码一样,却实现了非阻塞式的效果,没有回调也没有嵌套,不破坏代码逻辑结构
  • 自定义挂起函数 给函数加上suspend关键字并用withContext等系统自带挂起函数将函数内容包起来

简单使用

就像文章一开始那样,就可以简单使用协程+Retrofit 发送异步网络请求了,但是没有异常处理,我们可以简单封装一下加上异常处理以及 loading 显示等。

全局协程异常处理

class GlobalCoroutineExceptionHandler(
val block: (context: CoroutineContext, exception: Throwable) -> Unit
) :
CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler

override fun handleException(context: CoroutineContext, exception: Throwable) {
block(context, exception)
}
}

这里的 handleException 并没有实际处理异常,实际处理异常的方法是外边初始化 CoroutineExceptionHandler 时传进来的block。

Http 请求 Activity 基类

open class HttpActivity : AppCompatActivity() {
val httpInterface: HttpInterface = RetrofitFactory.httpInterface
private var progress: ProgressDialog? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}

inline fun launchMain(
crossinline block: suspend CoroutineScope.() -> Unit
) {
val job = lifecycleScope.launch(GlobalCoroutineExceptionHandler(::handException)) {
showProgress()
block()
}

job.invokeOnCompletion {
hideProgress()
}
}

fun showProgress() {
if (progress == null) {
progress = ProgressDialog.show(
this, “”, “加载中,请稍后…”, false, true
)
}
}

fun hideProgress() {
if (progress != null && progress!!.isShowing) {
progress!!.dismiss()
progress = null
}
}

open fun handException(context: CoroutineContext, exception: Throwable) {
var msg = “”
if (exception is HttpException) {
msg = when (exception.code()) {
404 -> {
“$localClassName-异常了,请求404了,请求的资源不存在”
}

500 -> {
KaTeX parse error: Expected 'EOF', got '}' at position 36: …求500了,内部服务器错误" }̲ 500 -> { "localClassName-异常了,请求401了,身份认证不通过”
}
else -> {
l o c a l C l a s s N a m e − h t t p 请求异常了 , localClassName-http请求异常了, localClassNamehttp请求异常了,{exception.response()}”
}

}
} else {
msg = “ l o c a l C l a s s N a m e − 异常了 , localClassName-异常了, localClassName异常了,{exception.stackTraceToString()}”

}
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

关于面试的充分准备

一些基础知识和理论肯定是要背的,要理解的背,用自己的语言总结一下背下来。

虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,现在高级工程师还是比较缺少的,我能明显感觉到国庆后多了很多高级职位,所以努力让自己成为高级工程师才是最重要的。

好了,希望对大家有所帮助。

接下来是整理的一些Android学习资料,有兴趣的朋友们可以关注下我免费领取方式

①Android开发核心知识点笔记

②对标“阿里 P7” 40W+年薪企业资深架构师成长学习路线图

③面试精品集锦汇总

④全套体系化高级架构视频

**Android精讲视频领取学习后更加是如虎添翼!**进军BATJ大厂等(备战)!现在都说互联网寒冬,其实无非就是你上错了车,且穿的少(技能),要是你上对车,自身技术能力够强,公司换掉的代价大,怎么可能会被裁掉,都是淘汰末端的业务Curd而已!现如今市场上初级程序员泛滥,这套教程针对Android开发工程师1-6年的人员、正处于瓶颈期,想要年后突破自己涨薪的,进阶Android中高级、架构师对你更是如鱼得水!

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!

开发核心知识点笔记**

②对标“阿里 P7” 40W+年薪企业资深架构师成长学习路线图

[外链图片转存中…(img-smMM2mw8-1712337850013)]

③面试精品集锦汇总

[外链图片转存中…(img-wYeVaJzB-1712337850014)]

④全套体系化高级架构视频

**Android精讲视频领取学习后更加是如虎添翼!**进军BATJ大厂等(备战)!现在都说互联网寒冬,其实无非就是你上错了车,且穿的少(技能),要是你上对车,自身技术能力够强,公司换掉的代价大,怎么可能会被裁掉,都是淘汰末端的业务Curd而已!现如今市场上初级程序员泛滥,这套教程针对Android开发工程师1-6年的人员、正处于瓶颈期,想要年后突破自己涨薪的,进阶Android中高级、架构师对你更是如鱼得水!

[外链图片转存中…(img-428Kg6Ux-1712337850014)]

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!
  • 20
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值