UGUI列表进阶

UGUI列表进阶

  在Unity中使用UGUI来设计用户界面是很方便的,但是对于列表显示这个特定的需求,UGUI没有给出一个足够具体的解决方案,而是设计了一个很宽泛的组件ScrollView,让设计者们自己想办法实现列表。
  在前文中初步讨论了如何设计一个可用的列表组件,最后设计出的结果是一个基本满足要求的通用列表组件,参考了安卓的ListView和Adapter,设计成类似的数据与展示解耦的结构,观察者模式,并引入了简单的缓存机制来改善效率。


大型列表之殇—设计循环重用列表

  在实际使用中遇到大型列表时,前文提到的那种基本满足要求的列表实现完全不堪重负,超过200个项目时其第一次载入就十分吃力了,移动平台上更加明显。如果制作静态列表还没什么太大问题,毕竟载入完成后就不再有过多的性能消耗,但大型动态列表需求使用这种设计方案的列表是不可接受的。
  针对这种问题,可行的方案似乎非常明显,在之前讨论设计抽象列表时就借鉴了安卓列表组件的设计思路,而安卓的ListView有一项重要的能力就是项目重用,它可以自动将离开可视范围的列表项重新投入使用,刷新数据后用在新出现的项目上。
  这个思路完全可以借鉴过来解决这个大型列表的问题,而且或许针对UGUI的列表而言这是唯一可行的解决方案了。
  既然要借鉴ListView的这一重用功能设计,那么首先就要大概了解这个重用的过程,重用的过程大致可以描述如下

  • 检查是否有列表项被推出可视范围(向上或向下)
    • 若没有,则不需更新
    • 若有被推出项,则将其回收,更新数据后放到即将出现在可视范围的位置

  从这个思路出发,转入Unity的UGUI设计范畴,便可以尝试设计一个循环重用的列表组件了。
  设计思路为分以下两个部分

  1. 列表的Content对象只初始化足够显示的项目数,一般为列表区域高度和项目高度的比值(引入间隔和Padding后会相应变化),若Adapter给出的项目数不足以铺满列表显示区域则将多余的项目隐藏。
  2. 列表滑动时每帧检测一次是否有列表项离开显示区域,若有则将其重用,改变位置到列表最底部作为新项目显示,反方向滑动的情况类似。

  根据以上两点可以确定出新列表组件的一些特征。
  首先它的Content对象只包含一定数量的子项,而且无论Adapter给出的数量有多少这个子项数目都不会改变,同时Content对象的高度依然要按照总项目数来确定。
  其次脚本中需要想办法确定什么时候一个项目离开了可视范围,并且要获取到这个项目的对象引用,然后根据需要更新其位置和数据。
  有了这个初步的想法,可以先上手试一试了,列表和Adapter的结构不变,编写一个新的列表脚本。

