录制视频,UI与Camera分离之UI的抽取

前言

Android 开发常见的录制视频的逻辑,平常我们用的最多的就是直接跳转到系统的视频 App 里面。让系统帮我们录制,这肯定是最方便效果最好的。

但是有些情况下我们就被需求限制了,例如需要双端统一UI,例如有些兼容性问题导致时长无法最大限制,有些时候我们难免就需要自定义录制视频的逻辑。

而网上一些的资源大多都是一些老的项目,页面与 Camera 逻辑耦合了,例如有的项目用的Camera1,有的用的Camera2, 有的用的之前的谷歌兼容库 CameraView 之类的,后面出了 CameraX 又如何与我们的录制视频页面绑定呢?又要重写一套,相对比较复杂。

所以就需要把 UI 与 Camera 逻辑分离出来,本文的 UI 效果是基于老版的微信录制页面仿制的

效果如下:

image.png

image.png

image.png

 

gif图片太大传不上来,大家应该能理解这样的效果,和微信比较类似。

分解之后我们需要做的步骤就分录制的按钮绘制与动画,集成整个控件与Camera的封装控件,录制完成之后的播放逻辑。

接下来我们一步步的往下走。

一、录制按钮

 

我们的录制按钮其实就是分为一个外圈,一个内圈,一个进度圆环三个东西。

默认的状态外圈与内圈相差5个dp,我们可以把整体的布局先测量并绘制出来:

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        if (measuredWidth == -1) {
            measuredWidth = getMeasuredWidth();

            radius1 = measuredWidth * zoom / 2;
            radius2 = measuredWidth * zoom / 2 - dp5;

            oval.left = dp5 / 2;
            oval.top = dp5 / 2;
            oval.right = measuredWidth - dp5 / 2;
            oval.bottom = measuredWidth - dp5 / 2;
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {

        //绘制外圈
        paint.setColor(colorGray);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius1, paint);
        //绘制内圈
        paint.setColor(Color.WHITE);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius2, paint);
        //绘制进度
        canvas.drawArc(oval, 270, girthPro, false, paintProgress);

    }

重点就是我们点击按钮的时候有放大的逻辑,完成录制有缩小的逻辑。所以我们需要定义动画,在动画的回调中根据缩放的值动态的设置外圈与内圈的两个 radius 。

 public void startAnim(float start, float end) {


        if (buttonAnim == null || !buttonAnim.isRunning()) {
            buttonAnim = ValueAnimator.ofFloat(start, end).setDuration(animTime);
            buttonAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    float value = (float) animation.getAnimatedValue();
                    radius1 = measuredWidth * (zoom + value) / 2;
                    radius2 = measuredWidth * (zoom - value) / 2 - dp5;


                    value = 1 - zoom - value;
                    oval.left = measuredWidth * value / 2 + dp5 / 2;
                    oval.top = measuredWidth * value / 2 + dp5 / 2;
                    oval.right = measuredWidth * (1 - value / 2) - dp5 / 2;
                    oval.bottom = measuredWidth * (1 - value / 2) - dp5 / 2;


                    invalidate();
                }
            });
            buttonAnim.start();
        }
    }

对于进度的绘制,我们是通过 setProgress 动态的设置当前的进度值,然后通过刷新实现进度的展示。

public void setProgress(float progress) {
    this.progress = progress;
    float ratio = progress / max;
    girthPro = 365 * ratio;

    postInvalidate();
}

它自己本身是不做动画与页面逻辑的,只是提供了方法供对方调用,由于我们之前复习过自定义 View 的绘制,所以这里代码逻辑并不复杂,全部代码如下:

public class RecordedButton extends View {

    private int measuredWidth = -1;
    private Paint paint;
    private int colorGray;
    private float radius1;
    private float radius2;
    private float zoom = 0.8f; //初始化缩放比例
    private int dp5;
    private Paint paintProgress;
    private int colorBlue;

    /**
     * 当前进度 以角度为单位
     */
    private float girthPro;
    private RectF oval;
    private int max;
    private int animTime = 400;   //动画执行的时间
    private Paint paintSplit;
    private boolean isDeleteMode;
    private Paint paintDelete;
    private ValueAnimator buttonAnim;
    private float progress;


    public RecordedButton(Context context) {
        super(context);
        init();
    }

