MeetingService重构和ParticipantList性能优化实践

一丶背景

1.1 现状

  • 最初Rooms客户端只支持加入Rcv meeting这个meeting
    type,RcvMeetingStateService里写了一些加会的状态机转换和Audio, Video,
    Share相关的功能代码。后续有新的业务,需要增加支持Webinar,
    Sip等各种Meeting,MeetingService的代码没有较好的做抽象和封装导致其他Meeting无法复用只能暂时写一些重复的代码去实现支持其他meeting。
  • MeetingService等一些Services遍历List对外通知MeetingStateChange和ParticipantsChange等事件的时候时常有Activity or Fragment收到通知后需要关闭自己然后关闭的同时去MeetingService解除注册, 此时会有并发修改异常产生导致崩溃。其次,由于这些Service都需要写同样的注册,解除注册等代码,导致产生大量重复的模板代码和UT cases。
  • Rcv Meeting最初只支持200人加会,后续我们想支持到一万人,发现ParticipantList界面消耗了大量内存和渲染卡顿。

1.2 目标

  • 对于MeetingService中的代码抽象封装,使其成为一个个高内聚低耦合的独立组件方便给各个MeetingService以组合的方式复用代码。
  • 重构现有的观察者模式使其支持并发修改,且检测出多线程操作数据问题给开发提示让其修改业务代码为在同一个线程操作数据和发通知,其次消除模板代码。
  • 对ParticipantList渲染性能优化,使其能够在万人会议中也能流畅的渲染出List和操作。

二丶解决方案

2.1 ParticipantList 问题分析和解决

Android界面渲染出现卡顿的直接原因是未在1帧内完成UI绘制工作,可能的原因主要有2个方向:

  • 界面嵌套太深,或者在渲染的时候有一些比较耗时的工作导致系统未能在16ms内完成绘制。
  • 程序消耗了大量内存,导致频繁地Full GC而Stop The World.

问题的难点在于如何分析找到耗时方法和内存分配。
对于常规的View层的优化,比较直接有效的手段是减少嵌套层级,在此基础上,我们可以利用AndroidStudio Profile对方法耗时内存分配做监控,结合业务代码对ParticipantList的RecyclerView做针对性的分析和优化。

众所周知,将layout文件渲染成一个View对象因为涉及到io读取和反射是较为耗时的,如果RecyclerView的item view足够简单,我们是可以尝试用JavaCode去直接创建ItemView的,甚至业界也有利用某些工具将Layout文件直接生成对应的JavaCode,但是我的工程中的itemview较为复杂,不适用这一点。既然如此,我们从尽可能地少创建或者提前创建itemview这个方向入手。

2.1.1 提高缓存利用率

RecyclerView复用原理是从它的各级缓存中查找,如果没有命中就调用onCreateViewHolder去创建一个新的ViewHolder,所以我们要尽可能的利用缓存,用空间换时间的理念去充分发挥缓存的作用。

recyclerView.setItemViewCacheSize(4)
recyclerView.recycledViewPool.setMaxRecycledViews(    
    ParticipantListAdapter.ITEM_TYPE_IN_MEETING, 
    7
)

考虑到用户可能会较为频繁的上下反复滚动查找某个Participant,适当将CacheViewSize设置为4,缓存offscreen viewholder. 某些场景下需要全量刷新,这里participant list的最多显示7个item,所以我们将RecyclePool设置到7来缓存全量刷新下的ViewHolder.接着我们设置hasStatbleId和为每一个Item返回唯一id来尽可能地当全量刷新的时候让ViewHolder缓存有效而不是进入RecyclPool。

2.1.2 减少绘制

进一步的,ItemAnimator对于业务没有帮助,我们将其禁止掉来减少绘制,其次由于我们ParticipantList的RecyclerView高度是固定的不会发生改变,我们调用

 recyclerView.setHasFixedSize(true)

来避免requestLayout。

2.1.3 减少没必要的刷新

我们业务中有多处监听去全量刷新(notifyDataSetChanged)的代码,比如LocalParticipant(Myself)的Audio/Video状态的变化,这时候没有必要全局刷新的,只需要刷新LocalParticipant就好了,此外底层的回调也会重复触发这些接口即使状态没有发生真正的变化,所以基于此,我们用

