为了账号安全,请及时绑定邮箱和手机立即绑定

RecyclerView 源码分析(三) - RecyclerView的缓存机制

标签:
Java Sass/Less

  由于本文跟本系列的前两篇文章都有关联,所以为了便于理解,可以去看作者本系列的前两篇文章。
  注意,本文所有的代码都来自于27.1.1。

1. 概述

  在正式分析源码之前,我先对缓存机制做一个概述,同时也会对一些概念进行统一解释,这些对后面的分析有很大的帮助,因为如果不理解这些概念的话,后面容易看得雨里雾里的。

(1).四级缓存

  首先,我将RecyclerView的缓存分为四级,可能有的人将它分为三级,这些看个人的理解。这里统一说明一下每级缓存的意思。

缓存级别实际变量含义
一级缓存mAttachedScrapmChangedScrap这是优先级最高的缓存,RecyclerView在获取ViewHolder时,优先会到这两个缓存来找。其中mAttachedScrap存储的是当前还在屏幕中的ViewHoldermChangedScrap存储的是数据被更新的ViewHolder,比如说调用了AdapternotifyItemChanged方法。可能有人对这两个缓存还是有点疑惑,不要急,待会会详细的解释。
二级缓存mCachedViews默认大小为2,通常用来存储预取的ViewHolder,同时在回收ViewHolder时,也会可能存储一部分的ViewHolder,这部分的ViewHolder通常来说,意义跟一级缓存差不多。
三级缓存ViewCacheExtension自定义缓存,通常用不到,在本文中先忽略
四级缓存RecyclerViewPool根据ViewType来缓存ViewHolder,每个ViewType的数组大小为5,可以动态的改变。

  如上表,统一的解释了每个缓存的含义和作用。在这里,我再来对其中的几个缓存做一个详细的解释。

  1. mAttachedScrap:上表中说,它表示存储的是当前还在屏幕中ViewHolder。实际上是从屏幕上分离出来的ViewHolder,但是又即将添加到屏幕上去的ViewHolder。比如说,RecyclerView上下滑动,滑出一个新的Item,此时会重新调用LayoutManageronLayoutChildren方法,从而会将屏幕上所有的ViewHolderscrap掉(含义就是废弃掉),添加到mAttachedScrap里面去,然后在重新布局每个ItemView时,会从优先mAttachedScrap里面获取,这样效率就会非常的高。这个过程不会重新onBindViewHolder

  2. mCachedViews:默认大小为2,不过通常是3,3由默认的大小2 + 预取的个数1。所以在RecyclerView在首次加载时,mCachedViewssize为3(这里以LinearLayoutManager的垂直布局为例)。通常来说,可以通过RecyclerViewsetItemViewCacheSize方法设置大小,但是这个不包括预取大小;预取大小通过LayoutManagersetItemPrefetchEnabled方法来控制。

(2).ViewHolder的几个状态值

  我们在看RecyclerView的源码时,可能到处都能看到调用ViewHolderisInvalidisRemovedisBoundisTmpDetachedisScrapisUpdated这几个方法。这里我统一的解释一下。

方法名对应的Flag含义或者状态设置的时机
isInvalidFLAG_INVALID表示当前ViewHolder是否已经失效。通常来说,在3种情况下会出现这种情况:1.调用了AdapternotifyDataSetChanged方法;2. 手动调用RecyclerViewinvalidateItemDecorations方法;3. 调用RecyclerViewsetAdapter方法或者swapAdapter方法。
isRemovedFLAG_REMOVED表示当前的ViewHolder是否被移除。通常来说,数据源被移除了部分数据,然后调用AdapternotifyItemRemoved方法。
isBoundFLAG_BOUND表示当前ViewHolder是否已经调用了onBindViewHolder
isTmpDetachedFLAG_TMP_DETACHED表示当前的ItemView是否从RecyclerView(即父View)detach掉。通常来说有两种情况下会出现这种情况:1.手动了RecyclerViewdetachView相关方法;2. 在从mHideViews里面获取ViewHolder,会先detach掉这个ViewHolder关联的ItemView。这里又多出来一个mHideViews,待会我会详细的解释它是什么。
isScrap无Flag来表示该状态,用mScrapContainer是否为null来判断表示是否在mAttachedScrap或者mChangedScrap数组里面,进而表示当前ViewHolder是否被废弃。
isUpdatedFLAG_UPDATE表示当前ViewHolder是否已经更新。通常来说,在3种情况下会出现情况:1.isInvalid方法存在的三种情况;2.调用了AdapteronBindViewHolder方法;3. 调用了AdapternotifyItemChanged方法

