史上最详Android版kotlin协程入门进阶实战(三)

val completion = completion!! // fail fast when trying to resume continuation

val outcome: Result<Any?> =

try {

val outcome = invokeSuspend(param)

if (outcome === COROUTINE_SUSPENDED) return

Result.success(outcome)

} catch (exception: Throwable) {

Result.failure(exception)

}

releaseIntercepted() // this state machine instance is terminating

if (completion is BaseContinuationImpl) {

current = completion

param = outcome

} else {

completion.resumeWith(outcome)

return

}

}

}

}

}

可以看到最终这里面invokeSuspend才是真正调用我们协程的地方。最后也是通过Continuation调用resumeWith函数恢复协程的执行,同时返回Result<T>类型的结果。和我们上面说的是一样的,只是他们是在不同阶段。

那、那、那、那下面那个finally它又是有啥用,我们都通过resumeWithException把异常抛出去了,为啥下面又还有个handleFatalException,这货又是干啥用的???

handleFatalException主要是用来处理kotlinx.coroutines库的异常,我们这里大致的了解下就行了。主要分为两种:

  1. kotlinx.coroutines库或编译器有错误,导致的内部错误问题。

  2. ThreadContextElement也就是协程上下文错误,这是因为我们提供了不正确的ThreadContextElement实现,导致协程处于不一致状态。

public interface ThreadContextElement : CoroutineContext.Element {

public fun updateThreadContext(context: CoroutineContext): S

public fun restoreThreadContext(context: CoroutineContext, oldState: S)

}

我们看到handleFatalException实际是调用了handleCoroutineException方法。handleCoroutineExceptionkotlinx.coroutines库中的顶级函数

public fun handleFatalException(exception: Throwable?, finallyException: Throwable?) {

//省略…

handleCoroutineException(this.delegate.context, reason)

}

public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {

try {

context[CoroutineExceptionHandler]?.let {

it.handleException(context, exception)

return

}

} catch (t: Throwable) {

handleCoroutineExceptionImpl(context, handlerException(exception, t))

return

}

handleCoroutineExceptionImpl(context, exception)

}

我们看到handleCoroutineException会先从协程上下文拿CoroutineExceptionHandler,如果我们没有定义的CoroutineExceptionHandler话,它将会调用handleCoroutineExceptionImpl抛出一个uncaughtExceptionHandler导致我们程序崩溃退出。

internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {

for (handler in handlers) {

try {

handler.handleException(context, exception)

} catch (t: Throwable) {

val currentThread = Thread.currentThread()

currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, handlerException(exception, t))

}

}

val currentThread = Thread.currentThread()

currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)

}

不知道各位是否理解了上面的流程,笔者最开始的时候也是被这里来来回回的。绕着晕乎乎的。如果没看懂的话,可以休息一下,揉揉眼睛,倒杯热水,再回过头捋一捋。

image.png

好滴,到此处为止。我们已经大概的了解kotlin协程中异常是如何抛出的,下面我们就不再不过多延伸。下面我们来说说异常的处理。

协程的异常处理


kotlin协程异常处理我们要分成两部分来看,通过上面的分解我们知道一种异常是通过resumeWithException抛出的,还有一种异常是直接通过CoroutineExceptionHandler抛出,那么我们现在就开始讲讲如何处理异常。

第一种:当然就是我们最常用的try..catch大法啦,只要有异常崩溃我就先try..catch下,先不管流程对不对,我先保住我的程序不能崩溃。

private fun testException(){

GlobalScope.launch{

launch(start = CoroutineStart.UNDISPATCHED) {

Log.d(“${Thread.currentThread().name}”, " 我要开始抛异常了")

try {

throw NullPointerException(“异常测试”)

} catch (e: Exception) {

e.printStackTrace()

}

}

Log.d(“${Thread.currentThread().name}”, “end”)

}

}

D/DefaultDispatcher-worker-1: 我要开始抛异常了

W/System.err: java.lang.NullPointerException: 异常测试

W/System.err: at com.carman.kotlin.coroutine.MainActivity$testException$1$1.invokeSuspend(MainActivity.kt:252)

