目录
2. 在Unity Assets/Plugins目录下创建jslib文件FileDialog.jslib
最近想开发一个提效工具,用于删除现有的云端(比如阿里云、腾讯云等。我们公司的是未来云)资源,并上传新的文件(我们处理的是unity热更资源,包括bundle文件和zip文件)到云端。为了方便mac和windows都可用,准备用unity发布WebGL的方式实现。想着应该很简单,因为这个功能已经在Unity 编辑器内实现了,如下:
还是年轻,想的太简单了。Unity发布WebGL后发现一堆一堆一堆坑,解决了一周时间,终于柳暗花明又一村。现在总结一下这些坑及解决方式。
- 发布WebGL后,需要部署到服务端,或者本地搭建环境模拟线上环境。
- 发布WebGL后,遇到了跨域问题,原先可成功调用的上传、删除等接口报405错误
- 发布WebGL后,InputFeild输入框不支持复制粘贴
- 最重要的问题,本地文件读取不了了
接下来依次来解决234问题。
一、跨域问题
解决方法:确保API服务器在响应头中设置了适当的CORS头,例如Access-Control-Allow-Origin。这可以是通配符 (*),但更安全和推荐的方法是指定确切的域名(如 http://example.com)
幸好公司提供的未来云平台支持设置跨域(跨域问题直接让提供API的服务器伙伴解决):
二、InputFeild输入框不支持复制粘贴问题
Unity插件unity-webgl-copy-and-paste-v0.2.0.unitypackage即可解决这个问题,看了下原理,也是采用和JS交互来解决的。
三、读取本地文件失败问题
由于浏览器的安全设置,System.IO读取本地文件的大部分功能都会受限。比如之前获取本地文件夹中文件列表、读取本地文件的代码都不支持:
private static Queue<string> GetLocalFileLists(string dirPath, bool onlyZip = false)
{
Queue<string> fileList = new Queue<string>();
if (Directory.Exists(dirPath))
{
string[] files = Directory.GetFiles(dirPath);
for (int i = 0; i < files.Length; i++)
{
Debug.Log("local files:" + files[i]);
if (onlyZip && !files[i].Contains(".zip"))
{
Debug.Log("只上传zip文件,跳过:" + files[i]);
continue;
}
fileList.Enqueue(files[i]);
}
}
return fileList;
}
private static byte[] LoadData(string path)
{
Debug.Log("LoadData path:" + path);
return System.IO.File.ReadAllBytes(path);
}
private static byte[] LoadData(string path)
{
FileStream fs = new FileStream(path, FileMode.Open);
byte[] data = new byte[fs.Length];
fs.Read(data, 0, data.Length);
fs.Close();
return data;
}
网上大佬前辈们和ChatGpt都建议使用Unity WebGL和JS交互来解决这个问题。先说一下原理:浏览器沙盒目录中的文件才支持读取。那我们需要利用JS创建一个文件夹选择对话框来选择要操作的文件,将文件列表发送给Unity WebGL,在Unity中利用UnityWebRequest将文件加载到浏览器沙盒目录下。就这么简单。来吧展示!
1. 先附个WebGL的页面效果
2. 在Unity Assets/Plugins目录下创建jslib文件FileDialog.jslib
mergeInto(LibraryManager.library, {
LoadFolder: function(_gameObjectName, _isZip) {
console.log('Pointers:', {
gameObjectName: _gameObjectName,
isZip: _isZip
});
var gameObjectName = UTF8ToString(_gameObjectName);
var isZip = UTF8ToString(_isZip);
console.log('LoadFolder called for GameObject:', gameObjectName);
console.log('LoadFolder called for ISZip:', isZip);
// 创建新的文件输入元素
console.log('Creating new file input element.');
var fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'folderInput';
fileInput.webkitdirectory = true; // 允许选择文件夹
fileInput.multiple = true; // 允许多选文件
fileInput.style.display = 'none'; // 隐藏元素
document.body.appendChild(fileInput);
// 定义事件处理函数
var fileInputChangeHandler = function(event) {
console.log('File selection changed.'); // 输出文件选择发生变化的信息
var files = Array.from(event.target.files);
console.log('Selected files:', files); // 输出所选文件的信息
var fileNames = files.map(file => ({
name: file.name,
blobPath: URL.createObjectURL(file),
localPath: file.webkitRelativePath || file.name
}));
var resultString = JSON.stringify({ files: fileNames });
console.log('Sending file dialog result:', resultString); // 输出要发送到 Unity 的结果信息
// 确保 gameInstance 已正确初始化
if (window.gameInstance) {
var message = isZip + "|" + resultString;
window.gameInstance.SendMessage(gameObjectName, 'FileDialogResult', message);
} else {
console.error('gameInstance is not defined');
}
// 移除事件监听器并删除输入元素
fileInput.removeEventListener('change', fileInputChangeHandler);
document.body.removeChild(fileInput);
};
// 添加事件监听器
fileInput.addEventListener('change', fileInputChangeHandler);
console.log('Triggering file input click.');
fileInput.click();
}
});
(1)LoadFolder函数支持Unity调用,该函数有两个参数,第一个是Unity中挂载脚本的物体名,第二个参数是我根据需求来设置传zip文件还是普通文件。所有C#传给js的字符串都需要用Pointer_stringify(或UTF8ToString 2021.2版本及以上)转化一遍,才能转化成js识别的字符串。官方文档:Interaction with browser scripting - Unity 手册
(2)调用LoadFolder函数,会创建文件夹选择对话框。当选择的文件有变化时,会触发fileInputChangeHandler函数,函数中会通过(gameInstance)Unity的SendMessage函数来进行通知,调用挂载脚本的FileDialogResult函数,传递文件列表。
(3)文件列表数据如下:
Sending file dialog result: {"files":
[
{
"name":".DS_Store",
"blobPath":"blob:https://static0.xesimg.com/aa004c1f-947a-4237-8e15-cfd86b50281e",
"localPath":"zip/.DS_Store"
},
{
"name":"Android_Resource_base.zip",
"blobPath":"blob:https://static0.xesimg.com/d3df1350-032a-4e2e-89d4-d2185f9015cf",
"localPath":"zip/Android_Resource_base.zip"
}
]}
注意文件列表中的blobPath值(blob:xxx的形式),这种形式才能被WebRequest读取到,再加载到浏览器沙盒目录下。沙盒目录下路径为::/idbfs/bad4f2aac7af9d794a38b7e22b79d351/Res/Android_Resource_base.zip
(4)由于SendMessage只支持一个参数,在这里把isZip和文件列表信息合并在了一个字符串中。当然也可封装成一个json字符串。
(5)gameInstance是什么呢?gameInstance是unity运行实例,有的叫unityInstance或者别的东西,具体看自己js模版中定义的变量。
(6)JS代码中每次调用LoadFolder都创建fileInput对话框,及时销毁即可,防止内存泄漏。因为本人出现过【第一次调用LoadFolder函数isZip是true,第二次传的是false,但第二次isZip返回到unity的还是true】的问题及【change监听触发多次】的问题。可能在于 fileInputChangeHandler 函数中 isZip 变量的值没有及时更新,导致多次调用 LoadFolder 时使用的是上一次调用时的参数值。
3.Js模版
补充index.html文件,来实例化gameInstance:
4.Unity中调用
[System.Serializable]
public class FileList
{
public FileDetail[] files;
}
[System.Serializable]
public class FileDetail
{
public string name;
public string blobPath;
public string localPath;
}
public class ToolView : MonoBehaviour
{
[DllImport("__Internal")]
private static extern void LoadFolder(string gameObjectName, string isZip);
private Button zipPathButton;
private Button resPathButton;
private void Awake()
{
zipPathButton = transform.Find("ZipPath/ZipPathButton").GetComponent<Button>();
zipPathButton.onClick.AddListener(() =>
{
GetLocalFileLists(true);
});
resPathButton = transform.Find("ResPath/ResPathButton").GetComponent<Button>();
resPathButton.onClick.AddListener(() =>
{
GetLocalFileLists(false);
});
}
public void GetLocalFileLists(bool isZip = false)
{
string _iszip = isZip ? "true" : "false";
string name = gameObject.name;
Debug.Log("Unity GetLocalFileLists " + "isZip: " + _iszip + " name: " + name);
LoadFolder(name, _iszip);
}
public void FileDialogResult(string message)
{
Debug.Log("Unity FileDialogResult: " + message);
string[] messages = message.Split('|');
string filesJson = messages[1];
bool isZip = bool.Parse(messages[0]);
if (isZip)
{
zipLocalFileList.Clear();
}
else
{
resLocalFileList.Clear();
}
var files = JsonUtility.FromJson<FileList>(filesJson);
needCopyCount = files.files.Length;
Debug.Log("Received files:" + needCopyCount);
copyCount = 0;
foreach (var file in files.files)
{
StartCoroutine(CopyFile(file, isZip));
}
}
int copyCount = 0;
int needCopyCount = 0;
IEnumerator CopyFile(FileDetail jsFileInfo, bool isZip = false)
{
// Debug.Log("Unity CopyFile: " + jsFileInfo.name + " - " + jsFileInfo.path);
UnityWebRequest request = UnityWebRequest.Get(jsFileInfo.blobPath);
//创建文件夹
string dirPath = Path.Combine(Application.persistentDataPath, "Res");
// Debug.Log("将被存至目录:" + dirPath);
if (!Directory.Exists(dirPath))
{
Directory.CreateDirectory(dirPath);
}
string fullPath = Path.Combine(dirPath, jsFileInfo.name);
request.downloadHandler = new DownloadHandlerFile(fullPath);//路径+文件名
// Debug.Log("复制到沙盒ing:" + fullPath);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
copyCount++;
Debug.Log("复制到沙盒完成:" + fullPath + "," + copyCount);
if (isZip)
{
if (fullPath.EndsWith(".zip"))
{
zipLocalFileList.Enqueue(fullPath);
}
}
else
{
resLocalFileList.Enqueue(fullPath);
}
if (needCopyCount == copyCount)
{
if (isZip)
{
zipPathInputField.text = ".../" + jsFileInfo.localPath;
}
else
{
resPathInputField.text = ".../" + jsFileInfo.localPath;
}
}
}
else
{
Debug.Log(request.error);
}
}
}
文件拷贝到浏览器沙盒目录后, 即可使用System.IO.File.ReadAllBytes(path)加载文件喽:
while (localFileList.Count > 0)
{
string item = zipLocalFileList.Dequeue();
byte[] data = LoadData(item);
if (data != null)
{
Debug.Log("LoadData succeed");
//...
}
}
5.打包测试
注意Editor模式运行会报错EntryPointNotFoundException。需要打包运行测试。
四、补充说明
1.使用Chrome浏览器来运行WebGL,Safari浏览器无法弹出文件夹选择对话框。
2.运行WebGL偶现报错,清理浏览器缓存后解决,后续解决后补充。