Unity动态列表+UniTask异步数据请求

Unity动态列表+UniTask异步数据请求

很久没有写东西了。最近有一个需求,在Unity项目里,有几个比较长的列表,经历了一翻优化,趁这几日闲暇,记录下来,给自己留个笔记,也送给有缘之人共同探讨吧。
以其中一个列表为例,其大体需求是:首先向后台请求列表数据,后台会反馈一个列表,本质上是数据的id数组。然后,由于数据项很复杂,所以要知道某条信息的具体数据,还要以id为参数,再次向后台请求关于这条数据的具体数据。即:

  • 第一次只请求列表数据:
    Reuest:getCableList?key=filter
    Response:{ success: true, message: null, datas: [ 1, 2, 5, 10, 12 ] }
  • 第二次请求详细数据:
    Request: getCableData?id=5
    Response: { success: true, message: null, data: [{ name: “西线机房至汇聚光交箱”, descript: “Some Text” …},…]}

一、异步加载

现成的有UniTask和协程方案,学习了下前人总结的UniTask之后,感觉UniTask比协程好:第一,性能好,更少的GC;第二,更灵活,体现在能很方便的做取消、超时管理、提供更多的yield时机选择、而且还不需要MonoBehaviour;第三,写起来代码来更人性化;第四,免费,没有额外的代价。所以,UniTask确实很好。
第一个版本的代码如下,主要思路是:从后台请求列表数据,获取到列表之后,分批进行第二次请求,然后将数据加载到列表中,实例化Item并更新UI。

// 用POST方法请求文本数据
private static async UniTask<string> RequestTextWithPostMethod(string url, Dictionary<string, string> data, float waitTime=3f)
{
    try
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        cts.CancelAfterSlim(TimeSpan.FromSeconds(waitTime));
        var request = await UnityWebRequest.Post(url, data)
            .SendWebRequest()
            .WithCancellation(cts.Token);
        return request.result == UnityWebRequest.Result.Success ? request.downloadHandler.text : null;
    }
    catch
    {
        return null;
    }
}

private readonly ConcurrentDictionary<int, CableItem> activeItems = new();
// 更新列表数据
private void UpdateList(string key)
{
	try
	{
		// 向后台请求列表数据
		string json = await RequestTextWithPostMethod("http://demo.myhost.com/unity/getCableList",
			new Dictionary<string, string> { { "key", key } });
		if (string.IsNummOrEmpty(json))
			throw new Exception(serverErrorMessage);
		var res = JsonConvert.DeserializeObject<CableListResponse>(json);
		if (!res.success)
			throw new Exception(res.message);

		HashSet<int> ids = new(res.datas);
		// 如果已实例化的项不在请求结果中,则清除它们
		foreach (var aid in activeItems.Keys.Where(aid => !ids.Contains(aid)))
		{
			itemPool.Release(activeItems[aid]);
			activeItems.TryRemove(aid, out _);
		}
		int allCount = res.datas.Count;
		int total = 0;
		// 每5个为一批,按批次异步请求数据,避免并发量太大
		foreach (var chunk in res.datas.Chunk(5))
		{
			var tasks = chunk.Select(async id =>
			{
				// 如果该ID未在活动列表中,则实例化该项
				if (!activeItems.TryGetValue(id, out CableItem item))
				{
					item = cableItemPool.Get();
					activeItems.TryAdd(id, item);
					item.transform.SetAsLastSibling();
				}
				// 发起第二次请求,将获取到的数据设置到Item
				await GetCableData(id).ContinueWith(cable =>
				{
					item.SetCableData(cable);
				});
				GlobalProgressBar.SetValue(0.1f + 0.9f * (++total / (float)allCount));
			});
			// 如果该批次已完成,则下一帧发起下一个批次
			await UniTask.WhenAll(tasks);
			await UniTask.Yield();
		}
	}
	catch (Exception e)
	{
		errorMessageText.text = e.Message;
	}
	finally
	{
		isRequsting = false;
	}
}

经测试,上述代码确实挺好,在数据请求时,对帧率几乎没有影像。但是,它还是不够好,当列表非常大时,更新一次数据总体上还是需要很久,更要命的是,由于它列表项太多,使用原生的Scroll View组件会严重影像性能,当切换UI页面(需要关闭或激活ScrollView时操作明显有粘滞感)。想到的解决方案有二:

  • 其一,分页。每次之请求一部分数据,肯定能改善操作,但是需要后台也同步改为分页支持,而且需要增加上一页、下一页、页面展示等按钮还有逻辑,还会让操作更复杂,不太符合原需求。
  • 其二,优化ScrollView。必然选这个。

