一篇文章搞定《RecyclerView缓存复用机制》

news2024/11/19 9:28:33

------《RecyclerView缓存复用机制》

  • 前言
  • 零、为什么要缓存
  • 一、RecyclerView如何构建我们的列表视图
  • 二、缓存过程
  • 三、缓存结构
    • 1、mChangedScrap/mAttachedScrap
    • 2、mCachedViews
    • 3、mViewCacheExtension
    • 4、mRecyclerPool
  • 四、总结

前言

本篇文章,暂时不加入预加载进行讲解。先了解缓存机制再加入预加载会很快的进行理解。如果混淆在一起会觉得很乱。

零、为什么要缓存

RecyclerView是Android 5.0提出的新的UI控件,顾名思义,它会回收其列表项视图以供重用。
具体而言,当一个列表项被移出屏幕后,RecyclerView并不会销毁其视图,而是会缓存起来,以提供给新进入屏幕的列表项重用,这种重用可以:

  • 避免重复创建不必要的视图
  • 避免重复执行昂贵的findViewById
    从而达到的改善性能、提升应用响应能力、降低功耗的效果。而要了解其中的工作原理,我们还得回到RecyclerView是如何构建动态列表的这一步。

一、RecyclerView如何构建我们的列表视图

与RecyclerView构建动态列表相关联的几个重要类中,Adapter与ViewHolder负责配合使用,共同定义RecyclerView列表项数据的展示方式,其中:

  • ViewHolder是一个「包含列表项视图(itemView)的封装容器」,同时也是「RecyclerView缓存复用的主要对象」。
  • Adapter则提供了「数据<->视图」 的“绑定”关系,其包含以下几个关键方法:
    • onCreateViewHolder:负责创建并初始化ViewHolder及其关联的视图,但不会填充视图内容。
    • onBindViewHolder:负责提取适当的数据,填充ViewHolder的视图内容。
      然而,这2个方法并非每一个进入屏幕的列表项都会回调,相反,由于视图创建及findViewById执行等动作都主要集中在这2个方法,每次都要回调的话反而效率不佳。因此,我们应该通过对ViewHolder对象积极地缓存复用,来尽量减少对这2个方法的回调频次。
  1. 最优情况是——取得的缓存对象正好是原先的ViewHolder对象,这种情况下既不需要重新创建该对象,也不需要重新绑定数据,即拿即用。
  2. 次优情况是——取得的缓存对象虽然不是原先的ViewHolder对象,但由于二者的列表项类型(itemType)相同,其关联的视图可以复用,因此只需要重新绑定数据即可。
  3. 最后实在没办法了,才需要执行这2个方法的回调,即创建新的ViewHolder对象并绑定数据。 实际上,这也是RecyclerView从缓存中查找最佳匹配ViewHolder对象时所遵循的优先级顺序。而真正负责执行这项查找工作的,则是RecyclerView类中一个被称为「回收者」的内部类——Recycler。

二、缓存过程

那我们就从头去看到底是怎么去查找到我们的视图的(因为更直观去看所以先不加预加载的逻辑了)
还记得我们在创建我们的RecyclerView时设置了我们的layoutManager吗。其实我们的列表所有View的添加、构建都是我们设置的LayoutManager去启动的。这里只针对LinearLayoutManager这种布局管理器做一个讲解。
在这里插入图片描述
当然我们的RecyclerView支持LinearLayoutManager普通、GridLayoutManager网格、StaggeredGridLayoutManager瀑布流、FlexboxLayoutManager流式的、自定义的LayoutManager。
但是最后都会去使用我们的Recycle中的返回机制,也就是重要的方法:tryGetViewHolderForPositionByDeadline
在这里插入图片描述
这个方法会尝试通过从Recycler scrap、cache、RecycledViewPool查找或直接创建的形式来获取指定位置的ViewHolder。

