基于UGUI的无限动态拖拽列表ScrollerView

渲染原理

Rect信息:抽象的格子数据,每个数据对应一个格子,矩形信息包括位置坐标,格子大小
Item格子:UI实例,每一个格子上挂载着唯一的一个Unit 控制渲染格子或者回收以及显示数据的变化
Data数据: 列表的每一个格子所属的数据,每一个这样的数据会对应一个Rect信息用来抽象出在列表中应有的格子位置

渲染流程:在每一动画帧后渲染列表。首先将蒙版坐标通过计算转化成和Item同一坐标系坐标,这样可以判断Item是否与蒙版交互,然后提取出相交的Rect信息,回收没有相交的Item格子(清除格子信息并隐藏)再将交互但还没有渲染出Item的Rect信息进行渲染,找到已回收的item渲染到rect 位置上并设置Data数据。

一、封装一个item

1.封装Rect 保存格子位置、大小
(1)Rect 用来保存这个动态格子的物理信息
(2)格子索引用来标记格子
(3)这个脚本作用是用来判断格子是否超出蒙版范围

/// <summary>
/// rect数据
/// </summary>
public class DynamicRectangle
{
    /// <summary>
    /// 矩形数据
    /// </summary>
    private Rect mRect;
    /// <summary>
    /// 格子索引
    /// </summary>
    public int Index;
    /// <summary>
    /// 生成2d矩形实例
    /// </summary>
    /// <param name="x">测量rect的X值。</param>
    /// <param name="y">测量rect的Y值。</param>
    /// <param name="width">矩形的宽度。</param>
    /// <param name="height">矩形的高度。</param>
    /// <param name="index">下标</param>
    public DynamicRectangle(float x, float y, float width, float height, int index)
    {
        this.Index = index;
        mRect = new Rect(x, y, width, height);
    }
    /// <summary>
    /// 是否相交
    /// </summary>
    /// <param name="otherRect"></param>
    /// <returns></returns>
    public bool Overlaps(DynamicRectangle otherRect)
    {
        return mRect.Overlaps(otherRect.mRect);
    }
    /// <summary>
    /// 是否相交
    /// </summary>
    /// <param name="otherRect"></param>
    /// <returns></returns>
    public bool Overlaps(Rect otherRect)
    {
        return mRect.Overlaps(otherRect);
    }
    public override string ToString()
    {
        return string.Format("index:{0},x:{1},y:{2},w:{3},h:{4}", Index, mRect.x, mRect.y, mRect.width, mRect.height);
    }
}

2.封装item 用来保存数据和Rect(渲染数值)
(1)这个脚本用来控制格子实体
(2)用来装载数据用于交互和物理信息用于显示

/// <summary>
/// Item控制
/// </summary>
public class DynamicInfinityUnit : MonoBehaviour {
    public delegate void OnSelect(DynamicInfinityUnit item);
    public delegate void OnUpdateData(DynamicInfinityUnit item);
    //选择时回调
    public OnSelect OnSelectHanlder;
    //更新数据时的回调
    public OnUpdateData OnUpdateDataHandler;
    /// <summary>
    /// Rect(位置、大小)
    /// </summary>
    protected DynamicRectangle mDRect;
    /// <summary>
    /// Data
    /// </summary>
    protected object mData;
    public DynamicRectangle DRect
    {
        set {
            mDRect = value;
            gameObject.SetActive(value != null);
        }
        get { return mDRect; }
    }
    /// <summary>
    /// 设置数据
    /// </summary>
    /// <param name="data"></param>
    public void SetData(object data)
    {
        if (data == null) return;
        mData = data;
        if (OnUpdateDataHandler != null)
        {
            OnUpdateDataHandler(this);
        }
        OnRenderer();
    }
    /// <summary>
    /// 每次渲染时调用
    /// </summary>
    protected virtual void OnRenderer() {
    }
    public object GetData()
    {
        return mData;
    }
    public T GetData<T>()
    {
        return (T)mData;
    }
}

二、封装一个scoller 类用来渲染item

