ijkPlayer主流程分析

這是一個跨平臺的播放器ijkplayer,iOS上集成看【如何快速的開發一個完整的iOS直播app】(原理篇)

為了學習ijkplayer的代碼,最好的還是使用workspace來集成,關于worksapce我有一篇簡單介紹iOS使用Workspace來管理多項目。這樣可以點擊函數名查看源碼,也可以設置斷點,修改源碼測試等等。

主架構

直播框架.png

每個類型的數據流構建各自的packet和frame緩沖區,讀取packet后根據類型分到不同的流程里。在最后顯示的時候,根據pts同步音視頻播放。

prepareToPlay

使用ijkPlayer只需兩部:用url構建IJKFFMoviePlayerController對象,然后調用它的prepareToPlay。因為init時構建的一些東西得到使用的時候才明白它們的意義,所以先從prepareToPlay開始看。

prepareToPlay流程.png

上圖是調用棧的流程,最終起關鍵作用的就是read_threadvideo_refresh_thread這兩個函數。

  • read_thread負責解析URL,把數據拿到
  • video_refresh_thread負責把內容顯示出來
  • 中間步驟在read_thread運行后逐漸開啟

上述兩個方法都是在新的線程里運行的,使用了SDL_CreateThreadEx這個方法,它開啟一個新的pthread,在里面運行指定的方法。

解碼部分

開啟數據流的流程.png
  • 淡黃色背景的是ffmpeg庫的函數,這幾個是解碼流程必須的函數。
  • 先獲取分析流,對每個類型流開啟解碼器。stream_component_open函數這里做了一點封裝。
  • 讀取packet之后,根據packet類型分到不同的packetQueue里面去:
     if (pkt->stream_index == is->audio_stream && pkt_in_play_range) {
              packet_queue_put(&is->audioq, pkt);
          } else if (pkt->stream_index == is->video_stream && pkt_in_play_range
                     && !(is->video_st && (is->video_st->disposition & 
     AV_DISPOSITION_ATTACHED_PIC))) {
              packet_queue_put(&is->videoq, pkt);
          } else if (pkt->stream_index == is->subtitle_stream && pkt_in_play_range) {
              packet_queue_put(&is->subtitleq, pkt);
          } else {
              av_packet_unref(pkt);
          }
    
    packet_queue_put是ijkplayer封裝的一個緩沖區PacketQueue
    的入隊方法。PacketQueue是采用鏈表構建的循環隊列,每個節點循環使用,一部分節點空閑,一部分使用中。就像一隊卡車來回運貨,有些車是空的,有些是裝滿貨的,循環使用。
  • video_threadaudio_thread的作用把packet緩沖區里的packet解碼成frame,裝入frame緩沖區
  • video_thread那里有個大坑,就是執行到node->func_run_sync(node)之后就是函數指針,要繞一大圈才能找到真正的函數。因為解碼涉及到平臺不同,iOS里還有VideoToolbox的解碼,所以這里做了個解耦處理。
    • node->func_run_sync(node)的node來源于ffp->node_vdec
    • ffp->node_vdec來源于pipeline->func_open_video_decoder(pipeline, ffp)
    • pipeline來源于最開始init播放器的時候的_mediaPlayer = ijkmp_ios_create(media_player_msg_loop);安卓平臺可能就不是這個函數了。也就是pipeline從播放器初始化的時候就不一樣了,然后node就不同了,node的func_run_sync也跟著不同了。
    • iOS里面做了VideoToolbox和ffmpeg解碼的區分。

解碼packet得到frame并入隊frameQueue

解碼packet并入隊frame.png
  • get_video_frame實際調用decoder_decode_frame,而這個函數在其他流解碼時也是走這個。
  • decoder_decode_frame調用packetQueue的方法獲取最早的packet,然后調用各自的解碼函數得到frame
  • packetQueue里面是空的時候,如果立馬返回,然后又來一次循環,很可能循環了N多次還沒有得到新的packet。視頻幀率一般好的也就60幀,一秒60次,而get_video_frame這種while循環一秒能幾次?這里如果不阻塞線程等待新的packet入隊,會占用大量cpu。
  • queue_picture把得到的frame加入到frameQueue里面去。
    這里不僅僅是做了入隊處理,還把AVFrame轉化成了一個自定義類型SDL_VoutOverlay
    struct SDL_VoutOverlay {
      int w; /**< Read-only */
      int h; /**< Read-only */
      Uint32 format; /**< Read-only */
      int planes; /**< Read-only */
      Uint16 *pitches; /**< in bytes, Read-only */
      Uint8 **pixels; /**< Read-write */
    
      int is_private;
    
      int sar_num;
      int sar_den;
    
      SDL_Class               *opaque_class;
      SDL_VoutOverlay_Opaque  *opaque;
    
      void    (*free_l)(SDL_VoutOverlay *overlay);
      int     (*lock)(SDL_VoutOverlay *overlay);
      int     (*unlock)(SDL_VoutOverlay *overlay);
      void    (*unref)(SDL_VoutOverlay *overlay);
    
      int     (*func_fill_frame)(SDL_VoutOverlay *overlay, const AVFrame *frame);
    };
    
    當關系到播放的時候,又回到了上層,所以這里又要做平臺適配問題。SDL_VoutOverlay在我的理解里,更像是一個中間件,把AVFrame里面的視頻信息都轉化到這個中間件里,不管之后你用什么方式去播放,不必對AVFrame造成依賴。這樣將播放功能和解碼功能接觸聯系了。我發現在c編程里,函數指針幾乎起著protocol+delegate/interface的作用。

