Android 音視頻開發(fā):FFmpeg 播放器

前言

本篇文章屬于 Android NDK 模塊,需要讀者有一點(diǎn) NDK 相關(guān)的基礎(chǔ)和 C/C++ 基礎(chǔ)棠笑,不然其中的語法會(huì)有點(diǎn)晦澀難懂。本篇文章共分為以下五個(gè)專題,通過這五個(gè)專題的學(xué)習(xí)最終帶大家制作一款屬于自己的直播流播放器硫椰。

  1. 直播流信息獲取
  2. 視頻解碼與原生繪制
  3. 音頻解碼與 OpenSL
  4. 音視頻同步
  5. 音視頻停止與釋放

在學(xué)習(xí)第一個(gè)專題之前我們先掌握一些基礎(chǔ)知識(shí)。我們知道播放在手機(jī)上的視頻圖像是由 RGB 三原色組成的萨蚕,視頻的話是各種圖片的集合靶草,由于 RGB 數(shù)據(jù)量太大我們需要進(jìn)行壓縮減少數(shù)據(jù)大小節(jié)省帶寬和磁盤空間。為什么可以壓縮呢岳遥,可以從以下幾個(gè)方面進(jìn)行考慮奕翔。

去除冗余信息

  • 空間冗余:圖像相鄰像素之間有較強(qiáng)的相關(guān)性
  • 時(shí)間冗余:視頻序列的相鄰圖像之間內(nèi)容相似
  • ?編碼冗余:不同像素值出現(xiàn)的概率不同?- 視覺冗余:人的視覺系統(tǒng)對(duì)某些細(xì)節(jié)不敏感
  • ?知識(shí)冗余:規(guī)律性的結(jié)構(gòu)可由先驗(yàn)知識(shí)和背景知識(shí)得到

我們需要做的是獲得壓縮數(shù)據(jù)解壓縮展示在手機(jī)上。那具體的流程是什么樣的呢浩蓉?比如說我們獲得了一個(gè) MP4 文件派继,如何解壓縮成為可以展示的 RGB 圖像呢?

我們可以借助 FFmpeg 進(jìn)行解封裝解碼的工作捻艳,F(xiàn)Fmpeg 不僅內(nèi)部實(shí)現(xiàn)了編解碼算法還可以集成其他的編解碼框架互艾,目前抖音斗魚等各種直播軟件都使用了 FFmpeg 所以說還是很強(qiáng)大的。

Android 做視頻只能通過 FFmpeg 嗎讯泣?FFmpeg 是通過軟編解碼通過代碼進(jìn)行編解碼纫普,還有一個(gè)硬編解碼,Android 中的 Media Codec 使用的就是硬編解碼好渠,由于兼容性比較差如果沒有廠商的硬件支持基本上是兼容不了昨稼。OK,下面首先介紹一下我們的工程結(jié)構(gòu)拳锚。

我的 build 文件假栓,配置 CPU 兼容 和 CMakeLists:

      externalNativeBuild {
            cmake {
                cppFlags ""
                abiFilters 'armeabi-v7a'
            }
        }
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }

