android rtmp+opensl es+opengl es camera直播推流

观看一些户外直播时,我们观众端看到的是主播摄像头的内容,这是如何实现的呢?这篇将手写一个直播Demo。
在上一篇中,可以拍摄camera的数据,并加上背景音乐,其实只要解决了如何推流到服务器就可以了。我们使用 Rtmp 来传输 Rtmp Packet 数据,需要用到 NDK 开发。
基本流程

  • 获取camera和录音数据(byte[])
  • 对数据进行 h264 编码
  • 封装Rtmp 数据包
  • 上传到直播服务器推流地址


一.前期准备

  1. 因为要用到推流服务器,所以需要自己自行搭建流媒体服务器,可以参照这篇,使用Nginx+rtmp搭建流媒体服务器 - 简书 (jianshu.com),需要对linux懂一些常识。

  2. 至于服务器,最开始想用vmware的网络转发来对外,然后手机连接使用,但是发现电脑上可以ping通,但是手机上ping不了,还是得买个带公网的云服务器,翻了下,腾讯云有轻量云服务,首年几十块钱挺合适,自己搭建做些探索性的工作够了。

3.配置cmakeList
需要加入rtmp包



然后配置配置cmakeList

# 添加 define  -DNO_CRYPTO,在c文件可使用,
#1. CMAKE_C_FLAGS介绍:https://cloud.tencent.com/developer/article/1433578
#2. define介绍:https://blog.csdn.net/chouhuan1877/article/details/100808689
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DNO_CRYPTO")
#或者使用 add_definitions(-DTEST_DEBUG),这样在cxx_flags,c_flags都有

AUX_SOURCE_DIRECTORY(${CMAKE_SOURCE_DIR} SRC_LIST)
AUX_SOURCE_DIRECTORY(${CMAKE_SOURCE_DIR}/librtmp RTMP_LIST)

add_library( # Sets the name of the library.
             live-push

             # Sets the library as a shared library.
             SHARED

             ${RTMP_LIST}

             ${SRC_LIST}

             # Provides a relative path to your source file(s).
            native-lib.cpp
            DZPacketQueue.cpp
            DZJNICall.cpp
            DZLivePush.cpp
            )

要注意,-DNO_CRYPTO得加上,要不然编译不通过,这是一个变量,和define的全局变量有点像,NO_CRYPTO = ture。

二.代码

视频推流

在上一篇基础上,VideoEncoderThread中加入打印代码,就可以看到视频的数据,可以先行打印看下,然后对照着sps、pps、I、P等帧的类型,推的流其实就是遵守一定协议的,一串二进制码。

   // 返回有效数据填充的输出缓冲区的索引
    int outputBufferIndex = mVideoCodec.dequeueOutputBuffer(bufferInfo,0);
    if(outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){
        //TODO

        ByteBuffer byteBuffer = mVideoCodec.getOutputFormat().getByteBuffer("csd-0");
        mVideoSps = new byte[byteBuffer.remaining()];
        byteBuffer.get(mVideoSps,0,mVideoSps.length);

        String videoData = parseByte2HexStr(mVideoPps);
        Log.e(TAG+" sps",videoData);

        byteBuffer = mVideoCodec.getOutputFormat().getByteBuffer("csd-1");
        mVideoPps = new byte[byteBuffer.remaining()];
        byteBuffer.get(mVideoPps,0,mVideoPps.length);

        videoData = parseByte2HexStr(mVideoSps);
        Log.e(TAG+" pps",videoData);
    }
// 获取数据
    ByteBuffer outBuffer = mVideoCodec.getOutputBuffers()[outputBufferIndex];

    outBuffer.position(bufferInfo.offset);
    outBuffer.limit(bufferInfo.offset+bufferInfo.size);

    // 修改视频的 pts,基准时间戳
    if(videoPts ==0)
        videoPts = bufferInfo.presentationTimeUs;
    bufferInfo.presentationTimeUs -= videoPts;


    byte[] mVideoBytes = new byte[outBuffer.remaining()];
    outBuffer.get(mVideoBytes,0,mVideoBytes.length);
    String v1 = parseByte2HexStr(mVideoBytes);
    Log.e(TAG+":",v1);

可以看到结果:



都是以00000001开头,然后跟着类型码及数据。最开始的为sps、pps打头。
41十六进制转为二进制为1000001,对照下表6-7位,可以看到为P帧。
65十六进制转为二进制为1100101,为I帧。
每隔30个P帧,为一个I帧。


VideoEncoderThread整体改造后如下:

 private class VideoEncoderThread extends Thread{

        WeakReference<BaseVideoPush> videoRecorderWf;
        private boolean shouldExit =false;

        private MediaCodec mVideoCodec;
        MediaCodec.BufferInfo bufferInfo;
        CyclicBarrier stopCb;
        long videoPts = 0;

        /**
         * 视频轨道
         */
        private int mVideoTrackIndex = -1;

        byte[] mVideoPps;
        byte[] mVideoSps;

        public VideoEncoderThread(WeakReference<BaseVideoPush> videoRecorderWf){
            this.videoRecorderWf = videoRecorderWf;
            this.mVideoCodec = videoRecorderWf.get().mVideoCodec;
            this.stopCb = videoRecorderWf.get().stopCb;
            bufferInfo = new MediaCodec.BufferInfo();

        }

        @Override
        public void run() {
            mVideoCodec.start();

            while (true){
                try {
                    if(shouldExit){
                        onDestroy();
                        return;
                    }

                        // 返回有效数据填充的输出缓冲区的索引
                        int outputBufferIndex = mVideoCodec.dequeueOutputBuffer(bufferInfo,0);
                        if(outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){
                            //TODO

                            ByteBuffer byteBuffer = mVideoCodec.getOutputFormat().getByteBuffer("csd-0");
                            mVideoSps = new byte[byteBuffer.remaining()];
                            byteBuffer.get(mVideoSps,0,mVideoSps.length);

                            String videoData = parseByte2HexStr(mVideoPps);
                            Log.e(TAG+" pps",videoData);

                            byteBuffer = mVideoCodec.getOutputFormat().getByteBuffer("csd-1");
                            mVideoPps = new byte[byteBuffer.remaining()];
                            byteBuffer.get(mVideoPps,0,mVideoPps.length);

                            videoData = parseByte2HexStr(mVideoSps);
                            Log.e(TAG+" sps",videoData);
                        }else {
                            while (outputBufferIndex >= 0){

                                if(bufferInfo.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME){
                                    videoRecorderWf.get().livePush.pushSpsPps(mVideoSps,mVideoSps.length,mVideoPps,mVideoPps.length);
                                    String v1 = parseByte2HexStr(mVideoSps);
                                    Log.e(TAG+"sps data:",v1);
                                    String v2 = parseByte2HexStr(mVideoPps);
                                    Log.e(TAG+"pps data:",v2);
                                }

                                // 获取数据
                                ByteBuffer outBuffer = mVideoCodec.getOutputBuffers()[outputBufferIndex];

                                outBuffer.position(bufferInfo.offset);
                                outBuffer.limit(bufferInfo.offset+bufferInfo.size);

                                // 修改视频的 pts,基准时间戳
                                if(videoPts ==0)
                                    videoPts = bufferInfo.presentationTimeUs;
                                bufferInfo.presentationTimeUs -= videoPts;


                                byte[] mVideoBytes = new byte[outBuffer.remaining()];
                                outBuffer.get(mVideoBytes,0,mVideoBytes.length);
                                String v1 = parseByte2HexStr(mVideoBytes);
                                Log.e(TAG+":",v1);

                                videoRecorderWf.get().livePush.pushVideo(mVideoBytes,mVideoBytes.length,
                                        bufferInfo.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME);


                                if(videoRecorderWf.get().recordInfoListener != null){
                                    // us,需要除以1000转为 ms
                                    videoRecorderWf.get().recordInfoListener.onTime( bufferInfo.presentationTimeUs / 1000);
                                }

                                // 释放 outBuffer
                                mVideoCodec.releaseOutputBuffer(outputBufferIndex,false);
                                outputBufferIndex = mVideoCodec.dequeueOutputBuffer(bufferInfo,0);
                            }
                    }
                } catch (Exception e){
                    e.printStackTrace();
                }
            }
        }

        private void onDestroy() {
            try {
                if (mVideoCodec != null){
                    mVideoCodec.stop();
                    mVideoCodec.release();
                    mVideoCodec = null;
                }

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

        public void requestExit() {
            shouldExit = true;
        }
    }

视频的数据分为sps、pps,还有i帧及p帧,需要sps、pps的数据先推上去

videoRecorderWf.get().livePush.pushSpsPps(mVideoSps,mVideoSps.length,mVideoPps,mVideoPps.length)

这里调用JNI的代码
先把几个数据帧的结构发一下



更细一点的结构如下,也是在代码里要用到的:

 // frame type : 1关键帧,2 非关键帧 (4bit)
    // CodecID : 7表示 AVC (4bit)  , 与 frame type 组合起来刚好是 1 个字节  0x17
    // fixed : 0x00 0x00 0x00 0x00 (4byte)
    // configurationVersion  (1byte)  0x01版本
    // AVCProfileIndication  (1byte)  sps[1] profile
    // profile_compatibility (1byte)  sps[2] compatibility
    // AVCLevelIndication    (1byte)  sps[3] Profile level
    // lengthSizeMinusOne    (1byte)  0xff   包长数据所使用的字节数

    // sps + pps 的数据
    // sps number            (1byte)  0xe1   sps 个数
    // sps data length       (2byte)  sps 长度
    // sps data                       sps 的内容
    // pps number            (1byte)  0x01   pps 个数
    // pps data length       (2byte)  pps 长度
    // pps data                       pps 的内容

因此,可以编写

void DZLivePush::pushSpsPps(jbyte* sps_data, jint sps_length, jbyte* pps_data, jint pps_length) {

    // frame type : 1关键帧,2 非关键帧 (4bit)
    // CodecID : 7表示 AVC (4bit)  , 与 frame type 组合起来刚好是 1 个字节  0x17
    // fixed : 0x00 0x00 0x00 0x00 (4byte)
    // configurationVersion  (1byte)  0x01版本
    // AVCProfileIndication  (1byte)  sps[1] profile
    // profile_compatibility (1byte)  sps[2] compatibility
    // AVCLevelIndication    (1byte)  sps[3] Profile level
    // lengthSizeMinusOne    (1byte)  0xff   包长数据所使用的字节数

    // sps + pps 的数据
    // sps number            (1byte)  0xe1   sps 个数
    // sps data length       (2byte)  sps 长度
    // sps data                       sps 的内容
    // pps number            (1byte)  0x01   pps 个数
    // pps data length       (2byte)  pps 长度
    // pps data                       pps 的内容

    int bodySize = sps_length + pps_length + 16;
    RTMPPacket* rtmpPacket =  (RTMPPacket *) malloc(sizeof(RTMPPacket));
    RTMPPacket_Alloc(rtmpPacket,bodySize);
    RTMPPacket_Reset(rtmpPacket);

    int index = 0;
    char* body = rtmpPacket->m_body;
    //标识位 sps pps,AVC sequence header 与IDR一样
    body[index++] = 0x17;

    //跟着的补齐
    body[index++] = 0x00;
    body[index++] = 0x00;
    body[index++] = 0x00;
    body[index++] = 0x00;

    //版本
    body[index++] = 0x01;

    //编码规格
    body[index++] = sps_data[1];
    body[index++] = sps_data[2];
    body[index++] = sps_data[3];
    // reserved(111111) + lengthSizeMinusOne(2位 nal 长度) 总是0xff
    body[index++] = 0xff;
    // reserved(111) + lengthSizeMinusOne(5位 sps 个数) 总是0xe1
    body[index++] = 0xe1;

    //sps length 2字节
    body[index++] = (sps_length >> 8) & 0xff; //第0个字节
    body[index++] = sps_length & 0xff; //第1个字节
    // sps data
    memcpy(&body[index], sps_data, sps_length);
    index += sps_length;

    //pps
    body[index++] = 0x01;
    body[index++] = (pps_length >> 8) & 0XFF;
    body[index++] = pps_length & 0xFF;

    memcpy(&body[index], pps_data, pps_length);

    rtmpPacket->m_hasAbsTimestamp = 0;
    rtmpPacket->m_nTimeStamp = 0;
    rtmpPacket->m_nBodySize = bodySize;
    rtmpPacket->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    rtmpPacket->m_headerType = RTMP_PACKET_SIZE_LARGE;
    rtmpPacket->m_nChannel = 0x04;
    rtmpPacket->m_nInfoField2 = rtmp->m_stream_id;

//    LOGE("sps pps 发送到dzPacketQueue");
    dzPacketQueue->push(rtmpPacket);
}

推送video的I帧、P帧如下

void DZLivePush::pushVideo(jbyte *videoByte, jint length,jboolean isKeyFrame) {

    // frame type : 1关键帧,2 非关键帧 (4bit)
    // CodecID : 7表示 AVC (4bit)  , 与 frame type 组合起来刚好是 1 个字节  0x17
    // fixed : 0x01 0x00 0x00 0x00 (4byte)  0x01  表示 NALU 单元

    // video data length       (4byte)  video 长度
    // video data
    // 数据的长度(大小) =  dataLen + 9
    int bodySize = 9+length;
    RTMPPacket* packet = (RTMPPacket *)(malloc(sizeof(RTMPPacket)));
    RTMPPacket_Alloc(packet,bodySize);
    RTMPPacket_Reset(packet);

    int index = 0;
    char* body = packet->m_body;
    // frame type : 1关键帧,2 非关键帧 (4bit)
    // CodecID : 7表示 AVC (4bit)  , 与 frame type 组合起来刚好是 1 个字节  0x17

    if(isKeyFrame)
        body[index++] =0x17;
    else
        body[index++] =0x27;

    body[index++] =0x01;
    body[index++] =0x00;
    body[index++] =0x00;
    body[index++] =0x00;

    body[index++] =(length >> 24) & 0xFF;
    body[index++] =(length >> 16) & 0xFF;
    body[index++] =(length >> 8) & 0xFF;
    body[index++] =length & 0xFF;

    memcpy(&body[index],videoByte,length);

    packet->m_nBodySize = bodySize;
    packet->m_nChannel = 0x04;

    packet->m_nTimeStamp = RTMP_GetTime() - startTime;
    packet->m_hasAbsTimestamp = 0;
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
    packet->m_nInfoField2 = rtmp->m_stream_id;

//    LOGE("I P 发送到dzPacketQueue");
    dzPacketQueue->push(packet);
}

这些都是固定写法,packet->m_nChannel=0x04,音频推送时也需要04,要不音频推送写个05,会发现无法正常播放声音。

音频推流

现在推音频流,使用AudioRecord 采集音频数据

  bufferSizeInBytes = AudioRecord.getMinBufferSize(
                    AUDIO_SAMPLE_RATE,
                    AudioFormat.CHANNEL_IN_STEREO,
                    AudioFormat.ENCODING_PCM_16BIT);

            audioRecord = new AudioRecord(
                    MediaRecorder.AudioSource.MIC,
                    AUDIO_SAMPLE_RATE,
                    AudioFormat.CHANNEL_IN_STEREO,
                    AudioFormat.ENCODING_PCM_16BIT,
                    bufferSizeInBytes);
            mAdudioData = new byte[bufferSizeInBytes];
     //开始采集
     audioRecord.startRecording();
     //录取录制的数据
     audioRecord.read(mAdudioData,0,bufferSizeInBytes);

因为原始的音频文件如采样率之类的,可能不是我们规定的,这里推到mediacodec中,再拿到数据推流

    audioRecord.read(mAdudioData,0,bufferSizeInBytes);
    
    int inputBufferTrack = mAudioCodec.dequeueInputBuffer(0);
    if(inputBufferTrack >= 0){
        ByteBuffer inputBuffer = mAudioCodec.getInputBuffers()[inputBufferTrack];
        inputBuffer.clear();
    
        inputBuffer.put(mAdudioData);
    
        //0.41795918 *1000 000
        audioPts += 1000000 * bufferSizeInBytes * 1.0f / AUDIO_SAMPLE_RATE * AUDIO_CHANNELS * 2;
        //数据放入mAudioCodec的队列中
        mAudioCodec.queueInputBuffer(inputBufferTrack,0,bufferSizeInBytes,audioPts,0);
    }

和视频一样的取数据

 mAudioCodec.start();

            while (true){
                try {
                    if(shouldExit){
                        onDestroy();
                        return;
                    }
                    // 返回有效数据填充的输出缓冲区的索引
                    int outputBufferIndex = mAudioCodec.dequeueOutputBuffer(bufferInfo,0);

                    while (outputBufferIndex >= 0){

//                            Log.e(TAG,"outputBufferIndex:"+outputBufferIndex+" count:"+index);
                        // 获取数据
                        ByteBuffer outBuffer = mAudioCodec.getOutputBuffers()[outputBufferIndex];

                        outBuffer.position(bufferInfo.offset);
                        outBuffer.limit(bufferInfo.offset+bufferInfo.size);

                        // 修改视频的 pts,基准时间戳
                        if(audioPts ==0)
                            audioPts = bufferInfo.presentationTimeUs;
                        bufferInfo.presentationTimeUs -= audioPts;

                        byte[] audioData = new byte[outBuffer.remaining()];
                        outBuffer.get(audioData,0,audioData.length);

                        recorderReference.get().livePush.pushAudio(audioData,audioData.length);

                        // 释放 outBuffer
                        mAudioCodec.releaseOutputBuffer(outputBufferIndex,false);
                        outputBufferIndex = mAudioCodec.dequeueOutputBuffer(bufferInfo,0);
                    }
                } catch (Exception e){
                    e.printStackTrace();
                }
            }

RTMP 包中封装的音视频数据流,其实和FLV/tag封装音频和视频数据的方式是相同的,所以我们只需要按照FLV格式封装音视频即可。


void DZLivePush::pushAudio(jbyte *audioData, jint audioLen) {


    // 2 字节头信息
    // 前四位表示音频数据格式 AAC  10(A)
    // 五六位表示采样率 0 = 5.5k  1 = 11k  2 = 22k  3(11) = 44k
    // 七位表示采样采样的精度 0 = 8bits  1 = 16bits
    // 八位表示音频类型  0 = mono  1 = stereo
    // 组合起来:1010 1111 -,算出来第一个字节是 0xAF
    // 0x01 代表 aac 原始数据
    int bodySize = audioLen+2;
    RTMPPacket* packet = (RTMPPacket *)(malloc(sizeof(RTMPPacket)));
    RTMPPacket_Alloc(packet,bodySize);
    RTMPPacket_Reset(packet);

    char * body = packet->m_body;
    //上面推算出
    body[0] = 0xaf;

    body[1] = 0x01;

    memcpy(&body[2],audioData,audioLen);

    packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    packet->m_nInfoField2 = rtmp->m_stream_id;
    packet->m_hasAbsTimestamp = 0;
    packet->m_nTimeStamp = RTMP_GetTime()-startTime;
    packet->m_nChannel = 0x04;
    packet->m_nBodySize = bodySize;

    LOGE("AAC 发送到dzPacketQueue");

    dzPacketQueue->push(packet);
}

这样就可以了。

停止

因为是开启的线程推流到服务器

void DZLivePush::initConnect() {
    pthread_create(&initConnectTid,NULL, initConnectRun,this);
}
void *initConnectRun(void * context){
  //不断循环取数据上传到服务器
...
  while (pLivePush->isPushing){

        RTMPPacket* packet = pLivePush->dzPacketQueue->pop();
        if(packet != NULL){
            int send_result = RTMP_SendPacket(pLivePush->rtmp,packet,1);
            LOGE("send_result: %d",send_result);

            RTMPPacket_Free(packet);
            free(packet);
            packet = NULL;
        }
    }
...
}

所以加一个退出标识,然后pthread_join等待线程完成退出。

void DZLivePush::stop() {
    isPushing = false;
    pthread_join(initConnectTid,NULL);
    LOGE("等待停止");
}

这样代码就写完了。

验证

可以使用下载的
Builds - CODEX FFMPEG @ gyan.dev
ffmpeg for windows,使用ffplay rtmp://自己的流媒体IP:1935/cctvf/mystream来播放了。

代码在这里:livepush at github

参考:

  1. (2条消息) H.264再学习 -- 详解 H.264 NALU语法结构_不积跬步,无以至千里-CSDN博客_h

  2. Android RTMP 投屏直播推流实现.md · 苦涩冰糖/myBlogMarkdown - Gitee.com

  3. (2条消息) librtmp发送AVC,AAC数据包_影音视频技术-CSDN博客

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,176评论 5 469
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,190评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,232评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,953评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,879评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,177评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,626评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,295评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,436评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,365评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,414评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,096评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,685评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,771评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,987评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,438评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,032评论 2 341

推荐阅读更多精彩内容