(3). ChildHelper的mHiddenViews

  在四级缓存中,我们并没有将mHiddenViews算入其中。因为mHiddenViews只在动画期间才会有元素,当动画结束了,自然就清空了。所以mHiddenViews并不算入4级缓存中。
  这里还有一个问题,就是上面在解释mChangedScrap时,也在说,当调用AdapternotifyItemChanged方法,会将更新了的ViewHolder反放入mChangedScrap数组里面。那到底是放入mChangedScrap还是mHiddenViews呢?同时可能有人对mChangedScrapmAttachedScrap有疑问,这里我做一个统一的解释:

首先,如果调用了AdapternotifyItemChanged方法,会重新回调到LayoutManageronLayoutChildren方法里面,而在onLayoutChildren方法里面,会将屏幕上所有的ViewHolder回收到mAttachedScrapmChangedScrap。这个过程就是将ViewHolder分别放到mAttachedScrapmChangedScrap,而什么条件下放在mAttachedScrap,什么条件放在mChangedScrap,这个就是他们俩的区别。

  接下来我们来看一段代码,就能分清mAttachedScrapmChangedScrap的区别了

        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("Called scrap view with an invalid view."
                            + " Invalid views cannot be reused from scrap, they should rebound from"
                            + " recycler pool." + exceptionLabel());
                }
                holder.setScrapContainer(this, false);
                mAttachedScrap.add(holder);
            } else {                if (mChangedScrap == null) {
                    mChangedScrap = new ArrayList<ViewHolder>();
                }
                holder.setScrapContainer(this, true);
                mChangedScrap.add(holder);
            }
        }

  可能很多人初次看到这方法时,会非常的懵逼,我也是如此。今天我们就来看看这个方法。这个根本的目的就是,判断ViewHolder的flag状态,从而来决定是放入mAttachedScrap还是mChangedScrap。从上面的代码,我们得出:

  1. mAttachedScrap里面放的是两种状态的ViewHolder:1.被同时标记为removeinvalid;2.完全没有改变的ViewHolder。这里还有第三个判断,这个跟RecyclerViewItemAnimator有关,如果ItemAnimator为空或者ItemAnimatorcanReuseUpdatedViewHolder方法为true,也会放入到mAttachedScrap。那正常情况下,什么情况返回为true呢?从SimpleItemAnimator的源码可以看出来,当ViewHolderisInvalid方法返回为true时,会放入到 mAttachedScrap里面。也就是说,如果ViewHolder失效了,也会放到mAttachedScrap里面。

  2. 那么mChangedScrap里面放什么类型flag的ViewHolder呢?当然是ViewHolderisUpdated方法返回为true时,会放入到mChangedScrap里面去。所以,调用AdapternotifyItemChanged方法时,并且RecyclerViewItemAnimator不为空,会放入到mChangedScrap里面。

  了解了mAttachedScrapmChangedScrap的区别之后,接下我们来看Scrap数组和mHiddenViews的区别。

mHiddenViews只存放动画的ViewHolder,动画结束了自然就清空了。之所以存在 mHiddenViews这个数组,我猜测是存在动画期间,进行复用的可能性,此时就可以在mHiddenViews进行复用了。而Scrap数组跟mHiddenViews两者完全不冲突,所以存在一个ViewHolder同时在Scrap数组和mHiddenViews的可能性。但是这并不影响,因为在动画结束时,会从mHiddenViews里面移除。

  本文在分析RecyclerView的换出机制时,打算从两个大方面入手:1.复用;2.回收。
  我们先来看看复用的部分逻辑,因为只有理解了RecyclerView究竟是如何复用的,对回收才能更加明白。

