RecyclerView缓存机制

1.前言

RecyclerView已经是大家比较熟悉的一个控件了,官方对其做了很好的封装抽象,使得它既灵活又好用,但是在它简单的使用方式之下着实是不简单,今天就围绕RecyclerView的视图回收机制来谈一谈,到底RecyclerView的回收机制是怎样的。

2.缓存模式

为什么RecyclerView强制我们实现ViewHolder模式?
关于这个问题,我们对比一下之前接触比较多的ListView就明白了. ListView是不强制我们实现ViewHolder的,但是后来Google建议我们实现ViewHolder模式.我们来看下两者的区别

不使用ViewHolder方式

   @NonNull
    @Override
    public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
        if(convertView==null){
            convertView=LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
        }
        TextView tvName=convertView.findViewById(R.id.tv_name);
        TextView tvContent=convertView.findViewById(R.id.tv_content);
       
        return convertView;
    }

使用ViewHolder方式

   // convertView 参数用于将之前加载好的布局进行缓存
    @Override
    public View getView(int position, View convertView, ViewGroup parent){   
        ViewHolder viewHolder;
        if (convertView==null){   
            convertView=LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
            viewHolder=new ViewHolder();
            viewHolder.tvName=convertView.findViewById(R.id.tv_name);
            viewHolder.tvContent=convertView.findViewById(R.id.tv_content);
            // 将ViewHolder存储在View中(即将控件的实例存储在其中)
            convertView.setTag(viewHolder);
        } else{
            viewHolder=(ViewHolder) convertView.getTag();
        }
        return convertView;
    }
    // 定义一个内部类,用于对控件的实例进行缓存
    class ViewHolder{
        TextView tvContent;
        TextView tvName;
    }

通过对比我们可以发现,在不使用ViewHolder的时候每次getView都要就行findViewById()操作,而findViewById()操作也是非常耗性能的.通过ViewHolder的复用不但减少的findViewById()的次数同时也提高了效率.而对于RecyclerView来说,强制实现ViewHolder的其中一个原因就是避免多次进行findViewById()的处理,另一个原因就是因为ItemView和ViewHolder的关系是一对一,也就是说一个ViewHolder对应一个ItemView。这个ViewHolder当中持有对应的ItemView的所有信息,拿到了ViewHolder基本就拿到了ItemView的所有信息,而ViewHolder使用起来相比itemView更加方便。RecyclerView缓存机制缓存的就是ViewHolder(ListView缓存的是ItemView),这也是为什么RecyclerView为什么强制我们实现ViewHolder的原因。

3.缓存层级

RecyclerView的缓存机制是通过内部类Recycler实现的,我们先来看下内部类Recycler

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

        final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
        RecycledViewPool mRecyclerPool;

        private ViewCacheExtension mViewCacheExtension;
        …… 省略 ……
    }

从上面代码中可以看出Recycler类中一共声明了五个成员变量
1.mAttachedScrap可以看到这个变量是个存放ViewHolder对象的ArrayList,而且是没有容量限制的,它是属于Scrap的一种,这里的数据是不做修改的,不会重新走Adapter的绑定方法的。

  1. mChangedScrap这个变量和mAttachedScrap是一样的,唯一不同的是,它存放的是发生变化的ViewHolder,如果使用到这里缓存的ViewHolder是要重新走Adapter的绑定方法的。

3.mCachedViews这个变量同样是一个存放ViewHolder对象的ArrayList,但是这个不同于上面的两个里面存放的是dettach掉的视图,它里面存放的是已经remove掉的视图,已经和RecyclerView分离关系的视图,但是它里面的ViewHolder依然保存着之前的信息(绑定的数据以及位置信息等),而且它的容量是有限的默认是2(不同的API可能会有差异),同样它的大小也是可以修改的,合理的改变它的大小可以ViewHolder数据绑定的次数。

4.mRecyclerPool这个变量是一个类和上面三个不一样,这里面保存的ViewHolder不仅仅是remove掉的视图,而且是‘恢复出厂设置’的视图,任何绑定过的痕迹都没有了,如果想用这里的缓存的ViewHolder那就要重新走Adapter的绑定方法。因为RecyclerView是支持多布局的,所以mRecyclerPool的缓存是按照itemType来分开存储的,来看一下它的结构:

    public static class RecycledViewPool {
        private static final int DEFAULT_MAX_SCRAP = 5;
        static class ScrapData {
            ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
            int mMaxScrap = DEFAULT_MAX_SCRAP;
            …… 省略 ……
        }
        SparseArray<ScrapData> mScrap = new SparseArray<>();
        …… 省略 ……
    }

