Android中播放webp动画的一种方式:FrameSequenceDrawable

简介

本篇主要是介绍FrameSequenceDrawable的相关实现原理的文章,FrameSequenceDrawable是Google实现的可以播放Webp动画的Drawable,这个并没有在SDK里面,但是我们可以在googlesource中看到相关的代码,FrameSequenceDrawable相关代码地址

播放效果

在介绍之前,我们可以先看一下播放效果:


webp.gif

我想直接用

如果你说我不想看原理,我就想知道咋播放webp,那么我就帮助你完成一个简单小库,虽然是我封装的,但是代码可都是人家google开发哥哥写的,我帮你搬运过来,哈哈
这里是链接

如何引入到工程

  • Add the JitPack repository to your build file
    allprojects {
        repositories {
            ...
            maven { url 'https://jitpack.io' }
        }
    }
  • Add the dependency
   dependencies {
           compile 'com.github.humorousz:FrameSequenceDrawable:1.0.1-SNAPSHOT'
   }

如何使用

  • xml
 <com.humrousz.sequence.view.AnimatedImageView
        android:id="@+id/google_sequence_image"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_above="@+id/group"
        app:loopCount="1"
        app:loopBehavior="loop_default|loop_finite|loop_inf"
        android:scaleType="centerCrop"
        android:src="@drawable/webpRes"/>
  • java
public void setImage(){
    AnimatedImageView mGoogleImage;
    mGoogleImage = findViewById(R.id.google_sequence_image);
    //设置重复次数
    mGoogleImage.setLoopCount(1);
    //重复行为默认 根据webp图片循环次数决定
    mGoogleImage.setLoopDefault();
    //重复行为无限
    mGoogleImage.setLoopInf();
    //重复行为为指定  跟setLoopCount有关
    mGoogleImage.setLoopFinite();
    //设置Assets下的图片
    mGoogleImage.setImageResourceFromAssets("newyear.webp");
    //设置图片通过drawable
    mGoogleImage.setImageResource(R.drawable.newyear);
    Uri uri = Uri.parse("file:"+Environment.getExternalStorageDirectory().toString()+"/animation");
    //通过添加"file:"协议,可以展示指定路径的图片,如例子中的本地资源
    mGoogleImage.setImageURI(uri);
}

当然你也可以不使用我这里的AnimatedImageView,AnimatedImageView是我参考其它的代码后修改封装的类,直接使用FrameSequenceDrawable+ImageView也是可以的,使用方法如下

 ImageView mImage;
 InputStream in = null;
 in = getResources().getAssets().open("anim.webp");
 final FrameSequenceDrawable drawable = new FrameSequenceDrawable(in);
 drawable.setLoopCount(1);
 drawable.setLoopBehavior(FrameSequenceDrawable.LOOP_FINITE);
 drawable.setOnFinishedListener(new FrameSequenceDrawable.OnFinishedListener() {
     @Override
     public void onFinished(FrameSequenceDrawable frameSequenceDrawable) {

     }
 });
 mImage.setImageDrawable(drawable);

原理介绍

原理简介

  • 利用了两个Bitmap对象,其中一个用于绘制到屏幕上,另外一个用于解析下一张要展示的图片,利用了HandlerThread在子线程解析,每次解析的时候获取上一张图片的展示时间,然后使用Drawable自身的scheduleSelf方法在指定时间替换图片,在达到替换时间时,会调用draw方法,在draw之前先去子线程解析下一张要展示的图片,然后重复这个步骤,直到播放结束或者一直播放

涉及到的类

  • FrameSequenceDrawable
    这个我们直接使用播放webp动画的类,它继承了Drawable并且实现了Animatable, Runnable两个接口,所以我们可以像使用Drawable一样的去使用它
  • FrameSequence
    从名字上来看这个类的意思很明确,那就是帧序列,它主要负责对传入的webp流进行解析,解析的地方是在native层,所以如果自己想编译FrameSequenceDrawable源码的话,需要编译JNI文件夹下的相关文件生成so库

流程分析

