Unity引擎的SpriteAtlas图集功能使用介绍

  大家好,我是阿赵。
  图集功能,对于制作游戏的朋友应该是很熟悉的。它可以把多张散图合并在一张大图里面,在实际渲染的时候,可以极大的减少DrawCall数量。
  Unity引擎对于UI图集打包有2种方式,分别是旧版本的Sprite Packer和新版本的Sprite Atlas。

一、新旧版本图集功能的介绍:

在这里插入图片描述

  在Project Setting里面的Editor页,找到Sprite Packer,点开Mode,可以看得到有5个选项。
第一个选项是关闭,意思是2种图集都不使用。剩下的四个选项,下面介绍一下:

1、旧版本的Sprint Packer

在这里插入图片描述

  前两个带着Legacy的,就是旧版本的Sprite Packer。Enabled for Builds的意思是只有打包AssetBundle或者打包安装包的时候生效。Always Enabled的意思是所有情况都生效,包括在编辑器状态下。
  一般来说,使用的时候如果要在编辑器就看到图集的效果,就要选择Always。不过如果项目里面的图集图片非常多的情况下,每次打开编辑器首次运行或者当有图片修改的时候,Unity引擎都会重新生成图集,这个过程是非常慢的。所以有时候只是为了看功能,一般选择Enabled for Builds就可以。
  当选择了Legacy的 Enabled for Builds或者Always之后,在图片的导入属性里面会多了Packing Tag项,这里可以输入图集名字,图集名字相同的所有图片将会生成在同一个图集里面。
在这里插入图片描述

  如果想看看图集的最终合并效果,可以打开Window——2D——Sprite Packer
在这里插入图片描述

  这里可以看到合并图集的效果:
在这里插入图片描述

  由于这一篇主要不是介绍Sprite Packer,所以就不展开说明了。

2、 新版本的Sprite Atlas

  回到刚才Project Setting的Editor页里面,在Mode里面最下面的两项,就是新版本SpriteAtlas的选项:
在这里插入图片描述

  同样是有Enabled For Builds和Always Enabled两项,含义和上面的一样,一个是只有打包时生效,另一个是在所有情况都生效。
  Sprite Atlas的用法和Sprite Packer区别挺大,下面看看具体是怎样使用。

1. 创建Sprite Atlas

  在Project的文件里面,要先创建一个Sprite Atlas文件。鼠标右键——Create——Sprite Atlas。我的习惯一般是把需要打包在同一个图集里面的图片放在同一个文件夹,然后把Sprite Atlas文件也创建在同一个文件夹内,Sprite Atlas文件的文件名和文件夹名相同,所以这里的Sprite Atlas文件名就是文件名“icon”。这样比较容易管理。
在这里插入图片描述

  创建出来Sprite Atlas文件之后,需要手动把该图集包含的图片拖动到Objects for Packing列表里面。然后点击一下Pack Preview按钮,就可以立刻看到当前图集里面的图片生成图集后的情况。
在这里插入图片描述

2. Master和Variant

  Sprite Atlas有一个很特别的功能,就是允许生成图集的变体。
  当我们生成一个图集的时候,它的Type是Master的,说明它是一个主图集:
在这里插入图片描述

  然后Sprite Atlas允许我们创建这个图集的变体,然后调整图集的缩放。这样做的好处是,假如游戏本身需要高质量和低质量2套资源,那么可以通过图集变体,直接生成多一套低质量的图集来使用。
  具体的操作是,再创建一个Sprite Atlas文件(我的命名习惯是在原图集的文件名后面加low字母,所以文件名会是iconlow),然后把Type选择成Variant,然后指定一个之前做好的图集作为Master Atlas。然后下面的Scale选项就是图集的缩放,比如我这里输入的是0.6,意思就是新生成的图集的大小将会是原来图集的0.6倍。
在这里插入图片描述

  仔细观察下面生成的图集预览,会发现图集总大小从512x512变成了308x308
在这里插入图片描述

二、 Sprite Atlas的性能分析

1、渲染合并

  当通过Sprite Atlas加载同一个图集里面的多张图片时(加载的代码下面会说),会发现Batches是不会增加的,因为它们多张图实际上是存在于同一张图集大图里面。
