解析Android ListView工作原理及其缓存机制 解析Android ListView工作原理及其缓存机制

解析Android ListView工作原理及其缓存机制

本文转载自:https://blog.csdn.net/Libmill/article/details/49644743

首先,这篇是观我大郭神博客之后的学习总结!!下面链接就是郭大神的对ListView的源码解析。先献上我的膝盖,膜拜~
Android ListView工作原理完全解析,带你从源码的角度彻底理解


ListView…..虐我千百遍啊!!我却还待它如初恋!!!这只磨人的小妖精,天生自带无比强大的洪荒之力,但是如果你控制不住它那颗不羁的心的话,你将会被折磨得不要不要的。
今天,我们就来找出它不安分的内在,来探讨如何安抚那颗不羁的心。


一、ListView的背景及Adapter

  1. ListView的背景
    我们来看一下ListView的继承树:
    ListView的继承结构
    ListView是直接继承于AbsListView,也就是说它老爸是AbsListView。而AbsListView有两个子实现类,一个是ListView,另一个是GridView。所以接触过GridView的同学,应该了解到,ListVIew和GridView两兄弟在很多地方是有很多共同点的,比如它俩都天生自带强大无比的洪荒之力。到这大家就奇怪了,这么屌的力量到底是从何而来?很明显啦,拼的就是爹~

  2. ListView的作用
    ListView只有一项工作,那就是展示数据。它并不关系数据从哪而来,数据到底是什么类型等等,它只负责展示数据。但是,它要是没数据的话也谈不上展示了。所以它有一个好基友,那就是Adapter。ListView需要访问什么数据,都是吩咐Adapter帮忙去访问数据的。两朋友形成了一种良好的工作模式,Adapter只负责提供数据,ListView只负责展示数据。所以要了解ListView那颗浪荡不羁的心,我们也需要了解Adapter,这样才能更好地把控ListView的洪荒之力。

  3. Adapter的作用
    Adapter做的工作,就是帮ListView去适配数据源的,这样ListView就不用烦恼数据的问题了,它就可以专心做好展示的工作。Adapter本身是一个接口,所以它能实现各种各样的子类,子类就通过自己特定的逻辑去完成特定的功能,去适配特定的数据。例如,ArrayAdapter可以用于数组和List类型的数据源适配等等。
    同时,我们继承Adapter的时候,有一个灰常重要的方法需要我们重写,那就是public View getView()方法。一般我们会这样写:

@Override  
public View getView(int position, View convertView, ViewGroup parent) {   
    View view;  
    if (convertView == null) {  
        view = LayoutInflater.from(context).inflate(resourceId, null);  
        ······
    } else {  
        view = convertView;  
    }   
    ······
    return view;  
} 
       
       
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

大家应该疑惑过,这个convertView是什么?从何而来?它作用是干什么的?为什么需要这样写?不急,接下来会一一为你解答~首先让我们了解一下ListView的RecycleBin缓存机制。

二、ListView洪荒之力的来源之一(RecycleBin缓存机制)

ListView这么厉害的原因,其中一部分就是因为RecycleBin缓存机制。RecycleBin缓存机制是写在AbsListView的一个内部类。所以ListView继承于AbsListView,也继承了这股力量。让我们看看它的内部几个重要的变量和方法:

变量:
1. private View[] mActiveViews : 缓存屏幕上可见的view
2. private int mViewTypeCount : ListView中的子view的不同布局类型总数
3. ArrayList<View>[] mScrapViews : ListView中所有的废弃缓存。注意,这是一个数组。在ListView中,每种childView布局类型都会单独启用一个RecycleBin缓存机制。所以数组中的每一项ArrayList都对应着一种childView布局类型的废弃缓存。
4. ArrayList<View> mCurrentScrap : 当前childView布局类型下的废弃缓存。