1.首先我们看到一个常量‘DEFAULT_MAX_SCRAP’默认值为5,这个就是一个缓存池的默认缓存数。它不是整个缓存池的总数,它是每个对应itemType类型的默认缓存数,当然你可以针对不同的类型修改其缓存数的大小,适当的修改缓存数的大小可以减少ViewHolder的创建数量。
2.我们看到一个静态内部类ScrapData,我们还看到了mMaxScrap并且前面的常量赋值给了它,这就解释了上面提到的,这个缓存数量是对应不同itemType类型的缓存数,再看一下mScrapHeap同样是一个缓存ViewHolder的ArrayList,这就说明ScrapData类是mScrapHeap对ViewHolder进行缓存,并且数组的最大值为5的类的一个封装。
3.最后我们看到了mScrap这个变量,它是一个存储我们上面提到的ScrapData类的对象的SparseArray,这样就解释了RecyclerPool是不同itemType的ViewHolder按itemType类型分类缓存起来的。

5.mViewCacheExtension这一级缓存是留给开发者自由发挥的,并没有默认实现,它本身就null,一般情况下是用不到它的这里就略过了。

4.各缓存的使用

上面讲到了RecyclerView的各个缓存层级,接下来我们就聊一聊它们是如何工作的,我们就从LinearLayoutManager简单的布局管理者入手。

1.RecyclerView 从无到有的过程

首先我们先看一下RecyclerView从无到有是怎么显示出数据来的。通常一个View视图的显示必然要经历 onMeasure , onLayout , onDraw 三个过程,我们就来看下onMeasure中做了什么。

@Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        if (mLayout == null) {
            defaultOnMeasure(widthSpec, heightSpec);
            return;
        }
       if (mLayout.isAutoMeasureEnabled()) {
             …… 省略 ……
            if (mState.mLayoutStep == State.STEP_START) {
                dispatchLayoutStep1();
            }
             …… 省略 ……
            dispatchLayoutStep2();
             …… 省略 ……
        }
             …… 省略 ……
    }

然后我们再来看下dispatchLayoutStep1和dispatchLayoutStep2方法

private void dispatchLayoutStep1() {
          …… 省略 ……
        if (mState.mRunPredictiveAnimations) {
          …… 省略 ……
            mLayout.onLayoutChildren(mRecycler, mState);
        }
          …… 省略 ……
        onExitLayoutOrScroll();
        stopInterceptRequestLayout(false);
        mState.mLayoutStep = State.STEP_LAYOUT;
    }

private void dispatchLayoutStep2() {
           …… 省略 ……
        mState.mInPreLayout = false;
        mLayout.onLayoutChildren(mRecycler, mState);

        mState.mLayoutStep = State.STEP_ANIMATIONS;
        onExitLayoutOrScroll();
        resumeRequestLayout(false);
    }

我们可以看到dispatchLayoutStep1和dispatchLayoutStep2方法都调用了mLayout.onLayoutChildren(mRecycler, mState); 而mLayout是RecyclerView的成员变量,也就是LayoutManager,接下来我们看下LinearLayoutManager中onLayoutChildren 方法做了什么吧

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        …… 省略 ……
        detachAndScrapAttachedViews(recycler);

       if (mAnchorInfo.mLayoutFromEnd) {
            // fill towards start
            fill(recycler, mLayoutState, state, false);

            // fill towards end
            fill(recycler, mLayoutState, state, false);
            endOffset = mLayoutState.mOffset;
        } else {
            // fill towards end
            fill(recycler, mLayoutState, state, false);

            // fill towards start
            fill(recycler, mLayoutState, state, false);
            startOffset = mLayoutState.mOffset;
        }
        …… 省略 ……
    }

接着我们看下detachAndScrapAttachedViews(recycler);方法做了什么吧

    public void detachAndScrapAttachedViews(Recycler recycler) {
        final int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            final View v = getChildAt(i);
            scrapOrRecycleView(recycler, i, v);
        }
    }

在此方法中通过遍历子view然后调用scrapOrRecycleView(recycler, i, v); 那就继续追踪吧

private void scrapOrRecycleView(Recycler recycler, int index, View view) {
       final ViewHolder viewHolder = getChildViewHolderInt(view);
       if (viewHolder.isInvalid() && !viewHolder.isRemoved()
               && !mRecyclerView.mAdapter.hasStableIds()) {
           removeViewAt(index);
           recycler.recycleViewHolderInternal(viewHolder);
       } else {
           detachViewAt(index);
           recycler.scrapView(view);
           mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
       }
   }

正常开始布局的时候会进入else分支,首先是调用detachViewAt(index)来分离视图,然后调用了recycler.scrapView(view)方法。前面我们说过Recycler是RecyclerView的内部类,是管理RecyclerView缓存的核心类,然后我们继续追踪这个srapView方法,看看里面做了什么。