我的 CMakeLists 文件

    cmake_minimum_required(VERSION 3.4.1)
    # 創(chuàng)建一個(gè)變量 source_file 它的值就是src/main/cpp/ 所有的.cpp文件
    file(GLOB source_file src/main/cpp/*.cpp)
    add_library(
             native-lib

             SHARED

             ${source_file} )
             include_directories(src/main/cpp/include)

             set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/cpp/libs/${ANDROID_ABI}")
             #avfilter avformat    avcodec  avutil  swresample swscale
             target_link_libraries(  native-lib
                avformat avcodec avfilter    avutil swresample swscale
                      log  z)

enter image description here

Java 層我們新建兩個(gè)文件,MainActivity 播放器的主界面霍掺,MyPlayer 播放器的管理類匾荆。

enter image description here

cpp 中的 libs 存放的是我們編譯出來的 FFmpeg 的 .a 庫,JavaCallHelper 類實(shí)現(xiàn)了 C++ 反射調(diào)用 Java 代碼杆烁,MyFFmpeg 中編寫獲取直播流解碼的代碼牙丽,獲取到流后分為視頻流和音頻流,分別用 VideoChannel 和 AudioChannel 類進(jìn)行處理兔魂。為了方便我編寫了一個(gè) util.h 文件來定義一些宏函數(shù)烤芦,由于直播涉及到線程我編寫了一個(gè)線程安全的類 safe_queue。OK析校,整體項(xiàng)目結(jié)構(gòu)就是這樣构罗,下面開始進(jìn)行代碼的講解铜涉。

直播流信息獲取

    public class MainActivity extends AppCompatActivity {

    private MyPlayer myPlayer;

    /**
     * 1,RTMP協(xié)議直播源
     * 香港衛(wèi)視:rtmp://live.hkstv.hk.lxdns.com/live/hks
     *
     * 2遂唧,RTSP協(xié)議直播源
     * 珠海過澳門大廳攝像頭監(jiān)控:rtsp://218.204.223.237:554/live/1/66251FC11353191F/e7ooqwcfbqjoo80j.sdp
     * 大熊兔(點(diǎn)播):rtsp://184.72.239.149/vod/mp4://BigBuckBunny_175k.mov
     *
     * 3芙代,HTTP協(xié)議直播源
     * 香港衛(wèi)視:http://live.hkstv.hk.lxdns.com/live/hks/playlist.m3u8
     * CCTV1高清:http://ivi.bupt.edu.cn/hls/cctv1hd.m3u8
     * CCTV3高清:http://ivi.bupt.edu.cn/hls/cctv3hd.m3u8
     * CCTV5高清:http://ivi.bupt.edu.cn/hls/cctv5hd.m3u8
     * CCTV5+高清:http://ivi.bupt.edu.cn/hls/cctv5phd.m3u8
     * CCTV6高清:http://ivi.bupt.edu.cn/hls/cctv6hd.m3u8
     * 蘋果提供的測試源(點(diǎn)播):http://devimages.apple.com.edgekey.net/streaming/examples/bipbop_4x3/gear2/prog_index.m3u8
     *
     * @param savedInstanceState
     */

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        SurfaceView surfaceView = findViewById(R.id.surfaceView);
        myPlayer = new MyPlayer();
        myPlayer.setSurfaceView(surfaceView);
        myPlayer.setDataSource("rtmp://live.hkstv.hk.lxdns.com/live/hks");
        myPlayer.setOnPrepareListener(new MyPlayer.OnPrepareListener() {
            @Override
            public void onPrepare() {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(MainActivity.this, "可以開始播放了", 0).show();
                    }
                });
            }
        });

    }

    public void start(View view) {
        myPlayer.prepare();
    }}