    public RecordedButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RecordedButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {

        dp5 = (int) getResources().getDimension(R.dimen.d_5dp);
        colorGray = getResources().getColor(R.color.gray);
        colorBlue = getResources().getColor(R.color.picture_color_blue);

        paint = new Paint();
        paint.setAntiAlias(true);


        paintProgress = new Paint();
        paintProgress.setAntiAlias(true);
        paintProgress.setColor(colorBlue);
        paintProgress.setStrokeWidth(dp5);
        paintProgress.setStyle(Paint.Style.STROKE);


        paintSplit = new Paint();
        paintSplit.setAntiAlias(true);
        paintSplit.setColor(Color.WHITE);
        paintSplit.setStrokeWidth(dp5);
        paintSplit.setStyle(Paint.Style.STROKE);


        paintDelete = new Paint();
        paintDelete.setAntiAlias(true);
        paintDelete.setColor(Color.RED);
        paintDelete.setStrokeWidth(dp5);
        paintDelete.setStyle(Paint.Style.STROKE);

        //设置绘制大小
        oval = new RectF();
    }


    /**
     * 开始动画,按钮的展开和缩回
     */
    public void startAnim(float start, float end) {


        if (buttonAnim == null || !buttonAnim.isRunning()) {
            buttonAnim = ValueAnimator.ofFloat(start, end).setDuration(animTime);
            buttonAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    float value = (float) animation.getAnimatedValue();
                    radius1 = measuredWidth * (zoom + value) / 2;
                    radius2 = measuredWidth * (zoom - value) / 2 - dp5;


                    value = 1 - zoom - value;
                    oval.left = measuredWidth * value / 2 + dp5 / 2;
                    oval.top = measuredWidth * value / 2 + dp5 / 2;
                    oval.right = measuredWidth * (1 - value / 2) - dp5 / 2;
                    oval.bottom = measuredWidth * (1 - value / 2) - dp5 / 2;


                    invalidate();
                }
            });
            buttonAnim.start();
        }
    }


    /**
     * 设置最大进度
     */
    public void setMax(int max) {
        this.max = max;
    }

    /**
     * 设置进度
     */
    public void setProgress(float progress) {
        this.progress = progress;
        float ratio = progress / max;
        girthPro = 365 * ratio;

        postInvalidate();
    }

    /**
     * 清除残留的进度
     */
    public void clearProgress() {
        setProgress(0);
    }

    /**
     * 获取到当前按钮的动画
     */
    public ValueAnimator getButtonAnim() {
        return buttonAnim;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        if (measuredWidth == -1) {
            measuredWidth = getMeasuredWidth();

            radius1 = measuredWidth * zoom / 2;
            radius2 = measuredWidth * zoom / 2 - dp5;

            oval.left = dp5 / 2;
            oval.top = dp5 / 2;
            oval.right = measuredWidth - dp5 / 2;
            oval.bottom = measuredWidth - dp5 / 2;
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {

        //绘制外圈
        paint.setColor(colorGray);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius1, paint);
        //绘制内圈
        paint.setColor(Color.WHITE);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius2, paint);
        //绘制进度
        canvas.drawArc(oval, 270, girthPro, false, paintProgress);

    }
}

其中的一些属性都是固定的,后期我们也可以抽取出来作为可配置选项。

二、自定义View封装

对一些录制状态的判断,录制页面的展示,录制按钮的控制等逻辑,我们统一封装到一个单独的 View 中

我们大致定义的布局如下:

image.png

预览的布局如下:

image.png

在一个录制的页面我们分为几种状态,录制前,录制中,录制后。

录制前,我们要隐藏显示对应的布局,初始化各种资源,对录制按钮做监听

录制中,我们通过倒计时,通过定时刷新的操作来调用 Camera 来录制,手动的完成录制或者达到最大录制时长,就走到录制后逻辑。

录制后,我们需要隐藏显示对应布局,预览已录制的视频,并释放摄像头与录制的资源。

