天天看點

Unity WebGL 應用開發總結

作者:青少年程式設計ABC

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

繼續閱讀