Android RTSP H264

RTSP/RTP/RTCP的区别:

RTSP: 客户端和服务器进行信息交流,例如客户端询问服务器支持哪些方法、协议、sps、pps等等,沟通以什么方式建立连接,是否要开始发送数据。

RTP: 服务器以约定好的格式往客户端进行发送封装好的数据;协议提供了时间戳和序列号,发送端在采样时设置时间戳,接收端收到后会按照时间戳依次播放。

RTCP: 当客户端发现RTP丢包的时候,可以通过约定好的格式往服务器发送数据,要求服务器重发数据;UDP是单向通信的,但是这样一来,RTP + RTCP 其实相当于建立起了一个双向对话的机制。

客户端和服务器进行RTSP协议的交互过程:

1、OPTIONS(可选)
//询问服务器端有哪些方法可使用
RTSP客户端 —> RTSP服务器端 OPTIONS命令

//回复客户端服务器支持的方法
RTSP服务器端 —> RTSP客户端 回复OPTIONS命令

2、DESCRIBE (可选)
// 请求对某个媒体资源(Live555中用ServerMediaSession表示)的描述信息
RTSP客户端 —> RTSP服务器端 DESCRIBE命令

//回复客户端某个媒体资源的描述信息(即SDP)
RTSP服务器端 —> RTSP客户端   回复DESCRIBE命令 

3、SETUP(必选)
//请求建立对某个媒体资源的连接
RTSP客户端 —> RTSP服务器端  SETUP命令

//回复建立连接的结果    
RTSP服务器端 —> RTSP客户端   回复SETUP命令 
  
4、PLAY(必选)
//请求播放媒体资源
RTSP客户端 —> RTSP服务器端  PLAY命令   

//回复播放的结果   
RTSP服务器端 —> RTSP客户端  回复PLAY命令  

5、此外还有一些交互方法,请自行查阅 例如TEARDOWN、PAUSE、SCALE、 GET_PARAMETER、SET_PARAMETER   

如果要测试rtsp的解码,可用以下软件:
采集、推流: EasyPusher (商业需要授权)
流媒体服务器 EasyDarwin (完全免费)
测试播放: VLC (完全免费)
相关概念:
网络抖动: 是指最大延迟与最小延迟的时间差,如最大延迟是20毫秒,最小延迟为5毫秒,那么网络抖动就是15毫秒,它主要标识一个网络的稳定性。
MTU: Maximum Transmission Unit 。大部分设备的是1500byte。如果本机的MTU比网关的MTU大,大的数据包就会被拆开来传送,这样会产生很多数据包碎片,增加丢包率,降低网络速度。把本机的MTU设成比网关的MTU小或相同,就可以减少丢包。
I帧 : 关键帧,可以理解为这一帧画面的完整保留,解码时只需要本帧数据就可以完成 ,数据量较大。
P帧: 表示的是这一帧跟之前的一个关键帧(或P帧)的差别,解码时需要用之前缓存的画面叠加上本帧定义的差别,生成最终画面。(也就是差别帧,P帧没有完整画面数据,只有与前一帧的画面差别的数据)。
B帧: 是双向差别帧,也就是B帧记录的是本帧与前后帧的差别(具体比较复杂,有4种情况),换言之,要解码B帧,不仅要取得之前的缓存画面,还要解码之后的画面,通过前后画面的与本帧数据的叠加取得最终的画面。B帧压缩率高,但是解码耗资源。
RTP封包情况:
1、单个NAL包单元:对于 NALU 的长度小于 MTU 大小的包, 一般采用单一 NAL 单元模式;
2、组合封包模式:当 NALU 的长度特别小时, 可以把几个 NALU 单元封在一个 RTP 包中(这种情况用的少);
3、FU-A的分片格式:而当 NALU 的长度超过 MTU 时, 就必须对 NALU 单元进行分片封包,也称为 Fragmentation Units (FUs),现存两个版本FU-A,FU-B。

h264常用的帧类型:

类型说明
00 00 00 01 06增强帧
00 00 00 01 4161和41其实都是P帧(type值为1),只是重要级别不一样
00 00 00 01 61P帧
00 00 00 01 65IDR 、I帧
00 00 00 01 67SPS
00 00 00 01 68PPS

