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();}// 发起第二次请求,将获取到的数据设置到Itemawait 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; // 预加载可视列表附近的几个Itempublic 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中进行取消即可。
结论
同时,项目还采取了其他的优化机制,比如,使用数据缓存,请求过的数据,在一定时间内再次使用时无需再次请求,还有使用对象池等奇数,经过上述优化后,项目中的列表非常丝滑,加载无感,进度条也删去了。