在功能开发中经常会遇到某些页面需要一次性实例化几十甚至上百个item的情况,如果真的根据当时的数据量把多个子item都实例化出来,内存占用会变得很大,而且UGUI对超出viewport区域的对象也会绘制,导致画面突然变得很卡。

这个时候就需要对实例化的步骤做些优化:

1.对象池的引入:

首先在滑动列表时始终展示出来的只有viewport区域内的item,对超出viewport区域的item是不予显示的。那么是否可以将超出viewport区域的item放入回收池中,当需要新的item时直接去回收池中查找,实现item的循环利用,这样也可以减少内存占用。同时由于content下始终只有相对固定的几个item之间循环复用,因此对于渲染压力也能够大大减轻。

//获取可用的item
ItemCell GetItem(int index)         //index代表获取到item后,该item当前需要显示的目标index
{
	//为什么这里不用GameObject,而用自定义的组件ItemCell?
	//解答:ItemCell是自定义的脚本,用于对item物体宽高、msg等进行刷新和显示控制,单纯的GameObject是无法达成这样的效果
	ItemCell item;   
	
	//获取item有两种方式——这个很重要:
	//第一种:在当前item列表中查找
	//当缓慢的上滑滚动条时,为了item频繁刷新导致的视觉疲劳,此时不应该直接去回收池中获取item,而应该先在当前显示的item列表中查询是否有可用的对象。
	//当然直接的刷新item列表是不可避免的,如从当前位置快速的滑动到某地,此时上一帧和当前帧完全没有相同的item,此时只能全部刷新。
	//之所以加入先从当前显示列表中查找的语句,是为了解决缓慢滑动滚动条时的刷新频繁问题。
	for(int i = 0; i < lastShowingItem.Count; ++i)
	{
		if(lastShowingItem[i].data == allItemDataList[index])
		{
			item = lastShowingItem[i];
			lastShowingItem.RemoveAt(i);   //这里找到目标后就结束迭代,因此也不会出现下次迭代时元素的遍历错误,所以可以在这里直接删除集合中的元素
			return item;
		}
	}

	//第二种:在回收池中查找
	//从回收池中查询item时分成三种情况:
	if(recycleItemPool.Count > 0)
	{
		//情况一:为了保证缓慢滑动时视觉上的连贯性,避免频繁刷新导致的视觉疲劳,这里先从回收池中查找是否有相同的元素
		for(int i = 0; i < recycleItemPool.Count; ++i)
		{
			if (recycleItemPool[i].data == allItemDataList[index])
			{
				item = recycleItemPool[i];
				recycleItemPool.RemoveAt(i);      //移除该item元素
				return item;
			}
		}

		//情况二:回收池中没有现成的元素,因此默认取第一个
		item = recycleItemPool[0];
		recycleItemPool.RemoveAt(0);   //从回收池中移除该item
	}
	else                 
	{
		//情况三:回收池中已然没有足够的item,此时只能重新实例化item新对象
		GameObject itemPrefab = Resources.Load("Prefabs/ItemPrefab") as GameObject;
		GameObject go = GameObject.Instantiate(itemPrefab);
		go.transform.SetParent(content);  //设置parent,否则不会在UI中显示出来
		go.transform.localPosition = Vector3.zero;
		item = go.GetComponent<ItemCell>();
	}

	//设置该item的数据信息itemData.
	item.data = allItemDataList[index];

	return item;
}

2.滚动列表的刷新:

当使用对象池来减少子item的实例化数量时,那么每一帧需要渲染的只是viewport区域内有限的几个item。这时就会出现一个问题:如何来判定当前需要渲染的是哪几个item?

为了解决这个问题,我们需要仔细看scrollRect滚动时的细节:当滚动scrollRect时,其content的X/Y坐标会随着滚动的方向作相应的变化。

这里以Vertical方向为例,当向上滑动时,content也会随着滑动自动上移,且其Y坐标会随着上移而自动增加,增加的数值恰好等于该子item到content顶端的高度。

由于有这样的便利情况,那么是否可以根据content的Y坐标数值来计算出当前正在viewport区域内的是哪几个item呢?答案是肯定的,但前提在于已经明确的知道每个item的宽高,这样可以根据各个item的高度累加统计出当前viewport区域内的首个item

