【六】Android MediaPlayer整体架构源码分析 -【start请求播放处理流程】【Part 8】【02】

承接上一章节分析:【六】Android MediaPlayer整体架构源码分析 -【start请求播放处理流程】【Part 8】【01】
本系列文章分析的安卓源码版本:【Android 10.0 版本】

推荐涉及到的知识点:
Binder机制实现原理:Android C++底层Binder通信机制原理分析总结【通俗易懂】
ALooper机制实现原理:Android native层媒体通信架构AHandler/ALooper机制实现源码分析
Binder异常关闭监听:Android native层DeathRecipient对关联进程(如相关Service服务进程)异常关闭通知事件的监听实现源码分析

【此章节小节编号就接着上一章节排列】
1.1.1、mCodec->findBufferByID(kPortIndexInput, bufferID)实现分析:
根据Buffer ID来查询对应Buffer,返回其对象指针,第三个参数index默认为NULL

// [frameworks/av/media/libstagefright/ACodec.cpp]
ACodec::BufferInfo *ACodec::findBufferByID(
        uint32_t portIndex, IOMX::buffer_id bufferID, ssize_t *index) {
    // 循环匹配对应端口队列Buffer
    for (size_t i = 0; i < mBuffers[portIndex].size(); ++i) {
        BufferInfo *info = &mBuffers[portIndex].editItemAt(i);

        if (info->mBufferID == bufferID) {
        	// ID匹配时
            if (index != NULL) {
            	// 若index指针不为空,则返回当前匹配到的Buffer在该队列中的index索引值
                *index = i;
            }
            // 返回
            return info;
        }
    }

    ALOGE("Could not find buffer with ID %u", bufferID);
    // 查询失败
    return NULL;
}

1.1.2、mCodec->mOMXNode->emptyBuffer(bufferID, info->mCodecData, flags, timeUs, info->mFenceFd)实现分析:
执行OMXNode请求清空(消耗)当前输入Buffer数据

// [frameworks/av/media/libstagefright/omx/OMXNodeInstance.cpp]
status_t OMXNodeInstance::emptyBuffer(
        buffer_id buffer, const OMXBuffer &omxBuffer,
        OMX_U32 flags, OMX_TICKS timestamp, int fenceFd) {
    // 加锁访问    	
    Mutex::Autolock autoLock(mLock);
    if (mHandle == NULL) {
        return DEAD_OBJECT;
    }

    // 该OMX Buffer类型
    // 备注:关于它此前分析过很多次,此处不再阐述
    switch (omxBuffer.mBufferType) {
    case OMXBuffer::kBufferTypePreset:
    	// 见1.1.2.1小节分析
        return emptyBuffer_l(
                buffer, omxBuffer.mRangeOffset, omxBuffer.mRangeLength,
                flags, timestamp, fenceFd);

    case OMXBuffer::kBufferTypeANWBuffer:
    	// 见1.1.2.2小节分析
        return emptyGraphicBuffer_l(
                buffer, omxBuffer.mGraphicBuffer, flags, timestamp, fenceFd);

    case OMXBuffer::kBufferTypeNativeHandle:
    	// 见1.1.2.3小节分析
        return emptyNativeHandleBuffer_l(
                buffer, omxBuffer.mNativeHandle, flags, timestamp, fenceFd);

    default:
        break;
    }

	// 其他类型返回失败
    return BAD_VALUE;
}

1.1.2.1、emptyBuffer_l(buffer, omxBuffer.mRangeOffset, omxBuffer.mRangeLength, flags, timestamp, fenceFd)实现分析:
清空(消耗)当前输入Buffer数据