二、动态Scroll View

这并不是我的首创,早有各种大神实现过了。它思路很简单,Scroll View同时可视的Item是有限的,只需要保证能看见的Item处于激活态就好,其余的可以禁用掉。进一步优化下就是,保留可视列表项的前几个和后几个项激活,以便优化滚动。首先实现一个动态的超级ScrollView:

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

namespace HXDynamicScrollView
{
    [RequireComponent(typeof(ScrollRect))]
    public abstract class DynamicScrollView<TData> : MonoBehaviour
    {
        [SerializeField] private DynamicScrollItem<TData> ItemPrefab;
        public float ItemHeight = 80f;		// 项的高度
        public float ItemSpacing = 10f;		// 项之间的间距
        public int PreheatCount = 10;		// 预加载可视列表附近的几个Item

        public TData[] DataArray { get; private set; }		// 数据列表
        public int totalItemCount => DataArray?.Length ?? 0;
        
        private ScrollRect m_scrollRect;
        private RectTransform m_viewport;
        private RectTransform m_content;
        private float contentHeight;
        private float ItemHeightWithSpcaing => ItemHeight + ItemSpacing;  // 每个项包括间距的高度
        private int currentFirstIndex = -1;
        private int currentLastIndex = -1;

        private readonly Dictionary<int, DynamicScrollItem<TData>> activeItems = new();  // 活动的项
        private ObjectPool<DynamicScrollItem<TData>> itemPool;	// Item对象池

        public delegate void OnItemInstancedHander(DynamicScrollItem<TData> item);
        public event OnItemInstancedHander OnItemInstanced;
        public delegate void OnItemActivedHandler(DynamicScrollItem<TData> item);
        public event OnItemActivedHandler OnItemActived;
        public delegate void OnItemRecycledHandler(DynamicScrollItem<TData> item);
        public event OnItemRecycledHandler OnItemRecycled;
        public delegate void OnBeforeItemDataChangedHander(TData[] datas);
        public event OnBeforeItemDataChangedHander OnBeforeItemDataChanged;

        private void Awake()
        {
            itemPool = new ObjectPool<DynamicScrollItem<TData>>(
                () =>
                {
                    var item = Instantiate(ItemPrefab, m_content);
                    OnItemInstanced?.Invoke(item);
                    return item;
                },
                item =>
                {
                    item.gameObject.SetActive(true);
                    OnItemActived?.Invoke(item);
                },
                item =>
                {
                    OnItemRecycled?.Invoke(item);
                    item.gameObject.SetActive(false);
                },
                item => Destroy(item.gameObject));

            m_scrollRect = GetComponent<ScrollRect>();
            m_viewport = m_scrollRect.viewport;
            m_content = m_scrollRect.content;
            m_scrollRect.onValueChanged.AddListener(OnScrollViewChanged);

            m_content.anchorMin = new Vector2(0, 1);
            m_content.anchorMax = new Vector2(1, 1);
            m_content.pivot = new Vector2(0.5f, 1);
            m_content.sizeDelta = new Vector2(0, 0);
        }
        
		// 滚动条滚动事件
        private void OnScrollViewChanged(Vector2 _)
        {
            UpdateVisibleItems();
        }
        
        // 设置数据项
        public void SetDataList(IEnumerable<TData> dataList)
        {
            if(DataArray is {Length: > 0 })
                OnBeforeItemDataChanged?.Invoke(DataArray);
            DataArray = dataList.ToArray();
            CalculateContentHeight();
            UpdateVisibleItems(true);
        }

		// 计算内容高度
        private void CalculateContentHeight()
        {
            contentHeight = totalItemCount * (ItemHeight + ItemSpacing) + ItemSpacing;
            m_content.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, contentHeight);
        }