//刷新当前滚动列表中的信息
//重点:刷新的时机一定在allItemDataList执行完毕后,不论是初始化还是后期insert或者删除某个元素,都要在allItemDataList处理完毕之后再进行列表的刷新
void RefreshContent()
{
	//刷新分成两部分:
	//重点:建议在“RefreshContentSize”后再刷新当前的“RefreshItem”。
	//      因为当出现滑动到底端,然后把bottom的几个元素删除,此时刷新列表,如果在RefreshItem之前没有RefreshContentSize则会导致刷新出错,此时滚动条会自动下滑
	//第一部分:是否需要更改content的width/height
	if (isRefreshContentSize)
	{
		isRefreshContentSize = false;    //重置,避免毫无理由重复刷新
		ResizeContentArea();
	}

	//第二部分:刷新当前需要显示的item
	if (isRefreshItem)
	{
		isRefreshItem = false;          //重置,避免毫无理由重复刷新

		//刷新当前scroll的关键在于确定当前需要显示哪些item
		//确定item的方法:
		//在向上滑动scroll时,content的Y坐标会增大,具体增大的数值则根据当前的item.height来计算获得。因此可反向推定,得到当前首个显示的item
		//所以:
		//首先确定content的Y坐标值
		contentYPos = content.localPosition.y;
		if (contentYPos < 0)
			contentYPos = 0;
		else
		{   //如果在RefreshItem之前没有RefreshContentSize,则此时取到的content.rect.height是旧值
			if (contentYPos > content.rect.height - scroll.viewport.rect.height)
				contentYPos = content.rect.height - scroll.viewport.rect.height;
		}

		//获取到contentYPos后,通过比较得到当前的item的index
		startIndex = -1;
		startYPos = -1;
		yPos = verticalLayout.paddingTop;
		maxYPos = contentYPos + scroll.viewport.rect.height;
		for(int i = 0; i < allItemDataList.Count; ++i)          //刷新之前要确保allItemDataList已处理完毕
		{
			yPos += allItemDataList[i].height;
			//与contentYPos比较
			if(yPos > contentYPos)    //“yPos == contentYPos”的情况也不需要渲染
			{
				startIndex = i;
				startYPos = yPos - allItemDataList[i].height;   //该startIndex对象在Y轴上的坐标
				break;    //此时必须要跳出当前循环
			}

			yPos += verticalLayout.gap;
			if(yPos > contentYPos)           //此时不包含“yPos == contentYPos”的情况
			{
				//针对于某些verticalLayout.gap特别大的情况,只是增加gap都有可能会超出当前viewport
				if(yPos >= maxYPos)     //包括“=”的情况,此时不需要渲染任何item,直接跳出循环
				{
					startIndex = -1;
					break;
				}
				else
				{
					startIndex = i + 1;
					startYPos = yPos;
					break;
				}  
			}
		}

		//根据获取到的startIndex来刷新当前scroll列表
		//为了引入对象池回收的机制,这里需要对上一帧和当前帧的item列表进行统计
		lastShowingItem.Clear();
		for(int i = 0; i < currentShowingItem.Count; ++i)
		{
			lastShowingItem.Add(currentShowingItem[i]);
		}
		currentShowingItem.Clear();    //将当前显示的item list清空

		if(startIndex != -1)    //说明此时列表中有item需要显示出来
		{
			while(startYPos < maxYPos && startIndex < allItemDataList.Count)      //1.“=”的情况时不需要渲染  2.同时也要考虑元素过少,无法填满viewport的情况
			{
				ItemCell item = GetItem(startIndex);
				item.gameObject.SetActive(true);
				item.gameObject.name = string.Format("item_{0}", startIndex);
				item.Refresh();
				//注意:这里设置的是item相对于content的position,所以Y坐标为负值
				item.transform.localPosition = new Vector3(verticalLayout.paddingLeft, -startYPos, 0);
				currentShowingItem.Add(item);      //添加进当前渲染的item列表中

				//下个item的Y坐标
				++startIndex;
				startYPos += item.data.height;
				startYPos += verticalLayout.gap;
			}
		}

		//回收当前不用的item
		for(int i = 0; i < lastShowingItem.Count; ++i)
		{
			recycleItemPool.Add(lastShowingItem[i]);
			lastShowingItem[i].gameObject.SetActive(false);    //放进回收池中暂不用再显示
		}
	}
}

3.每个item的宽高计算:

那么如何知道每个item的宽高呢?对于这个问题,有些时候其实可以不同考虑,比如使用Grid布局时,由于每个item的size基本都是统一的,那么直接使用默认的width/height即可。但是并非所有情况都这样,比如在聊天的对话列表中,由于每句话的内容都不一样,这时候每个对话气泡的宽高都会不同。在这种情况下就需要对每个item的宽高做计算了。

以聊天气泡为例,在气泡UI内部,头像、名字等UI的位置其实已经相对固定,唯一可能变化的只是显示聊天的Text会根据内容不同而变化。所以这里的关键在于计算出聊天Text的UI宽高,如果能够知道Text的宽高,那么根据Anchors的设定sizeDelta即可知道整体聊天UI的宽高

那么如何计算Text的理想宽高呢?这里使用text.preferredWidth/preferredHeight来计算

//注意:
//1.用来计算每个item最理想的width, height,这个对于统计content实际的总size和确定当前帧需要渲染的第一个item的索引至关重要
//  因为确定第一个item就是根据content的Y轴偏移和每个item的height累计比较,最终得以确定
//  所以每个item的真实width, height就显得尤为重要
//2.通常情况只需要在初始时对每个item的宽高计算一次,无需重复计算。除非item的宽高会随着index或其他某些因素而动态改变
void InitializeAllItemData(string[] allMsg)                  
{
	//将原始数据整理成目标格式的数据形式
	GameObject go;
	if (recycleItemPool.Count > 0)
		go = recycleItemPool[0].gameObject;
	else
	{
		GameObject itemPrefab = Resources.Load("Prefabs/ItemPrefab") as GameObject;
		go = GameObject.Instantiate(itemPrefab, content);
		ItemCell item = go.GetComponent<ItemCell>();
		recycleItemPool.Add(item);           //由于这个item只是用来计算item真实size,所以可以直接放入回收池中以便下次使用
	}
	go.SetActive(false);      //这里不需要显示出来,只是用来计算item真实width/height时使用

	//由于初始化时有大量的数据需要操作,加上“GetComponent”、“Find”等方法比较耗费CPU资源,因此这里不宜直接使用CalculateSingleItemData来获取单个item的size
	RectTransform itemRect = go.GetComponent<RectTransform>();     
	RectTransform txtRect = go.transform.Find("ItemTxt").GetComponent<RectTransform>();       
	Text msgTxt = go.transform.Find("ItemTxt").GetComponent<Text>();    

	//计算每个item理想的width, height
	float maxItemWidth = content.rect.width - 30;   //item的最大宽度
	float width, height;
	for (int i = 0; i < allMsg.Length; ++i)
	{
		msgTxt.text = allMsg[i];
		width = Mathf.Min(msgTxt.preferredWidth + Mathf.Abs(txtRect.sizeDelta.x), maxItemWidth);   //itemRect能够实现的最合适width
		itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
		height = msgTxt.preferredHeight + Mathf.Abs(txtRect.sizeDelta.y);
		itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);

		ItemData tempItemData = new ItemData(width, height, allMsg[i]);
		allItemDataList.Add(tempItemData);     //整理成目标格式的数据列表
	}
	isRefreshContentSize = true;
	isRefreshItem = true;
}

这里item的UI设计是这样的:

unity ScrollView居中 unity scrollview优化_宽高

txtRect.sizeDelta是聊天text与外部UI背景之间的Anchors矩形内边距,itemRect是整个item的RectTransform。通过以上方式,每个item的宽高数据就计算出来了。

4.content宽高的重新计算:

当需要对scroll做增删操作时,content的总height/width也会发生变化,此时需要重新设置content的size,然后根据当前content的Y坐标刷新item列表