// [frameworks/av/media/libstagefright/omx/OMXNodeInstance.cpp]
status_t OMXNodeInstance::emptyBuffer_l(
        IOMX::buffer_id buffer,
        OMX_U32 rangeOffset, OMX_U32 rangeLength,
        OMX_U32 flags, OMX_TICKS timestamp, int fenceFd) {

    // no emptybuffer if using input surface
    // 若使用的输入Surface,那么将不会处理该流程
    // 备注:关于它的作用前面章节已有分析,即简单说它是一种数据来源
    if (getBufferSource() != NULL) {
        android_errorWriteLog(0x534e4554, "29422020");
        return INVALID_OPERATION;
    }

    OMX_BUFFERHEADERTYPE *header = NULL;
    // findBufferHeader() 根据Buffer id查询对应的Buffer header头类型结构对象指针
    // 见此前已有分析
    // 这个bool值表示是否是额外数据Buffer,也就是说当前Buffer ID对应在【kPortIndexInputExtradata】
    // 额外数据输入端口队列中的header存储时,则为true,否则为false,关于额外数据输入端口阐述此前流程中已有分析过
    OMX_BOOL extradata_buffer = ((header = findBufferHeader(buffer, kPortIndexInput)) != NULL) ?
        OMX_FALSE : ((header = findBufferHeader(buffer, kPortIndexInputExtradata)) != NULL) ?
        OMX_TRUE : OMX_FALSE;
    if (header == NULL) {
    	// header为空则数据有误
        ALOGE("b/25884056");
        return BAD_VALUE;
    }
    // 强转为此前初始化缓存的OMXNode中程序私有Buffer数据类型
    BufferMeta *buffer_meta =
        static_cast<BufferMeta *>(header->pAppPrivate);
	
	// 注:设置适当的填充数据长度,如果组件配置为gralloc元数据模式,
	// 在这种情况下忽略数据访问范围偏移量rangeOffset(因为客户端可能会假设为ANW元数据Buffer类型)。
    // set up proper filled length if component is configured for gralloc metadata mode
    // ignore rangeOffset in this case (as client may be assuming ANW meta buffers).
    if (!extradata_buffer && mMetadataType[kPortIndexInput] == kMetadataBufferTypeGrallocSource) {
    	// 非额外数据Buffer并且元数据Buffer类型为【GrallocSource】时
    	// 设置有效的负载元数据大小
        header->nFilledLen = rangeLength ? sizeof(VideoGrallocMetadata) : 0;
        // 偏移量为0
        header->nOffset = 0;
    } else {
    	// 其他情况时
    	// 注:rangeLength和rangeOffset必须是缓冲区中已分配数据的子集。
    	// 极端情况:我们允许在rangeOffset为Buffer结尾处时rangeLength为0,因为已经没有可读有效buffer了。
        // rangeLength and rangeOffset must be a subset of the allocated data in the buffer.
        // corner case: we permit rangeOffset == end-of-buffer with rangeLength == 0.
        if (rangeOffset > header->nAllocLen
                || rangeLength > header->nAllocLen - rangeOffset) {
            // 极端情况,无有效可读数据时    
            CLOG_ERROR(emptyBuffer, OMX_ErrorBadParameter, FULL_BUFFER(NULL, header, fenceFd));
            if (fenceFd >= 0) {
            	// 关于Fence文件描述符
                ::close(fenceFd);
            }
            // 返回BAD参数错误码
            return BAD_VALUE;
        }
        // 有效可读数据存在时,设置数据长度和读取偏移量
        header->nFilledLen = rangeLength;
        header->nOffset = rangeOffset;

        // 拷贝该数据给OMX组件支持Buffer数据类型中
        // 见下面分析
        buffer_meta->CopyToOMX(header);
    }

    // 见下面分析
    return emptyBuffer_l(header, flags, timestamp, (intptr_t)buffer, fenceFd);
}

buffer_meta->CopyToOMX(header)实现分析:
拷贝该数据给OMX组件支持Buffer数据类型中

// [frameworks/av/media/libstagefright/omx/OMXNodeInstance.cpp]
    void CopyToOMX(const OMX_BUFFERHEADERTYPE *header) {
    	// 该标志参数作用:由前面分配Buffer时流程分析可知,只有在非metadata模式即非元数据Buffer模式
    	// 并且是非共享模式时才为true,否则默认为false即不需要拷贝转移数据
        if (!mCopyToOmx) {
            return;
        }
        // 拷贝转移数据给【header->pBuffer】字段缓存
		
		// 也就是说如果是非共享内存模式时,将会执行此处的拷贝转移数据处理

        // 计算需要拷贝的(有效)数据字节大小【从数据读取偏移量位置开始拷贝的】
        size_t bytesToCopy = header->nFlags & OMX_BUFFERFLAG_EXTRADATA ?
            header->nAllocLen - header->nOffset : header->nFilledLen;
        memcpy(header->pBuffer + header->nOffset,
                getPointer() + header->nOffset,
                bytesToCopy);
    }