public class CommonRecycleListObject : MonoBehaviour, IUpdateViews {
    private const string COMMON_LIST_CONTENT_PATH = "Viewport/Content";
    protected BaseAdapter baseAdapter; // 列表适配器
    protected RectTransform contentRoot; // 内容根对象
    protected CycleList showItemList; // 展示对象缓存(循环列表)
    private bool initFlag; // 初次载入标志
    private bool updateFlag; // 刷新标志
    private int prevStartIndex; // 缓存前一个刷新周期的列表头部索引
    private int currStartIndex; // 缓存当前刷新周期的列表头部索引
    private CommonPair<int, int> itemSize; // 储存项目对象尺寸
    private CommonPair<int, int> pageSize; // 储存显示区域尺寸
    private CommonPair<int, int> listSize; // 储存整个列表的尺寸
    private bool updateFlag = false;
    // ------ 公用变量
    public int spacing; // 边距,暂时会在底部形成同样高度的空间
    void Awake() {
        preLoad();
    }
    void Start() {
        loadViews();
        loadMembers();
    }
    void Update() {
        execute();
    }
    protected void preLoad() {
        initFlag = true;
        prevStartIndex = 0;
        currStartIndex = 0;
        updateFlag = false;
    }
    protected void loadViews() {
        contentRoot = (RectTransform)(transform.Find(COMMON_LIST_CONTENT_PATH));
    }
    protected void loadMembers() {
        showItemList = new CycleList();
        updateFlag = true;
    }
    public void updateViews() {
        resetAll();
        updateFlag = true;
    }
    protected void execute() {
        if(initFlag && baseAdapter != null && baseAdapter.getCount() > 0) {
            initObjects();
            initFlag = false;
        }
        updateData();
        if (showItemList.getCount() > 0) {
            checkRecycle();
        }
    }
    /// 
    /// 数据刷新方法,在有需要的时候刷新显示项目的数据
    /// 
    private void updateData() {
        if(updateFlag) {
            if (updateFlag && baseAdapter != null) {
                for (int i = currStartIndex; i < currStartIndex + showItemList.getCount() - 1; i++) {
                    if (i < baseAdapter.getCount()) {
                        GameObject obj = showItemList.getElement(i);
                        obj.SetActive(true);
                        baseAdapter.getObject(obj, i);
                    }
                }
                updateFlag = false;
            }
        }
    }
    /// 
    /// 初始化列表所需的GameObject,固定为铺满列表显示区
    /// 
    private void initObjects() {
        if(baseAdapter != null) {
            pageSize.x = (int)gameObject.GetComponent().rect.width;
            pageSize.y = (int)gameObject.GetComponent().rect.height;
            GameObject obj = baseAdapter.getObject(null, 0);
            itemSize.x = (int)obj.GetComponent().rect.width;
            itemSize.y = (int)obj.GetComponent().rect.height + spacing;
            listSize.x = pageSize.x;
            listSize.y = itemSize.y * baseAdapter.getCount() + spacing;
            contentRoot.sizeDelta = new Vector2(0, listSize.y);
            int pageCount = (int)Math.Ceiling((double)pageSize.y / itemSize.y);
            obj.transform.SetParent(contentRoot);
            obj.transform.localScale = Vector3.one;
            (obj.transform as RectTransform).sizeDelta = new Vector2(listSize.x - 20, itemSize.y);
            obj.transform.localPosition = new Vector3(0, 0);
            showItemList.insertContent(obj);
            // 循环从1开始,因为第一个子项元素已经创建好了
            for(int i = 1; i < pageCount; i++) {
                if (i < baseAdapter.getCount()) {
                    obj = baseAdapter.getObject(null, i);
                } else {
                    obj = baseAdapter.getObject(null, 0);
                    obj.SetActive(false);
                }
                obj.transform.SetParent(contentRoot);
                obj.transform.localScale = Vector3.one;
                (obj.transform as RectTransform).sizeDelta = new Vector2(listSize.x - 20, itemSize.y);
                obj.transform.localPosition = new Vector3(0, (-i * itemSize.y) - spacing);
                showItemList.insertContent(obj);
            }
        }
    }
    /// 
    /// 每帧检查是否需要移动重用对象
    /// 
    private void checkRecycle() {
        currStartIndex = getStartIndex();
        if (prevStartIndex != currStartIndex) { // 发现显示区域变化超过阈值,需要移动显示对象
            // need to update
            if (prevStartIndex < currStartIndex) { // 表示列表在向下滚动
                if (currStartIndex + showItemList.getCount() <= baseAdapter.getCount()) {
                    GameObject go = showItemList.getElement(prevStartIndex);
                    updatePosition(go, prevStartIndex + showItemList.getCount());
                } else { // 此时表示已经触底,直接返回即可,避免项目标识更新出错
                    return;
                }
            } else { // 表示列表在向上滑动
                if (currStartIndex >= 0) {
                    GameObject go = showItemList.getElement(prevStartIndex + showItemList.getCount() - 1);
                    updatePosition(go, prevStartIndex - 1);
                }
            }
            prevStartIndex = currStartIndex;
        }
    }
    /// 
    /// 每帧调用获取当前显示的第一个项目索引,使用高度计算
    /// 
    /// 索引值
    private int getStartIndex() {
        float yPos = contentRoot.anchoredPosition.y;
        int result = (int)(yPos / itemSize.y);
        return result < 0 ? 0:result;
    }
    /// 
    /// 重排方法,按照目标索引所表示的位置将对象更新到指定坐标
    /// 
    /// 待更新对象
    /// 目标索引
    private void updatePosition(GameObject obj, int toIndex) {
        obj.transform.localPosition = new Vector3(0, (-toIndex * itemSize.y) - toIndex * spacing);
        if(toIndex >= 0 && toIndex < baseAdapter.getCount()) {
            obj.SetActive(true);
            baseAdapter.getObject(obj, toIndex);
        } else { // 表示更新位置处于显示范围之外,隐藏即可
            obj.SetActive(false);
        }
    }
    /// 
    /// 重设整个列表,当数据发生变化时使用
    /// 
    private void resetAll() {
        listSize.y = itemSize.y * baseAdapter.getCount() + spacing;
        contentRoot.sizeDelta = new Vector2(0, listSize.y);
        contentRoot.localPosition = Vector3.zero;
        prevStartIndex = 0;
        currStartIndex = 0;
        for(int i = 0; i < showItemList.getCount(); i++) {
            GameObject obj = showItemList.getElement(i);
            obj.transform.localPosition = new Vector3(0, -i * itemSize.y);
            if(i >= 0 && i < baseAdapter.getCount()) {
                obj.SetActive(true);
                baseAdapter.getObject(obj, i);
            } else {
                obj.SetActive(false);
            }
        }
    }
    public void setAdapter(BaseAdapter adapter) { // 设置适配器
        baseAdapter = adapter;
        baseAdapter.setupListReference(this); // 装载列表引用,观察者模式成立
        baseAdapter.setRelatedObject(getTagName());
        updateFlag = true;
    }
    public BaseAdapter getAdapter() {
        return baseAdapter;
    }
}

  其中CommonPair是自定义的泛型键值对结构