W/System.err: at com.carman.kotlin.coroutine.MainActivity$testException$1$1.invoke(Unknown

//省略…

D/DefaultDispatcher-worker-1: end

诶嘿,这个时候我们程序没有崩溃,只是输出了警告日志而已。那如果遇到try..catch搞不定的怎么办,或者遗漏了需要try..catch的位置怎么办。比如:

private fun testException(){

var a:MutableList = mutableListOf(1,2,3)

GlobalScope.launch{

launch {

Log.d(“${Thread.currentThread().name}”,“我要开始抛异常了” )

try {

launch{

Log.d(“ T h r e a d . c u r r e n t T h r e a d ( ) . n a m e " , " {Thread.currentThread().name}", " Thread.currentThread().name","{a[1]}”)

}

a.clear()

} catch (e: Exception) {

e.printStackTrace()

}

}

Log.d(“${Thread.currentThread().name}”, “end”)

}

}

D/DefaultDispatcher-worker-1: end

D/DefaultDispatcher-worker-2: 我要开始抛异常了

E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-2

Process: com.carman.kotlin.coroutine, PID: 5394

java.lang.IndexOutOfBoundsException: Index: 1, Size: 0

at java.util.ArrayList.get(ArrayList.java:437)

at com.carman.kotlin.coroutine.MainActivity$testException$1$1$1.invokeSuspend(MainActivity.kt:252)

at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)

at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)

at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)

at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)

at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)

at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

image.png

当你以为使用try..catch就能捕获的时候,然而实际并没有。这是因为我们的try..catch使用方式不对,我们必须在使用a[1]时候再用try..catch捕获才行。那就有人会想那我每次都记得使用try..catch就好了。

是,当然没问题。但是你能保证你每次都能记住吗,你的同一战壕里的战友会记住吗。而且当你的逻辑比较复杂的时候,你使用那么多try..catch你代码阅读性是不是降低了很多后,你还能记住哪里有可能会出现异常吗。

这个时候就需要使用协程上下文中的CoroutineExceptionHandler。我们在上一篇文章讲解协程上下文的时候提到过,它是协程上下文中的一个Element,是用来捕获协程中未处理的异常。

public interface CoroutineExceptionHandler : CoroutineContext.Element {

public companion object Key : CoroutineContext.Key

public fun handleException(context: CoroutineContext, exception: Throwable)

}

我们稍作修改:

private fun testException(){

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->

Log.d(“exceptionHandler”, “ c o r o u t i n e C o n t e x t [ C o r o u t i n e N a m e ] : {coroutineContext[CoroutineName]} : coroutineContext[CoroutineName]throwable”)

}

GlobalScope.launch(CoroutineName(“异常处理”) + exceptionHandler){

val job = launch{

Log.d(“${Thread.currentThread().name}”,“我要开始抛异常了” )

throw NullPointerException(“异常测试”)

}

Log.d(“${Thread.currentThread().name}”, “end”)

}

}

D/DefaultDispatcher-worker-1: 我要开始抛异常了

D/exceptionHandler: CoroutineName(异常处理) :java.lang.NullPointerException: 异常测试

D/DefaultDispatcher-worker-2: end

这个时候即使我们没有使用try..catch去捕获异常,但是异常还是被我们捕获处理了。是不是感觉异常处理也没有那么难。那如果按照上面的写,我们是不是得在每次启动协程的时候,也需要跟try..catch一样都需要加上一个CoroutineExceptionHandler呢? 这个时候我们就看出来,各位是否真的有吸收前面讲解的知识:

  • 第一种:我们上面讲解的协程作用域部分你已经消化吸收,那么恭喜你接下来的你可以大概的过一遍或者选择跳过了。因为接下来的部分和协程作用域中说到的内容大体一致。

  • 第二种:除第一种的,都是第二种。那你接下来你就得认证仔细的看了。

我们之前在讲到协同作用域主从(监督)作用域的时候提到过,异常传递的问题。我们先来看看协同作用域:

  • 协同作用域如果子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。

容我盗个官方图

默认情况下,当协程因出现异常失败时,它会将异常传播到它的父级,父级会取消其余的子协程,同时取消自身的执行。最后将异常在传播给它的父级。当异常到达当前层次结构的根,在当前协程作用域启动的所有协程都将被取消。

我们在前一个案例的基础上稍作做一下修改,只在父协程上添加CoroutineExceptionHandler,照例上代码:

private fun testException(){

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->

Log.d(“exceptionHandler”, “ c o r o u t i n e C o n t e x t [ C o r o u t i n e N a m e ] 处理异常: {coroutineContext[CoroutineName]} 处理异常 : coroutineContext[CoroutineName]处理异常:throwable”)

}

