相关收集animation,aseetbundle的优化

Animation//

如何降低动画文件的浮点数精度 ?

动画文件后处理可以做两件事,1)精度压缩,2)scale曲线剔除。比起用工具修改原始fbx文件,这样比较灵活。实际测试,在开启Optimal压缩的情况下,加上这个后处理,能再节省40%左右。

FileSize

  1. FileInfo.Length取得的文件大小
  2. 可以在操作系统的文件系统中看到

MemorySize

  1. Profiler.GetRuntimeMemorySize取得的内存大小
  2. 可以在Profiler中通过采样看到
  3. 分别在真机和Editor下进行了采样

BlobSize

  1. 反射取得的AnimationClipStats.size二进制大小
  2. 显示在AnimationClip的Inspector的面板上

红色框内即是BlobSize,在我的理解,FileSize是指文件在硬盘中占的大小,BlobSize是从文件反序列化出来的对象的二进制大小。Editor下的MemorySize不仅有序列化后的内存大小,还维护了一份原文件的内存。就像我们在Editor下加载一张Texture内存是双份一样,而真机下就约等于BlobSize。真机下的MemorySize和Inspector里的BlobSize非常接近,BlobSize可以认为是真机上的内存大小,这个大小更有参考意义

同时,我也对去除Scale曲线的方法进行了实验。下图这个动画文件原来Inspector中Scale的值为4,即有Scale曲线,原始文件BlobSize为10.2KB,去除Scale曲线后,Blob Size变为7.4KB,所以BlobSize减小了27%。

Curve减少导致内存减小

从上面的实验可以看出来,只裁剪动画文件的压缩精度,没有引起Curve减少。BlobSize是不会有任何变化的,因为每个浮点数固定占32bit。而文件大小、AB大小、Editor下的内存大小,压缩精度后不管有没有引起Curve的变化,都会变小。

裁剪动画文件的精度,意味着点的位置发生了变化,所以Constant Curve和Dense Curve的数量也有可能发生变化。由于是裁剪精度所以动画的点更稀疏了,而连续相同的点更多了。所以Dense Curve是减少了,Constant Curve是增多了,总的内存是减小了。

Constant Curve只需要最左边的点就可以描述一个曲线段。

裁剪精度后,大小为2.1kb,ConstantCurve为7(100%),Stream为0(0%)。裁剪完精度后导致ConstantCurve增加了3,Stream(Optimal模式下即为Dense)减少了3,BlobSize减小了0.1kb。
 

通过精度优化降低内存的方式,其实质是将曲线上过于接近的数值(例如相差数值出现在小数点4位以后)直接变为一致,从而使部分曲线变为constant曲线来降低内存消耗。


总结

隔壁项目组对他们项目中所有的动画文件都进行了优化。其中文件大小从820MB->225MB, ab大小从72MB->64MB, 内存大小从50MB->40MB。总的来说动画文件的scale越多优化越明显。


取BlobSize代码

AnimationClip aniClip = AssetDatabase.LoadAssetAtPath<AnimationClip> (path);

var fileInfo = new System.IO.FileInfo(path);

Debug.Log(fileInfo.Length);//FileSize

Debug.Log(Profiler.GetRuntimeMemorySize (aniClip));//MemorySize

Assembly asm = Assembly.GetAssembly(typeof(Editor));

MethodInfo getAnimationClipStats = typeof(AnimationUtility).GetMethod("GetAnimationClipStats", BindingFlags.Static | BindingFlags.NonPublic);

Type aniclipstats = asm.GetType("UnityEditor.AnimationClipStats");

FieldInfo sizeInfo = aniclipstats.GetField ("size", BindingFlags.Public | BindingFlags.Instance);

var stats = getAnimationClipStats.Invoke(null, new object[]{aniClip});

Debug.Log(EditorUtility.FormatBytes((int)sizeInfo.GetValue(stats)));//BlobSize

一个简单的工具:

using System;

using System.Collections.Generic;

using UnityEngine;

using System.Reflection;

using UnityEditor;

using System.IO;

namespace EditorTool

{

    class AnimationOpt

    {

        static Dictionary<uint,string> _FLOAT_FORMAT;

        static MethodInfo getAnimationClipStats;

        static FieldInfo sizeInfo;

        static object[] _param = new object[1];

        static AnimationOpt ()

        {

            _FLOAT_FORMAT = new Dictionary<uint, string> ();

            for (uint i = 1; i < 6; i++) {

                _FLOAT_FORMAT.Add (i, "f" + i.ToString ());

            }

            Assembly asm = Assembly.GetAssembly (typeof(Editor));

            getAnimationClipStats = typeof(AnimationUtility).GetMethod ("GetAnimationClipStats", BindingFlags.Static | BindingFlags.NonPublic);

            Type aniclipstats = asm.GetType ("UnityEditor.AnimationClipStats");

            sizeInfo = aniclipstats.GetField ("size", BindingFlags.Public | BindingFlags.Instance);

        }

        AnimationClip _clip;

        string _path;

        public string path { get{ return _path;} }

        public long originFileSize { get; private set; }

        public int originMemorySize { get; private set; }

        public int originInspectorSize { get; private set; }

        public long optFileSize { get; private set; }

        public int optMemorySize { get; private set; }

        public int optInspectorSize { get; private set; }

        public AnimationOpt (string path, AnimationClip clip)

        {

            _path = path;

            _clip = clip;

            _GetOriginSize ();

        }

        void _GetOriginSize ()

        {

            originFileSize = _GetFileZie ();

            originMemorySize = _GetMemSize ();

            originInspectorSize = _GetInspectorSize ();

        }

        void _GetOptSize ()

        {

            optFileSize = _GetFileZie ();

            optMemorySize = _GetMemSize ();

            optInspectorSize = _GetInspectorSize ();

        }

        long _GetFileZie ()

        {

            FileInfo fi = new FileInfo (_path);

            return fi.Length;

        }

        int _GetMemSize ()

        {

            return Profiler.GetRuntimeMemorySize (_clip);

        }

        int _GetInspectorSize ()

        {

            _param [0] = _clip;

            var stats = getAnimationClipStats.Invoke (null, _param);

            return (int)sizeInfo.GetValue (stats);

        }

        void _OptmizeAnimationScaleCurve ()