创建list 渲染管理类 ,以下都是DynamicInfinityScrollerRender 类中的内容
该脚本挂载在ScollerRect 子节点List中 List为item 的父节点用来承载所有item
1.(创建list 渲染管理类)

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// 动态无限列表
/// </summary>
public class DynamicInfinityScrollerRender : MonoBehaviour
{
    /// <summary>
    /// 单元格尺寸(宽,高)
    /// </summary>
    public Vector2 CellSize;
    /// <summary>
    /// 单元格间隙(水平,垂直)
    /// </summary>
    public Vector2 SpacingSize;
    /// <summary>
    /// 列数
    /// </summary>
    public int ColumnCount;
    /// <summary>
    /// 单元格渲染器prefab
    /// </summary>
    public GameObject RenderGO;
    /// <summary>
    /// 渲染格子数
    /// </summary>
    protected int mRendererCount;
    /// <summary>
    /// 父节点蒙版尺寸
    /// </summary>
    private Vector2 mMaskSize;
    /// <summary>
    /// 蒙版矩形
    /// </summary>
    private Rect mRectMask;
    protected ScrollRect mScrollRect;
    /// <summary>
    /// List(item根节点)
    protected RectTransform mRectTransformContainer;
    /// <summary>
    /// Item集合
    /// </summary>
    protected List<DynamicInfinityUnit> mList_items;
    /// <summary>
    /// Rect集合
    /// </summary>
    private Dictionary<int, DynamicRectangle> mDict_dRect;
    /// <summary>
    /// Data数据集合
    /// </summary>
    protected Dictionary<int, object> mDict_DataProviders;
    /// <summary>
    /// 是否初始化
    /// </summary>
    protected bool mHasInited = false;

2.初始化渲染数据 数据初始 渲染初始
(1)记录下蒙版的大小和位置信息储存在Rect 中 ,用来对比Item 是否相交于蒙版 ,回收不相交的Item
(2)通过蒙版大小计算需要初始可以塞满蒙版的格子数,一般还要再多出一行或两行,因为拖动时会显示出N+1个行
(4)实例化Item存放在List 中 , 每个格子挂载DynamicInfinityUnit脚本用来控制这个格子,初始为回收状态(隐藏)
(6)更新格子位置与List 尺寸

    /// <summary>
    /// 初始化渲染脚本
    /// </summary>
    public virtual void InitRendererList(DynamicInfinityUnit.OnSelect OnSelect, DynamicInfinityUnit.OnUpdateData OnUpdateData)
    {
        if (mHasInited) return;

        //List 承载所有Item 的父节点
        mRectTransformContainer = transform as RectTransform;
        //记录一下蒙版尺寸
        mMaskSize = transform.parent.GetComponent<RectTransform>().sizeDelta;
        mScrollRect = transform.parent.GetComponent<ScrollRect>();
        //初始渲染的item 的个数 多渲染一列  通过蒙版的尺寸和格子的尺寸计算需要渲染器的个数(格子数 = 行数 * 列数)
        mRendererCount = ColumnCount * (Mathf.CeilToInt(mMaskSize.y / GetBlockSizeY()) + 1);
        //初始一个蒙版的Rect
        _UpdateDynamicRects(mRendererCount);
        //初始每一个渲染出的item 都有DynamicInfinityUnit 用来控制
        mList_items = new List<DynamicInfinityUnit>();
        for (int i = 0; i < mRendererCount; i++)
        {
            //预设体克隆实例
            GameObject child = GameObject.Instantiate(RenderGO);
            child.transform.SetParent(transform);
            child.transform.localRotation = Quaternion.identity;
            child.transform.localScale = Vector3.one;
            child.layer = gameObject.layer;
            RectTransform re = child.transform as RectTransform;
            re.sizeDelta = CellSize;
            DynamicInfinityUnit dfItem = child.GetComponent<DynamicInfinityUnit>();
            if (dfItem == null)
                child.AddComponent<DynamicInfinityUnit>();
            mList_items.Add(dfItem);
            //选择回调
            mList_items[i].OnSelectHanlder = OnSelect;
            //更新数据回调
            mList_items[i].OnUpdateDataHandler = OnUpdateData;
            child.SetActive(false);
            //初始item位置
            _UpdateChildTransformPos(child, i);
        }
        //初始该list的尺寸 遮罩尺寸
        _SetListRenderSize(mRendererCount);
        mHasInited = true;
    }

3.设置渲染列表的尺寸 设置item父节点List 的x、y值 设置蒙版的尺寸
List 的宽度不变 长度为一列的item数*(item高+行高)
(1)计算出List 大小,用来List为所有item大小总和
(2)记录蒙版Rect,前面有说过是用来对比每个格子的是否在蒙版范围内

