IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> Android:深入理解RecyclerView的缓存机制 -> 正文阅读

[移动开发]Android:深入理解RecyclerView的缓存机制


前言

本篇记录笔者学习ReclerView缓存机制的心路历程

一、概述

我们都知道RecyclerView无论如何都不会导致OOM的发生,而这背后依靠是其本身强大的回收复用机制,那么其回收复用机制是如何实现的呢,下面笔者记录对其的分析过程

二、基本知识

1.复用的内容

在搞清楚RecyclerView的缓存复用机制之前,我们先要清楚缓存复用机制是对什么进行复用的呢,毫无疑问不可能是我们针对每个itemViews书写的布局里面的那些控件,这里直接给出答案,缓存复用机制针对复用的内容是ViewHolder,后面的源码分析会给出分析

2.何时调用

在我们自定义书写RecyclerView适配器的过程中,难免会接触到两个方法:onCreateViewHolder()onBindViewHolder(),前者会调用方法创建ViewHolder(),后者会调用相关方法进行数据绑定工作。
需要注意的是,如果是新创建View并填充数据,则会调用onCreateViewHolder()onBindViewHolder()两个方法,这通常发生RecyclerView首次创建View并填充数据的过程中;
如果是屏幕滑动过程中不断出现新的ItemView显示,这一过程中通常会复用已经存在的
ViewHolder
,并调用onBindViewHolder() 对数据进行绑定

3.每个Item对应的状态

下面介绍ViewHolder的各个状态,相关方法如下
在这里插入图片描述

三、RecyclerView的四级缓存

在了解RecyclerView的几级缓存之前,我们先通过RecyclerView的官方源码构造函数了解其几级缓存的数据结构

final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();//保存重新布局时从RecyclerView分离的item的无效、未移除、未更新的holder
        ArrayList<ViewHolder> mChangedScrap = null;//保存重新布局时无效的item,未移除的Holder

        final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();//保存最新被移除的ViewHolder,进行滚动地回收复用

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

        private int mRequestedCacheMax = DEFAULT_CACHE_SIZE;
        int mViewCacheMax = DEFAULT_CACHE_SIZE;

        RecycledViewPool mRecyclerPool;

        private ViewCacheExtension mViewCacheExtension;

        static final int DEFAULT_CACHE_SIZE = 2;//默认滚动回收复用的item数量为2

Scrap

Scrap是RecyclerView最轻量的缓存,包括mAttachedScrapmChangedScrap,它不参与列表滚动时的回收复用,作为重新布局时的临时缓存,它的作用是,缓存当界面重新布局前和界面重新布局后都出现的ViewHolder,这些ViewHolder是无效、未移除、未标记的。在这些无效、未移除、未标记的ViewHolder之中,mAttachedScrap负责保存其中没有改变的ViewHolder;剩下的由mChangedScrap负责保存。mAttachedScrapmChangedScrap也只是分工合作保存不同ViewHolder而已。

注意:Scrap只是作为布局的临时缓存,它和滑动时的缓存没有任何关系,它的detach和atach只是临时存在于布局过程中。布局结束时,Scrap列表应该是空的,缓存的数据要么重新布局出来,要么被清空;总之在布局结束后Scrap列表不应该存在任何东西。

上述的描述难免抽象,下面我们通过一个具体的例子进行阐述和解读
在这里插入图片描述

在这个案例中,我们对一个RecyclerView的数据itemA、itemB进行删除,然后itemC、itemD、itemE依次移动上来,毫无疑问这一过程会对后者itemView的布局参数产生影响,也就是对onLayout()需要的参数产生影响,在这一过程中,itemA、itemB前后的相关参数没有发生变化,因而itemA、itemB存放于mAttachedScrap中,itemC、itemD存放于mChangedScrap

需要注意这一过程中仅仅是对屏幕上出现的item进行操作,如itemE没有出现在屏幕上,是被扔到任意一个列表中的

细致分析如下