GlobalScope.launch(CoroutineName(“父协程”) + exceptionHandler){

val job = launch(CoroutineName(“子协程”)) {

Log.d(“${Thread.currentThread().name}”,“我要开始抛异常了” )

for (index in 0…10){

launch(CoroutineName(“孙子协程$index”)) {

Log.d(“ T h r e a d . c u r r e n t T h r e a d ( ) . n a m e " , " {Thread.currentThread().name}"," Thread.currentThread().name","{coroutineContext[CoroutineName]}” )

}

}

throw NullPointerException(“空指针异常”)

}

for (index in 0…10){

launch(CoroutineName(“子协程$index”)) {

Log.d(“ T h r e a d . c u r r e n t T h r e a d ( ) . n a m e " , " {Thread.currentThread().name}"," Thread.currentThread().name","{coroutineContext[CoroutineName]}” )

}

}

try {

job.join()

} catch (e: Exception) {

e.printStackTrace()

}

Log.d(“${Thread.currentThread().name}”, “end”)

}

}

D/DefaultDispatcher-worker-3: 我要开始抛异常了

W/System.err: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine is cancelling; job=StandaloneCoroutine{Cancelling}@f6b7807

W/System.err: Caused by: java.lang.NullPointerException: 空指针异常

W/System.err: at com.carman.kotlin.coroutine.MainActivity$testException 1 1 1job$1.invokeSuspend(MainActivity.kt:26//省略…

D/DefaultDispatcher-worker-6: end

D/exceptionHandler: CoroutineName(父协程) 处理异常 :java.lang.NullPointerException: 空指针异常

我们看到子协程job的异常被父协程处理了,无论我下面开启多少个子协程产生异常,最终都是被父协程处理。但是有个问题是:因为异常会导致父协程被取消执行,同时导致后续的所有子协程都没有执行完成(可能偶尔有个别会执行完)。那可能就会是有人问了,这种做法的意义和应用场景是什么呢?

如果有一个页面,它最终展示的数据,是通过请求多个服务器接口的数据拼接而成的,而其中某一个接口出问题都将不进行数据展示,而是提示加载失败。那么你就可以使用上面的方案去做,都不用管它们是谁报的错,反正都是统一处理,一劳永逸。类似这样的例子我们在开发中应该经常遇到。

但是另外一个问题就来了。例如我们APP的首页,首页上展示的数据五花八门。如:广告,弹窗,未读状态,列表数据等等都在首页存在,但是他们相互之间互不干扰又不关联,即使其中某一个失败了也不影响其他数据展示。那通过上面的方案,我们就没办法处理。

这个时候我们就可以通过主从(监督)作用域的方式去实现,与协同作用域一致,区别在于该作用域下的协程取消操作的单向传播性,子协程的异常不会导致其它子协程取消。我再盗个官方图:

我们在讲解主从(监督)作用域的时候提到过,要实现主从(监督)作用域需要使用supervisorScope或者SupervisorJob。这里我们需要补充一下,我们在使用supervisorScope其实用的就是SupervisorJob。 这也是为什么使用supervisorScope与使用SupervisorJob协程处理是一样的效果。

/**

  • 省略…

  • but overrides context’s [Job] with [SupervisorJob].

  • 省略…

*/

public suspend fun supervisorScope(block: suspend CoroutineScope.() -> R): R {

//省略…

}

这段是摘自官方文档的,其他的我把它们省略了,只留了一句:“SupervisorJob会覆盖上下文中的Job”。这也就说明我们在使用supervisorScope的就是使用的SupervisorJob。我们先用supervisorScope实现以下我们上面提到的案例:

private fun testException(){

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->

Log.d(“exceptionHandler”, “ c o r o u t i n e C o n t e x t [ C o r o u t i n e N a m e ] . t o S t r i n g ( ) 处理异常: {coroutineContext[CoroutineName].toString()} 处理异常 : coroutineContext[CoroutineName].toString()处理异常:throwable”)

}

GlobalScope.launch(exceptionHandler) {

supervisorScope {

launch(CoroutineName(“异常子协程”)) {

Log.d(“${Thread.currentThread().name}”, “我要开始抛异常了”)

throw NullPointerException(“空指针异常”)

}

for (index in 0…10) {

launch(CoroutineName(“子协程$index”)) {

Log.d(“ T h r e a d . c u r r e n t T h r e a d ( ) . n a m e 正常执行 " , " {Thread.currentThread().name}正常执行", " Thread.currentThread().name正常执行","index”)

if (index %3 == 0){

throw NullPointerException(“子协程${index}空指针异常”)

}

}

}

}

}

}