public final class Recycler {
        ...
        /**
         * Attempts to get the ViewHolder for the given position, either from the Recycler scrap,
         * cache, the RecycledViewPool, or creating it directly.
         * 
         * 尝试通过从Recycler scrap缓存、RecycledViewPool查找或直接创建的形式来获取指定位置的ViewHolder。
         * ...
         */
        @Nullable
        ViewHolder tryGetViewHolderForPositionByDeadline(int position,
                boolean dryRun, long deadlineNs) {
            if (mState.isPreLayout()) {
                // 0 尝试从mChangedScrap中获取ViewHolder对象
                holder = getChangedScrapViewForPosition(position);
                ...
            }
            if (holder == null) {
                // 1.1 尝试根据position从mAttachedScrap或mCachedViews中获取ViewHolder对象
                holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
                ...
            }
            if (holder == null) {
                ...
                final int type = mAdapter.getItemViewType(offsetPosition);
                if (mAdapter.hasStableIds()) {
                    // 1.2 尝试根据id从mAttachedScrap或mCachedViews中获取ViewHolder对象
                    holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
                            type, dryRun);
                    ...
                }
                if (holder == null && mViewCacheExtension != null) {
                    // 2 尝试从mViewCacheExtension中获取ViewHolder对象
                    final View view = mViewCacheExtension
                            .getViewForPositionAndType(this, position, type);
                    if (view != null) {
                        holder = getChildViewHolder(view);
                        ...
                    }
                }
                if (holder == null) { // fallback to pool
                    // 3 尝试从mRecycledViewPool中获取ViewHolder对象
                    holder = getRecycledViewPool().getRecycledView(type);
                    ...
                }
                if (holder == null) {
                    // 4.1 回调createViewHolder方法创建ViewHolder对象及其关联的视图
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
                    ...
                }
            }
    
            if (mState.isPreLayout() && holder.isBound()) {
                ...
            } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
                ...
                // 4.1 回调bindViewHolder方法提取数据填充ViewHolder的视图内容
                bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
            }
    
            ...
    
            return holder;
        }
        ...
    }    

结合RecyclerView类中的源码及注释可知,Recycler会依次从mChangedScrap/mAttachedScrap、mCachedViews、mViewCacheExtension、mRecyclerPool中尝试获取指定位置或ID的ViewHolder对象以供重用,如果全都获取不到则直接重新创建。这其中涉及的几层缓存结构分别是:

三、缓存结构

1、mChangedScrap/mAttachedScrap

mChangedScrap/mAttachedScrap主要用于「临时存放仍在当前屏幕可见、但被标记为「移除」或「重用」的列表项」,其均以ArrayList的形式持有着每个列表项的ViewHolder对象,大小无明确限制,但一般来讲,其最大数就是屏幕内总的可见列表项数。

final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
ArrayList<ViewHolder> mChangedScrap = null;

但问题来了,既然是当前屏幕可见的列表项,为什么还需要缓存呢?又是什么时候列表项会被标记为「移除」或「重用」的呢?
这2个缓存结构实际上更多是为了避免出现像「局部刷新」这一类的操作,导致所有的列表项都需要重绘的情形。
区别在于,mChangedScrap主要的使用场景是:

  1. 开启了列表项动画(itemAnimator),并且列表项动画的canReuseUpdatedViewHolder(ViewHolder viewHolder)方法返回false的前提下;
  2. 调用了notifyItemChanged、notifyItemRangeChanged这一类方法,通知列表项数据发生变化;
boolean canReuseUpdatedViewHolder(ViewHolder viewHolder) {
        return mItemAnimator == null || mItemAnimator.canReuseUpdatedViewHolder(viewHolder,
                viewHolder.getUnmodifiedPayloads());
    }
    
    public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder,
            @NonNull List<Object> payloads) {
        return canReuseUpdatedViewHolder(viewHolder);
    }
        
    public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder) {
        return true;
    }

canReuseUpdatedViewHolder方法的返回值表示的不同含义如下:

  • true,表示可以重用原先的ViewHolder对象
  • false,表示应该创建该ViewHolder的副本,以便itemAnimator利用两者来实现动画效果(例如交叉淡入淡出效果)。