data class ParticipantListRoomSettingModel(
    var hasVideoDevices: Boolean = true,
    var hasAudioDevices: Boolean = true,
    var isWaitingRoomEnable: Boolean = false,
    var localRole: ERoomParticipantRole = ERoomParticipantRole.NORMAL
)

来记录当前的各个状态,当接口通知的时候我们比较确实与本地的状态不一致时我们做局部刷新:

notifyItemChanged(localParticipantIndex)

2.1.4 减少冗余Log

我们发现当业务代码中有非常多的冗余Log在记录Participant的状态,这在当有大量Participant在会议中的时候会频繁的输出大量Log,过多的Log会占用CPU,所以我们精简了一些Log尤其禁止在遍历语句中输出Log.

2.1.5 局部刷新

经我们测试和查看Profile,发现在上万人会议中,大量Participant的状态会频繁的发生更新(update, add, remove),服务端会很频繁的通知客户端去全量刷新RecyclerView,这其实是这次卡顿的真正元凶。此外因为,在业务上用户并不需要这么及时地看到participant list的更新,所以我们让服务端更新全量下发participant list数据的方案,服务端每次只需要在数据发生变化后就起一个1s的延时任务,在这1s内收集这些diff数据下发给客户端即可,客户端在子线程中解析好拿到update list, add list, remove list后我们二分查找这些participant 的Index后去做局部刷新。这样既减少了冗余的数据传输,也解决了客户端过于敏感地刷新数据问题。

	parseJob = ioScope.launch {
			// 起协程解析json
            val participantsNewModel = newGson.fromJson(
                participantListJson,
                ParticipantsNew::class.java
            )
            // 解析updates通知局部刷新类型为 EUpdateType.CHANGED
            participantsNewModel.updates.forEach { participant ->
                val index = participantsList.binarySearch { it.modelId - participant.modelId }
                if (index < 0) return@forEach
                participantsList[index] = participant
                mainScope.launch {
                    notifyEachListener {
                        onParticipantListChanged(EUpdateType.CHANGED, index, 1)
                    }
                }
            }
			// 省略解析 adds, removes 
        }

	// UI 通知adapter做局部刷新
	override fun updateParticipant(
        type: ParticipantListViewModel.EUpdateType,
        index: Int,
        count: Int
    ) {
		when (type) {
            ParticipantListViewModel.EUpdateType.ADDED -> dataAdapter?.notifyItemRangeChanged(
                index,
                count
            )

            ParticipantListViewModel.EUpdateType.DELETED -> dataAdapter?.notifyItemRemoved(
                index
            )

            ParticipantListViewModel.EUpdateType.CHANGED -> dataAdapter?.notifyItemChanged(
                index
            )
		}
	}

2.1.7 滑动时暂停加载图片

我们发现RecyclerView快速滑动的时候,其实用户这时候时不关心具体的item数据展示的,所以此时我们可以选择暂停加载图片。

监听RecyclerView滚动状态,我们在快速滑动的时候暂停加载头像,当速度慢下来和停止滑动的时恢复加载头像

class RecyclerViewScrollController(
    recyclerView: RecyclerView,
    private val listener: RecyclerViewScrollListener,
    private val threshold: Int = 150,
) {

    private val scrollListener = object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            if (newState == SCROLL_STATE_IDLE) {
                listener.onStopScroll(recyclerView)
            }
        }

        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            if (abs(dy) < threshold) {
                listener.onSlowScroll(recyclerView)
            } else {
                listener.onFastScroll(recyclerView)
            }
        }
    }

    init {
        recyclerView.addOnScrollListener(scrollListener)
    }
}
class PauseLoadImageOnScrollListener : RecyclerViewScrollListener {
    override fun onFastScroll(recyclerView: RecyclerView) {
        Fresco.getImagePipeline().pause()
    }

    override fun onSlowScroll(recyclerView: RecyclerView) {
        Fresco.getImagePipeline().resume()
    }

    override fun onStopScroll(recyclerView: RecyclerView) {
        Fresco.getImagePipeline().resume()
    }
}