RTP Header详细解析及定义参考文章

本人项目中用到的是:单个NAL包单元封装+ FUA分片封装模式

1、客户端每次从服务器接收一个RTP包 ,RTP头 + 数据data;
2、根据data[0] 判断是什么封包模式;
3、如果是单个NAL就直接判断拿到整个NALU;如果是FU_A模式就根据data[1]判断是当前NALU单元是否开始、结束还是中间;如果是开头就需要在开始接收前手动添加当前NALU类型(rtpPackage.data[0]& 0xE0)|(rtpPackage.data[1]&0x1F) -->最后获得F 、NRI 、Type 这三个信息 ,F可以忽略, NRI标记这个NALU的重要性 ,Type标记帧类型);
4、最后获得整个NAUL后 在前面00 00 00 01 传给解码器解码。

关键代码:

RtspClient.java

public class RtspClient
{
    private final String TAG="RtspClient" ;
    /**是否是tcp方式*/
    private boolean isTCPtranslate;
    private String hostIp;
    private int port;
    private String rtspUrl ;
    /**rtsp版本号,必须是1.0? 还不确定!*/
    private static final String VERSION = " RTSP1.0\r\n";
    /**rtsp服务器正确反应标记*/
    private static final String RTSP_OK = "RTSP/1.0 200 OK";
    /**请求回应对的序列*/
    private int seq=1;
    /**会话 Session在setup请求后返回  */
    private String sessionid=null;
    /**trackID 在 请求Descrbe 之后返回*/
    private String trackInfo;
    /**转换账号和密码*/
    private String authorBase64;
    /**客户端代理名称随意填*/
    private final static String UserAgent = "RtspWalkera/1.0";
    private Socket mSocket;
    private BufferedReader mBufferreader;
    private OutputStream mOutputStream;
    /**
     * 构造函数 , 进行参数初始化
     * @param isTCPtranslate tcp方式还是 udp方式
     * @param hostIp  服务器ip
     * @param port  服务器端口
     * @param rtspUrl  rtsp播放地址
     * @param authorName 登录账号
     * @param authorPassword 登录密码
     */
    public RtspClient(boolean isTCPtranslate, String hostIp,int port, String rtspUrl ,String authorName,String authorPassword)
    {
        this.isTCPtranslate = isTCPtranslate;
        this.rtspUrl = rtspUrl;
        this.hostIp = hostIp ;
        this.port = port;
        if(authorName == null && authorPassword == null) {
            authorBase64 = null;
        }  else {
            // 返回Base64编码过的字节数组字符串
            authorBase64 = Base64.encodeToString((authorName+":"+authorPassword).getBytes(),Base64.DEFAULT);
        }
    }

    private String addHeaders()
    {
        return "CSeq: " + (seq++) + "\r\n"
                + ((authorBase64 == null)?"":("Authorization: Basic " +authorBase64 +"\r\n"))
                + "User-Agent: " + UserAgent + "\r\n"
                + ((sessionid == null)?"":("Session: " + sessionid + "\r\n"))
                + "\r\n";
    }


    /**
     * option请求 <br>
     *     此步骤非必须
     */
    public void doOption()
    {
        StringBuilder sb = new StringBuilder();
        sb.append("OPTIONS ");
        sb.append(this.rtspUrl.substring(0, rtspUrl.lastIndexOf("/")));
        sb.append(VERSION);
        sb.append(addHeaders());

        try
        {
            outQueue.put(sb.toString());
        }catch (Exception e)
        {
        }
    }

    /**
     *  Describe 请求<br>
     *  sps 和 pps 会在这个阶段返回
     *  sprop-parameter-sets= Z0IAKpY1QMgEvTcBAQEC,aM48gA==
     *  将  sprop-parameter-sets 对应的值用base64 解码即可 67 和68
     *  分别对应sps 和pps . <br>
     *      不过有的rtsp服务器如果没有按标准写的话 ,sprop-parameter-sets 返回的值可能为空。
     *      此时 如果想获取sps 和pps 只能通过rtsp服务器返回的 帧数据中去获取了。
     */
    public void doDescribe()
    {
        StringBuilder sb = new StringBuilder();
        sb.append("DESCRIBE ");
        sb.append(this.rtspUrl);
        sb.append(VERSION);
        sb.append(addHeaders());
        try
        {
            outQueue.put(sb.toString());
        }catch (Exception e)
        {
        }
    }