简单讲就是,mChangedScrap主要是为列表项数据发生变化时的动画效果服务的。
而mAttachedScrap应对的则是剩下的绝大部分场景,比如:

  • 像notifyItemMoved、notifyItemRemoved这种列表项发生移动,但列表项数据本身没有发生变化的场景。
  • 关闭了列表项动画,或者列表项动画的canReuseUpdatedViewHolder方法返回true,即允许重用原先的ViewHolder对象的场景。

下面以一个简单的notifyItemRemoved(int position)操作为例来演示:
notifyItemRemoved(int position)方法用于通知观察者,先前位于position的列表项已被移除, 其往后的列表项position都将往前移动1位。
为了简化问题、方便演示,我们的范例将会居于以下限制:

  • 列表项总个数没有铺满整个屏幕——意味着不会触发mCachedViews、mRecyclerPool等结构的缓存操作
  • 去除列表项动画——意味着调用notifyItemRemoved后RecyclerView只会重新布局子视图一次

recyclerView.itemAnimator = null

理想情况下,调用notifyItemRemoved(int position)方法后,应只有位于position的列表项会被移除,其他的列表项,无论是位于position之前或之后,都最多只会调整position值,而不应发生视图的重新创建或数据的重新绑定,即不应该回调onCreateViewHolder与onBindViewHolder这2个方法。
为此,我们就需要将当前屏幕内的可见列表项暂时从当前屏幕剥离,临时缓存到mAttachedScrap这个结构中去。
在这里插入图片描述
等到RecyclerView重新开始布局显示其子视图后,再遍历mAttachedScrap找到对应position的ViewHolder对象进行复用。
在这里插入图片描述

2、mCachedViews

mCachedViews主要用于「存放已被移出屏幕、但有可能很快重新进入屏幕的列表项」。其同样是以ArrayList的形式持有着每个列表项的ViewHolder对象,默认大小限制为2。

final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
int mViewCacheMax = DEFAULT_CACHE_SIZE;
static final int DEFAULT_CACHE_SIZE = 2;

比如像朋友圈这种按更新时间的先后顺序展示的Feed流,我们经常会在快速滑动中确定是否有自己感兴趣的内容,当意识到刚才滑走的内容可能比较有趣时,我们往往就会将上一条内容重新滑回来查看。
这种场景下我们追求的自然是上一条内容展示的实时性与完整性,而不应让用户产生“才滑走那么一会儿又要重新加载”的抱怨,也即同样不应发生视图的重新创建或数据的重新绑定。
我们用几张流程示意图来演示这种情况:
同样为了简化问题、方便描述,我们的范例将会居于以下限制:

  • 关闭预拉取——意味着之后向上滑动时,都不会再预拉取「待进入屏幕区域」的一个列表项放入mCachedView了

recyclerView.layoutManager?.isItemPrefetchEnabled = false

  • 只存在一种类型的列表项,即所有列表项的itemType相同,默认都为0。
    我们将图中的列表项分成了3块区域,分别是被滑出屏幕之外的区域、屏幕内的可见区域、随着滑动手势待进入屏幕的区域。
    在这里插入图片描述

1、当position=0的列表项随着向上滑动的手势被移出屏幕后,由于mCachedViews初始容量为0,因此可直接放入;
在这里插入图片描述
2、当position=1的列表项同样被移出屏幕后,由于未达到mCachedViews的默认容量大小限制,因此也可继续放入;
在这里插入图片描述
3、此时改为向下滑动,position=1的列表项重新进入屏幕,Recycler就会依次从mAttachedScrap、mCachedViews查找可重用于此位置的ViewHolder对象;
4、mAttachedScrap不是应对这种情况的,自然找不到。而mCachedViews会遍历自身持有的ViewHolder对象,对比ViewHolder对象的position值与待复用位置的position值是否一致,是的话就会将ViewHolder对象从mCachedViews中移除并返回;
5、此处拿到的ViewHolder对象即可直接复用,即符合前面所述的「最优情况」。
在这里插入图片描述
6、另外,随着position=1的列表项重新进入屏幕,position=7的列表项也会被移出屏幕,该位置的列表项同样会进入mCachedViews,即RecyclerView是双向缓存的。
在这里插入图片描述