        {

            if (_clip != null) {

                //去除scale曲线

                foreach (EditorCurveBinding theCurveBinding in AnimationUtility.GetCurveBindings(_clip)) {

                    string name = theCurveBinding.propertyName.ToLower ();

                    if (name.Contains ("scale")) {

                        AnimationUtility.SetEditorCurve (_clip, theCurveBinding, null);

                        Debug.LogFormat ("关闭{0}的scale curve", _clip.name);

                    }

                }

            }

        }

        void _OptmizeAnimationFloat_X (uint x)

        {

            if (_clip != null && x > 0) {

                //浮点数精度压缩到f3

                AnimationClipCurveData[] curves = null;

                curves = AnimationUtility.GetAllCurves (_clip);

                Keyframe key;

                Keyframe[] keyFrames;

                string floatFormat;

                if (_FLOAT_FORMAT.TryGetValue (x, out floatFormat)) {

                    if (curves != null && curves.Length > 0) {

                        for (int ii = 0; ii < curves.Length; ++ii) {

                            AnimationClipCurveData curveDate = curves [ii];

                            if (curveDate.curve == null || curveDate.curve.keys == null) {

                                //Debug.LogWarning(string.Format("AnimationClipCurveData {0} don't have curve; Animation name {1} ", curveDate, animationPath));

                                continue;

                            }

                            keyFrames = curveDate.curve.keys;

                            for (int i = 0; i < keyFrames.Length; i++) {

                                key = keyFrames [i];

                                key.value = float.Parse (key.value.ToString (floatFormat));

                                key.inTangent = float.Parse (key.inTangent.ToString (floatFormat));

                                key.outTangent = float.Parse (key.outTangent.ToString (floatFormat));

                                keyFrames [i] = key;

                            }

                            curveDate.curve.keys = keyFrames;

                            _clip.SetCurve (curveDate.path, curveDate.type, curveDate.propertyName, curveDate.curve);

                        }

                    }

                } else {

                    Debug.LogErrorFormat ("目前不支持{0}位浮点", x);

                }

            }

        }

        public void Optimize (bool scaleOpt, uint floatSize)

        {

            if (scaleOpt) {

                _OptmizeAnimationScaleCurve ();

            }

            _OptmizeAnimationFloat_X (floatSize);

            _GetOptSize ();

        }

        public void Optimize_Scale_Float3 ()

        {

            Optimize (true, 3);

        }

        public void LogOrigin ()

        {

            _logSize (originFileSize, originMemorySize, originInspectorSize);

        }

        public void LogOpt ()

        {

            _logSize (optFileSize, optMemorySize, optInspectorSize);

        }

        public void LogDelta ()

        {

        }

        void _logSize (long fileSize, int memSize, int inspectorSize)

        {

            Debug.LogFormat ("{0} \nSize=[ {1} ]", _path, string.Format ("FSize={0} ; Mem->{1} ; inspector->{2}",

                EditorUtility.FormatBytes (fileSize), EditorUtility.FormatBytes (memSize), EditorUtility.FormatBytes (inspectorSize)));

        }

    }

    public class OptimizeAnimationClipTool

    {

        static List<AnimationOpt> _AnimOptList = new List<AnimationOpt> ();

        static List<string> _Errors = new List<string>();

        static int _Index = 0;

        [MenuItem("Assets/Animation/裁剪浮点数去除Scale")]

        public static void Optimize()

        {

            _AnimOptList = FindAnims ();

            if (_AnimOptList.Count > 0)

            {

                _Index = 0;

                _Errors.Clear ();

                EditorApplication.update = ScanAnimationClip;

            }

        }

        private static void ScanAnimationClip()

        {

            AnimationOpt _AnimOpt = _AnimOptList[_Index];

            bool isCancel = EditorUtility.DisplayCancelableProgressBar("优化AnimationClip", _AnimOpt.path, (float)_Index / (float)_AnimOptList.Count);

            _AnimOpt.Optimize_Scale_Float3();

            _Index++;

            if (isCancel || _Index >= _AnimOptList.Count)

            {

                EditorUtility.ClearProgressBar();

                Debug.Log(string.Format("--优化完成--    错误数量: {0}    总数量: {1}/{2}    错误信息↓:\n{3}\n----------输出完毕----------", _Errors.Count, _Index, _AnimOptList.Count, string.Join(string.Empty, _Errors.ToArray())));

                Resources.UnloadUnusedAssets();

                GC.Collect();

                AssetDatabase.SaveAssets();

                EditorApplication.update = null;

                _AnimOptList.Clear();

                _cachedOpts.Clear ();

                _Index = 0;

            }

        }

        static Dictionary<string,AnimationOpt> _cachedOpts = new Dictionary<string, AnimationOpt> ();

        static AnimationOpt _GetNewAOpt (string path)

        {

            AnimationOpt opt = null;

            if (!_cachedOpts.ContainsKey(path)) {

                AnimationClip clip = AssetDatabase.LoadAssetAtPath<AnimationClip> (path);

                if (clip != null) {

                    opt = new AnimationOpt (path, clip);

                    _cachedOpts [path] = opt;

                }

            }

            return opt;

        }

        static List<AnimationOpt> FindAnims()

        {

            string[] guids = null;

            List<string> path = new List<string>();

            List<AnimationOpt> assets = new List<AnimationOpt> ();

            UnityEngine.Object[] objs = Selection.GetFiltered(typeof(object), SelectionMode.Assets);

            if (objs.Length > 0)

            {

                for(int i = 0; i < objs.Length; i++)

                {

                    if (objs [i].GetType () == typeof(AnimationClip))

                    {

                        string p = AssetDatabase.GetAssetPath (objs [i]);

                        AnimationOpt animopt = _GetNewAOpt (p);

                        if (animopt != null)

                            assets.Add (animopt);

                    }

                    else

                        path.Add(AssetDatabase.GetAssetPath (objs [i]));

                }

                if(path.Count > 0)

                    guids = AssetDatabase.FindAssets (string.Format ("t:{0}", typeof(AnimationClip).ToString().Replace("UnityEngine.", "")), path.ToArray());

                else

                    guids = new string[]{};

            }

            for(int i = 0; i < guids.Length; i++)

            {

                string assetPath = AssetDatabase.GUIDToAssetPath (guids [i]);

                AnimationOpt animopt = _GetNewAOpt (assetPath);

                if (animopt != null)

                    assets.Add (animopt);

            }

            return assets;

        }

    }

}

