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%;}