到這,數據流開啟了,packet得到了,frame得到了。read_thread的任務結束了。然后就是把frame顯示出來的事了。

顯示部分

  • 視頻的顯示函數是video_refresh_thread,在第一張圖里
  • 音頻的播放函數是audio_open,在第二張圖里,這時是已經解析到了音頻流并且獲得了音頻的格式后。這么做,我的理解是:音頻和視頻不同,視頻播放最終都是要轉成rgb,得到樣顯示的時候再轉;而音頻采樣率、格式、數這些可以設置成和源數據一樣,就不需要轉換了,所以需要先獲取了音頻源數據的格式,再開啟音頻播放。

視頻播放

video_refresh_thread

video_refresh_thread.png
  • video_refresh_thread就是不斷的顯示下一幀,如果距離下一幀還有時間,就用av_usleep先暫停會。

  • video_refresh內部:

    • 先獲取最早的frame,計算和下一幀的時間(vp_duration)
    • 紅色部分就是重點,用來處理音視頻同步的。根據同步的方式,獲取修正過后的下一幀時間。
    • 如果時間沒到直接跳過退出video_refresh,進入av_usleep暫停下。代碼里有點坑的是,這里寫的是goto display;,而display代碼塊需要is->force_refresh這個為true才真的起作用,所以實際上是結束了,看起來有些誤導。
    • 確定要顯示下一幀之后,才調用frame_queue_next把下一幀推到隊列關鍵位,即索引rindex指定的位置。
    • video_display2最終到了SDL_Voutdisplay_overlay函數。和前面一樣,到了顯示層,在這做了解耦處理,SDL_Vout對象是ffp->vout,也是在IJKFFMoviePlayerControllerinit里構建的。
  • 對iOS,SDL_Voutdisplay_overlay最終到了IJKSDLGLView- (void)display: (SDL_VoutOverlay *) overlay函數。下面的內容就是在iOS里如何用OpenGLES顯示視頻的問題了。可以看看用OpenGLES實現yuv420p視頻播放界面, ijkplayer里的顯示代碼有點復雜,貌似是為了把OpenGL ES部分再拆分出來做多平臺共用。

音頻播放

音頻和視頻的邏輯不一樣,視頻是搭建好播放層(OpenGLES的view等)后,把一幀幀的數據 主動 推過去,而音頻是開啟了音頻隊列后,音頻隊列會過來跟你要數據,解碼層這邊是 被動 的把數據裝載進去。

音頻和視頻的播放邏輯不同.png
  • 音頻Buffer就像卡車一樣,用完了,就再回來裝載音頻數據,然后回去再播放。如此循環。
  • 音頻有兩部分:1. 開啟音頻,這部分函數是audio_open 2. 裝載音頻數據,這部分函數是sdl_audio_callback
  • 不管開啟音頻還是填充音頻buffer,都做了解耦處理,音頻隊列的部分是iOS里的AudioQueue的運行邏輯。
audio_open

必須先說幾個結構體,它們是連接界面層和顯示層的紐帶。

  • SDL_Aout
typedef struct SDL_Aout_Opaque SDL_Aout_Opaque;
typedef struct SDL_Aout SDL_Aout;
struct SDL_Aout {
    SDL_mutex *mutex;
    double     minimal_latency_seconds;

    SDL_Class       *opaque_class;
    SDL_Aout_Opaque *opaque;
    void (*free_l)(SDL_Aout *vout);
    int (*open_audio)(SDL_Aout *aout, const SDL_AudioSpec *desired, SDL_AudioSpec *obtained);
    ......
    一些操作函數,開始、暫停、結束等
};