    /// <summary>
    /// 设置渲染列表的尺寸
    /// </summary>
    /// <param name="count">渲染格子个数</param>
    private void _SetListRenderSize(int count)
    {
        //赋值list 的大小  x轴保持不变    Y轴获取 (行数 * 行高)
        mRectTransformContainer.sizeDelta = new Vector2(mRectTransformContainer.sizeDelta.x, Mathf.CeilToInt((count * 1.0f / ColumnCount)) * GetBlockSizeY());
        //记录蒙版的尺寸  蒙版尺寸为父节点的尺寸
        mRectMask = new Rect(0, -mMaskSize.y, mMaskSize.x, mMaskSize.y);
        //是否需要拖动条
        mScrollRect.vertical = mRectTransformContainer.sizeDelta.y > mMaskSize.y;
    }

4.每个格子的大小,行距

    /// <summary>
    /// 获取格子块尺寸
    /// </summary>
    /// <returns></returns>
    protected float GetBlockSizeY() {
        return CellSize.y + SpacingSize.y;
    }
    protected float GetBlockSizeX()
    {
        return CellSize.x + SpacingSize.x;
    }

5.对Item 的操作
(1)封装更新Item 的方法
根据Index 计算出Item 应该对应于List 的位置坐标,
Item和List的锚点一点要调整:左下角为格子的锚点 List的左上角为锚点
举例:第三个格子的位置
在这里插入图片描述
(2)回收所有Item 当Item 不再显示了 , 这时我们应该回收掉以供接下来使用
置空 DynamicInfinityUnit 的DRect 就可以隐藏掉Item
(3)获取已经回收的格子
在DynamicInfinityUnit 集合中找到DRect被置空的格子
(4)通过Rect 获取格子 每一个DRect都有一个唯一的Index 对应与数据的唯一ID

    #region Item操作
    /// <summary>
    /// 更新各个渲染格子的位置
    /// </summary>
    /// <param name="child"></param>
    /// <param name="index"></param>
    private void _UpdateChildTransformPos(GameObject child, int index)
    {
        //根据坐标判断所在行、列  再计算xy坐标
        int row = index / ColumnCount;
        int column = index % ColumnCount;
        Vector2 v2Pos = new Vector2();
        v2Pos.x = column * GetBlockSizeX();
        v2Pos.y = -CellSize.y - row * GetBlockSizeY();
        ((RectTransform)child.transform).anchoredPosition3D = Vector3.zero;
        ((RectTransform)child.transform).anchoredPosition = v2Pos;
    }
    /// <summary>
    /// 清理所有已经存在的格子的矩形信息(回收所有格子)
    /// </summary>
    private void _ClearAllListRenderDr()
    {
        if (mList_items != null)
        {
            for (int i = 0; i < mList_items.Count; i++)
            {
                DynamicInfinityUnit item = mList_items[i];
                item.DRect = null;
            }
        }
    }
    /// <summary>
    /// 获取已经回收的格子
    /// </summary>
    /// <returns></returns>
    private DynamicInfinityUnit _GetNullDynamicItem()
    {
        for (int i = 0; i < mList_items.Count; i++)
        {
            DynamicInfinityUnit item = mList_items[i];
            if (item.DRect == null)
                return item;
        }
        return null;
    }
    /// <summary>
    /// 通过rect获得格子
    /// </summary>
    /// <param name="rect"></param>
    /// <returns></returns>
    DynamicInfinityUnit _GetDynamicItem(DynamicRectangle rect)
    {
        for (int i = 0; i < mList_items.Count; i++)
        {
            DynamicInfinityUnit item = mList_items[i];
            if (item.DRect == null)
                continue;
            if (rect.Index == item.DRect.Index)
                return item;
        }
        return null;
    }
    #endregion

6.Rects 矩形数据集合操作
(1)更新对应于数据的rect 一个数据对应一个rect数据
(2)每添加一个rect 或者删除一个rect 都是对列表末尾操作