//计算当前content的总size
void ResizeContentArea()
{
	//计算content的总height
	float totalHeight = verticalLayout.paddingTop;
	float count = allItemDataList.Count;
	for (int i = 0; i < count; ++i)
	{
		totalHeight += allItemDataList[i].height;
		if (i < count - 1)           //添加item后还需要加上两个item之间的gap
			totalHeight += verticalLayout.gap;
	}
	totalHeight += verticalLayout.paddingBottom;

	//content的宽度暂时不需要改变
	content.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, totalHeight);
}

运行效果如下:

unity ScrollView居中 unity scrollview优化_宽高_02

完整代码如下:

 负责整体ScrollRect滑动列表刷新的脚本:VerticalScroll.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.IO;

public class VerticalScroll : MonoBehaviour
{
    InputField msgInput, indexInput;
    Button addBtn, insertBtn, removeBtn, scrollToIndexBtn, scrollToEndBtn;
    Text sumLabel;

    VerticalLayoutSetting verticalLayout;
    int inertiaNum = 0;    //用于判断惯性开启的条件

    ScrollRect scroll;
    RectTransform content;

    List<ItemData> allItemDataList = new List<ItemData>();       //整理之后的用于存储所有item相关信息的数据列表
    List<ItemCell> recycleItemPool = new List<ItemCell>();     //item的回收池
    List<ItemCell> currentShowingItem = new List<ItemCell>();  //当前正在显示的item列表
    List<ItemCell> lastShowingItem = new List<ItemCell>();     //上一帧显示的item列表

    #region 刷新关键参数
    bool isRefreshContentSize = false;    //是否要刷新content的size
    bool isRefreshItem = false;          //是否要刷新滚动列表的item
    //解析:
    //isRefreshContentSize:通常只在第一次刷新时,或者后面对scroll列表插入或者删除某些item时会导致content的总height/width改变,此时需要重新设置content的size
    //isRefreshItem:只要scroll的OnValueChange则会导致刷新进行。但是刷新完成后则不会再重复刷新,除非再次ValueChange

    int startIndex = -1;                //scroll列表开始渲染的首个item的index,初始值为-1是因为“startIndex = 0”仍是正常刷新的情况,两者区分开
    float startYPos = -1;               //scroll列表刷新时首个item的Ypos
    float contentYPos = 0;   //content的Y坐标值
    float yPos = 0;         //刷新item时用于Y坐标累加,并与contentYPos比较得到当前的item
    float maxYPos = 0;      //当前帧需要显示的所有item的Y最大值
    #endregion

    void Start()
    {
        InitializeAllBtn();
        verticalLayout = new VerticalLayoutSetting(10, 10, 10, 10);

        scroll = transform.Find("Right/Scroll View").GetComponent<ScrollRect>();
        scroll.onValueChanged.AddListener(OnScrollChanged);    //当滑动时刷新scroll面板
        content = scroll.content;

        //获取原始数据
        string temp = File.ReadAllText(Application.dataPath + "/Resources/Files/ModernPoems.txt");
        string[] allMsg = temp.Split(new string[] { "##" }, System.StringSplitOptions.None);   //原始数据
        //将原始数据整理成目标形式的数据列表:主要的任务是计算每个item最理想的width, height
        InitializeAllItemData(allMsg);
    }

    void LateUpdate()
    {
        RefreshContent();      //为了达到更好的效果,通常是在每帧的末尾执行相应的刷新操作

        //由于默认是开启滑动惯性的,但由于某些情况可能会将惯性暂时关闭,因此这里需要重新开启
        //开启滑动惯性时需要注意:不能在同一帧再次开启滑动惯性,否则会无法呈现之前惯性暂时关闭的效果
        if (!scroll.inertia)
        {
            if (inertiaNum > 0)
            {
                scroll.inertia = true;
                inertiaNum = 0;
            } 
            else
                ++inertiaNum;
        }
    }