2.1.6 减少内存消耗

内存增长,我们前面用让服务端下发Diff数据已经有效的减少了大量的冗余数据,其实我们还有进一步优化的空间。我们检查json数据,删掉一些冗余字段后,我们借鉴http2的压缩header的方法,我们也对json的key做了精简,然后将其映射表下发给客户端,客户端拿到精简后的数据后再用mapping还原即可。这在非常大的list数据中效果非常明显,经测试对比,大约可以减少41%的json长度。

private val FIELD_NAME_MAP = mapOf(
            "id" to "id",
            "modelId" to "mId",
            "initialsAvatarName" to "iAN",
            "displayName" to "dN",
            "isPstn" to "iP",
            "isGuest" to "iG",
            "isVideoLocalMute" to "iVLM",
            "isVideoServerMute" to "iVSM",
            "isAudioLocalMute" to "iALM",
            "isAudioServerMute" to "iASM",
            "roomParticipantRole" to "rPR",
            "isMe" to "iM",
            "activeDeviceCount" to "aDC",
            "isWhiteBoardSharing" to "iWBS",
            "isOnhold" to "iO",
            "isScreenSharing" to "iSS",
            "isAllowUmuteAudio" to "iAUA",
            "isAllowUmuteVideo" to "iAUV",
            "isRoomExtension" to "iRE",
            "headshotColor" to "hC",
            "hasNonPstnSession" to "hNPS",
            "headshotUrlWithSize" to "hUWS",
            "audioStreamActivated" to "aSA",
        )
  //服务端下发上面的map,然后解析的时候可以用此还原key。      
 private val newGson = GsonBuilder().setFieldNamingStrategy {
        filedNameMap?.getOrDefault(it.name, it.name)
    }.create()

2.1.7 减少对象创建避免内存抖动

我们知道图片加载框架都会对Bitmap对象做缓存复用,ParticipantList里面会加载大量的头想图片,头像由图片加载框架缓存,但是我们加载头像前会创建PlaceHolderDrawable来作为placeholder。这一部分我们可以借鉴这个思路,做一个缓存池来减少RecyclerView滚动中不断创建PlaceHolderDrawable,以此来减少GC次数避免内存抖动。

object PlaceHolderDrawablePools {
    @JvmStatic
    val avatarPlaceholderDrawablePool = SimplePool<AvatarPlaceholderDrawable>(20)

    @JvmStatic
    val avatarTextDrawablePool= SimplePool<AvatarTextDrawable>(20)
}
// 当设置placeholder的时候先从pool中获取,取不到的话再创建新的。
Pools.SimplePool<AvatarTextDrawable> pool = PlaceHolderDrawablePools.getAvatarTextDrawablePool();
AvatarTextDrawable avatarTextDrawable = pool.acquire();

// 图片加载结束后 或者 detach avatarview的时候回收drawaable
AvatarPlaceholderDrawable drawable = (AvatarPlaceholderDrawable) mPlaceholderDrawable;
Log.i(TAG, "recycle AvatarPlaceholderDrawable=" + drawable);
Pools.SimplePool<AvatarPlaceholderDrawable> pool = PlaceHolderDrawablePools.getAvatarPlaceholderDrawablePool();
pool.release(drawable);

此外,因为onBindViewHolder中有一处设置OnClickListener的地方,因为onBindViewHolder会频繁调用,这样也会导致频繁创建listener,对此我们需要将其setOnClickListener放到onCreateViewHolder中调用。