在分析源码之前,先把整个代码的流程分步骤简单介绍一下,后面根据这里介绍的流程去逐个分析源码

  • 在FrameSequenceDrawable构造函数中创建解析线程,使用HandlerThread作为解析线程
  • 在触发了setVisiable方法之后,会触发自身start方法开始解析第一张图片
  • start方法调用scheduleDecodeLocked开始解析
  • mDecodeRunnable的run方法执行,解析下一张要展示的图片,调用Drawable自身的scheduleSelf方法,参数when会设置为当前图片的展示时间
  • scheduleSelf 会调用FrameSequenceDrawable所实现Runnable的run方法,并且导致draw,在draw方法中会首先调用解析线程去解析下一张图片,然后在继续绘制当前图片
  • 反复执行绘制和解析步骤,知道循环次数达到设置状态或者无限循环

效果示意图

1.png
2.png
3.png

源码分析

现在我们对整个流程上的源码进行一些分析

  • 首先第一步我们先看看FrameSequenceDrawable的构造函数,可以发现源码中一共有两个构造函数,我为了方便在我分享的github项目里增加了第三个构造,下面我们来一起看一看
//这个是我自己添加的,利用了FrameSequence可以通过InputStream方法创建FrameSequence功能
public FrameSequenceDrawable(InputStream inputStream){
    this(FrameSequence.decodeStream(inputStream));
}

public FrameSequenceDrawable(FrameSequence frameSequence) {
    this(frameSequence, sAllocatingBitmapProvider);
}

