2021.12.28更新
经群友提醒,目前Unity官方已经支持直接不进行任何更改打出aab包了。
支持的Unity版本:
- 2021 → 2021.2.0b4 以上
- 2020 → 2020.3.15f2 以上
- 2019 → 2019.4.29f1 以上
将Split Application Binary选项勾选
在ProjectSettings → Android → Publish Settings 最底下有个 Split Application Binary,将其勾选
原本这是会让 APK 产生 APK Expansion Files (.oob) 的选项,但在build target选AAB的情况下会变成使用Play Asset Delivery。
详细操作方式按照官方文档操作即可
也可以查看该文章解决:https://medium.com/akatsuki-taiwan-technology/unity-play-asset-delivery-1d468fd90c2d
-----------------------------------------------------------------------------------------------------
概述
对于项目本身就使用AssetBundle的来说,打包新格式aab是很容易的,上篇文章已经详细说过了。
对于项目之初没有考虑AssetBundle热更新的项目怎么办呢?
项目都是采用Resources加载,并且是同步加载的,unity场景资源也较多,没有做好分包设计的怎么办呢?
这篇文章我们讲怎么处理。
难点
- 场景采用同步/异步加载
SceneManager.LoadScene ("xx",LoadSceneMode.Single);
- 资源采用Resources.Load加载
GameObject prefab = Resources.Load<GameObject> (path);
方案
采用Unity自带的可寻址系统插件Addressables(该插件已经替代AssetBundle作为Unity推荐的热更新方案)
采用Addressables的方式将项目快速转换为热更新。
此方案改动较少,对于之前的文件路径也不需要修改太多逻辑,几乎能完美移植。
插件导入
在项目中使用Package Manager,找到Addressables安装即可。
目标
如果项目本身加载场景/资源都采用异步加载,也就是SceneManager.LoadSceneAsync和Resources.LoadAsync,并且已经实现了等待过程处理,那这种转换起来还是蛮简单的。如果没有也没关系,因为Addressables可寻址系统也支持同步加载(最开始没有,1.17以上加了)
-
场景加载API替换
-
Resources.Load加载资源的API替换
-
实例化逻辑替换
-
内存释放
请注意: 因为之前Resources.Load不需要考虑内存释放的问题,但是Addressables系统需要自己管理内存释放,否则内存会一直留着,导致内存不足可能会导致闪退情况。
资源可寻址
将插件导入后,需要开始配置可寻址路径,只有进行正确的配置,才能实现快速转换。
目标:
- Resources文件目录转移(Addressables自行处理),否则也会打包到包体内
- 场景变为可寻址
配置:
- 打开Window/Asset Management/Addressables/Groups
- 将需要分包的资源目录/文件拖到组里面,会自动将资源移动到Resources_moved目录,防止Unity打包到包体内
注意:加载路径有所修改,后面需要加上.prefab后缀名才能读取到 - 对于场景,直接将场景目录或.unity文件直接拖入即可。(名字可以自行更改)
代码配置
如果想通过代码配置,Addressables官方没有提供简单的方式,但是我们也可以通过这种方式实现。
为此我自己封装了这个类,大家调用接口即可通过代码方式把资源变为可寻址。
using System.Collections.Generic;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.AddressableAssets.Settings;
#endif
using System;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceProviders;
using UnityEngine.SceneManagement;
public class AddressablesUtils
{
public static T LoadAsset<T>(string path)
{
var request = LoadAssetAsync<T>(path);
return request.WaitForCompletion();
}
public static AsyncOperationHandle<T> LoadAssetAsync<T>(string path)
{
try
{
var request = Addressables.LoadAssetAsync<T>(path);
return request;
}
catch (Exception e)
{
Debug.Log(e.Message);
}
return default;
}
public static AsyncOperationHandle<SceneInstance> LoadSceneAsync(string level,LoadSceneMode loadSceneMode)
{
var async = Addressables.LoadSceneAsync(level, loadSceneMode);
return async;
}
public static GameObject Instantiate(string path, Transform parent)
{
var request = InstantiateAsync(path, parent);
return request.WaitForCompletion();
}
public static AsyncOperationHandle<GameObject> InstantiateAsync(string path,Transform parent)
{
try
{
return Addressables.InstantiateAsync(path, parent);
}
catch (Exception e)
{
Debug.Log(e.Message);
}
return default;
}
public static void ReleaseInstance(GameObject instance)
{
Addressables.ReleaseInstance(instance);
}
public static void Release<T>(T target)
{
Addressables.Release(target);
}
#if UNITY_EDITOR
public static void AddToAddressable(string path,string address="",string groupName="PlayAssetDelivery",bool isAdd=true)
{
if (string.IsNullOrEmpty(path)) return;
var assetSettingPath = "Assets/AddressableAssetsData/AddressableAssetSettings.asset";
AddressableAssetSettings addressableAssetSettings = AssetDatabase.LoadAssetAtPath<AddressableAssetSettings>(assetSettingPath);
SetAaEntry(addressableAssetSettings, groupName, path, isAdd, address);
}
static void SetAaEntry(AddressableAssetSettings aaSettings,string groupName, string path, bool create,string address="")
{
AddressableAssetGroup assetGroup = null;
if (!string.IsNullOrEmpty(groupName))
{
assetGroup = aaSettings.FindGroup(groupName);
}
else
{
assetGroup = aaSettings.DefaultGroup;
}
if (create && assetGroup.ReadOnly)
{
Debug.LogError("Current default group is ReadOnly. Cannot add addressable assets to it");
return;
}
Undo.RecordObject(aaSettings, "AddressableAssetSettings");
var guid = string.Empty;
//if (create || EditorUtility.DisplayDialog("Remove Addressable Asset Entries", "Do you want to remove Addressable Asset entries for " + targets.Length + " items?", "Yes", "Cancel"))
{
var entriesAdded = new List<AddressableAssetEntry>();
var modifiedGroups = new HashSet<AddressableAssetGroup>();
Type mainAssetType;
guid = AssetDatabase.AssetPathToGUID(path);
if (create)
{
var e = aaSettings.CreateOrMoveEntry(guid, assetGroup, false, false);
if (!string.IsNullOrEmpty(address)) e.address = address;
entriesAdded.Add(e);
modifiedGroups.Add(e.parentGroup);
}
else
{
aaSettings.RemoveAssetEntry(guid);
}
if (create)
{
foreach (var g in modifiedGroups)
g.SetDirty(AddressableAssetSettings.ModificationEvent.EntryMoved, entriesAdded, false, true);
aaSettings.SetDirty(AddressableAssetSettings.ModificationEvent.EntryMoved, entriesAdded, true, false);
}
}
}
#endif
}
增加到Unity右键菜单
选中目录,直接添加可寻址,并去除后缀
因为unity通过Resources.Load加载的资源路径不需要后缀的,而拖文件夹的方式后缀名又无法去除,所以写了个菜单,自动将选中的目录下的文件加到可寻址,并去除后缀名。
static List<string> SelectsPath
{
get
{
List<string> pathList = new List<string>();
var selectionList = Selection.assetGUIDs;
foreach (var guid in selectionList)
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
var fullPath = SVNProjectPath + "/" + assetPath;
pathList.Add(fullPath);
}
List<string> result = new List<string>(pathList);
for (int i = 0; i < pathList.Count; i++)
{
string svnPath = pathList[i];
//移除掉所有比自己长并且部分完全包含自己的路径(也就是移除所有子路径)
result.RemoveAll((path) =>
{
return path.Length > svnPath.Length && path.StartsWith(svnPath);
});
}
return result;
}
}
public static string SVNProjectPath{
get{
System.IO.DirectoryInfo parent = System.IO.Directory.GetParent(Application.dataPath);
return parent.ToString();
}
}
[MenuItem("Assets/Add To Addressables", false)]
static void AddToAddressables()
{
List<string> selectPath = SelectsPath;
foreach (var path in selectPath)
{
if (FileControll.FolderExist(path))
{
var files = FileControll.GetFolderFiles(path,true);
int totalLength = files.Count;
int i = 0;
foreach (var filePath in files)
{
i++;
float progress = ((float)i / (float)totalLength);
bool isCancel = EditorUtility.DisplayCancelableProgressBar (string.Format("正在处理..."),
string.Format("处理资源({0}/{1})",i,totalLength), progress);
if (isCancel)
{
EditorUtility.ClearProgressBar();
break;
}
if (filePath.EndsWith(".meta")) continue;
string fPath = FileControll.GetRelativePath(filePath, Application.dataPath.Replace("Assets",""));
string address = fPath.Replace(new FileInfo(fPath).Extension,"");
address = FileControll.MakePathPerfect(address);
if (address.StartsWith("Assets/Game/Resources_moved"))
{
address = address.Replace("Assets/Game/Resources_moved/", "");
}
//Debug.Log(fPath+"==="+Application.dataPath);
AddressablesUtils.AddToAddressable(fPath,address);
}
EditorUtility.ClearProgressBar();
}
}
}
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System;
using System.Text;
public static class FileControll{
/// <summary>
/// 创建文件
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
public static bool CreateFile(string filePath){
if (!FileExist (filePath)) {
File.Create (filePath);
return FileExist(filePath);
}
return false;
}
/// <summary>
/// 创建目录
/// </summary>
/// <param name="folderPath"></param>
/// <returns></returns>
public static bool CreateFolder(string folderPath){
if (!FolderExist(folderPath)) {
DirectoryInfo info = Directory.CreateDirectory (folderPath);
return info.Exists;
}
return false;
}
/// <summary>
/// 删除文件
/// </summary>
/// <param name="filePath"></param>
public static void DeleteFile(string filePath){
if (FileExist (filePath)) {
File.Delete (filePath);
}
}
/// <summary>
/// 删除目录
/// </summary>
/// <param name="folderPath"></param>
public static void DeleteFolder(string folderPath){
DeleteFolder (folderPath, true);
}
/// <summary>
/// 删除目录
/// </summary>
/// <param name="folderPath"></param>
/// <param name="recursive">是否递归删除</param>
public static void DeleteFolder(string folderPath,bool recursive){
if(FolderExist(folderPath)){
Directory.Delete (folderPath, recursive);
}
}
/// <summary>
/// 目录是否存在
/// </summary>
/// <param name="folderPath"></param>
/// <returns></returns>
public static bool FolderExist(string folderPath){
return Directory.Exists (folderPath);
}
/// <summary>
/// 文件是否存在
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
public static bool FileExist(string filePath){
return File.Exists (filePath);
}
/// <summary>
/// 获取文件的目录
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
public static string GetFileFolder(string filePath){
FileInfo fileInfo = new FileInfo (filePath);
return fileInfo.Directory.ToString ();
}
/// <summary>
/// 获取子目录
/// </summary>
/// <param name="folderPath"></param>
/// <returns></returns>
public static List<string> GetSubFolders(string folderPath){
List<string> result = new List<string> ();
GetSubFolders (folderPath, ref result);
return result;
}
/// <summary>
/// 获取子目录
/// </summary>
/// <param name="path"></param>
/// <param name="result"></param>
static void GetSubFolders(string path,ref List<string> result){
result.Add (path);
if (Directory.Exists(path))
{
foreach (string sub in Directory.GetDirectories(path)) {
GetSubFolders (sub + "/", ref result);
}
}
}
/// <summary>
/// 获取对应路径的相对路径(如absolutePath=E:/AB/c.txt,relativeTo=E:/AB/,输出c.txt)
/// </summary>
/// <param name="absolutePath"></param>
/// <param name="relativeTo"></param>
/// <returns></returns>
public static string GetRelativePath(string absolutePath,string relativeTo)
{
var fileInfo = new FileInfo(relativeTo);
var fullFileInfo = new FileInfo(absolutePath);
string absoluteName = fullFileInfo.FullName;
absoluteName = MakePathPerfect(absoluteName);
string relative = fileInfo.FullName;
relative = MakePathPerfect(relative);
string result = absoluteName.Replace(relative, "");
return result;
}
/// <summary>
/// 获取目录下的所有文件
/// </summary>
/// <param name="folderPath"></param>
/// <param name="recursive">是否递归获取</param>
/// <param name="endWith"></param>
/// <returns></returns>
public static List<string> GetFolderFiles(string folderPath, bool recursive,string endWith="")
{
List<string> fileList;
if (recursive)
{
fileList = new List<string>();
List<string> subFolderList = GetSubFolders (folderPath);
foreach (var subFolder in subFolderList)
{
List<string> subFileList = GetFolderFiles(subFolder, endWith);
fileList.AddRange (subFileList);
}
}
else
{
fileList = GetFolderFiles(folderPath, endWith);
}
return fileList;
}
/// <summary>
/// 获取目录下的所有文件
/// </summary>
/// <param name="folderPath"></param>
/// <param name="endWith"></param>
/// <returns></returns>
public static List<string> GetFolderFiles(string folderPath,string endWith=""){
List<string> result = new List<string> ();
if (Directory.Exists(folderPath))
{
foreach (string file in Directory.GetFiles(folderPath))
{
if (!string.IsNullOrEmpty(endWith) && !file.ToLower().EndsWith(endWith.ToLower())) continue;
result.Add (file);
}
}
return result;
}
/// <summary>
/// 写入文件
/// </summary>
/// <param name="path"></param>
/// <param name="bytes"></param>
public static void WriteFile(string path,byte[] bytes){
WriteFile (path, bytes, FileMode.Create);
}
/// <summary>
/// 写入文件
/// </summary>
/// <param name="path"></param>
/// <param name="bytes"></param>
/// <param name="fileMode"></param>
public static void WriteFile(string path,byte[] bytes,FileMode fileMode)
{
#if UNITY_EDITOR || (!UNITY_WINRT)
try {
FileStream fs = new FileStream(path, fileMode);
fs.Write (bytes, 0, bytes.Length);
fs.Close();
} catch (Exception ex) {
Debug.LogError ("文件写入失败" + path + ":" + ex.Message);
}
#endif
}
/// <summary>
/// 写入文件
/// </summary>
/// <param name="path"></param>
/// <param name="append"></param>
/// <param name="infos"></param>
public static void WriteFile(string path,bool append,List<string> infos){
try {
StreamWriter sw = new StreamWriter (path, append);
if (infos!=null) {
foreach (string info in infos) {
sw.WriteLine(info);
}
}
sw.Close ();
sw.Dispose ();
} catch (Exception ex) {
Debug.LogError ("文件写入失败" + path + ":" + ex.Message);
}
}
/// <summary>
/// 写入Txt文件
/// </summary>
/// <param name="path"></param>
/// <param name="content"></param>
/// <param name="encoding"></param>
public static void WriteTxtFile(string path, string content,Encoding encoding)
{
File.WriteAllText(path,content,encoding);
}
/// <summary>
/// 写入Txt文件
/// </summary>
/// <param name="path"></param>
/// <param name="content"></param>
public static void WriteTxtFile(string path, string content)
{
File.WriteAllText(path,content);
}
/// <summary>
/// 复制文件到
/// </summary>
/// <param name="path"></param>
/// <param name="toPath"></param>
/// <param name="overwrite">是否覆盖</param>
public static void CopyFile(string path,string toPath,bool overwrite){
try {
File.Copy(path,toPath,overwrite);
} catch (Exception ex) {
Debug.LogError ("拷贝文件失败:"+ex.Message);
}
}
/// <summary>
/// 复制目录到
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
public static void CopyFolder(string from, string to)
{
if (!Directory.Exists(to))
Directory.CreateDirectory(to);
// 子文件夹
foreach (string sub in Directory.GetDirectories(from))
CopyFolder(sub + "/", to + Path.GetFileName(sub) + "/");
// 文件
foreach (string file in Directory.GetFiles(from)){
try {
File.Copy(file, to + Path.GetFileName(file), true);
} catch (Exception ex) {
Debug.LogWarning ("拷贝失败:" + ex.Message);
}
}
}
/// <summary>
/// 读取文件
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
public static byte[] ReadFile(string path){
#if UNITY_EDITOR || (!UNITY_WINRT)
if (!File.Exists (path))
return null;
FileStream fs = new FileStream(path, FileMode.Open);
long size = fs.Length;
byte[] array = new byte[size];
//将文件读到byte数组中
fs.Read(array, 0, array.Length);
fs.Close();
return array;
#else
return null;
#endif
}
/// <summary>
/// 读取Txt数据
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
public static string ReadTxtFile(string path){
if (!File.Exists (path))
return null;
return File.ReadAllText (path);
}
/// <summary>
/// 读取Txt文件
/// </summary>
/// <param name="path"></param>
/// <param name="encoding"></param>
/// <returns></returns>
public static string ReadTxtFile(string path,System.Text.Encoding encoding){
if (!File.Exists (path))
return null;
return File.ReadAllText (path, encoding);
}
/// <summary>
/// 读取Txt行
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
public static List<string> ReadTxtFileLine(string path){
if (!File.Exists (path))
return null;
try{
using(StreamReader sr = new StreamReader (path)){
List<string> dataList=new List<string>();
string line;
while ((line = sr.ReadLine()) != null)
{
dataList.Add(line);
}
return dataList;
}
}catch(Exception e){
Debug.Log("文件未能读取"+e.Message);
return null;
}
}
/// <summary>
/// 检测并矫正CSV格式
/// </summary>
/// <param name="path">csv文件路径</param>
/// <param name="fileEncoding"></param>
/// <returns>是否矫正</returns>
public static bool CheckAndCollectCSVFormat(string path,Encoding fileEncoding=null)
{
if (fileEncoding == null)
{
TextUtil.EncodingType encodingType = TextUtil.GuessFileEncoding(path);
if (encodingType == TextUtil.EncodingType.Unknown)
{
fileEncoding = TextUtil.ANSI_CHINESE; //默认用ANSI编码
}
else fileEncoding = TextUtil.GetEncoding(encodingType);
}
if (!fileEncoding.CodePage.Equals(Encoding.UTF8.CodePage))
{
var content = File.ReadAllText(path, fileEncoding);
File.WriteAllText(path,content,Encoding.UTF8);
return true;
}
return false;
}
/// <summary>
/// 合并路径
/// </summary>
/// <param name="folderPath"></param>
/// <param name="fileName"></param>
/// <returns></returns>
public static string CombineFilePath(string folderPath, string fileName)
{
if (string.IsNullOrEmpty(folderPath))
{
return "";
}
DirectoryInfo directoryInfo=new DirectoryInfo(folderPath);
if (!directoryInfo.Exists)
{
Debug.LogError("目录不存在:"+folderPath);
return "";
}
return directoryInfo.FullName + "\\" + fileName;
}
/// <summary>
/// 使路径规范化(都变成这样的格式:Assets/Game/Source)
/// 如Assets\Game\Source变成Assets/Game/Source
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
public static string MakePathPerfect(string path)
{
return path.Replace("\\", "/");
}
/// <summary>
/// 移动文件
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
public static void MoveFile(string @from, string to)
{
@from = from.Replace('\\', '/');
@to = to.Replace('\\', '/');
if (Directory.Exists(to))
{
FileControll.CopyFolder(@from, to);
FileControll.DeleteFolder(@from);
Debug.Log($"覆盖文件夹:{@from} ===> {to}");
}
else if (File.Exists(to))
{
FileControll.CopyFile(@from, to, true);
FileControll.DeleteFile(@from);
Debug.Log($"覆盖文件:{@from} ===> {to}");
}
else
{
string fileLastPath = to;
if (to.Contains("."))
{
fileLastPath=to.GetFileLastPath();
}
if (!Directory.Exists(fileLastPath))
{
Directory.CreateDirectory(fileLastPath);
}
File.Move(@from, to);
Debug.Log($"移动文件:{@from} ===> {to}");
}
}
}
场景加载API替换
代码示例
void LoadScene(string sceneName)
{
SceneManager.LoadSceneAsync(sceneName,LoadSceneMode.Single);
}
替换后:
void LoadScene(string sceneName)
{
Addressables.LoadSceneAsync(sceneName, LoadSceneMode.Single);
}
注意:
对于LoadSceneMode.Single,Addressables系统会在切换到其他场景时自动卸载内存,所以无需内存管理。而对于LoadSceneMode.Additive模式需要卸载场景时调用Addressables.UnloadSceneAsync卸载场景释放内存。
Resources资源API替换
代码示例
T LoadAsset<T>(string path)where T:Object
{
return Resources.Load<T>(path);
}
替换后
使用request.WaitForCompletion可以将线程卡死,等待加载完成释放(该行为有风险,可能会导致一些意外情况)相当于是同步过程。
参考官方文档:Synchronous Workflow | Addressables | 1.17.17
T LoadAsset<T>(string path)where T:Object
{
var request = Addressables.LoadAssetAsync<T>(path);
request.WaitForCompletion();
return request.Result;
}
注意:
- 加载后的资源需要在恰当的时候释放掉,否则会导致内存泄露问题
释放接口如下:
//释放实例
public static void ReleaseInstance(GameObject instance)
{
Addressables.ReleaseInstance(instance);
}
//释放加载的对象
public static void Release<T>(T target)
{
Addressables.Release(target);
}
实例化逻辑替换
对于使用Instantiate实例化预制,需要修改,否则释放不了
代码示例
void InstancePrefab(string path,Transform parent)
{
var prefab=Resources.Load<GameObject>(path);
var go = GameObject.Instantiate(prefab, parent);
}
替换后
void InstancePrefab(string path,Transform parent)
{
var request = Addressables.InstantiateAsync(path);
var go=request.WaitForCompletion();
}
注意:
只有通过这种方式Instance出来的对象,才能进行释放
public static void ReleaseInstance(GameObject instance)
{
Addressables.ReleaseInstance(instance);
}
内存管理
还有一步重要的设置,就是需要自行管理内存释放,否则随着游戏进程的增加,会导致内存泄露,玩家设备内存不足,会出现闪退等错误情况。
如何知道内存情况呢?
- 打开EventView,可以检测内存情况。
Window/Addressables/Event Viewer - 打开设置,选中配置文件,勾选Send Profiler Events,只有打开这个设置,才会检测资源情况
- 资源卸载时可以分析资源是否在列表里,在列表里说明没有释放,还占用着内存
测试
至此,转换工作顺利完成,可以开始测试。
- 打包AssetBundle
- 打包本体
- 打包后运行测试
结语
Addressables转换完成了,资源也分包好了,后面的工作就是进行谷歌aab合并上传。通过Play Asset Delivery可以快速的进行分发。
因为Google提供的插件是针对AssetBundle的,对于Addressables系统还有很多问题,而Unity这方面也没有过多的解释,所以后续还有蛮多工作要做的。
后续我们再细讲。
上一篇:Unity发布Android App Bundle详解(二)Play Asset Delivery介绍
下一篇:Unity发布Android App Bundle详解(四)Addressables+Play Asset Delivery分发
系列文章索引
Unity发布Android App Bundle详解(一)Unity .aab支持情况
Unity发布Android App Bundle详解(二)Play Asset Delivery介绍
Unity发布Android App Bundle详解(三)快速转换Addressables
Unity发布Android App Bundle详解(四)Addressables+Play Asset Delivery分发