		// 更新可视的项
        private void UpdateVisibleItems(bool bForce = false)
        {
        	// 如果数据是空的,则清理现存的并直接返回
            if (DataArray is not { Length: > 0 })
            {
                foreach (var item in activeItems)
                {
                    item.Value.Hide();
                    itemPool.Release(item.Value);
                }
                activeItems.Clear();
                return;
            }
            
            var viewportTop = m_content.anchoredPosition.y;
            var viewportBottom = viewportTop + m_viewport.rect.height;

            // 计算可视项前后预加载项的索引
            int newFirstIndex = Mathf.Max(0,
                Mathf.FloorToInt((viewportTop - PreheatCount * ItemHeightWithSpcaing) / ItemHeightWithSpcaing));
            int newLastIndex = Mathf.Min(totalItemCount - 1,
                Mathf.CeilToInt((viewportBottom + PreheatCount * ItemHeightWithSpcaing) / ItemHeightWithSpcaing));

			// 如果不需要更新则返回
            if (!bForce && currentFirstIndex == newFirstIndex && currentLastIndex == newLastIndex)
                return;

			// 清理需要删除的项
            List<int> toRemove = new();
            foreach (var item in activeItems.Where(item => item.Key < newFirstIndex || item.Key > newLastIndex))
            {
                item.Value.Hide();
                itemPool.Release(item.Value);
                toRemove.Add(item.Key);
            }
            foreach (var index in toRemove)
                activeItems.Remove(index);

			// 激活可视或可视附近的,即需要预加载的项
            for (int i = newFirstIndex; i <= newLastIndex; i++)
            {
                if (!activeItems.ContainsKey(i))
                {
                    var item = itemPool.Get();
                    item.SetDataAndShow(DataArray[i]);
                    PlaceItem(i, item);
                    activeItems.Add(i, item);
                }
            }
            currentFirstIndex = newFirstIndex;
            currentLastIndex = newLastIndex;
        }
        
        // 放置项
        private void PlaceItem(int index, DynamicScrollItem<TData> item)
        {
            float yPos = -index * ItemHeightWithSpcaing - ItemSpacing;
            item.anchoredPosition = new Vector2(0, yPos);
        }
    }
}
using UnityEngine;
using UnityEngine.EventSystems;

namespace HXDynamicScrollView
{
    public abstract class DynamicScrollItem<TData> : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
    {
        public virtual void Hide()
        {
        }

        public abstract void SetDataAndShow(TData data);

        public Vector2 anchoredPosition
        {
            get => ((RectTransform)transform).anchoredPosition;
            set=> ((RectTransform)transform).anchoredPosition = value;
        }

        protected bool IsMouseHover { get; private set; }
        public virtual void OnPointerEnter(PointerEventData eventData)
        {
            IsMouseHover = true;
        }

        public virtual void OnPointerExit(PointerEventData eventData)
        {
            IsMouseHover = false;
        }
    }
}

上面代码就是全部的超级ScrollView了。
以上面的CableList为例,使用它,变成异步动态加载的超级列表。每次滚动时,只会有非常少量的项被激活,所以无需分批,直接异步求情数据即可。

public class CableItem : DynamicScrollItem<int>
{
	// 这里省略了一些其他的代码

    public CableData Data { get; private set; }

    private void SetCableData(CableData data)
    {
        Data = data;
        if (data != null)
        {
            // 将数据显示到UI组件上
        }
    }

	// 当Item处于预加载或可视时,被调用,请求数据
    public override void SetDataAndShow(int data)
    {
        UpdateData(data).Forget();
    }
    
    private async UniTaskVoid UpdateData(int id)
    {
        var cableInfo = await GetCableData(id);
        if(cableInfo!=null)
            SetCableData(cableInfo);
    }
	private static async UniTask<CableData> GetCableData(int id)
	{
		// 如果数据已存在,并且数据处于有效期内,则直接返回
	    if (cables.TryGetValue(id, out CableData cable))
	    {
	        if (cable.IsEditing || Time.time - cable.lastUpdatetime < 300f)
	            return cable;
	    }
	
		// 向后台请求数据
	    var json = await RequestTextWithPostMethod(urlGetCableInfo,
	        new Dictionary<string, string> { { "id", id.ToString() } });
	    try
	    {
	        if (string.IsNullOrEmpty(json))
	            throw new Exception(serverErrorMessage);
	        var cableData = JsonConvert.DeserializeObject<CableInfo>(json, JsonSettings);
	        if (!cableData.success)
	            throw new Exception(cableData.message);
	        cableData.data.lastUpdatetime = Time.time;
	        cableData.data.IsEditing = false;
	        cables.AddOrUpdate(id, cableData.data, (_, _) => cableData.data);
	        return cableData.data;
	    }
	    catch
	    {
	        return null;
	    }
	}
}

上述Item还有进一步优化的空间,如,极端情况下,滚动速度很快,或网络情况不好的情况下,可能数据请求还未返回,Item就由可是状态变为非可视状态,此时,可以很容易的增加取消机制。在disable中进行取消即可。

结论

同时,项目还采取了其他的优化机制,比如,使用数据缓存,请求过的数据,在一定时间内再次使用时无需再次请求,还有使用对象池等奇数,经过上述优化后,项目中的列表非常丝滑,加载无感,进度条也删去了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

示申○言舌

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值