    /**
     * 请求setup 。 <br>
     * RTP/AVP/TCP : RTP采用TCP方式 <br>
     * unicast表示单播<br>
     * client_port=55640-55641 :前一个是RTP端口 ,后一个是RTCP端口<br>
     *
     */
    public void doSetup( )
    {
        StringBuilder sb = new StringBuilder();
        if ( isTCPtranslate ) {
            sb.append("SETUP ");
            sb.append(this.rtspUrl);
            sb.append("/");
            sb.append(trackInfo);
            sb.append(VERSION);
            sb.append("Transport: RTP/AVP/TCP;unicast;client_port=55640-55641" + "\r\n");
            sb.append(addHeaders());
        } else {

            sb.append("SETUP ");
            sb.append(this.rtspUrl);
            sb.append("/");
            sb.append(trackInfo);
            sb.append(VERSION);
            sb.append("Transport: RTP/AVP/UDP;unicast;client_port=55640-55641" + "\r\n");
            sb.append(addHeaders());
        }
        try
        {
            outQueue.put(sb.toString());
        }catch (Exception e)
        {
        }

    }

    /**
     * play 请求
     */
    public void doPlay()
    {
        StringBuilder sb = new StringBuilder();
        sb.append("PLAY ");
        sb.append(this.rtspUrl);
        sb.append(VERSION);
        //设置播放的时间范围
        sb.append("Range: npt=0.000-\r\n");
        sb.append(addHeaders()) ;

        try
        {
            outQueue.put(sb.toString());
        }catch (Exception e)
        {
        }

    }

    /**
     * Pause 暂停请求
     */
    public void doPause() {
        StringBuilder sb = new StringBuilder();
        sb.append("PAUSE ");
        sb.append(this.rtspUrl);
        sb.append("/");
        sb.append(VERSION);
        sb.append(addHeaders()) ;
        try
        {
            outQueue.put(sb.toString());
        }catch (Exception e)
        {
        }
    }

    /**
     * 关闭连接
     */
    public void doTeardown()
    {
        StringBuilder sb = new StringBuilder();
        sb.append("TEARDOWN ");
        sb.append(this.rtspUrl);
        sb.append("/");
        sb.append(VERSION);
        sb.append(addHeaders()) ;
        try
        {
            outQueue.put(sb.toString());
        }catch (Exception e)
        {
        }
    }


    /**
     * 往服务器发送数据
     */
    private void sendBytes(String questStr)
    {

        try
        {
            byte[] buffer= questStr.getBytes("UTF-8");
            mOutputStream.write(buffer);
            mOutputStream.flush();
            Log.i(TAG ,"请求服务器:"+questStr +" "+outQueue.size());
        }catch (Exception e)
        {
            Log.i(TAG ,"请求服务器异常:"+e.getMessage());
        }
    }

    private boolean isStopReceive =false ;
    /***
     * rtsp 接收子线程
     */
    private class ReceiveThread extends Thread
    {

        @Override
        public void run()
        {
            while(!isStopReceive)
            {
                try
                {
                    if(mBufferreader!=null)
                    {
                        String line  ;
                        if( (line = mBufferreader.readLine()) != null)
                        {
                            Log.i(TAG ,"收到服务器返回=" + line);
                        }else {
                            Log.i(TAG ,"收到服务器返回 line==null" );
                        }
                    }
                    //休眠一段时间
                    Thread.sleep(60);
                }catch (Exception e)
                {
                    Log.i(TAG ,"接收服务器异常" +e.getLocalizedMessage());
                }
            }
        }
    }