动画文件后处理可以做两件事,精度压缩,scale曲线剔除。
比起用工具修改原始fbx文件,这样比较灵活。

实际测试,在开启Optimal压缩的情况下,加上这个后处理,能再节省40%左右。

void OnPostprocessModel(GameObject g) {

       // for skeleton animations.

      

              List<AnimationClip> animationClipList = new List<AnimationClip>(AnimationUtility.GetAnimationClips(g));

              if (animationClipList.Count == 0) {

                    AnimationClip[] objectList = UnityEngine.Object.FindObjectsOfType (typeof(AnimationClip)) as AnimationClip[];

                    animationClipList.AddRange(objectList);

              }

             

              foreach (AnimationClip theAnimation in animationClipList)

              {

                    try

                    {

                           //去除scale曲线

                           foreach (EditorCurveBinding theCurveBinding in AnimationUtility.GetCurveBindings(theAnimation))

                           {

                                  string name = theCurveBinding.propertyName.ToLower();

                                  if (name.Contains("scale"))

                                  {

                                         AnimationUtility.SetEditorCurve(theAnimation, theCurveBinding, null);

                                  }

                           }

                           //浮点数精度压缩到f3

                           AnimationClipCurveData[] curves = null;

                           curves = AnimationUtility.GetAllCurves(theAnimation);

                   Keyframe key;

                   Keyframe[] keyFrames;

                   for (int ii = 0; ii < curves.Length; ++ii)

                   {

                       AnimationClipCurveData curveDate = curves[ii];

                       if (curveDate.curve == null || curveDate.curve.keys == null)

                       {

                           //Debug.LogWarning(string.Format("AnimationClipCurveData {0} don't have curve; Animation name {1} ", curveDate, animationPath));

                           continue;

                       }

                       keyFrames = curveDate.curve.keys;

                       for (int i = 0; i < keyFrames.Length; i++)

                       {

                           key = keyFrames[i];

                           key.value = float.Parse(key.value.ToString("f3"));

                           key.inTangent = float.Parse(key.inTangent.ToString("f3"));

                           key.outTangent = float.Parse(key.outTangent.ToString("f3"));

                           keyFrames[i] = key;

                       }

                       curveDate.curve.keys = keyFrames;

                                  theAnimation.SetCurve(curveDate.path, curveDate.type, curveDate.propertyName, curveDate.curve);

                   }

                    }

                    catch (System.Exception e)

                    {

                           Debug.LogError(string.Format("CompressAnimationClip Failed !!! animationPath : {0} error: {1}", assetPath, e));

                    }

              }

             

}

测试:不同压缩格式的AnimationClip资源加载效率测试
我们制作了三组测试用例,AnimationClip资源数量分别为10个、30个和50个。同时,每组AnimationClip又根据其压缩格式的不同分为三小组:None Compression、Keyframe Reduction和Optimal。

我们在三种不同档次的机型上加载这些AnimationClip资源,为降低偶然性,每台设备上重复进行十次加载操作并将取其平均值作为最终性能开销。具体测试结果如下表所示。

第1组测试
10个“None Compression”资源、10个“Keyframe Reduction”资源和10个“Optimal”资源,打包成AssetBundle文件后,其文件大小分别为:409KB、172KB和92KB。

第2组测试
30个“None Compression”资源、30个“Keyframe Reduction”资源和30个“Optimal”资源,打包成AssetBundle文件后,其文件大小分别为:1.42MB、514KB和312KB。

第3组测试
50个“None Compression”资源、50个“Keyframe Reduction”资源和50个“Optimal”资源,打包成AssetBundle文件后,其文件大小分别为:2.46MB、858KB和525KB。

通过上述测试,我们可以得到以下结论:

  1. Optimal压缩方式确实可以提升资源的加载效率,无论是在高端机、中端机还是低端机上;
  2. 硬件设备性能越好,其加载效率越高。但随着设备的提升,Keyframe Reduction和Optimal的加载效率提升已不十分明显;
  3. Optimal压缩方式可能会降低动画的视觉质量,因此,是否最终选择Optimal压缩模式,还需根据最终视觉效果的接受程度来决定。

只裁剪精度使BlobSize减小的实例

裁剪精度前,大小为2.2kb,ScaleCurve为0, ConstantCurve为4(57.1%),Stream(使用Optimal模式这部分数据将储存为Dense)为3(42.9%)。

取两帧的 animation 曲线去操作 /

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

namespace CompressTool
{
    public static class SETTING
    {
        public static class FILTER
        {
            //缩放属性剔除(变化误差范围 x 分之一)
            public readonly static int ERR_RANGE_SCALE_PROPERTY = 1000;
            //相同关键帧剔(除误差范围 X 分之一)
            public readonly static int ERR_RANGE_SAME_FRAME = 10000;
        }

        //精度压缩(根据曲线变化坡度压缩,坡度越大精度越高,坡度越小精度越小)
        public static class ACCURACY
        {
            //精度1级 坡度阀值
            public readonly static float THRESHOLD1 = 0;
            //精度2级 坡度阀值
            public readonly static float THRESHOLD2 = 0.1f;
            //精度1级(小数点后3位)
            public readonly static string LEVEL1 = "f3";
            //精度2级(小数点后4位)
            public readonly static string LEVEL2 = "f4";
            //精度3级(小数点后5位)
            public readonly static string LEVEL3 = "f5";
        }
    }


    public class CompressOpt
    {
        public AnimationClip AnimClip { private set; get; }
        public string AnimClipPath { private set; get; }
        private HashSet<string> mScaleBonePaths;
        private Dictionary<string, float> mGradientVals;
        public CompressOpt(AnimationClip animClip, string animClipPath)
        {
            AnimClip = animClip;
            AnimClipPath = animClipPath;
            mGradientVals = new Dictionary<string, float>();
        }

        public void SetScaleBonePaths(HashSet<string> scaleBonePaths)
        {
            mScaleBonePaths = scaleBonePaths;
        }

