Live-client-6-直播功能

做了那么久铺垫,终于要写本项目中最为重要的直播功能了。首先介绍一下项目中用到的一些知识点和协议

零、知识点与协议

(一)音视频基础知识

1. 音频相关概念:

  1. 采样率(SampleRateInHz):采样率指每秒采样点个数(8000/44100Hz),采样率单位用Hz(赫兹)表示
  2. 声道(Sound Channel):是指声音在录制或播放时在不同空间位置采集或回放的相互独立的音频信号,所以声道数也是声音录制或者播放时的扬声器数量。
    常见声道数:单声道、立体声道、4声道、5.1声道、7.1声道等
  3. 量化精度:量化精度表示可以将模拟信号分成多少个等级,量化精度越高,音乐的声压振幅越接近原始音乐。量化精度的单位是bit(比特),也可以理解为一个采样点用多少比特表示(8/16/24/32 bit)。在Android中,量化精度由AudioFormat.ENCODING_PCM_16BIT、ENCODING_PCM_8BIT等表示。
  4. 数据量(字节/秒)= (采样频率(Hz) * 采样位数(量化精度,bit) * 声道数)/ 8

2. 视频相关概念

  1. 帧率(Frame Rate):用于测量显示帧数的量度,测量单位是每秒显示帧数(frames per second,简称fps)。每秒显示帧数(fps)或者帧率表示图形处理器场时每秒能够更新的次数。一般而言,30fps是能够接受的帧率,而60fps则更为逼真、更为流畅,超过75fps后就不容易察觉有明显的流畅度提升。
  2. 分辨率:指视频成像产品所形成的图像大小或者尺寸。
  3. 刷新率:刷新率是指屏幕每秒画面被刷新的次数,刷新率分为垂直刷新率和水平刷新率,一般指垂直刷新率。垂直刷新率表示屏幕上图像每秒重绘多少次,也就是每秒屏幕刷新的次数,以Hz为单位。刷新率越高,图像越稳定,图像显示就越清晰。
  4. 码率:码率也就是比特率,比特率是单位时间播放连续的媒体的比特数量。比特率越高,带宽消耗得越多。
    文件大小(b) = 码率(b/s) * 时长(s)
  5. 视频帧:常见的视频帧有I、P、S帧等
  • I帧表示关键帧,可以理解为这一帧画面的完整保留,解码时只需要本帧数据就可以完成。
  • P帧表示这一帧和之前的一个关键帧(或者P帧)的差别,解码时需要用之前的画面叠加上本帧定义的差别生成最终画面。
  • B帧是双向差别帧,B帧记录的是本帧和前后帧的差别,也就是解码B帧时,不仅需要之前的缓存画面,还需要解码之后的画面,通过前后的画面和B帧进行叠加,得到最后画面。
  1. 数据量 = 分辨率(width * height) *每个像素所占的字节数。比如YUV420 = 分辨率 * 3 / 2

(二)相关协议与库

1. AAC音频格式

AAC音频格式有两种:ADIF和ADTS

  • ADIF:Audio Data Interchange Format,音频数据交换格式,这种格式的特征是可以确定找到这个音频数据的开始,不需要在音频数据流中间开始的解码,即其解码必须在明确的位置开始进行。适用于磁盘文件。
  • ADTS:Audio Data Transport Stream,音频数据传输流,这种格式的特征是它有一个同步字的比特流,解码可以在这个流中的任何位置开始。

两者的区别:ADTS可以在任意帧进行解码,每一帧都有头信息。ADIF只有一个统一的头,必须得到所有的数据后才能进行解码。

ADTS是帧序列,本身就具备流特征,在音频流的传输和处理方面更加合适,ADST帧格式如下:


ADST帧格式.png

而ADST帧的Header结构如下:


ADST帧的Header结构.png
  • syncword :同步头 总是0xFFF, all bits must be 1,代表着一个ADTS帧的开始
  • ID:MPEG Version: 0 for MPEG-4, 1 for MPEG-2
  • Layer:always: '00'
  • profile:表示使用哪个级别的AAC,有些芯片只支持AAC LC 。在MPEG-2 AAC中定义了3种:
    0:Main profile; 1:Low Complexity profile(LC) ; 2:Scalable Sampling Rate Profile(SSR)
  • sampling_frequency_index:表示使用的采样率下标,通过这个下标在 Sampling Frequencies[ ]数组中查找得知采样率的值。
    0: 96000 Hz
    1: 88200 Hz
    2: 64000 Hz
    3: 48000 Hz
    4: 44100 Hz
    5: 32000 Hz
  • channel_configuration: 表示声道数
    0: AOT默认设置
    1: 1个声道数:前中央
    2: 2个声道数:左前、右前
    3: 3个声道数:左前、右前、前中央
    4: 4个声道数:左前、后前、前中央、后中央
    5: 5个声道数:左前、后前、前中央、左后、右后
  • frame_length : 一个ADTS帧的长度包括ADTS头和AAC原始流.
  • adts_buffer_fullness:0x7FF 说明是码率可变的码流

2. H264视频编码

1. H264码流文件分为2层:

(1)VCL(Video Coding Layer):视频编码层,负责高效的视频内容表示,VCL数据即编码处理的输出,表示被压缩数据编码后的视频数据序列。
(2)NAL(Network Abstraction Layer):网络提取层,复杂以网络所要求的恰当的方式对数据进行打包和传送,是传输层,不管在本地播放还是在网络播放的传输,都需要这一层来传输。

2. H264编码格式

在VCL数据传输或者存储之前,这些编码的 VCL 数据,先被映射或封装进NAL 单元中。每个 NAL 单元包括一个原始字节序列负荷( RBSP, Raw Byte Sequence Payload)和一组对应于视频编码的 NAL 头信息。RBSP 的基本结构是:在原始编码数据的后面填加了结尾比特。一个 bit“1”若干比特“0”,以便字节对齐。


NAL单元序列.png

3. NAL Header

NAL头由一个字节组成,包含禁止位(1bit)、重要性位(2bit)、NALU类型(5bit)

+---------------+
|0|1|2|3|4|5|6|7|
+-+-+-+-+-+-+-+-+
|F|NRI|  TYPE   |
+---------------+
0 1-2 3-7
简称 F NRI TYPE
全称 forbidden_zero_bit nal_ref_idc nal_unite_type
中文 禁止位 重要性指示位 NALU类型
作用 当网络发现NAL单元有比特错误时,可以设置该比特为1,以便接受方丢掉该单元 标志该NAL单元用于重建时的重要性,值越大,越重要,取值为00-11 1-23表示单个NAL包,24-31需要分包或者组合发送

NALU类型:
0:没有定义
(1-23 NAL单元,单个 NAL 单元包)
1:不分区,非IDR图像的片
2:片分区A
3:片分区B
4:片分区C
5:IDR图像中的片
6:补充增强信息单元(SEI)
7:SPS(Sequence Parameter Set序列参数集,作用于一串连续的视频图像,即视频序列)
8:PPS(Picture Parameter Set图像参数集,作用于视频序列中的一个或多个图像)
9:序列结束
10:序列结束
11:码流结束
12:填充
13-23:保留
24:STAP-A单一时间的组合包
25:STAP-B单一时间的组合包
26:MTAP16单一时间的组合包
27:MTAP24多个时间的组合包
28:FU-A分片的单元
29:FU-B分片的单元
30-32:未定义

4. H264传输

H264的编码视频序列包括一系列的NAL单元,每个单元包含一个RBSP。每个单元都按独立的NAL单元传输。NAL单元的信息头定义了RBSP的类型,NAL单元的其余部分为RBSP数据。

5. H264码流结构

这部分内容比较复杂,重点在于如果一个NALU单元内有SPS、PPS相连,那么该帧就是关键帧。

6. H264的Level和profile说明