    private LinkedBlockingDeque<byte[]> rtpDataBuffer = new LinkedBlockingDeque<>();
    private DatagramSocket mUdpSocket;
    private DatagramPacket mUdpPackets;
    int udpPort = 55640; //55640- 55641
    private byte[] message = new byte[2048];
    /***
     * upd rtp 接收子线程
     */
    private class ReceiveRTPUdpThread extends Thread
    {

        @Override
        public void run()
        {
            while(!isStopReceive)
            {
                try
                {
                    if(mUdpSocket==null)
                    {
                        mUdpSocket = new DatagramSocket(udpPort);
                        mUdpPackets = new DatagramPacket(message,message.length);
                    }else{
                        mUdpSocket.receive(mUdpPackets);
                        byte[] buffer = new byte[mUdpPackets.getLength()];
                        System.arraycopy(mUdpPackets.getData(), 0, buffer, 0, mUdpPackets.getLength());
                        try {
                            rtpDataBuffer.put(buffer);
                        } catch (InterruptedException e) {
                            Log.e(TAG,"The buffer queue is full , wait for the place..");
                        }

                    }

                    //休眠一段时间
                    Thread.sleep(0);
                }catch (Exception e)
                {
                    Log.i(TAG ,"udp接收服务器异常" +e.getLocalizedMessage());
                }
            }
        }
    }

    public byte[]getRtpData()
    {
        byte[] nalu =null;
        if(rtpDataBuffer !=null && rtpDataBuffer.size()>0)
        {
            try {
                nalu = rtpDataBuffer.take();
            }catch (Exception e)
            {

            }

        }
        return  nalu ;
    }

    /**发送队列*/
    private BlockingQueue<String> outQueue = new LinkedBlockingQueue< >(Integer.MAX_VALUE);;
    /**
     * 发送子线程
     */
    private class SendThread extends  Thread
    {

        @Override
        public void run()
        {
            while(!isStopReceive)
            {
                try
                {
                    String buffer  = outQueue.poll();
                    if (buffer != null)
                    {
                        sendBytes(buffer);
                    }
                    //休眠一段时间
                    Thread.sleep(60);
                }catch (Exception e)
                {

                }
            }
        }
    }

    /**
     * 开启rtsp客户端
     */
    public void openRtspCline()
    {
        isStopReceive = false ;

        Thread  thread = new Thread(new Runnable() {
            @Override
            public void run() {
                try{
                    mSocket = new Socket(hostIp, port);
                    mBufferreader = new BufferedReader(new InputStreamReader(mSocket.getInputStream()));
                    mOutputStream = mSocket.getOutputStream();
                }catch (IOException e)
                {
                    Log.i(TAG,"创建socket出现异常="+e.getLocalizedMessage()) ;
                }
            }
        });
        thread.start();

        ReceiveThread receiveThread = new ReceiveThread();
        receiveThread.start();
        SendThread sendThread = new SendThread() ;
        sendThread.start();

        ReceiveRTPUdpThread receiveRTPUdpThread = new ReceiveRTPUdpThread();
        receiveRTPUdpThread.start();
    }

    /**
     * 关闭rtsp客户端
     */
    public void shutDownRtspClient()
    {
        isStopReceive = true ;
    }
}

RtpPackage.java

/**
 *  RTP 数据包 结构
 */
public class RtpPackage
{
    /**版本是 2*/
    public int V ;
    /**填充标志*/
    public int P ;
    /**拓展标志*/
    public int X ;
    /**csrc计数器*/
    public int CC ;
    /**音频、视频标记*/
    public int M ;
    /**多媒体的类型 H.264的类型为96*/
    public int PT;
    /**序列号*/
    public int sequenceNumber;
    /**时间戳*/
    public long timestamp;
    /**ssrc*/
    public long ssrcIdentifier;
    /**csrc列表*/
    public int csrcIdentiferList ;
    /**rtp 包数据*/
    public byte[] data;
}

RTP2NaluTools.java

