Anatomy of RecyclerView: a Search for a ViewHolder[译]

RecyclerView剖析:搜索ViewHolder

原文: Anatomy of RecyclerView: a Search for a ViewHolder

介绍

在本系列文章中,我将分享我对RecyclerView内部工作原理的了解。为什么?试想一下:几乎每个现代Android应用都需要使用RecyclerView,因此开发人员对它的使用方式会影响数百万用户的体验。然而,我们在RecyclerView上有什么样的教育资料?您当然可以找到一些关于如何使用 RecyclerView的基本教程,但如何工作的呢?“黑匣子”方法绝对不够好,特别是如果您正在进行复杂的自定义或优化性能。[1]我推荐过 “最深”的材料可能是Google I/O 2016讨论的RecyclerView的来龙去脉,但是说真的,这甚至都不是“来龙去脉”,这只是冰山一角。我的目标是更深入。

我假设读者具有RecyclerView的基本知识,如:LayoutManager是什么,如何通知adapter更改制定数据或如何使用item的viewType。

在第一部分中,我们将理解RecyclerView内的一个方法:getViewByPosition()(support-v7 27.0.2 源码中为Recycler.getViewForPosition())。这是源代码中最重要的部分之一,通过研究,我们将了解RecyclerView的许多方面,例如ViewHolder回收,隐藏视图,预测动画和固定ID。看到这里的预测动画您可能会惊讶。嗯,尽管Google的人们尽最大努力解耦RecyclerView不同的责任组件,但它们之间仍然共享了许多“知识”,预测动画就是其中之一。无法避免谈论到它们。

因此在laying items时,LayoutManager会询问RecyclerView“请在8号位给我一个视图”。以下是RecyclerView的响应:

  1. 搜索changed scrap
  2. 搜索attached scrap
  3. 搜索未删除的hidden views
  4. 搜索view cache
  5. 如果Adapter具有稳定的ID,则会针对给定的ID再次搜索attached scrapview cache
  6. 搜索ViewCacheExtension
  7. 搜索RecycledViewPool

如果所有这些地方都无法在找到合适的视图,它会通过调用适配器的onCreateViewHolder()方法创建一个。然后,如果需要onBindViewHolder(),它会绑定View ,最后返回它。

RecyclerView的响应源码:

public class RecyclerView {
    public final class Recycler {
        @Nullable
        ViewHolder tryGetViewHolderForPositionByDeadline(int position,
                                                         boolean dryRun, long deadlineNs) {
            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) {
                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);
                }
                //Find from mViewCacheExtension
                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 (holder == null) { // fallback to pool
                    holder = getRecycledViewPool().getRecycledView(type);
                }

                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;
                    }
                    //Create ViewHolder
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
                }
            }

            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);
                //Bind ViewHolder
                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;
            }
            //When a ViewHolder is created, the reference to it is stored in the View’s LayoutParams
            rvLayoutParams.mViewHolder = holder;
            rvLayoutParams.mPendingInvalidate = fromScrapOrHiddenOrCache && bound;
            return holder;
        }
    }
}


RecycledViewPool

对每种缓存,我们希望找到以下答案:它的后备数据结构是什么,存储和检索ViewHolders的条件,最重要的是,它的目的是什么。

您可能非常了解池的用途:向下滚动时,向上消失的视图将被回收到池中,以便从底部出现的视图重用。ViewHolders进入池中的其他场景,我们将稍后讨论。但首先让我们来看看一些RecycledViewPool的代码(这是RecyclerView.Recycler的内部类):