在这里插入图片描述

  如果我既加载正常的Sprite Atlas的Master图集里面的图片,又加载Variant变体图集里面的图片,会发现两者是不能合并的:
在这里插入图片描述

  左边这个图集,就是低质量的图片,可以看得出,左边的图片是会模糊一些。

2、内存占用

  使用Profiler来采样一下内存,会发现实际上现在是有2张图集图片在内存里面,一张是512x512的,另外一张是308x308的。可以看出,低质量的那张图集,占用的内存同样也小一些。所以如果在游戏里面预先设置了高低不同的质量选项,那么在有需要的时候只加载低质量的图集,可以时游戏的运行时占用内存变低。
在这里插入图片描述

3、 内存回收

  在回收内存的时候,如果对于实际的资源,我们一般会用Resources.UnloadAsset(object)来回收单个资源。对于Sprite Atals来说,我们应该怎样回收呢?
  如果直接回收加载出来的Sprite本身,会报错UnloadAsset can only be used on assets;
  如果回收从AssetBundle里面加载的SpriteAtlas对象,实际上内存是不会释放的。
  有效的回收方式是:

1. Resources.UnloadUnusedAssets

  在确保没有任何引用该图集的资源时,具体是指Sprite Atlas加载出来的Sprite,包括没有代码上的引用,也没有场景里面的物体使用时,执行Resources.UnloadUnusedAssets();可以释放掉内存里面已经生成的图集图片。

2. AssetBundle.Unload(true)

  这是终极绝招了,所有通过AssetBundle加载出来的资源,使用这个终极绝招,不论是否有引用,都会释放。

三、Sprite Atlas打包AssetBundle

  现在有2个Sprite Atlas,一个是Master的icon,另外一个Variant的iconlow。

1、高低质量分别打包

  如果单独对这两个文件设置不同的AssetBundleName,分别打包出来,可以看到:
icon:
在这里插入图片描述

iconlow:
在这里插入图片描述

  这种情况下,如果需要不同质量的图集,可以加载不同的AssetBundle,然后加载对应的SpriteAtlas。

2、只打包高质量

  如果iconlow不设置AssetBundleName,只设置icon:
在这里插入图片描述

  这个时候,会发现虽然只打包了高质量的图集,但低质量的图集的Texture也包含在里面。
  不过这个时候,如果加载了这个AssetBundle,然后再想通过iconlow的名字加载里面的SpriteAtlas,是加载不了的。
  所以这是一种浪费,AssetBundle的容量大了,但加载不到低质量的图集。

3、 把高低质量的图集打包在同一个AssetBundle

  如果我把icon和iconlow这两个SpriteAtlas都设置成一样的AssetBundleName,然后打包AssetBundle,毫无疑问,这个AssetBundle里面会包含高质量和低质量两个图集的图片
在这里插入图片描述

  这时候我通过这个AssetBundle加载iconlow的图集,是可以成功加载的。

  所以,是把高低质量的图集分开打包AssetBundle,还是把它们都打包在同一个AssetBundle其实都是可以的,可以根据自己的加载策略来决定。如果分开打包,就可以单独修改低质量的图集单独更新。如果在一起打包,我觉得管理起来会比较方便,不存在漏了一个导致加载出错。我自己是比较倾向于把高低质量打包在同一个AssetBundle加载的。
  其实如果把散图也设置和icon图集同一个AssetBundleName,打在同一个AssetBundle里面,由于已经包含了图集的大图,这些小图也不会真的重复打出去的。如果单纯是把图集加载来读取Sprite,散图是不需要设置AssetBundleName的,但如果是被其他预设引用着,图片本身如果不设置AssetBundleName只设置SpriteAtlas的AssetBundleName,那么预设是不会有这个图片的依赖的。
  所以,为了统一处理,需要注意:为了预设的依赖,在设置AssetBundleName的时候,散图和SpriteAtlas一起设置同一个AssetBundleName。

四、SpriteAtlas的代码读取方式:

  需要注意一点,在Editor设置里面,一定要把Mode设置成Always Enabled:
在这里插入图片描述
  如果设置了Disabled或者是Enabled For Builds,不论是从外部AssetBundle加载,还是从编辑器类用AssetDatabase加载的SpriteAtlas对象,从SpriteAtlas对象里面加载出来的Sprite,将会出现Texture为空的情况,加载出来的内容将会是一片空白。
  由于是基于SpriteAtlas 这个对象来加载Sprite,所以核心的代码是:
  先通过各种方式获取SpriteAtlas对象

SpriteAtlas sa;

  然后从SpriteAtlas对象里面获取Sprite

sa.GetSprite(spriteName);

1、 在Unity编辑器读取项目本地的Sprite:

  在项目开发的过程中,不一定随时会打包AssetBundle,我们一般会希望直接放图片到项目里面,就能正常读取。所以需要一个编辑器加载图集的方法。
  其实如果是在编辑器内,通过路径直接用AssetDatabase.LoadAssetAtPath也是可以加载到合并图集之前的图片的。不过那样就没有图集的效果。所以还是希望通过SpriteAtlas.GetSprite(spriteName)来加载。这里我设置了一个变量叫做“isLoadLowSprite”,这个变量可以通过全局设置,控制是否需要加载低质量的图集。所以在加载的过程中,就不需要再传参数了。

    private Sprite GetSpriteFromEditor(string atlasName, string spriteName)
    {
    
    
        string realAtlasName = "";
        if (isLoadLowSprite)
        {
    
    
            realAtlasName = atlasName + "low";
        }
        else
        {
    
    
            realAtlasName = atlasName;
        }
        string path = "Assets/UI/atlas/" + atlasName + "/" + realAtlasName + ".spriteatlas";
        SpriteAtlas sa = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(path);
        if(sa == null)
        {
    
    
            return null;
        }
        return sa.GetSprite(spriteName);
}

  值得庆幸的是,就算在ProjectSetting里面把图集功能关闭了,我们还是可以通过上面的代码加载出想显示的Sprite的,只是没有了图集合并的效果而已。所以在编辑器里面可以用统一的方法去加载图集里的图片。

2、 读取AssetBundle:

  打包AssetBundle后如果要加载回来,可以这样做:

private Sprite GetSpriteFromAB(string atlasName, string spriteName)
{
    
    
    SpriteAtlas sa = GetSpriteAtlas(atlasName);
    if(sa == null)
    {
    
    
        return null;
    }
    else
    {
    
    
        return sa.GetSprite(spriteName);
    }

}

private SpriteAtlas GetSpriteAtlas(string atlasName)
{
    
    
    string realAtlasName = "";
    if(isLoadLowSprite)
    {
    
    
        realAtlasName = atlasName+"low";
    }
    else
    {
    
    
        realAtlasName = atlasName;
    }
    if(spriteAtlasDict!=null&&spriteAtlasDict.ContainsKey(realAtlasName))
    {
    
    
        return spriteAtlasDict[realAtlasName];
    }
    AssetBundle ab = GetAB(atlasName);
    if(ab == null)
    {
    
    
        return null;
    }
    SpriteAtlas sa = ab.LoadAsset<SpriteAtlas>(realAtlasName);
    if(sa == null)
    {
    
    
        return null;
    }
    else
    {
    
    
        return sa;
    }
}

3、 综合加载

  有了上面的不同方法加载图集图片之后,就可以写一个加载类,用于在开发的过程中可以切换加载项目内资源和AssetBundle,然后在发布之后,就只加载AssetBundle:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.U2D;
#if UNITY_EDITOR
using UnityEditor;
#endif

public class AtlasLoader
{
    
    
    private bool isLoadLowSprite = false;
    private bool isLoadLocal = false;
    private Dictionary<string, SpriteAtlas> spriteAtlasDict;
    private Dictionary<string,AssetBundle> abDict;
    private Dictionary<string, int> abFailDict;
    private static AtlasLoader _instance;

    static public AtlasLoader Instance {
    
    
        get
        {
    
    
            if(_instance == null)
            {
    
    
                _instance = new AtlasLoader();
            }
            return _instance;
        }
    }

    public void SetIsLow(bool val)
    {
    
    
        isLoadLowSprite = val;
    }

    public void SetIsLocal(bool val)
    {
    
    
        isLoadLocal = val;
    }