    #region 核心方法:用于列表的动态刷新
    //刷新当前滚动列表中的信息
    //重点:刷新的时机一定在allItemDataList执行完毕后,不论是初始化还是后期insert或者删除某个元素,都要在allItemDataList处理完毕之后再进行列表的刷新
    void RefreshContent()
    {
        //刷新分成两部分:
        //重点:建议在“RefreshContentSize”后再刷新当前的“RefreshItem”。
        //      因为当出现滑动到底端,然后把bottom的几个元素删除,此时刷新列表,如果在RefreshItem之前没有RefreshContentSize则会导致刷新出错,此时滚动条会自动下滑
        //第一部分:是否需要更改content的width/height
        if (isRefreshContentSize)
        {
            isRefreshContentSize = false;    //重置,避免毫无理由重复刷新
            ResizeContentArea();
        }

        //第二部分:刷新当前需要显示的item
        if (isRefreshItem)
        {
            isRefreshItem = false;          //重置,避免毫无理由重复刷新

            //刷新当前scroll的关键在于确定当前需要显示哪些item
            //确定item的方法:
            //在向上滑动scroll时,content的Y坐标会增大,具体增大的数值则根据当前的item.height来计算获得。因此可反向推定,得到当前首个显示的item
            //所以:
            //首先确定content的Y坐标值
            contentYPos = content.localPosition.y;
            if (contentYPos < 0)
                contentYPos = 0;
            else
            {   //如果在RefreshItem之前没有RefreshContentSize,则此时取到的content.rect.height是旧值
                if (contentYPos > content.rect.height - scroll.viewport.rect.height)
                    contentYPos = content.rect.height - scroll.viewport.rect.height;
            }

            //获取到contentYPos后,通过比较得到当前的item的index
            startIndex = -1;
            startYPos = -1;
            yPos = verticalLayout.paddingTop;
            maxYPos = contentYPos + scroll.viewport.rect.height;
            for(int i = 0; i < allItemDataList.Count; ++i)          //刷新之前要确保allItemDataList已处理完毕
            {
                yPos += allItemDataList[i].height;
                //与contentYPos比较
                if(yPos > contentYPos)    //“yPos == contentYPos”的情况也不需要渲染
                {
                    startIndex = i;
                    startYPos = yPos - allItemDataList[i].height;   //该startIndex对象在Y轴上的坐标
                    break;    //此时必须要跳出当前循环
                }

                yPos += verticalLayout.gap;
                if(yPos > contentYPos)           //此时不包含“yPos == contentYPos”的情况
                {
                    //针对于某些verticalLayout.gap特别大的情况,只是增加gap都有可能会超出当前viewport
                    if(yPos >= maxYPos)     //包括“=”的情况,此时不需要渲染任何item,直接跳出循环
                    {
                        startIndex = -1;
                        break;
                    }
                    else
                    {
                        startIndex = i + 1;
                        startYPos = yPos;
                        break;
                    }  
                }
            }

            //根据获取到的startIndex来刷新当前scroll列表
            //为了引入对象池回收的机制,这里需要对上一帧和当前帧的item列表进行统计
            lastShowingItem.Clear();
            for(int i = 0; i < currentShowingItem.Count; ++i)
            {
                lastShowingItem.Add(currentShowingItem[i]);
            }
            currentShowingItem.Clear();    //将当前显示的item list清空

            if(startIndex != -1)    //说明此时列表中有item需要显示出来
            {
                while(startYPos < maxYPos && startIndex < allItemDataList.Count)      //1.“=”的情况时不需要渲染  2.同时也要考虑元素过少,无法填满viewport的情况
                {
                    ItemCell item = GetItem(startIndex);
                    item.gameObject.SetActive(true);
                    item.gameObject.name = string.Format("item_{0}", startIndex);
                    item.Refresh();
                    //注意:这里设置的是item相对于content的position,所以Y坐标为负值
                    item.transform.localPosition = new Vector3(verticalLayout.paddingLeft, -startYPos, 0);
                    currentShowingItem.Add(item);      //添加进当前渲染的item列表中

                    //下个item的Y坐标
                    ++startIndex;
                    startYPos += item.data.height;
                    startYPos += verticalLayout.gap;
                }
            }

            //回收当前不用的item
            for(int i = 0; i < lastShowingItem.Count; ++i)
            {
                recycleItemPool.Add(lastShowingItem[i]);
                lastShowingItem[i].gameObject.SetActive(false);    //放进回收池中暂不用再显示
            }
        }
    }

    //获取可用的item
    ItemCell GetItem(int index)         //index代表获取到item后,该item当前需要显示的目标index
    {
        //为什么这里不用GameObject,而用自定义的组件ItemCell?
        //解答:ItemCell是自定义的脚本,用于对item物体宽高、msg等进行刷新和显示控制,单纯的GameObject是无法达成这样的效果
        ItemCell item;   
        
        //获取item有两种方式——这个很重要:
        //第一种:在当前item列表中查找
        //当缓慢的上滑滚动条时,为了item频繁刷新导致的视觉疲劳,此时不应该直接去回收池中获取item,而应该先在当前显示的item列表中查询是否有可用的对象。
        //当然直接的刷新item列表是不可避免的,如从当前位置快速的滑动到某地,此时上一帧和当前帧完全没有相同的item,此时只能全部刷新。
        //之所以加入先从当前显示列表中查找的语句,是为了解决缓慢滑动滚动条时的刷新频繁问题。
        for(int i = 0; i < lastShowingItem.Count; ++i)
        {
            if(lastShowingItem[i].data == allItemDataList[index])
            {
                item = lastShowingItem[i];
                lastShowingItem.RemoveAt(i);   //这里找到目标后就结束迭代,因此也不会出现下次迭代时元素的遍历错误,所以可以在这里直接删除集合中的元素
                return item;
            }
        }

        //第二种:在回收池中查找
        //从回收池中查询item时分成三种情况:
        if(recycleItemPool.Count > 0)
        {
            //情况一:为了保证缓慢滑动时视觉上的连贯性,避免频繁刷新导致的视觉疲劳,这里先从回收池中查找是否有相同的元素
            for(int i = 0; i < recycleItemPool.Count; ++i)
            {
                if (recycleItemPool[i].data == allItemDataList[index])
                {
                    item = recycleItemPool[i];
                    recycleItemPool.RemoveAt(i);      //移除该item元素
                    return item;
                }
            }

            //情况二:回收池中没有现成的元素,因此默认取第一个
            item = recycleItemPool[0];
            recycleItemPool.RemoveAt(0);   //从回收池中移除该item
        }
        else                 
        {
            //情况三:回收池中已然没有足够的item,此时只能重新实例化item新对象
            GameObject itemPrefab = Resources.Load("Prefabs/ItemPrefab") as GameObject;
            GameObject go = GameObject.Instantiate(itemPrefab);
            go.transform.SetParent(content);  //设置parent,否则不会在UI中显示出来
            go.transform.localPosition = Vector3.zero;
            item = go.GetComponent<ItemCell>();
        }

        //设置该item的数据信息itemData.
        item.data = allItemDataList[index];

        return item;
    }