        private bool Approximately(Keyframe a, Keyframe b)
        {
            return Mathf.Abs(a.value - b.value) * SETTING.FILTER.ERR_RANGE_SAME_FRAME < 1f &&
                Mathf.Abs(a.inTangent - b.inTangent) * SETTING.FILTER.ERR_RANGE_SAME_FRAME < 1f &&
                Mathf.Abs(a.outTangent - b.outTangent) * SETTING.FILTER.ERR_RANGE_SAME_FRAME < 1f &&
                Mathf.Abs(a.inWeight - b.inWeight) * SETTING.FILTER.ERR_RANGE_SAME_FRAME < 1f &&
                Mathf.Abs(a.outWeight - b.outWeight) * SETTING.FILTER.ERR_RANGE_SAME_FRAME < 1f;
        }


        private string GetCurveKey(string path, string propertyName)
        {
            var splits = propertyName.Split('.');
            var name = splits[0];
            return string.Format("{0}/{1}", path, name);
        }

        //获取曲线坡度
        private float GetCurveThreshold(string path, string propertyName)
        {
            var curveKey = GetCurveKey(path, propertyName);
            float threshold = 0;
            mGradientVals.TryGetValue(curveKey, out threshold);
            return threshold;
        }

        //设置曲线坡度
        private void SetCurveThreshold(string path, string propertyName, float threshold)
        {
            var curveKey = GetCurveKey(path, propertyName);
            if (!mGradientVals.ContainsKey(curveKey))
                mGradientVals.Add(curveKey, threshold);
            else
                mGradientVals[curveKey] = threshold;
        }

        //获取曲线压缩精度
        private string GetCompressAccuracy(string path, string propertyName)
        {
            var threshold = GetCurveThreshold(path, propertyName);
            if (threshold <= SETTING.ACCURACY.THRESHOLD1)
                return SETTING.ACCURACY.LEVEL1;
            else if (threshold <= SETTING.ACCURACY.THRESHOLD2)
                return SETTING.ACCURACY.LEVEL2;
            return SETTING.ACCURACY.LEVEL3;
        }

        public void Compress()
        {
            if (AnimClip != null)
            {
                var curveBindings = AnimationUtility.GetCurveBindings(AnimClip);
                for (int i = 0; i < curveBindings.Length; i++)
                {
                    EditorCurveBinding curveBinding = curveBindings[i];
                    float threshold = GetCurveThreshold(curveBinding.path, curveBinding.propertyName);

                    string name = curveBinding.propertyName.ToLower();
                    var curve = AnimationUtility.GetEditorCurve(AnimClip, curveBinding);
                    var keys = curve.keys;
                    if (name.Contains("scale"))
                    {
                        //优化scale曲线
                        if (!mScaleBonePaths.Contains(curveBinding.path))
                        {
                            AnimationUtility.SetEditorCurve(AnimClip, curveBinding, null);
                            continue;
                        }
                    }

                    float bottomVal = 999999;
                    float topVal = -999999;

                    //优化采样点数量
                    List<Keyframe> newFrames = new List<Keyframe>();
                    if (keys.Length > 0)
                    {
                        newFrames.Add(keys[0]);
                        var lastSameFrameIndex = 0;
                        var comparerFrameIndex = 0;
                        for (int j = 1; j < keys.Length; j++)
                        {
                            var curFrame = keys[j];
                            var comparerFrame = keys[comparerFrameIndex];
                            if (Approximately(curFrame, comparerFrame))
                            {
                                lastSameFrameIndex = j;
                            }
                            else
                            {
                                if (lastSameFrameIndex > comparerFrameIndex)
                                    newFrames.Add(keys[lastSameFrameIndex]);
                                newFrames.Add(keys[j]);
                                comparerFrameIndex = j;
                            }
                            bottomVal = Mathf.Min(bottomVal, keys[j].value);
                            topVal = Mathf.Max(topVal, keys[j].value);
                        }

                        if (newFrames.Count == 1)
                            newFrames.Add(keys[keys.Length - 1]);//最少两帧

                        if (newFrames.Count != keys.Length)
                        {
                            curve.keys = newFrames.ToArray();
                            //Debug.LogFormat("{0}=>{1}", keys.Length, newFrames.Count);
                            AnimationUtility.SetEditorCurve(AnimClip, curveBinding, curve);
                        }
                    }

                    SetCurveThreshold(curveBinding.path, curveBinding.propertyName, Mathf.Max(threshold, topVal - bottomVal));
                }

                //优化精度
                AnimationClipCurveData[] curves = AnimationUtility.GetAllCurves(AnimClip);
                if (curves != null && curves.Length > 0)
                {
                    for (int i = 0; i < curves.Length; i++)
                    {
                        AnimationClipCurveData curveDate = curves[i];
                        if (curveDate.curve == null || curveDate.curve.keys == null)
                            continue;

                        string accuracy = GetCompressAccuracy(curveDate.path, curveDate.propertyName);
                        Keyframe[] keyFrames = curveDate.curve.keys;
                        for (int j = 0; j < keyFrames.Length; j++)
                        {
                            Keyframe key = keyFrames[j];
                            key.value = float.Parse(key.value.ToString(accuracy));
                            //切线固定精度
                            key.inTangent = float.Parse(key.inTangent.ToString("f3"));
                            key.outTangent = float.Parse(key.outTangent.ToString("f3"));
                            keyFrames[j] = key;
                        }
                        curveDate.curve.keys = keyFrames;
                        AnimClip.SetCurve(curveDate.path, curveDate.type, curveDate.propertyName, curveDate.curve);
                    }
                }
            }
        }
    }


    public class AnimClipDirectory
    {
        public string Path { get; }
        public List<string> AnimClipPaths { get; private set; }
        public List<CompressOpt> CompressOpts { get; private set; }
        public AnimClipDirectory(string directory)
        {
            Path = directory;
            AnimClipPaths = new List<string>();
            CompressOpts = new List<CompressOpt>();
        }

        public void AddAnimClipPath(string animClipPath)
        {
            AnimClipPaths.Add(animClipPath);
        }