public struct CommonPair {
    public K pairKey;
    public K x {
        get {
            return pairKey;
        }
        set {
            pairKey = value;
        }
    }
    public V pairValue;
    public V y {
        get {
            return pairValue;
        }
        set {
            pairValue = value;
        }
    }
}

  而CycleList则是封装好的循环列表,用于比较方便地通过位置索引获取对象;封装起来只是为了方便使用,如果没有其它地方需要用到这个循环列表的话可以写成私有字段,用几个方法来控制循环获取对象。

public class CycleList {
    private List contentList;
    private int offset = 0;
    public CycleList() {
        contentList = new List();
    }
    public void insertContent(T ele) {
        contentList.Add(ele);
    }
    public T getElement(int index) {
        if(contentList.Count <= 0) {
            return default(T);
        }
        int realIndex = (index + offset) % contentList.Count;
        return contentList[realIndex];
    }
    public int getCount() {
        return contentList.Count;
    }
    public void clearData() {
        contentList.Clear();
    }
    public void setOffset(int off) {
        offset = off;
    }
}

  接着分析重点部分initObjects和checkRecycle两个方法。
  initObjects中首先获取到列表显示范围大小装入pageSize,创建第一个列表元素(在执行initObjects之前已经判断过Adapter是否有数据)并获取列表元素尺寸装入itemSize中,接着计算出Content对象所需的尺寸,也就是滑动区域的尺寸装入listSize中。
  通过设置Content对象的sizeDelta属性将其高度调整为全列表高度,这样才能让列表正常滑动起来,宽度可以随意设置,因为循环重用列表不能使用VerticalLayoutGroup和ContentSizeFitter组件来自适应大小,列表项的位置是手动指定的。

// 显示区域尺寸获取
pageSize.x = (int)gameObject.GetComponent().rect.width;
pageSize.y = (int)gameObject.GetComponent().rect.height;
// 创建第一个子项
GameObject obj = baseAdapter.getObject(null, 0);
// 子项尺寸获取
itemSize.x = (int)obj.GetComponent().rect.width;
itemSize.y = (int)obj.GetComponent().rect.height + spacing;
// 最后确定列表区域尺寸
listSize.x = pageSize.x;
listSize.y = itemSize.y * baseAdapter.getCount() + spacing;
// 为Content对象设置尺寸,高度是必须的
contentRoot.sizeDelta = new Vector2(0, listSize.y);

  随后将第一个元素放入Content中,计算铺满整个显示区域所需的元素个数,在这里使用了Ceiling函数处理计算结果,因为铺满时可能最后一个显示元素会有一部分遮挡,这时要将它算上。
  然后添加元素到循环列表,启动一次循环,循环次数为pageCount-1次,用于创建全部的可用元素并放入Content对象,注意即便是Adapter中的数据个数达不到铺满列表显示区域的元素个数也要生成足够的元素,并放置到指定位置,这样才能防止数据从少量切换到大量时造成的元素个数错误。

