Android RTMP推流之MediaCodec硬编码一(H.264进行flv封装)

在前面Android平台下使用FFmpeg进行RTMP推流(摄像头推流)的文章中,介绍了如何使用FFmpeg进行H264编码和Rtmp推流。接下来讲分几篇文章来介绍如何使用Android系统的MediaCodec进行H264硬编码,然后封装推流。这一块涉及的内容很多,其中涉及一些基础知识也会有单独文章介绍比如flv格式。这篇文章主要介绍如何用MediaCodec进行编码,然后将编码后的数据进行flv封装。

文章同步项目源码地址
注意版本为V1.3

3.png

MediaCodec介绍

学习个模块内容当然是参考官方文档Android MediaCodec。但有些兄弟可能没有大多耐心看英文,那我在推荐一个中文版的MediaCodec官方文档译文,如果还是没耐心看,或者没看懂,那就试试看下我的理解。
先上一张图:

1.png

这个图也是官网上抠下来的。对这个图的理解很关键。我先总结一下:

  • MediaCodec编码器包含两个缓冲区,一个输入缓冲区,一个输出缓冲区。
  • 客户端先从MediaCodec获取一个可用的输入缓冲区,然后将待编码的数据填充到缓冲区,然后交给MediaCodec去处理。
  • 客户端从输出缓冲区获取已经处理好的数据,客户端得到数据后并处理后,释放空间,最后将缓冲区还给MediaCodec。

我把整条线简单的描述了一下。也就是整个编码流程,客户端是如何操作的。下面我们要深入了解MediaoCodec如何工作,还是先上图

2.png

这里我也总结下:

  • 要用MediaCodec,首先需要创建,根据我们想要的编码格式创建。创建后就是Uninitialized状态
  • 创建完成之后还不能直接用,我们需要进行配置,进入Configured。这时候就准备就绪了
  • Configured后,就可以start。进行运行阶段了。
  • 运行阶段又分3个子状态。start()后就进入Flushed状态。
  • 当客户端获取一个有效的输入缓冲区后,就进入了Running,而MediaCodec大部分时间在这个状态
  • 如果客户端将得到的输入缓冲区入队时带有末尾标记时,编码器就进入End of Stream状态,这时候就不再接受后面缓冲区的输入
  • stop之后就会重新进入Uninitialized状态。
  • 如果出现错误就会进入Error状态

到这里我们就简单的吧MediaCodec介绍完了。当然我只是简单的介绍,大概了解后,我们先用起来,然后自己再体会就知道了。


MediaCodec编码

创建并配置MediaCodec

我们按前面的流程使用MediaCodec。先创建MediaCodec
先上代码


    private void initMediaCodec() {
        int bitrate = 2 * WIDTH * HEIGHT * FRAME_RATE / 20;
        try {
            MediaCodecInfo mediaCodecInfo = selectCodec(VCODEC_MIME);
            if (mediaCodecInfo == null) {
                Toast.makeText(this, "mMediaCodec null", Toast.LENGTH_LONG).show();
                throw new RuntimeException("mediaCodecInfo is Empty");
            }
            LogUtils.w("MediaCodecInfo " + mediaCodecInfo.getName());
            mMediaCodec = MediaCodec.createByCodecName(mediaCodecInfo.getName());
            MediaFormat mediaFormat = MediaFormat.createVideoFormat(VCODEC_MIME, WIDTH, HEIGHT);
            mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitrate);
            mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
            mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,
                    MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar);
            mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
            mMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            mMediaCodec.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
  • 查找编码器
    MediaCodecInfo mediaCodecInfo = selectCodec(VCODEC_MIME);
    我们看到selectCodec方法。我们要使用H.264编码,所以传入的参数
    private static final String VCODEC_MIME = "video/avc";
    private MediaCodecInfo selectCodec(String mimeType) {
        int numCodecs = MediaCodecList.getCodecCount();
        for (int i = 0; i < numCodecs; i++) {
            MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i);
            //是否是编码器
            if (!codecInfo.isEncoder()) {
                continue;
            }
            String[] types = codecInfo.getSupportedTypes();
            LogUtils.w(Arrays.toString(types));
            for (String type : types) {
                LogUtils.e("equal " + mimeType.equalsIgnoreCase(type));
                if (mimeType.equalsIgnoreCase(type)) {
                    LogUtils.e("codecInfo " + codecInfo.getName());
                    return codecInfo;
                }
            }
        }
        return null;
    }