3、mViewCacheExtension

mViewCacheExtension主要用于提供额外的、可由开发人员自由控制的缓存层级,属于非常规使用的情况,因此这里暂不展开讲。

4、mRecyclerPool

mRecyclerPool主要用于「按不同的itemType分别存放超出mCachedViews限制的、被移出屏幕的列表项」,其会先以SparseArray区分不同的itemType,然后每种itemType对应的值又以ArrayList的形式持有着每个列表项的ViewHolder对象,每种itemType的ArrayList大小限制默认为5。

  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;
            long mCreateRunningAverageNs = 0;
            long mBindRunningAverageNs = 0;
        }
        SparseArray<ScrapData> mScrap = new SparseArray<>();
        ...
    }

由于mCachedViews默认的大小限制仅为2,因此,当滑出屏幕的列表项超过2个后,就会按照先进先出的顺序,依次将ViewHolder对象从mCachedViews移出,并按itemType放入RecycledViewPool中的不同ArrayList。
这种缓存结构主要考虑的是随着被滑出屏幕列表项的增多,以及被滑出距离的越来越远,重新进入屏幕内的可能性也随之降低。于是Recycler就在时间与空间上做了一个权衡,允许相同itemType的ViewHolder被提取复用,只需要重新绑定数据即可。
这样一来,既可以避免无限增长的ViewHolder对象缓存挤占了原本就紧张的内存空间,又可以减少回调相比较之下执行代价更加昂贵的onCreateViewHolder方法。
同样我们用几张流程示意图来演示这种情况,这些示意图将在前面的mCachedViews示意图基础上继续操作:
1、假设目前存在于mCachedViews中的仍是position=0及position=1这两个列表项。
2、当我们继续向上滑动时,position=2的列表项会尝试进入mCachedViews,由于超出了mCachedViews的容量限制,position=0的列表项会从mCachedViews中被移出,并放入RecycledViewPool中itemType为0的ArrayList,即图中的情况①;
在这里插入图片描述
3、同时,底部的一个新的列表项也将随着滑动手势进入到屏幕内,但由于此时mAttachedScrap、mCachedViews、mRecyclerPool均没有合适的ViewHolder对象可以提供给其复用,因此该列表项只能执行onCreateViewHolder与onBindViewHolder这2个方法的回调,即图中的情况②;
4、等到position=2的列表项被完全移出了屏幕后,也就顺利进入了mCachedViews中。
在这里插入图片描述
5、我们继续保持向上滑动的手势,此时,由于下一个待进入屏幕的列表项与position=0的列表项的itemType相同,因此我们可以在走到从mRecyclerPool查找合适的ViewHolder对象这一步时,根据itemType找到对应的ArrayList,再取出其中的1个ViewHolder对象进行复用,即图中的情况①。
6、由于itemType类型一致,其关联的视图可以复用,因此只需要重新绑定数据即可,即符合前面所述的「次优情况」。
在这里插入图片描述
7、②③ 情况与前面的一致,此处不再赘述。

四、总结

RecyclerView缓存复用机制
对象ViewHolder(包含列表项视图(itemView)的封装容器)
目的减少对onCreateViewHolder、onBindViewHolder这2个方法的回调
好处1.避免重复创建不必要的视图 2.避免重复执行昂贵的findViewById
效果改善性能、提升应用响应能力、降低功耗
核心类Recycler、RecyclerViewPool
缓存结构mChangedScrap/mAttachedScrap、mCachedViews、mViewCacheExtension、mRecyclerPool
缓存结构容器类型容量限制缓存用途是否回调createView是否回调bindView
mChangedScrap/mAttachedScrapArrayList无,一般为屏幕内总的可见列表项数存放已被移出屏幕、但有可能很快重新进入屏幕的列表项
mCachedViewsArrayList默认为2临时存放仍在当前屏幕可见、但被标记为「移除」或「重用」的列表项
mViewCacheExtension开发者自己定义提供额外的可由开发人员自由控制的缓存层级
mRecyclerPoolSparseArray每种itemType默认为5按不同的itemType分别存放超出mCachedViews限制的、被移出屏幕的列表项

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/502662.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