public static class RecycledViewPool {
    private SparseArray<ArrayList<ViewHolder>> mScrap =
                   new SparseArray<>();
    private SparseIntArray mMaxScrap = new SparseIntArray();
    …
    public ViewHolder getRecycledView(int viewType) {
        ArrayList<ViewHolder> scrapHeap = mScrap.get(viewType);
        …

首先,不要为mScrap这个变量名感到困惑 - 这与上面列表中提到的attached scrap或changed scrap无关。

我们看到每个viewType都有自己的ViewHolders池(mScrap:key为viewType)。当RecyclerView在搜索ViewHolder期间用完所有其他可能性时,它会要求池根据viewType提供ViewHolder;在这一点上,viewType是唯一重要的。

现在,每种viewType都有自己的容量。它默认为5,但您可以像这样更改它:

recyclerView.getRecycledViewPool()
            .setMaxRecycledViews(SOME_VIEW_TYPE, POOL_CAPACITY);

这对灵活性是非常重要的。如果屏幕上有许多相同类型的items(通常会同时更改),请使该viewType的池更大。如果您知道,某些viewType的项目非常罕见,它们出现在屏幕上的数量永远不会超过一个,请设置该viewType池大小为1。否则,池迟早会被5个同样viewType的item填满,其中4个就会闲置在那里,这是浪费内存。

方法getRecycledView()putRecycledView()clear()是公开的,所以你可以操纵池的内容。但是手动使用putRecycledView()是个坏主意,例如:预先准备一些ViewHolders。您应该onCreateViewHolder()适配器的方法中创建ViewHolder ,否则ViewHolders可以出现在RecyclerView不期望的状态中。[2]

另一个很酷的功能是,除了getRecycledViewPool()之外还有一个setRecycledViewPool(),因此您可以为多个RecyclerView重用单个池。

最后,我会注意到每个viewType的池都是一个堆栈(后进先出)。为什么使用栈更好,我们稍后会介绍。

汇集方式

现在让我们解决ViewHolders何时被抛入池中的问题。有5种场景:

  1. 在滚动期间,超出了RecyclerView的界限。
    (不是直接放入pool中,也可能会放入viewCache中 稍后介绍)
  2. 数据已更改,因此不再显示该view。当消失动画结束时,会添加到池中。
  3. 更新或删除view cache中的item。
  4. 在scrap或缓存中搜到了一个我们想要位置的ViewHolder,但由于错误的viewType或id(如果适配器具有固定的ID)而被判定为不合适的。[3]
  5. LayoutManager在pre-layout中添加了一个视图,但没有在post-layout中添加该视图。

前两个场景非常明显。然而,有一点需要注意的是,场景2不仅在删除有问题的item时会触发,在插入其他item时也可能被触发,例如其他item插入后,被推出界限的item不显示时。
场景1
LinearLayoutManager.scrollBy() -->
LinearLayoutManager.fill() -->
LinearLayoutManager.recycleByLayoutState() -->
LinearLayoutManager.recycleViewsFromStart() -->
LinearLayoutManager.recycleChildren()-->
RecyclerView.LayoutManager.removeAndRecycleViewAt()-->
RecyclerView.Recycler.recycleView()-->
RecyclerView.Recycler.recycleViewHolderInternal() (存入viewCache、pool的逻辑)-->
RecyclerView.Recycler.addViewHolderToRecycledViewPool-->
RecycledViewPool.putRecycledView(ViewHolder scrap)

class LinearLayoutManager {
     /**
     * Recycles views that went out of bounds after scrolling towards the end of the layout.
     * <p>
     * Checks both layout position and visible position to guarantee that the view is not visible.
     */
     private void recycleViewsFromStart(RecyclerView.Recycler recycler, int dt){
         
        final int childCount = getChildCount();
        if (mShouldReverseLayout) {
            for (int i = childCount - 1; i >= 0; i--) {
                View child = getChildAt(i);
                if (mOrientationHelper.getDecoratedEnd(child) > limit
                        || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
                    // stop here
                    recycleChildren(recycler, childCount - 1, i);
                    return;
                }
            }
        } else {
            for (int i = 0; i < childCount; i++) {
                View child = getChildAt(i);
                if (mOrientationHelper.getDecoratedEnd(child) > limit
                        || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
                    // stop here
                    recycleChildren(recycler, 0, i);
                    return;
                }
            }
        }
     }
}

其他方案需要一些说明。我们还没有涵盖view cache和scrap,但方案3和4背后的想法很简单。池保存的是“dirty” views和需要重新绑定的view。除了池之外,所有缓存中的ViewHolders都保留了一些状态(最重要的是位置)。所有这些缓存都按位置搜索,希望一些ViewHolder可以按原样重用。相反,当视图进入池时,它的状态(所有标志,位置等)被清除。唯一剩下的就是关联视图和view type。正如我们所知,池时根据view type搜索的,当在池中找到ViewHolder时,ViewHolder会开始新的生命周期。

鉴于该情况,场景3和场景4应该不难理解:例如,如果视图缓存中的某个项被删除,那么将其保留在该缓存中是没有意义的,因为无法在原有的位置重用(原有的位置已经被删除)。但是把它扔掉是不好的,所以我们把它扔进池。(见RecyclerView.Recycler.recycleViewHolderInternal())

最后一个场景要求我们知道pre-layout和post-layout的内容。好吧,让我们继续吧!虽然pre-layout/post-layout不是很重要,但这种机制一般在RecyclerView的每个部分都有所体现,所以我们无论如何都要知道它。


Offtopic:预布局,布局后和预测动画

考虑一个场景,我们有a,b和c项,其中a和b适合屏幕。我们删除b,它将c带入视图:


预布局_1.png

我们希望看到的是c从底部顺利滑动到它的新位置。但怎么做呢?我们知道新布局中c的最终位置,但如何知道它应该从何处滑过来?通过查看c应该来自底部的新布局来假设RecyclerView或ItemAnimator是错误的。我们可能有一些自定义的LayoutManager,让c从侧面或其他地方进来。所以我们需要LayoutManager的更多帮助。我们可以使用以前的布局吗?不行,因为那里没有с。那时没人知道b将被删除,所以LayoutManager认为布局c是浪费资源。

谷歌的解决方案提供如下。在适配器发生更改后,RecyclerView会从LayoutManager请求两个而不是一个布局。第一个 - 预布局,在先前的适配器中布置项目的状态,但使用适配器更改作为提示,布置一些额外的视图,这可能是个好主意。在我们的例子中,因为我们现在知道b被删除了,所以我们额外列出了c,尽管它已经超出界限。第二个 - 后布局,只是一个正常的布局,对应于更改后的适配器状态。


预测动画_1.png

现在,通过比较预布局和布局后c的位置,我们可以正确地为其外观设置动画。

这种动画 - 当动画视图在先前的布局中或在新的布局中都不存在时 - 被称为预测动画,这是RecyclerView中最重要的概念之一。我们将在本系列的后续部分中更详细地讨论它。但现在让我们快速看一下另一个例子:如果b不是被删而只是改变了除怎么办?

预布局_2.png

可能让你惊讶:LayoutManager仍然在预布局阶段布局c。为什么?因为b的改变可能会使它变得更小,谁知道呢?如果b变小,c可能会从底部弹出,所以我们最好在预先布局中将其布局。但后来,在后期布局中,似乎并非如此,我们只是在b中更改了一些TextView 。因此不需要c,并将其扔进池中。这就是进入pool的场景5中描述的。现在我们可以重新回到RecycledViewPool。


RecycledViewPool,续

当我们遇到ViewHolder应该进入池的场景时,还有两个障碍:它可能不是可回收的;它的View可能处于临时状态。

可回收

可回收性只是ViewHolder中的一个标志,您可以使用RecyclerView.ViewHolder.setIsRecyclable()方法进行操作。RecycleView也通过此方法让ViewHolders在动画期间不可回收。

从不同地方操纵同一个标志通常是一个坏主意。例如,当动画结束时,RecyclerView会调用setIsRecyclable(true),因为程序的某些特定原因,你希望它不可回收。但是在这种情况下事情并没有真正打破,因为调用setIsRecyclable()是配对的。也就是说,如果你调用setIsRecyclable(false)两次,那么setIsRecyclable(true) 只调用一次不会使ViewHolder可回收,你也需要调用setIsRecyclable(true)两次。

临时状态

View的临时状态也类似。它是View中的一个标志,由setHasTransientState()方法操纵,并且也是配对调用的。View类本身不使用该标志,只是保留它。它可以作为ListView和RecyclerView等控件的提示,在新内容中最好不要重用临时状态下的View。

您可以自己设置此标志,但ViewPropertyAnimatorsomeView.animate()…被调用时)会在动画开始时自动将其设置为true,并在动画结束时自动设置为false。[4]请注意,如果您使用ValueAnimator为视图设置动画,则必须自行管理临时状态。

关于临时状态的最后一点需要注意的是,它是从子节点传播到父节点,一直传播到根视图。因此,如果您为列表中的item的某个内部view设置动画,不仅仅是该item的内部view,就连ViewHolder引用root view也会进入临时状态。

OnFailedToRecycleView

如果要回收的ViewHolder无法通过可回收性或临时状态检查,则Adapter的onFailedToRecycleView()方法会触发。这是非常重要的一点:这种方法不仅仅是一个事件的通知,而且是一个如何处理的问题。

onFailedToRecycledView()中直接return true的意思是“无论如何都回收它”。其中一个适用的场景是,在绑定新项目时清除所有动画和其他此类问题的来源。或者,您可以在onFailedToRecycledView()方法中处理这些事情。

你不该完全忽略onFailedToRecycledView()。否则会给您带来损失,比如以下情况:想象一下,当item进入视野时,其中的图像淡入显示。如果用户滚动列表足够地快,则当图像离开视图时,图像还没有完成淡入,导致ViewHolders无法进行回收。因此,滚动会滞后,最重要的是,新的ViewHolders不停的创建,使内存变得紧张。

ViewHolder回收成功时会调用onViewRecycled()方法,这是释放大量资源(如图像)的好地方。请记住,一些ViewHolder实例可能会在没有使用的情况下长时间留在池中,这可能会浪费大量内存。

现在我们进入下一种缓存 - view cache


View Cache

当我说“view Cache”(视图缓存)或只是“cache”(缓存),所指的都是RecyclerView.Recycler类中的mCachedViews字段。它在代码中的一些注释中也称为“第一级缓存”。

这只是ViewHolders的ArrayList,这里没有按view type拆分。默认容量为2,您可以通过RecyclerView.setItemViewCacheSize()的方法进行调整。

正如我之前提到的,pool和其他缓存(包括view cache)之间最重要的区别是,在pool中搜索ViewHolder是根据view type,而在其他缓存中搜索是根据关联的position。当ViewHolder在view cache中时,它进入缓存后与进入缓存前的位置相同,我们希望“原样”重用它而不需要重新绑定。所以让我们明确这个区别:

  • 如果ViewHolder找不到,它将被创建和绑定。
  • 如果在pool中找到ViewHolder ,它将被绑定。
  • 如果在cache中找到ViewHolder ,则无需执行任何操作。

这时,有一个重要的事情变得很清楚:一个ViewHolder的绑定、回收到pool中(onViewRecycled())和它进入、移出列表的可视范围是不一样的东西。当ViewHolder进入可视范围时,ViewHolder有时会从view cache中检索到并且没有重新绑定;当它从可视范围移出时,它的ViewHolder可以缓存到view cache中而不是pool中(参考RecyclerView.Recycler.recycleViewHolderInternal() )。如果您需要在屏幕上跟踪item的存在,请使用适配器的onViewAttachedToWindow()onViewDetachedFromWindow()回调。

填充pool和cache

现在,回到下一个问题:ViewHolders如何在view cache中结束?当我谈到viewholder缓存到pool的场景时,我实际上欺骗了你一点点。在这些情况下(第三个除外),ViewHolder会转到缓存或池中。[5]

让我举例说明选择cache或pool的规则。比如说,我们最初有空cache和pool,items逐个被回收。这是cache和pool的填充方式(假设容量为默认且只有一种view type):


View_Cache_Pool_1.png

因此,只要cache未满,ViewHolders就会存到那里。如果它已满,则新的ViewHolder将缓存中已有的ViewHolder从缓存的“另一端”推送到池中。如果一个池已经满了,那么ViewHolder会被遗忘到垃圾收集器。[6]


Cache和Pool的运转方式

现在让我们看看cache和pool在RecyclerView的几个实际使用场景。

滚动中:


滚动中_1.png

当我们向下滚动时,在当前看到的items后面有一个“尾巴”,包括cache中的item,然后是一个pool中的item。当item8出现在屏幕上时,在缓存中找不到合适的ViewHolder:没有与位置8相关联的ViewHolder。所以我们使用一个pool中的ViewHolder,它先前位于第3位。当第6项消失在顶部时,它进入缓存,将4推入池中。

当我们开始向相反方向滚动时,图片会有所不同:


滚动_2.png

在这里,我们在视图缓存中找到位置5的ViewHolder,并立即重用它,无需重新绑定。这似乎是缓存的主要用例 - 反方向滚动查看刚刚看到的item,此时效率更高。因此,如果您有新闻源,则缓存可能无用,因为用户不会经常返回。但是如果它是一个可供选择的列表,比如一个壁纸库,你可能想要扩展缓存的容量。

这里有几点需要注意。首先,如果我们向上滚动查看3怎么办?请记住,池的工作方式就像一个堆栈,所以如果我们上次看到3之后只是滚动,除此之外没有做任何事情,那么ViewHolder 3将是最后一个放入池中的,因此现在在第3位重新绑定。实际上如果数据没有改变,我们在绑定时不需要做任何事。您应该始终检查onBindViewHolder()是否确实需要更改此TextView或ImageView等,此处不需要做更改。

其次,请注意滚动时池中总是不超过一个项目(每种视图类型)!(当然,如果你有一个包含n列的多列网格,那么你将在池中有n个项目。)通过场景2-5在池中结束的其他项目,只是在滚动期间无用地停留在那里。

现在让我们看一个场景,相比之下,很多项目都会进入池中:调用notifyDataSetChanged() (或者notifyItemRangeChanged() 使用一些范围参数):

池中包含多个Items.png

所有ViewHolders都变得无效,缓存不适合他们,他们都试图存入池中。池中可能没有足够的空间,因此一些不幸的item将被作为垃圾收集然后再次创建。与滚动相比,在这种情况下您可能需要更大的池。更大的池另一个有用的情况是通过调用scrollToPosition()从一个位置跳到另一个位置。

那么池的最佳大小如何选择呢?似乎最佳策略是在你需要池之​​前扩充它,并在之后缩小它。实现此目的,以下是一种简单粗暴的方式:

recyclerView.getRecycledViewPool().setMaxRecycledViews(0, 20);
adapter.notifyDataSetChanged();
new Handler().post(new Runnable() {
    @Override
    public void run() {
        recyclerView.getRecycledViewPool()
                    .setMaxRecycledViews(0, 1);
    }
});

接下来:
Anatomy of RecyclerView: a Search for a ViewHolder (continued)

[1]事实上,即使了解RecyclerView的公共API,也需要了解一些内部工作原理。例如,javadoc to setHasStableIds()方法不会告诉您为什么要使用它。

[2]例如,createViewHolder()在适配器调用之后的方法中设置了正确的视图类型,并且该字段是包本地的,因此您无法自己设置它。

[3]发生这种情况时的示例:更改项目,以便更改视图类型,调用notifyItemChanged()。此外,禁用ItemAnimator中的更改动画,否则将发生方案2。

[4]ViewView处于临时状态的另一个例子是EditText,其中选择了一些文本或正在编辑过程中。

[5]在缓存和池之间进行选择之前检查可回收性和临时状态,老实说对我没有多大意义,因为缓存中的视图应该完全以消失时的状态重新出现。

[6]在support版本23中,这种机制被一个简单的逐个索引错误打破。当我们逐个回收ViewHolders时,缓存中ViewHolders的数量在1和2之间交替变化。

结合log 看布局过程中从Recycler.mCachedViews 获取viewHolder

 class Recycler{        
        /**
         * Returns a view for the position either from attach scrap, hidden children, or cache.
         *
         * @param position Item position
         * @param dryRun  Does a dry run, finds the ViewHolder but does not remove
         * @return a ViewHolder that can be re-used for this position.
         */
        ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
            

            //...


            // 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;
                }
            }
            return null;
        }
}
at android.support.v7.widget.RecyclerView$Recycler.getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
        at android.support.v7.widget.RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline(RecyclerView.java:5750)
        at android.support.v7.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:5589)
        at android.support.v7.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:5585)
        at android.support.v7.widget.LinearLayoutManager$LayoutState.next(LinearLayoutManager.java:2231)
        at android.support.v7.widget.LinearLayoutManager.layoutChunk(LinearLayoutManager.java:1558)
        at android.support.v7.widget.LinearLayoutManager.fill(LinearLayoutManager.java:1518)
        at android.support.v7.widget.LinearLayoutManager.onLayoutChildren(LinearLayoutManager.java:610)


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

推荐阅读更多精彩内容