// 计算铺满列表显示的项目数量
int pageCount = (int)Math.Ceiling((double)pageSize.y / itemSize.y);
// 将第一个子项放置到位
obj.transform.SetParent(contentRoot);
obj.transform.localScale = Vector3.one;
(obj.transform as RectTransform).sizeDelta = new Vector2(listSize.x - 20, itemSize.y);
obj.transform.localPosition = new Vector3(0, 0);
// 加入循环列表
showItemList.insertContent(obj);
// 补充剩下所需的部分
for(int i = 1; i < pageCount; i++) {
    if (i < baseAdapter.getCount()) {
        obj = baseAdapter.getObject(null, i);
    } else { // 数据量无法铺满列表显示区域时获取第一个子项作为占位即可
        obj = baseAdapter.getObject(null, 0);
        obj.SetActive(false);
    }
    obj.transform.SetParent(contentRoot);
    obj.transform.localScale = Vector3.one;
    (obj.transform as RectTransform).sizeDelta = new Vector2(listSize.x - 20, itemSize.y);
    obj.transform.localPosition = new Vector3(0, (-i * itemSize.y) - spacing);
    showItemList.insertContent(obj);
}

  然后看到checkRecycle,这个方法是实现循环重用的重点。
  首先每一帧都执行,第一行获取起始项目索引位置,这个方法比较简单。

private int getStartIndex() {
    float yPos = contentRoot.anchoredPosition.y;
    int result = (int)(yPos / itemSize.y);
    return result < 0 ? 0:result;
}

  通过简单的计算得到当前起始索引位置,获取到之后与缓存的前一次起始索引值进行对比,若不相等则表示有循环重用的项目需要调整,此时进入主要逻辑。
  如果当前起始索引比缓存值大,则列表整体在下移,即下方有新元素推入,上方有元素推出,此时从循环列表中取到被推出的元素对象,更新其位置并刷新数据即可。
  resetAll方法用于在数据刷新时重置所有项目
  这样的一个循环重用列表就可以使用了,但实测过后可以发现它依然存在很多缺陷。

  1. 在上下滑动过程中发现列表上下方均有空缺处,那是因为只有当索引值变化时才引发重用更新,因此在向上向下滑动过程中遇到列表有空缺,却因为滑动距离不足以引发更新时就出现了问题。
  2. 在快速滑动时因为更新速度跟不上会造成列表元素混乱,由于重用更新是每帧进行的,而用户的滑动操作可能比这个要快,也就是说用户可能在一帧之内就滑动很长的距离,导致索引值变化超过1,单次更新不能正确地将元素显示到位。
  3. 在数据变化时,resetAll方法会重新排列所有的元素,导致列表索引归零,这在体验上可能成为一个缺陷,有改进余地。

针对这三点问题,循环重用列表组件可以进行改造。


循环重用列表的优化改造

  首先考虑第一个问题如何解决,既然上下方可能出现空缺,那么一个很直接的方案就是多来两个元素把空缺堵上不就好了。这当然是可以达到目的的,而且代价很小,主要的问题将会集中在索引值的换算和偏移上。
  为了方便使用多出来的元素,首先为CycleList设置一个偏移量,由于头部多出一个元素,而为了保持显示的第一个元素索引值还是0,设置偏移量为1,即传入的索引值会被加上1后再取元素。
  然后修改一下initObjects方法

