问题抛出
Unity的UI从NGUI到UGUI一直都在进化中,但是一直都是运行效率低。一个简洁的游戏界面会令游戏运行很舒服,但是有时候游戏就是需要大量的UI。
今天我们不谈UI的优化效率方法,网络上一搜一大把各种优化,今天我来分享下关于我们的游戏优化ScrollView的替代办法。
我们当前项目是一个轻量游戏。很大的比重都是UI,特别是各种List,Grid,Page翻页,滚动的Banner。
在早期Demo发布的时候,用的系统的ScrollView,大家知道这个显示一些数量不多的还能接受,如果数据量超大,那么就要用一些缓存逻辑来显示。而且那个拖动手感弱到不能接受。
解决办法
- 早期方案:
再一开始使用了store商店里的SupperScrollView组件(如下图),能够很好的缓存数据,优化显示,有几种显示方案,如下图:
他是基于UGUI系统ScrollView,有池管理,用最少的实例化对象来显示数据,算的上很容易上手的一个组件。基本上两个函数就可以显示数据出来。
因为早期的我们游戏UI也相对简单,只有一个主列表和固定的Banner,这个方案总的来说过得去。
随着UI界面的复杂,Banner需要能横向滚动,Grid列表不但可以上下滑动还要可以左右翻动,要有Page的概念。还有比较麻烦的顶部Banner和底部Grid拖拉关系的变化,这就需要改动Drag相关底层函数,SupperScrollView已经很难去扩展了。加上之前效率测试中发现ScrollView组件本身占用CPU耗费过大,如果再多几个ScrollView,那么不可想象,所以又有了下面的解决方案。
- 最终优化方案
经过大量翻找,要排除用系统自己的ScrollView,最好是自己重写ScrollView,终于找到了这个(OSA)Optimized ScrollView Adapter,貌似用过的人不多,看到好评不错,尝试了下感觉可以使用。
OSA完全抛弃了系统ScrollView,完全重写了列表,表格,并加了各种扩充,尽量表现的接近原生手感和效率,还有拖动特效等。
经过几天的测试和使用,在摸透了大部分功能后应用到项目里,发现效率更加的好,而且手感逼近原生,开心:)。
But,他并不适合Unity新手使用,他的模板类能创建简单的脚本模板,如果要加自己的东西,要改大量的代码。如果你有一颗挑战的心可以尝试下阅读和修改他的源码,进行学习和扩充。
https://docs.google.com/document/d/1OXxId2McwZ162Mm2VSd8BTvyjYjsgskT1BWkRF00wXU/edit
这个链接是我把OSA的文档的部分机翻和手动改正。内容主要是一步一步教你怎么能快速上手,后面的QA模块可能能遇到你想问的问题。这个建议还是要看一下的。或者你可以在他的网站上直接看最新的英文版手册。
学习和使用她
一些Unity初学者拿到OSA,看了帮助文档,照着做了,发现要改动下无从下手。那么本教程就是带你大致了解下他的框架,方便修改脚本。
首先看他的帮助文档,就是上文的地址。他的指引教程其实也只是创建了脚本,但是脚本的内容修改,还是要靠自己的理解。
所以这里我对他的源码进行加入一些我的理解分析,帮助大家能快速知道在什么地方改什么东西。也是写本文的另一个目的。
首先,你想要什么?
一个Grid还是一个List?区别就是Grid是表格的,支持一行多个,List只有一列。
所以在官方文档引导创建中也有选择:GridSRIA还是ListSRIA。他们创建出来的类是不同的
下面我们先从Grid来说,相比List稍微简单些。因为复杂的Grid脚本已经被他封装到基类了。
源码部分片段来自我自己的测试用例:
- GridAdapter
// You should modify the namespace to your own or - if you're sure there won't ever be conflicts - remove it altogether
namespace Thinbug
{
// There is 1 important callback you need to implement, apart from Start(): UpdateCellViewsHolder()
// See explanations below
public class MainGridAdapter : GridAdapter<GridParams, MainGridItemViewsHolder>
{
// Helper that stores data and notifies the adapter when items count changes
// Can be iterated and can also have its elements accessed by the [] operator
public SimpleDataHelper<MainGridItemModel> Data { get; private set; }
#region GridAdapter implementation
public void InitGridAdpter()
{
Data = new SimpleDataHelper<MainGridItemModel>(this);
// Calling this initializes internal data and prepares the adapter to handle item count changes
base.Start();
}
// This is called anytime a previously invisible item become visible, or after it's created,
// or when anything that requires a refresh happens
// Here you bind the data from the model to the item's views
// *For the method's full description check the base implementation
protected override void UpdateCellViewsHolder(MainGridItemViewsHolder newOrRecycled)
{
// In this callback, "newOrRecycled.ItemIndex" is guaranteed to always reflect the
// index of item that should be represented by this views holder. You'll use this index
// to retrieve the model from your data set
MainGridItemModel model = Data[newOrRecycled.ItemIndex];
newOrRecycled.listone.InitListOne(gameRoot.inst.HeroPlayer, XMLRoot.inst.levelXMLDict[model.idx], 1 , model.listid);
LoadRoot.inst.ListOneLoad(newOrRecycled.listone.xml.fullpath, "png", newOrRecycled.listone);
}
// This is the best place to clear an item's views in order to prepare it from being recycled, but this is not always needed,
// especially if the views' values are being overwritten anyway. Instead, this can be used to, for example, cancel an image
// download request, if it's still in progress when the item goes out of the viewport.
// <newItemIndex> will be non-negative if this item will be recycled as opposed to just being disabled
// *For the method's full description check the base implementation
protected override void OnBeforeRecycleOrDisableCellViewsHolder(MainGridItemViewsHolder inRecycleBinOrVisible, int newItemIndex)
{
if (inRecycleBinOrVisible.listone != null)
{
inRecycleBinOrVisible.listone.Clear();
}
base.OnBeforeRecycleOrDisableCellViewsHolder(inRecycleBinOrVisible, newItemIndex);
}
#endregion
// These are common data manipulation methods
// The list containing the models is managed by you. The adapter only manages the items' sizes and the count
// The adapter needs to be notified of any change that occurs in the data list.
// For GridAdapters, only Refresh and ResetItems work for now
#region data manipulation
public void AddItemsAt(int index, IList<MainGridItemModel> items)
{
//Commented: this only works with Lists. ATM, Insert for Grids only works by manually changing the list and calling NotifyListChangedExternally() after
//Data.InsertItems(index, items);
Data.List.InsertRange(index, items);
Data.NotifyListChangedExternally();
}
public void RemoveItemsFrom(int index, int count)
{
//Commented: this only works with Lists. ATM, Remove for Grids only works by manually changing the list and calling NotifyListChangedExternally() after
//Data.RemoveRange(index, count);
Data.List.RemoveRange(index, count);
Data.NotifyListChangedExternally();
}
public void SetItems(IList<MainGridItemModel> items)
{
Data.ResetItems(items);
}
#endregion
public void OnDataRetrieved(MainGridItemModel[] newItems)
{
//Commented: this only works with Lists. ATM, Insert for Grids only works by manually changing the list and calling NotifyListChangedExternally() after
// Data.InsertItemsAtEnd(newItems);
Data.List.Clear();
Data.List.AddRange(newItems);
Data.NotifyListChangedExternally();
//刷新位置
SetNormalizedPosition(_lastpos);
}
}
// Class containing the data associated with an item
public class MainGridItemModel : FrameBaseModel
{
public int idx;
public int listid;
}
// This class keeps references to an item's views.
// Your views holder should extend BaseItemViewsHolder for ListViews and CellViewsHolder for GridViews
// The cell views holder should have a single child (usually named "Views"), which contains the actual
// UI elements. A cell's root is never disabled - when a cell is removed, only its "views" GameObject will be disabled
public class MainGridItemViewsHolder : CellViewsHolder
{
public SvgOne listone;
//public Text titleText;
// Retrieving the views from the item's root GameObject
public override void CollectViews()
{
base.CollectViews();
// GetComponentAtPath is a handy extension method from frame8.Logic.Misc.Other.Extensions
// which infers the variable's component from its type, so you won't need to specify it yourself
views.GetComponentAtPath("listone", out listone);
//views.GetComponentAtPath("TitleText", out titleText);
}
}
}
- 头部结构说明
public class MainGridAdapter : GridAdapter<GridParams, MainGridItemViewsHolder>
MainGridAdapter ,是我的类的名字,Adapter表示他一种显示数据的方法。继承自GridAdapter(OSA内置)
GridParmams(OSA内置),这个你可以理解是对应的Prefab
MainGridItemViewsHolder ,是一个View,也就是对应的一个Grid的显示组件集合的布局、
public SimpleDataHelper Data 这个是所有数据的集合。
所以你就明白了OSA他是把Data,View,Prefab配合Adapter来处理的。列表模式也是如此。
初始化函数InitGridAdpter() ,我更喜欢在系统开始是我自己调用初始化,而不是Start函数,所以这里和模板里不一样。
调用过Init后,那么我的Adapter就准备好了,随时数据可以进入了。
这里注意下,再初始化数据的时候,组件本身一定是selfActive = true 才行。
- 数据初始化
//初始化数据
......
List<MainGridItemModel> listitem = new List<MainGridItemModel>();
for (int i = 0; i < list.Length; ++i)
{
var model = new MainGridItemModel()
{
idx = list[i],
listid = pageid
};
listitem.Add(model);
}
_gridAdapter.OnDataRetrieved(listitem.ToArray());
if (iScrollTo != -1)
{
_gridAdapter.ScrollTo(iScrollTo);
}
数据初始化调用OnDataRetrieved后,整个Adapter就开始运作起来了。
GridAdapter的核心只有UpdateCellViewsHolde,所以在这里你就可以刷新(update)数据就可以了。
注意你的组件大小,他会自动适配是几行几列,方便适配不同设备。开心吧,适配省心了。
这里不打算说Adapter里的其他函数,下面的List 里 我们再细讲。
只需要知道,只要在UpdateCellViewsHolder里刷新数据就可以了。
newOrRecycled.listone.InitListOne 就是用来初始化这个Grid数据 。
这个listone就是我的另外一个脚本,每个Grid有一个,能快速访问UGUI组件,写一些交互方法等,如下图:
下面来说一下稍微复杂点的List类的框架
- ListAdapter
// You should modify the namespace to your own or - if you're sure there won't ever be conflicts - remove it altogether
namespace Thinbug
{
// There are 2 important callbacks you need to implement, apart from Start(): CreateViewsHolder() and UpdateViewsHolder()
// See explanations below
public class MainListAdapter : OSA<MainParamsWithPrefab, FrameBaseView>
{
// Helper that stores data and notifies the adapter when items count changes
// Can be iterated and can also have its elements accessed by the [] operator
public SimpleDataHelper<FrameBaseModel> Data { get; private set; }
#region OSA implementation
public void InitMainListAdapter(RectTransform freeze = null, float _freezeMoveHeight = 0f)
{
Data = new SimpleDataHelper<FrameBaseModel>(this);
// Calling this initializes internal data and prepares the adapter to handle item count changes
base.Start();
}
// This is called initially, as many times as needed to fill the viewport,
// and anytime the viewport's size grows, thus allowing more items to be displayed
// Here you create the "ViewsHolder" instance whose views will be re-used
// *For the method's full description check the base implementation
protected override FrameBaseView CreateViewsHolder(int itemIndex)
{
var modelType = Data[itemIndex].CachedType;
if (modelType == typeof(FrameBannerModel))
{
//用来存放顶部Banner的View
var vh = new FrameBannerView();
vh.Init(_Params.bannerFramePrefab, _Params.Content, itemIndex);
if (Data[itemIndex].dataType == 1)
{
if (BannerFreezeTrm != null)
{
bannerFrameView = vh; //因为只有一个bannerview所以,这里锁定可以用这个
}
}
return vh;
}
if (modelType == typeof(FramePageMainModel))
{
//用来存放Page翻页的
var vh = new FramePageMainView();
vh.Init(_Params.pageFramePrefab, _Params.Content, itemIndex);
return vh;
}
// If you want to avoid ifs, you could use a dictionary with model type as key and a Func<int, SimpleBaseHV> as value
// which would point to a separate method for each model type. Then here simply return _Map[modelType](itemIndex)
throw new InvalidOperationException("Unrecognized model type: " + modelType.Name);
}
public void OnDataRetrieved(FrameBaseModel[] newItems)
{
Data.ResetItems(newItems);
}
// This is called anytime a previously invisible item become visible, or after it's created,
// or when anything that requires a refresh happens
// Here you bind the data from the model to the item's views
// *For the method's full description check the base implementation
protected override void UpdateViewsHolder(FrameBaseView newOrRecycled)
{
// In this callback, "newOrRecycled.ItemIndex" is guaranteed to always reflect the
// index of item that should be represented by this views holder. You'll use this index
// to retrieve the model from your data set
FrameBaseModel model = Data[newOrRecycled.ItemIndex];
newOrRecycled.UpdateViews(model);
// This allows items to have different sizes by calling UpdateItemSizeOnTwinPass() for each of them after the current ComputeVisibility pass.
// We're always calling it just for simplicity, but usually you'd only call it if you detect the item's size has changed (in our case
// this can only happen with the Ad items, whose sizes depend on their image)
ScheduleComputeVisibilityTwinPass();
}
protected override float UpdateItemSizeOnTwinPass(FrameBaseView viewsHolder)
{
viewsHolder.UpdateSize();
FrameBaseModel model = Data[viewsHolder.ItemIndex];
viewsHolder.root.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, model.modelSize);
//viewsHolder.root.SetSizeFromParentEdgeWithCurrentAnchors(_Params.Content, RectTransform.Edge.Top, model.modelSize);
return viewsHolder.root.rect.height;
}
protected override bool IsRecyclable(FrameBaseView potentiallyRecyclable, int indexOfItemThatWillBecomeVisible, double sizeOfItemThatWillBecomeVisible)
{
FrameBaseModel model = Data[indexOfItemThatWillBecomeVisible];
return potentiallyRecyclable.CanPresentModelType(model.CachedType);
}
#endregion
}
/// <summary>
/// Custom params containing a single prefab. <see cref="ItemPrefabSize"/> is calculated on first accessing and invalidated each time <see cref="InitIfNeeded(IOSA)"/> is called.
/// </summary>
[System.Serializable]
public class MainParamsWithPrefab : BaseParams
{
public RectTransform bannerFramePrefab, pageFramePrefab, panelPrefab, myTopFramePrefab, myPageFramePrefab;
public override void InitIfNeeded(IOSA iAdapter)
{
base.InitIfNeeded(iAdapter);
//AssertValidWidthHeight(bannerFramePrefab);
//AssertValidWidthHeight(pageFramePrefab);
}
}
}
为什么List复杂呢,我们在看下结构
public class MainListAdapter : OSA<MainParamsWithPrefab, FrameBaseView>
MainListAdapter 继承自 OSA (OSA核心类)
FrameBaseView 还是View,但是和Grid在概念上不一样了。因为列表类可以显示不同的东西。
下图是OSA Demo中的一个例子截图:
第一个行是一个进度,第二行是带头像的,有的大小高一些,这样的多样性就可以通过不同的View来实现。
那么就需要这里可以建立一个FrameBaseView 基类,根据数据来显示不同的View。
我的View类是这样的 :
- FrameBaseView 基类
namespace Thinbug
{
/// <summary>
/// Includes common functionalities for the 3 views holders. <see cref="CanPresentModelType(Type)"/> is
/// implemented in all of them to return wether the views holder can present a model of specific type (that's
/// why we cache the model's type into <see cref="SimpleBaseModel.CachedType"/> inside its constructor)
/// </summary>
public abstract class FrameBaseView : BaseItemViewsHolder
{
//public Text titleText;
/// <inheritdoc/>
public override void CollectViews()
{
base.CollectViews();
//root.GetComponentAtPath("TitleText", out titleText);
}
public abstract bool CanPresentModelType(Type modelType);
/// <summary>
/// Called to update the views from the specified model.
/// Overriden by inheritors to update their own views after casting the model to its known type.
/// </summary>
public virtual void UpdateViews(FrameBaseModel model)
{
//if (titleText)
// titleText.text = "#" + ItemIndex;
}
/// <summary>Used to manually update the RectTransform's size based on custom rules each VH type specifies</summary>
public virtual void UpdateSize()
{
}
}
}
我的最外围的ListAdapter是分为了一个Banner,一个Page,Page里嵌套了Grid。
- FramePageMainView
继承自FrameBaseView
我在构造函数CollectViews里初始化了Page数据。因为不会有变动,其实严格的来说应该放到UpdateViews中。
namespace Thinbug
{
/// <summary>The views holder that can present an <see cref="GreenModel"/></summary>
public class FramePageMainView : FrameBaseView
{
/// <inheritdoc/>
public override void CollectViews()
{
base.CollectViews();
MainPageListAdapter pageAdapter = root.GetComponentInChildren<MainPageListAdapter>();
pageAdapter.InitPageAdpter();
MainListScrollView.inst.InitPageData(pageAdapter);
//root.GetComponentAtPath("ContentText", out contentText);
}
/// <inheritdoc/>
public override bool CanPresentModelType(Type modelType)
{ return modelType == typeof(FramePageMainModel); }
/// <inheritdoc/>
public override void UpdateViews(FrameBaseModel model)
{
base.UpdateViews(model);
}
}
}
下面是顶部的Banner类
- FrameBannerView
namespace Thinbug
{
/// <summary>The views holder that can present an <see cref="GreenModel"/></summary>
public class FrameBannerView : FrameBaseView
{
public RectTransform child;
public RectTransform rawHide; //隐藏用的遮盖板
public Image imgLine; //阴影线
/// <inheritdoc/>
public override void CollectViews()
{
base.CollectViews();
//如果bannle初始化了,那么显示bannle数据
MainTableAdapter tableAdapter;
root.GetComponentAtPath("Banner/TableParent/OSATable", out tableAdapter);
tableAdapter.InitTableAdapter();
MainListScrollView.inst.InitTableData(tableAdapter);
MainBannerAdapter bannerAdapter;
root.GetComponentAtPath("Banner/OSABanner", out bannerAdapter);
bannerAdapter.InitBannerAdapter();
MainListScrollView.inst.InitBannerData(bannerAdapter);
root.GetComponentAtPath("Banner", out child);
root.GetComponentAtPath("Banner/OSABanner", out rawHide);
root.GetComponentAtPath("Banner/ImageLine", out imgLine);
}
/// <inheritdoc/>
public override bool CanPresentModelType(Type modelType)
{ return modelType == typeof(FrameBannerModel); }
/// <inheritdoc/>
public override void UpdateViews(FrameBaseModel model)
{
base.UpdateViews(model);
//var greenModel = model as BannerFrameModel;
//contentText.text = greenModel.textContent;
}
//头部移入隐藏
public void Hide()
{
child.transform.SetParent(MainListScrollView.inst.bannerFreeze, false);
rawHide.gameObject.SetActive(false);
imgLine.gameObject.SetActive(true);
}
//头部移除
public void Show()
{
child.transform.SetParent(root, false);
rawHide.gameObject.SetActive(true);
imgLine.gameObject.SetActive(false);
}
}
}
同样的在构造函数里初始化了一些要用的组件数据等。
包含在Banner里横向的TableAdpter等数据。
- MainParamsWithPrefab
接下来看下预设Prefab类, MainParamsWithPrefab 是继承自 BaseParams,他也有了变化,对应不同的Prefab,和View对应起来。
/// <summary>
/// Custom params containing a single prefab. <see cref="ItemPrefabSize"/> is calculated on first accessing and invalidated each time <see cref="InitIfNeeded(IOSA)"/> is called.
/// </summary>
[System.Serializable]
public class MainParamsWithPrefab : BaseParams
{
public RectTransform bannerFramePrefab, pageFramePrefab, panelPrefab, myTopFramePrefab, myPageFramePrefab;
public override void InitIfNeeded(IOSA iAdapter)
{
base.InitIfNeeded(iAdapter);
//AssertValidWidthHeight(bannerFramePrefab);
//AssertValidWidthHeight(pageFramePrefab);
}
}
最后还是数据
public SimpleDataHelper Data
所以我们看到List和Grid的区别,就是List因为可能内容不同,需要能灵活的扩充View和Prefab。
- 数据的刷新
//初始化主框架
var newModels = new FrameBaseModel[2];
newModels[0] = new FrameBannerModel();
newModels[0].dataType = 1;
newModels[0].modelSize = mainAdapter.Parameters.bannerFramePrefab.rect.height;
newModels[1] = new FramePageMainModel();
newModels[1].dataType = 1;
newModels[1].modelSize = mainAdapter.Viewport.rect.height - (newModels[0].modelSize - freezeMoveHeight);
mainAdapter.OnDataRetrieved(newModels);
这里我们是两个数据,一个Banner,一个Page的数据。
(TableAdapter是包含在BannerAdapter的预设里的,所以是在Banner的构造函数里初始化的)
下面我们说下ListAdapter里的其他函数。
- View创建
protected override FrameBaseView CreateViewsHolder(int itemIndex)
数据创建后,OSA会进入CreateViewsHolder函数,Grid里并不需要我们处理这个,毕竟不同的数据对应的View是不同的。
这里我根据数据类型创建不同的View就可以了。
根据不同的数据创建不同的View类。(就是上面的FrameBannerView,FramePageMainModel)
var modelType = Data[itemIndex].CachedType;
if (modelType == typeof(FrameBannerModel))
{
//用来存放顶部Banner的View
var vh = new FrameBannerView();
vh.Init(_Params.bannerFramePrefab, _Params.Content, itemIndex);
if (Data[itemIndex].dataType == 1)
{
if (BannerFreezeTrm != null)
{
bannerFrameView = vh; //因为只有一个bannerview所以,这里锁定可以用这个
}
}
return vh;
}
if (modelType == typeof(FramePageMainModel))
{
//用来存放Page翻页的
var vh = new FramePageMainView();
vh.Init(_Params.pageFramePrefab, _Params.Content, itemIndex);
return vh;
}
。。。
-
View刷新
protected override void UpdateViewsHolder(FrameBaseView newOrRecycled)
在需要显示的时候,会调用到这里,刷新某个View -
View大小
protected override float UpdateItemSizeOnTwinPass(FrameBaseView viewsHolder)
因为多Prefab,大小不同,所以显示时候要处理不同的大小 -
回收
protected override bool IsRecyclable
因为缓存的问题,对于相同的有缓存,这里要根据类型判断是否需要回收。
总结:
经过Grid和List 两个结构的简单介绍,希望大家能迅速的熟悉OSA的一个基础框架。
就只有这样吗?
是的,简单的了解下核心代码的结构就够了,结合自己的需求去做,才能深刻的理解他。再就是大量看官方例子中的源码更加深刻的理解相应部分。
经过预设的嵌套多层OSA,可以实现比较复杂的列表显示。
自己做的一个项目里
主界面Adapter嵌套了BannerAdapter(可以左右滚动的),TitleAdapter标题栏,PageAdapter(就是下面的分页的Grid,List的不同列表体)这样的一个多嵌套结构。
Unity中运行良好,手感和效率都值得推荐。
最后,好的组件来之不易,请大家支持原版。
SuperScrollView
https://assetstore.unity.com/packages/tools/gui/ugui-super-scrollview-86572
OSA
https://assetstore.unity.com/packages/tools/gui/optimized-scrollview-adapter-68436
https://download.youkuaiyun.com/download/thinbug/12928761?spm=1001.2014.3001.5501
试用如果好用还是建议支持osa作者