沃尔玛入驻教程:中国卖家如何免费、快速入驻沃尔玛walmart.com?

作为一家全球知名的零售巨头&#xff0c;沃尔玛&#xff08;Walmart&#xff09;的在线商城walmart.com拥有庞大的消费者基础和巨大的商机。对于中国的卖家来说&#xff0c;入驻沃尔玛的平台是一个很好的机会&#xff0c;但是有没有什么方法可以免费、快速入驻呢&#xff1f;有…

CTF-PHP反序列化漏洞4-实例理解POP链(经典赛题)

作者&#xff1a;Eason_LYC 悲观者预言失败&#xff0c;十言九中。 乐观者创造奇迹&#xff0c;一次即可。 一个人的价值&#xff0c;在于他所拥有的。可以不学无术&#xff0c;但不能一无所有&#xff01; 技术领域&#xff1a;WEB安全、网络攻防 关注WEB安全、网络攻防。我的…

软件测试 - 缺陷管理

1. 缺陷的定义 产品不满足用户的需求或者测试执行时实际结果和预期结果不一致都属于缺陷。 2. 缺陷的判定标准及产生原因 软件不满足下述任何一种都算作是软件的缺陷&#xff0c;缺陷的概念是包括bug概念的。 未达到需求说明书指明的功能出现了需求说明书指明不应该出现的错…

cordova-10 打apk

Android升级到api 31后&#xff0c;ionic打出来的release包默认是一个aab文件&#xff0c;要想走以前的手动签名和zipalign的流程我需要打一个没有签名的apk的包&#xff0c;参考以下文章&#xff1a; Android Platform Guide - Apache Cordova Ionic Cordova Build for Andro…

Ubuntu: 搭建 NFS 服务器

文章目录 1. 前言2. 测试环境3. NFS 服务器搭建3.1 安装 NFS 服务3.2 导出 NFS 共享目录3.3 重启 NFS 服务 4. 客户端连接 NFS 服务端5. VMware Ubuntu6. Linux 内核 NFS 支持 1. 前言 限于作者能力水平&#xff0c;本文可能存在谬误&#xff0c;因此而给读者带来的损失&…

Seurat -- 数据集的整合

文章目录 briefPerforming integration on datasets normalized with LogNormalizePerforming integration on datasets normalized with SCTransform brief 这里主要根据seurat的教程走的&#xff0c;描述了多个单细胞数据集的整合&#xff0c;其中数据集的integration并不是…

java的对象模型

背过面试题的朋友都知道&#xff0c;Java对象是保存在堆内存中&#xff0c;在内存中&#xff0c;一个Java对象包含三部分&#xff1a;对象头&#xff0c;实例数据&#xff0c;对其填充。其中对象头是一个很关键的部分&#xff0c;因为对象头中包含锁状态标志&#xff0c;线程持…

使用MindSDK的at-server组件开发从机模组

使用MindSDK的at-server组件开发从机模组 文章目录 使用MindSDK的at-server组件开发从机模组引言AT命令应用场景AT命令技术简介MindSDK中的at-server组件及样例工程at_port.cat_cmd_led.cmain.c 基于AT命令的人机交互应用使用串口调试助手软件发送AT命令使用Python脚本发送AT命…

Java高阶数据结构 并查集 最小生成树

并查集与最小生成树 文章目录 Java高阶数据结构 & 并查集 & 最小生成树1. 并查集1.1 并查集的原理1.1.1 例子&#xff1a;1.1.2 这样存储有什么好处呢&#xff1f; 1.2 并查集的代码实现1.2.1 类的定义与属性1.2.2 构造方法1.2.3 获取下标的方法1.2.4 获得根节点1.2.5 …

1.SpringCloud技术实用02

SpringCloud技术实用02 0.学习目标 1.Nacos配置管理 Nacos除了可以做注册中心&#xff0c;同样可以做配置管理来使用。 1.1.统一配置管理 当微服务部署的实例越来越多&#xff0c;达到数十、数百时&#xff0c;逐个修改微服务配置就会让人抓狂&#xff0c;而且很容易出错。…