        //分析被缩放的所有骨骼路径
        public void Analyse()
        {
            HashSet<string> scaleBonePaths = new HashSet<string>();
            for (int i = 0; i < AnimClipPaths.Count; i++)
            {
                var assetPath = AnimClipPaths[i];
                AnimationClip clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(assetPath);
                CompressOpts.Add(new CompressOpt(clip, assetPath));
                AnimationClipCurveData[] curves = AnimationUtility.GetAllCurves(clip);
                if (curves != null && curves.Length > 0)
                {
                    for (int j = 0; j < curves.Length; j++)
                    {
                        string name = curves[j].propertyName.ToLower();
                        if (name.Contains("scale"))
                        {
                            AnimationClipCurveData curveDate = curves[j];
                            if (curveDate.curve == null || curveDate.curve.keys == null)
                                continue;
                            var keyFrames = curveDate.curve.keys;
                            bool isScaleChanged = false;
                            if (keyFrames.Length > 0)
                            {
                                var frist = keyFrames[0].value;
                                if (Mathf.Abs(frist - 1f) * SETTING.FILTER.ERR_RANGE_SCALE_PROPERTY > 1f) //如果第一帧大小变了
                                    isScaleChanged = true;
                                else
                                {
                                    for (int k = 1; k < keyFrames.Length; k++)
                                    {
                                        if (Mathf.Abs(keyFrames[k].value - frist) * SETTING.FILTER.ERR_RANGE_SCALE_PROPERTY > 1f) //如果差异超过千分之一,则不可删除
                                        {
                                            isScaleChanged = true;
                                            break;
                                        }
                                    }
                                }
                            }
                            if (isScaleChanged)
                                scaleBonePaths.Add(curves[j].path);
                        }
                    }
                }
            }

            for (int i = 0; i < CompressOpts.Count; i++)
            {
                CompressOpts[i].SetScaleBonePaths(scaleBonePaths);
            }

        }
    }


    public class AnimClipCompressTool
    {
        private enum ProcessType
        {
            Analyse,
            Compress,
            Finish,
        }
        private static ProcessType mCurProcess;
        private static List<AnimClipDirectory> mAnimClipDirectoryList;
        private static List<CompressOpt> mCompressOptList;
        private static int mIndex = 0;

        [MenuItem("Assets/TA/Compress AnimationClip", priority = 2001)]
        public static void Optimize()
        {
            Dictionary<string, AnimClipDirectory> animClipPaths = new Dictionary<string, AnimClipDirectory>();
            var selectObjs = Selection.objects;
            if (selectObjs != null && selectObjs.Length > 0)
            {
                for (int i = 0; i < selectObjs.Length; i++)
                {
                    var assetPath = AssetDatabase.GetAssetPath(selectObjs[i]);
                    GetAllAnimClipPaths(assetPath, ref animClipPaths);
                }
            }

            mAnimClipDirectoryList = new List<AnimClipDirectory>();
            mAnimClipDirectoryList.AddRange(animClipPaths.Values);
            mCompressOptList = new List<CompressOpt>();

            mIndex = 0;
            mCurProcess = ProcessType.Analyse;

            if (mAnimClipDirectoryList.Count > 0)
                EditorApplication.update = Update;
            else
                EditorUtility.DisplayDialog("Tips", "can not found AnimationClip file!", "ok");
        }

        private static void Update()
        {
            if (mCurProcess == ProcessType.Analyse)
            {
                AnimClipDirectory animClipDirectory = mAnimClipDirectoryList[mIndex];
                bool isCancel = EditorUtility.DisplayCancelableProgressBar(string.Format("正在读取AnimationClip文件夹信息[{0}/{1}])", mIndex, mAnimClipDirectoryList.Count), animClipDirectory.Path, (float)mIndex / (float)mAnimClipDirectoryList.Count);
                if (isCancel)
                    mCurProcess = ProcessType.Compress;
                else
                {
                    animClipDirectory.Analyse();
                    mIndex++;
                    if (mIndex >= mAnimClipDirectoryList.Count)
                    {
                        for (int i = 0; i < mAnimClipDirectoryList.Count; i++)
                            mCompressOptList.AddRange(mAnimClipDirectoryList[i].CompressOpts);

                        if (mCompressOptList.Count > 0)
                            mCurProcess = ProcessType.Compress;
                        else
                            mCurProcess = ProcessType.Finish;
                        mIndex = 0;
                    }
                }
            }
            else if (mCurProcess == ProcessType.Compress)
            {
                CompressOpt compressOpt = mCompressOptList[mIndex];
                bool isCancel = EditorUtility.DisplayCancelableProgressBar(string.Format("正在压缩AnimationClip文件[{0}/{1}]", mIndex, mCompressOptList.Count), compressOpt.AnimClipPath, (float)mIndex / (float)mCompressOptList.Count);
                if (isCancel)
                    mCurProcess = ProcessType.Finish;
                else
                {
                    compressOpt.Compress();
                    mIndex++;
                    if (mIndex >= mCompressOptList.Count)
                        mCurProcess = ProcessType.Finish;
                }
            }
            else if (mCurProcess == ProcessType.Finish)
            {
                mAnimClipDirectoryList = null;
                mCompressOptList = null;
                mIndex = 0;
                EditorUtility.ClearProgressBar();
                Resources.UnloadUnusedAssets();
                GC.Collect();
                AssetDatabase.SaveAssets();
                EditorApplication.update = null;
            }


        }

        private static void GetAllAnimClipPaths(string assetPath, ref Dictionary<string, AnimClipDirectory> animClipPaths)
        {
            if (IsDirectory(assetPath))
            {
                if (!assetPath.Contains(".."))
                {
                    string[] paths = System.IO.Directory.GetFileSystemEntries(assetPath);
                    for (int i = 0; i < paths.Length; i++)
                    {
                        var path = paths[i];
                        if (IsDirectory(path))
                        {
                            GetAllAnimClipPaths(path, ref animClipPaths);
                        }
                        else
                        {
                            if (path.EndsWith(".anim"))
                            {
                                var directoryPath = GetFileDirectoryPath(path);
                                if (!animClipPaths.ContainsKey(directoryPath))
                                    animClipPaths.Add(directoryPath, new AnimClipDirectory(directoryPath));
                                animClipPaths[directoryPath].AddAnimClipPath(path);
                            }
                        }
                    }
                }
            }
            else
            {
                if (assetPath.EndsWith(".anim"))
                {
                    var directoryPath = GetFileDirectoryPath(assetPath);
                    if (!animClipPaths.ContainsKey(directoryPath))
                        animClipPaths.Add(directoryPath, new AnimClipDirectory(directoryPath));
                    animClipPaths[directoryPath].AddAnimClipPath(assetPath);
                }
            }
        }

        private static bool IsDirectory(string assetPath)
        {
            Debug.Log(System.IO.File.GetAttributes(assetPath));
            return System.IO.File.GetAttributes(assetPath) == System.IO.FileAttributes.Directory;
        }

