打包策略

  1. 制作编辑器工具,统一设置AB包名,与路径管理
  2. 根据依赖关系生成不冗余AB包
  3. 根据Asset全路径生成依赖关系表
  4. 根据依赖关系表加载资源 / 编辑器下直接通过AssetDataBase加载资源

配置文件

打包一共有两种配置

  1. 以文件夹为单位,一个文件夹打成一个包
  2. 文件夹下的所有文件打成单独的一个包(一般是Prefab)

使用ScriptableObject制作配置表

public class AssetBundleBuildConfig : ScriptableObject
{
    public const string buildConfigName = "AssetBundleBuildConfig";
    
    // 是否生成XML文件(默认配置文件是bytes,xml用于debug)
    public bool buildXML = true;
    
    // 配置文件的保存路径,以及配置文件的名称
    public string configPath = "Assets/Resources";
    public string configName = "AssetBundleConfig";
    
    // 打包路径
    public string targetPath;
    
    // 该文件夹路径下的所有Prefab都会被单独打成一个AB包,包名即Prefab名
    public List<string> prefabList;
    
    // 该文件夹会被打成一个AB包,文件夹名即包名
    public List<string> assetList;
}

生成配置表的Editor脚本,保证全局只有一个配置表,并且只能生成在Resources目录下

public class CreateConfig 
{
    [MenuItem("Assets/Create/AssetModule/Create Config")]
    private static void Create()
    {
        var config = Resources.Load<AssetBundleBuildConfig>(AssetBundleBuildConfig.buildConfigName);
        // 说明没有配置表
        if (config == null)
        {
            if (!Directory.Exists("Assets/Resources"))
                Directory.CreateDirectory("Assets/Resources");
            var asset = ScriptableObject.CreateInstance<AssetBundleBuildConfig>();

            AssetDatabase.CreateAsset(asset, $"Assets/Resources/{AssetBundleBuildConfig.buildConfigName}.asset");
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            EditorGUIUtility.PingObject(asset);
        }
        else
        {
            EditorGUIUtility.PingObject(config);
            Debug.Log("资源管理 - 配置文件已经存在");
        }
    }
}

稍微修改一下编辑器界面

[CustomEditor(typeof(AssetBundleBuildConfig))]
public class AssetBundleBuildConfigInspector : Editor
{
#region Field
    private AssetBundleBuildConfig script;

    private SerializedProperty buildXML;
    private SerializedProperty configName;
    
    private SerializedProperty targetPath;
    private SerializedProperty prefabs;
    private SerializedProperty assets;

    private ReorderableList prefabList;
    private ReorderableList assetList;
    
    // 所有的文件夹AB包,key是包名,value是路径
    private Dictionary<string, string> folderBundles = new Dictionary<string, string>();
    // 所有单独打包的AB包
    private Dictionary<string, List<string>> prefabBundles = new Dictionary<string, List<string>>();
    // 过滤
    private List<string> filter = new List<string>();
    // XML的过滤
    private List<string> xmlFilter = new List<string>();
#endregion

#region 生命周期
    private void OnEnable()
    {
        script = target as AssetBundleBuildConfig;

        buildXML = serializedObject.FindProperty(nameof(AssetBundleBuildConfig.buildXML));
        configName = serializedObject.FindProperty(nameof(AssetBundleBuildConfig.configName));
        targetPath = serializedObject.FindProperty(nameof(AssetBundleBuildConfig.targetPath));
        prefabs = serializedObject.FindProperty(nameof(AssetBundleBuildConfig.prefabList));
        assets = serializedObject.FindProperty(nameof(AssetBundleBuildConfig.assetList));

        prefabList = new ReorderableList(serializedObject, prefabs);
        assetList = new ReorderableList(serializedObject, assets);

        RegisterList(prefabList,prefabs,"以下文件夹内的每一个Prefab都会被打成一个AB包");
        RegisterList(assetList,assets,"以下文件夹会被打成一个AB包");
    }