emptyBuffer_l(header, flags, timestamp, (intptr_t)buffer, fenceFd)实现分析:
清空(消耗)当前输入Buffer数据

// [frameworks/av/media/libstagefright/omx/OMXNodeInstance.cpp]
status_t OMXNodeInstance::emptyBuffer_l(
        OMX_BUFFERHEADERTYPE *header, OMX_U32 flags, OMX_TICKS timestamp,
        intptr_t debugAddr, int fenceFd) {    
    header->nFlags = flags;
    // 当前输入Buffer的PTS时间戳
    header->nTimeStamp = timestamp;

    // 存储Fence信息在元数据中
    // 见此前已有分析
    status_t res = storeFenceInMeta_l(header, fenceFd, kPortIndexInput);
    if (res != OK) {
    	// 失败时
        CLOG_ERROR(emptyBuffer::storeFenceInMeta, res, WITH_STATS(
                FULL_BUFFER(debugAddr, header, fenceFd)));
        return res;
    }
    // 成功时

	// 加锁代码块,此处仅为debug代码块,因此可不关注
    {
        Mutex::Autolock _l(mDebugLock);
        mInputBuffersWithCodec.add(header);

        // bump internal-state debug level for 2 input frames past a buffer with CSD
        if ((flags & OMX_BUFFERFLAG_CODECCONFIG) != 0) {
            bumpDebugLevel_l(2 /* numInputBuffers */, 0 /* numOutputBuffers */);
        }

        CLOG_BUMPED_BUFFER(emptyBuffer, WITH_STATS(FULL_BUFFER(debugAddr, header, fenceFd)));
    }

    // 执行宏定义方法,调用底层具体组件的对应方法EmptyThisBuffer,请求其清空(消耗)当前输入Buffer数据
    // 国际惯例目前只举例分析具体组件实现结果
    // 举例SoftAVCDec实现结果:
    // 其实际组件的该方法是所有软编解码器组件父类统一接收处理的,它将在组件内部ALooper线程中执行该任务获取数据后进行编解码,
    // 而该过程其实和前面【Part 7】部分中分析的【OMX_FillThisBuffer】处理流程是相同的处理流程,
    // 而此处执行完毕后将会触发回调【*mCallbacks->EmptyBufferDone】即回调OMXNode中onEmptyBufferDone消耗输入Buffer完成事件方法被处理。
    // 该回调流程分析将会在【Part 10】部分分析
    OMX_ERRORTYPE err = OMX_EmptyThisBuffer(mHandle, header);
    // 检查是否发生错误,若错误则打印它,此处的err仅仅是调用方法的错误码,
    // 而非编解码结果错误码,也就是该命名安全到达底层组件将会返回成功码。
    CLOG_IF_ERROR(emptyBuffer, err, FULL_BUFFER(debugAddr, header, fenceFd));
	
	// debug加锁代码块,不再关注
    {
        Mutex::Autolock _l(mDebugLock);
        if (err != OMX_ErrorNone) {
            mInputBuffersWithCodec.remove(header);
        } else if (!(flags & OMX_BUFFERFLAG_CODECCONFIG)) {
            unbumpDebugLevel_l(kPortIndexInput);
        }
    }

    // 返回方法执行错误码
    return StatusFromOMXError(err);
}

1.1.2.2、emptyGraphicBuffer_l(buffer, omxBuffer.mGraphicBuffer, flags, timestamp, fenceFd)实现分析:
清空(消耗)当前输入Buffer数据
重要备注:此处必须理解这种关于mMetadataType不同元数据Buffer类型的作用,简单总结就是:
它使用新的Buffer数据结构类型来存储实际负载数据,也就是将数据类型转换后再递交给底层组件去处理。

// [frameworks/av/media/libstagefright/omx/OMXNodeInstance.cpp]