录制前:

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecorderVideoView, defStyle, 0);

        int mWidth = a.getInteger(R.styleable.RecorderVideoView_record_width, 320);// 默认320
        int mHeight = a.getInteger(R.styleable.RecorderVideoView_record_height, 240);// 默认240

        mRecordMaxTime = a.getInteger(R.styleable.RecorderVideoView_record_max_time, 10);// 默认为10秒

        a.recycle();

        //todo 设置自定义属性给CameraAction
        mCameraAction.setupCustomParams(mWidth, mHeight, mRecordMaxTime);

        /*
         * 自定义录像控件填充自定义的布局
         */
        LayoutInflater.from(context).inflate(R.layout.recorder_video_view, this);

        //找到其他的控件
        mVideoPlay = (MyVideoView) findViewById(R.id.vv_play);
        mRlbottom = (RelativeLayout) findViewById(R.id.rl_bottom);
        mIvfinish = (ImageView) findViewById(R.id.iv_finish);
        mIvclose = (ImageView) findViewById(R.id.iv_close);
        mShootBtn = (RecordedButton) findViewById(R.id.shoot_button);
        ViewGroup flCameraContrainer = findViewById(R.id.fl_camera_contrainer);

        // 初始化并添加Camera载体
        flCameraContrainer.addView(mCameraAction.initCamera(getContext()));

        createRecordDir();

        initListener();

然后我们需要对事件做监听,实现录制的逻辑:

private void initListener() {

    mShootBtn.setMax(mRecordMaxTime);

    mShootBtn.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {

            if (event.getAction() == MotionEvent.ACTION_DOWN) {

                mShootBtn.startAnim(0, 0.2f);
                mCurProgress = 0.5f;

                startRecord(new RecorderVideoView.OnRecordFinishListener() {
                    @Override
                    public void onRecordFinish() {
                        mHandler.sendEmptyMessage(1);
                    }
                });

            } else if (event.getAction() == MotionEvent.ACTION_UP) {

                if (getTimeCount() > 1)
                    mHandler.sendEmptyMessage(1);

                else {

                    /* 录制时间小于1秒 录制失败 并且删除保存的文件  */
                    if (getVecordFile() != null) {
                        getVecordFile().delete();
                    }

                    mHandler.postDelayed(new Runnable() {
                        @Override
                        public void run() {
                            mShootBtn.startAnim(0.2f, 0);
                        }
                    }, 400);

                    stop();

                    Toast.makeText(getContext(), "视频录制时间太短", Toast.LENGTH_SHORT).show();
                }
            }
            return true;
        }
    });


    /*  点击取消 恢复控件显示状态 删除文件 */
    mIvclose.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            mVideoPlay.stop();

            clearWindow();

            mShootBtn.clearProgress();
            mVideoPlay.setVisibility(View.GONE);

            mCameraAction.isShowCameraView(true);

            mRlbottom.setVisibility(View.GONE);
            mShootBtn.setVisibility(View.VISIBLE);

            getVecordFile().delete();
        }
    });


    /*  点击确认 录制完成 可以选择发送或者到另一个界面看视频 */
    mIvfinish.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Toast.makeText(getContext(), "录制完成,视频保存的地址:" + getVecordFile().toString(), Toast.LENGTH_SHORT).show();

            if (mCompleteListener != null) {
                mCompleteListener.onComplete();
            }
        }
    });

}

录制中:

首先我们定义一个 Handler 去触发状态,并且执行定时的一些操作:

 private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (msg.what == 1) {

                finishRecode();

            } else if (msg.what == 2) {

                mCurProgress += 0.016;

                mShootBtn.setProgress(mCurProgress);

                mHandler.sendEmptyMessageDelayed(2, 16);

            } else if (msg.what == 100) {

                //执行倒计时,计算已录制的时间
                mTimeCount++;

                if (mTimeCount >= mRecordMaxTime) {  // 达到指定时间,停止拍摄
                    mShootBtn.setProgress(mRecordMaxTime);

                    stop();

                    if (mOnRecordFinishListener != null) {
                        mOnRecordFinishListener.onRecordFinish();
                    }

                } else {
                    mHandler.sendEmptyMessageDelayed(100, 1000);
                }

            }
        }
    };

当我们开始录制的时候,调用 CameraAction接口 去录制视频,并且切换状态与开始倒计时:

public void startRecord(final OnRecordFinishListener onRecordFinishListener) {
    //设置监听
    this.mOnRecordFinishListener = onRecordFinishListener;

    //动画执行
    mHandler.sendEmptyMessage(2);

    // 录制时间记录
    mTimeCount = 0;
    mHandler.sendEmptyMessageDelayed(100, 1000);

    //  CameraAction调用录制
    mCameraAction.startCameraRecord();
}

此时就会回调到 setProgress 设置进度了。

录制后:

当我们手动的抬起手指,或者到达录制时间,我们切换为录制后的状态。展示隐藏布局,并且释放资源。