H264的Level用来约束分辨率、帧率和码率的。Level越高所支持的分辨率、码率、帧率就越大。
H264的profile则是用来说明视频序列的帧类型:

  1. BP(Baseline profile)基线档次:提供I/P帧,仅支持Progressive(逐行扫描)和CAVLC。多应用于“视频会话”,如可视电话、会议电视、远程教学、视频监控等实时通信领域;
  2. XP(Extended profile)进阶档次:提供I/P/B/SP/SI帧,仅支持Progressive和CAVLC。多应用于流媒体领域,如视频点播、基于网络的视频监控等;
  3. MP(Main profile)主要档次:提供I/P/B帧,支持Progressive和Interlaced(隔行扫描),提供CAVLC和CABAC。多应用于数字电视广播、数字视频存储等领域;
  4. HiP(High profile)高级档次:(Fidelity Range Extensions,FRExt)在Main profile基础上新增8*8帧内预测,Custom Quant,Lossless Video Coding,更多YUV格式(4:2:2,4:4:4),像素精度提高到10位或14位。多应用于对高分辨率和高清晰度有特别要求的领域。

3. RTMP协议

RTMP中的数据类型有Message(消息)和Chunk(消息块)。Message是RTMP协议中的基本数据单元,而RTMP协议在互联网中传输时,将Message拆分成更小的单元,这个单元就是Chunk
Message格式如下:


Message格式.png
  • Message Type 消息类型:1字节,0x04表示Ping包,0x08为Audio、0x09为video
  • Payload Length 负载消息长度:3字节,表述了tag中数据段的大小
  • Time Stamp 时间戳:4字节,记录每一个tag相对于第一个tag的相对时间。
  • Stream ID 媒体流ID:3字节,标识消息所属的媒体流
  • Message Body 音视频消息

一、架构设计

首先来介绍一下rtmp直播的要点:

  1. 服务器:通过使用nginx+nginx-rtmp-module模块来实现rtmp的流媒体服务器,提供用户连接、数据中转、直播数据存储等功能,该服务器的搭建见Live-Server-9-Maven打包,部署+Nginx服务器这篇文章。
  2. 客户端:客户端要处理的东西就非常多了,分为两大部分:推流和拉流。
  • 推流:采集麦克风的音频数据,通过faac编码成aac格式的音频;采集相机数据,通过x264编码成H.264格式的视频;然后通过rtmp将aac音频和H.264视频封装成flv的视频格式,然后向nginx-rtmp服务器发送数据。
  • 拉流:通过支持rtmp直播流的播放器拉取nginx-rtmp中的数据,并播放。播放器可供选择的有:Bilibili的ijkplayer播放器、ffmpeg播放器。


    推流拉流过程.png

一、推流

推流部分是最复杂的一部分,也是拓展性最强的部分。目前的FAAC、x264、RTMP只是最简单的方案,还可以对视频图像进行人像优化、添加滤镜、图像裁剪、图标等;也可以对麦克风的pcm音频进行变声、变调、去杂音、添加背景声音等。

为了便于实现该部分功能和添加拓展性,将推流部分按如下代码结构来实现:


推流代码结构.png

总体实现思路如下:

  1. 定义一个抽象类BasePusher,并定义开始直播、停止直播、释放资源等控制直播的抽象方法;
  2. 定义视频推流器VideoPusher、音频推流器AudioPusher继承抽象类BasePusher,实现上述抽象方法。
    VideoPusher要控制手机摄像头的开关及数据采集转码等,
    AudioPusher要控制麦克风开关和音频数据录取转码等;
  3. 定义直播推流器LivePusher控制视频推流器和音频推流器的执行。
  4. 视频推流器、音频推流器、直播推流器都各自拥有PushNative类对象,该类是NDK的native方法定义,用来控制NDK原生代码实现视频编码、音频编码、RTMP推流等功能。

(一)PushNative

既然在AudioPusher、VideoPusher、LivePusher中都拥有一个PusherNative类对象,那么我们先来看看这个类需要做什么:

  • 视频编码:设置视频编码格式、发送视频数据包
  • 音频编码:设置音频编码格式、发送音频数据包
  • 推流:开始推流、停止推流、释放资源
/**
 * 调用C代码进行编码和推流
 * @author Ljh 2019/6/15 14:07
 */
public class PushNative {