// 注:和上一个流程emptyBuffer处理相似,但是数据早已经存储于header->pBuffer字段中了,
// 也就是它不需要拷贝转移数据给【header->pBuffer】字段了
// like emptyBuffer, but the data is already in header->pBuffer
status_t OMXNodeInstance::emptyGraphicBuffer_l(
        IOMX::buffer_id buffer, const sp<GraphicBuffer> &graphicBuffer,
        OMX_U32 flags, OMX_TICKS timestamp, int fenceFd) {
    // 获取输入Buffer id对应的header对象指针    
    OMX_BUFFERHEADERTYPE *header = findBufferHeader(buffer, kPortIndexInput);
    if (header == NULL) {
        ALOGE("b/25884056");
        return BAD_VALUE;
    }

    // 更新GraphicBuffer图像Buffer数据到元数据对象中
    // 见前面已有分析
    status_t err = updateGraphicBufferInMeta_l(
            kPortIndexInput, graphicBuffer, buffer, header);
    if (err != OK) {
    	// 更新失败
        CLOG_ERROR(emptyGraphicBuffer, err, FULL_BUFFER(
                (intptr_t)header->pBuffer, header, fenceFd));
        return err;
    }
    // 更新成功时

    // 获取CTS即编解码时间戳
    // 见下面分析
    int64_t codecTimeUs = getCodecTimestamp(timestamp);

    header->nOffset = 0;
    if (graphicBuffer == NULL) {
    	// GraphicBuffer为空时,清除当前负载数据size为0,也就是说不需要访问它
        header->nFilledLen = 0;
    } else if (mMetadataType[kPortIndexInput] == kMetadataBufferTypeGrallocSource) {
    	// 不为空并且输入端口元数据类型为【GrallocSource】时,
    	// 将已填充数据size计算为负载的【VideoGrallocMetadata】元数据结构占用内存大小。
    	// 重要备注:此处必须理解这种关于mMetadataType不同元数据Buffer类型的作用,简单总结就是:
    	// 它使用新的Buffer数据结构类型来存储实际负载数据,也就是将数据类型转换后再递交给底层组件去处理。
        header->nFilledLen = sizeof(VideoGrallocMetadata);
    } else {
    	// 这种元数据类型存储数据时,转换成该结构内存大小
        header->nFilledLen = sizeof(VideoNativeMetadata);
    }
    // 见前面已有分析
    return emptyBuffer_l(header, flags, codecTimeUs, (intptr_t)header->pBuffer, fenceFd);
}

getCodecTimestamp(timestamp)实现分析:
获取CTS即编解码时间戳
注意传入的是该输入Buffer的PTS时间戳

// [frameworks/av/media/libstagefright/omx/OMXNodeInstance.cpp]
int64_t OMXNodeInstance::getCodecTimestamp(OMX_TICKS timestamp) {
	// 记录原始PTS时间戳
    int64_t originalTimeUs = timestamp;

	// 关于mMaxTimestampGapUs即帧编解码间隔最大时间戳此前流程中有分析和阐述过
	// 通常情况下该值为0,只有在码率控制设置的情况下才会重新计算更新它
    if (mMaxTimestampGapUs > 0LL) {
    	// 大于0时
    	// 注:将相邻帧之间的时间戳间隔限制到指定的最大值。
    	// 在投屏镜像的情况下,编码可能会被长时间暂停。
    	// 限制pts间隙以解决编码器的速率控制逻辑在长时间暂停后产生巨大帧的问题。
        /* Cap timestamp gap between adjacent frames to specified max
         *
         * In the scenario of cast mirroring, encoding could be suspended for
         * prolonged periods. Limiting the pts gap to workaround the problem
         * where encoder's rate control logic produces huge frames after a
         * long period of suspension.
         */
        if (mPrevOriginalTimeUs >= 0LL) {
        	// 缓存的此前原始PTS时间戳大于0时
        	// 计算gap间隔值
            int64_t timestampGapUs = originalTimeUs - mPrevOriginalTimeUs;
            // 取小的gap值,并加上此前已修改的PTS时间戳为新的PTS时间戳
            timestamp = (timestampGapUs < mMaxTimestampGapUs ?
                timestampGapUs : mMaxTimestampGapUs) + mPrevModifiedTimeUs;
        }
        ALOGV("IN  timestamp: %lld -> %lld",
            static_cast<long long>(originalTimeUs),
            static_cast<long long>(timestamp));
    } else if (mMaxTimestampGapUs < 0LL) {
    	// 小于0时
    	// 注:在相邻帧之间应用固定的时间戳间隔。
    	// 这用于静态图像捕获,帧上的时间戳可以向前或向后。
    	// 一些编码器可能会在帧倒退(甚至保持不变)时悄悄地删除帧。
        /*
         * Apply a fixed timestamp gap between adjacent frames.
         *
         * This is used by scenarios like still image capture where timestamps
         * on frames could go forward or backward. Some encoders may silently
         * drop frames when it goes backward (or even stay unchanged).
         */
        if (mPrevOriginalTimeUs >= 0LL) {
        	// 大于0时
        	// 更新值为已修改的PTS时间戳加上该gap值
            timestamp = mPrevModifiedTimeUs - mMaxTimestampGapUs;
        }
        ALOGV("IN  timestamp: %lld -> %lld",
            static_cast<long long>(originalTimeUs),
            static_cast<long long>(timestamp));
    }

    // 再次更新这两个PTS值,一个记录原始PTS值,一个记录修改后适合的PTS值
    mPrevOriginalTimeUs = originalTimeUs;
    mPrevModifiedTimeUs = timestamp;

    if (mMaxTimestampGapUs != 0LL && !mRestorePtsFailed) {
    	// gap不为空,并且存储PTS成功时
    	// 将这两个值存储到该原始PTS队列中
        mOriginalTimeUs.add(timestamp, originalTimeUs);
    }

    return timestamp;
}