   #region Rects 矩形数据集合操作
    /// <summary>
    /// 更新对应于数据的rect 
    /// </summary>
    private void _UpdateDynamicRects(int count)
    {
        //生成渲染用的格子
        mDict_dRect = new Dictionary<int, DynamicRectangle>();
        for (int i = 0; i < count; i++)
        {
            //行
            int row = i / ColumnCount;
            //列
            int column = i % ColumnCount;
            //记录矩形的宽高、位置
            //父节点的锚点为左上角  item的锚点为左下角
            DynamicRectangle dRect = new DynamicRectangle(column * GetBlockSizeX(), -row * GetBlockSizeY() - CellSize.y, CellSize.x, CellSize.y, i);
            mDict_dRect[i] = dRect;
        }
    }
    /// <summary>
    /// 添加一个渲染格子
    /// </summary>
    /// <param name="index"></param>
    private void _AddDynamicRect()
    {
        if (mDict_dRect == null)
            mDict_dRect = new Dictionary<int, DynamicRectangle>();
        int index = mDict_dRect.Count;
        int row = index / ColumnCount;
        int column = index % ColumnCount;
        DynamicRectangle dRect = new DynamicRectangle(column * GetBlockSizeX(), -row * GetBlockSizeY() - CellSize.y, CellSize.x, CellSize.y, index);
        mDict_dRect[index] = dRect;
    }
    /// <summary>
    /// 删除全部Rect
    /// </summary>
    private void _CloseAllDynamicRect()
    {
        mDict_dRect = null;
    }
    /// <summary>
    /// 移除一个Rect
    /// </summary>
    private void _CloseDynamicRect()
    {
        mDict_dRect.Remove(mDict_dRect.Count);
    }
    #endregion

7.对数据Data 的操作
(1)数据列表中存放所有数据 Data
(2)每个数据Data都会对应一个rect
(3)对数据的增删改查最后都会作用于rect集合

   #region Data 操作
    /// <summary>
    /// 添加所有的数据
    /// </summary>
    /// <param name="strings"></param>
    private void _AddDynamicDatas(List<object> strings)
    {
        mDict_DataProviders = new Dictionary<int, object>();
        for (int i = 0; i < strings.Count; i++)
        {
            mDict_DataProviders.Add(i, strings[i]);
        }
    }
    /// <summary>
    /// 添加一个数据
    /// </summary>
    /// <param name="str"></param>
    private void _AddDynamicData(object str)
    {
        if (mDict_DataProviders == null)
            mDict_DataProviders = new Dictionary<int, object>();
        mDict_DataProviders.Add(mDict_DataProviders.Count, str);
    }
    /// <summary>
    /// 移除一个数据
    /// </summary>
    /// <param name="index"></param>
    private void _RemoveDynamicData(int index)
    {
        if (mDict_DataProviders == null)
            return;
        mDict_DataProviders.Remove(index);
    }
    private void _ClearDynamicDatas()
    {
        if (mDict_DataProviders == null)
            return;
        mDict_DataProviders.Clear();
    }
    private void _UpdateDynamicData(int index, object str)
    {
        if (mDict_DataProviders == null)
            return;
        if (mDict_DataProviders.ContainsKey(index))
        {
            mDict_DataProviders[index] = str;
        }
    }
    #endregion

8.移动到目标行
(1)控制目标行的范围 , 不要超过最后X位下标 (X为蒙版内可显示出的行数)
(2)求出坐标和行数 (注意拖动的是List,List 坐标在改变Item根据是否与蒙版相交而显示) 目标x轴保持不动就行 , y轴为 移动的(行数+行间距)*格子数
举例: 一格就是初始状态 List 坐标(0,0) 四格就是 List坐标为 (0,3X(格子高+行距))
在这里插入图片描述
(3)之后就用协程执行, 采用Lerp差值实现平滑移动到目标位置