这段逻辑主要是获取系统的编码器并查找是否有我们需要的编码器并返回其信息。得到信息后我们就可以创建MediaCodec

mMediaCodec = MediaCodec.createByCodecName(mediaCodecInfo.getName());
  • 配置编码器信息
    前面我们已经查找并创建了编码器,这一步就是进行参数配置。主要是setInteger等方法进行类似key-value的设置。如:码率KEY_BIT_RATE、编码像素格式KEY_COLOR_FORMAT、帧率KEY_FRAME_RATE
    这里要注意KEY_COLOR_FORMAT像素格式的设置,后面涉及到格式的转换,同时不同的设备可能支持的格式不同,我测试的设备就不支持COLOR_FormatYUV420SemiPlanar
  • 配置
    mMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
    这一步也是必须的

编码

前面的文章我们已经讲到了如何采集获取Camera的数据,这里就不再累述。直接看到Camera.PreviewCallbackonPreviewFrame(final byte[] data, Camera camera)回调方法。

    public class StreamIt implements Camera.PreviewCallback {
        @Override
        public void onPreviewFrame(final byte[] data, Camera camera) {
            long endTime = System.currentTimeMillis();
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    encodeTime = System.currentTimeMillis();
                    flvPackage(data);
                    LogUtils.w("编码第:" + (encodeCount++) + "帧,耗时:" + (System.currentTimeMillis() - encodeTime));
                }
            });
            LogUtils.d("采集第:" + (++count) + "帧,距上一帧间隔时间:"
                    + (endTime - previewTime) + "  " + Thread.currentThread().getName());
            previewTime = endTime;
        }
    }