SDL_Aout_Opaque在這(通用模塊)只是typedef重定義了下,并沒有實際的結構,實際定義根據各平臺而不同,這也是解耦的手段之一。open_audio之后它的值才設置,在iOS里:

struct SDL_Aout_Opaque {
    IJKSDLAudioQueueController *aoutController;
};
.....
//aout_open_audio函數里
opaque->aoutController = [[IJKSDLAudioQueueController alloc] initWithAudioSpec:desired];

這個值用來存儲IJKSDLAudioQueueController對象,是負責音頻播放的音頻隊列的控制器。這樣,一個重要的連接元素就被合理的隱藏起來了,對解碼層來說不用關系它具體是誰,因為使用它的操作也是函數指針,實際被調用的函數平臺各異,到時這個函數內部就可以使用SDL_Aout_Opaque的真身了。

  • SDL_AudioSpec
typedef struct SDL_AudioSpec
{
    int freq;                   /**采樣率 */
    SDL_AudioFormat format;     /**音頻格式*/
    Uint8 channels;             /**聲道 */
    Uint8 silence;              /**空白聲音,沒數據時用來播放 */
    Uint16 samples;             /**一個音頻buffer的樣本數,對齊成2的n次方,用來做音視頻同步處理 */
    Uint16 padding;             /**< NOT USED. Necessary for some compile environments */
    Uint32 size;                /**< 一個音頻buffer的大小*/
    SDL_AudioCallback callback;
    void *userdata;
} SDL_AudioSpec;
  • 這個結構體基本就是對音頻格式的描述,拿到了我們要播放的音頻格式,顯示層可能可以播也可能不能播,所以需要溝通一下:把源音頻格式傳給顯示層,然后顯示層根據自身情況返回一個兩者都接受的格式回來。所以也就需要一個用于描述音頻格式的通用結構,SDL_AudioSpec就出來了。
  • 音頻3大樣:采樣率(freq)、格式(format)、聲道(channels)。格式是自定義復合類型,這個后面使用到的時候再說。
  • samples 這個其實是用來做音視頻頻同步的,因為音頻隊列的這種架構,填充音頻Buffer的時候,音頻還沒有播放,而解碼層只接觸到填充任務,所以它得不到正確的播放時間和音頻pts的差值。有了一個音頻Buffer的樣本數,除以采樣率就是一個音頻Buffer的播放時間,就可以估算正在裝載的音頻Buffer在未來的播放時間。音視頻同步的問題以后單獨說,比較復雜(如果我能都摸清楚的話-_-)。
  • callback 這個是填充音頻Buffer的回調方法,前面說填充任務是被動的,所以顯示層得知道如何告訴解碼層要填充音頻數據了,這個callback就負擔著這個任務。顯示層一個音頻Buffer空了,就只管調用這個callback就好了。
IJKSDLAudioQueueController

再說audio_open,它實際調用了SDL_Aoutopen_audio函數,在iOS里,最終到了ijksdl_aout_ios_audiounit.m文件的aout_open_audio,關鍵就是這句:

opaque->aoutController = [[IJKSDLAudioQueueController alloc] initWithAudioSpec:desired];