方法:
1. void fillActiveViews () : 此方法会将ListView中的指定元素存储到mActiveViews数组当中。
2. View getActiveView () : 从mActiveViews中获取指定的元素。取出view后,在mActiveViews里的该指定位置将被置空。所以这个mActiveViews只能使用一次,并不能复用。
3. void addScrapView () : 将一个废弃的view进行缓存。如果childView的布局类型只有一项,就直接缓存到mCurrentScrap。如果多种布局,则从mScrapViews找到相对应的废弃缓存ArrayList并缓存view。
4. View getScrapView () : 从废弃缓存中取出一个View。同理,如果childView的布局类型只有一项,就直接从mCurrentScrap中取。如果多种布局,则从mScrapViews找到相对应的缓存ArrayList再取出view。
5. void setViewTypeCount () : 为mViewTypeCount设置childView布局类型总数,并为每种类型的childView单独启用一个RecycleBin缓存机制。

只是简单介绍了这几个重要的方法,可能大伙看得也是糊里糊涂的。或许有疑问,这个mActiveViews与mCurrentScrap有点相似,好像都是缓存view的。但它俩的区别在哪呢?莫急,等下会一一解答。

先来说说RecycleBin缓存机制的工作原理
ListView每当一项子view滑出界面时,RecycleBin会调用addScrapView()方法将这个废弃的子view进行缓存。每当子view滑入界面时,RecycleBin会调用getScrapView()方法获取一个废弃已缓存的view。所以我们再看回Adapter的getView()方法:

@Override  
public View getView(int position, View convertView, ViewGroup parent) {   
    View view;  
    if (convertView == null) {  
        view = LayoutInflater.from(context).inflate(resourceId, null);  
        ······
    } else {  
        view = convertView;  
    }   
    ······
    return view;  
} 
       
       
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

这个convertView是什么?convertView就是RecycleBin缓存机制调用getScrapView()方法获取废弃已缓存的view。getView()方法中有个判断,if (convertView == null),当convertView为空,也就是没有废弃已缓存的view时,将调用LayoutInflater的inflate()方法加载出来布局view,这个操作是比较耗时的;当convertView不为空时,我们就直接用convertView了,而不需要再次调用LayoutInflater的inflate()方法加载出来布局view。所以如果我们没利用convertView,成千上万条数据,所有的视图都是调用LayoutInflater的inflate()方法去创建布局view的话,想想就知道这样的ListView的性能是多么的糟糕了,ListView的强大就大大打折扣了,所谓的洪荒之力也就没有了。
简单了解了RecycleBin缓存机制,接下来让我们看看ListView的工作原理:

三、ListView洪荒之力的来源之二(ListView的工作原理)

从ListView的继承结构来看,ListView的爸爸的爸爸的爸爸的爸爸就是View,所以ListView终究还是一个View。而每一个View的工作流程都是分为三步,onMeasure()测量,接着onLayout()布局,最后onDraw()绘制。今天我们的重点就是在onLayout()方法上。

在我们平时实验中,可能大家会有所发现,View在显示到界面的过程中,会进行两次onMeasure()和onLayout()过程。(这个问题我到现在都还不清楚原因,有答案的童鞋可以告诉我一下。)ListView也是属于View,所以这个它也逃不出onLayout()两次这个过程。ListView如果没有解决掉这个问题的话,ListView中将会有一份重复的数据。但是在我们的平时实验中,我们会发现这个问题并不存在。所以我们要从它的工作流程出发,找出它是在哪里巧妙地把这个问题解决了。

第一次onLayout()
ListView在加载子项视图的时候,先判断是否有子元素、RecycleBin缓存机制中是否已经有缓存视图了。由于此时ListView是第一次加载,没有任何视图,RecycleBin中也没有任何的缓存记录,所以ListView就直接进行计算,绘制子view等等一系列操作。

第二次onLayout()
到了第二次onLayout()的时候,要注意,因为在有了第一次onLayout()的过程,ListView现在已经加载好了子项视图了。所以当ListView再次判断子元素是否为空时,现在子元素不再等于0了。所以这次会进行下面这些操作:
1. ListView首先调用RecycleBin缓存机制的fillActiveViews()方法,将第一次onLayout()已经加载好的视图全部缓存到mActiveViews中,然后再detach掉第一次所有加载好的视图。这样就解决了第二次onLayout()再次加载视图的时候,出现数据重复的问题。
2. 巧妙的是,在接下来加载子项视图的时候,也是先判断RecycleBin缓存机制中的mActiveViews是否为空,但是因为刚才ListView已经把第一次加载好的子视图全部缓存到了mActiveViews中了,所以此时mActiveViwe并不空,接下来就只要把mActiveViews里面的视图全部attach到ListView上,这样ListView中所有子视图又全部显示出来了。