private void initObjects() {
    if (baseAdapter != null) {
        pageSize.x = (int)gameObject.GetComponent().rect.width;
        pageSize.y = (int)gameObject.GetComponent().rect.height;
        GameObject obj = baseAdapter.getObject(null, 0);
        itemSize.x = (int)obj.GetComponent().rect.width;
        itemSize.y = (int)obj.GetComponent().rect.height + spacing;
        listSize.x = pageSize.x;
        listSize.y = itemSize.y * baseAdapter.getCount() + spacing;
        contentRoot.sizeDelta = new Vector2(0, listSize.y);
        int pageCount = (int)Math.Ceiling((double)pageSize.y / itemSize.y) + 1; // 增加一个子项用来补缺
        obj.transform.SetParent(contentRoot);
        obj.transform.localScale = Vector3.one;
        (obj.transform as RectTransform).sizeDelta = new Vector2(listSize.x - 20, itemSize.y);
        obj.transform.localPosition = new Vector3(0, itemSize.y - spacing);
        showItemList.insertContent(obj);
        for (int i = 0; i < pageCount; i++) { // 依然从零开始,又可以多得到一个子项
            if (i < baseAdapter.getCount()) {
                obj = baseAdapter.getObject(null, i);
            } else {
                obj = baseAdapter.getObject(null, 0);
                obj.SetActive(false);
            }
            obj.transform.SetParent(contentRoot);
            obj.transform.localScale = Vector3.one;
            (obj.transform as RectTransform).sizeDelta = new Vector2(listSize.x - 20, itemSize.y);
            obj.transform.localPosition = new Vector3(0, (-i * itemSize.y) - spacing);
            showItemList.insertContent(obj);
        }
    }
}

  将pageCount增大,同时将第一个用来获取尺寸的元素放到第一个位置,此后的循环保持pageCount次,则成功地将元素个数扩大两个,之后只要在checkRecycle方法中调整一下获取元素的索引值就可以了。比如第一个元素,在显示区域之外的那个索引值为-1,其余的依次类推,最后一个元素超过了显示区域的最大范围,所以在到达列表顶端和底端时都需要注意处理边界。
  接着考虑第二个问题,快速滑动时更新跟不上滑动速度,那么就考虑在每帧更新重用时分情况讨论,比如将列表的滑动分成两类。

  1. 滑动速度适中,单帧时间内滑动距离不超过整个列表范围
  2. 滑动速度极快,单帧时间内就滑动超过了整个列表显示范围

  针对第一种,它事实上包含了慢速和中速两个情况,可以合并在一起处理。而针对第二种,考虑在确定发生快速滑动后在新位置上重新排列所有的元素,因为不涉及新建和销毁对象这类高开销的操作,仅仅改变位置,因此操作速度很快,一帧内完成问题不大。
  有了方案和思路,接下来改造checkRecycle方法

private void checkRecycle() {
    currStartIndex = getStartIndex();
    GameObject headObj = showItemList.getElement(-1); // 循环列表设置了Offset,因此-1才是第一个位置
    if (currStartIndex == 0) { // 若当前显示的第一个位置即是列表的第一条记录,表示头部超出部分需要隐藏
        if (headObj.activeSelf) {
            headObj.SetActive(false);
        }
    } else { // 否则根据情况选择是否隐藏
        if (!headObj.activeSelf) {
            if (currStartIndex + showItemList.getCount() - 2 <= baseAdapter.getCount()) { // 判断是否需要显示缓冲的头部对象,下拉时需要
                showItemList.getElement(-1).SetActive(true);
            }
        }
    }
    if (prevStartIndex != currStartIndex) { // 发现显示区域变化超过阈值,需要移动显示对象
        if (prevStartIndex < currStartIndex) { // 表示列表在向下滚动
            int gap = currStartIndex - prevStartIndex; // 得出前后两帧内列表滑过的项目数
            if (gap >= showItemList.getCount()) { // 若滑动极快,一帧之内就滑动超过了最大显示项目数,则直接从头排列一遍
                for (int i = 0; i < showItemList.getCount(); i++) {
                    GameObject go = showItemList.getElement(prevStartIndex - 1 + i);
                    updatePosition(go, currStartIndex - 1 + i);
                }
            } else { // 否则的话按照顺序挨个移动
                if (currStartIndex + showItemList.getCount() - 2 <= baseAdapter.getCount()) {
                    for (int i = 0; i < gap; i++) {
                        GameObject go = showItemList.getElement(prevStartIndex - 1 + i);
                        updatePosition(go, prevStartIndex - 1 + showItemList.getCount() + i);
                    }
                } else { // 此时表示已经触底,直接返回即可,避免项目标识更新出错
                    return;
                }
            }
        } else { // 表示列表在向上滑动
            int gap = prevStartIndex - currStartIndex; // 得出前后两帧内列表滑过的项目数
            if (gap >= showItemList.getCount()) { // 若滑动极快,一帧之内就滑动超过了最大显示项目数,则直接从头排列一遍
                for (int i = 0; i < showItemList.getCount(); i++) {
                    GameObject go = showItemList.getElement(prevStartIndex - 1 + i);
                    updatePosition(go, currStartIndex - 1 + i);
                }
            } else { // 否则的话按照顺序挨个移动
                if (currStartIndex >= 0) {
                    for (int i = 0; i < gap; i++) {
                        GameObject go = showItemList.getElement(prevStartIndex - 2 + showItemList.getCount() - i);
                        updatePosition(go, prevStartIndex - 2 - i);
                    }
                }
            }
        }
        prevStartIndex = currStartIndex;
    }
}

  在获取到当前第一个项目的索引值后,首先判断是否到达列表顶部,如果确定是顶部则隐藏顶上的超出部分元素,否则根据实际情况判定是否需要显示。
  随后的逻辑与之前类似,但在确定了滑动方向后首先计算滑动距离,通过缓存索引值和当前索引值的差来得到,当距离没有超过循环列表元素个数,即滑动长度没有超过整个列表可视范围的时候,将已经推出的部分元素逐个取出更新位置和数据;当距离超过循环列表元素个数,即表示单帧内滑动距离超过了列表可视范围,此时直接在新位置上重新排列所有元素即可。
  注意在列表向下滑动时有一个边界判定,即当列表触底超界后直接返回不更新缓存索引,避免索引更新出错,因为Unity3D的ScrollView组件有回弹功能,列表触底超界后会在释放滑动后自动回弹,此时更新会正常进行。
  上下滑动的情况类似,其它的方法不需要改动。
  这样改造后,循环重用列表就能工作的很好了,虽然它依然无法设置表头和表尾,而且手动设置项目位置也导致列表的项目预制体必须设定为对齐左上角且锚点在左上,否则可能会出现位置问题;但这个列表现在正常可以使用并且在遇到大量数据展示时效率很高,对比普通的列表实现差别非常巨大。
  下面再考虑第三个问题,resetAll导致的归零问题。
  这个问题的解决方案比较简单,如果不想归零就要手动计算当数据数量更新后索引值分别应该是怎样的,在此也要进行分类讨论。

  1. 如果新的数据集个数低于可显示范围内的元素个数,则正常归零即可
  2. 如果新的数据集个数小于当前情况下最后一个元素的索引,则需要重新计算索引,一般而言考虑到体验问题是变为显示新数据集的最后部分即可
  3. 如果新的数据集个数大于当前情况下最后一个元素的索引值,则不需要重新计算任何索引,全部照旧即可

  基于以上三点考虑,对resetAll方法进行修改