主界面主要做的工作就是用 SurfaceView 來播放音視頻流,實(shí)例化 MyPlayer 將 surfaceView 傳遞給 MyPlayer 設(shè)置播放地址盖彭,實(shí)現(xiàn)一個(gè)準(zhǔn)備播放的監(jiān)聽链蕊,一個(gè)開始播放的方法。

    /**
 * 提供java 進(jìn)行播放 停止 等函數(shù)
 */

    public class MyPlayer implements SurfaceHolder.Callback {
    static {
        System.loadLibrary("native-lib");
    }

    private String dataSource;
    private SurfaceHolder holder;
    private OnPrepareListener listener;

    /**
     * 讓使用 設(shè)置播放的文件 或者 直播地址
     */
    public void setDataSource(String dataSource) {
        this.dataSource = dataSource;
    }

    /**
     * 設(shè)置播放顯示的畫布
     *
     * @param surfaceView
     */
    public void setSurfaceView(SurfaceView surfaceView) {
        holder = surfaceView.getHolder();
        holder.addCallback(this);
    }

    public void onError(int errorCode){
        System.out.println("Java接到回調(diào):"+errorCode);
    }

    public void onPrepare(){
        if (null != listener){
            listener.onPrepare();
        }
    }

    public void setOnPrepareListener(OnPrepareListener listener){
        this.listener = listener;
    }
    public interface OnPrepareListener{
        void onPrepare();
    }

    /**
     * 準(zhǔn)備好 要播放的視頻
     */
    public void prepare() {
        native_prepare(dataSource);
    }

    /**
     * 開始播放
     */
    public void start() {

    }

    /**
     * 停止播放
     */
    public void stop() {

    }

    public void release() {
        holder.removeCallback(this);
    }

    /**
     * 畫布創(chuàng)建好了
     *
     * @param holder
     */
    @Override
    public void surfaceCreated(SurfaceHolder holder) {

    }

    /**
     * 畫布發(fā)生了變化(橫豎屏切換谬泌、按了home都會(huì)回調(diào)這個(gè)函數(shù))
     *
     * @param holder
     * @param format
     * @param width
     * @param height
     */
    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

    }

    /**
     * 銷毀畫布 (按了home/退出應(yīng)用/)
     *
     * @param holder
     */
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {

    }

    native void native_prepare(String dataSource);}

MyPlayer 類實(shí)現(xiàn)了一個(gè) SurfaceHolder.Callback 接口滔韵,我們看下源碼:

      public interface Callback {
        /**
         * This is called immediately after the surface is first created.
         * Implementations of this should start up whatever rendering code
         * they desire.  Note that only one thread can ever draw into
         * a {@link Surface}, so you should not draw into the Surface here
         * if your normal rendering will be in another thread.
         *
         * @param holder The SurfaceHolder whose surface is being created.
         */
        public void surfaceCreated(SurfaceHolder holder);

        /**
         * This is called immediately after any structural changes (format or
         * size) have been made to the surface.  You should at this point update
         * the imagery in the surface.  This method is always called at least
         * once, after {@link #surfaceCreated}.
         *
         * @param holder The SurfaceHolder whose surface has changed.
         * @param format The new PixelFormat of the surface.
         * @param width The new width of the surface.
         * @param height The new height of the surface.
         */
        public void surfaceChanged(SurfaceHolder holder, int format, int width,
                int height);

        /**
         * This is called immediately before a surface is being destroyed. After
         * returning from this call, you should no longer try to access this
         * surface.  If you have a rendering thread that directly accesses
         * the surface, you must ensure that thread is no longer touching the
         * Surface before returning from this function.
         *
         * @param holder The SurfaceHolder whose surface is being destroyed.
         */
        public void surfaceDestroyed(SurfaceHolder holder);
    }

可以看到這個(gè)接口有三個(gè)方法要我們實(shí)現(xiàn),控制了畫布創(chuàng)建掌实、畫布改變陪蜻、畫布銷毀。在 setSurfaceView 中 getHolder 和 addCallback贱鼻,在 release() 方法中調(diào)用 holder.removeCallback 釋放掉 Callback宴卖,防止內(nèi)存泄漏。由于視頻的編解碼操作是在 native 方法中邻悬,所以定義一個(gè) native_prepare 方法把 dataSource 傳進(jìn)去烙肺。下面開始 native 方法的編寫富拗。在 Myplayer 類中編寫 native void native_prepare(String dataSource); 通過快捷鍵 Alt+Enter 可以在 native-lib 中快速生成相對(duì)應(yīng)的 native 方法。

    MyFFmpeg *ffmpeg = 0;
    extern "C"
    JNIEXPORT void JNICALL
    Java_com_my_player_MyPlayer_native_1prepare(JNIEnv *env, jobject instance,
                                                 jstring dataSource_) {
    const char *dataSource = env->GetStringUTFChars(dataSource_, 0);
    //創(chuàng)建播放器
    JavaCallHelper *helper =  new JavaCallHelper(javaVm, env, instance);
    ffmpeg = new MyFFmpeg(helper, dataSource);
    ffmpeg->prepare();
    env->ReleaseStringUTFChars(dataSource_, dataSource);
    }