    /**
     * 异常标识
     * 
     * CONNECT_FAILED:连接异常
     * INIT_FAILED:初始化异常
     * WHAT_FAILED:未知异常
     */
    public static final int CONNECT_FAILED = 101;
    public static final int INIT_FAILED = 102;
    public static final int WHAT_FAILED = 103;

    /**
     * 异常回调监听
     */
    LiveStateChangeListener liveStateChangeListener;
    
    /**
     * 接受Native层抛出的错误
     * @param code
     */
    public void throwNativeError(int code){
        if(liveStateChangeListener != null) {
            liveStateChangeListener.onError(code);
        }
    }

    /**
     * 开始推流
     * @param url 推流地址
     */
    public native void startPush(String url);

    /**
     * 停止推流
     */
    public native void stopPush();

    /**
     * 释放资源
     */
    public native void release();

    /**
     * 设置视频参数
     * @param width 视频宽
     * @param height 视频高
     * @param bitrate 比特率(码率)
     * @param fps 帧率
     */
    public native void setVideoOptions(int width, int height, int bitrate, int fps);

    /**
     * 设置音频参数
     * @param sampleRateInHz 采样率
     * @param channel 声道数
     */
    public native void setAudioOptions(int sampleRateInHz, int channel);

    /**
     * 发送视频数据
     * @param data 视频数据
     */
    public native void fireVideo(byte[] data, int width, int height);

    /**
     * 发送音频数据
     * @param data 音频数据
     * @param len 数据长度
     */
    public native void fireAudio(byte[] data, int len);

    public void removeLiveStateChangeListener() {
        this.liveStateChangeListener = null;
    }

    public void setLiveStateChangeListener(LiveStateChangeListener liveStateChangeListener) {
        this.liveStateChangeListener = liveStateChangeListener;
    }

    static {
        System.loadLibrary("faac");
        System.loadLibrary("x2641");
        System.loadLibrary("rtmp");
        System.loadLibrary("native-lib");
    }
}

在代码中,可以看到PushNative和BasePusher类一样定义了startPush()、stopPush()、release()三个方法来控制直播推流,不过这些方法都是native方法,也就是需要使用C/C++代码进行实现,也就是我们常说的JNI编程。

除了上述几个方法以外,还包含setVideoOptions、setAudioOptions、fireVideo、fireAudio四个方法来实现音视频的格式参数的设置和发送音视频数据包,最后还用了一个LiveStateChangeListener用来监听native代码的异常,在Activity中实现监听的回调就可以完成对native代码异常的处理。

(二)LivePusher直播推流器

接下来看下直播推流器需要做什么?在上面的推流代码结构图中,LivePusher可以看成是直播推流的一个封装类,封装好所需要的接口提供给Activity、Presenter来使用。

前面提到BasePusher中定义了开始推流、停止推流和释放资源三个方法,在VideoPusher、AudioPusher、PushNative中都有实现,那么这些实现方法由谁调用呢?既然是LivePusher来封装,那肯定是由LivePusher来调用,于是在LivePusher中又定义了这三个方法,分别调用推流器中对应的方法。那么问题又来了,如果直接进行推流(startPush()方法),那么怎么知道我推流的音视频编码格式和封装格式呢?不能每次推流前才去设置吧,如果我直播直到一半,突然想暂停,过几分钟又开始直播,就没必要重新设置音视频格式了吧 ?那需要在LivePusher中事先设置好音视频编码格式等属性,于是定义一个prepare()方法,并调用PushNative中的setAudio(Video)Options来实现。

在直播中可能需要切换摄像头,让观众看看主播那精致的面容,那就定义一个切换摄像头的操作,提供给上一层来调用吧。同时还需要将相机预览的数据显示在屏幕上,就需要从Activity中获取一个可以显示预览数据的TextureView给下一层的VideoPusher来进行处理。

public class LivePusher2 implements TextureView.SurfaceTextureListener {

    private TextureView textureView;
    private VideoPusher2 videoPusher;
    private AudioPusher audioPusher;
    private PushNative pushNative;

    public LivePusher2(TextureView textureView, Context context) {
        this.textureView = textureView;
        textureView.setSurfaceTextureListener(this);
        prepare(context);
    }