在一个手机屏幕中,将itemB删除,并且调用notifyItemRemoved()方法,如果item是无效并且被移除的就会回收到其他的缓存,否则都是缓存到Scrap中,那么mAttachedScrap和mChangedScrap会分别存储itemView,itemA没有任何的变化,存储到mAttachedScrap中,itemB虽然被移出了,但是还有效,也被存储到mAttachedScrap中(但是会被标记REMOVED,之后会移除);itemC和itemD发生了变化,位置往上移动了,会被存储到mChangedScrap中。删除时,ABCD都会进入Scrap中;删除后,ACD都会回来,A没有任何变化,CD只是位置发生了变化,内容没有发生变化。
RecyclerView的局部刷新就是依赖Scrap的临时缓存,当我们通过notifyItemRemoved(),notifyItemChanged()通知item发生变化的时候,通过mAttachedScrap缓存没有发生变化的ViewHolder,其他的则由mChangedScrap缓存,添加itemView的时候快速从里面取出,完成局部刷新。注意,如果我们使用notifyDataSetChanged()来通知RecyclerView刷新,屏幕上的itemView被标记为FLAG_INVALID并且未被移除,所以不会使用Scrap缓存,而是直接扔到CacheView或者RecycledViewPool池中,回来的时候重新走一次绑定数据。

CacheView

CacheView用于RecyclerView列表位置产生变动时,对刚刚移出屏幕的view进行回收复用。根据position/id来精准匹配是不是原来的item,如果是则直接返回使用,不需要重新绑定数据;如果不是则去RecycledViewPool中找holder实例返回,并且重新绑定数据。
CacheView的最大容量为2,缓存一个新的ViewHolder时,如果超出了最大限制,那么会将CacheView缓存的第一个数据添加到RecycledViewPool后再移除掉,最后才会将新的ViewHolder添加进来。我们在滑动RecyclerView的时候,Recycler会不断地缓存刚刚移出屏幕不可见的View到CacheView中,CacheView到达上限时又会不断替换CacheView中旧的ViewHolder,将它们扔到RecycledViewPool中。如果一直朝一个方向滚动,CacheView并没有在效率上产生帮助,它只是把后面滑过的ViewHolder缓存起来,缓存到RecycledViewPool中,如果经常来回滑动,那么从CacheView根据对应位置的item直接复用,不需要重新绑定数据,将会得到很好的利用。

下面来看一个CacheView的应用,如图,itemA先移动出屏幕,然后移入到CacheView中,向下滑动或向上滑动的过程中对移入item进行判定,根据position/id确定是刚才的itemA,则直接从CacheView中进行移入
在这里插入图片描述

ViewCacheExtension

ViewCacheExtension是缓存拓展的帮助类,额外提供了一层缓存池给开发者。开发者视情况而定是否使用ViewCacheExtension增加一层缓存池,Recycler首先去scrap和CacheView中寻找复用view,如果没有就去ViewCacheExtension中寻找View,如果还是没有找到,那么最后去RecycledViewPool寻找复用的View。

在日常的开发中,一般我们使用不到ViewCacheExtension,所以这里就简略带过了

RecycledViewPool

ScrapCacheViewViewCacheExtension都不愿意回收的时候,都会丢到RecycledViewPool中回收,所以RecycledViewPool是Recycler的终极回收站。
RecycledViewPool实际上是以SparseArray嵌套一个ArraryList的形式保存ViewHolder的,因为RecycledViewPool保存的ViewHolder是以itemType来区分的。这样方便不同的itemType保存不同的ViewHolder。它在回收的时候只是回收该viewType的ViewHolder对象,并没有保存原来的数据信息,在复用的时候需要重新走onBindViewHolder()方法重新绑定数据。

RecycledViewPool源码如下

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

可以看出,RecycledViewPool中定义了SparseArray mScrap,它是一个根据不同itemType来保存静态类ScrapData对象的SparseArray,ScrapData中包含了ArrayList mScrapHeap ,mScrapHeap是保存该itemType类型下ViewHolder的ArrayList。
缓存池定义了默认的缓存大小DEFAULT_MAX_SCRAP = 5,这个数量不是说整个缓存池只能缓存这多个ViewHolder,而是不同itemType的ViewHolder的list的缓存数量,即mScrap的数量,说明最多只有5组不同类型的mScrapHeap。mMaxScrap = DEFAULT_MAX_SCRAP说明每种不同类型的ViewHolder默认保存5个,当然mMaxScrap的值是可以设置的。这样RecycledViewPool就把不同ViewType的ViewHolder按类型分类缓存起来。