2. 复用

  RecyclerViewViewHolder的复用,我们得从LayoutStatenext方法开始。LayoutManager在布局itemView时,需要获取一个ViewHolder对象,就是通过这个方法来获取,具体的复用逻辑也是在这个方面开始调用的。我们来看看:

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

  next方法里面其实也没做什么事,就是调用RecyclerViewgetViewForPosition方法来获取一个View的。而getViewForPosition方法最终会调用到RecyclerViewtryGetViewHolderForPositionByDeadline方法。所以,RecyclerView真正复用的核心就在这个方法,我们今天来详细的分析一下这个方法。

(1). 通过Position方式来获取ViewHolder

  通过这种方式来获取优先级比较高,因为每个ViewHolder还没被改变,通常在这种情况下,都是某一个ItemView对应的ViewHolder被更新导致的,所以在屏幕上其他的ViewHolder,可以快速对应原来的ItemView。我们来看看相关的源码。

            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
                        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;
                    }
                }
            }

  如上的代码分为两步:

  1. mChangedScrap里面去获取ViewHolder,这里面存储的是更新的ViewHolder

  2. 分别mAttachedScrapmHiddenViewsmCachedViews获取ViewHolder

  我们来简单的分析一下这两步。先来看看第一步。

            if (mState.isPreLayout()) {
                holder = getChangedScrapViewForPosition(position);
                fromScrapOrHiddenOrCache = holder != null;
            }

  如果当前是预布局阶段,那么就从mChangedScrap里面去获取ViewHolder。那什么阶段是预布局阶段呢?这里我对预布局这个概念简单的解释。

预布局又可以称之为preLayout,当当前的RecyclerView处于dispatchLayoutStep1阶段时,称之为预布局;dispatchLayoutStep2称为真正布局的阶段;dispatchLayoutStep3称为postLayout阶段。同时要想真正开启预布局,必须有ItemAnimator,并且每个RecyclerView对应的LayoutManager必须开启预处理动画

  是不是感觉听了解释之后更加的懵逼了?为了解释一个概念,反而引出了更多的概念了?关于动画的问题,不出意外,我会在下一篇文章分析,本文就不对动画做过多的解释了。在这里,为了简单,只要RecyclerView处于dispatchLayoutStep1,我们就当做它处于预布局阶段。
  为什么只在预布局的时候才从mChangedScrap里面去取呢?
  首先,我们得理解在什么情况下才算是开启了预布局。从代码上来说,只有当前RecyclerViewItemAnimator不为null,并且当前的操作支持运行预布局的动画。如此,才算开了预布局。而在mChangedScrap只有在预布局的情况才会存储ViewHolder,这一点我们可以从之前的总结中可以得到,比如调用了AdapternotifyItemChanged方法,如果当前RecyclerViewItemAnimator不为空的话,就会添加到mChangedScrap数组里面。在这种情况下,肯定满足预布局的条件,所以复用时,会从mChangedScrap数组里面取。
  这里说到预布局的动画,详细的可以参考:RecyclerView animations - AndroidDevSummit write-up。这是动画的知识点,本文就不分析了,后续会有相关的文章来详细的解释RecyclerView的动画。
  然后,我们再来看看第二步。

            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
                        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;
                    }
                }
            }

  这一步理解起来比较容易,分别从mAttachedScrapmHiddenViewsmCachedViews获取ViewHolder。但是我们需要的是,如果获取的ViewHolder是无效的,得做一些清理操作,然后重新放入到缓存里面,具体对应的缓存就是mCacheViewsRecyclerViewPoolrecycleViewHolderInternal方法就是回收ViewHolder的方法,后面再分析回收相关的逻辑会重点分析这个方法,这里就不进行追究了。

(2). 通过viewType方式来获取ViewHolder

  前面分析了通过Position的方式来获取ViewHolder,这里我们来分析一下第二种方式--ViewType。不过在这里,我先对前面的方式做一个简单的总结,RecyclerView通过Position来获取ViewHolder,并不需要判断ViewType是否合法,因为如果能够通过Position来获取ViewHolderViewType本身就是正确对应的。
  而这里通过ViewType来获取ViewHolder表示,此时ViewHolder缓存的Position已经失效了。ViewType方式来获取ViewHolder的过程,我将它分为3步:

  1. 如果AdapterhasStableIds方法返回为true,优先通过ViewTypeid两个条件来寻找。如果没有找到,那么就进行第2步。

  2. 如果AdapterhasStableIds方法返回为false,在这种情况下,首先会在ViewCacheExtension里面找,如果还没有找到的话,最后会在RecyclerViewPool里面来获取ViewHolder。

  3. 如果以上的复用步骤都没有找到合适的ViewHolder,最后就会调用AdapteronCreateViewHolder方法来创建一个新的ViewHolder

  在这里,我们需要注意的是,上面的第1步 和 第2步有前提条件,就是两个都必须比较ViewType。接下来,我通过代码简单的分析一下每一步。

