天天看点

Unity WebGL 应用开发总结

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

继续阅读