四、源码解析部分

RecyclerView的绘制部分

设置布局管理器

RecyclerView在正式进行绘制之前需要进行布局管理器的设置,不然RecyclerView也不知道如何去进行绘制,我们先从此部分源码进行下手理解

 recyclerView.setLayoutManager(manager);//设置布局管理器
 
 public void setLayoutManager(@Nullable LayoutManager layout) {
        if (layout == mLayout) {
            return;
        }
        stopScroll();//先停止滚动,防止缓存View发生影响
        // TODO We should do this switch a dispatchLayout pass and animate children. There is a good
        // chance that LayoutManagers will re-use views.
        if (mLayout != null) {//重新所有RecyclerView的参数
            // end all running animations
            if (mItemAnimator != null) {
                mItemAnimator.endAnimations();//结束动画
            }
            mLayout.removeAndRecycleAllViews(mRecycler);//移除所有回收的itemView
            mLayout.removeAndRecycleScrapInt(mRecycler);//溢出所有被废弃的View
            mRecycler.clear();//清除缓存

            if (mIsAttached) {
                mLayout.dispatchDetachedFromWindow(this, mRecycler);
            }
            mLayout.setRecyclerView(null);
            mLayout = null;
        } else {
            mRecycler.clear();
        }
        // this is just a defensive measure for faulty item animators.
        mChildHelper.removeAllViewsUnfiltered();
        mLayout = layout;
        if (layout != null) {
            if (layout.mRecyclerView != null) {
                throw new IllegalArgumentException("LayoutManager " + layout
                        + " is already attached to a RecyclerView:"
                        + layout.mRecyclerView.exceptionLabel());
            }
            mLayout.setRecyclerView(this);//进行关联,这一步可以看到,一个LayoutManager只可以和一个RecyclerView进行管理
            if (mIsAttached) {
                mLayout.dispatchAttachedToWindow(this);
            }
        }
        mRecycler.updateViewCacheSize();//更新缓存大小
        requestLayout();
    }

这里看到RecyclerView在设置布局管理器之前,先是进行了相关的重置回收工作,而后将LayoutManaer和RecyclerView关联起来,最后进行请求重绘,调用请求重回的==requestLayout()==方法,该方法会调用RecyclerView的onMeasure()、onLayout()、onDraw()方法绘制三部曲

RecyclerView的回收部分

这里由LinearLayoutManager进行分析,观察其对子View进行布局的==onLayoutChilren()==方法

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (mPendingSavedState != null || mPendingScrollPosition != RecyclerView.NO_POSITION) {
            if (state.getItemCount() == 0) {
                removeAndRecycleAllViews(recycler);//移除所有子View
                return;
            }
        }
        ensureLayoutState();
        mLayoutState.mRecycle = false;//禁止回收
        //颠倒绘制布局
        resolveShouldLayoutReverse();
        onAnchorReady(recycler, state, mAnchorInfo, firstLayoutDirection);

        //暂时分离已经附加的view,即将所有child detach并通过Scrap回收
        detachAndScrapAttachedViews(recycler);
    }

在==onLayoutChildren()布局的时候,先根据实际情况是否需要removeAndRecycleAllViews()移除所有的子View,那些ViewHolder不可用;然后通过detachAndScrapAttachedViews()==暂时分离已经附加的ItemView,缓存到List中。

detachAndScrapAttachedViews()的作用就是把当前屏幕所有的item与屏幕分离,将他们从RecyclerView的布局中拿下来,保存到list中,在重新布局时,再将ViewHolder重新一个个放到新的位置上去。将屏幕上的ViewHolder从RecyclerView的布局中拿下来后,存放在Scrap中,Scrap包括mAttachedScrapmChangedScrap,它们是一个list,用来保存从RecyclerView布局中拿下来ViewHolder列表,==detachAndScrapAttachedViews()只会在onLayoutChildren()==中调用,只有在布局的时候,才会把ViewHolder detach掉,然后再add进来重新布局,但是大家需要注意,Scrap只是保存从RecyclerView布局中当前屏幕显示的item的ViewHolder,不参与回收复用,单纯是为了现从RecyclerView中拿下来再重新布局上去。对于没有保存到的item,会放到mCachedViews或者RecycledViewPool缓存中参与回收复用。

   public void detachAndScrapAttachedViews(@NonNull Recycler recycler) {
        final int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            final View v = getChildAt(i);
            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);//移除VIew
            recycler.recycleViewHolderInternal(viewHolder);//缓存到CacheView或者RecycledViewPool中
        } else {
            detachViewAt(index);//分离View
            recycler.scrapView(view);//scrap缓存
            mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
        }
    }

    void scrapView(View view) {
        final ViewHolder holder = getChildViewHolderInt(view);
        if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
                || !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
            holder.setScrapContainer(this, false);
            mAttachedScrap.add(holder);//保存到mAttachedScrap中
        } else {
            if (mChangedScrap == null) {
                mChangedScrap = new ArrayList<ViewHolder>();
            }
            holder.setScrapContainer(this, true);
            mChangedScrap.add(holder);//保存到mChangedScrap中
        }
    }


