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中進行取消即可。
結論
同時,項目還采取了其他的優化機制,比如,使用數據緩存,請求過的數據,在一定時間內再次使用時無需再次請求,還有使用對象池等奇數,經過上述優化后,項目中的列表非常絲滑,加載無感,進度條也刪去了。