public   class RTP2NaluTools
{
    private final String TAG="RTP2NaluTools" ;
    private final static int NAL_UNIT_TYPE_STAP_A = 24;
    private final static int NAL_UNIT_TYPE_STAP_B = 25;
    private final static int NAL_UNIT_TYPE_MTAP16 = 26;
    private final static int NAL_UNIT_TYPE_MTAP24 = 27;
    private final static int NAL_UNIT_TYPE_FU_A = 28;
    private final static int NAL_UNIT_TYPE_FU_B = 29;
    /**一帧数据(一个NALU)*/
    private byte[] NALUnit;
    /**NALU结束标记*/
    private boolean NALEndFlag;
    /**临时变量 buffer[i] 存储每一片数据 */
    private byte[][] buffer = new byte[1024][];
    /**当前已经接收的NALU的数据长度*/
    private int bufferLength;
    /**当前已经接收的数据片数量*/
    private int packetNum;
    public RTP2NaluTools()
    {
        NALEndFlag = false;
        bufferLength= 0;
        packetNum =0;
    }

    /**
     * 将 rtp包 组成 NALU 格式的数据 <br>
     *     返回一帧H264数据(一个NALU)
     */
    public byte[] rtpPackage2NALU(byte[] rtpData)
    {
        if(rtpData.length == 0)
        {
            return null ;
        }
        RtpPackage rtpPackage = new RtpPackage();
        rtpPackage.V=(rtpData[0]&0xFF)>>6 ;
        rtpPackage.PT = rtpData[1] & 0x7F;
        rtpPackage.data = new byte[rtpData.length-12];
        System.arraycopy(rtpData, 12, rtpPackage.data, 0, rtpData.length - 12);
        if( (rtpPackage.V != 2)&&(rtpPackage.PT!=96) || rtpPackage.data.length <2 )
        {
            return null;
        }
        // rtp封包方式,单一封包、组合封包、分片封包等 0x1F -> 0001 1111
        int fuIndicatorTypeFlag = rtpPackage.data[0] & 0x1F;
        // FU head中的 S、E标记 用来判断NALU的开始、结束 0xCO ->  1100 0000  取S、E
        int fuHeadSEFlag = rtpPackage.data[1] & 0xC0;

        switch (fuIndicatorTypeFlag)
        {
            case NAL_UNIT_TYPE_STAP_A:
                break;
            case NAL_UNIT_TYPE_STAP_B:
                break;
            case NAL_UNIT_TYPE_MTAP16:
                break;
            case NAL_UNIT_TYPE_MTAP24:
                break;
            case NAL_UNIT_TYPE_FU_B:
                break;
            case NAL_UNIT_TYPE_FU_A:
                switch (fuHeadSEFlag)
                {
                    //NAL Unit start packet 0x80-> 1000 0000
                    case 0x80:
                        NALEndFlag = false;
                        bufferLength = rtpPackage.data.length-1 ;
                        buffer[0] = new byte[rtpPackage.data.length-1];
                        /*
                        每一帧开始的时候要加上表示这个帧类型的信息。
                        0xE0 ->1110 0000    0x1F -> 0001 1111
                        取FU indicator的前三位和FU Header的后五位。
                        最后这个byte包含了  F 、NRI 、Type 这三个信息
                        F可以忽略, NRI标记这个NALU的重要性 ,Type标记帧类型
                        buffer[0][0] 帧类型数据 41、61、65、67、68等
                         */
                        buffer[0][0] = (byte)((rtpPackage.data[0] & 0xE0)|(rtpPackage.data[1]&0x1F));
                        System.arraycopy(rtpPackage.data,2,buffer[0],1,rtpPackage.data.length-2);
                        packetNum = 1;
                        break;
                    //NAL Unit middle packet   0x00->0000 0000
                    case 0x00:
                        NALEndFlag = false;
                        bufferLength += rtpPackage.data.length-2;
                        buffer[packetNum] = new byte[rtpPackage.data.length-2];
                        System.arraycopy(rtpPackage.data,2,buffer[packetNum],0,rtpPackage.data.length-2);
                        packetNum++;
                        break;
                    //NAL Unit end packet  0x40 -->0100 0000
                    case 0x40:
                        NALEndFlag = true;
                        //+4 是因为多了 00 00 00 01
                        NALUnit = new byte[bufferLength + rtpPackage.data.length- 2 + 4];
                        //因为打包发送的时候是去掉了00 01这种标记位的,为了处理后续的H264,需要手动加上
                        NALUnit[0] = 0x00;
                        NALUnit[1] = 0x00;
                        NALUnit[2] = 0x00;
                        NALUnit[3] = 0x01;
                        int tmpLen = 4;
                        //第一片 + 中间片 数据
                        for(int i = 0; i < packetNum; ++i)
                        {
                            System.arraycopy(buffer[i],0,NALUnit,tmpLen,buffer[i].length);
                            tmpLen += buffer[i].length;
                        }
                        //最后一片的数据
                        System.arraycopy(rtpPackage.data,2,NALUnit,tmpLen,rtpPackage.data.length-2);
                        break;
                }
                break;

            //这里采用排除法,排除了组包封装和分片封装 之后就认为是 单个打包模式
            default:
                NALUnit = new byte[4+rtpPackage.data.length];
                NALUnit[0] = 0x00;
                NALUnit[1] = 0x00;
                NALUnit[2] = 0x00;
                NALUnit[3] = 0x01;
                System.arraycopy(rtpPackage.data,0,NALUnit,4,rtpPackage.data.length);
                NALEndFlag = true;
                break;
        }
        if(NALEndFlag)
        {
           return  NALUnit;
        }else{
            return  null;
        }
    }
    public void stop()
    {
        NALUnit = null;
    }
}