   #region 移动至目标
    /// <summary>
    /// 移动列表使之能定位到给定数据的位置上
    /// </summary>
    /// <param name="target">目标</param>
    /// <param name="delay">延迟</param>
    public virtual void LocateRenderItemAtTarget(object target, float delay)
    {
        if (mDict_DataProviders.ContainsValue(target))
        {
            foreach (KeyValuePair<int, object> kvp in mDict_DataProviders)
            {
                if (kvp.Value.Equals(target))
                {
                    //找到目标对象的下标
                    LocateRenderItemAtIndex(kvp.Key, delay);
                    return;
                }
            }
        }
    }
    /// <summary>
    /// 根据目标下标判断移动目的地
    /// </summary>
    /// <param name="index"></param>
    /// <param name="delay"></param>
    public virtual void LocateRenderItemAtIndex(int index, float delay)
    {
        //查看是否在超范围
        if (index < 0 || index > mDict_DataProviders.Count - 1)
            throw new Exception("Locate Index Error " + index);
        index = Math.Min(index, mDict_DataProviders.Count - mRendererCount);
        index = Math.Max(0, index);
        //List 的坐标
        Vector2 pos = mRectTransformContainer.anchoredPosition;
        //目标的行数
        int row = index / ColumnCount;
        //目的地的坐标
        Vector2 v2Pos = new Vector2(pos.x, row * GetBlockSizeY());
        m_Coroutine = StartCoroutine(TweenMoveToPos(pos, v2Pos, delay));
    }
    protected Coroutine m_Coroutine = null;
    /// <summary>
    /// 移动协程
    /// </summary>
    /// <param name="pos">现在的坐标</param>
    /// <param name="v2Pos">目的地坐标</param>
    /// <param name="delay">延迟</param>
    /// <returns></returns>
    protected IEnumerator TweenMoveToPos(Vector2 pos, Vector2 v2Pos, float delay)
    {
        bool running = true;
        //延迟时间
        float passedTime = 0f;
        while (running)
        {
            yield return new WaitForEndOfFrame();
            passedTime += Time.deltaTime;
            Vector2 vCur;
            //到达设置延迟时间的点
            if (passedTime >= delay)
            {
                //遮罩xy
                vCur = v2Pos;
                running = false;
                StopCoroutine(m_Coroutine);
                m_Coroutine = null;
            }
            else
            {
                //差值移动
                vCur = Vector2.Lerp(pos, v2Pos, passedTime / delay);
            }
            mRectTransformContainer.anchoredPosition = vCur;
        }
    }
    private void _MoveToPos(int index)
    {
        //查看是否在超范围
        if (index < 0 || index > mDict_DataProviders.Count - 1)
            throw new Exception("Locate Index Error " + index);
        index = Math.Min(index, mDict_DataProviders.Count - mRendererCount);
        index = Math.Max(0, index);
        //List 的坐标
        Vector2 pos = mRectTransformContainer.anchoredPosition;
        //目标的行数
        int row = index / ColumnCount;
        //目的地的坐标
        Vector2 v2Pos = new Vector2(pos.x, row * GetBlockSizeY());
        mRectTransformContainer.anchoredPosition = v2Pos;
    }
    #endregion

9.接下来的重点就是更新渲染
(1)首先求出来蒙版的rect 原点为左下角 , 参照坐标系父节点为List和Item一样
(2)遍历所有的Rect信息 , 每个数据都有对应的Rect信息储存着item应该显示的位置 判断是否与蒙版相交 如果相交保存一个相交合集
(3)利用相交合集 判断item是否需要回收
(4)将新进入蒙版需要显示的数据 分配一个已回收的item 设置其位置并显示