    public Sprite GetSprite(string atlasName, string spriteName)
    {
    
    

#if UNITY_EDITOR
        if(isLoadLocal)
        {
    
    
            return GetSpriteFromEditor(atlasName, spriteName);
        }
        else
        {
    
    
            return GetSpriteFromAB(atlasName, spriteName);
        }
#else
        return GetSpriteFromAB(atlasName, spriteName);
#endif
    }
     
    private Sprite GetSpriteFromAB(string atlasName, string spriteName)
    {
    
    
        SpriteAtlas sa = GetSpriteAtlas(atlasName);
        if(sa == null)
        {
    
    
            return null;
        }
        else
        {
    
    
            return sa.GetSprite(spriteName);
        }

    }

    private SpriteAtlas GetSpriteAtlas(string atlasName)
    {
    
    
        string realAtlasName = "";
        if(isLoadLowSprite)
        {
    
    
            realAtlasName = atlasName+"low";
        }
        else
        {
    
    
            realAtlasName = atlasName;
        }
        if(spriteAtlasDict!=null&&spriteAtlasDict.ContainsKey(realAtlasName))
        {
    
    
            return spriteAtlasDict[realAtlasName];
        }
        AssetBundle ab = GetAB(atlasName);
        if(ab == null)
        {
    
    
            return null;
        }
        SpriteAtlas sa = ab.LoadAsset<SpriteAtlas>(realAtlasName);
        if(sa == null)
        {
    
    
            return null;
        }
        else
        {
    
    
            return sa;
        }
    }

    private AssetBundle GetAB(string atlasName)
    {
    
            
        if(abDict!=null&&abDict.ContainsKey(atlasName))
        {
    
    
            return abDict[atlasName];
        }
        if(abFailDict!=null&&abFailDict.ContainsKey(atlasName))
        {
    
    
            return null;
        }
        string abName = "atlas/" + atlasName + ".unity3d";
        abName = abName.ToLower();
        string path = Application.streamingAssetsPath + "/ab/" + abName;
        AssetBundle ab = AssetBundle.LoadFromFile(path);
        if(ab !=null)
        {
    
    
            if(abDict == null)
            {
    
    
                abDict = new Dictionary<string, AssetBundle>();
            }
            abDict.Add(atlasName, ab);
            return ab;
        }
        else
        {
    
    
            if(abFailDict==null)
            {
    
    
                abFailDict = new Dictionary<string, int>();
            }
            abFailDict.Add(atlasName, 1);
            return null;
        }
    }

#if UNITY_EDITOR
    private Sprite GetSpriteFromEditor(string atlasName, string spriteName)
    {
    
    
        string realAtlasName = "";
        if (isLoadLowSprite)
        {
    
    
            realAtlasName = atlasName + "low";
        }
        else
        {
    
    
            realAtlasName = atlasName;
        }
        string path = "Assets/UI/atlas/" + atlasName + "/" + realAtlasName + ".spriteatlas";
        SpriteAtlas sa = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(path);
        if(sa == null)
        {
    
    
            return null;
        }
        return sa.GetSprite(spriteName);
    }
#endif
}

五、关于IncludeInBuild

在这里插入图片描述
  在SpriteAtlas文件设置里面有一个Include in Build的选项。这个选项默认是勾选上的,它可以在打包AssetBundle或者app的时候,保存预设和图集的引用关系。
  网上很多人都说需要把这个选项取消掉,这是因为在之前有些Unity版本有一个bug,如果勾选了这个选项,会导致引用图集的预设或者场景里面包含着图集,导致图片重复打包。不过这个bug已经在后续的Unity版本修复了。
  如果勾选了Include In Build,并且使用的是没有bug的Unity版本,那么使用上是很方便的。因为我们就按正常加载图集之后加载预设,预设就能自动找到它需要的图片并且显示。
  但如果是使用有bug的Unity版本,就不能勾选这个选项了。不勾选这个选项,加载完预设之后,它不会自动找到需要的图片,所以预设上的图显示的是一片白色。这时候他会触发SpriteAtlasManager.atlasRequested方法,并且会传入一个图集名称。我们可以通过SpriteAtlasManager.atlasRequested的回调方法来把预设需要的图集文件塞回去,让它找到具体需要用到的图片。
  这里举个例子,假如我的图集在一个AssetBundle里面,叫做a1,然后预设在另一个AssetBundle里面,叫做a2。但由于a1的图集并没有勾选Include In Build,所以加载完a2的预设之后,图片并没有显示出来。这时候,我们需要自己通过依赖关系,知道图集是在a1的AssetBundle里面,然后通过回调方法来加载图集。