A. 通过id来寻找ViewHolder

  通过id寻找合适的ViewHolder主要是通过调用getScrapOrCachedViewForId方法来实现的,我们简单的看一下代码:

                // 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;
                    }
                }

  而getScrapOrCachedViewForId方法本身没有什么分析的必要,就是分别从mAttachedScrapmCachedViews数组寻找合适的ViewHolder

B. 从RecyclerViewPool里面获取ViewHolder

  ViewCacheExtension存在的情况是非常的少见,这里为了简单,就不展开了(实际上我也不懂!),所以这里,我们直接来看RecyclerViewPool方式。
  在这里,我们需要了解RecyclerViewPool的数组结构。我们简单的分析一下RecyclerViewPool这个类。

        static class ScrapData {            final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();            int mMaxScrap = DEFAULT_MAX_SCRAP;            long mCreateRunningAverageNs = 0;            long mBindRunningAverageNs = 0;
        }
        SparseArray<ScrapData> mScrap = new SparseArray<>();

  在RecyclerViewPool的内部,使用SparseArray来存储每个ViewType对应的ViewHolder数组,其中每个数组的最大size为5。这个数据结构是不是非常简单呢?
  简单的了解了RecyclerViewPool的数据结构,接下来我们来看看复用的相关的代码:

                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);
                        }
                    }
                }

  相信这段代码不用我来分析吧,表达的意思非常简单。

C. 调用Adapter的onCreateViewHolder方法创建一个新的ViewHolder
                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");
                    }
                }

  上面的代码主要的目的就是调用AdaptercreateViewHolder方法来创建一个ViewHolder,在这个过程就是简单计算了创建一个ViewHolder的时间。
  关于复用机制的理解,我们就到此为止。其实RecyclerView的复用机制一点都不复杂,我觉得让大家望而却步的原因,是因为我们不知道为什么在这么做,如果了解这么做的原因,一切都显得那么理所当然。
  分析RecyclerView的复用部分,接下来,我们来分析一下回收部分。

3. 回收

  回收是RecyclerView复用机制内部非常重要。首先,有复用的过程,肯定就有回收的过程;其次,同时理解了复用和回收两个过程,这可以帮助我们在宏观上理解RecyclerView的工作原理;最后,理解RecyclerView在何时会回收ViewHolder,这对使用RecyclerView有很大的帮助。
  其实回收的机制也没有想象中那么的难,本文打算从几个方面来分析RecyclerView的回收过程。

  1. scrap数组

  2. mCacheViews数组

  3. mHiddenViews数组

  4. RecyclerViewPool数组

  接下来,我们将一一的分析。

(1). scrap数组

  关于ViewHolder回收到scrap数组里面,其实我在前面已经简单的分析了,重点就在于RecyclerscrapView方法里面。我们来看看scrapView在哪里被调用了。有如下两个地方:

  1. getScrapOrHiddenOrCachedHolderForPosition方法里面,如果从mHiddenViews获得一个ViewHolder的话,会先将这个ViewHoldermHiddenViews数组里面移除,然后调用RecyclerscrapView方法将这个ViewHolder放入到scrap数组里面,并且标记FLAG_RETURNED_FROM_SCRAPFLAG_BOUNCED_FROM_HIDDEN_LIST两个flag。

  2. LayoutManager里面的scrapOrRecycleView方法也会调用RecyclerscrapView方法。而有两种情形下会出现如此情况:1. 手动调用了LayoutManager相关的方法;2. RecyclerView进行了一次布局(调用了requestLayout方法)