在前面说到RecycleBin缓存机制的时候,我们曾经提过这样一个疑问,mActiveViews和mCurrentViews有什么区别。还记得mActiveViews是不能复用的么?所以mActiveViews的作用就一个地方,就是专门为第二次onLayout()这个过程缓存第一次加载好的子视图。而mCurrentViews是缓存滑出界面的废弃子view。

完成了两次onLayout()过程后,我们就能看到了ListView中的内容了。但这过程只体现到了ListView的第一次加载子视图的过程,如果我们ListView有1000项数据,当前第一次加载屏幕只显示了10项数据,剩下的990项数据是怎么通过我们滑动来显示到屏幕上来的呢?所以接下来介绍,ListView的滑动部分的工作原理。

四、ListView洪荒之力的来源之二(ListView的滑动部分工作原理)

滑动部分的机制是写在AbsListView当中的。那么监听触控事件是在onTouchEvent()方法当中进行的。onTouchEvent()方法内有个switch()条件判断。
1. 首先当判断到我们的动作是滑动时,就计算出我们触发event事件手指在Y方向上的位移距离。
2. 根据这个距离计算出view是否滑出了界面之外,如果滑出了界面之外,RecycleBin缓存机制就调用addScrapView()方法将这个View加入到废弃缓存当中,然后再将这个view进行detach掉。因为这个view已经移出界面了,所以没必要为它保存。
3. 接下来,所有子视图就根据这个距离进行相应的偏移。当发现ListView中最后一个View的底部已经移入了屏幕,或者ListView中第一个View的顶部移入了屏幕时,就会对ListView进行填充。
4. 我们来看一下填充最核心的那块源码:

View obtainView(int position, boolean[] isScrap) {  
    isScrap[0] = false;  
    View scrapView;  
    scrapView = mRecycler.getScrapView(position);  
    View child;  
    if (scrapView != null) {  
        child = mAdapter.getView(position, scrapView, this);  
        if (child != scrapView) {  
            mRecycler.addScrapView(scrapView);  
            if (mCacheColorHint != 0) {  
                child.setDrawingCacheBackgroundColor(mCacheColorHint);  
            }  
        } else {  
            isScrap[0] = true;  
            dispatchFinishTemporaryDetach(child);  
        }  
    } else {  
        child = mAdapter.getView(position, null, this);  
        if (mCacheColorHint != 0) {  
            child.setDrawingCacheBackgroundColor(mCacheColorHint);  
        }  
    }  
    return child;  
}
       
       
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

注意第4行代码,在填充的时候,ListView先调用RecycleBin缓存机制中的getScrapView()方法来尝试从废弃缓存中获取一个View。注意,在前面的第2点的时候,当有子view滑出界面时,我们就会将它缓存到废弃缓存中去了。所以看第7行代码时,你是否觉得很熟悉呢!这里调用的就是Adapter的getView()方法!如果成功从废弃缓存中取得一个scrapView时,我们就将这个scrapView传入getView()方法中,否则就将null传入getView()方法中。所以这也正是我们前面所说到RecycleBin缓存机制时,convertView就是getScrapView()方法获取的废弃已缓存的view。这也是为什么我们getView()方法一般是这样写的原因:

@Override  
public View getView(int position, View convertView, ViewGroup parent) {   
    View view;  
    if (convertView == null) {  
        view = LayoutInflater.from(context).inflate(resourceId, null);  
        ······
    } else {  
        view = convertView;  
    }   
    ······
    return view;  
} 
       
       
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

现在再来看这段代码,再看convertView,是否感觉完全不同了,背后整个条理也清晰了许多了?