        private static string GetFileDirectoryPath(string filePath)
        {
            var fileName = System.IO.Path.GetFileName(filePath);
            var directoryPath = filePath.Replace(fileName, "");
            return directoryPath;
        }

    }
}

assetbundle//

AssetBundle加载基础

通过AssetBundle加载资源,分为两步,第一步是获取AssetBundle对象,第二步是通过该对象加载需要的资源。而第一步又分为两种方式,下文中将结合常用的API进行详细地描述。

一、获取AssetBundle对象的常用API

(1)先获取WWW对象,再通过WWW.assetBundle获取AssetBundle对象:

·        publicWWW(string url);
加载Bundle文件并获取WWW对象,完成后会在内存中创建较大的WebStream(解压后的内容,通常为原Bundle文件的4~5倍大小,纹理资源比例可能更大),因此后续的AssetBundle.Load可以直接在内存中进行。

public static WWW LoadFromCacheOrDownload(stringurl, int version, uint crc = 0);

·        加载Bundle文件并获取WWW对象,同时将解压形式的Bundle内容存入磁盘中作为缓存(如果该Bundle已在缓存中,则省去这一步),完成后只会在内存中创建较小的SerializedFile,而后续的AssetBundle.Load需要通过IO从磁盘中的缓存获取。

·        publicAssetBundle assetBundle;
通过之前两个接口获取WWW对象后,即可通过WWW.assetBundle获取AssetBundle对象。

(2) 直接获取AssetBundle:

·        publicstatic AssetBundle CreateFromFile(string path);
通过未压缩的Bundle文件,同步创建AssetBundle对象,这是最快的创建方式。创建完成后只会在内存中创建较小的SerializedFile,而后续的AssetBundle.Load需要通过IO从磁盘中获取。

·        publicstatic AssetBundleCreateRequest CreateFromMemory(byte[] binary);
通过Bundle的二进制数据,异步创建AssetBundle对象。完成后会在内存中创建较大的WebStream。调用时,Bundle的解压是异步进行的,因此对于未压缩的Bundle文件,该接口与CreateFromMemoryImmediate等价。

·        publicstatic AssetBundle CreateFromMemoryImmediate(byte[] binary);
该接口是CreateFromMemory的同步版本。

·        注:5.3下分别改名为LoadFromFile,LoadFromMemory,LoadFromMemoryAsync并增加了LoadFromFileAsync,且机制也有一定的变化,可详见Unity官方文档。

二、从AssetBundle加载资源的常用API

·        publicObject Load(string name, Type type);
通过给定的名字和资源类型,加载资源。加载时会自动加载其依赖的资源,即Load一个Prefab时,会自动Load其引用的Texture资源。

·        publicObject[] LoadAll(Type type);
一次性加载Bundle中给定资源类型的所有资源。

·        publicAssetBundleRequest LoadAsync(string name, Type type);
该接口是Load的异步版本。

·        注:5.x下分别改名为LoadAsset,LoadAllAssets,LoadAssetAsync,并增加了LoadAllAssetsAsync。

AssetBundle加载进阶

一、接口对比:new WWW与WWW.LoadFromCacheOrDownload

(1)前者的优势

·        后续的Load操作在内存中进行,相比后者的IO操作开销更小;

·        不形成缓存文件,而后者则需要额外的磁盘空间存放缓存;

·        能通过WWW.texture,WWW.bytes,WWW.audioClip等接口直接加载外部资源,而后者只能用于加载AssetBundle

(2)前者的劣势

·        每次加载都涉及到解压操作,而后者在第二次加载时就省去了解压的开销;

·        在内存中会有较大的WebStream,而后者在内存中只有通常较小的SerializedFile。(此项为一般情况,但并不绝对,对于序列化信息较多的Prefab,很可能出现SerializedFile比WebStream更大的情况)

二、内存分析


在管理AssetBundle时,了解其加载过程中对内存的影响意义重大。在上图中,我们在中间列出了AssetBundle加载资源后,内存中各类物件的分布图,在左侧则列出了每一类内存的产生所涉及到的加载API:

·        WWW对象:在第一步的方式1中产生,内存开销小;

·        WebStream:在使用new WWW或CreateFromMemory时产生,内存开销通常较大;

·        SerializedFile:在第一步中两种方式都会产生,内存开销通常较小;

·        AssetBundle对象:在第一步中两种方式都会产生,内存开销小;

·        资源(包括Prefab):在第二步中通过Load产生,根据资源类型,内存开销各有大小;

·        场景物件(GameObject):在第二步中通过Instantiate产生,内存开销通常较小。
在后续的章节中,我们还将针对该图中各类内存物件分析其卸载的方式,从而避免内存残留甚至泄露。

三、注意点

·        CreateFromFile只能适用于未压缩的AssetBundle,而Android系统下StreamingAssets是在压缩目录(.jar)中,因此需要先将未压缩的AssetBundle放到SD卡中才能对其使用CreateFromFile。
Application.streamingAsstsPath = "jar:file://" +Application.dataPath+"!/assets/";

·        iOS系统有256个开启文件的上限,因此,内存中通过CreateFromFile或WWW.LoadFromCacheOrDownload加载的AssetBundle对象也会低于该值,在较新的版本中,如果LoadFromCacheOrDownload超过上限,则会自动改为new WWW的形式加载,而较早的版本中则会加载失败。

·        CreateFromFile和WWW.LoadFromCacheOrDownload的调用会增加RersistentManager.Remapper的大小,而PersistentManager负责维护资源的持久化存储,Remapper保存的是加载到内存的资源HeapID与源数据FileID的映射关系,它是一个Memory Pool,其行为类似Mono堆内存,只增不减,因此需要对这两个接口的使用做合理的规划。

·        对于存在依赖关系的Bundle包,在加载时主要注意顺序。举例来说,假设CanvasA在BundleA中,所依赖的AtlasB在BundleB中,为了确保资源正确引用,那么最晚创建BundleB的AssetBundle对象的时间点是在实例化CanvasA之前。即,创建BundleA的AssetBundle对象时、Load(“CanvasA”)时,BundleB的AssetBundle对象都可以不在内存中。

·        根据经验,建议AssetBundle文件的大小不超过1MB,因为在普遍情况下Bundle的加载时间与其大小并非呈线性关系,过大的Bundle可能引起较大的加载开销。

