一、环境
- Unity 2020.3.18f1
- Addressables 是基于1.16.19 修改过适配 webgl 平台的修改版本(原版在webgl用不了)
二、遇到问题
项目上一直使用 addressables 用来加载资源,需求是添加断点续传功能,不要一断网就下载资源失败,于是乎找到下载模块修改,但是发现只要资源包稍微大一些,分段存入 idbfs 就会报错,而且信息是空的,具体可以看一下我之前的提问
三、分析
首先说明 unity 在 webgl 上是通过 emscripen 来管理虚拟文件系统 idbfs 的,经过测试 emscripen 在 1.x 版本时会出现这个问题,只要随着 unity 版本升级 emscripen 升级到 2.x 版本就没有此问题了
所以最简单的解决办法就是升级 unity,当然前提是你写好了断点续传功能
下面我会简单提一下怎么写,但是重点还是解决如何不升级 unity 解决追加写入 idbfs 失败的问题
官方详细说明
四、断点续传实现
在 emscripen 是 2.x 的情况下实现比较简单,unity6 的 emscripen 版本自带断点续传功能,只不过 retrycount 默认是0改一下就好了,其他的版本没有测试过(因为是基于别人修改过的版本不是官方版本,可能会有些代码不全,以提供个思路为主)
- 首先找到目标脚本 /Runtime/ResourceManager/ResourceProviders/AssetBundleProvider.cs,添加一些方法用于获取存储路径和已永久化存储的数据的长度
publicstaticStringGetCacheDirPath(){return Path.Combine(Application.persistentDataPath,"ABCache");}publicstaticStringGetAssetLocalPath(String internalId,AssetBundleRequestOptions options){return Path.Combine(GetCacheDirPath(),(options ==null?"000": options.Hash)+"."+ internalId.GetHashCode().ToString());}publicstaticlongGetAssetLocalPathFileSize(String filePath){returnnewFileInfo(filePath).Length;}
- 找到目标函数 BeginOperation(),这个是请求发起函数,downloadHandler 设置为 DownloadHandlerFile,这样下载完自动追加写入,给webrequest添加一个请求头确定下载哪一段的数据
string path = m_ProvideHandle.ResourceManager.TransformInternalId(m_ProvideHandle.Location);string cachepath =GetAssetLocalPath(path, m_Options);
webRequest.downloadHandler =newDownloadHandlerFile(cachepath);// 自动追加写入该路径
webRequest.SetRequestHeader("Range","bytes="+ m_DownloadPartialFlag +"-"+ endPartial);// 设置要下载的范围
- 修改回调函数的思路是,有一个标志位表明当前进度,然后回调显示成功了就继续请求下一个数据段落,失败了则再次请求
privatevoidDelaySendRequest(float seconds){// webgl上是单线程,没找到不阻塞的方法实现延迟,官方高版本的addressables自带的断点续传是不阻塞的,不知道是怎么实现的 :)DateTime startTime = DateTime.Now;TimeSpan delay = TimeSpan.FromSeconds(seconds);while(true){if(DateTime.Now - startTime >= delay){
m_WebRequestQueueOperation =null;BeginOperation();break;}}}privatevoidWebGLRequestOperationCompleted(AsyncOperation op){UnityWebRequestAsyncOperation remoteReq = op asUnityWebRequestAsyncOperation;var webReq = remoteReq.webRequest;long bundleSize = m_Options.BundleSize;long partialBundleSize = m_DownloadPartialFlag + PARTIALSIZE -1>= bundleSize ? bundleSize - m_DownloadPartialFlag : PARTIALSIZE;if(string.IsNullOrEmpty(webReq.error)&& webReq.downloadedBytes ==(ulong)partialBundleSize){
m_Retries =0;// 成功一次就重置重连次数string path = m_ProvideHandle.ResourceManager.TransformInternalId(m_ProvideHandle.Location);string cachepath =GetAssetLocalPath(path, m_Options);if(m_DownloadPartialFlag < bundleSize){string endPartial = m_DownloadPartialFlag + PARTIALSIZE -1>= bundleSize ?"":(m_DownloadPartialFlag + PARTIALSIZE -1).ToString();
Debug.LogFormat("[ABCache] Downloaded: {0}, Range: {1} - {2}", webReq.url, m_DownloadPartialFlag, endPartial);
m_DownloadPartialFlag += PARTIALSIZE;
m_WebRequestQueueOperation =null;if(m_DownloadPartialFlag < bundleSize)BeginOperation();else
Debug.LogFormat("[ABCache] Downloaded and Cached: {0}", webReq.url);}else{LoadFromCache(cachepath);
m_WebRequestQueueOperation =null;}}else{if(m_Retries < m_Options.RetryCount){// 失败的情况下只要还在尝试请求次数之内就再次发起请求
m_Retries++;
Debug.LogFormat(string.Format("[ABCache] Download Failed: {0}, Range: {1} - {2} error={3}, retrying ({4}/{5})...", webReq.url, m_DownloadPartialFlag, m_DownloadPartialFlag +10485759, webReq.error, m_Retries, m_Options.RetryCount));DelaySendRequest(2);}else{
Debug.LogFormat("[ABCache] Download Failed: {0} error={1}", webReq.url, webReq.error);
m_ProvideHandle.Complete<AssetBundleResource>(null,false,newException(string.Format("[ABCache] Download Failed: {0}, error={1}", webReq.url, webReq.error)));
m_WebRequestQueueOperation =null;}}
webReq.Dispose();}
五、解决方案
因为有时候项目很大,牵一发而动全身,下面着重说明如何在不升级 unity 的情况下如何解决,以下是基于上面的断点续传所改
- 要使用 .jslib 文件自己实现写入,首先要先了解到虚拟文件系统有两套,其一是在内存中,其二是在 indexdb 中,第二个是永久化储存,然后通过 FS.syncfs 相互同步,第一个参数是 true 的情况下是 indexedDB 的文件系统同步到内存的文件系统,是 false 则反之(被gpt骗过,气死了告诉我错误答案,耽误好长时间,官网当时排版乱了看不出来,问的gpt)问题的根本就是 emscripen 的版本太低了,追加写入功能有bug导致的,所以我们需要下载最新版本的 emscripen 然后编译出来 wasm 文件来接管写入功能,下面是从下载到编译的步骤:
(1)安装过程
* 安装 Python (构建时使用的Python36)
* 安装 Git
* 克隆 Emscripten 仓库:git clone https://github.com/emscripten-core/emsdk.git
* 进入 emsdk 目录:cd emsdk
* 安装 Emscripten:.\emsdk install latest
* 激活 Emscripten:.\emsdk activate latest
* 设置环境变量:.\emsdk_env.bat
* 检查 Emscripten 版本:emcc --version
(2)使用过程
* 新建一个空的c++文件(我用的在下面,具体多空没有测试过)
* 构建:emcc EmptyCPP.cpp -o indexedDB_file_system.js -s EXPORT_NAME='CustomModule' -s "EXPORTED_RUNTIME_METHODS=['FS', 'IDBFS','PATH']" -s DEFAULT_LIBRARY_FUNCS_TO_INCLUDE='$getBoundingClientRect' -lidbfs.js -s MODULARIZE=1 --bind -s FORCE_FILESYSTEM=1 -s ENVIRONMENT='web'
* 会生成一个.js文件一个.wsam文件,放到Assets/StreamingAssets目录下
(3)备注
* 如果执行安装没有反应,python添加环境变量
C:\Users\<你的用户名>\AppData\Local\Programs\Python\PythonXX
C:\Users\<你的用户名>\AppData\Local\Programs\Python\PythonXX\Scripts
* 如果显示 emcc 不是内部命令,添加环境变量
C:\Users\<你的用户名>\emsdk
C:\Users\<你的用户名>\emsdk\node\18.20.3_64bit\bin(按照路径填写可能不是18.20.3_64bit)
C:\Users\<你的用户名>\emsdk\upstream\emscripten
重启cmd,检查 Emscripten 版本
* 构建时的命令可以自定义修改的有三处
EmptyCPP.cpp(C++文件名)
indexedDB_file_system.js(目标文件名)
CustomModule(jslib中使用时的名字)
// dummy.cpp#include<emscripten.h>#include<emscripten/bind.h>EMSCRIPTEN_BINDINGS(my_module){// This can be empty if not using any C++ functions}
如果懒得编译,这里可以使用我编译好的
- 第一步编译出来的 .js 文件和 .wasm 文件需要放到 StreamingAssets 文件夹下被使用,用下面 .jslib 文件调用刚才编译好的文件系统
var CustomIDBFS ={
$idbfs_g :{
CustomFS :{},
CustomPATH :{},
RootPath :'',
ProjectPath :'',
isFileSystemInitialized :false},JS_InitializeFileSystem:function(rootPath, projectPath){if(idbfs_g.isFileSystemInitialized)return;
idbfs_g.RootPath =Pointer_stringify(rootPath);
idbfs_g.ProjectPath =Pointer_stringify(projectPath);var script = document.createElement('script');
script.src ='StreamingAssets/indexedDB_file_system.js';
script.onload=function(){var wasmFilePath ='StreamingAssets/indexedDB_file_system.wasm';fetch(wasmFilePath).then(function(response){return response.arrayBuffer();}).then(function(bytes){CustomModule().then(function(CustomModuleInstance){
idbfs_g.CustomFS = CustomModuleInstance.FS;
idbfs_g.CustomPATH = CustomModuleInstance.PATH;
idbfs_g.isFileSystemInitialized =true;
CustomModuleInstance.FS.mkdirTree(idbfs_g.ProjectPath);
CustomModuleInstance.FS.mount(CustomModuleInstance.IDBFS,{}, idbfs_g.RootPath);// 每次启动都要挂载
CustomModuleInstance.FS.syncfs(true,function(err){// 从indexedDB的文件系统同步到内存的文件系统if(err){
console.error('Custom File system initialization failed:', err);}else{
console.log('Custom File system initialization successful.');}});});});};
document.body.appendChild(script);},JS_WriteToIDBFS:function(path, dataPtr, dataLength, isLastPart, callbackid, callback){if(!idbfs_g.isFileSystemInitialized)returnfalse;var dataArray =newUint8Array(Module.HEAPU8.buffer, dataPtr, dataLength);var pathStr =Pointer_stringify(path);var CustomFS = idbfs_g.CustomFS;var CustomPATH = idbfs_g.CustomPATH;var stream
try{if(!CustomFS.analyzePath(pathStr).exists){
CustomFS.mkdirTree(CustomPATH.dirname(pathStr));}
stream = CustomFS.open(pathStr,'a');
CustomFS.write(stream, dataArray,0, dataLength);
console.log('Writed to IDBFS.');}catch(e){
console.error('Error writing to IDBFS:', e);}finally{if(stream){
CustomFS.close(stream);}}if(isLastPart){
CustomFS.syncfs(false,function(err){if(err){
console.error('Failed to CustomFS sync:', err);}else{// 原生FS同步FS.unmount(idbfs_g.RootPath);FS.mkdirTree(idbfs_g.ProjectPath);FS.mount(IDBFS,{}, idbfs_g.RootPath);FS.syncfs(true,function(fserr){if(fserr){
console.error('Failed to FS sync:', fserr);if(fserr.errno ==17){// errno: 17, code: 'EEXIST', message: 'File exists'
Runtime.dynCall('vi', callback,[callbackid]);// 因为是异步所以要回调C#函数}}else{
console.log('Successful to FS sync.');
Runtime.dynCall('vi', callback,[callbackid]);}});}});}returntrue;},JS_FileExists:function(path){var pathStr =Pointer_stringify(path);return idbfs_g.CustomFS.analyzePath(pathStr).exists;},JS_FileSize:function(path){var pathStr =Pointer_stringify(path);return idbfs_g.CustomFS.stat(pathStr).size;}};autoAddDeps(CustomIDBFS,'$idbfs_g');mergeInto(LibraryManager.library,CustomIDBFS);
- 在C#中调用 .jslib 文件函数,用的时候首先要初始化函数,再调用写入函数
privatestaticList<AssetBundleResource> callbackList =newList<AssetBundleResource>();privatestaticList<string> callbackParaList =newList<string>();//public delegate void OnCallback(int instanceId);[DllImport("__Internal")]privatestaticexternvoidJS_InitializeFileSystem(string rootPath,string projectPath);[DllImport("__Internal")]privatestaticexternboolJS_WriteToIDBFS(string path,byte[] data,int dataLength,bool isLastPart,int callbackid,Action<int> callback);// 初始化未完成则不会写入返回false,并不是代表所有情况下写入是否正确完成[DllImport("__Internal")]publicstaticexternboolJS_FileExists(string path);[DllImport("__Internal")]publicstaticexternintJS_FileSize(string path);publicstaticvoidInitializeFileSystem(){JS_InitializeFileSystem("/idbfs", Application.persistentDataPath);// 挂载自定义IDBFS虚拟文件系统到原生虚拟文件路径}publicstaticStringGetCacheDirPath(){return Path.Combine(Application.persistentDataPath,"ABCache");}publicstaticStringGetAssetLocalPath(String internalId,AssetBundleRequestOptions options){return Path.Combine(GetCacheDirPath(),(options ==null?"000": options.Hash)+"."+ internalId.GetHashCode().ToString());}publicstaticlongGetAssetLocalPathFileSize(String filePath){returnJS_FileSize(filePath);}
- 修改断点续传代码,downloadHandler 设置为 DownloadHandlerBuffer
webRequest.downloadHandler =newDownloadHandlerBuffer();
修改回调函数中要加入写入的函数在再次发起请求之前
WriteCache(webReq.downloadHandler.data);
privatevoidWriteCache(byte[] data){string path = m_ProvideHandle.ResourceManager.TransformInternalId(m_ProvideHandle.Location);string cachepath =GetAssetLocalPath(path, m_Options);bool isLastPart = m_DownloadPartialFlag >= m_Options.BundleSize;int callbackid =-1;if(isLastPart){
callbackid = callbackList.Count;
callbackList.Add(this);
callbackParaList.Add(cachepath);}bool isDone =JS_WriteToIDBFS(cachepath, data, data.Length, isLastPart, callbackid, OnWriteComplete);// 防止未初始化导致的没有写入DateTime startTime = DateTime.Now;TimeSpan delay = TimeSpan.FromSeconds(1);while(!isDone){if(DateTime.Now - startTime >= delay){
isDone =JS_WriteToIDBFS(cachepath, data, data.Length, isLastPart, callbackid, OnWriteComplete);
startTime = DateTime.Now;}}}[MonoPInvokeCallback(typeof(Action<int>))]publicstaticvoidOnWriteComplete(int callbackid){
callbackList[callbackid].LoadFromCache(callbackParaList[callbackid]);}
- 总结 主要的操作就是下载 emscripen 最新版本编译出来新版 idbfs 文件管理,接管 unity 原生的写入操作,读操作没有问题不需要管,第一次写这么长的文章,分好几次写,写的有些混乱,先提交一版,大家先凑活看,有时间我会迭代更新
前人栽树后人乘凉,乘凉之后也要记得栽树哦。
版权归原作者 Asunaの楠世 所有, 如有侵权,请联系我们删除。