好了,学习总结完毕~
最后,再次膜拜一下郭大神~

本文转载自:https://blog.csdn.net/Libmill/article/details/49644743

首先,这篇是观我大郭神博客之后的学习总结!!下面链接就是郭大神的对ListView的源码解析。先献上我的膝盖,膜拜~
Android ListView工作原理完全解析,带你从源码的角度彻底理解


ListView…..虐我千百遍啊!!我却还待它如初恋!!!这只磨人的小妖精,天生自带无比强大的洪荒之力,但是如果你控制不住它那颗不羁的心的话,你将会被折磨得不要不要的。
今天,我们就来找出它不安分的内在,来探讨如何安抚那颗不羁的心。


一、ListView的背景及Adapter

  1. ListView的背景
    我们来看一下ListView的继承树:
    ListView的继承结构
    ListView是直接继承于AbsListView,也就是说它老爸是AbsListView。而AbsListView有两个子实现类,一个是ListView,另一个是GridView。所以接触过GridView的同学,应该了解到,ListVIew和GridView两兄弟在很多地方是有很多共同点的,比如它俩都天生自带强大无比的洪荒之力。到这大家就奇怪了,这么屌的力量到底是从何而来?很明显啦,拼的就是爹~

  2. ListView的作用
    ListView只有一项工作,那就是展示数据。它并不关系数据从哪而来,数据到底是什么类型等等,它只负责展示数据。但是,它要是没数据的话也谈不上展示了。所以它有一个好基友,那就是Adapter。ListView需要访问什么数据,都是吩咐Adapter帮忙去访问数据的。两朋友形成了一种良好的工作模式,Adapter只负责提供数据,ListView只负责展示数据。所以要了解ListView那颗浪荡不羁的心,我们也需要了解Adapter,这样才能更好地把控ListView的洪荒之力。

  3. Adapter的作用
    Adapter做的工作,就是帮ListView去适配数据源的,这样ListView就不用烦恼数据的问题了,它就可以专心做好展示的工作。Adapter本身是一个接口,所以它能实现各种各样的子类,子类就通过自己特定的逻辑去完成特定的功能,去适配特定的数据。例如,ArrayAdapter可以用于数组和List类型的数据源适配等等。
    同时,我们继承Adapter的时候,有一个灰常重要的方法需要我们重写,那就是public View getView()方法。一般我们会这样写:

@Override  
public View getView(int position, View convertView, ViewGroup parent) {   
    View view;  
    if (convertView == null) {  
        view = LayoutInflater.from(context).inflate(resourceId, null);  
        ······
    } else {  
        view = convertView;  
    }   
    ······
    return view;  
} 
  
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

大家应该疑惑过,这个convertView是什么?从何而来?它作用是干什么的?为什么需要这样写?不急,接下来会一一为你解答~首先让我们了解一下ListView的RecycleBin缓存机制。

二、ListView洪荒之力的来源之一(RecycleBin缓存机制)

ListView这么厉害的原因,其中一部分就是因为RecycleBin缓存机制。RecycleBin缓存机制是写在AbsListView的一个内部类。所以ListView继承于AbsListView,也继承了这股力量。让我们看看它的内部几个重要的变量和方法:

变量:
1. private View[] mActiveViews : 缓存屏幕上可见的view
2. private int mViewTypeCount : ListView中的子view的不同布局类型总数
3. ArrayList<View>[] mScrapViews : ListView中所有的废弃缓存。注意,这是一个数组。在ListView中,每种childView布局类型都会单独启用一个RecycleBin缓存机制。所以数组中的每一项ArrayList都对应着一种childView布局类型的废弃缓存。
4. ArrayList<View> mCurrentScrap : 当前childView布局类型下的废弃缓存。