D/DefaultDispatcher-worker-1: 我要开始抛异常了

D/exceptionHandler: CoroutineName(异常子协程) 处理异常 :java.lang.NullPointerException: 空指针异常

D/DefaultDispatcher-worker-1正常执行: 1

D/DefaultDispatcher-worker-1正常执行: 2

D/DefaultDispatcher-worker-3正常执行: 0

D/DefaultDispatcher-worker-1正常执行: 3

D/exceptionHandler: CoroutineName(子协程0) 处理异常 :java.lang.NullPointerException: 子协程0空指针异常

D/exceptionHandler: CoroutineName(子协程3) 处理异常 :java.lang.NullPointerException: 子协程3空指针异常

D/DefaultDispatcher-worker-4正常执行: 4

D/DefaultDispatcher-worker-4正常执行: 5

D/DefaultDispatcher-worker-5正常执行: 7

D/DefaultDispatcher-worker-3正常执行: 6

D/DefaultDispatcher-worker-5正常执行: 8

D/DefaultDispatcher-worker-5正常执行: 9

D/exceptionHandler: CoroutineName(子协程9) 处理异常 :java.lang.NullPointerException: 子协程9空指针异常

D/exceptionHandler: CoroutineName(子协程6) 处理异常 :java.lang.NullPointerException: 子协程6空指针异常

D/DefaultDispatcher-worker-2正常执行: 10

可以看到即使当中有多个协程都出现问题,我们还是能够让所有的子协程执行完成。这个时候我们用这样方案是不是就可以解决,我们首页多种数据互不干扰的刷新问题了,同也能够在出现异常的时候统一处理。

那我们在用SupervisorJob实现一遍,看看是不是和supervisorScope一样的,代码奉上:

private fun testException(){

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->

Log.d(“exceptionHandler”, “ c o r o u t i n e C o n t e x t [ C o r o u t i n e N a m e ] . t o S t r i n g ( ) 处理异常: {coroutineContext[CoroutineName].toString()} 处理异常 : coroutineContext[CoroutineName].toString()处理异常:throwable”)

}

val supervisorScope = CoroutineScope(SupervisorJob() + exceptionHandler)

with(supervisorScope) {

launch(CoroutineName(“异常子协程”)) {

Log.d(“${Thread.currentThread().name}”, “我要开始抛异常了”)

throw NullPointerException(“空指针异常”)

}

for (index in 0…10) {

launch(CoroutineName(“子协程$index”)) {

Log.d(“ T h r e a d . c u r r e n t T h r e a d ( ) . n a m e 正常执行 " , " {Thread.currentThread().name}正常执行", " Thread.currentThread().name正常执行","index”)

if (index % 3 == 0) {

throw NullPointerException(“子协程${index}空指针异常”)

}

}

}

}

可以看到我们通过CoroutineScope创建一个SupervisorJobsupervisorScope,然后再通过with(supervisorScope)是不是就变得跟直接使用supervisorScope一样了。

D/DefaultDispatcher-worker-1: 我要开始抛异常了

D/DefaultDispatcher-worker-2正常执行: 0

D/exceptionHandler: CoroutineName(子协程0) 处理异常 :java.lang.NullPointerException: 子协程0空指针异常

D/exceptionHandler: CoroutineName(异常子协程) 处理异常 :java.lang.NullPointerException: 空指针异常

D/DefaultDispatcher-worker-2正常执行: 1

D/DefaultDispatcher-worker-2正常执行: 2

D/DefaultDispatcher-worker-4正常执行: 3

D/exceptionHandler: CoroutineName(子协程3) 处理异常 :java.lang.NullPointerException: 子协程3空指针异常

D/DefaultDispatcher-worker-1正常执行: 4

D/DefaultDispatcher-worker-4正常执行: 5

D/DefaultDispatcher-worker-4正常执行: 6

D/exceptionHandler: CoroutineName(子协程6) 处理异常 :java.lang.NullPointerException: 子协程6空指针异常

D/DefaultDispatcher-worker-4正常执行: 8

D/DefaultDispatcher-worker-3正常执行: 7

D/DefaultDispatcher-worker-2正常执行: 9

D/exceptionHandler: CoroutineName(子协程9) 处理异常 :java.lang.NullPointerException: 子协程9空指针异常

D/DefaultDispatcher-worker-3正常执行: 10