重點轉到IJKSDLAudioQueueController這個類。如果對上圖里audioQueue的處理邏輯理解了,就比較好辦。

  • AudioQueueNewOutput 構建一個用于播放音頻的audioQueue

      extern OSStatus             
     AudioQueueNewOutput( const AudioStreamBasicDescription *inFormat,
                                      AudioQueueOutputCallback        inCallbackProc,
                                      void * __nullable               inUserData,
                                      CFRunLoopRef __nullable         inCallbackRunLoop,
                                      CFStringRef __nullable          inCallbackRunLoopMode,
                                      UInt32                          inFlags,
                                      AudioQueueRef __nullable * __nonnull outAQ) 
    
    • inCallbackProc 這個就是用來指定audioQueue需要裝載AudioBuffer的時候的回調函數。
    • inUserData 就是傳給回調函數的參數,這種東西比較常見。
    • inCallbackRunLoop 和inCallbackRunLoopMode就是回調函數觸發的runloop和mode。
    • inFlags無意義保留位置。
    • AudioQueueRef 就是新構建的audioQueue
    • AudioStreamBasicDescription是音頻格式的描述,這個就是前面說的SDL_AudioSpec對應的東西,需要根據傳入的SDL_AudioSpec生成AudioQueue的格式:
    extern void IJKSDLGetAudioStreamBasicDescriptionFromSpec(const 
    SDL_AudioSpec *spec, AudioStreamBasicDescription *desc)
    {
    desc->mSampleRate = spec->freq;
    desc->mFormatID = kAudioFormatLinearPCM;
    desc->mFormatFlags = kLinearPCMFormatFlagIsPacked;
    desc->mChannelsPerFrame = spec->channels;
    desc->mFramesPerPacket = 1;
    
    desc->mBitsPerChannel = SDL_AUDIO_BITSIZE(spec->format);
    if (SDL_AUDIO_ISBIGENDIAN(spec->format))
        desc->mFormatFlags |= kLinearPCMFormatFlagIsBigEndian;
    if (SDL_AUDIO_ISFLOAT(spec->format))
        desc->mFormatFlags |= kLinearPCMFormatFlagIsFloat;
    if (SDL_AUDIO_ISSIGNED(spec->format))
        desc->mFormatFlags |= kLinearPCMFormatFlagIsSignedInteger;
    
    desc->mBytesPerFrame = desc->mBitsPerChannel * desc->mChannelsPerFrame / 8;
    desc->mBytesPerPacket = desc->mBytesPerFrame * desc->mFramesPerPacket;
    }
    

    格式是默認的kAudioFormatLinearPCM,但是采樣率(mSampleRate)和聲道(mChannelsPerFrame)是直接從SDL_AudioSpec拷貝過來。mFormatFlags比較復雜,有許多參數需要設置,比如是否有符號數:

    #define SDL_AUDIO_MASK_SIGNED        (1<<15)
    #define SDL_AUDIO_ISSIGNED(x)        (x & SDL_AUDIO_MASK_SIGNED)
    

    就是在spec->format的第16位上保存這個標識,使用&的方式取出來。使用標識位的方式,把多個信息存在一個值里面,其實在枚舉里面也是這個手段。位運算和枚舉這里有我對枚舉的一點認識。

    SDL_AUDIO_BITSIZE是計算這個音頻格式下單個樣本的大小,注意單位是比特,這個宏也就是把spec->format最低的8位取了出來,在之前構建spec->format的時候這個值就被存進去了。

    默認是使用s16也就是“有符號16比特”的格式,定義是這樣的:#define AUDIO_S16LSB 0x8010 /**< Signed 16-bit samples */,16位上是1,所以是有符號,低8位是0x10,也就是16。這只是ijkplayer自定義的一種復合類型,或說傳遞手段,并不是一種標準。

  • AudioQueueStart 開啟audioQueue沒啥好說的

  • 分配幾個AudioBuffer

    for (int i = 0;i < kIJKAudioQueueNumberBuffers; i++)
         {
             AudioQueueAllocateBuffer(audioQueueRef, _spec.size, &_audioQueueBufferRefArray[i]);
             _audioQueueBufferRefArray[i]->mAudioDataByteSize = _spec.size;
             memset(_audioQueueBufferRefArray[i]->mAudioData, 0, _spec.size);
             AudioQueueEnqueueBuffer(audioQueueRef, _audioQueueBufferRefArray[i], 0, NULL);
         }
    

    這里的重點是size的問題,AudioBuffer該多大?假如你希望1s裝載m次AudioBuffer,那么1s裝載的音頻數據就是m x AudioBuffer.size。而音頻播放1s需要多少數據?有個公式:1s數據大小 = 采樣率 x 聲道數 x 每個樣本大小。所以AudioBuffer.size = (采樣率 x 聲道數 x 每個樣本大小)/ m。再看ijkplayer里求_spec.size的代碼

    //2 << av_log2這么搞一下是為了對齊成2的n次方吧。
    wanted_spec.samples = FFMAX(SDL_AUDIO_MIN_BUFFER_SIZE, 
    2 << av_log2(wanted_spec.freq / SDL_AoutGetAudioPerSecondCallBacks(ffp->aout)));      
    .....
    void SDL_CalculateAudioSpec(SDL_AudioSpec * spec)
    {
    .....
      spec->size = SDL_AUDIO_BITSIZE(spec->format) / 8;
      spec->size *= spec->channels;
      spec->size *= spec->samples;
    }
    

    spec->samples是單個AudioBuffer的樣本數,而不是采樣率,注意除以了SDL_AoutGetAudioPerSecondCallBacks,這個就是1s中裝載AudioBuffer的次數。

  • 裝載回調IJKSDLAudioQueueOuptutCallback和裝載之后AudioQueueEnqueueBuffer

    static void IJKSDLAudioQueueOuptutCallback(void * inUserData,   AudioQueueRef inAQ, AudioQueueBufferRef inBuffer) {
      @autoreleasepool {
          ....
          (*aqController.spec.callback)(aqController.spec.userdata, inBuffer->mAudioData, inBuffer->mAudioDataByteSize);
          AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, NULL);
      }
    }
    

    在這里接力,調用spec.callback,從這又回到解碼層,那邊把AudioBuffer裝滿,AudioQueueEnqueueBuffer在把AudioBuffer推給AudioQueue入隊播放。