这个回调方法大家就很首席了data就是采集到的原始YUV数据。为了方便调试,我就把第几帧和编码时间以及采集时间打印出来。而这里的YUV数据和Camera的参数设置有关params.setPreviewFormat(ImageFormat.YV12);系统默认使用的N21,这里我使用YV12格式。
接下来重点就是flvPackage(data);调用了

    private void flvPackage(byte[] buf) {
        final int LENGTH = HEIGHT * WIDTH;
        //YV12数据转化成COLOR_FormatYUV420Planar
        LogUtils.d(LENGTH + "  " + (buf.length - LENGTH));
        for (int i = LENGTH; i < (LENGTH + LENGTH / 4); i++) {
            byte temp = buf[i];
            buf[i] = buf[i + LENGTH / 4];
            buf[i + LENGTH / 4] = temp;
//            char x = 128;
//            buf[i] = (byte) x;
        }
        ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers();
        ByteBuffer[] outputBuffers = mMediaCodec.getOutputBuffers();
        try {
            //查找可用的的input buffer用来填充有效数据
            int bufferIndex = mMediaCodec.dequeueInputBuffer(-1);
            if (bufferIndex >= 0) {
                //数据放入到inputBuffer中
                ByteBuffer inputBuffer = inputBuffers[bufferIndex];
                inputBuffer.clear();
                inputBuffer.put(buf, 0, buf.length);
                //把数据传给编码器并进行编码
                mMediaCodec.queueInputBuffer(bufferIndex, 0,
                        inputBuffers[bufferIndex].position(),
                        System.nanoTime() / 1000, 0);
                MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();

                //输出buffer出队,返回成功的buffer索引。
                int outputBufferIndex = mMediaCodec.dequeueOutputBuffer(bufferInfo, 0);
                while (outputBufferIndex >= 0) {
                    ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
                    //进行flv封装
                    mFlvPacker.onVideoData(outputBuffer, bufferInfo);
                    mMediaCodec.releaseOutputBuffer(outputBufferIndex, false);
                    outputBufferIndex = mMediaCodec.dequeueOutputBuffer(bufferInfo, 0);
                }
            } else {
                LogUtils.w("No buffer available !");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

我们看到第一步有一个格式转换,YV12数据转化成COLOR_FormatYUV420Planar。因为编码器支持的输入是COLOR_FormatYUV420Planar,而我们采集到的是YV12。所以需要转换。两者的区别就是U、V分量颠倒了个位置。在Android平台下使用FFmpeg进行RTMP推流(摄像头推流)有具体介绍。

接下来就是关键部分了MediaCodec进行H264编码。客户端的使用流程我们按照对图1的总结来进行操作
首先获取编码器的输入和输出缓冲区

        ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers();
        ByteBuffer[] outputBuffers = mMediaCodec.getOutputBuffers();

接下来获取一个可用的输入缓冲区索引

int bufferIndex = mMediaCodec.dequeueInputBuffer(-1);

如果返回>0说明有效。然后获取到对应的ByteBuffer
ByteBuffer inputBuffer = inputBuffers[bufferIndex];
接下来就是讲图像数据填充到inputBuffer中。

                inputBuffer.clear();
                inputBuffer.put(buf, 0, buf.length);

然后告诉编码器开始编码

 //把数据传给编码器并进行编码
                mMediaCodec.queueInputBuffer(bufferIndex, 0,
                inputBuffers[bufferIndex].position(),
                System.nanoTime() / 1000, 0);

接下来就是获取编码后的数据,这里先得到输出缓冲区索引

int outputBufferIndex = mMediaCodec.dequeueOutputBuffer(bufferInfo, 0);

然后就可以得到对应的ByteBuffer。也就是编码后的数据,得到数据后我们就可以进行flv封装。
最后我们需要释放缓冲区

mMediaCodec.releaseOutputBuffer(outputBufferIndex, false);

到这里我们就了解了如何具体使用MediaCodec,接下来就是如何进行flv封装


flv封装

前面已经讲到如何进行H264编码,并得到编码后的数据。接下来就是如何将原始的H264数据封装成flv格式的数据。在将flv封装之前,大家一定要熟悉flv的格式。flv格式相对比较简单,可以参考flv格式详解+实例剖析。否则接下来的内容大家会一脸懵逼。在讲代码前,还是先总结下流程:

  • flv文件封装视频数据前先写入flv头,metadata数据。
  • MediaCodec进行编码后的第一个数据是sps、pps数据,也是flv中的第一个video tag。
  • 后面收到的MediaCodec编码后的数据就是正常的视频h264数据,封装到flv中。

封装h264的调用:

//进行flv封装
mFlvPacker.onVideoData(outputBuffer, bufferInfo);

我们进入代码看到:

    @Override
    public void onVideoData(ByteBuffer bb, MediaCodec.BufferInfo bi) {
        mAnnexbHelper.analyseVideoData(bb, bi);
    }

再跟进方法

    /**
     * 将硬编得到的视频数据进行处理生成每一帧视频数据,然后传给flv打包器
     * @param bb 硬编后的数据buffer
     * @param bi 硬编的BufferInfo
     */
    public void analyseVideoData(ByteBuffer bb, MediaCodec.BufferInfo bi) {
        bb.position(bi.offset);
        bb.limit(bi.offset + bi.size);

        ArrayList<byte[]> frames = new ArrayList<>();
        boolean isKeyFrame = false;

        while(bb.position() < bi.offset + bi.size) {
            byte[] frame = annexbDemux(bb, bi);
            if(frame == null) {
                LogUtils.e("annexb not match.");
                break;
            }
            // ignore the nalu type aud(9)
            if (isAccessUnitDelimiter(frame)) {
                continue;
            }
            // for pps
            if(isPps(frame)) {
                mPps = frame;
                continue;
            }
            // for sps
            if(isSps(frame)) {
                mSps = frame;
                continue;
            }
            // for IDR frame
            if(isKeyFrame(frame)) {
                isKeyFrame = true;
            } else {
                isKeyFrame = false;
            }
            byte[] naluHeader = buildNaluHeader(frame.length);
            frames.add(naluHeader);
            frames.add(frame);
        }
        if (mPps != null && mSps != null && mListener != null && mUploadPpsSps) {
            if(mListener != null) {
                mListener.onSpsPps(mSps, mPps);
            }
            mUploadPpsSps = false;
        }
        if(frames.size() == 0 || mListener == null) {
            return;
        }
        int size = 0;
        for (int i = 0; i < frames.size(); i++) {
            byte[] frame = frames.get(i);
            size += frame.length;
        }
        byte[] data = new byte[size];
        int currentSize = 0;
        for (int i = 0; i < frames.size(); i++) {
            byte[] frame = frames.get(i);
            System.arraycopy(frame, 0, data, currentSize, frame.length);
            currentSize += frame.length;
        }
        if(mListener != null) {
            mListener.onVideo(data, isKeyFrame);
        }
    }

这个方法主要是从编码后的数据中解析得到NALU,然后判断NALU的类型,最后再把数据回调给FlvPacker去处理。那如何解析得到NALU,我们看到annexbDemux(bb, bi)方法

    /**
     * 从硬编出来的数据取出一帧nal
     * @param bb
     * @param bi
     * @return
     */
    private byte[] annexbDemux(ByteBuffer bb, MediaCodec.BufferInfo bi) {
        AnnexbSearch annexbSearch = new AnnexbSearch();
        avcStartWithAnnexb(annexbSearch, bb, bi);

        if (!annexbSearch.match || annexbSearch.startCode < 3) {
            return null;
        }

        for (int i = 0; i < annexbSearch.startCode; i++) {
            bb.get();
        }

        ByteBuffer frameBuffer = bb.slice();
        int pos = bb.position();
        while (bb.position() < bi.offset + bi.size) {
            avcStartWithAnnexb(annexbSearch, bb, bi);
            if (annexbSearch.match) {
                break;
            }
            bb.get();
        }

        int size = bb.position() - pos;
        byte[] frameBytes = new byte[size];
        frameBuffer.get(frameBytes);
        return frameBytes;
    }

方法返回得到NALU数据,那如何解析的呢,看到avcStartWithAnnexb(annexbSearch, bb, bi);方法调用

   /**
     * 从硬编出来的byteBuffer中查找nal
     * @param as
     * @param bb
     * @param bi
     */
    private void avcStartWithAnnexb(AnnexbSearch as, ByteBuffer bb, MediaCodec.BufferInfo bi) {
        as.match = false;
        as.startCode = 0;
        int pos = bb.position();
        while (pos < bi.offset + bi.size - 3) {
            // not match.
            if (bb.get(pos) != 0x00 || bb.get(pos + 1) != 0x00) {
                break;
            }

            // match N[00] 00 00 01, where N>=0
            if (bb.get(pos + 2) == 0x01) {
                as.match = true;
                as.startCode = pos + 3 - bb.position();
                break;
            }
            pos++;
        }
    }

这里逻辑也比较简单,遍历寻找NALU的开头,开头是有0x0000010x00000001开头。这里找到匹配的位置后设置的到AnnexbSearch中。

我们回到analyseVideoData方法,在调用byte[] frame = annexbDemux(bb, bi);后我们已经得到NALU数据,接下来就是判断NALU类型

        while(bb.position() < bi.offset + bi.size) {
            byte[] frame = annexbDemux(bb, bi);
            if(frame == null) {
                LogUtils.e("annexb not match.");
                break;
            }
            // ignore the nalu type aud(9)
            if (isAccessUnitDelimiter(frame)) {
                continue;
            }
            // for pps
            if(isPps(frame)) {
                mPps = frame;
                continue;
            }
            // for sps
            if(isSps(frame)) {
                mSps = frame;
                continue;
            }
            // for IDR frame
            if(isKeyFrame(frame)) {
                isKeyFrame = true;
            } else {
                isKeyFrame = false;
            }
            byte[] naluHeader = buildNaluHeader(frame.length);
            frames.add(naluHeader);
            frames.add(frame);
        }
        if (mPps != null && mSps != null && mListener != null && mUploadPpsSps) {
            if(mListener != null) {
                mListener.onSpsPps(mSps, mPps);
            }
            mUploadPpsSps = false;
        }

首先需要知道NALU是有header+Payload组成。而header固定1个字节,由3个部分组成forbidden_bit(1bit),nal_reference_bit(2bits)(优先级),nal_unit_type(5bits)(类型)
这里重点看到类型:

3.jpg

所以我们看到代码的判断,解析第一个字节就可以啦:

    private boolean isSps(byte[] frame) {
        if (frame.length < 1) {
            return false;
        }
        // 5bits, 7.3.1 NAL unit syntax,
        // H.264-AVC-ISO_IEC_14496-10.pdf, page 44.
        //  7: SPS, 8: PPS, 5: I Frame, 1: P Frame
        int nal_unit_type = (frame[0] & 0x1f);
        return nal_unit_type == SPS;
    }

    private boolean isPps(byte[] frame) {
        if (frame.length < 1) {
            return false;
        }
        // 5bits, 7.3.1 NAL unit syntax,
        // H.264-AVC-ISO_IEC_14496-10.pdf, page 44.
        //  7: SPS, 8: PPS, 5: I Frame, 1: P Frame
        int nal_unit_type = (frame[0] & 0x1f);
        return nal_unit_type == PPS;
    }

    private boolean isKeyFrame(byte[] frame) {
        if (frame.length < 1) {
            return false;
        }
        // 5bits, 7.3.1 NAL unit syntax,
        // H.264-AVC-ISO_IEC_14496-10.pdf, page 44.
        //  7: SPS, 8: PPS, 5: I Frame, 1: P Frame
        int nal_unit_type = (frame[0] & 0x1f);
        return nal_unit_type == IDR;
    }

回到analyseVideoData方法,当sps和pps都回去到后,就可以调用mListener.onSpsPps(mSps, mPps);把数据回调给FlvPacker。我们看到实现部分

    @Override
    public void onSpsPps(byte[] sps, byte[] pps) {
        if(packetListener == null) {
            return;
        }
        //写入Flv header信息
        writeFlvHeader();
        //写入Meta 相关信息
        writeMetaData();
        //写入第一个视频信息
        writeFirstVideoTag(sps, pps);
        //写入第一个音频信息
        writeFirstAudioTag();
        mStartTime = System.currentTimeMillis();
        isHeaderWrite = true;
    }

因为sps和pps有且仅有一个而且是第一个。所以在这里,同事写入flv的头部信息和metaData数据,然后将sps和pps信息写入。至于每个tag的封装,这里就不做讲解了,大家针对前面flv格式详解+实例剖析文章,再对照代码就很清晰了。
再看到mListener.onVideo(data, isKeyFrame);

    @Override
    public void onVideo(byte[] video, boolean isKeyFrame) {
        if(packetListener == null || !isHeaderWrite) {
            return;
        }
        int compositionTime = (int) (System.currentTimeMillis() - mStartTime);
        int packetType = INTER_FRAME;
        if(isKeyFrame) {
            isKeyFrameWrite = true;
            packetType = KEY_FRAME;
        }
        //确保第一帧是关键帧,避免一开始出现灰色模糊界面
        if(!isKeyFrameWrite) {
            return;
        }

        int videoPacketSize = VIDEO_HEADER_SIZE + video.length;
        int dataSize = videoPacketSize + FLV_TAG_HEADER_SIZE;
        int size = dataSize + PRE_SIZE;
        ByteBuffer buffer = ByteBuffer.allocate(size);
        FlvPackerHelper.writeFlvTagHeader(buffer, FlvPackerHelper.FlvTag.Video, videoPacketSize, compositionTime);
        FlvPackerHelper.writeH264Packet(buffer, video, isKeyFrame);
        buffer.putInt(dataSize);
        packetListener.onPacket(buffer.array(), packetType);
    }

这里就是正常的视频NALU数据写入了。
两个回调方法最后都调用了packetListener.onPacket(buffer.array(), packetType);。这个packetListener就是我们CameraMediaCodecActivity中设置的回调

        mFlvPacker.setPacketListener(new Packer.OnPacketListener() {
            @Override
            public void onPacket(byte[] data, int packetType) {
                IOUtils.write(mOutStream, data, 0, data.length);
                LogUtils.w(data.length + " " + packetType);
            }
        });

代码会简单就是讲封装好的flv数据写入到文件中。

到此,我们就基本了解如何使用MediaCodec进行H.264硬编码,然后坐Flv格式封装。后续会陆续推出将封装的flv数据进行RTMP推流,请大家关注!

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

推荐阅读更多精彩内容