    //计算当前content的总size
    void ResizeContentArea()
    {
        //计算content的总height
        float totalHeight = verticalLayout.paddingTop;
        float count = allItemDataList.Count;
        for (int i = 0; i < count; ++i)
        {
            totalHeight += allItemDataList[i].height;
            if (i < count - 1)           //添加item后还需要加上两个item之间的gap
                totalHeight += verticalLayout.gap;
        }
        totalHeight += verticalLayout.paddingBottom;

        //content的宽度暂时不需要改变
        content.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, totalHeight);
    }

    //当滚动滑动列表时
    void OnScrollChanged(Vector2 pos)
    {
        isRefreshItem = true;     //仅仅只是滚动滑动条时,不需要对content的总size进行改变,因此这里无需改变“isRefreshContentSize”

        if (Mathf.Abs(scroll.velocity.y) > 5)        //滑动时暂时隐藏底部标识栏
            sumLabel.gameObject.SetActive(false);
        else                                     //说明滑动基本停止
        {   
            sumLabel.text = "<color=yellow>Total item count is: <b><size=30> " + allItemDataList.Count + " </size></b></color>";
            sumLabel.gameObject.SetActive(true);
        }
    }
    #endregion

    #region 初始化相关操作
    //注意:
    //1.用来计算每个item最理想的width, height,这个对于统计content实际的总size和确定当前帧需要渲染的第一个item的索引至关重要
    //  因为确定第一个item就是根据content的Y轴偏移和每个item的height累计比较,最终得以确定
    //  所以每个item的真实width, height就显得尤为重要
    //2.通常情况只需要在初始时对每个item的宽高计算一次,无需重复计算。除非item的宽高会随着index或其他某些因素而动态改变
    void InitializeAllItemData(string[] allMsg)                  
    {
        //将原始数据整理成目标格式的数据形式
        GameObject go;
        if (recycleItemPool.Count > 0)
            go = recycleItemPool[0].gameObject;
        else
        {
            GameObject itemPrefab = Resources.Load("Prefabs/ItemPrefab") as GameObject;
            go = GameObject.Instantiate(itemPrefab, content);
            ItemCell item = go.GetComponent<ItemCell>();
            recycleItemPool.Add(item);           //由于这个item只是用来计算item真实size,所以可以直接放入回收池中以便下次使用
        }
        go.SetActive(false);      //这里不需要显示出来,只是用来计算item真实width/height时使用

        //由于初始化时有大量的数据需要操作,加上“GetComponent”、“Find”等方法比较耗费CPU资源,因此这里不宜直接使用CalculateSingleItemData来获取单个item的size
        RectTransform itemRect = go.GetComponent<RectTransform>();     
        RectTransform txtRect = go.transform.Find("ItemTxt").GetComponent<RectTransform>();       
        Text msgTxt = go.transform.Find("ItemTxt").GetComponent<Text>();    

        //计算每个item理想的width, height
        float maxItemWidth = content.rect.width - 30;   //item的最大宽度
        float width, height;
        for (int i = 0; i < allMsg.Length; ++i)
        {
            msgTxt.text = allMsg[i];
            width = Mathf.Min(msgTxt.preferredWidth + Mathf.Abs(txtRect.sizeDelta.x), maxItemWidth);   //itemRect能够实现的最合适width
            itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
            height = msgTxt.preferredHeight + Mathf.Abs(txtRect.sizeDelta.y);
            itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);

            ItemData tempItemData = new ItemData(width, height, allMsg[i]);
            allItemDataList.Add(tempItemData);     //整理成目标格式的数据列表
        }
        isRefreshContentSize = true;
        isRefreshItem = true;
    }

    //按钮事件注册
    void InitializeAllBtn()
    {
        msgInput = transform.Find("Left/Input/MsgInput").GetComponent<InputField>();
        indexInput = transform.Find("Left/Input/IndexInput").GetComponent<InputField>();
        addBtn = transform.Find("Left/Buttons/Add").GetComponent<Button>();
        insertBtn = transform.Find("Left/Buttons/Insert").GetComponent<Button>();
        removeBtn = transform.Find("Left/Buttons/Remove").GetComponent<Button>();
        scrollToIndexBtn = transform.Find("Left/Buttons/ScrollToIndex").GetComponent<Button>();
        scrollToEndBtn = transform.Find("Left/Buttons/ScrollToEnd").GetComponent<Button>();
        sumLabel = transform.Find("Left/SumLabel").GetComponent<Text>();

        addBtn.onClick.AddListener(OnAddBtnClick);
        insertBtn.onClick.AddListener(OnInsertBtnClick);
        removeBtn.onClick.AddListener(OnRemoveBtnClick);
        scrollToIndexBtn.onClick.AddListener(OnScrollToIndexBtnClick);
        scrollToEndBtn.onClick.AddListener(OnScrollToEndBtnClick);
    }
    #endregion

    #region 对列表进行增删、插入指定元素、滑动到指定位置等操作
    ItemData CalculateSingleItemData(GameObject go, string msg)          //计算单个item的理想size
    {
        RectTransform itemRect = go.GetComponent<RectTransform>();
        RectTransform txtRect = go.transform.Find("ItemTxt").GetComponent<RectTransform>();
        Text msgTxt = go.transform.Find("ItemTxt").GetComponent<Text>();
        float maxItemWidth = content.rect.width - 30;
        msgTxt.text = msg;
        float width = Mathf.Min(msgTxt.preferredWidth + Mathf.Abs(txtRect.sizeDelta.x), maxItemWidth);
        itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
        float height = msgTxt.preferredHeight + Mathf.Abs(txtRect.sizeDelta.y);
        itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);

        return new ItemData(width, height, msg);
    }

    //往列表中增加元素
    void AddItem(string msg, bool isInsert = false, int index = 0)   
    {
        //直接去回收池中查找是否有可用的item,而不用重新实例化
        GameObject go;
        if (recycleItemPool.Count > 0)
            go = recycleItemPool[0].gameObject;
        else
        {
            GameObject itemPrefab = Resources.Load("Prefabs/ItemPrefab") as GameObject;
            go = GameObject.Instantiate<GameObject>(itemPrefab, content);
            ItemCell item = go.GetComponent<ItemCell>();
            recycleItemPool.Add(item);          //由于这个item是用来计算size,不具备实际刷新的用途,因此可以直接放入回收池以便下次使用
        }
        go.SetActive(false);

        ItemData data = CalculateSingleItemData(go, msg);
        if (isInsert)              //当需要在指定位置插入元素时
            allItemDataList.Insert(index, data);
        else                      //默认在列表末尾添加元素
            allItemDataList.Add(data);
        
        //更新scroll面板
        isRefreshContentSize = true;
        isRefreshItem = true;
    }

    //删除指定位置的元素
    void RemoveItem(int index)
    {
        allItemDataList.RemoveAt(index);
        isRefreshContentSize = true;
        isRefreshItem = true;
    }

    //滑动到指定index的元素位置
    void ScrollToIndex(int index)
    {
        float yPos = 0;
        if (index > 0)             //第一个元素直接设置为0
            yPos += verticalLayout.paddingTop;
        for(int i = 0; i < index; ++i)
        {
            yPos += allItemDataList[i].height;
            //由于通常情况verticalLayout.gap不会很大,因此为了显示效果,保留index上面的gap区间
            if(i < index - 1)
                yPos += verticalLayout.gap;
        }

        //检测yPos是否合理
        if (content.rect.height <= scroll.viewport.rect.height)   //当元素过少,无法填满viewport区域时
            yPos = 0;
        else
        {
            //检测是否已到底端
            if (yPos > content.rect.height - scroll.viewport.rect.height)
                yPos = content.rect.height - scroll.viewport.rect.height;
        }
        content.localPosition = new Vector3(content.localPosition.x, yPos, 0);
        isRefreshItem = true;   //由于对content总size没有影响,所以只改变“isRefreshItem”

        //当需要滑动到特定位置时则需要暂时停止scroll的惯性滑动
        scroll.inertia = false;
        inertiaNum = 0;
    }
    #endregion

    #region 点击按钮执行相关操作
    void OnAddBtnClick()
    {
        string msg = msgInput.text;
        if (string.IsNullOrEmpty(msg))
            msg = "This is <b><size=30>add</size></b> operation.";
        AddItem(msg);
    }

    void OnInsertBtnClick()
    {
        string msg = msgInput.text;
        if (string.IsNullOrEmpty(msg)) 
            msg = "This is <b><size=30>Insert</size></b> operation.";

        int index = 0;
        if (!string.IsNullOrEmpty(indexInput.text))
            int.TryParse(indexInput.text, out index);
        if (index < 0) index = 0;
        if (index > allItemDataList.Count) index = allItemDataList.Count;   //list的插入方法insert其插入的index最大可以为list.count,此时默认在list末尾插入元素

        AddItem(msg, true, index);
    }

    void OnRemoveBtnClick()
    {
        if(allItemDataList.Count > 0)
        {
            int index = 0;
            if (!string.IsNullOrEmpty(indexInput.text))
                int.TryParse(indexInput.text, out index);
            if (index < 0) index = 0;
            if (index >= allItemDataList.Count) index = allItemDataList.Count - 1;

            RemoveItem(index);
        }
    }

    void OnScrollToIndexBtnClick()
    {
        if(allItemDataList.Count > 0)
        {
            int index = 0;
            if (!string.IsNullOrEmpty(indexInput.text))
                int.TryParse(indexInput.text, out index);
            if (index < 0) index = 0;
            if (index >= allItemDataList.Count) index = allItemDataList.Count - 1;

            ScrollToIndex(index);
        }
    }

    void OnScrollToEndBtnClick()
    {
        if(allItemDataList.Count > 0)
        {
            int index = allItemDataList.Count - 1;
            ScrollToIndex(index);
        }
    }
    #endregion


    void OnDestroy()
    {
        addBtn.onClick.RemoveListener(OnAddBtnClick);
        insertBtn.onClick.RemoveListener(OnInsertBtnClick);
        removeBtn.onClick.RemoveListener(OnRemoveBtnClick);
        scrollToIndexBtn.onClick.RemoveListener(OnScrollToIndexBtnClick);
        scrollToEndBtn.onClick.RemoveListener(OnScrollToEndBtnClick);
        scroll.onValueChanged.RemoveListener(OnScrollChanged);
    }
}