void OnEnable()
{
    SpriteAtlasManager.atlasRequested += RequestAtlas;
}

void OnDisable()
{
    SpriteAtlasManager.atlasRequested -= RequestAtlas;
}

void RequestAtlas(string tag, System.Action<SpriteAtlas> callback)
{
    Debug.Log("RequestAtlas:" + tag);
    var sa = a1.LoadAsset<SpriteAtlas>(tag);
    callback(sa);
}

  我们不需要理会RequestAtlas方法什么时候调用,也不需要知道callback具体是什么内容,只要我们从a1的AssetBundle里面加载出SpriteAtlas并且回调给callback,那么a2预设上面自然就会能显示出对应的图片了

六、 代码自动生成SpriteAtlas

  在上面介绍Sprite Atlas用法时,都是鼠标右键创建Sprite Atlas文件,然后手动去添加图片,手动设置参数和AssetBundleName,这个过程其实很繁琐,而且很容易出错。接下来我提供一个自动设置工具。这个工具是右键选择想要打图集的文件,然后选择BuildAtlas,就会自动生成并设置好Sprite Atlas。我这个工具只是针对单个文件夹的,各位可以根据自己的项目具体情况,修改一下。
在这里插入图片描述

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

public class SpriteAtlasBuild
{
    [MenuItem("Assets/BuildAtlas")]
    static void BuildAtlas()
    {
        Object[] obj = Selection.GetFiltered(typeof(UnityEngine.Object), SelectionMode.Assets);
        if (obj == null || obj.Length == 0)
        {
            return;
        }
        string path = AssetDatabase.GetAssetPath(obj[0]);
        BuildOneFolderAtlas(path);
    }