(2). mCacheViews数组

  mCacheViews数组作为二级缓存,回收的路径相较于一级缓存要多。关于mCacheViews数组,重点在于RecyclerrecycleViewHolderInternal方法里面。我将mCacheViews数组的回收路径大概分为三类,我们来看看:

  1. 在重新布局回收了。这种情况主要出现在调用了AdapternotifyDataSetChange方法,并且此时AdapterhasStableIds方法返回为false。从这里看出来,为什么notifyDataSetChange方法效率为什么那么低,同时也知道了为什么重写hasStableIds方法可以提高效率。因为notifyDataSetChange方法使得RecyclerView将回收的ViewHolder放在二级缓存,效率自然比较低。

  2. 在复用时,从一级缓存里面获取到ViewHolder,但是此时这个ViewHolder已经不符合一级缓存的特点了(比如Position失效了,跟ViewType对不齐),就会从一级缓存里面移除这个ViewHolder,从添加到mCacheViews里面

  3. 当调用removeAnimatingView方法时,如果当前ViewHolder被标记为remove,会调用recycleViewHolderInternal方法来回收对应的ViewHolder。调用removeAnimatingView方法的时机表示当前的ItemAnimator已经做完了。

(3). mHiddenViews数组

  一个ViewHolder回收到mHiddenView数组里面的条件比较简单,如果当前操作支持动画,就会调用到RecyclerViewaddAnimatingView方法,在这个方法里面会将做动画的那个View添加到mHiddenView数组里面去。通常就是动画期间可以会进行复用,因为mHiddenViews只在动画期间才会有元素。

(4). RecyclerViewPool

  RecyclerViewPoolmCacheViews,都是通过recycleViewHolderInternal方法来进行回收,所以情景与mCacheViews差不多,只不过当不满足放入mCacheViews时,才会放入到RecyclerViewPool里面去。

(5). 为什么hasStableIds方法返回true会提高效率呢?

  了解了RecyclerView的复用和回收机制之后,这个问题就变得很简单了。我从两个方面来解释原因。

A. 复用方面

  我们先来看看复用怎么能体现hasStableIds能提高效率呢?来看看代码:

                if (mAdapter.hasStableIds()) {
                    holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
                            type, dryRun);                    if (holder != null) {                        // update position
                        holder.mPosition = offsetPosition;
                        fromScrapOrHiddenOrCache = true;
                    }
                }

  在前面通过Position方式来获取一个ViewHolder失败之后,如果AdapterhasStableIds方法返回为true,在进行通过ViewType方式来获取ViewHolder时,会优先到1级或者二级缓存里面去寻找,而不是直接去RecyclerViewPool里面去寻找。从这里,我们可以看到,在复用方面,hasStableIds方法提高了效率。

B. 回收方面
        private void scrapOrRecycleView(Recycler recycler, int index, View view) {            final ViewHolder viewHolder = getChildViewHolderInt(view);            if (viewHolder.shouldIgnore()) {                if (DEBUG) {
                    Log.d(TAG, "ignoring view " + viewHolder);
                }                return;
            }            if (viewHolder.isInvalid() && !viewHolder.isRemoved()
                    && !mRecyclerView.mAdapter.hasStableIds()) {
                removeViewAt(index);
                recycler.recycleViewHolderInternal(viewHolder);
            } else {
                detachViewAt(index);
                recycler.scrapView(view);
                mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
            }
        }

  从上面的代码中,我们可以看出,如果hasStableIds方法返回为true的话,这里所有的回收都进入scrap数组里面。这刚好与前面对应了。
  通过如上两点,我们就能很好的理解为什么hasStableIds方法返回true会提高效率。

4. 总结

  RecyclerView回收和复用机制到这里分析的差不多了。这里做一个小小的总结。

  1. RecyclerView内部有4级缓存,每一级的缓存所代表的意思都不一样,同时复用的优先也是从上到下,各自的回收也是不一样。

  2. mHideenViews的存在是为了解决在动画期间进行复用的问题。

  3. ViewHolder内部有很多的flag,在理解回收和复用机制之前,最好是将ViewHolder的flag梳理清楚。

  最后用一张图片来结束本文的介绍。

webp



作者:琼珶和予
链接:https://www.jianshu.com/p/efe81969f69d


点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消