#region 垂直方向的布局设置
public class VerticalLayoutSetting
{
    public float paddingTop;     //距离顶部的内边距
    public float paddingBottom;  //距离底端的内边距
    public float gap;           //每个cell之间的间隔
    public float paddingLeft;    //距离左边的内边距

    public VerticalLayoutSetting(float _top, float _bottom, float _gap, float _left)
    {
        paddingTop = _top;
        paddingBottom = _bottom;
        gap = _gap;
        paddingLeft = _left;
    }
}
#endregion

负责单个item刷新以及数据模块的脚本:ItemCell.cs  和 ItemData.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class ItemCell : MonoBehaviour
{
    public ItemData data;    //该itemCell对应的data数据信息

    public void Refresh()
    {
        Text contentTxt = transform.Find("ItemTxt").GetComponent<Text>();
        RectTransform itemRect = this.GetComponent<RectTransform>();
        contentTxt.text = data.msg;
        itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, data.width);
        itemRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, data.height);
    }

}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ItemData
{
    //针对于每个itemData,不需要存储该Item在scrollList中对应的索引值,因为list会有增删操作,所以index有可能会被改变
    public float width;  //该item所使用的prefab的总宽度,并不仅仅是text组件的宽度,还包括Bg等的总宽度
    public float height; //该item所使用的prefab的总高度

    public string msg;   //该item显示的信息

    public ItemData(float _width, float _height, string _msg)
    {
        width = _width;
        height = _height;
        msg = _msg;
    }
}

注意:

1.content的pivot默认为(0, 1),子item的pivot也设定成(0, 1),这样在赋值item.localposition时更为方便

会先对allItemDataList执行相应的增删操作,然后才会开始刷新UI

项目源码链接:UGUI滑动列表优化项目源码-Unity3D文档类资源-CSDN下载

PS:

1.在使用滑动列表时经常会遇到始终无法滑动到底部的问题:

默认情况下向上拖动滑动列表时,可以一直拖动到“Content”的底部与Viewport的底部重合为止如果Content的底部外依然有子item,但该item并没有包含在content的height内,此时就会出现滑动列表无法拖动到底部的情况,如下为content的区域:

unity ScrollView居中 unity scrollview优化_List_03

但是content底部依然有子item:

unity ScrollView居中 unity scrollview优化_unity ScrollView居中_04

如上所示,红色方框的内容虽然在Hierarchy中依然是Content的子对象:

unity ScrollView居中 unity scrollview优化_unity_05

但是却始终无法滑动到底部,将“Item(7)”显示出来。

解决办法:之所以出现这样的问题是因为content的height区域设置有误,并没有包含所有的子对象。上述例子中的情况只需要将“Content”中RectTransform的Height数值增大即可达到效果:

unity ScrollView居中 unity scrollview优化_List_06

如以上将Height增大到“500”后,则ScrollList可以滑动到底端了。

为了精准的解决以上的问题,有两种方法:

方法一:使用UGUI自身为我们提供的两个组件

VerticalLayoutGroup:解决Content下多个item的排列问题

ContentSizeFitter:设置该UI自身的width或者height,当设置“Vertical fit”为“preferred size”时,则UI自身的height会自动被改变

unity ScrollView居中 unity scrollview优化_unity_07

两个组件作用不同,需要配合使用才能解决无法滑动到底端的问题

方法二:这个问题的关键在于Content区域设置错误导致的,所以直接设置Content的rect.height即可。并且VerticalLayoutGroup的作用是解决所有item的排列,可是在某些情况下由于子item的SetActive(true/false)会导致排版强制刷新 —— 关键是某些情况下其实是没有必要刷新的,比如把已经超出viewport边界的子item.SetActive(false)

所以为了避免以上的情况,可以自主的设定子item的排列

这里只考虑Vertical方向上的排列:设置子item的排列主要是考虑item的height和相邻之间的间隔导致Vertical方向上Y坐标的变化。至于X坐标可以设置Pivot为(0, 1), (0.5, 1)或(1, 1)来解决,也可以设置子item的width一致来达到整齐排列的效果

这里在解决了子item垂直方向上排列后,一定要获取到所有子item的总height,将其赋值给Content.rect.height,这才是最重要的。也就是说只要单独计算每个item的排列方式,以及获取所有item的总height/width后赋值给content的rectTransform即可

以上两种方式都可以解决滑动列表无法触底的问题

2.Viewport无法自由设定区域:

UGUI在创建一个ScrollView后,发现其viewport的区域是无法自由设定的,显示部分数值由ScrollRect设定

unity ScrollView居中 unity scrollview优化_宽高_08

unity ScrollView居中 unity scrollview优化_c#_09

因此只需要调节以上的“Spacing”数值即可。

如果只需要做垂直方向上的滚动列表,可以直接这样设置:

unity ScrollView居中 unity scrollview优化_List_10

由于scrollView底部的水平滚动条不需要了,因此viewport的垂直方向上的可视区域会自动增加

unity ScrollView居中 unity scrollview优化_unity_11

  

unity ScrollView居中 unity scrollview优化_List_12

viewport的rectTransform的“H Delta”数值也自动变为0

同理如果不需要VerticalBar也是一样的方法。当不需要滚动条或者需要调整viewport的区域时,可以调整“Spacing”数值,或者直接scrollbar对象都可以达到效果

3.Anchors和Pivot的区别:

Pivot解决的是在设置UI自身的position和宽高时以此pivot为基准点进行拉伸,position的坐标值就是该pivot在世界空间中的坐标值,至于拉伸的方向和范围则需要根据pivot的设置来变化,而Anchors是为了解决同一个UI界面在不同分辨率的设备上显示时,与父级UI直接的自适应关系,根据anchors中设定的与父级UI之间的偏移来显示该UI

而由于UGUI中的渲染顺序是从上到下依次渲染,因此也说明必然是先渲染父级UI,后渲染子UI,又因为anchors设置中父级UI和子UI之间的偏移关系,导致子UI的position和宽高有可能会受到影响。所以在某些情况下为了避免子UI因为适配而变形,直接设置父级UI的宽高即可,如道具tips的UI就是这样的原理。

并且Anchors中设置的只是一种大致上的偏移关系,具体的数值可以在RectTransform中详细设置,所以也给予了很大的灵活空间。Anchors主要解决的是不同分辨率下的适配问题,如果所有设备屏幕分辨率都一样,那anchors其实没有多大意义

unity ScrollView居中 unity scrollview优化_unity_13

unity ScrollView居中 unity scrollview优化_宽高_14

4.如果UGUI中的text组件是不显示的,那么其 text.preferredWidth 和 text.preferredHeight属性是否可以获取到正确的数值?

解答:即使该Text组件在Hierarchy中不显示,只要设置“msg.text = ... ”,就可以获取到该text的preferredWidth 和 preferredHeight数值。

因此在某些需要预先计算UI的宽高用于事先调整整体layout的功能,这个可以起到很重要的作用

验证如下:

string msg = File.ReadAllText(Application.dataPath + "/Resources/Files/ModernPoems.txt");
string[] allMsg = msg.Split(new string[] { "##" }, System.StringSplitOptions.None);

GameObject go = Resources.Load("Prefabs/ItemPrefab") as GameObject;
Text contentTxt = go.transform.Find("ItemTxt").GetComponent<Text>();
contentTxt.text = allMsg[0];
Debug.Log("<color=yellow>   get data: " + contentTxt.preferredWidth + "   " + contentTxt.preferredHeight + "    </color>");

“ItemPrefab”为false的状态,运行后结果如下:

unity ScrollView居中 unity scrollview优化_unity_15

从以上结果得知,甚至不需要实例化,只要为对应的UI组件赋值后即可获取到preferredWidth, preferredHeight。但默认情况不建议直接在prefab上改变数值,可以在Instantiate后的实例上修改

5.如何在滑动过程中立即停止滚动条的惯性滑动?

在开发中通常会遇到这样一种情况:在拖动scroll时突然增加一个需求,要马上滑动到某个特定的位置,但由于scrollRect通常开启了惯性,所以即使停止拖动,scroll依然会因为惯性而向之前的方向继续滑动。

此时就需要提供一个方法能够马上停止scrollRect惯性滑动的方法。

scrollRect有一个属性“inertia”用于是否开启惯性滑动

重点:

1.inertia控制滑动惯性,对于每一帧画面绘制的情况以LateUpdate中情况为准

void Update()
{
	scroll.inertia = true;
}

void LateUpdate()
{
	scroll.inertia = false;
}

此时展现出来的画面是没有惯性滑动的,但是如果改变顺序:

void Update()
{
	scroll.inertia = false;
}

void LateUpdate()
{
	scroll.inertia = true;
}

此时在当前帧,scroll始终是具有滑动惯性的,完全不会呈现惯性暂时消失的情况。

因此如果要实现上述“暂时停止惯性”的需求,则“scroll.inertia = true”不能在当前帧被执行