    static private void BuildOneFolderAtlas(string path)
    {
        string folderPath = AssetPathToFilePath(path);
        string[] subFiles = GetSubFiles(folderPath);
        if (subFiles == null ||subFiles.Length==0)
        {
            return;
        }
        List<string> textureFiles = new List<string>();
        string exName;
        for(int i = 0;i<subFiles.Length;i++)
        {
            exName = GetExName(subFiles[i]).ToLower();
            if(exName == "png"||exName=="jpg"||exName=="tga")
            {
                textureFiles.Add(subFiles[i]);
            }
        }
        if(textureFiles.Count==0)
        {
            return;
        }
        string realPath = path.Replace("Assets/UI/", "");
        string[] realPathStrs = realPath.Split('/');
        string folderName = GetLastName(path);
        
        string atlasPath1 = folderPath + "/" + folderName + ".spriteatlas";
        string atlasPath2 = folderPath + "/" + folderName + "low.spriteatlas";
        string atlasAssetPath1 = FilePathToAssetPath(atlasPath1);
        string atlasAssetPath2 = FilePathToAssetPath(atlasPath2);
        string abName = realPathStrs[0]+"/" + folderName + ".unity3d";
        SpriteAtlas sp1;
        SpriteAtlas sp2;
        if(File.Exists(atlasPath1)==false)
        { 
            sp1 = new SpriteAtlas();
            AssetDatabase.CreateAsset(sp1, atlasAssetPath1);
            AssetDatabase.SaveAssets();
        }
        sp1 = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasAssetPath1);
        if (File.Exists(atlasPath2) == false)
        {
            sp2 = new SpriteAtlas();
            AssetDatabase.CreateAsset(sp2, atlasAssetPath2);
            AssetDatabase.SaveAssets();
        }        
        sp2 = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasAssetPath2);
        Debug.Log(sp2);
        bool isTextureDirty = false;
        List<Sprite> spriteList = new List<Sprite>();
        Object[] packables = sp1.GetPackables();
        List<Sprite> packableList = new List<Sprite>();
        for (int i = 0; i < packables.Length; i++)
        {
            packableList.Add((Sprite)packables[i]);
        }
        string subAssetPath;
        TextureImporter ti;
        Sprite sp;
        for (int i = 0;i< textureFiles.Count;i++)
        {
            isTextureDirty = false;
            subAssetPath = FilePathToAssetPath(textureFiles[i]);
            ti = (TextureImporter)TextureImporter.GetAtPath(subAssetPath);
            if(ti == null)
            {
                continue;
            }
            if(ti.textureType != TextureImporterType.Sprite)
            {
                ti.textureType = TextureImporterType.Sprite;
                isTextureDirty = true;
            }
            if(ti.assetBundleName !=abName)
            {
                ti.assetBundleName = abName;
                isTextureDirty = true;
            }
            if(isTextureDirty)
            {
                ti.SaveAndReimport();
            }
            sp = AssetDatabase.LoadAssetAtPath<Sprite>(subAssetPath);
            if (packableList.IndexOf(sp)<0)
            {
            	spriteList.Add(sp);
            }            
        }
        SpriteAtlasPackingSettings setting = new SpriteAtlasPackingSettings();        
        setting.enableRotation = false;
        setting.enableTightPacking = false;
        setting.padding = 4;
        sp1.SetPackingSettings(setting);
        sp1.SetIsVariant(false);
        sp2.SetIsVariant(true);
        sp2.SetMasterAtlas(sp1);
        sp2.SetVariantScale(0.6f);
        sp1.Add(spriteList.ToArray());
        AssetImporter ai1 = AssetImporter.GetAtPath(atlasAssetPath1);
        if(ai1!=null)
        {
            ai1.assetBundleName = abName;
        }
        AssetImporter ai2 = AssetImporter.GetAtPath(atlasAssetPath2);
        if (ai2 != null)
        {
            ai2.assetBundleName = abName;
        }
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
    }

    static private string rootPath;
    static private string GetRootPath()
    {
        if(string.IsNullOrEmpty(rootPath))
        {
            rootPath = Application.dataPath;
            int lastIndex = rootPath.LastIndexOf("/");
            rootPath = rootPath.Substring(0, lastIndex)+"/";
        }
        return rootPath;
    }

    /// <summary>
    /// Unity路径转文件路径
    /// </summary>
    /// <param name="path"></param>
    /// <returns></returns>
    static private string AssetPathToFilePath(string path)
    {       
        return GetRootPath() + path;
    }
    /// <summary>
    /// 文件路径转Unity路径
    /// </summary>
    /// <param name="path"></param>
    /// <returns></returns>
    static private string FilePathToAssetPath(string path)
    {
        return path.Replace(GetRootPath(), "");
    }
    /// <summary>
    /// 获取某个文件夹下的所有文件
    /// </summary>
    /// <param name="folderPath"></param>
    /// <returns></returns>
    static private string[] GetSubFiles(string folderPath)
    {
        string[] subFilePaths = Directory.GetFiles(folderPath);
        return subFilePaths;
    }
    /// <summary>
    /// 获取扩展名
    /// </summary>
    /// <param name="path"></param>
    /// <returns></returns>
    static private string GetExName(string path)
    {
        int lastIndex = path.LastIndexOf(".");
        if(lastIndex >= path.Length-1)
        {
            return "";
        }
        return path.Substring(lastIndex + 1, path.Length - lastIndex - 1);
    }
    /// <summary>
    /// 获取文件名
    /// </summary>
    /// <param name="path"></param>
    /// <returns></returns>
    static private string GetLastName(string path)
    {
        int lastIndex = path.LastIndexOf("/");
        if (lastIndex >= path.Length - 1)
        {
            return "";
        }
        return path.Substring(lastIndex + 1, path.Length - lastIndex - 1);
    }
}

其中有一段

Object[] packables = sp1.GetPackables();
List<Sprite> packableList = new List<Sprite>();
for (int i = 0; i < packables.Length; i++)
{
    packableList.Add((Sprite)packables[i]);
}

然后判断

if (packableList.IndexOf(sp)<0)
{
	spriteList.Add(sp);
}      

是为了防止重复添加同一个sprite到SpriteAtlas对象中。

  我自己的实际项目里面,会再提供一个修改图片导入的脚步,当图片放进文件夹之后,就自动会设置图集,这样会更方便一点。这个做法的代码就不放出来了,各位有兴趣可以自己研究一下。

猜你喜欢

转载自blog.csdn.net/liweizhao/article/details/142535260