    public override void OnInspectorGUI()
    {
        serializedObject.Update();
        Draw();
        
        EditorGUILayout.Space();
        EditorGUILayout.Space();
        prefabList.DoLayoutList();
        
        EditorGUILayout.Space();
        EditorGUILayout.Space();
        assetList.DoLayoutList();
        serializedObject.ApplyModifiedProperties();

        Build();
    }

#endregion

#region 面板
    private void RegisterList(ReorderableList list, SerializedProperty property,string title)
    {
        list.drawHeaderCallback = rect =>
        {
            EditorGUI.LabelField(rect,title);
        };

        list.drawElementCallback = (rect, index, active, focused) =>
        {
            var item = property.GetArrayElementAtIndex(index);

            var folderRect = new Rect(rect)
            {
                y = rect.y + 2.5f,
                width = (rect.width - 5f) / 4f,
                height = rect.height - 5,
            };
            var folder = EditorGUI.ObjectField(folderRect, AssetDatabase.LoadAssetAtPath<DefaultAsset>(item.stringValue), typeof(DefaultAsset), true);
            item.stringValue = AssetDatabase.GetAssetPath(folder);

            var pathRect = new Rect(rect)
            {
                y = rect.y + 2.5f,
                x = rect.x + folderRect.width + 5,
                width = (rect.width - 5) / 4f * 3f,
                height = rect.height-5
            };
            GUI.enabled = false;
            EditorGUI.TextField(pathRect,item.stringValue);
            GUI.enabled = true;
        };
    }

    private void Draw()
    {
        // AssetBundle的保存路径
        EditorGUILayout.BeginHorizontal();
        GUI.enabled = false;
        EditorGUIUtility.labelWidth = 55f;
        EditorGUILayout.TextField("保存路径",targetPath.stringValue);
        GUI.enabled = true;
        if (GUILayout.Button("...", GUILayout.Width(100))) 
        {
            var tempPath = EditorUtility.OpenFolderPanel("保存路径", targetPath.stringValue, "");
            // 只能选择StreamingAsset下的目录
            var streamingAssetsPath = Application.streamingAssetsPath;
            if (!tempPath.Contains(streamingAssetsPath))
            {
                Debug.LogError("保存路径必须位于项目路径以下");
                targetPath.stringValue = "";
                return;
            }

            var index = tempPath.IndexOf("Assets");
            targetPath.stringValue = tempPath.Substring(index);
        }
        EditorGUILayout.EndHorizontal();


        EditorGUILayout.Space();
        EditorGUILayout.Space();

        // 是否生成XML,以及配置文件的名称
        EditorGUILayout.BeginHorizontal();
        EditorGUIUtility.labelWidth = 80f;
        buildXML.boolValue = EditorGUILayout.Toggle("生成XML文件", buildXML.boolValue, GUILayout.Width(120f));
        configName.stringValue = EditorGUILayout.TextField("配置文件名称", configName.stringValue);
        EditorGUILayout.LabelField(".bytes",GUILayout.Width(40f));
        EditorGUILayout.EndHorizontal();
    }
#endregion
}

image-20220405183351875

生成

1.不冗余AB包

有3个目的

  1. 根据单个文件和文件夹设置AB包
  2. 剔除冗余AB包
  3. 生成AB包配置表

如果每一个Prefab都单独打成一个包,那么我应该把Prefab依赖的所有资源也打入这个包内

但是,比如Prefab依赖于某一张贴图A,但是这张贴图A与其他贴图B C D E都已经被打入了某一个贴图AB包内,此时我们打包Prefab时就不应该再继续打包这张贴图了

image-20220327175930792

首先,先要知道哪些文件夹会被打成AB包

// 文件夹名:文件夹路径
private Dictionary<string, string> folderBundles = new Dictionary<string, string>();
// 过滤
private List<string> filter = new List<string>();

