RecyclerView复用机制浅析:从实现下载列表入手

       RecyclerView目前基本上已经替代了ListView,其强大的可定制性和性能上的优化深受开发者的喜爱,这篇文章就不再介绍使用方法,依然是通过带着问题到源码寻找答案,而且更多地通过实践来证实理论。
       这次项目做的是游戏列表,每个item都有一个下载进度条,我们知道,recyclerview的viewHolder是复用的,在最开始创建了足够的viewholder,后面在滑动过程中就是复用这些最初create的viewholder。这边就不贴项目效果图了,直接在我练手的项目里再写个demo(下载按钮直通车# DownloadButton),如下

下载列表demo.png

       屏幕里有9个item,右侧是继承于progress自定义的下载按钮,假设我们每个item是视频对象,点击下载按钮就是在下载视频,当然这里我就不实际下载视频了,而是通过模拟每秒2%下载,这时候你可能会想到开一个线程,通过sleep来达到这种效果,但是这里有个更强大的Rxjava了解一下,线程灵活切换和强大的操作符完全可以驾驭这等基础操作。写法如下:

          Observable.interval(0, 1, TimeUnit.SECONDS)  //interval定时器
                            .subscribeOn(Schedulers.computation())
                            .filter(new Predicate<Long>() {
                                @Override
                                public boolean test(Long aLong) throws Exception {
                                    return aLong < 100;   //过滤出100以内的进度
                                }
                            })
                            .map(new Function<Long, Object>() {
                                @Override
                                public Object apply(Long aLong) throws Exception {
                                    return aLong.intValue();  //long转int
                                }
                            })           
                            .subscribeOn(AndroidSchedulers.mainThread())
                            .subscribe(new Consumer<Object>() {
                                @Override
                                public void accept(Object o) throws Exception {
                                    percent+=2;   //每隔1s进度加2%
                                    button.setProgress(percent);
                                    item.setState(Constant.DOWNLOAD_STATE_DOWDLOADING);
                                    item.setProgress(button.getProgress());
                                }
                            });

       这时候我们随便点一个按钮开启下载,点击第一个下载按钮,进度条走起来,目前一切看起来很尽人意,效果达到预期。这时候往下拉,不愿意看到的发生了,item14也自己开启了下载:


开启下载demo.gif

       像这种情况如果是你的App用户碰到,他一定觉得你们这是流氓App。下载一个文件,还偷偷送你一个,捆绑销售啊?但是这种情况基本不会流到用户手中,自信何来?因为了解了今天的主题:RecyclerView的复用机制,当你只是用来展示静态数据的时候,RV能够完美地显示成百上千条数据不出差错,但是当你的item出现动态数据的时候,比如进度条,开关等,那么你就必须自己设置好数据,怎么设置好,还是得先了解所谓的复用机制是怎么一回事。

       要说复用机制,首先必须知道RecyclerView内部维护的缓存类:Recycler

public final class Recycler {
        final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<ViewHolder>();
        private ArrayList<ViewHolder> mChangedScrap = null;
        final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();

        private final List<ViewHolder>
                mUnmodifiableAttachedScrap = Collections.unmodifiableList(mAttachedScrap);

        private int mViewCacheMax = DEFAULT_CACHE_SIZE;
        private RecycledViewPool mRecyclerPool;
        private ViewCacheExtension mViewCacheExtension;
        private static final int DEFAULT_CACHE_SIZE = 2;
        ...
}

       我们看到了常说的RecyclerView的四级缓存

  • 一级缓存:mAttachedScrap
  • 二级缓存:mCacheViews
  • 三级缓存:mViewCacheExtension
  • 四级缓存:mRecyclerPool

       简单了解下这四级缓存的概念,mAttachedScrap其实跟item的复用没有关系,它是recyclerview在layout它的child的时候经历了先移除再添加的过程,再添加就是从这个mAttachedScrap去取,白话文就是屏幕内item的复用;mCacheViews只存两个viewHolder;mViewCacheExtension留给开发者扩展的,可以先忽略;mRecyclerPool缓存5个viewholder;那么这池里面缓存的viewholder是怎么被复用到的呢,这当中发生了什么,首先请出第一个当事人---LayoutManager,我们知道RV中itemview的measure、layout就是LayoutManager在负责的,本文暂不研究LayoutManager的工作原理,等后续详细研究了再补充,这边我们只需要知道在LayoutManager里几个重要的方法:

1.fill(recycler, mLayoutState, state, false); //填充itemview
2.layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result) 
3.View next(RecyclerView.Recycler recycler)

       以上三个方法是一级级调用下来的,到next这个方法,我们看到参数RecyclerView.Recycler,非常利索地又回到Recycler这个话题来,这边也就知道了Layout在fill这些itemView是来Recycler来取的,这个后面一一分析,这边先从源头next这个方法入手:

final View view = recycler.getViewForPosition(mCurrentPosition);

       精选出以上这行代码,从代码可以初步了解到 通过当前位置获得当前位置的view,先放下,继续跟进getViewForPosition方法,发现进入到了最终的方法,也就是取缓存最最关键的方法块,也就是layoutManager在摆放itemview的时候,就是来这里层层大门拿到需要的viewholder:

ViewHolder tryGetViewHolderForPositionByDeadline(int position,
                boolean dryRun, long deadlineNs) {
 if (position < 0 || position >= mState.getItemCount()) {
                throw new IndexOutOfBoundsException("Invalid item position " + position
                        + "(" + position + "). Item count:" + mState.getItemCount()
                        + exceptionLabel());
            }
            boolean fromScrapOrHiddenOrCache = false;
            ViewHolder holder = null;
            // 0) If there is a changed scrap, try to find from there
            if (mState.isPreLayout()) {
                holder = getChangedScrapViewForPosition(position);
                fromScrapOrHiddenOrCache = holder != null;
            }
            // 1) Find by position from scrap/hidden list/cache
            if (holder == null) {
                holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
                if (holder != null) {
                    if (!validateViewHolderForOffsetPosition(holder)) {
                        // recycle holder (and unscrap if relevant) since it can't be used
                       //当前位置的viewholder是不是当前position的,不是就将viewholder置null
                        if (!dryRun) {
                            // we would like to recycle this but need to make sure it is not used by
                            // animation logic etc.
                            holder.addFlags(ViewHolder.FLAG_INVALID);
                            if (holder.isScrap()) {
                                removeDetachedView(holder.itemView, false);
                                holder.unScrap();
                            } else if (holder.wasReturnedFromScrap()) {
                                holder.clearReturnedFromScrapFlag();
                            }
                           //
                            recycleViewHolderInternal(holder);
                        }
                        holder = null;
                    } else {
                        fromScrapOrHiddenOrCache = true;
                    }
                }
            }
            if (holder == null) {
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                if (offsetPosition < 0 || offsetPosition >= mAdapter.getItemCount()) {
                    throw new IndexOutOfBoundsException("Inconsistency detected. Invalid item "
                            + "position " + position + "(offset:" + offsetPosition + ")."
                            + "state:" + mState.getItemCount() + exceptionLabel());
                }

                final int type = mAdapter.getItemViewType(offsetPosition);
                // 2) Find from scrap/cache via stable ids, if exists
                if (mAdapter.hasStableIds()) {
                    holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
                            type, dryRun);
                    if (holder != null) {
                        // update position
                        holder.mPosition = offsetPosition;
                        fromScrapOrHiddenOrCache = true;
                    }
                }
                if (holder == null && mViewCacheExtension != null) {
                    // We are NOT sending the offsetPosition because LayoutManager does not
                    // know it.
                    final View view = mViewCacheExtension
                            .getViewForPositionAndType(this, position, type);
                    if (view != null) {
                        holder = getChildViewHolder(view);
                        if (holder == null) {
                            throw new IllegalArgumentException("getViewForPositionAndType returned"
                                    + " a view which does not have a ViewHolder"
                                    + exceptionLabel());
                        } else if (holder.shouldIgnore()) {
                            throw new IllegalArgumentException("getViewForPositionAndType returned"
                                    + " a view that is ignored. You must call stopIgnoring before"
                                    + " returning this view." + exceptionLabel());
                        }
                    }
                }
                if (holder == null) { // fallback to pool
                    if (DEBUG) {
                        Log.d(TAG, "tryGetViewHolderForPositionByDeadline("
                                + position + ") fetching from shared pool");
                    }
                    holder = getRecycledViewPool().getRecycledView(type);
                    if (holder != null) {
                        holder.resetInternal();
                        if (FORCE_INVALIDATE_DISPLAY_LIST) {
                            invalidateDisplayListInt(holder);
                        }
                    }
                }
                if (holder == null) {
                    long start = getNanoTime();
                    if (deadlineNs != FOREVER_NS
                            && !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) {
                        // abort - we have a deadline we can't meet
                        return null;
                    }
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
                    if (ALLOW_THREAD_GAP_WORK) {
                        // only bother finding nested RV if prefetching
                        RecyclerView innerView = findNestedRecyclerView(holder.itemView);
                        if (innerView != null) {
                            holder.mNestedRecyclerView = new WeakReference<>(innerView);
                        }
                    }

                    long end = getNanoTime();
                    mRecyclerPool.factorInCreateTime(type, end - start);
                    if (DEBUG) {
                        Log.d(TAG, "tryGetViewHolderForPositionByDeadline created new ViewHolder");
                    }
                }
            }

            // This is very ugly but the only place we can grab this information
            // before the View is rebound and returned to the LayoutManager for post layout ops.
            // We don't need this in pre-layout since the VH is not updated by the LM.
            if (fromScrapOrHiddenOrCache && !mState.isPreLayout() && holder
                    .hasAnyOfTheFlags(ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST)) {
                holder.setFlags(0, ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST);
                if (mState.mRunSimpleAnimations) {
                    int changeFlags = ItemAnimator
                            .buildAdapterChangeFlagsForAnimations(holder);
                    changeFlags |= ItemAnimator.FLAG_APPEARED_IN_PRE_LAYOUT;
                    final ItemHolderInfo info = mItemAnimator.recordPreLayoutInformation(mState,
                            holder, changeFlags, holder.getUnmodifiedPayloads());
                    recordAnimationInfoIfBouncedHiddenView(holder, info);
                }
            }

            boolean bound = false;
            if (mState.isPreLayout() && holder.isBound()) {
                // do not update unless we absolutely have to.
                holder.mPreLayoutPosition = position;
            } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
                if (DEBUG && holder.isRemoved()) {
                    throw new IllegalStateException("Removed holder should be bound and it should"
                            + " come here only in pre-layout. Holder: " + holder
                            + exceptionLabel());
                }
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
            }

            final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
            final LayoutParams rvLayoutParams;
            if (lp == null) {
                rvLayoutParams = (LayoutParams) generateDefaultLayoutParams();
                holder.itemView.setLayoutParams(rvLayoutParams);
            } else if (!checkLayoutParams(lp)) {
                rvLayoutParams = (LayoutParams) generateLayoutParams(lp);
                holder.itemView.setLayoutParams(rvLayoutParams);
            } else {
                rvLayoutParams = (LayoutParams) lp;
            }
            rvLayoutParams.mViewHolder = holder;
            rvLayoutParams.mPendingInvalidate = fromScrapOrHiddenOrCache && bound;
            return holder;
}

       因为整个方法块都是缓存的精髓所在,没办法剔除无用代码,那就一个个分析下来,首先看到第一个holder判空的地方:

  if (holder == null) {
                  //Returns a view for the position either from attach scrap, hidden children, or cache.
                 //mAttachedScrap 中寻找 position 一致的 viewHolder
                holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
}

       首先到mAttachedScrap 去寻找holder,mAttachedScrap 什么时候存了holder呢,打听得知是在RecyclerView重新layout的时候,比如Resume了,会将所有chldren的holder移除掉,放到哪里呢,就是放到了这个mAttachedScrap 中;从这个方法中我们也看出,还到hidden和mCached里去找holder了,首先hidden不知道是干啥用的,估计很少场景会使用到这个;然后就是很关键的mCachedView,也就是上文所讲的二级缓存,

 // Search in our first-level recycled view cache.
            final int cacheSize = mCachedViews.size();
            for (int i = 0; i < cacheSize; i++) {
                final ViewHolder holder = mCachedViews.get(i);
                // invalid view holders may be in cache if adapter has stable ids as they can be
                // retrieved via getScrapOrCachedViewForId
                if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
                    if (!dryRun) {
                        mCachedViews.remove(i);
                    }
                    if (DEBUG) {
                        Log.d(TAG, "getScrapOrHiddenOrCachedHolderForPosition(" + position
                                + ") found match in cache: " + holder);
                    }
                    return holder;
                }
            }

       遍历mCachedView,找到 position 一致的 ViewHolder;