sdl_audio_callback填充AudioBuffer

整體邏輯是:循環獲取音頻的AVFrame,填充AudioBuffer。比較麻煩的兩個點是:

  • 裝載AudioBuffer的時候,裝滿了但是音頻幀數據還省一點的,這一點不能丟
  • 獲取音頻幀數據的時候可能需要轉格式
音頻Buffer裝載.png

因為剩了一截,那么下一次填充就不能從完整的音頻幀數據開始了。所以需要一個索引,指示AudioFrame的數據上次讀到哪了,然后從這開始讀。

while (len > 0) {
        //獲取新的audioFrame
        if (is->audio_buf_index >= is->audio_buf_size) {
           audio_size = audio_decode_frame(ffp);
           if (audio_size < 0) {
               is->audio_buf = NULL;
               is->audio_buf_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_tgt.frame_size * is->audio_tgt.frame_size;
           } else {
               is->audio_buf_size = audio_size;
           }
           is->audio_buf_index = 0;
        }
        
        len1 = is->audio_buf_size - is->audio_buf_index;
        if (len1 > len)
            len1 = len;
        if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)
            memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
        else {
            memset(stream, 0, len1);
        }
        len -= len1;
        stream += len1;
        is->audio_buf_index += len1;
    }
  • len代表當前AudioBuffer剩余的填充量,因為len -= len1;每次循環都會減去這次的填充量
  • audio_buf_size是當前的AudioFrame總的數據大小,audio_buf_index是這個AudioFrame上次讀到的位置。所以memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);這句填充函數里,從audio_buf+audio_buf_index的位置開始讀。
  • len1開始是當前的AudioFrame剩余的數據大小,如果超過了AudioBuffer剩余的填充量(len),就只填充len這么多。
  • 如果不可填充,就插入0:memset(stream, 0, len1)
audio_decode_frame獲取音頻幀數據
  • frame_queue_nb_remaining沒有數據,先睡會av_usleep (1000);
  • frame_queue_peek_readable讀取新的音頻幀,frame_queue_next調到下一個位置。
  • 轉換分3部:
    • 設置轉換參數
    is->swr_ctx = swr_alloc_set_opts(NULL,
                                           is->audio_tgt.channel_layout, is->audio_tgt.fmt, is->audio_tgt.freq,
                                           dec_channel_layout,           af->frame->format, af->frame->sample_rate,
                                           0, NULL);
    
    3個轉換前的音頻參數,3個轉換后的參數,都是3大樣:聲道、格式、采樣率。
    • 初始化轉換
      swr_init(is->swr_ctx) < 0
    • 轉換操作
    const uint8_t **in = (const uint8_t **)af->frame->extended_data;
    uint8_t **out = &is->audio_buf1;
    int out_count = (int)((int64_t)wanted_nb_samples * is->audio_tgt.freq / af->frame->sample_rate + 256);
    int out_size  = av_samples_get_buffer_size(NULL, is->audio_tgt.channels, out_count, is->audio_tgt.fmt, 0);
    .....
    av_fast_malloc(&is->audio_buf1, &is->audio_buf1_size, out_size);
    ......
    len2 = swr_convert(is->swr_ctx, out, out_count, in, af->frame->nb_samples);
    
    inframe->extended_data,就是原始的音頻數據,音頻數據時存放在extended_data里而不是data里。
    outis->audio_buf1的地址,轉換完后,數據直接寫到了 is->audio_buf1里。
    av_fast_malloc是重用之前的內存來快速分配內存,audio_buf1是一個重復利用的中間量,在這里裝載數據,在播放的時候用掉數據。所以可以利用好之前的內存,不必每次都構建。
  • 不管是否轉換,數據都到了is->audio_buf,填充AudioBuffer的時候就是用的這個。

最后

把主流程和某些細節寫了一遍,這個項目還有一些值得說的問題:

  • 音視頻同步
  • AVPacket和AVFrame的內存管理和坑
  • 緩沖區的構建和操作,這應該是有一種最佳實踐的,而且這是一個很通用的問題。
  • 平臺各異的顯示層和核心的解碼層是怎么連接到一起又互相分離的。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,702評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,615評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,606評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,044評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,826評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,227評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,307評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,447評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,992評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,807評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,001評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,550評論 5 361
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,243評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,667評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,930評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,709評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,996評論 2 374

推薦閱讀更多精彩內容