·        由于WWW对象的加载是异步的,因此逐个加载容易出现下图中CPU空闲的情况(选中帧处Vsync占了大部分),此时建议适当地同时加载多个对象,以增加CPU的使用率,同时加快加载的完成。

AssetBundle卸载

前文提到了通过AssetBundle加载资源时的内存分配情况,下面,我们结合常用的API来介绍如何将已分配的内存进行卸载,最终达到清空所有相关内存的目的。

一、内存分析

在上图中的右侧,我们列出了各种内存物件的卸载方式:

·        场景物件(GameObject):这类物件可通过Destroy函数进行卸载;

·        资源(包括Prefab):除了Prefab以外,资源文件可以通过三种方式来卸载:
1) 通过Resources.UnloadAsset卸载指定的资源,CPU开销小;
2)通过Resources.UnloadUnusedAssets一次性卸载所有未被引用的资源,CPU开销大;
3)通过AssetBundle.Unload(true)在卸载AssetBundle对象时,将加载出来的资源一起卸载。
而对于Prefab,目前仅能通过DestroyImmediate来卸载,且卸载后,必须重新加载AssetBundle才能重新加载该Prefab。由于内存开销较小,通常不建议进行针对性地卸载。

·        WWW对象:调用对象的Dispose函数或将其置为null即可;

·        WebStream:在卸载WWW对象以及对应的AssetBundle对象后,这部分内存即会被引擎自动卸载;

·        SerializedFile:卸载AssetBundle后,这部分内存会被引擎自动卸载;

·        AssetBundle对象:AssetBundle的卸载有两种方式:
1)通过AssetBundle.Unload(false),卸载AssetBundle对象时保留内存中已加载的资源;
2)通过AssetBundle.Unload(true),卸载AssetBundle对象时卸载内存中已加载的资源,由于该方法容易引起资源引用丢失,因此并不建议经常使用;

二、注意点

在通过AssetBundle.Unload(false)卸载AssetBundle对象后,如果重新创建该对象并加载之前加载过的资源到内存时,会出现冗余,即两份相同的资源。
被脚本的静态变量引用的资源,在调用Resources.UnloadUnusedAssets时,并不会被卸载,在Profiler中能够看到其引用情况。

UWA推荐方案

通过以上的讲解,相信您对AssetBundle的加载和卸载已经有了明确的了解。下面,我们将简单地做一下API选择上的推荐:

·        对于需要常驻内存的Bundle文件来说,优先考虑减小内存占用,因此对于存放非Prefab资源(特别是纹理)的Bundle文件,可以考虑使用WWW.LoadFromCacheOrDownload或AssetBundle.CreateFromFile加载,从而避免WebStream常驻内存;而对于存放较多Prefab资源的Bundle,则考虑使用new WWW加载,因为这类Bundle用WWW.LoadFromCacheOrDownload加载时产生的SerializedFile可能会比new WWW产生的WebStream更大。

·        对于加载完后即卸载的Bundle文件,则分两种情况:优先考虑速度(加载场景时)和优先考虑流畅度(游戏进行时)。
1)加载场景的情况下,需要注意的是避免WWW对象的逐个加载导致的CPU空闲,可以考虑使用加载速度较快的WWW.LoadFromCacheOrDownload或AssetBundle.CreateFromFile,但需要避免后续大量地进行Load资源的操作,引起IO开销(可以尝试直接LoadAll)。
2) 游戏进行的情况下,则需要避免使用同步操作引起卡顿,因此可以考虑使用new WWW配合AssetBundle.LoadAsync来进行平滑的资源加载,但需要注意的是,对于Shader、较大的Texture等资源,其初始化操作通常很耗时,容易引起卡顿,因此建议将这类资源在加载场景时进行预加载。

·        只在Bundle需要加密的情况下,考虑使用CreateFromMemory,因为该接口加载速度较慢。

·        尽量避免在游戏进行中调用Resources.UnloadUnusedAssets(),因为该接口开销较大,容易引起卡顿,可尝试使用Resources.Unload(obj)来逐个进行卸载,以保证游戏的流畅度。

AssetBundle 打包(4.x)基础

基本介绍

(1)常用打包API

public static bool BuildAssetBundle(ObjectmainAsset, Object[] assets,

string pathName, out uint crc,BuildAssetBundleOptions assetBundleOptions,

BuildTarget targetPlatform);

public static stringBuildStreamedSceneAssetBundle(string[] levels,

string locationPath, BuildTarget target, outuint crc, BuildOptions options);

·        BuildPipeline.BuildAssetBundle
对除Scene以外的资源打包,支持单个和多个;

·        BuildPipeline.BuildStreamedSceneAssetBundle
对Scene文件打包,也支持单个和多个。

(2)常用打包选项(BuildAssetBundleOptions)

·        CompleteAssets
用于保证资源的完备性。比如,当你仅打包一个Mesh资源并开启了该选项时,引擎会将Mesh资源和相关GameObject一起打入AssetBundle文件中;

·        CollectDependencies
用于收集资源的依赖项。比如,当你打包一个Prefab并开启了该选项时,引擎会将该Prefab用到的所有资源和Component全部打入AssetBundle文件中;

·        DeterministicAssetBundle
用于为资源维护固定ID,以便进行资源的热更新。

以上选项均已在5.x新机制中默认开启。因此在4.x版本中,开发者如果没有深入了解每个选项的意义,我们建议也都开启。

三个选项开启的情况下打包,可以保证在加载并实例化其中的Prefab时不会出现资源引用丢失的情况,因为所有依赖的资源都在包中。这也意味着,如果Prefab-A和Prefab-B引用了同一个Asset-A且分别打包时,两个包中就都会包含Asset-A。

加载到内存后,通过Profiler会发现Asset-A的冗余资源。

然而很多时候,并不希望把两个Prefab打在一个Bundle中,此时,就需要通过依赖性打包来解决。

依赖性打包

依赖性打包的作用在于避免资源冗余,同时提高资源加载和卸载的灵活性,其重要性不言而喻。在4.x版本的AssetBundle打包系统中,涉及一对 BuildPipeline.PushAssetDependencies和BuildPipeline.PopAssetDependencies接口,从官方文档中可以大致了解其用法:http://docs.unity3d.com/ScriptReference/BuildPipeline.PushAssetDependencies.html