private void Build()
{
    if (GUILayout.Button("打包", GUILayout.Height(40)))
    {
        folderBundles.Clear();
        filter.Clear();
            
        // 所有资源
        for (int i = 0; i < script.assetList.Count; i++)
        {
            var path = script.assetList[i];
            var bundleName = path.Substring(path.LastIndexOf("/")+1);
            if (!folderBundles.ContainsKey(bundleName))
            {
                folderBundles.Add(bundleName, path);
                filter.Add(path);
            }
            else
            {
                throw new Exception($"重复的文件夹:{bundleName}");
            }
        }
    }
}

关键在于filter,我们会把每一个文件夹的路径都添加到filter里

如果某一个Prefab的依赖资源包含该路径,则说明该依赖资源已经被打入资源包内,不需要再次被打入Prefab包

// 所有的文件夹AB包,key是包名,value是路径
private Dictionary<string, string> folderBundles = new Dictionary<string, string>();
// 所有单独打包的AB包
private Dictionary<string, string> prefabBundles = new Dictionary<string, string>();
// 过滤
private List<string> filter = new List<string>();

private void Build()
{
    if (GUILayout.Button("打包", GUILayout.Height(40)))
    {
        ...

        // 所有单独打包的Prefab
        var allFolderAssetGUID = AssetDatabase.FindAssets("t:prefab", script.prefabList.ToArray());
        for (int i = 0; i < allFolderAssetGUID.Length; i++)
        {
            var path = AssetDatabase.GUIDToAssetPath(allFolderAssetGUID[i]);
            var bundleName = path.Substring(path.LastIndexOf("/") + 1);
            bundleName = bundleName.Substring(0, bundleName.Length - 7);
                        
            var depend = AssetDatabase.GetDependencies(path);
            var tempDepend = new List<string>();
            for (int j = 0; j < depend.Length; j++)
            {
                if (!ContainAsset(depend[j]) && !depend[j].EndsWith(".cs"))
                {
                    filter.Add(depend[j]);
                    tempDepend.Add(depend[j]);
                }
            }
            if (!prefabBundles.ContainsKey(bundleName)) 
                prefabBundles.Add(bundleName,tempDepend);
            else
                throw new Exception($"重复的Prefab:{bundleName}");
        } 
    }
}

// 如果一个资源的路径中包含有资源文件夹的路径,说明该资源肯定已经被打入资源包了
// A/B/CC/TEST.prefab是会包含 A/B/C 这个目录的,所有&&是有必要的
private bool ContainAsset(string path)
{
    for (int i = 0; i < filter.Count; i++)
    {
        if (path == filter[i] || (path.Contains(filter[i]) && path.Replace(filter[i], "")[0] == '/')) 
            return true;
    }
    return false;
}

然后设置标签,并且在最开始我们应该清楚所有的标签

// 所有的文件夹AB包,key是包名,value是路径
private Dictionary<string, string> folderBundles = new Dictionary<string, string>();
// 所有单独打包的AB包
private Dictionary<string, List<string>> prefabBundles = new Dictionary<string, List<string>>();
// 过滤
private List<string> filter = new List<string>();

private void Build()
{
    if (GUILayout.Button("打包", GUILayout.Height(40)))
    {
        Clear();
        
        ...
        
        // 设置Bundle
        foreach (var bundle in folderBundles)
            SetBundle(bundle.Key, bundle.Value);

        foreach (var bundle in prefabBundles)
            SetBundle(bundle.Key, bundle.Value);

        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();

        // 测试
        BuildPipeline.BuildAssetBundles(script.targetPath, BuildAssetBundleOptions.None,
            BuildTarget.StandaloneWindows);
    }
}

private void SetBundle(string name, string path)
{
    var asset = AssetImporter.GetAtPath(path);
    if (asset == null)
        throw new Exception($"不存在{path}");
    asset.assetBundleName = name;
}