else分支中,先detachViewAt()分离视图,然后再通过scrapView()缓存到scrap中

在scrapView()方法中,进入if()分支的ViewHolder保存到mAttachedScrap中,else分支的保存到mChangedScrap中。

可以看到,mAttachedScrap为已移除的(isInvalid())或是参数未发生改变的,
mChangedScrap则为其他情况

回到==scrapOrRecycleView()中,进入if()分支如果viewHolder是无效、未被移除、未被标记的则放到recycleViewHolderInternal()缓存起来,同时removeViewAt()==移除了viewHolder

   void recycleViewHolderInternal(ViewHolder holder) {
           ·····
        if (forceRecycle || holder.isRecyclable()) {
            if (mViewCacheMax > 0
                    && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
                    | ViewHolder.FLAG_REMOVED
                    | ViewHolder.FLAG_UPDATE
                    | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {

                int cachedViewSize = mCachedViews.size();
                if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {//如果超出容量限制,把第一个移除
                    recycleCachedViewAt(0);
                    cachedViewSize--;
                }
                 	·····
                mCachedViews.add(targetCacheIndex, holder);//mCachedViews回收
                cached = true;
            }
            if (!cached) {
                addViewHolderToRecycledViewPool(holder, true);//放到RecycledViewPool回收
                recycled = true;
            }
        }
    }

如果符合条件,会优先缓存到mCachedViews中时,如果超出了mCachedViews的最大限制,通过recycleCachedViewAt()CacheView缓存的第一个数据添加到终极回收池RecycledViewPool后再移除掉,最后才会add()新的ViewHolder添加到mCachedViews中。

剩下不符合条件的则通过==addViewHolderToRecycledViewPool()==缓存到RecycledViewPool中。

void addViewHolderToRecycledViewPool(@NonNull ViewHolder holder, boolean dispatchRecycled) {
    clearNestedRecyclerViewIfNotNested(holder);
    View itemView = holder.itemView;
    ······
    holder.mOwnerRecyclerView = null;
    getRecycledViewPool().putRecycledView(holder);//将holder添加到RecycledViewPool中
}

还有一个就是在填充布局fill()的时候,它会回收移出屏幕的view到mCachedViews或者RecycledViewPool中:

 int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
              recycleByLayoutState(recycler, layoutState);//回收移出屏幕的view
        }
    }

在recycleByLayoutState()层层追查下去,会来到recycler.recycleView(view)Recycler的公共回收方法中,:

public void recycleView(@NonNull View view) {
        ViewHolder holder = getChildViewHolderInt(view);
        if (holder.isTmpDetached()) {
            removeDetachedView(view, false);
        }
        recycleViewHolderInternal(holder);
    }

回收分离的视图到缓存池中,方便以后重新绑定和复用,这里又来到了recycleViewHolderInternal(holder),和上面的一样,按照优先级缓存 mCachedViews> RecycledViewPool

RecyclerView的复用流程