    //准备音频、视频推流器
    private void prepare(Context context) {
        pushNative = new PushNative();
        //实例化视频推流器
        VideoParam videoParam = new VideoParam(480, 360, Camera.CameraInfo.CAMERA_FACING_BACK);;

        videoPusher = new VideoPusher2(textureView, videoParam, pushNative, context);

        //实例化音频推流器
        AudioParam audioParam = new AudioParam();
        audioPusher = new AudioPusher(audioParam, pushNative);
    }

    /**
     * 切换摄像头
     */
    public void switchCamera() {
        videoPusher.switchCamera();
    }

    /**
     * 开始推流
     *
     * @param url 推流服务器地址
     */
    public void startPush(final String url, LiveStateChangeListener liveStateChangeListener) {
        pushNative.startPush(url);
        videoPusher.startPusher();
        audioPusher.startPusher();
        pushNative.setLiveStateChangeListener(liveStateChangeListener);
    }

    /**
     * 停止推流
     */
    public void stopPush() {
        videoPusher.stopPusher();
        audioPusher.stopPusher();
        pushNative.stopPush();
        pushNative.removeLiveStateChangeListener();
    }

    /**
     * 释放资源
     */
    public void release() {
        videoPusher.release();
        audioPusher.release();
        pushNative.release();
    }

    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {

    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {

    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
        stopPush();
        release();
        return true;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surface) {

    }
}

(三)音频推流与AudioPusher

2. 音频推流实现

音频推流的过程是设置音频格式;通过麦克风获取PCM音频数据,然后给native层的faac库编码成aac格式的音频数据,随后通过rtmp产生音频数据包。

(1)设置音频格式

从上文中得知PushNative类中有setAudioOptions的方法,设置音频格式就是给这个方法传入采样率、声道数等数据,然后在Jni中实现native方法。设置音频格式主要是设置faac编码器的参数。

/**
 * 设置音频参数
 */
extern "C"
JNIEXPORT void JNICALL
Java_com_ljh_live_jni_PushNative_setAudioOptions(JNIEnv *env, jobject instance, jint sampleRateInHz,
                                                 jint numChannel) {
    audio_encode_handle = faacEncOpen(sampleRateInHz, numChannel, &nInputSamples, &nMaxOutputBytes);
    if (!audio_encode_handle) {
        LOGE("%s", "音频编码器打开失败");
        return;
    }
    //设置音频参数
    faacEncConfigurationPtr p_config = faacEncGetCurrentConfiguration(audio_encode_handle);
    p_config->mpegVersion = MPEG4;
    p_config->allowMidside = 1;
    p_config->aacObjectType = LOW;
    p_config->outputFormat = 0; //输出是否包含ADTS头
    p_config->useTns = 1; //时域噪音控制,大概是消除爆破音
    p_config->useLfe = 0;
    p_config->quantqual = 100;
    p_config->bandWidth = 0; //频宽
    p_config->shortctl = SHORTCTL_NORMAL;

    if (!faacEncSetConfiguration(audio_encode_handle, p_config)) {
        LOGE("%s", "音频编码器配置失败");
        throwNativeError(env, INIT_FAILED);
        return;
    }
    LOGI("%s", "音频编码器配置成功");
}

(2)推流实现

1)Java层代码

在Android系统中有这样的一个API:AudioRecord,该类是用于启动麦克风,录制音频并产生PCM音频数据。AudioRecord的使用步骤如下:

  1. 创建最小缓冲区
    最小缓冲区需要采样率、声道数、采样(量化)精度来设定。
minBufferSize = AudioRecord.getMinBufferSize(audioParam.getSampleRateInHz(), channelConfig, AudioFormat.ENCODING_PCM_16BIT);
  1. 创建AudioRecord对象
    AudioRecord对象的创建需要声音源、采样率、声道数、采样(量化)精度、最小缓冲区来创建。
audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                audioParam.getSampleRateInHz(),
                channelConfig,
                AudioFormat.ENCODING_PCM_16BIT,
                minBufferSize);
  1. 开始录制
audioRecord.startRecording();
  1. 获取PCM数据