第一個(gè)參數(shù) JNIEnv 類型實(shí)際上代表了 Java 環(huán)境,通過這個(gè) JNIEnv* 指針购笆,就可以對(duì) Java 端的代碼進(jìn)行操作煞肾。例如刀疙,創(chuàng)建 Java 類中的對(duì)象舔痕,調(diào)用 Java 對(duì)象的方法,獲取 Java 對(duì)象中的屬性等等镀首。JNIEnv 的指針會(huì)被 JNI 傳入到本地方法的實(shí)現(xiàn)函數(shù)中來對(duì) Java 端的代碼進(jìn)行操作坟漱。JNIEnv 類中有很多函數(shù)可以用:

  • NewObject:創(chuàng)建 Java 類中的對(duì)象
  • NewString:創(chuàng)建 Java 類中的 String 對(duì)象
  • New<Type>Array:創(chuàng)建類型為 Type 的數(shù)組對(duì)象
  • Get<Type>Field:獲取類型為 Type 的字段
  • Set<Type>Field:設(shè)置類型為 Type 的字段的值
  • GetStatic<Type>Field:獲取類型為 Type 的 static 的字段
  • SetStatic<Type>Field:設(shè)置類型為 Type 的 static 的字段的值
  • Call<Type>Method:調(diào)用返回類型為 Type 的方法
  • CallStatic<Type>Method:調(diào)用返回值類型為 Type 的 static 方法

等許多的函數(shù),具體的可以查看 jni.h 文件中的函數(shù)名稱更哄。

參數(shù):jobject instance

  • 如果 native 方法不是 static 的話芋齿,這個(gè) instance 就代表這個(gè) native 方法的類實(shí)例。
  • 如果 native 方法是 static 的話成翩,這個(gè) instance 就代表這個(gè) native 方法的類的 class 對(duì)象實(shí)例(static 方法不需要類實(shí)例的觅捆,所以就代表這個(gè)類的 class 對(duì)象)。

將所有的操作封裝在 Myffmpeg 中捕传,在 native-lib 中進(jìn)行調(diào)用惠拭。

    MyFFmpeg::MyFFmpeg(JavaCallHelper *callHelper,const char *dataSource) {
    this->callHelper = callHelper;
    //防止 dataSource參數(shù) 指向的內(nèi)存被釋放
    this->dataSource = new char[strlen(dataSource)];
    //錯(cuò)誤寫法 this->dataSource = const_cast<char * >(dataSource);
     strcpy(this->dataSource,dataSource); }
    MyFFmpeg::~MyFFmpeg() {
    //釋放
    DELETE(dataSource);
    DELETE(callHelper);}

構(gòu)造方法中的錯(cuò)誤寫法是因?yàn)?MyFFmpeg 的成員直接指向參數(shù) dataSource 的話有可能這個(gè) dataSource 在其他地方被釋放了導(dǎo)致 MyFFmpeg 中的 dataSource 變成一個(gè)懸空指針。strcpy 指的是字符串拷貝庸论。在析構(gòu)方法中釋放 dataSource 和 callHelper职辅。到此我們的 MyFFmpeg 已經(jīng)拿到了播放的地址,接下來要對(duì)這個(gè)地址進(jìn)行解析聂示。

解碼流程

enter image description here