2.1.8 核心代码

  • com.ringcentral.rooms.controller.service.model.ParticipantListViewModel: ParticipantList 的ViewModel,负责Participants的数据解析和对RecyclerView提供绑定
  • com.ringcentral.rooms.controller.meeting.ui.participants.ParticipantInMeetingListPresenter:ParticipantList 的Presenter,决定RecyclerView是用局部刷新还是全量刷新
  • com.ringcentral.rooms.controller.meeting.ui.participants.ParticipantListAdapter:RecyclerView的Adapter
  • com.ringcentral.rooms.controller.meeting.ui.participants.ParticipantListViewFragment: Participants List界面
  • com.ringcentral.rooms.controller.meeting.ui.participants.ParticipantInMeetingItemViewHolder:RecyclerView的ViewHolder
  • com.ringcentral.rooms.common.widget.recyclerview.PauseLoadImageOnScrollListener:当RecyclerView快速滑动时暂停加载头像
  • com.ringcentral.rooms.common.widget.recyclerview.RecyclerViewScrollController:监听RecyclerView的滑动状态组件
  • com.ringcentral.rooms.common.widget.image.PlaceHolderDrawablePools:头像的PlaceHolderDrawable缓存池
  • com.ringcentral.rooms.common.widget.image.AvatarView:Participant的头像

2.1.9 成果举证

1.旧的方案:
旧方案
2.新的方案:
请添加图片描述

旧的方案里,CPU一直被消耗了40%左右,内存峰值达到153.9MB左右,且后续因为有大量的临时对象创建和回收,可以看到内存图有很多锐化,有很明显的内存抖动现象,频繁的GC会使程序感觉到卡顿。这里因为Demo去掉了其他业务,在实际项目中由于还有很多其他业务在一起抢占资源,所以会明显感觉到卡顿。

新方案优化后,CPU消耗了降低了很多,后续几乎不怎么占用CPU资源了。内存因为我们做了一些缓存优化,可以看到峰值较之前少了25.6MB且一直比较稳定不会因为要分配和回收太多内存而造成内存抖动。

2.2 MeetingService问题分析和解决

2.2.1 复用性问题

2.2.1.1 问题分析

因为旧的业务中只有RcvMeeting这一种类型,旧的代码把Meeting相关的逻辑一股脑的都放在了MeetingService里了,不曾想后续新增了很多种MeetingType,这导致无法复用。如果我们能把这些相关的代码按照一定的业务划分为各个内聚的独立组件,如果有某个业务方,比如RcvMeetingService和WebinarMeetingService都需要这部分代码,那么它都可以直接以组合的方式去复用这块代码,那这样就能比较好地做到高内聚低耦合且可以很好地应对需求变更。
这块的难点在于如何将现有的业务和加会流程梳理清晰然后抽象为各个高内聚低耦合的独立组件,再由这些组件组成一个大的MeetingService框架。

2.2.1.2 解决方案

我们抽象一个BaseMeetingService,把原来的join流程和inmeeting相关的逻辑拆成BaseClientInMeetingController、BaseClientJoinMeetingController。然后再抽取可选配的业务组件,比如E2EE模块,BreakoutRoom某块等等将其也封装成E2EEController,BreakoutController, 这样比如RcvMeeting和WebinarMeeting需要E2EEController的话就可以在JoinMeetingController中以组合方式集成,其他的SipMeeting不需要的话就不用集成这个模块。这样我们就将原来的臃肿的MeetingService重构为轻量,高可用的组件,极大的提高了代码可维护性。

2.2.1.3 核心代码
  • com.ringcentral.rooms.controller.service.deprecated.MeetingService:以前旧的Rcv MeetingService
  • com/ringcentral/rooms/common/meeting/service/BaseMeetingStateService:新的BaseMeetingService
  • com.ringcentral.rooms.controller.service.ControllerRcvMeetingService: 新的Rcv MeetingService
  • com.ringcentral.rooms.controller.service.ControllerWebinarPanelistService: 新的Webinar MeetingService
  • com.ringcentral.rooms.controller.service.base.BaseClientInMeetingController: 抽象的InMeeting 组件
  • com.ringcentral.rooms.controller.service.base.BaseClientJoinMeetingController: 抽象的JoinMeeting 组件
  • com.ringcentral.rooms.controller.service.RcvClientInMeetingController: Rcv meeting的Inmeeting 组件的实现
  • com.ringcentral.rooms.controller.service.RcvClientJoinMeetingController:Rcv meeting的JoinMeeting 组件的实现
2.2.1.4 UML

改造后分为各个MeetingService
类图:
在这里插入图片描述
将加会流程和各个Meeting业务抽取封装为各个独立组件(XXXController)
请添加图片描述

2.2.2 观察者模式并发修改异常和模板代码