方法:
1. void fillActiveViews () : 此方法会将ListView中的指定元素存储到mActiveViews数组当中。
2. View getActiveView () : 从mActiveViews中获取指定的元素。取出view后,在mActiveViews里的该指定位置将被置空。所以这个mActiveViews只能使用一次,并不能复用。
3. void addScrapView () : 将一个废弃的view进行缓存。如果childView的布局类型只有一项,就直接缓存到mCurrentScrap。如果多种布局,则从mScrapViews找到相对应的废弃缓存ArrayList并缓存view。
4. View getScrapView () : 从废弃缓存中取出一个View。同理,如果childView的布局类型只有一项,就直接从mCurrentScrap中取。如果多种布局,则从mScrapViews找到相对应的缓存ArrayList再取出view。
5. void setViewTypeCount () : 为mViewTypeCount设置childView布局类型总数,并为每种类型的childView单独启用一个RecycleBin缓存机制。

只是简单介绍了这几个重要的方法,可能大伙看得也是糊里糊涂的。或许有疑问,这个mActiveViews与mCurrentScrap有点相似,好像都是缓存view的。但它俩的区别在哪呢?莫急,等下会一一解答。

先来说说RecycleBin缓存机制的工作原理
ListView每当一项子view滑出界面时,RecycleBin会调用addScrapView()方法将这个废弃的子view进行缓存。每当子view滑入界面时,RecycleBin会调用getScrapView()方法获取一个废弃已缓存的view。所以我们再看回Adapter的getView()方法:

@Override  
public View getView(int position, View convertView, ViewGroup parent) {   
    View view;  
    if (convertView == null) {  
        view = LayoutInflater.from(context).inflate(resourceId, null);  
        ······
    } else {  
        view = convertView;  
    }   
    ······
    return view;  
} 
  
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

这个convertView是什么?convertView就是RecycleBin缓存机制调用getScrapView()方法获取废弃已缓存的view。getView()方法中有个判断,if (convertView == null),当convertView为空,也就是没有废弃已缓存的view时,将调用LayoutInflater的inflate()方法加载出来布局view,这个操作是比较耗时的;当convertView不为空时,我们就直接用convertView了,而不需要再次调用LayoutInflater的inflate()方法加载出来布局view。所以如果我们没利用convertView,成千上万条数据,所有的视图都是调用LayoutInflater的inflate()方法去创建布局view的话,想想就知道这样的ListView的性能是多么的糟糕了,ListView的强大就大大打折扣了,所谓的洪荒之力也就没有了。
简单了解了RecycleBin缓存机制,接下来让我们看看ListView的工作原理:

三、ListView洪荒之力的来源之二(ListView的工作原理)

从ListView的继承结构来看,ListView的爸爸的爸爸的爸爸的爸爸就是View,所以ListView终究还是一个View。而每一个View的工作流程都是分为三步,onMeasure()测量,接着onLayout()布局,最后onDraw()绘制。今天我们的重点就是在onLayout()方法上。

在我们平时实验中,可能大家会有所发现,View在显示到界面的过程中,会进行两次onMeasure()和onLayout()过程。(这个问题我到现在都还不清楚原因,有答案的童鞋可以告诉我一下。)ListView也是属于View,所以这个它也逃不出onLayout()两次这个过程。ListView如果没有解决掉这个问题的话,ListView中将会有一份重复的数据。但是在我们的平时实验中,我们会发现这个问题并不存在。所以我们要从它的工作流程出发,找出它是在哪里巧妙地把这个问题解决了。

第一次onLayout()
ListView在加载子项视图的时候,先判断是否有子元素、RecycleBin缓存机制中是否已经有缓存视图了。由于此时ListView是第一次加载,没有任何视图,RecycleBin中也没有任何的缓存记录,所以ListView就直接进行计算,绘制子view等等一系列操作。

第二次onLayout()
到了第二次onLayout()的时候,要注意,因为在有了第一次onLayout()的过程,ListView现在已经加载好了子项视图了。所以当ListView再次判断子元素是否为空时,现在子元素不再等于0了。所以这次会进行下面这些操作:
1. ListView首先调用RecycleBin缓存机制的fillActiveViews()方法,将第一次onLayout()已经加载好的视图全部缓存到mActiveViews中,然后再detach掉第一次所有加载好的视图。这样就解决了第二次onLayout()再次加载视图的时候,出现数据重复的问题。
2. 巧妙的是,在接下来加载子项视图的时候,也是先判断RecycleBin缓存机制中的mActiveViews是否为空,但是因为刚才ListView已经把第一次加载好的子视图全部缓存到了mActiveViews中了,所以此时mActiveViwe并不空,接下来就只要把mActiveViews里面的视图全部attach到ListView上,这样ListView中所有子视图又全部显示出来了。