private void finishRecode() {

    stop();

    /*  录制完成显示 控制控件的显示和隐藏  */
    mVideoPlay.setVisibility(View.VISIBLE);

    // todo CameraAction是否展示预览页面
    mCameraAction.isShowCameraView(false);

    mRlbottom.setVisibility(View.VISIBLE);

    mShootBtn.startAnim(0.2f, 0);
    ValueAnimator anim = mShootBtn.getButtonAnim();

    if (anim != null && anim.isRunning()) {
        anim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mShootBtn.setVisibility(View.GONE);
            }
        });
    }

    //录制完成之后展示已经录制的路径下的视频文件
    mVideoPlay.setVideoPath(getVecordFile().toString());
    mVideoPlay.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
        @Override
        public void onPrepared(MediaPlayer mp) {
            mVideoPlay.setLooping(true);
            mVideoPlay.start();
        }
    });
    if (mVideoPlay.isPrepared()) {
        mVideoPlay.setLooping(true);
        mVideoPlay.start();
    }

}

释放的资源的操作:

/**
 * 停止拍摄
 */
public void stop() {
    mHandler.removeMessages(2);
    mHandler.removeMessages(100);

    mShootBtn.setProgress(0);

    stopRecord();
    releaseRecord();

    //todo CameraAction释放摄像头资源
    mCameraAction.releaseCamera();
}

/**
 * 停止录制
 */
public void stopRecord() {
    //todo CameraAction录制的相关控制
    mCameraAction.stopCameraRecord();
}

/**
 * 释放资源
 */
private void releaseRecord() {
    //todo CameraAction录制的相关控制
    mCameraAction.releaseCameraRecord();
}

/**
 * 销毁全部的资源
 */
public void destoryAll() {
    mShootBtn.clearProgress();
    mHandler.removeCallbacksAndMessages(null);
}

这样就完成了录制视频的UI逻辑,而具体的 Camera 的操作,我们可以通过接口的方式使用不同的策略来使用不同的 Camera API,例如我是使用的过时的 Camera1的Api。

interface ICameraAction {

    void setupCustomParams(int width ,int height  ,int recordMaxTime);

    void setOutFile(File file);

    File getOutFile();

    View initCamera(Context context);

    void initCameraRecord();

    void startCameraRecord();

    void stopCameraRecord();

    void releaseCameraRecord();

    void releaseCamera();

    void clearWindow();

    void isShowCameraView(boolean isVisible);
}

Camera1 的大致实现:

public class Camera1ActionImpl implements ICameraAction {

    @Override
    public View initCamera(Context context) {
        mSurfaceView = new SurfaceView(context);
        mSurfaceView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));

        mSurfaceHolder = mSurfaceView.getHolder();
        mSurfaceHolder.addCallback(new CustomCallBack());
        mSurfaceHolder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);

        return mSurfaceView;
    }


    private class CustomCallBack implements SurfaceHolder.Callback {

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            initCameraAndRecord();
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            releaseCamera();
        }
    }

