Unity WebGL 应用开发总结
1.开发环境
软件 | 版本 |
Unity | 2020.1.0f1 |
PyCharm | 2022.3.2 |
Python | 3.7.3 |
2.编译WebGL
对 Unity 项目进行 WebGL 编译时,经常会出现如下问题:
1.Unable to parse Build/web.framework.js.gz! This can happen if build compression was enabled but web server hosting the content was misconfigured to not serve the file with HTTP Response Header “Content-Encoding: gzip” present. Check browser Console and Devtools Network tab to debug. 此时,通过 Build Settings -> PlayerSettings -> Publishing Settings 中勾选 Decompression Fallback(解压缩回退)。
2.Unable to parse Build/acWeb.framework.js.unityweb! The file is corrupt, or compression was misconfigured? (check Content-Encoding HTTP Response Header on web server) 此时,在 Build Settings -> PlayerSettings -> Other Settings -> Rendering
- 将Color Space 设置为Gamma
- 将Lightmap Encoding 设置为NormalQuality
然后重新构建即可。
3.获取 URL 请求参数
在项目的 Assets 文件夹下创建 Plugins\WebGL 目录,在 WebGL 目录下新建一个文本文件 xxx.jslib(如:QYPlugin.jslib),并在文件中编写函数如下:
var QYPlugin = {
FindURLParameter : function()
{
var parameters = window.location.search;
var bufferSize = lengthBytesUTF8(parameters) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(parameters, buffer, bufferSize);
return buffer;
}
};
mergeInto(LibraryManager.library, QYPlugin);
其中的方法不要写错,否则会导致 unity 打包不成功的问题出现。
然后,在 C# 脚本中通过如下方式来调用:
1.在脚本中添加引用
using UnityEngine;
using System.Runtime.InteropServices;
2.引用
#if !UNITY_EDITOR&&UNITY_WEBGL
[DllImport("__Internal")]
private static extern string FindURLParameter();
#endif
3.在需要的位置调用方法
#if !UNITY_EDITOR&&UNITY_WEBGL
string urlParameter = FindURLParameter();
Globals.SessionId = urlParameter.Substring(1);
#endif
4.网络请求
对于 Unity' WebGL 应用,网络请求目前不支持 Socket 和 WebSocket,只支持 HTTP 请求方式,所以在一般情况下,后台采用 RestAPI 提供接口,Unity WebGL 通过 HTTP 方式连接到网络来执行网络请求并获取数据。
对于 Unity 的 HTTP 请求,支持三种方式:
- WWW(基于协程,不适用于线程)
- UnityWebRequest(基于协程,不适用于线程)
- HttpWebRequest(C#原生的HttpWebRequest,同步接口,阻塞等待结果返回,适用于线程)
对于 WWW 目前官方已经不支持,其余两种方式在 WebGL 中只能使用 UnityWebRequest。
对 UnityWebRequest 请求的封装代码如下:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
using LitJson;
using UnityEngine.SceneManagement;
public class QYHttp : MonoBehaviour
{
private static QYHttp _instance;
public static QYHttp instance
{
get
{
if (_instance == null)
{
GameObject go = new GameObject("QYHttp");
_instance = go.AddComponent<QYHttp>();
}
return _instance;
}
}
public void PostFormRequest(string url, WWWForm form=null, Action<bool, string> callBack = null)
{
StartCoroutine(_PostForm(url, form, callBack));
}
private IEnumerator _PostForm(string url, WWWForm form=null, Action<bool, string> callBack = null)
{
if (form == null)
{
form = new WWWForm();
form.AddField("tmp", "tmp");
}
UnityWebRequest request = UnityWebRequest.Post(url, form);
request.SetRequestHeader("content-type", "application/x-www-form-urlencoded");
request.downloadHandler = new DownloadHandlerBuffer();
yield return request.SendWebRequest();
string responseStr = "";
if (request.isNetworkError || request.isHttpError)
{
responseStr = request.error;
}
else
{
responseStr = request.downloadHandler.text;
}
if (callBack != null)
{
callBack(request.isNetworkError || request.isHttpError, responseStr);
}
}
public void PostAuthRequest(string url, JsonData data, Action<bool, string> callBack = null)
{
StartCoroutine(_PostAuth(url, data, callBack));
}
private IEnumerator _PostAuth(string url, JsonData data, Action<bool, string> callBack = null)
{
if (data == null)
{
data = new JsonData();
data["tmp"] = "tmp";
}
data["authorization"] = Globals.TokenType + " " + Globals.AccessToken;
string jsonstring = JsonMapper.ToJson(data);
string ciphertext = QYEncrypt.AESEncryString(jsonstring);
JsonData jsonData = new JsonData();
jsonData["value"] = ciphertext;
jsonData["size"] = jsonstring.Length;
byte[] bytes = Encoding.UTF8.GetBytes(JsonMapper.ToJson(jsonData));
UnityWebRequest request = new UnityWebRequest(url, "POST");
request.uploadHandler = new UploadHandlerRaw(bytes);
request.SetRequestHeader("Content-Type", "application/json;charset=utf-8");
request.downloadHandler = new DownloadHandlerBuffer();
yield return request.SendWebRequest();
if (request.responseCode == 401) SceneManager.LoadScene("Scenes/ErrorScene");
string responseStr = "";
if (request.isNetworkError || request.isHttpError)
{
responseStr = request.error;
}
else
{
responseStr = request.downloadHandler.text;
}
if (callBack != null)
{
callBack(request.isNetworkError || request.isHttpError, responseStr);
}
}
}
以上代码封装的是两个 Post 方法,其中 PostFormRequest 是以表单数据为参数提交请求,PostAuthRequest 是携带请求令牌以 Json 数据为参数提交请求。
在携带请求令牌时没有使用 http Headers 的原因是:经过测试,通过 UnityWebRequest 的 SetRequestHeader 方法携带令牌在请求时不稳定,在服务端有时会出现获取不到令牌的情况。
所以,在封装 PostAuthRequest 方法时,使用了如下代码:
data["authorization"] = Globals.TokenType + " " + Globals.AccessToken;
string jsonstring = JsonMapper.ToJson(data);
string ciphertext = QYEncrypt.AESEncryString(jsonstring);
JsonData jsonData = new JsonData();
jsonData["value"] = ciphertext;
jsonData["size"] = jsonstring.Length;
将 authorization 参数直接写到 Json 格式的数据中,然后对 Json 字符串数据进行加密,加密后提交的数据只有两个字段:
- value:加密后的字符串
- size:原始字符串长度
5.数据传输
由于携带令牌出现了不稳定数据传输的情况,所以,我们将令牌与请求体合并通过 Json 格式进行数据传输,鉴于此,数据传输需要加密,加密采用的是 AES 算法,加密代码如下:
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
public class QYEncrypt
{
private static string AesKey = "****************"; // 可以是16/24/32位
public static string AESEncryString(string text)
{
byte[] bytes = Encoding.UTF8.GetBytes(text);
RijndaelManaged rijndael = new RijndaelManaged();
rijndael.Key = Encoding.UTF8.GetBytes(AesKey);
// rijndael.IV = Encoding.UTF8.GetBytes(AesIv);
rijndael.Mode = CipherMode.ECB;
rijndael.Padding = PaddingMode.PKCS7;
ICryptoTransform cryptoTransform = rijndael.CreateEncryptor();
byte[] resultBytes = cryptoTransform.TransformFinalBlock(bytes, 0, bytes.Length);
string AesStr = Convert.ToBase64String(resultBytes);
return AesStr;
}
public static string AESDecryString(string text)
{
byte[] bytes = Convert.FromBase64String(text);
RijndaelManaged rijndael = new RijndaelManaged();
rijndael.Key = Encoding.UTF8.GetBytes(AesKey);
// rijndael.IV = Encoding.UTF8.GetBytes(AesIv);
rijndael.Mode = CipherMode.ECB;
rijndael.Padding = PaddingMode.PKCS7;
ICryptoTransform cryptoTransform = rijndael.CreateDecryptor();
byte[] resultBytes = cryptoTransform.TransformFinalBlock(bytes, 0, bytes.Length);
string AesStr = Encoding.UTF8.GetString(resultBytes);
return AesStr;
}
}
后端采用 Python 的 FastApi 实现 RestAPI ,后端的解密算法如下:
# coding: utf-8
import base64
from Crypto.Cipher import AES
AesKey = "*****************"; #可以是16/24/32位
def pkcs7padding(text):
"""明文使用PKCS7填充"""
need_size = 16
text_length = len(text)
bytes_length = len(text.encode('utf-8'))
padding_size = text_length if (bytes_length == text_length) else bytes_length
padding = need_size - padding_size % need_size
padding_text = chr(padding) * padding
return text + padding_text
def AESEncryString(text=None):
text = pkcs7padding(text)
aes = AES.new(key=AesKey.encode("utf-8"), mode=AES.MODE_ECB)
en_text = aes.encrypt(text.encode('utf-8'))
result = str(base64.b64encode(en_text), encoding='utf-8')
return result
def AESDecryString(ciphertext=None):
aes = AES.new(key=AesKey.encode('utf-8'), mode=AES.MODE_ECB)
if len(ciphertext) % 3 == 1:
ciphertext += "=="
elif len(ciphertext) % 3 == 2:
ciphertext += "="
content = base64.b64decode(ciphertext)
text = aes.decrypt(content).decode('utf-8')
return text
if __name__ == '__main__':
res = AESEncryString(text="hello,呼和浩特")
print("加密后的密文是:",res)
res = AESDecryString(ciphertext=res)
print("密文解密后的明文是:",res)
后端令牌校验算法:
# Token校验
def verify_token(token: str, user_agent: str):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
logger.info(payload)
sub = json.loads(payload['sub'])
if sub['user_agent'] != user_agent:
return False
except Exception as ex:
logger.info(str(ex))
return False
return True
async def authorized(request: Request):
data: dict = await request.json()
value = data.get('value')
size = data.get('size')
if value is None or size is None: return False
plaintext = AESDecryString(value)
print(plaintext)
jsondata: dict = json.loads(plaintext[0:size])
# 若不存在authorization则返回鉴权失败
if not 'authorization' in jsondata.keys():
return False
authorization = jsondata.get('authorization')
logger.info(authorization)
# 若authorization类型不是Bearer则返回鉴权失败
if not authorization.startswith('Bearer'):
return False
token = authorization[7:]
logger.info(token)
# 若令牌校验失败则返回鉴权失败
logger.info(request.headers['user-agent'])
if not verify_token(token, request.headers['user-agent']):
return False
return True
RestAPI 对应的业务代码中,需要在每次请求时对令牌进行校验,而不是在中间件中进行校验,其代码类似于:
@router.post(path='/save_all_devices')
async def save_all_devices(request: Request, value: str = Body(..., embed=True), size: int = Body(..., embed=True)):
if (not await authorized(request)): raise HTTPException(status_code=401)
FastApi 跨域需要在主程序中增加代码如下:
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
6.WebGL 页面自适应显示
Unity 项目编译为 WebGL 页面后,如果想要在浏览器中让页面自适应显示,需要对编译后的 html 文件和 css 文件做一些修改。对于 Unity 2020.1.0f1 版本编译后的调整记录如下:
- 生成的 index.html:
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Unity WebGL Player | devicefix</title>
<link rel="shortcut icon" href="TemplateData/favicon.ico">
<link rel="stylesheet" href="TemplateData/style.css">
</head>
<body>
<div id="unity-container" class="unity-desktop">
<canvas id="unity-canvas"></canvas>
<div id="unity-loading-bar">
<div id="unity-logo"></div>
<div id="unity-progress-bar-empty">
<div id="unity-progress-bar-full"></div>
</div>
</div>
<div id="unity-footer">
<div id="unity-webgl-logo"></div>
<div id="unity-fullscreen-button"></div>
<div id="unity-build-title">devicefix</div>
</div>
</div>
<script>
var buildUrl = "Build";
var loaderUrl = buildUrl + "/webgl.loader.js";
var config = {
dataUrl: buildUrl + "/webgl.data.unityweb",
frameworkUrl: buildUrl + "/webgl.framework.js.unityweb",
codeUrl: buildUrl + "/webgl.wasm.unityweb",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "devicefix",
productVersion: "0.1",
};
var container = document.querySelector("#unity-container");
var canvas = document.querySelector("#unity-canvas");
var loadingBar = document.querySelector("#unity-loading-bar");
var progressBarFull = document.querySelector("#unity-progress-bar-full");
var fullscreenButton = document.querySelector("#unity-fullscreen-button");
if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
container.className = "unity-mobile";
config.devicePixelRatio = 1;
} else {
canvas.style.width = "1024px";
canvas.style.height = "768px";
}
loadingBar.style.display = "block";
var script = document.createElement("script");
script.src = loaderUrl;
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {
progressBarFull.style.width = 100 * progress + "%";
}).then((unityInstance) => {
loadingBar.style.display = "none";
fullscreenButton.onclick = () => {
unityInstance.SetFullscreen(1);
};
}).catch((message) => {
alert(message);
});
};
document.body.appendChild(script);
</script>
</body>
</html>
- 生成的 style.css:
body { padding: 0; margin: 0 }
#unity-container { position: absolute }
#unity-container.unity-desktop { left: 50%; top: 50%; transform: translate(-50%, -50%) }
#unity-container.unity-mobile { width: 100%; height: 100% }
#unity-canvas { background: #231F20 }
.unity-mobile #unity-canvas { width: 100%; height: 100% }
#unity-loading-bar { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: none }
#unity-logo { width: 154px; height: 130px; background: url('unity-logo-dark.png') no-repeat center }
#unity-progress-bar-empty { width: 141px; height: 18px; margin-top: 10px; background: url('progress-bar-empty-dark.png') no-repeat center }
#unity-progress-bar-full { width: 0%; height: 18px; margin-top: 10px; background: url('progress-bar-full-dark.png') no-repeat center }
#unity-footer { position: relative }
.unity-mobile #unity-footer { display: none }
#unity-webgl-logo { float:left; width: 204px; height: 38px; background: url('webgl-logo.png') no-repeat center }
#unity-build-title { float: right; margin-right: 10px; line-height: 38px; font-family: arial; font-size: 18px }
#unity-fullscreen-button { float: right; width: 38px; height: 38px; background: url('fullscreen-button.png') no-repeat center }
修改如下:
1.修改 index.html 文件中的如下内容:
<div id="unity-footer">
为
<div id="unity-footer" style="display:none;">
将页面中下面的 logo、应用名称、全屏按钮等隐藏掉。
2.修改 index.html 文件中的如下内容:
if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
container.className = "unity-mobile";
config.devicePixelRatio = 1;
} else {
canvas.style.width = "1024px";
canvas.style.height = "768px";
}
将 else 语句体修改为:
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
3.在 style.css 文件的末尾增加如下代码:
html,body{width:100%;height:100%;margin:0;padding:0;overflow:hidden;}
#unity-canvas {width: 100%; height: 100%;}
#unity-container{width: 100%; height: 100%;}