Unity下的List,Grid,Page,Banner优化方案

问题抛出

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脚本已经被他封装到基类了。

源码部分片段来自我自己的测试用例:

  1. 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类的框架

  1. 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作者

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Thinbug

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

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

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

打赏作者

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

抵扣说明:

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

余额充值