private void initCameraAndRecord() {
        if (mCamera != null) {
            releaseCamera();
        }

        //打开摄像头
        try {
            mCamera = Camera.open();

        } catch (Exception e) {
            e.printStackTrace();
            releaseCamera();
        }
        if (mCamera == null)
            return;

        //设置摄像头参数
        setCameraParams();

        try {
            mCamera.setDisplayOrientation(90);   //设置拍摄方向为90度(竖屏)
            mCamera.setPreviewDisplay(mSurfaceHolder);
            mCamera.startPreview();
            mCamera.unlock();

            //摄像头参数设置完成之后,初始化录制API配置
            initCameraRecord();

        } catch (IllegalStateException e) {
            e.printStackTrace();
        } catch (RuntimeException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

}

我们就能把 UI 与 Camera 的逻辑分离,后期就可以替换为各种不同的 Camera 来实现。虽然实现相比比较简单,但具体代码还是太多,有兴趣可以查看文章末尾的源码查看。

 

三、展示已经录制的视频

录制完成之后我们就需要播放预览已经录制的视频,并让用户选择是重新录制还是确定完成。

播放视频的方式有很多,由于我们一般本地录制的视频都是 MP4 格式,所以使用原生的 VideoPlayer 或者 TextureView 都能简单快速的完成视频的预览。

例如我这里使用的 MediaPlayer + TextureView实现的视频预览,大致的代码如下:

public class MyVideoView extends TextureView implements TextureView.SurfaceTextureListener {

    private MediaPlayer mMediaPlayer = null;
    private SurfaceTexture mSurfaceHolder = null;

    public void openVideo(Uri uri) {
        if (uri == null || mSurfaceHolder == null || getContext() == null) {
            // not ready for playback just yet, will try again later
            if (mSurfaceHolder == null && uri != null) {
                mUri = uri;
            }
            return;
        }

        mUri = uri;
        mDuration = 0;

        Exception exception = null;
        try {
            if (mMediaPlayer == null) {
                mMediaPlayer = new MediaPlayer();
                mMediaPlayer.setOnPreparedListener(mPreparedListener);
                mMediaPlayer.setOnCompletionListener(mCompletionListener);
                mMediaPlayer.setOnErrorListener(mErrorListener);
                mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
                mMediaPlayer.setOnSeekCompleteListener(mSeekCompleteListener);
                //            mMediaPlayer.setScreenOnWhilePlaying(true);
                mMediaPlayer.setVolume(mVolumn, mVolumn);
                mMediaPlayer.setSurface(new Surface(mSurfaceHolder));
            } else {
                mMediaPlayer.reset();
            }
            mMediaPlayer.setDataSource(getContext(), uri);

            mMediaPlayer.prepareAsync();

            mCurrentState = STATE_PREPARING;
        } catch (IOException ex) {
            exception = ex;
        } catch (IllegalArgumentException ex) {
            exception = ex;
        } catch (Exception ex) {
            exception = ex;
        }
        if (exception != null) {
            exception.printStackTrace();
            mCurrentState = STATE_ERROR;
            if (mErrorListener != null)
                mErrorListener.onError(mMediaPlayer, MediaPlayer.MEDIA_ERROR_UNKNOWN, 0);
        }
    }
}

一般我们设置循环播放的话,一些暂停恢复等操作都是可不用的,只需要开始与停止即可:

public void start() {
    mTargetState = STATE_PLAYING;
    //可用状态{Prepared, Started, Paused, PlaybackCompleted}
    if (mMediaPlayer != null && (mCurrentState == STATE_PREPARED || mCurrentState == STATE_PAUSED || mCurrentState == STATE_PLAYING || mCurrentState == STATE_PLAYBACK_COMPLETED)) {
        try {
            if (!isPlaying())
                mMediaPlayer.start();
            mCurrentState = STATE_PLAYING;
            if (mOnPlayStateListener != null)
                mOnPlayStateListener.onStateChanged(true);
        } catch (IllegalStateException e) {
            tryAgain(e);
        } catch (Exception e) {
            tryAgain(e);
        }
    }
}

public void stop() {
    mTargetState = STATE_STOP;
    if (mMediaPlayer != null && (mCurrentState == STATE_PLAYING || mCurrentState == STATE_PAUSED)) {
        try {
            mMediaPlayer.stop();
            mCurrentState = STATE_STOP;
            if (mOnPlayStateListener != null)
                mOnPlayStateListener.onStateChanged(false);
        } catch (IllegalStateException e) {
            tryAgain(e);
        } catch (Exception e) {
            tryAgain(e);
        }
    }
}   

这样就能实现一个超简单的视频录制逻辑了。

后期我们还能把一些配置都抽取出来,一些图片资源也能抽取出来,对于闪光灯与切换前后摄像头等逻辑都能加上。

后记

本文的示例代码是基于Camera + SurfaceView + MediaRecorder 录制API完成的。

对于录制视频的方法有很多种,示例只是最简单的 MediaRecorder ,MidiaRecoder 本质上就是对 MediaCodec 的封装,它用起来确实方便,但是一些配置不是很方便更改,例如修改录制的提示音,不方便断点续录,等等有时候并不符合我们的要求。

那我们可以使用 CameraX 的录制也显得更方便,或者自己手动的使用 MediaCodec 生成视频流与音频流的编码格式,然后通过 MediaMuxer去封装格式为MP4。

甚至你觉得可以都可以用 ffmpeg 去编码音频与视频的编码格式,然后合成MP4。

甚至我们还能直接使用第三方的一些jar包实现特效/美颜录制。

可选择的太多了,所以我们第一步把 UI 逻辑分离出来之后,后期我们想要通过怎样的方式来实现视频录制都是很方便了的。

好了,关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。

作者:newki
链接:https://juejin.cn/post/7234795795215794237
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值