void scrapView(View view) {
        final ViewHolder holder = getChildViewHolderInt(view);
        if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
                || !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
            if (holder.isInvalid() && !holder.isRemoved() && !mAdapter.hasStableIds()) {
                throw new IllegalArgumentException("……");
            }
            holder.setScrapContainer(this, false);
            mAttachedScrap.add(holder);
        }
    }

这里我们看到了熟悉的身影,'mAttachedScrap',到此为止我们知道了,onLayoutChildren方法中调用detachAndScrapAttachedViews方法把存在的子view先分离然后缓存到了AttachedScrap中。我们回到onLayoutChildren方法中看看接下来做了什么,我们发现它先判断了方向,因为LinearLayoutManager有横纵两个方向,无论哪个方向最后都是调用fill方法,见名知意,这是个填充布局的方法,

 int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            ........省略..........
            layoutChunk(recycler, state, layoutState, layoutChunkResult)
        }
        if (DEBUG) {
            validateChildOrder();
        }
        return start - layoutState.mAvailable;
    }

fill方法中又调用了layoutChunk这个方法,我们看一眼这个方法。

void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
        View view = layoutState.next(recycler);
        if (view == null) {
            return;
        }
        if (layoutState.mScrapList == null) {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                addView(view);
            } else {
                addView(view, 0);
            }
        }
    }

该方法中我们看到通过layoutState.next(recycler)方法来拿到视图,如果这个视图为null那么方法终止,否则就会调用addView方法将视图添加或者重新attach回来,我们看看是怎么拿到视图的。

View next(RecyclerView.Recycler recycler) {
        if (mScrapList != null) {
            return nextViewFromScrapList();
        }
        final View view = recycler.getViewForPosition(mCurrentPosition);
        mCurrentPosition += mItemDirection;
        return view;
    }

首先我们看到如果mScrapList不为空会去其中取视图,mScrapList实际上它就是mAttachedScrap,但是它是只读的,而且只有在开启预测动画时才会被赋值,所以我们忽略它即可。重点关注下recycler.getViewForPosition(mCurrentPosition)方法,这个方法经过层层调用,最终是调用的Recycler类中的tryGetViewHolderForPositionByDeadline(int position,boolean dryRun,long deadlineNs)方法,接下来看一下这个方法做了哪些事。

    ViewHolder tryGetViewHolderForPositionByDeadline(int position,
            boolean dryRun, long deadlineNs) {
        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) {
            // 2) Find from scrap/cache via stable ids, if exists
            if (holder == null && mViewCacheExtension != null) {
                final View view = mViewCacheExtension
                        .getViewForPositionAndType(this, position, type);
            }
            if (holder == null) {
                holder = getRecycledViewPool().getRecycledView(type);
            }
            if (holder == null) {
                holder = mAdapter.createViewHolder(RecyclerView.this, type);
            }
        }
        return holder;
    }

这段代码着实做了不少事情,获取View和绑定View都是在这个方法中完成的,当然关于绑定和其它的无关代码这里就不贴了。我们一步步的看一下:

  1. 第一步先从getChangedScrapViewForPosition(position)方法中找需要的视图,但是有个条件mState.isPreLayout()要为true,这个一般在我们调用adapter的notifyItemChanged等方法时为true,其实也很好理解,数据发生了变化,viewholder被detach掉后缓存在mChangedScrap之中,在这里拿到的viewHolder后续需要重新绑定。
  2. 第二步,如果没有找到视图则从getScrapOrHiddenOrCachedHolderForPosition这个方法中继续找。这个方法的代码就不贴了,简单说下这里的查找顺序:
    首先从mAttachedScrap中查找
    再次从前面略过的ChildHelper类中的mHiddenViews中查找
    最后是从mCachedViews中查找的
  3. 第三步, mViewCacheExtension中查找,我们说过这个对象默认是null的,是由我们开发者自定义缓存策略的一层,所以如果你没有定义过,这里是找不到View的。
  4. 第四步,从RecyclerPool中查找,前面我们介绍过RecyclerPool,先通过itemType从SparseArray类型的mscrap中拿到ScrapData,不为空继续拿到scrapHeap这个ArrayList,然后取到视图,这里拿到的视图需要重新绑定。
  5. 第五步,如果前面几步都没有拿到视图,那么调用了mAdapter.createViewHolder(RecyclerView.this, type)方法,这个方法内部调用了一个抽象方法onCreateViewHolder,是不是很熟悉,没错,就是我们自己写一个Adapter要实现的方法之一。