private void SetBundle(string name, List<string> paths)
{
    for (int i = 0; i < paths.Count; i++)
        SetBundle(name, paths[i]);
}

private void Clear()
{
    var bundles = AssetDatabase.GetAllAssetBundleNames();
    for (int i = 0; i < bundles.Length; i++)
        AssetDatabase.RemoveAssetBundleName(bundles[i], true);
} 

此时我们的资源引用关系与打包关系如下

image-20220327212222418image-20220327212326434

再观察Mainfest文件

image-20220327212405113

符合预期

2.Crc32验证

我们这个框架加载资源时是以资源全路径为加载目录的,例如:

AssetModule.Load("Assets/Test/Cube.prefb");

此时我们肯定需要去配置文件中读取相关的配置,而能够和配置文件相关联的只有这个资源的路径,即”Assets/Test/Cube.prefb”

但是我们有必要把这么长一串(实际项目只可能更长)字符串存入到配置文件中去吗?那会非常的浪费

所以可以采用一种压缩格式,对资源路径进行压缩(也是为了方便校验)

这里采用Crc32校验,核心原理是把一个字符串转译为一串uint数字

public class CRC32
{
    static UInt32[] crcTable =
    {
        0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005,
        0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd,
        0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9, 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75,
        0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd,
        0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5,
        0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81, 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d,
        0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95,
        0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d,
        0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae, 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072,
        0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca,
        0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02,
        0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066, 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba,
        0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692,
        0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a,
        0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e, 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2,
        0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a,
        0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb,
        0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f, 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53,
        0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b,
        0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623,
        0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7, 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b,
        0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3,
        0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b,
        0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f, 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3,
        0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c,
        0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24,
        0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30, 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec,
        0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654,
        0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c,
        0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18, 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4,
        0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c,
        0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4
    };


    public static uint GetCRC32(string str)
    {
        var bytes = Encoding.UTF8.GetBytes(str);
        return GetCRC32(bytes);
    }

    private static uint GetCRC32(byte[] bytes)
    {
        uint iCount = (uint)bytes.Length;
        uint crc = 0xFFFFFFFF;

        for (uint i = 0; i < iCount; i++)
        {
            crc = (crc << 8) ^ crcTable[(crc >> 24) ^ bytes[i]];
        }

        return crc;
    }
}

3.配置文件

XML所需要的属性

[Serializable]
public class AssetConfig
{
    public uint crc;   // 资源CRC32验证
    public string bundleName; // 所属Bundle
    public string assetName; // 资源名称
    public List<string> dependence; // 依赖的Bundle Name
}

[Serializable]
public class AssetBundleConfig
{
    public List<AssetConfig> bundleList;
}

生成XML配置文件

private void Build()
{
    if (GUILayout.Button("打包", GUILayout.Height(40)))
    {
        ...

        BuildConfig();
        BuildBundle();
    }
}