private void resetAll() {
    listSize.y = itemSize.y * baseAdapter.getCount() + spacing;
    contentRoot.sizeDelta = new Vector2(0, listSize.y);
    if(baseAdapter.getCount() < pageCount) {
        // 当数据量小于界面显示数量时
        contentRoot.localPosition = Vector3.zero;
        prevStartIndex = 0;
        currStartIndex = 0;
        for (int i = -1; i < showItemList.getCount() - 1; i++) {
            GameObject obj = showItemList.getElement(i);
            obj.transform.localPosition = new Vector3(0, -i * itemSize.y);
            if (i >= 0 && i < baseAdapter.getCount()) {
                obj.SetActive(true);
            } else {
                obj.SetActive(false);
            }
        }
    } else if(baseAdapter.getCount() < currStartIndex + showItemList.getCount()) {
        // 当数据量小于更新前显示的最大索引值时
        prevStartIndex = baseAdapter.getCount() - pageCount;
        currStartIndex = prevStartIndex;
        contentRoot.localPosition = new Vector3(0, currStartIndex * itemSize.y);
        for (int i = - 1; i < showItemList.getCount() - 1; i++) {
            GameObject obj = showItemList.getElement(currStartIndex + i);
            obj.transform.localPosition = new Vector3(0, -(currStartIndex + i) * itemSize.y);
            if (currStartIndex + i >= 0 && currStartIndex + i < baseAdapter.getCount()) {
                obj.SetActive(true);
            } else {
                obj.SetActive(false);
            }
        }
    } else {
        // 当数据量大于更新前显示的最大索引值时
        for (int i = -1; i < showItemList.getCount() - 1; i++) {
            GameObject obj = showItemList.getElement(currStartIndex + i);
            if (currStartIndex + i >= 0) {
                obj.SetActive(true);
            } else {
                obj.SetActive(false);
            }
        }
    }
}

  三个不同的分支对不同情况进行处理,这样一来第三个缺陷也修复了。
  至此整个循环重用列表的设计和编写就完成了,该列表适用于有大型数据集需要列表展示的场合,而且还能将其改造为循环重用的网格结构列表,虽然依然存在各种不足和缺陷,但满足一般需求是没有问题的。

你可能感兴趣的:(Unity开发相关)