以上就是第一次寻找holder的过程,主要是通过position来寻找,找不到了就通过id来找,也就是第二次的holder判空方法:

 if (holder == null) {
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);

                final int type = mAdapter.getItemViewType(offsetPosition);
                // 2) Find from scrap/cache via stable ids, if exists
                if (mAdapter.hasStableIds()) {
                //依然是到ScrapView和CachedView中找,通过id
                    holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
                            type, dryRun);
                    if (holder != null) {
                        // update position
                        holder.mPosition = offsetPosition;
                        fromScrapOrHiddenOrCache = true;
                    }
                }

       这里的id是adapter里的StableId,其实正常也不会走到里面,所以跳过,直接进入下一个主角RecycledViewPool中吧:

         if (holder == null) { // fallback to pool
                    holder = getRecycledViewPool().getRecycledView(type);
                    if (holder != null) {
                        holder.resetInternal();
                        if (FORCE_INVALIDATE_DISPLAY_LIST) {
                            invalidateDisplayListInt(holder);
                        }
                    }
                }

       这边看到会通过不同的viewType去取holder,如果取到了,就holder.resetInternal()一下,将二手的转成崭新的,也就是adapter里要重新再onBindViewHolder。好了,recyclerview的四级缓存我们已经找过一遍了,这时候如果还是没找到呢,那当然就是创建Holder了,不然还能咋地,所以整个方法的最后就是调用到了adapter的CreateViewHolder:

                if (holder == null) {
                   //创建新的viewholder
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
                }

       以上大篇幅讲的都是holder的取,那recyclerview又是怎么来存这些holder来等待复用的呢?反手又是一波源码亮出来:

void recycleViewHolderInternal(ViewHolder holder) {
  
            ...
        if (forceRecycle || holder.isRecyclable()) {
            if (!holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID | ViewHolder.FLAG_REMOVED
                    | ViewHolder.FLAG_UPDATE)) {
                // Retire oldest cached view
                final int cachedViewSize = mCachedViews.size();
                if (cachedViewSize == mViewCacheMax && cachedViewSize > 0) {
                    //mViewCacheMax 默认大小2
                    recycleCachedViewAt(0);
                }
                if (cachedViewSize < mViewCacheMax) {
                    mCachedViews.add(holder);
                    cached = true;
                }
            }
            if (!cached) {
                addViewHolderToRecycledViewPool(holder);
                recycled = true;
            }
        }
        ...

       代码又做了一波精简,映入眼帘的两个熟悉的字眼mCachedViews,RecycledViewPool,没错了这就是我们的缓存器,这边大概的逻辑就是:回收的itemView优先存放到最大容量为2的mCachedViews,如果mCachedViews已经满了,就移除一个出去到RecycledViewPool中去;看到这里,我们也可以猜测,之前将的mAttachedScrap之辈跟复用应该是没半毛钱关系的,整个复用机制的主力军就是这两位mCachedViews和RecycledViewPool;
       大篇幅的分析完毕,这时候实战项目的问题还没解决,一顿解说猛如虎,实际操作零杠五,所以根据上面的分析,我们也来理论对号入座;首先一开始,屏幕的9个item经历了一次remove和detach,也就是layoutManager在mAttachedScrap中拿到了view,摆放在了recyclerview可见区域里;这时候点击了下载按钮,这时候缓存池里的兄弟们除了mAttachedScrap都还没操刀干活,只有当屏幕往上滑一小段(还会再create几个viewholder),该复用前面的itemview了,开始找缓存里有没有holder可以复用,所以这边item14很明显是复用到了item 0的holder(可以给itemview打Tag验证),itemview上的text被bind上了新的数字,但是,下载按钮并没有给他新的数据,所以它还是用的item 0的下载状态,这时候,我们就应该主动地去调解复用上的分歧了,直接晒出Adapter里完整的代码(这边使用的时是封装的adapter,convert就是onBindViewHolder):


public class DownloadAdapter extends BaseQuickAdapter<GameBean, DownloadAdapter.SimpleViewHolder> {
    private final static String TAG="Adapter";
    private CompositeDisposable compositeDisposable;
    private Disposable disposable;

    private void addDisposable(Disposable disposable){
        if (compositeDisposable == null) {
            compositeDisposable = new CompositeDisposable();
        }
        compositeDisposable.add(disposable);
    }

    private void removeDisposable(Disposable disposable){
        if (disposable!=null)
            compositeDisposable.remove(disposable);
    }

    public void removeDisposable(){
        if (disposable!=null) {
            removeDisposable(disposable);
            if (!disposable.isDisposed()) {
                disposable.dispose();
            }
        }
    }

    public DownloadAdapter(int layoutResId, @Nullable List<GameBean> data) {
        super(layoutResId, data);

    }