public FrameSequenceDrawable(FrameSequence frameSequence, BitmapProvider bitmapProvider) {
    if (frameSequence == null || bitmapProvider == null) throw new IllegalArgumentException();
    mFrameSequence = frameSequence;
    mFrameSequenceState = frameSequence.createState();
    final int width = frameSequence.getWidth();
    final int height = frameSequence.getHeight();
    mBitmapProvider = bitmapProvider;
    mFrontBitmap = acquireAndValidateBitmap(bitmapProvider, width, height);
    mBackBitmap = acquireAndValidateBitmap(bitmapProvider, width, height);
    mSrcRect = new Rect(0, 0, width, height);
    mPaint = new Paint();
    mPaint.setFilterBitmap(true);
    mFrontBitmapShader
            = new BitmapShader(mFrontBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
    mBackBitmapShader
            = new BitmapShader(mBackBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
    mLastSwap = 0;
    mNextFrameToDecode = -1;
    mFrameSequenceState.getFrame(0, mFrontBitmap, -1);
    initializeDecodingThread();
}

我们可以看到在构造方法中,创建了mFrontBitmap和mBackBitmap两个对象,它俩的作用就是mFrontBitmap用于绘制,mBackBitmap用于解析线程下一张要展示的图片,在每次draw方法之前会把它俩所指向的实际bitmap交换,FrameSequence就是抽象出去的帧序列对象,它内部封装了动画的长、宽、透明度、循环次数、帧数等属性,它的内部所有解析和获取帧的方法都是native,我们来看看initializeDecodingThread这个方法做了哪些事情

private static void initializeDecodingThread() {
    synchronized (sLock) {
        if (sDecodingThread != null) return;
        sDecodingThread = new HandlerThread("FrameSequence decoding thread",
                Process.THREAD_PRIORITY_BACKGROUND);
        sDecodingThread.start();
        sDecodingThreadHandler = new Handler(sDecodingThread.getLooper());
    }
}

这里也很简单,就是创建了一个HandlerThread,后续所有调用线程调度解析都是通过sDecodingThreadHandler这个去实现的

  • setVisible,动画的开始
    FrameSequenceDrawable的setVisible重载了父类的setVisible,这个会在设置动画的时候被调用,这里也是动画调度开始的地方,我们来看一下它的实现
@Override
public boolean setVisible(boolean visible, boolean restart) {
    boolean changed = super.setVisible(visible, restart);
    if (!visible) {
        stop();
    } else if (restart || changed) {
        stop();
        start();
    }
    return changed;
}

@Override
//Animatable中的方法
public void start() {
    if (!isRunning()) {
        synchronized (mLock) {
            checkDestroyedLocked();
            if (mState == STATE_SCHEDULED) return; // already scheduled
            mCurrentLoop = 0;
            scheduleDecodeLocked();
        }
    }
}

private void scheduleDecodeLocked() {
    mState = STATE_SCHEDULED;
    mNextFrameToDecode = (mNextFrameToDecode + 1) % mFrameSequence.getFrameCount();
    sDecodingThreadHandler.post(mDecodeRunnable);
}

可以看到,setVisible会调用start方法,start方法会调用到scheduleDecodeLocked方法,这个方法会计算下一张需要解析的index,然后通过sDecodingThreadHandler调用mDecodeRunnable去在子线程进行解析,下面我们来看看mDecodeRunnable干了一些什么事情

/**
* Runs on decoding thread, only modifies mBackBitmap's pixels
*/
private Runnable mDecodeRunnable = new Runnable() {
    @Override
    public void run() {
        int nextFrame;
        Bitmap bitmap;
        synchronized (mLock) {
            if (mDestroyed) return;
            //下一张要解析的index
            nextFrame = mNextFrameToDecode;
            if (nextFrame < 0) {
                return;
            }
            //后台解析时用mBackBitmap
            bitmap = mBackBitmap;
            mState = STATE_DECODING;
        }
        int lastFrame = nextFrame - 2;
        boolean exceptionDuringDecode = false;
        long invalidateTimeMs = 0;
        try {
            //解析下一张图片到bitmap,并且返回nextFrame-1的展示时间
            invalidateTimeMs = mFrameSequenceState.getFrame(nextFrame, bitmap, lastFrame);
        } catch (Exception e) {
            // Exception during decode: continue, but delay next frame indefinitely.
            Log.e(TAG, "exception during decode: " + e);
            exceptionDuringDecode = true;
        }
        if (invalidateTimeMs < MIN_DELAY_MS) {
            invalidateTimeMs = DEFAULT_DELAY_MS;
        }
        boolean schedule = false;
        Bitmap bitmapToRelease = null;
        //计算是否满足交换普片的条件
        synchronized (mLock) {
            if (mDestroyed) {
                bitmapToRelease = mBackBitmap;
                mBackBitmap = null;
            } else if (mNextFrameToDecode >= 0 && mState == STATE_DECODING) {
                schedule = true;
                //计算下次调度的时间,上一张图片的展示时间加上上次调度的时间(mLastSwap就是上次调度的时间)
                mNextSwap = exceptionDuringDecode ? Long.MAX_VALUE : invalidateTimeMs + mLastSwap;
                mState = STATE_WAITING_TO_SWAP;
            }
        }
        if (schedule) {
            //在mNextSwap时调度自己的run方法
            scheduleSelf(FrameSequenceDrawable.this, mNextSwap);
        }
        if (bitmapToRelease != null) {
            // destroy the bitmap here, since there's no safe way to get back to
            // drawable thread - drawable is likely detached, so schedule is noop.
            mBitmapProvider.releaseBitmap(bitmapToRelease);
        }
    }
};

在上面的代码中比较关键的部分我已经加了注释,整段代码的逻辑可以分为三个部分,第一个部分是设置条件判断以及设置mState为STATE_DECODING

synchronized (mLock) {
    if (mDestroyed) return;
    //下一张要解析的index
    nextFrame = mNextFrameToDecode;
    if (nextFrame < 0) {
        return;
    }
    //后台解析时用mBackBitmap
    bitmap = mBackBitmap;
    mState = STATE_DECODING;
}

第二部分是解析nextFrame并且获取nextFrame上一张图片的展示时间,并且修改mState和计算mNextSwap时间

...
try {
    //解析下一张图片到bitmap,并且返回lastFrame的展示时间
    invalidateTimeMs = mFrameSequenceState.getFrame(nextFrame, bitmap, lastFrame);
} catch (Exception e) {
    // Exception during decode: continue, but delay next frame indefinitely.
    Log.e(TAG, "exception during decode: " + e);
    exceptionDuringDecode = true;
}
....
synchronized (mLock) {
    if (mDestroyed) {
        bitmapToRelease = mBackBitmap;
        mBackBitmap = null;
    } else if (mNextFrameToDecode >= 0 && mState == STATE_DECODING) {
        schedule = true;
        //计算下次调度的时间,上一张图片的展示时间加上上次调度的时间(mLastSwap就是上次调度的时间)
        mNextSwap = exceptionDuringDecode ? Long.MAX_VALUE : invalidateTimeMs + mLastSwap;
        mState = STATE_WAITING_TO_SWAP;
    }
}

关于 mFrameSequenceState.getFrame(nextFrame, bitmap, lastFrame)这个方法的返回值到底是哪一帧的时间,我一开始也不是很明确,但是后来通过思考和后面的逻辑来看,这个返回值应该是nextFrame的上一张图片的时间,因为下次调度的时间是这个返回值+ mLastSwap,后来看了一下native的代码,证实了这个想法,getFrame调用了native的nativeGetFrame方法,nativeGetFrame方法又调用了drawFrame,c++层的代码如下

static jlong JNICALL nativeGetFrame(
     ... 省略
    jlong delayMs = frameSequenceState->drawFrame(frameNr,
            (Color8888*) pixels, pixelStride, previousFrameNr);
    AndroidBitmap_unlockPixels(env, bitmap);
    return delayMs;
}
long FrameSequenceState_webp::drawFrame(int frameNr,
        Color8888* outputPtr, int outputPixelStride, int previousFrameNr) {
    ... 省略
    WebPIterator currIter;
    WebPIterator prevIter;
    int ok = WebPDemuxGetFrame(demux, start, &currIter);  // Get frame number 'start - 1'.
    ALOG_ASSERT(ok, "Could not retrieve frame# %d", start - 1);
    // Use preserve buffer only if needed.
    Color8888* prevBuffer = (frameNr == 0) ? outputPtr : mPreservedBuffer;
    int prevStride = (frameNr == 0) ? outputPixelStride : canvasWidth;
    Color8888* currBuffer = outputPtr;
    int currStride = outputPixelStride;
    for (int i = start; i <= frameNr; i++) {
        prevIter = currIter;
        ok = WebPDemuxGetFrame(demux, i + 1, &currIter);  // Get ith frame.
        ALOG_ASSERT(ok, "Could not retrieve frame# %d", i);
    ...省略
    // Return last frame's delay.
    const int frameCount = mFrameSequence.getFrameCount();
    const int lastFrame = (frameNr + frameCount - 1) % frameCount;
   //这里虽然+1应该是计算值可能从1开始,因为上面for循环计算第ith时也加了1
    ok = WebPDemuxGetFrame(demux, lastFrame + 1, &currIter);
    ALOG_ASSERT(ok, "Could not retrieve frame# %d", lastFrame);
    const int lastFrameDelay = currIter.duration;
    WebPDemuxReleaseIterator(&currIter);
    WebPDemuxReleaseIterator(&prevIter);
    return lastFrameDelay;
}

可以看到最后的返回值是lastFrameDelay它的计算帧lastFrame是(frameNr + frameCount - 1) % frameCount计算出来的,可以看到确实是frameNr的上一张,frameNr就是我们这里的nextFrame,为什么要纠结于这一块的?因为我们只要理解了这个方法,就可以抽象FrameSequence,然后使用自己或者其他的解析代码来解析帧,可以灵活的使用解析库,还可以同时支持gif和webp
继续代码第三部分,这部分就是在调度了,在nextSwap的时间

if (schedule) {
    //在mNextSwap时调度自己的run方法
    scheduleSelf(FrameSequenceDrawable.this, mNextSwap);
}
if (bitmapToRelease != null) {
    // destroy the bitmap here, since there's no safe way to get back to
    // drawable thread - drawable is likely detached, so schedule is noop.
    mBitmapProvider.releaseBitmap(bitmapToRelease);
}
  • scheduleSelf调用自身的run方法触发了绘制
    通过上面的流程,到达了时间后,就会触发scheduleSelf调用FrameSequenceDrawable自身的run方法并且会触发绘制,下面我们就来看看这部分代码
@Override
public void run() {
    // set ready to swap as necessary
    boolean invalidate = false;
    synchronized (mLock) {
        if (mNextFrameToDecode >= 0 && mState == STATE_WAITING_TO_SWAP) {
            mState = STATE_READY_TO_SWAP;
            invalidate = true;
        }
    }
    if (invalidate) {
        invalidateSelf();
    }
}

@Override
public void draw(Canvas canvas) {
    synchronized (mLock) {
        checkDestroyedLocked();
        if (mState == STATE_WAITING_TO_SWAP) {
            // may have failed to schedule mark ready runnable,
            // so go ahead and swap if swapping is due
            if (mNextSwap - SystemClock.uptimeMillis() <= 0) {
                mState = STATE_READY_TO_SWAP;
            }
        }
        if (isRunning() && mState == STATE_READY_TO_SWAP) {
            // Because draw has occurred, the view system is guaranteed to no longer hold a
            // reference to the old mFrontBitmap, so we now use it to produce the next frame
            Bitmap tmp = mBackBitmap;
            mBackBitmap = mFrontBitmap;
            mFrontBitmap = tmp;
            BitmapShader tmpShader = mBackBitmapShader;
            mBackBitmapShader = mFrontBitmapShader;
            mFrontBitmapShader = tmpShader;
            mLastSwap = SystemClock.uptimeMillis();
            boolean continueLooping = true;
            if (mNextFrameToDecode == mFrameSequence.getFrameCount() - 1) {
                mCurrentLoop++;
                if ((mLoopBehavior == LOOP_FINITE && mCurrentLoop == mLoopCount) ||
                        (mLoopBehavior == LOOP_DEFAULT && mCurrentLoop == mFrameSequence.getDefaultLoopCount())) {
                    continueLooping = false;
                }
            }
            if (continueLooping) {
                scheduleDecodeLocked();
            } else {
                scheduleSelf(mFinishedCallbackRunnable, 0);
            }
        }
    }
    if (mCircleMaskEnabled) {
        final Rect bounds = getBounds();
        final int bitmapWidth = getIntrinsicWidth();
        final int bitmapHeight = getIntrinsicHeight();
        final float scaleX = 1.0f * bounds.width() / bitmapWidth;
        final float scaleY = 1.0f * bounds.height() / bitmapHeight;
        canvas.save();
        // scale and translate to account for bounds, so we can operate in intrinsic
        // width/height (so it's valid to use an unscaled bitmap shader)
        canvas.translate(bounds.left, bounds.top);
        canvas.scale(scaleX, scaleY);
        final float unscaledCircleDiameter = Math.min(bounds.width(), bounds.height());
        final float scaledDiameterX = unscaledCircleDiameter / scaleX;
        final float scaledDiameterY = unscaledCircleDiameter / scaleY;
        // Want to draw a circle, but we have to compensate for canvas scale
        mTempRectF.set(
                (bitmapWidth - scaledDiameterX) / 2.0f,
                (bitmapHeight - scaledDiameterY) / 2.0f,
                (bitmapWidth + scaledDiameterX) / 2.0f,
                (bitmapHeight + scaledDiameterY) / 2.0f);
        mPaint.setShader(mFrontBitmapShader);
        canvas.drawOval(mTempRectF, mPaint);
        canvas.restore();
    } else {
        mPaint.setShader(null);
        canvas.drawBitmap(mFrontBitmap, mSrcRect, getBounds(), mPaint);
    }
}

这里代码的主要就可以分成两个部分了,下面绘制的部分我们就不说了,主要看上面的获取当前需要绘制的图片和解析下一张图片的部分

if (mState == STATE_WAITING_TO_SWAP) {
    // may have failed to schedule mark ready runnable,
    // so go ahead and swap if swapping is due
    if (mNextSwap - SystemClock.uptimeMillis() <= 0) {
        mState = STATE_READY_TO_SWAP;
    }
}
if (isRunning() && mState == STATE_READY_TO_SWAP) {
    //因为交换时间到了,所以应该绘制mBackBitmap的内容了,而mFrontBitmap所指向的内存可以用于解析下一张图片使用了
    //所以交换它们所指向的bitmap
    Bitmap tmp = mBackBitmap;
    mBackBitmap = mFrontBitmap;
    mFrontBitmap = tmp;
    BitmapShader tmpShader = mBackBitmapShader;
    mBackBitmapShader = mFrontBitmapShader;
    mFrontBitmapShader = tmpShader;
    mLastSwap = SystemClock.uptimeMillis();
    boolean continueLooping = true;
    //如果绘制到了最后一张,就需要我们根据条件判断是否继续loop了
    if (mNextFrameToDecode == mFrameSequence.getFrameCount() - 1) {
        mCurrentLoop++;
        //第一个判断的条件是,LoopBehavior是LOOP_FINITE时,根据是否达到我们设置的loopCount为依据,如果达到就结束
        //第二个判断的条件是,LoopBehavior是LOOP_DEFAULT时,根据Sequence自身的LoopCount来决定,如果达到就结束
        if ((mLoopBehavior == LOOP_FINITE && mCurrentLoop == mLoopCount) ||
                (mLoopBehavior == LOOP_DEFAULT && mCurrentLoop == mFrameSequence.getDefaultLoopCount())) {
            continueLooping = false;
        }
    }
    if (continueLooping) {
        //继续调度下张
        scheduleDecodeLocked();
    } else {
        scheduleSelf(mFinishedCallbackRunnable, 0);
    }
}

同样关键的部分我已经注释在上面了,主要就是达到了交换的时间会产生调度,然后重新绘制,在重新绘制时,需要绘制的图片是mBackBitmap,然后mFrontBitmap可以用于解析下一张图片,所以把它俩做了一次交换,后面主要就是判断是否播放到了最后一张,如果播放到了最后一张,那么就会根据条件判断是否继续循环播放,最后满足条件的话调用scheduleDecodeLocked,这个方法上面有介绍,就是让解析线程解析下一张图片,这样反复的进行,整个webp动画就播放起来了,整个解析的过程中也不会造成内存的飙升,因为使用的内存只有mFrontBitmap和mBackBitmap,这种思想还是很好的,如果我们想在节约内存,只用一个bitmap,解一张播一张的话会没有这么流畅,别问我为什么知道,因为我们项目里现在就是播一张解析一张的。。。
好了,到这里整体代码逻辑的介绍就完成了,如果你觉得我说的不是那么清晰,可以留言说出你的疑问,也可以直接阅读源码看看到底是咋回事

预告

其实我看了这个源码以后,想了一下我们之前播放webp用的库,我通过抽象了FrameSequence这个类,在保持了FrameSequenceDrawable几乎所有的源码后,使用了facebook 的 Fresco库对FrameSequence这个类进行了抽象和实现,达到了一个Drawable可以通过简单的修改可以同时支持webp和gif的功能,介绍的文章在我写完这个之后会马上开始~

更新

预告中的文章已经写完Android播放webp和gif的一种方法(接上篇),欢迎批评指正

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,398评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • round 2,session 10 最近的情绪波动比较大。 以前认为在亲密关系中,可能两个人灵魂上彼此吸引就够了...
    laBonita阅读 348评论 1 0
  • 思念萦绕两座城市,我该如何去回忆? 盼望着,北方的冬天终于来了。冬至,小雪,冷空气踏上徐徐的征途,覆盖着我所在的这...
    遗忘的罐罐阅读 296评论 0 2
  • 今天我和爸爸妈妈去外婆外公家。爸爸对我说:“到了那我们去挖笋吧。”“太好了!”我高兴的说。 到了外婆外公家,那...
    无忧东东阅读 250评论 0 0