播放直播需要連接網(wǎng)絡(luò)所以我們要加上網(wǎng)絡(luò)權(quán)限域携,而且聯(lián)網(wǎng)的操作肯定不能在主線程進(jìn)行,所以我們要開辟一個(gè)子線程在子線程中操作鱼喉。

    void* task_prepare(void *args){
    MyFFmpeg *ffmpeg = static_cast<MyFFmpeg *>(args);
    ffmpeg->_prepare();
    return 0;}

    void MyFFmpeg::prepare(){
    //創(chuàng)建一個(gè)線程
    pthread_create(&pid,0,task_prepare,this);
    }

    導(dǎo)入頭文件 pthread.h秀鞭、libavformat/avformat.h

    通過查看pthread.h 頭文件int pthread_create(pthread_t* __pthread_ptr, pthread_attr_t const* __attr, void* (*__start_routine)(void*), void*);

  • 第一個(gè)參數(shù)為指向線程標(biāo)識(shí)符的指針
  • 第二個(gè)參數(shù)用來設(shè)置線程屬性
  • 第三個(gè)參數(shù)是線程運(yùn)行函數(shù)的起始地址
  • 最后一個(gè)參數(shù)是運(yùn)行函數(shù)的參數(shù)
    void MyFFmpeg::_prepare(){
    // 初始化網(wǎng)絡(luò) 讓ffmpeg能夠使用網(wǎng)絡(luò)
    avformat_network_init();
    //1、打開媒體地址(文件地址扛禽、直播地址)
    // AVFormatContext  包含了 視頻的 信息(寬锋边、高等)
    formatContext = 0;
    //文件路徑不對(duì) 手機(jī)沒網(wǎng)
    int ret = avformat_open_input(&formatContext,dataSource,0,0);
    //ret不為0表示 打開媒體失敗
    if(ret != 0){
        LOGE("打開媒體失敗:%s",av_err2str(ret));
        callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_OPEN_URL);
        return;
    }
    //2、查找媒體中的 音視頻流 (給 contxt里的 streams等成員賦)
    ret =  avformat_find_stream_info(formatContext,0);
    // 小于0 則失敗
    if (ret < 0){
        LOGE("查找流失敗:%s",av_err2str(ret));
        callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_FIND_STREAMS);
        return;
    }
    //nb_streams :幾個(gè)流(幾段視頻/音頻)
    for (int i = 0; i < formatContext->nb_streams; ++i) {
        //可能代表是一個(gè)視頻 也可能代表是一個(gè)音頻
        AVStream *stream = formatContext->streams[i];
        //包含了 解碼 這段流 的各種參數(shù)信息(寬编曼、高豆巨、碼率、幀率)
        AVCodecParameters  *codecpar =  stream->codecpar;

        //無論視頻還是音頻都需要干的一些事情(獲得解碼器)
        // 1掐场、通過 當(dāng)前流 使用的 編碼方式往扔,查找解碼器
        AVCodec *dec = avcodec_find_decoder(codecpar->codec_id);
        if(dec == NULL){
            LOGE("查找解碼器失敗:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_FIND_DECODER_FAIL);
            return;
        }
        //2、獲得解碼器上下文
        AVCodecContext *context = avcodec_alloc_context3(dec);
        if(context == NULL){
            LOGE("創(chuàng)建解碼上下文失敗:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_ALLOC_CODEC_CONTEXT_FAIL);
            return;
        }
        ret = avcodec_parameters_to_context(context,codecpar);
        //失敗
        if(ret < 0){
            LOGE("設(shè)置解碼上下文參數(shù)失敗:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_CODEC_CONTEXT_PARAMETERS_FAIL);
            return;
        }
        // 4熊户、打開解碼器
        ret = avcodec_open2(context,dec,0);
        if (ret != 0){
            LOGE("打開解碼器失敗:%s",av_err2str(ret));
            callHelper->onError(THREAD_CHILD,FFMPEG_OPEN_DECODER_FAIL);
            return;
        } 
        //音頻
        if (codecpar->codec_type == AVMEDIA_TYPE_AUDIO){
            audioChannel = new AudioChannel;
        } else if(codecpar->codec_type == AVMEDIA_TYPE_VIDEO){
            videoChannel = new VideoChannel;
        }
        //沒有音視頻 
    if(!audioChannel && !videoChannel){
        LOGE("沒有音視頻");
        callHelper->onError(THREAD_CHILD,FFMPEG_NOMEDIA);
        return;
    }
    // 準(zhǔn)備完了 通知java 你隨時(shí)可以開始播放
    callHelper->onPrepare(THREAD_CHILD);

以上代碼包含了獲取音視頻信息(寬高等)萍膛、查找解碼器、獲得解碼器上下文嚷堡、設(shè)置上下文參數(shù)蝗罗、打開解碼器,下面就幾個(gè)重要的方法進(jìn)行講解蝌戒。

avformat_open_input 打開頭文件可以看到 int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options);绿饵,里面?zhèn)鬟f了 AVFormatContext 點(diǎn)進(jìn)去看,里面包含和音視頻的信息(寬高等)瓶颠,第二個(gè)參數(shù)傳遞了地址拟赊,后面兩個(gè)參數(shù)分別代表文件容器格式、最大延時(shí)粹淋,超時(shí)時(shí)間吸祟,以及支持的協(xié)議的白名單等。

可以看到 avformat_open_input 方法是有返回值的桃移,返回 0 是成功屋匕,不為 0 失敗〗杞埽可以通過我之前編寫的 JavaCallHelper 返回給 Java 進(jìn)行處理过吻,在這里我們需要注意的是,我們是在子線程中反射調(diào)用 Java,而 JNIEnv 不能跨線程調(diào)用這里就涉及到跨線程問題纤虽。這里我們需要一個(gè) JavaVM 來獲得對(duì)應(yīng)線程的 JNIEnv乳绕。

    class JavaCallHelper {
    public:
    JavaCallHelper(JavaVM *vm,JNIEnv* env,jobject instace);
    ~JavaCallHelper();
    //回調(diào)java
    void  onError(int thread,int errorCode);
    void onPrepare(int thread);
    private:
    JavaVM *vm;
    JNIEnv *env;
    jobject  instance;
    jmethodID onErrorId;
    jmethodID onPrepareId;

下面介紹下我的 JavaCallHelper 類,其中包含了所有的回調(diào) Java 的方法逼纸。

    JavaCallHelper::JavaCallHelper(JavaVM *vm, JNIEnv *env, jobject instace) {
    this->vm = vm;
    //如果在主線程 回調(diào)
    this->env = env;
    // 一旦涉及到j(luò)object 跨方法 跨線程 就需要?jiǎng)?chuàng)建全局引用
    this->instance = env->NewGlobalRef(instace);

    jclass  clazz = env->GetObjectClass(instace);
    onErrorId = env->GetMethodID(clazz,"onError","(I)V");
    onPrepareId = env->GetMethodID(clazz,"onPrepare","()V");}
    JavaCallHelper::~JavaCallHelper() {
    env->DeleteGlobalRef(instance);}

    void JavaCallHelper::onError(int thread,int error){
    //主線程
    if (thread == THREAD_MAIN){
       env->CallVoidMethod(instance,onErrorId,error);
    } else{
        //子線程
        JNIEnv *env;
        //獲得屬于我這一個(gè)線程的jnienv
        vm->AttachCurrentThread(&env,0);
        env->CallVoidMethod(instance,onErrorId,error);
        vm->DetachCurrentThread();
    }}

    void JavaCallHelper::onPrepare(int thread) {
    if (thread == THREAD_MAIN){
        env->CallVoidMethod(instance,onPrepareId);
    } else{
        //子線程
        JNIEnv *env;
        //獲得屬于我這一個(gè)線程的jnienv
        vm->AttachCurrentThread(&env,0);
        env->CallVoidMethod(instance,onPrepareId);
        vm->DetachCurrentThread();
    }}

onError 方法中處理我們的錯(cuò)誤信息反射給 Java洋措,如果是主線程 THREAD_MAIN 直接傳遞 Env,如果是子線程就通過 vm->AttachCurrentThread(&env,0); 獲得屬于我這一個(gè)線程的 JNIEnv杰刽,調(diào)用完畢后 DetachCurrentThread菠发。然后在 MyFFmpeg 中 callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_OPEN_URL); 第一個(gè)參數(shù)代表子線程,第二個(gè)參數(shù)代表錯(cuò)誤信息贺嫂。

回到我們的 _prepare 方法中來滓鸠,第二個(gè)方法 avformat_find_stream_info,查看頭文件 int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options); 這個(gè)方法表示查找媒體中的音視頻流(給 contxt 里的 streams 等成員賦值)第喳。返回小于 0 失敗糜俗,大于等于 0 成功。失敗的話就回調(diào)給 Java 墩弯,調(diào)用這個(gè)方法后吩跋,formatContext 就有值了。這里我們看一下 AVFormatContext 這個(gè)結(jié)構(gòu)體:

還有 69% 的精彩內(nèi)容
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
支付 ¥12.99 繼續(xù)閱讀
  • 序言:七十年代末渔工,一起剝皮案震驚了整個(gè)濱河市锌钮,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌引矩,老刑警劉巖梁丘,帶你破解...
    沈念sama閱讀 217,542評(píng)論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異旺韭,居然都是意外死亡氛谜,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,822評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門区端,熙熙樓的掌柜王于貴愁眉苦臉地迎上來值漫,“玉大人,你說我怎么就攤上這事织盼⊙詈危” “怎么了?”我有些...
    開封第一講書人閱讀 163,912評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵沥邻,是天一觀的道長危虱。 經(jīng)常有香客問我,道長唐全,這世上最難降的妖魔是什么埃跷? 我笑而不...
    開封第一講書人閱讀 58,449評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上弥雹,老公的妹妹穿的比我還像新娘垃帅。我一直安慰自己,他們只是感情好缅糟,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,500評(píng)論 6 392
  • 文/花漫 我一把揭開白布挺智。 她就那樣靜靜地躺著祷愉,像睡著了一般窗宦。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上二鳄,一...
    開封第一講書人閱讀 51,370評(píng)論 1 302
  • 那天赴涵,我揣著相機(jī)與錄音,去河邊找鬼订讼。 笑死髓窜,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的欺殿。 我是一名探鬼主播寄纵,決...
    沈念sama閱讀 40,193評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼脖苏!你這毒婦竟也來了程拭?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,074評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤棍潘,失蹤者是張志新(化名)和其女友劉穎恃鞋,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體亦歉,經(jīng)...
    沈念sama閱讀 45,505評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡恤浪,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,722評(píng)論 3 335
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了肴楷。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片水由。...
    茶點(diǎn)故事閱讀 39,841評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖赛蔫,靈堂內(nèi)的尸體忽然破棺而出砂客,到底是詐尸還是另有隱情,我是刑警寧澤濒募,帶...
    沈念sama閱讀 35,569評(píng)論 5 345
  • 正文 年R本政府宣布鞭盟,位于F島的核電站,受9級(jí)特大地震影響瑰剃,放射性物質(zhì)發(fā)生泄漏齿诉。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,168評(píng)論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望粤剧。 院中可真熱鬧歇竟,春花似錦、人聲如沸抵恋。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,783評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽弧关。三九已至盅安,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間世囊,已是汗流浹背别瞭。 一陣腳步聲響...
    開封第一講書人閱讀 32,918評(píng)論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留株憾,地道東北人蝙寨。 一個(gè)月前我還...
    沈念sama閱讀 47,962評(píng)論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像嗤瞎,于是被迫代替她去往敵國和親墙歪。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,781評(píng)論 2 354