来看LinearLayoutManager的布局入口的方法==onLayoutChildren()==观看

  @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (mPendingSavedState != null || mPendingScrollPosition != RecyclerView.NO_POSITION) {
            if (state.getItemCount() == 0) {
                removeAndRecycleAllViews(recycler);//移除所有子View
                return;
            }
        }
    
        //暂时分离已经附加的view,即将所有child detach并通过Scrap回收
        detachAndScrapAttachedViews(recycler);
        
        if (mAnchorInfo.mLayoutFromEnd) {
            //描点位置从start位置开始填充ItemView布局
            updateLayoutStateToFillStart(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);//填充所有itemView
           
 			//描点位置从end位置开始填充ItemView布局
            updateLayoutStateToFillEnd(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);//填充所有itemView
            endOffset = mLayoutState.mOffset;
        }else {
            //描点位置从end位置开始填充ItemView布局
            updateLayoutStateToFillEnd(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);
 
            //描点位置从start位置开始填充ItemView布局
            updateLayoutStateToFillStart(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);
            startOffset = mLayoutState.mOffset;
        }
    }

这里有两个方法,分别对应从不同方向对RecyclerView进行滑动导致的;
但无论是哪个方向,最终都是调用==fill()方法填充由layoutState()==定义的给定布局

 int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
        recycleByLayoutState(recycler, layoutState);//回收滑出屏幕的view
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {//一直循环,知道没有数据
            layoutChunkResult.resetInternal();
            layoutChunk(recycler, state, layoutState, layoutChunkResult);//添加一个child
            ······
            if (layoutChunkResult.mFinished) {//布局结束,退出循环
                break;
            }
            layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;//根据添加的child高度偏移计算   
        }
     	······
        return start - layoutState.mAvailable;//返回这次填充的区域大小
    }

核心方法是==while()循环,并通过判断可见区域是否有剩余空间,如果有则填充view上去,核心是通过while()循环执行layoutChunk()==填充一个itemView到屏幕, ==layoutChunk()==完成布局工作:

 void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
        View view = layoutState.next(recycler);//获取复用的view
        ······
        }

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

    @NonNull
    public View getViewForPosition(int position) {
        return getViewForPosition(position, false);
    }

    View getViewForPosition(int position, boolean dryRun) {
        return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
    }

tryGetViewHolderForPositionByDeadline()才是获取view的方法,它会根据给出的position/idscrapcacheRecycledViewPool、或者创建获取一个ViewHolder

总结

RecyclerVIew的回收原理

在RecyclerView重新布局onLayoutChildren()或者填充布局fill()的时候,会先把必要的item与屏幕分离或者移除,并做好标记,保存到list中,在重新布局时,再将ViewHolde拿出来重新一个个放到新的位置上去。

  • 如果是RecyclerView不滚动情况下缓存(比如删除item),重新布局时,把屏幕上的ViewHolder与屏幕分离下来,存放到Scrap中,即发生改变的ViewHolder缓存到mChangedScrap中,不发生改变的ViewHolder存放到mAttachedScrap中;剩下ViewHolder的会按照mCachedViews>RecycledViewPool的优先级缓存到mCachedViews或者RecycledViewPool中。
  • 如果是RecyclerVIew滚动情况下缓存(比如滑动列表),在滑动时填充布局,先移除滑出屏幕的item,第一级缓存mCachedViews优先缓存这些ViewHolder,但是mCachedViews最大容量为2,当mCachedViews满了以后,会利用先进先出原则,把旧的ViewHolder存放到RecycledViewPool中后移除掉,腾出空间,再将新的ViewHolder添加到mCachedViews中,最后剩下的ViewHolder都会缓存到终极回收池RecycledViewPool中,它是根据itemType来缓存不同类型的ArrayList,最大容量为5。

RecycleView的缓存复用过程

  • 当RecyclerView要拿一个复用的ViewHolder时,如果是预加载,则会先去mChangedScrap中精准查找(分别根据position和id)对应的ViewHolder,如果有就返回;
  • 如果没有就再去mAttachedScrap和mCachedViews中精确查找(先position后id)是不是原来的ViewHolder,如果是说明ViewHolder是刚刚被移除的;
    如果不是,则最终去mRecyclerPool找,如果itemType类型匹配对应的ViewHolder,那么返回实例,让它重新绑定数据;
    (4)如果mRecyclerPool也没有返回ViewHolder才会调用createViewHolder()重新去创建一个。

参考博客
深入理解RecyclerView的回收复用机制

深入理解RecyclerView的绘制流程和滑动原理

RecyclerView的缓存机制

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2022-03-06 13:12:56  更:2022-03-06 13:16:03 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 17:02:24-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码