2.2.2.1 观察者模式问题分析

由于MeetingService使用Set<Listener>来保存订阅者,在MeetingState发生变化的时候通过遍历这个Set<Listener>去通知
订阅者,随着业务复杂度的增加,后续发现当MeetingState变为Idle的时候需要通知Fragment or Activity去退出,而此时Fragment退出的时候又会向MeetingService解除订阅,此时会产生并发修改异常。而这种业务场景却又是符合正常的逻辑的,所以我们希望能自定义一个数据结构来支持这种在遍历中准确的移除or添加Listener.

其次有些通知可能由一些比较复杂的业务模块或者由底层的Corelib团队发出来的,有时候他们会忘记切换线程在子线程通知到业务层,而业务层会在主线程去添加or移除这些Listener,此时会因为线程同步问题而出现一些并发修改问题。借鉴Android规定只有创建View的线程才能操作View以此来规避线程同步问题,我们也不想利用这个理念来规范对数据的并发修改。我们可以对这个数据结构做线程检查,在Debug build中发现有不同的线程在修改这些数据的话,我们可以抛出异常来尽早提示开发者这里可能有线程同步问题需要优化。

最后,由于新增了很多MeetingService(Rcv, Webinar, Sip, Phone, etc.) 这些Service包括其他一些组件都在使用观察者模式,即需要注册Listener,解除注册Listener,存取Listener,相关的UT, 这些都时重复的模板代码,我们利用Kotlin 委托(By)特性, 可以将这些模板代码委托给一个小的ListenerHolder组件来实现,这样就可以消除这些模板代码。

2.2.2.2 观察者模式优化方案
class ConcurrentWeakRefSet<E> {

    private val targetWeakRefElements = mutableSetOf<WeakReference<E>>()
    private val waitingAddElements = mutableSetOf<WeakReference<E>>()

    private val expectId = Thread.currentThread().id
    private val expectName = Thread.currentThread().name
    private var concurrentFlag = 0

    val size: Int
        get() = targetWeakRefElements.size

    fun add(element: E): Boolean {
        if (!checkThread()) return false

        val old = targetWeakRefElements.find { it.get() == element }
        if (old != null) {
            return false
        }
        return if (concurrentFlag == 0) {
            targetWeakRefElements.add(WeakReference(element))
        } else {
            waitingAddElements.add(WeakReference(element))
        }
    }
    
	private fun checkThread(): Boolean {
        if (!NEED_CHECK) return true

        val actualId = Thread.currentThread().id
        val actualName = Thread.currentThread().name
        if (actualId != expectId) {
            val message =
                "expectThread:$expectName($expectId), actualThread:$actualName($actualId)"
            if (!BuildConfig.DEBUG) {
                throw ConcurrentModificationException(message)
            } else {
                Log.e(TAG, message)
            }
            return false
        }
        return true
    }
}

我们自定义一个数据结构,创建的时候我们就会记录下当前的current thread id,当外部操作这个数据结构的时候我们就会检查当前操作的线程和创建的线程时不是同个线程,不是的话在debug build中抛出异常,在release build中给出exception log.
当往这个数据接口添加数据的时候,即添加一个Listener的时候,我们判断当前这个flag如果不需要处理并发修改的话就直接添加跟其他数据接口一样,但是如果需要处理的话,即这次添加时在上一次遍历中添加的,我们把它先添加到waitingAddElements。

private fun forEachWithIndex(action: (E) -> Unit, index: Int) {
        val size = targetWeakRefElements.size
        for (i in (index until size)) {
            val element = targetWeakRefElements.elementAt(i).get()
            element?.runCatch(action)
            if (waitingAddElements.isNotEmpty()) {
                targetWeakRefElements.addAll(waitingAddElements)
                waitingAddElements.clear()
                forEachWithIndex(action, i + 1)
                return
            }
        }
}

而在遍历的时候,我们取出来如果弱引用的对象还存在的话就执行这个闭包,且当执行闭包完毕以后,我们就会检查当前的waitingAddElements是否有待添加的Listener,如果有的话,我们添加到尾部,然后从当前这个位置重新发起遍历,这样就规避掉并发修改异常了。
再来看移除,如果当前不需要处理并发,只需要直接移除,如果要处理我们先把弱引用的对象置空,这样遍历的时候就获取不到这个Listener也就不会下发给这个Listener通知,然后在遍历结束的时候再移除这些无效的弱引用对象即可。