在前面说到RecycleBin缓存机制的时候,我们曾经提过这样一个疑问,mActiveViews和mCurrentViews有什么区别。还记得mActiveViews是不能复用的么?所以mActiveViews的作用就一个地方,就是专门为第二次onLayout()这个过程缓存第一次加载好的子视图。而mCurrentViews是缓存滑出界面的废弃子view。

完成了两次onLayout()过程后,我们就能看到了ListView中的内容了。但这过程只体现到了ListView的第一次加载子视图的过程,如果我们ListView有1000项数据,当前第一次加载屏幕只显示了10项数据,剩下的990项数据是怎么通过我们滑动来显示到屏幕上来的呢?所以接下来介绍,ListView的滑动部分的工作原理。

四、ListView洪荒之力的来源之二(ListView的滑动部分工作原理)

滑动部分的机制是写在AbsListView当中的。那么监听触控事件是在onTouchEvent()方法当中进行的。onTouchEvent()方法内有个switch()条件判断。
1. 首先当判断到我们的动作是滑动时,就计算出我们触发event事件手指在Y方向上的位移距离。
2. 根据这个距离计算出view是否滑出了界面之外,如果滑出了界面之外,RecycleBin缓存机制就调用addScrapView()方法将这个View加入到废弃缓存当中,然后再将这个view进行detach掉。因为这个view已经移出界面了,所以没必要为它保存。
3. 接下来,所有子视图就根据这个距离进行相应的偏移。当发现ListView中最后一个View的底部已经移入了屏幕,或者ListView中第一个View的顶部移入了屏幕时,就会对ListView进行填充。
4. 我们来看一下填充最核心的那块源码:

View obtainView(int position, boolean[] isScrap) {  
    isScrap[0] = false;  
    View scrapView;  
    scrapView = mRecycler.getScrapView(position);  
    View child;  
    if (scrapView != null) {  
        child = mAdapter.getView(position, scrapView, this);  
        if (child != scrapView) {  
            mRecycler.addScrapView(scrapView);  
            if (mCacheColorHint != 0) {  
                child.setDrawingCacheBackgroundColor(mCacheColorHint);  
            }  
        } else {  
            isScrap[0] = true;  
            dispatchFinishTemporaryDetach(child);  
        }  
    } else {  
        child = mAdapter.getView(position, null, this);  
        if (mCacheColorHint != 0) {  
            child.setDrawingCacheBackgroundColor(mCacheColorHint);  
        }  
    }  
    return child;  
}
  
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

注意第4行代码,在填充的时候,ListView先调用RecycleBin缓存机制中的getScrapView()方法来尝试从废弃缓存中获取一个View。注意,在前面的第2点的时候,当有子view滑出界面时,我们就会将它缓存到废弃缓存中去了。所以看第7行代码时,你是否觉得很熟悉呢!这里调用的就是Adapter的getView()方法!如果成功从废弃缓存中取得一个scrapView时,我们就将这个scrapView传入getView()方法中,否则就将null传入getView()方法中。所以这也正是我们前面所说到RecycleBin缓存机制时,convertView就是getScrapView()方法获取的废弃已缓存的view。这也是为什么我们getView()方法一般是这样写的原因:

@Override  
public View getView(int position, View convertView, ViewGroup parent) {   
    View view;  
    if (convertView == null) {  
        view = LayoutInflater.from(context).inflate(resourceId, null);  
        ······
    } else {  
        view = convertView;  
    }   
    ······
    return view;  
} 
  
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

现在再来看这段代码,再看convertView,是否感觉完全不同了,背后整个条理也清晰了许多了?

好了,学习总结完毕~
最后,再次膜拜一下郭大神~

猜你喜欢

转载自blog.csdn.net/liyi1009365545/article/details/82219746