2.“scroll.inertia”的好处在于如果设置“scroll.inertia = false”后,再次开启“scroll.inertia = true”,此时并不会继续上次滑动的效果。也就是说在“scroll.inertia = false”后,所有与本次滑动相关的惯性数据都清空了,再次“scroll.inertia = true”则是全新的滑动,不会对下次滑动有任何影响。前提:滑动的关闭和开启不能在同一帧

3.滑动惯性的关闭“scroll.inertia = false”和开启“scroll.inertia = true”,两者的执行至少相隔一帧

6.如何控制在scroll滑动时隐藏某个物体,然后在滑动停止后再显示这个对象?

之前在做功能开发时有个需求,就是在滚动条滑动时隐藏底部的标识栏,等到滑动停止后再次显示该对象。

那么要实现这个对象就需要使用scroll的另一参数“scroll.velocity”,通过该参数数值来判定滑动的开始和结束。注意这里的滑动指的是content的滑动,并不会拖动drag的开始和结束,由于scrollRect的惯性,通常OnEndDrag后,该content依然会由于惯性继续滑动。所以不能使用“OnEndDrag”来判定滑动的结束

void OnScrollChanged(Vector2 pos)
{
	if (Mathf.Abs(scroll.velocity.y) > 5)    
		sumLabel.gameObject.SetActive(false);
	else                                       //说明滑动基本停止
	{   
		sumLabel.text = "<color=yellow>Total item count is: <b><size=30> " + allItemDataList.Count + " </size></b></color>";
		sumLabel.gameObject.SetActive(true);
	}
		
	Debug.Log("<color=yellow>  " + pos + "   </color>  " + Mathf.Abs(scroll.velocity.y) + "  " + scroll.velocity);
}

运行结果如下:

unity ScrollView居中 unity scrollview优化_c#_16

从以上结果可知:scroll.velocity通常会对x, y值进行四舍五入操作,但并不完全与“Mathf.Round”结果相同。—— 上述代码中,对于判断滑动基本停止的时机也可以根据需要自由设定

Mathf.Round: 通常使用四舍五入的方式返回与目标参数最近的int整数的float形式(Mathf.RoundToInt 才是直接返回int形式),并且与正负符号无关。但是需要注意的是:当小数点第一位为5时,会根据个位数的奇偶来返回不同的数值,偶数进1,奇数保持不变。验证代码和运行结果如下:

Debug.Log("<color=blue> " + Mathf.Round(4.5f) + "   " + Mathf.RoundToInt(4.5f) + "   " + Mathf.Round(1.5f) + "   " + Mathf.RoundToInt(1.5f) + "  </color>");

unity ScrollView居中 unity scrollview优化_List_17

在开发中有时候需要对某些数据进行取整,会涉及到向上或向下两种方式:

Mathf.FloorToInt:向下取整,返回不大于参数的最大整数。(与Mathf.Floor的唯一区别在于返回值的形式,数值完全相同,前者为数值的int形式,后者为float形式)

Mathf.CeilToInt:向上取整,返回不小于参数的最小整数

Debug.Log("<color=blue> " + Mathf.Floor(4.6f) + "   " + Mathf.Floor(-4.6f) + "  </color>");

unity ScrollView居中 unity scrollview优化_c#_18

注意:这两种取整方式会根据不小于、不大于来返回结果,与参数的正负符号没有直接关系

7.GameObject.Find 和 transform.Find的区别:

当该GameObject.activeInHierarchy = false时,使用“GameObject.Find”查找该对象时,返回结果始终为null;

transform.Find可以查找隐藏对象,但只能在子对象中查找

所以通常需要查找某个对象时,使用transform.find,而非GameObject.Find

8.代码书写时其他一些小Tips:

1.将某个string值转换成int类型时,使用int.Parse,int.TryParse的区别:

string num = "hello";
int result = 0;
int.TryParse(num, out result);
Debug.Log("<color=yellow>  " + result + "  </color>");

int result2 = int.Parse(num);
Debug.Log("<color=yellow>  " + result2 + "  </color>");

运行结果如下:

unity ScrollView居中 unity scrollview优化_unity ScrollView居中_19

总结:从以上结果知晓,直接使用“int.Parse”当参数无法被转换成int数值时会报错,而“int.TryParse”则增加了一个缓冲机制,当参数可以被转换时得到正确的数值,如果无法被转换则使用默认数值,避免了直接报错,影响后续代码执行建议使用“int.TryParse”

2.在声明一个方法时如何为方法中的参数设定默认值,以及传参的顺序?

在写代码时有时为了方便会给某些方法中的参数设定默认值,在调用该方法时,参数传递的顺序需要注意:

1.默认将不指定默认数值的参数写在前面,指定默认值的参数写在后面。在调用方法传参时,按照先后顺序依次赋值

2.当需要越过当前参数直接给后面的参数赋值时需要指定参数的名称,并加冒号

void TestMethod(int num, string str = "hello", string str2 = "world")
{
	Debug.Log("<color=yellow>  " + num + "   " + str + "    " + str2 + "   </color>");
}

.................................

TestMethod(1);
TestMethod(2, "Today");
TestMethod(3, str2: " is a good day");     
//注意:当越过参数“str”直接给“str2”传值时需要注明参数名称

运行结果如下:

unity ScrollView居中 unity scrollview优化_unity_20

3.同一方法体中for循环的i值是否有必要重命名?:

for(int i = 0; i < 5; ++i)
{
	Debug.Log("<color=yellow>  11111  " + i + "   </color>");
}

//Debug.Log("<color=blue>  " + i + "  </color>");   //这里没有参数“i”,直接会报错

for(int i = 0; i < 6; ++i)
{
	Debug.Log("<color=yellow> 2222222  " + i + "   </color>");
}

运行结果如下:

unity ScrollView居中 unity scrollview优化_c#_21

  

unity ScrollView居中 unity scrollview优化_c#_22

从以上结果知道:for循环其实就相当于一个局部的方法,其内部结构中声明的变量“i”在循环执行完毕时即被释放,因此也无法在for外再调用其“i”变量,所以也不会影响下一个for循环声明同一name的变量“i”