private void BuildConfig()
{
    // 所有的BundleName
    var bundleNames = AssetDatabase.GetAllAssetBundleNames();
    // 资源路径:资源所属Bundle
    var assetInBundle = new Dictionary<string, string>();
    for (int i = 0; i < bundleNames.Length; i++)
    {
        // 某一个Bundle下所有的资源路径
        var assetPath = AssetDatabase.GetAssetPathsFromAssetBundle(bundleNames[i]);
        for (int j = 0; j < assetPath.Length; j++)
        {
            if (assetPath[j].EndsWith(".cs") || !IsValidPath(assetPath[j]))
                continue;
            assetInBundle.Add(assetPath[j], bundleNames[i]);
        }
    }

    AssetBundleConfig config = new AssetBundleConfig();
    config.bundleList = new List<AssetConfig>();
    foreach (var assetInfo in assetInBundle)
    {
        var asset = new AssetConfig();
        // assetInfo.key 是资源路径,我们不会把资源路径放入配置文件,而是放入对应的CRC32验证码
        asset.crc = CRC32.GetCRC32(assetInfo.Key);
        asset.bundleName = assetInfo.Value;
        asset.assetName = assetInfo.Key.Remove(0, assetInfo.Key.LastIndexOf("/", StringComparison.Ordinal) + 1);
        asset.dependence = new List<string>();

        var tempDep = AssetDatabase.GetDependencies(assetInfo.Key);
        for (int i = 0; i < tempDep.Length; i++)
        {
            if (tempDep[i] == assetInfo.Key || tempDep[i].EndsWith(".cs"))
                continue;
            if (assetInBundle.TryGetValue(tempDep[i], out var depBundle))
            {
                if (depBundle == assetInfo.Value)
                    continue;
                if (!asset.dependence.Contains(depBundle))
                    asset.dependence.Add(depBundle);
            }
        }

        config.bundleList.Add(asset);
    }

    if (Directory.Exists(script.configPath))
        Directory.CreateDirectory(script.configPath);

    // XML
    if (buildXML.boolValue)
    {
        var xmlPath = $"{script.configPath}/{script.configName}.xml";
        if (File.Exists(xmlPath))
            File.Delete(xmlPath);
        using (var stream = new FileStream(xmlPath, FileMode.OpenOrCreate, FileAccess.ReadWrite))
        {
            using (var writer = new StreamWriter(stream, Encoding.UTF8))
            {
                var xs = new XmlSerializer(typeof(AssetBundleConfig));
                xs.Serialize(writer, config);
            }
        }
    }

    // bytes
    var bytesPath = $"{script.configPath}/{script.configName}.bytes";
    if (File.Exists(bytesPath))
        File.Delete(bytesPath);
    using (var stream = new FileStream(bytesPath, FileMode.OpenOrCreate, FileAccess.ReadWrite))
    {
        var bf = new BinaryFormatter();
        bf.Serialize(stream, config);
    }
}

还有一点问题,此时会把我们项目里的所有资源都写入XML中,但实际上一部分资源我们是永远不会手动加载的,比如资源文件夹下的资源,那都是加载Prefab时自动加载的,我们并不关心,我们只关心那些我们真正会手动加载的资源

// XML的过滤
private List<string> xmlFilter = new List<string>();

private void Build()
{
    if (GUILayout.Button("打包", GUILayout.Height(40)))
    {
        ...
        xmlFilter.Clear();

        for (int i = 0; i < script.assetList.Count; i++)
        {
            ...
            if (!folderBundles.ContainsKey(bundleName))
            {
                ...
                xmlFilter.Add(path); 
            }
            ...
        }

        for (int i = 0; i < allFolderAssetGUID.Length; i++)
        {
            ...
            xmlFilter.Add(path);
            ...
        }

        BuildXML();
     	...
    }
}

private void BuildXML()
{
    ...
    for (int i = 0; i < bundleNames.Length; i++)
    {
        ...
        for (int j = 0; j < assetPath.Length; j++)
        {
            if (assetPath[j].EndsWith(".cs") || !IsValidPath(assetPath[j])) // <---
                continue;
			...
        }
    }

    ...
}

private bool IsValidPath(string path)
{
    for (int i = 0; i < xmlFilter.Count; i++)
    {
        if (path.Contains(xmlFilter[i]))
            return true;
    }

    return false;
}

打包后测试一下加载

打印所有资源

public class ResourceLoadTest : MonoBehaviour
{
    void Start()
    {
        var buildConfig = Resources.Load<AssetBundleBuildConfig>(AssetBundleBuildConfig.buildConfigName);
        var bundleConfig = Resources.Load<TextAsset>(buildConfig.configName);
        using (var stream = new MemoryStream(bundleConfig.bytes))
        {
            var bf = new BinaryFormatter();
            var test = bf.Deserialize(stream) as AssetBundleConfig;
            foreach (var bundle in test.bundleList)
            {
                Debug.Log(bundle.bundleName);
            }
        }
    }
}

image-20220404160351952