1.1.2.3、emptyNativeHandleBuffer_l( buffer, omxBuffer.mNativeHandle, flags, timestamp, fenceFd)实现分析:
清空(消耗)当前输入Buffer数据

// [frameworks/av/media/libstagefright/omx/OMXNodeInstance.cpp]
status_t OMXNodeInstance::emptyNativeHandleBuffer_l(
        IOMX::buffer_id buffer, const sp<NativeHandle> &nativeHandle,
        OMX_U32 flags, OMX_TICKS timestamp, int fenceFd) {
    // 查询header对象指针    
    OMX_BUFFERHEADERTYPE *header = findBufferHeader(buffer, kPortIndexInput);
    if (header == NULL) {
        ALOGE("b/25884056");
        return BAD_VALUE;
    }

    // 更新native数据访问句柄指针在元数据中
    // 备注:见此前流程已有分析,该流程实际和上面的【updateGraphicBufferInMeta_l】流程类似的。这样总好理解了吧。
    status_t err = updateNativeHandleInMeta_l(
            kPortIndexInput, nativeHandle, buffer, header);
    if (err != OK) {
    	// 失败
        CLOG_ERROR(emptyNativeHandleBuffer_l, err, FULL_BUFFER(
                (intptr_t)header->pBuffer, header, fenceFd));
        return err;
    }
	
	// 因此和前面元数据类型转换时一样处理,
	// 必须要从新计算header此时真正负载数据类型【VideoNativeMetadata】的大小和可访问偏移量
    header->nOffset = 0;
    header->nFilledLen = (nativeHandle == NULL) ? 0 : sizeof(VideoNativeMetadata);
	
    // 见前面已有分析
    return emptyBuffer_l(header, flags, timestamp, (intptr_t)header->pBuffer, fenceFd);
}

1.1.3、getMoreInputDataIfPossible()实现分析:
可能的话尝试获取更多输入Buffer

// [frameworks/av/media/libstagefright/ACodec.cpp]
void ACodec::BaseState::getMoreInputDataIfPossible() {
    if (mCodec->mPortEOS[kPortIndexInput]) {
    	// 当前输入端口队列已EOS时,不再请求
        return;
    }

    BufferInfo *eligible = NULL;

    // 循环遍历输入端口队列
    for (size_t i = 0; i < mCodec->mBuffers[kPortIndexInput].size(); ++i) {
        BufferInfo *info = &mCodec->mBuffers[kPortIndexInput].editItemAt(i);

#if 0
// 此代码块不会执行
		// 这个判断就是当前该输入Buffer已经递交给了数据源Client了,不需要再次请求
        if (info->mStatus == BufferInfo::OWNED_BY_UPSTREAM) {
            // There's already a "read" pending.
            return;
        }
#endif

        if (info->mStatus == BufferInfo::OWNED_BY_US) {
        	// 找到属于ACodec自身拥有权的合适有效的输入Buffer
            eligible = info;
        }
    }
    // 备注:上面for循环处理有点奇怪,它在第一次找到合适的输入Buffer时并没有立即停止循环,
    // 而是一直循环查找,最终的效果就是:找到的Buffer一定是队列最后一个拥有使用权【OWNED_BY_US】的输入Buffer

    if (eligible == NULL) {
    	// 没有找到时,即可能当前所有输入Buffer都已经递交给Client端去填充数据了
        return;
    }

    // 否则,请求填充该输入Buffer
    // 见此前分析
    postFillThisBuffer(eligible);
}

