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