到此为止我们获取一个视图的流程就讲完了,获取到视图之后就是怎么摆放视图并添加到RecyclerView之中,然后最终展示到我们面前。细心的小伙伴可能发现这个流程貌似有点问题啊?第一次进入onLayoutChildren时还没有任何子view,在fill方法前等于没有缓存子view,所有的子View都是第五步onCreateViewHolder创建而来的。实际上这里的设计是有道理的,除了一些特殊情况onLayoutChildren方法会被多次调用外,一个View从无到有展示在我们面前要至少经过两次onMeasure,一次onLayout,一次onDraw方法。所以这里需要做个缓存,而不至于每次都重新创建新的视图。

这里提一下,在RecyclerView展示成功后,Scrap这层的缓存就为空了,在从Scrap中取视图的同时就被移出了缓存。在onLayout这里最终会调用到dispatchLayoutStep3方法,没错,除了1和2还有3,在3中,如果Scrap还有缓存,那么缓存会被清空,清空的缓存会被添加到mCachedViews或者RecyclerPool中。

2.RecyclerView 滑动时的缓存过程

RecyclerView是可以通过滚动来展示大量数据的控件,那么由当前屏幕滚动而出的View去哪了?滚动而入的View哪来的?

一个LayoutManager如果可以滑动,那么scrollHorizontallyBy,scrollVerticallyBy两个方法要返回非0值,分别代表可以横向滚动和纵向滚动。最终两个方法都会调用scrollBy方法,然后scrollby方法调用了fill方法,这个fill我们已经见过了,现在再看一下。

 int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
        final int start = layoutState.mAvailable;
        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            // TODO ugly bug fix. should not happen
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            recycleByLayoutState(recycler, layoutState);
        }
    }

这这段代码中判断了当前是否是滚动触发的fill方法,如果是调用recycleByLayoutState(recycler, layoutState)方法。这个方法几经周转会调用到removeAndRecycleViewAt方法:

public void removeAndRecycleViewAt(int index, Recycler recycler) {
        final View view = getChildAt(index);
        removeViewAt(index);
        recycler.recycleView(view);
    }

这里注意先把视图remove掉了,而不是detach掉。然后调用Recycler中的recycleView方法,这个方法最后会调用recycleViewHolderInternal方法,方法如下:

    void recycleViewHolderInternal(ViewHolder holder) {

        if (forceRecycle || holder.isRecyclable()) {
            if (省略) {
                int cachedViewSize = mCachedViews.size();
                if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
                    recycleCachedViewAt(0);
                    cachedViewSize--;
                }
                mCachedViews.add(targetCacheIndex, holder);
                cached = true;
            }
            if (!cached) {
                addViewHolderToRecycledViewPool(holder, true);
                recycled = true;
            }
        }
    }

前面我们说过mCachedViews是有容量限制的,默认为2。那么如果符合放到mCachedViews中的条件,首先会判断mCachedViews是否已经满了,如果满了会通过recycleCachedViewAt(0)方法把最老得那个缓存放进RecyclerPool,然后在把新的视图放进mCachedViews中。如果这个视图不符合条件会直接被放进RecyclerPool中。我们注意到,在缓存进mCachedViews之前,我们的视图只是被remove掉了,绑定的数据等信息都还在,这意味着从mCachedViews取出的视图如果符合需要的目标视图是可以直接展示的,而不需要重新绑定。而放进RecyclerPool最终是要调用putRecycledView方法的。

public void putRecycledView(ViewHolder scrap) {
        final int viewType = scrap.getItemViewType();
        final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
        if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
            return;
        }
        scrap.resetInternal();
        scrapHeap.add(scrap);
    }

这个方法中同样对容量做了判断,跟mCachedViews不一样,如果容量满了,就不再继续缓存了。在缓存之前先调用了scrap.resetInternal()方法,这个方法顾名思义是个重置的方法,缓存之前把视图的信息都清除掉了,这也是为什么这里缓存满了之后就不再继续缓存了,而不是把老的缓存替换掉,因为它们重置后都一样了(这里指具有同种itemType的是一样的)。这就是滑动缓存的全过程。

3.数据更新时的缓存过程

数据更新更新RecyclerView通常的做法是set数据源后调用notifyDataSetChanged进行数据的更新展示。

◉在调用notifyDataSetChanged方法后,所有的子view会被标记,这个标记导致它们最后都被缓存到RecyclerPool中,然后重新绑定数据。并且由于RecyclerPool有容量限制,如果不够最后就要重新创建新的视图了。

◉但是使用notifyItemChanged等方法会将视图缓存到mChangedScrap和mAttachedScrap中,这两个缓存是没有容量限制的,所以基本不会重新创建新的视图,只是mChangedScrap中的视图需要重新绑定一下。

5.总结

从上面的分析我们可以知道 RecyclerView不同的时期使用的缓存层级是不一样的 可以说是四级缓存(从无到有加载显示数据),也可以说是两级(滑动时),所以我们需求可以动态的修改缓存默认数组,从而达到进一步提升RecyclerView的性能。

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