1.2、statsBufferSent(timeUs)实现分析:
统计已发送给Codec的Buffer

// [frameworks/av/media/libstagefright/MediaCodec.cpp]

// when we send a buffer to the codec;
void MediaCodec::statsBufferSent(int64_t presentationUs) {

    // only enqueue if we have a legitimate time
    // 仅处理添加有效PTS媒体显示时间戳
    if (presentationUs <= 0) {
        ALOGV("presentation time: %" PRId64, presentationUs);
        return;
    }

    if (mBatteryChecker != nullptr) {
    	// 早前分析过,是视频数据源时电池检查器对象不会为空
    	
    	// 此处将会执行编解码器活动方法回调,处理原理为:【不展开详细分析】
    	// 执行addResource方法即添加kBattery媒体资源类型
    	// 到系统资源管理服务ResourceManagerService去管理它。
    	// 但是注意,该方法内部也将会延迟3秒执行【kWhatCheckBatteryStats】事件消息,
    	// 然后将会执行removeResource,也就消除了addResource中的操作。
        mBatteryChecker->onCodecActivity([this] () {
        	// 该方法早前有阐述过
            addResource(MediaResource::kBattery, MediaResource::kVideoCodec, 1);
        });
    }

    // 获取本次系统已开机时长,单位纳秒
    const int64_t nowNs = systemTime(SYSTEM_TIME_MONOTONIC);
    // 当前Buffer数据"飞行"定时时间(延迟显示时长)
    // 该结构见下面定义
    BufferFlightTiming_t startdata = { presentationUs, nowNs };

	// 加锁代码块
    {
        // 延迟锁加锁访问
        // mutex access to mBuffersInFlight and other stats
        Mutex::Autolock al(mLatencyLock);


        // 注:作为一致性检查的一部分,我们【可以】确保该时间晚于队列结束时间……
        // XXX: we *could* make sure that the time is later than the end of queue
        // as part of a consistency check...
        mBuffersInFlight.push_back(startdata);
    }
}

BufferFlightTiming_t 声明定义:
当前Buffer数据"飞行"定时时间(延迟显示时长)
讲述一下该结构的实际作用:
也就是说它记录当前已填充输入Buffer刚添加到输入端口队列时的系统时间戳和当前Buffer即当前帧PTS(媒体)显示时间戳,用于在解码器输出解码帧Buffer时进行对比判断,即判断是否匹配上了或者延迟了则丢弃它等处理,后续会分析到。

// [frameworks/av/media/libstagefright/media/stagefright/MediaCodec.h]

    // 管理飞行时间,也就是延迟时长
    // managing time-of-flight aka latency
    typedef struct {
    		// PTS显示时间戳
            int64_t presentationUs;
            // 当前Buffer已开始时长
            int64_t startedNs;
    } BufferFlightTiming_t;

结束语

在上面的流程完毕后,就可知晓,底层编解码器组件在接收到输入输出端口Buffer(输入Buffer递交给组件流程此前已分析)后,将会执行【*mCallbacks->EmptyBufferDone】和【*mCallbacks->FillBufferDone】回调,即将会触发回调OMXNode中回调监听类【OMX_CALLBACKTYPE OMXNodeInstance::kCallbacks】的onEmptyBufferDone消耗输入Buffer完成事件方法被执行和onFillBufferDone填充输出buffer完毕事件方法被执行。
也就是说此时我们终于完成了编解码器获取数据和输出数据的完整过程了!
接下来分析的这两个底层组件消耗输入buffer或填充输出buffer完成事件回调流程分析将会在【Part 10】部分分析

本章结束

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值