    private void Update()
    {
        if (mHasInited)
        {
            UpdateRender();
        }
    }
    /// <summary>
    /// 更新渲染
    /// </summary>
    protected void UpdateRender()
    {
        //蒙版y = - 蒙版高 - List坐标y   获取的是蒙版相对于List的坐标
        mRectMask.y = -mMaskSize.y - mRectTransformContainer.anchoredPosition.y;
        Dictionary<int, DynamicRectangle> inOverlaps = new Dictionary<int, DynamicRectangle>();
        foreach (DynamicRectangle dR in mDict_dRect.Values)
        {
            if (dR.Overlaps(mRectMask))
            {
                //如果与蒙版相交
                inOverlaps.Add(dR.Index, dR);
            }
        }
        for (int i = 0; i < mList_items.Count; i++)
        {
            //查看格子是否蒙版相交如果没有相交则消除
            DynamicInfinityUnit item = mList_items[i];
            if (item.DRect != null && !inOverlaps.ContainsKey(item.DRect.Index))
                item.DRect = null;
        }
        foreach (DynamicRectangle dR in inOverlaps.Values)
        {
            if (mDict_DataProviders != null && dR.Index < mDict_DataProviders.Count)
            {
                if (_GetDynamicItem(dR) == null)
                {
                    DynamicInfinityUnit item = _GetNullDynamicItem();
                    item.DRect = dR;
                    _UpdateChildTransformPos(item.gameObject, dR.Index);
                    item.SetData(mDict_DataProviders[dR.Index]);
                }
            }
        }
    }

三、拖动列表的增、删、改、查

接下来就可以实现增删改查了,其实增删用到的比较多而且可以根据需求灵活修改对应的方式
基本逻辑就是:添加数据保证每一个数据Data对应一个rect , 而List 大小可以装载所有rect 的尺寸 , 而item进行实际的可见渲染。

    #region 增删改查
    /// <summary>
    /// 添加数据集合
    /// </summary>
    /// <param name="list"></param>
    public void AddDatasToInfinityList(List<object> list)
    {
        //配置的rect
        _UpdateDynamicRects(list.Count);
        //设置List大小
        _SetListRenderSize(list.Count);
        //配置data
        _AddDynamicDatas(list);
        //回收所有item
        _ClearAllListRenderDr();
    }
    /// <summary>
    /// 添加一个数据
    /// </summary>
    /// <param name="obj"></param>
    public void AddDataToInfinityList(object obj)
    {
        //添加数据至末端
        _AddDynamicData(obj);
        //添加rect在末端
        _AddDynamicRect();
        //设置List大小
        _SetListRenderSize(mDict_DataProviders.Count);
    }
    /// <summary>
    /// 移除整个List
    /// </summary>
    public void RemoveAllDatasToInfinityList()
    {
        //移动至第一行
        _MoveToPos(0);
        //回收所有的item
        _ClearAllListRenderDr();
        //置空数据
        _ClearDynamicDatas();
        //置空rect
        _CloseAllDynamicRect();
        //缩短List
        _SetListRenderSize(0);
    }
    /// <summary>
    /// 更改数据
    /// </summary>
    /// <param name="index"></param>
    /// <param name="str"></param>
    public void UpdateDataToInfinityList(int index, object str)
    {
        _UpdateDynamicData(index, str);
        //清除所有格子信息
        _ClearAllListRenderDr();
    }
    /// <summary>
    /// 移除一个item
    /// </summary>
    public void RemoveDataToInfinityList(int index)
    {
        //清除所有已存在的格子的矩形信息
        _ClearAllListRenderDr();
        _RemoveDynamicData(index);
        _SetListRenderSize(mDict_DataProviders.Count);
        _UpdateDynamicRects(mDict_DataProviders.Count);
    }
    #endregion

到这里这个DynamicInfinityScrollerRender 类可以封装起来了。

四、总结

需要注意的几个点是:
(1)这个拖拽列表在每次拖拽到新的一行时,需要将回收的item重新渲染出来所以如果数据最好缓存起来以提高效率.
比如:如果你在赋值item数据的时候加载图片 ,那就会在每次拖动时新的item就会被赋值再加载图片 每次就会卡顿,
如果你在一开始就加载好图片再添加数据的时候赋值引用,那么每次拖动时新的item就不用再去加载图片了
(2)锚点和中心点记得要调整,当然也可以根据自己的需求修改。
在这里插入图片描述
这个也是参考其他大神的一些例子改出来的,借用了很多别人的东西,一开始没有注释也看不太懂,自己搞半天慢慢改了改,就分享了出来,这个无限动态拖拽列表基于UGUI,可能有些需要不太合适,后续还会分享出其他的动态拖拽列表和一些UI知识,也希望有些不足或者错的地方能有大佬指出来一起探讨一起进步。

链接: 测试工程.
提取码:z1bk

看了这么久别忘了收藏一下呀!
链接: https://blog.csdn.net/weixin_40492525/article/details/102708015.

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值