    @Override
    protected void convert(final SimpleViewHolder helper, final GameBean item) {
        helper.setText(R.id.tv,item.getTv());

        final DownloadProgressButton button=helper.getView(R.id.btn_download);
        //读取下载按钮的状态
        if (item.getState()==0){
            button.setStartText("下载");
            button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_BEGIN);
            button.setProgress(0);
        } else {
            if (item.getState()==Constant.DOWNLOAD_STATE_DEFAULT){
                button.setStartText("下载");
                button.setProgress(0);
               button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_BEGIN);
            }else if (item.getState()==Constant.DOWNLOAD_STATE_DOWDLOADING){
                button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_DOWNLOADING);
                button.setProgress(item.getProgress());
            }else if  (item.getState()==Constant.DOWNLOAD_STATE_PAUSE){
                button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_PAUSE);
                button.setProgress(item.getProgress());
            }else if (item.getState()==Constant.DOWNLOAD_STATE_FINISH){
              button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_FINISH);
                button.setProgress(100);
            }
        }
      //每次下载按钮onClick相应时的回调,分别有下载,暂停,完成几种状态
        button.setStateChangeListener(new DownloadProgressButton.StateChangeListener() {
            @Override
            public void onPauseTask() {

                button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_PAUSE);
                item.setState(Constant.DOWNLOAD_STATE_PAUSE);
                item.setProgress(button.getProgress());
                if (!disposable.isDisposed()){
                    disposable.dispose();
                }
            }

            @Override
            public void onFinishTask() {
                button.setState(DownloadProgressButton.STATUS_PROGRESS_BAR_FINISH);

                item.setState(Constant.DOWNLOAD_STATE_FINISH);
                item.setProgress(100);
            }

            @Override
            public void onLoadingTask() {

                    disposable= Observable.interval(0, 1, TimeUnit.SECONDS)
                            .subscribeOn(Schedulers.computation())
                            .filter(new Predicate<Long>() {
                                @Override
                                public boolean test(Long aLong) throws Exception {
                                    return aLong < 100;
                                }
                            })
                            .map(new Function<Long, Object>() {
                                @Override
                                public Object apply(Long aLong) throws Exception {
                                    return aLong.intValue();
                                }
                            })
                            .doOnNext(new Consumer<Object>() {
                                @Override
                                public void accept(Object o) throws Exception {

                                }
                            })
                            .subscribeOn(AndroidSchedulers.mainThread())
                            .subscribe(new Consumer<Object>() {
                                @Override
                                public void accept(Object o) throws Exception {
                                    percent+=2;
                                    button.setProgress(percent);
                                    item.setState(Constant.DOWNLOAD_STATE_DOWDLOADING);
                                    item.setProgress(button.getProgress());
                                }
                            });
                    addDisposable(disposable);
            }
        });
    }

    int percent=0;
     public static class SimpleViewHolder extends MyViewHolder{

         DownloadProgressButton button;
         public SimpleViewHolder(View view) {
             super(view);
             button=getView(R.id.btn_download);

         }
     }
}

       因为是demo,所以在实际项目开发中这样写法是不科学的,但是原理是一样的,就是每次渲染item的时候,都必须给它设置上数据,这边我模拟给每个itemBean加上两个字段:

public class DownloadBean {

    private String tv;

    private int state;

    private int progress;

    public int getProgress() {
        return progress;
    }

    public void setProgress(int progress) {
        this.progress = progress;
    }
    
    public int getState() {
        return state;
    }

    public void setState(int state) {
        this.state = state;
    }

    public String getTv() {
        return tv;
    }

    public void setTv(String tv) {
        this.tv = tv;
    }
}

       state和progress就是专门给下载按钮加的字段,state是下载的状态:下载中、暂停等等;progress就是下载的进度百分比。
接着就是在你每次点击下载按钮的时候,都需要将状态和进度保存起来

item.setState(Constant.DOWNLOAD_STATE_DOWDLOADING);//不同回调里设置不同状态,详见以上代码
item.setProgress(button.getProgress());

       这样,你的onBindViewHolder,在这边是Convert,每次渲染item,就会去从你的bean去读取当前的状态和进度,为每一个下载按钮设置上属于它自己的状态和进度,我们再看下最后的正确效果:


下载列表2.gif

       可以看到,不会再出现复用上的差错,每个下载按钮都有自己的状态或下载进度。

  • 总结

       了解了RecyclerView的复用机制,我们也就很容易找到问题的所在,每个ViewHolder被拿出来复用的时候,除了二级缓存里的mCache的是可以不用重新bind数据,而从RecyclerPool取出来的是光溜溜的,需要重新bind上数据,这时候如果没有给它穿上衣服(bind上数据),它就会穿着之前人的衣服,这就造成了尴尬。

  • 最后

       这篇断断续续两个星期才写完了,这其中也是边学边总结,还是有理解出错的地方,后续慢慢修正。

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

推荐阅读更多精彩内容