fun remove(element: E): Boolean {
        if (!checkThread()) return false

        return if (concurrentFlag == 0) {
            targetWeakRefElements.removeAll { it.get() == element }
        } else {
            targetWeakRefElements.forEach {
                if (it.get() == element) it.clear()
            }
            true
        }
    }

fun forEach(reversed: Boolean = false, action: (E) -> Unit) {
        if (!checkThreadSafely()) return

        concurrentFlag += 1
        if (reversed) {
            forEachWithReversed(action, 0)
        } else {
            forEachWithIndex(action, 0)
        }
        concurrentFlag -= 1
        if (concurrentFlag == 0) {
            targetWeakRefElements.removeAll { it.get() == null }
        }
}

借此我们解决了并发问题,并且我们将线程同步的潜在问题在开发阶段暴露出来给开发去识别和处理,提高了代码的健壮性。

消除模板代码问题我们用by interface的特性来把实现委托给ConcurrentListenerHolder

class ConcurrentListenerHolder<T> : IListenerHolder<T> {

    private val listeners = ConcurrentWeakRefSet<T>()

    override fun registerListener(listener: T): Boolean {
        return listeners.add(listener)
    }

    override fun unregisterListener(listener: T): Boolean {
        return listeners.remove(listener)
    }

    override fun clearListeners(): Boolean {
        return listeners.clear()
    }

    override fun notifyEachListener(reversed: Boolean, action: T.() -> Unit) {
        listeners.forEach(reversed, action)
    }
	
	override fun bindLifecycleListener(listener: T, lifecycle: Lifecycle, state: Lifecycle.State) {
        lifecycle.addObserver(ListenerLifecycle(listener, state))
        if (lifecycle.currentState.isAtLeast(state)) {
            listeners.add(listener)
        }
    }
}

而且我们还可以结合Lifecycle来实现自动解除订阅:

private inner class ListenerLifecycle(private val listener: T, private val targetState: Lifecycle.State) : DefaultLifecycleObserver {

        override fun onCreate(owner: LifecycleOwner) {
            if (targetState == Lifecycle.State.CREATED) {
                listeners.add(listener)
            }
        }

        override fun onStart(owner: LifecycleOwner) {
            if (targetState == Lifecycle.State.STARTED) {
                listeners.add(listener)
            }
        }

        override fun onResume(owner: LifecycleOwner) {
            if (targetState == Lifecycle.State.RESUMED) {
                listeners.add(listener)
            }
        }

        override fun onPause(owner: LifecycleOwner) {
            if (targetState == Lifecycle.State.RESUMED) {
                listeners.remove(listener)
            }
        }

        override fun onStop(owner: LifecycleOwner) {
            if (targetState == Lifecycle.State.STARTED) {
                listeners.remove(listener)
            }
        }

        override fun onDestroy(owner: LifecycleOwner) {
            listeners.remove(listener)
        }
    }

在被观察者处使用:

object RoomsMeetingConnector : IListenerHolder<RoomsMeetingConnector.Delegate> by ConcurrentListenerHolder() {
	fun joinMeetingWithOptions(
        meetingOptions: IRseJoinMeetingOptions,
        joinCallback: ActionCallback,
        newSolution: Boolean
    ) {
        // ...
		// 通知订阅者
        notifyEachListener {
            onHostStatusChange(HostMeetingStatus.CONNECTING, meetingType)
        }
    }
}

可以看到现在我们被观察者不再需要写这些重复的registerListener, unregisterListener,foreach等模板代码,也减少了UT cases,既使代码变得更简洁也提高了编码效率。

2.2.2.3 核心代码
  • com.ringcentral.rooms.common.utils.IListenerHolder
  • com/ringcentral/rooms/common/utils/ConcurrentListenerHolder.kt
  • com.ringcentral.rooms.common.utils.ConcurrentWeakRefSet
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值