当然,我们在使用协程的时候,可能某个协程需要自己处理自己的异常,这个时候只需要在这个协程的上下文中添加CoroutineExceptionHandler即可。毕竟按需使用,谁也不知道产品又会有什么奇怪的想法。

好了,到现在我们也基本的知道协程中的异常产生流程,和按需处理协程中的异常问题。如果您还有什么不清楚的地方,可以自己动手实验一下或者在下方留言、私信笔者等方式,我会在看到消息的第一时间处理。

预告以及意见收集

在下一章节中,我们将会进入到实际的Android开发中,我们会先构建一个基础APP的框架,封装一些常用的协程方法和请求方式,至于具体的实战项目类型,我想征求一下大家的意见,然后根据反馈的实际情况再来决定,欢迎大家踊跃的提出意见。

最后:祝愿大家都能写出完美的BUG,让测试都无法找到BUG所在。


如果你想学习kotlin又缺少学习资料,我正好薅到这本谷歌内部大佬根据实战编写的Kotlin宝典,从入门到精通,教程通俗易懂,实例丰富,既有基础知识,也有进阶技能,能够帮助读者快速入门,是你学习Kotlin的葵花宝典,快收藏起来!!!

今天分享的一共分为两部分:【字节跳动厂内部超高质量Kotlin笔记】、【谷歌大佬编写高级Kotlin强化实战(附Demo)】。

一、字节跳动厂内部超高质量Kotlin笔记

首先目录乘上:

1.准备开始

主要内容:基本语法、习惯用语、编码风格

2.基础

主要内容:基本类型、包、控制流、返回与跳转

3.类和对象

主要内容:类和继承、属性和字段、接口、可见性修饰词、扩展、数据对象、泛型、嵌套类、枚举类、对象表达式和声明、代理模式、代理属性

4.函数和lambda表达式

主要内容:函数、高阶函数与 lambda 表达式

5.其它

主要内容:多重申明,Ranges,类型检查和自动转换,This表达式,等式,运算符重载,空安全,异常,注解,反射,动态类型

6.互用性

主要内容:动态类型

7.工具

主要内容:使用Maven、使用 Ant、使用 Gradle、使用Griffon

8.FAQ

主要内容:与java对比、与Scala对比

基础知识掌握之后就是靠实战提升了!

二、谷歌大佬编写高级Kotlin强化实战(附Demo)

照样目录乘上:

第一章 Kotlin入门教程
  • Kotlin 概述

  • Kotlin 与 Java 比较

  • 巧用 Android Studio

  • 认识 Kotlin 基本类型

  • 走进 Kotlin 的数组

  • 走进 Kotlin 的集合

  • 集合问题

  • 完整代码

  • 基础语法

第二章 Kotlin 实战避坑指南
  • 方法入参是常量,不可修改

  • 不要 Companion 、INSTANCE ?

  • Java 重载,在 Kotlin 中怎么巧妙过渡一下?

  • Kotlin 中的判空姿势

  • Kotlin 复写 Java 父类中的方法

  • Kotlin “狠”起来,连TODO 都不放过!

  • is、as` 中的坑

尾声

如果你想成为一个优秀的 Android 开发人员,请集中精力,对基础和重要的事情做深度研究。

对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。 整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。

这里,笔者分享一份从架构哲学的层面来剖析的视频及资料分享给大家梳理了多年的架构经验,筹备近6个月最新录制的,相信这份视频能给你带来不一样的启发、收获。

PS:之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

架构篇

《Jetpack全家桶打造全新Google标准架构模式》

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

第二章 Kotlin 实战避坑指南
  • 方法入参是常量,不可修改

  • 不要 Companion 、INSTANCE ?

  • Java 重载,在 Kotlin 中怎么巧妙过渡一下?

  • Kotlin 中的判空姿势

  • Kotlin 复写 Java 父类中的方法

  • Kotlin “狠”起来,连TODO 都不放过!

  • is、as` 中的坑

尾声

如果你想成为一个优秀的 Android 开发人员,请集中精力,对基础和重要的事情做深度研究。

对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。 整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。

这里,笔者分享一份从架构哲学的层面来剖析的视频及资料分享给大家梳理了多年的架构经验,筹备近6个月最新录制的,相信这份视频能给你带来不一样的启发、收获。[外链图片转存中…(img-hpSqok6B-1715023035958)]

PS:之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

[外链图片转存中…(img-xa60UfjN-1715023035959)]

架构篇

《Jetpack全家桶打造全新Google标准架构模式》
[外链图片转存中…(img-lZB8DQa1-1715023035960)]
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值