ES+Redis+MySQL 高可用架构设计

一、背景 二、ES高可用方案 三、会员Redis缓存方案 四、高可用会员主库方案 五、异常会员关系治理 六、展望&#xff1a;更精细化的流控和降级策略 一、背景 会员系统是一种基础系统&#xff0c;跟公司所有业务线的下单主流程密切相关。如果会员系统出故障&#xff0c;会…

网络安全信息收集初探之域名信息收集

网络安全信息收集初探之域名信息收集 域名信息收集工具oneforall收集子域名扫描单个域名批量扫描域名oneforall 额外参数 google hacking 证书收集子域名证书子域名在线收集网站子域名收集的各种细节 域名信息收集工具 oneforall收集子域名 扫描单个域名 python oneforall.p…

进阶自动化测试,这3点你一定要知道的...

自动化测试指软件测试的自动化&#xff0c;在预设状态下运行应用程序或系统&#xff0c;预设条件包括正常和异常&#xff0c;最后评估运行结果。将人为驱动的测试行为转化为机器执行的过程。 自动化测试框架一般可以分为两个层次&#xff0c;上层是管理整个自动化测试的开发&a…

云渲染农场具有什么特点?

众所周知&#xff0c;渲染农场的出现是为了解决长时间的图像渲染问题。渲染农场的底层搭建原理是利用很多计算机、网络和操作系统来构建一个庞大的计算群组&#xff0c;把一个渲染任务从一台机器分发到这个计算群组&#xff0c;从而达到短时间内能够快速得到渲染结果。 到了20…

分布式搜索引擎es 面试突击

es elastocsearch 倒排索引是在数据查询之前建立&#xff0c;在查询的时候可以直接通过关键词定位到文档内容。用空间换时间 分布式架构原理说一下&#xff1f; es底层是基于lucene来的 大概就是一个用于全文检索的jar包 用es来做分布式的搜索引擎 可以承载一秒钟几千的…

【Vue工程】005-Vue Router

【Vue工程】005-Vue Router 文章目录 【Vue工程】005-Vue Router一、概述1、Slogan2、官网3、参考文章 二、安装三、基本使用1、定义路由2、创建路由实例3、在 main.ts 注册路由4、在 App.vue 定义路由出口 四、嵌套路由1、修改路由2、定义嵌套路由出口 五、配置404页面六、声明…

联合索引该如何选择合适的列?

前面一篇文章&#xff0c;松哥和大家聊了 MySQL 中的索引合并&#xff0c;虽然 MySQL 提供了索引合并机制来提升 SQL 执行的效率&#xff0c;然而在具体实践中&#xff0c;如果能避免发生索引合并是最好的&#xff0c;毕竟这是没办法的办法&#xff0c;是一个下下策。发生索引合…

Wikidata 模型分析+实体抽取+数据处理

Wikidata 数据分析与处理 需求&#xff1a;Wikidata 数据描述了很多实体&#xff0c;以及实体属性。比如某一个公司/组织/机构名称是&#xff1a;阿里巴巴&#xff0c;对数据内该组织的相关属性进行观察、分析、治理、抽取等&#xff0c;最后用图数据库进行存储和展示其关系&am…

为什么半导体FAB生产线需要EAP系统?

在半导体制造中&#xff0c;设备自动化系统EAP&#xff08;Equipment Automation Program&#xff09;是不可或缺的重要软件&#xff0c;它是连接MES、RMS、APC、FDC等上层系统和设备层的桥梁&#xff0c;用于管控生产线上的所有机台&#xff0c;并实现设备运行的自动化。 作为…

QT+OpenGL高级数据和高级GLSL

QTOpenGL高级数据和高级GLSL 本篇完整工程见gitee:QtOpenGL 对应点的tag&#xff0c;由turbolove提供技术支持&#xff0c;您可以关注博主或者私信博主 高级数据 OpenGL中的缓冲区 对象管理特定的GPU内存 在将缓冲区绑定到特定的缓冲区目标时候赋予它意义 OpenGL在内部会保…