使用方法:

boolean isTCPtranslate = false ; //false  true
String hostIp ="192.168.1.235";
int port= 554 ;
String rtspUrl ="rtsp://192.168.1.235:554/";
String authorName=null ;
String authorPassword=null ;
RtspClient rtspClient = new RtspClient( isTCPtranslate,  hostIp, port,  rtspUrl , authorName, authorPassword);
rtspClient.openRtspCline();

//请求options
rtspClient.doOption();
//请求describe
rtspClient.doDescribe();
//请求建立连接(此阶段指明用Tcp或UDP建立连接)
rtspClient.doSetup();
//请求开始下发Rtp包
rtspClient.doPlay();

//开启子线程接收RTP数据
byte[] rtpData =rtspClient.getRtpData() ;
//对Rtp数据进行解析,获得H264数据
byte[] nalu= rtp2NaluTools.rtpPackage2NALU(rtpData) ;
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Android支持H.265视频编解码器,同时也支持RTSP协议来实现流媒体传输。你可以使用Android的MediaCodec API来实现H.265视频编解码器的开发,同时使用Android的MediaPlayer或ExoPlayer来实现RTSP协议的流媒体传输。下面是一个简单的示例: ```java // 创建H.265编码器 MediaCodec encoder = MediaCodec.createEncoderByType("video/hevc"); MediaFormat format = MediaFormat.createVideoFormat("video/hevc", width, height); format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); encoder.start(); // 创建H.265解码器 MediaCodec decoder = MediaCodec.createDecoderByType("video/hevc"); decoder.configure(format, surface, null, 0); decoder.start(); // 创建RTSP播放器 String url = "rtsp://xxx.xxx.xxx.xxx:xxxx/xxx"; MediaPlayer mediaPlayer = new MediaPlayer(); mediaPlayer.setDataSource(url); mediaPlayer.prepare(); mediaPlayer.start(); // 创建ExoPlayer String userAgent = Util.getUserAgent(context, "AppName"); DefaultHttpDataSourceFactory dataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); ExtractorMediaSource.Factory mediaSourceFactory = new ExtractorMediaSource.Factory(dataSourceFactory); MediaSource mediaSource = mediaSourceFactory.createMediaSource(Uri.parse(url)); SimpleExoPlayer exoPlayer = new SimpleExoPlayer.Builder(context).build(); exoPlayer.setMediaSource(mediaSource); exoPlayer.prepare(); exoPlayer.play(); ``` 需要注意的是,H.265编码器和解码器的实现可能因硬件支持和系统版本而异,需要根据具体情况进行适配。同时,RTSP协议需要在网络环境中进行传输,需要考虑网络带宽和延迟等问题。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值