你可以简单地认为,PushAssetDependencies是将资源进栈,PopAssetDependencies是让资源出栈,每打一个包,引擎都会检查当前栈中所有的依赖项,查看是否有相同资源已经在栈中。如有,则与其相关的AssetBundle建立依赖关系。机制不难理解,但使用中依然有几个容易忽视的注意点,请移步下文进阶篇。


AssetBundle 打包(4.x)进阶

注意点

·        进行一次Push,多次Build操作,如依次Build资源Prefab-A,Prefab-B时,可以认为Prefab-A,Prefab-B会依次 进栈,所以如果两者之间也存在共享资源,则后者会依赖前者。具体表现为,运行时先加载Prefab-B会出现共享资源丢失的情况。

·        4.x中脚本也会作为“共享资源”参与依赖性打包,即当Prefab-A和Prefab-B同时挂有脚本M时,如果出现了上一点中的情况,那么后者同样会依赖前者。具体表现为,运行时先加载Prefab-B会出现脚本M丢失。

·        将shader放入GraphicsSettings->Always IncludedShaders中后,打包时会将相应的shader抽离,运行时加载时会自动加载其依赖的shader。同时也意味着,如果修改了Always Included Shaders或在一个新建项目中使用该Bundle,会出现shader丢失的问题。

·        当需要更新bundle内容,但不改变依赖关系时,仍然需要重打其依赖的Bundle包。即如果Bundle-B依赖Bundle-A,那么在更新Bundle-A时可以不需要重打Bundle-B(前提是开启了DeterministicAssetBundle);但要更新Bundle-B的话,则必须重打Bundle-A。

AssetBundle打包(5.x)基础

基本介绍

(1)唯一API

public static AssetBundleManifestBuildAssetBundles(string outputPath, BuildAssetBundleOptions      assetBundleOptions =BuildAssetBundleOptions.None,BuildTarget targetPlatform =BuildTarget.WebPlayer);

调用BuildPipeline.BuildAssetBundles,引擎将自动根据资源的assetbundleName属性(以下简称abName)批量打包,自动建立Bundle以及资源之间的依赖关系。

(2)打包规则

在资源的Inpector界面最下方可设置一个abName,每个abName(包含路径)对应一个Bundle,即abName相同的资源会打在一个Bundle中。如果所依赖的资源设置了不同的abName,则会与之建立依赖关系,避免出现冗余。

支持增量式发布,即在资源内容改变并重新打包时,会自动跳过内容未变的Bundle。因此,相比4.x,会极大地缩短更新Bundle的时间。

(3) 新打包选项

除了前文提到的,5.x下默认开启的三个选项(CompleteAssets ,用于保证资源的完备性;CollectDependencies,用于收集资源的依赖项;DeterministicAssetBundle,用于为资源维护固定ID)之外,5.x中新增了以下选项:

·        ForceRebuildAssetBundle
用于强制重打所有AssetBundle文件;

·        IgnoreTypeTreeChanges
用于判断AssetBundle更新时,是否忽略TypeTree的变化;

·        AppendHashToAssetBundleName
用于将Hash值添加在AssetBundle文件名之后,开启这个选项,可以直接通过文件名来判断哪些Bundle的内容进行了更新(4.x下普遍需要通过比较二进制等方法来判断,但在某些情况下即使内容不变重新打包,Bundle的二进制也会变化)。

与4.x不同的是,对于移动平台,5.x下默认会将TypeTree信息写入AssetBundle,因此在移动平台上DisableWriteTypeTree选项也变得有意义了。

(4)Manifest文件

在4.x版本中,我们通常需要自行维护配置文件,以记录AssetBundle之间的依赖关系,并供运行时使用。而在5.x版本中,Manifest文件可以免去这一过程。

(5) Variant参数

Variant参数能够让AssetBundle方便地进行“多分辨率支持”,相关详解请移步下文。


AssetBundle 打包(5.x)进阶

在新系统中,添加了以下两个实用的新特性,也许能够给开发者带来事半功倍的效果。

(1) Manifest文件

在打包后生成的文件夹中,每个Bundle都会对应一个manifest文件,记录了Bundle的一些信息,但这类manifest只在增量式打包时才用到;同时,根目录下还会生成一个同名manifest文件及其对应的Bundle文件,通过该Bundle可以在运行时得到一个AssetbundleManifest对象,而所有的Bundle以及各自依赖的Bundle都可以通过该对象提供的接口进行获取。

(2) Variant参数

在资源的Inspector界面最下方,除了可以指定abName,在其后方还可以指定Variant。打包时,Variant会作为后缀添加在Bundle名字之后。相同abName,不同variant的Bundle中,资源必须是一一对应的,且他们在Bundle中的ID也是相同的,从而可以起到相互替换的作用。

当需要为手机和平板上的某个UI界面使用两套分辨率不同的纹理、Shader,以及文字提示时,借助Variant的特性,只需创建两个文件夹,分别放置两套不同的资源,且资源名一一对应,然后给两个文件夹设置相同的abName和不同的variant,再给UI界面设置abName,然后进行打包即可。运行时,先选择合适的依赖包加载,那么后续加载UI界面时,会根据已加载的依赖包,呈现出相对应的版本。

开发者注意事项

·        abName可通过脚本进行设置和清除,也可以通过构造一个AssetBundleBuild数组来打包。

·        新机制打包无法指定Assetbundle.mainAsset,因此无法再通过mainAsset来直接获取资源。

·        开启DisableWriteTypeTree可能造成AssetBundle对Unity版本的兼容问题,但会使Bundle更小,同时也会略微提高加载速度。

·        Prefab之间不会建立依赖,即如果Prefab-A和Prefab-B引用了同一张纹理,而他们设置了不同的abName,而共享的纹理并未设置abName,那么Prefab-A和Prefab-B可视为分别打包,各自Bundle中都包含共享的纹理。因此在使用UGUI,开启Sprite Packer时,由于Atlas无法标记abName,在设置UI界面Prefab的abName时就需要注意这个问题。

·        5.x中加入了Shader stripping功能,在打包时,默认情况下会根据当前场景的Lightmap及Fog设置对资源中的Shader进行代码剥离。这意味着,如果在一个空场景下进行打包,则Bundle中的Shader会失去对Lightmap和Fog的支持,从而出现运行时Lightmap和Fog丢失的情况.而通过将Edit->ProjectSettings->Graphics下shader Stripping中的modes改为Manual,并勾选相应的mode即可避免这一问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值