while(true){
    //通过AudioRecord不断读取音频数据
    byte[] buffer = new byte[minBufferSize];
    int len = audioRecord.read(buffer, 0, buffer.length);
}

audioRecord.read()运行在当前线程中,如果不断的调用该方法,会导致Android UI线程阻塞,导致ANR,因此需要创建一个新的线程来执行。

class AudioRecordTask implements Runnable {
        @Override
        public void run() {
            //开始录音
            audioRecord.startRecording();

            while (isPushing) {
                //通过AudioRecord不断读取音频数据
                byte[] buffer = new byte[minBufferSize];
                int len = audioRecord.read(buffer, 0, buffer.length);
            }
        }
    }

既然已经在子线程中获取到了PCM数据,那么何不直接将其传给PushNative来完成编码和推送呢?于是在循环中添加如下代码:

  if (len > 0) {
        //传给Native代码,进行音频编码
        pushNative.fireAudio(buffer, len);
 }
2)Native代码

接下来又是Native代码的编写。需要明确的是音频推流的native部分主要是faac库和rtmp库的使用,在设置音频格式时,已经初始化了audio_encode_handle(faacEncHandle),也就是初始化了faac音频编码处理器,获取到数据时,就可以直接进行AAC编码了,编码完成后就加入到RTMP消息队列中。

每次从实时的pcm音频队列中读出量化位数为8的pcm数据,用8个二进制位来表示一个采样量化点(模数转换),然后调用faacEncEncode这个函数来编码,需要传入编码处理器audio_encode_handle、转换后的pcm流数组pcmbuf、采样数量audioLength(采样的pcm数组大小)、编码后的aac音频数组、最大输出字节数。

/**
 * 对音频采样数据进行AAC编码
 */
extern "C"
JNIEXPORT void JNICALL
Java_com_ljh_live_jni_PushNative_fireAudio(JNIEnv *env, jobject instance, jbyteArray buffer,
                                           jint len) {
    //转换后的pcm流数组
    int *pcmbuf;
    //编码后的数据buff
    unsigned char *bitbuf;
    jbyte *b_buffer = env->GetByteArrayElements(buffer, NULL);
    if (b_buffer == NULL) {
        LOGI("%s", "音频数据为空");
    }
    pcmbuf = (int *) malloc(nInputSamples * sizeof(int));
    bitbuf = (unsigned char *) malloc(nMaxOutputBytes * sizeof(unsigned char));
    int nByteCount = 0;
    unsigned int nBufferSize = (unsigned int) len / 2;
    unsigned short *buf = (unsigned short *) b_buffer;
    while (nByteCount < nBufferSize) {
        int audioLength = nInputSamples;
        if ((nByteCount + nInputSamples) >= nBufferSize) {
            audioLength = nBufferSize - nByteCount;
        }
        int i;
        for (i = 0; i < audioLength; ++i) {
            //每次从实时的pcm音频队列中读取量化位数为8的pcm数据
            int s = ((int16_t *) buf + nByteCount)[i];
            pcmbuf[i] = s << 8;  //用8个二进制位来表示一个采样量化点(模数转换)
        }
        nByteCount += nInputSamples;

        //利用FAAC进行编码,pcmbuf为转换后的pcm流数组,audioLength为调用faacEncOpen时得到的输入采样数
        //bitbuf为编码后的数据buff,nMaxOutputBytes为调用faacEncOpen时得到的最大输出字节数
        int byteslen = faacEncEncode(audio_encode_handle, pcmbuf, audioLength, bitbuf,
                                     nMaxOutputBytes);
        if (byteslen < 1) {
            continue;
        }
        add_aac_body(bitbuf, byteslen); //从bitbuf中得到编码后的aac数据流,放到数据队列
    }

    env->ReleaseByteArrayElements(buffer, b_buffer, 0);
    if (bitbuf) {
        free(bitbuf);
    }
    if (pcmbuf) {
        free(pcmbuf);
    }
}

完成AAC编码后,接下来就要将AAC数据传给rtmp封装成RTMP Packet。

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

推荐阅读更多精彩内容