在Unity WebView中运行React应用的解决方案

在Unity WebView中运行React应用的解决方案

概述

本文档详细介绍了如何在Unity应用中通过WebView加载React应用,并解决在Android平台上常见的路径和网络问题。

目录

  1. 项目架构
  2. React项目打包
  3. Unity本地服务器配置
  4. Android平台特殊处理
  5. 路径映射问题解决
  6. 网络权限配置
  7. 完整实现步骤
  8. 常见问题排查

项目架构

该项目采用以下架构:

  • Unity应用:主应用程序,包含游戏逻辑和UI
  • WebView:嵌入在Unity中的网页视图组件(使用Vuplex.WebView)
  • 本地HTTP服务器:在Unity应用内运行,提供静态文件服务
  • React应用:打包为静态文件,由本地服务器提供给WebView
Unity应用
├── WebView组件
├── 本地HTTP服务器
└── StreamingAssets
    └── web/
        ├── index.html (React应用入口)
        ├── assets/ (资源文件)
        └── image/ (图片文件)

React项目打包

1. 配置React项目

确保React项目的package.json中包含正确的构建脚本:

"scripts": {
  "build": "react-scripts build"
}

2. 构建React项目

npm run build

这将生成一个build文件夹,包含所有静态资源。

3. 复制到Unity项目

将React构建输出复制到Unity项目的StreamingAssets目录:

复制 build/* 到 Assets/StreamingAssets/web/

Unity本地服务器配置

1. 创建服务器管理器

ServerManager.cs负责启动和管理本地HTTP服务器:

using UnityEngine;
using System.Threading.Tasks;
using System.IO;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.Networking;

public class ServerManager : MonoBehaviour
{
    private UnityStaticFileServer _server;
    private List<string> _scannedFiles = new List<string>();

    private async void Start()
    {
        string rootPath = Application.streamingAssetsPath + "/web";
        
        if (Application.platform == RuntimePlatform.Android)
        {
            Debug.Log($"Android platform detected. Using StreamingAssets path: {rootPath}");
            StartCoroutine(ScanAndroidStreamingAssets());
        }
        else
        {
            // 在其他平台上列出目录内容
            if (Directory.Exists(rootPath))
            {
                Debug.Log($"Web directory exists at: {rootPath}");
                try
                {
                    string[] files = Directory.GetFiles(rootPath, "*", SearchOption.AllDirectories);
                    foreach (string file in files)
                    {
                        string relativePath = file.Replace(rootPath, "").TrimStart('/', '\\');
                        _scannedFiles.Add(relativePath);
                    }
                }
                catch (System.Exception ex)
                {
                    Debug.LogError($"Error listing directory contents: {ex.Message}");
                }
            }
        }
        
        _server = new UnityStaticFileServer(rootPath);
        _server.SetScannedFiles(_scannedFiles);
        await _server.StartAsync();
    }
    
    // 扫描Android上的StreamingAssets/web文件夹
    private IEnumerator ScanAndroidStreamingAssets()
    {
        string webPath = Application.streamingAssetsPath + "/web";
        
        // 获取index.html并解析其中的资源
        string indexPath = webPath + "/index.html";
        UnityWebRequest indexRequest = UnityWebRequest.Get(indexPath);
        yield return indexRequest.SendWebRequest();
        
        if (indexRequest.result == UnityWebRequest.Result.Success)
        {
            _scannedFiles.Add("index.html");
            string html = indexRequest.downloadHandler.text;
            ParseHtmlForResources(html, webPath);
        }
        
        // 尝试常见的文件路径
        string[] commonPaths = new string[] 
        {
            "/css/style.css",
            "/js/main.js",
            "/assets/gameConfig.json",
            "/image/title_memory_card_text_tu.png",
            // 添加其他常见文件
        };
        
        foreach (string path in commonPaths)
        {
            string fullPath = webPath + path;
            UnityWebRequest request = UnityWebRequest.Get(fullPath);
            yield return request.SendWebRequest();
            
            if (request.result == UnityWebRequest.Result.Success)
            {
                _scannedFiles.Add(path.TrimStart('/'));
            }
        }
        
        _server.SetScannedFiles(_scannedFiles);
    }
    
    // 解析HTML中的资源引用
    private void ParseHtmlForResources(string html, string basePath)
    {
        // 解析CSS、JS、图片等资源
        ParseResourceLinks(html, "href=\"", "\"", ".css", basePath);
        ParseResourceLinks(html, "src=\"", "\"", ".js", basePath);
        ParseResourceLinks(html, "src=\"", "\"", ".png", basePath);
        // 添加其他资源类型
    }
    
    private void OnDestroy()
    {
        _server?.Stop();
        _server?.Dispose();
    }
}

2. 实现静态文件服务器

UnityStaticFileServer.cs实现了HTTP服务器功能:

using System;
using System.IO;
using System.Net;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

public class UnityStaticFileServer : IDisposable
{
    private readonly HttpListener _listener;
    private readonly string _rootPath;
    private bool _isRunning;
    private readonly Dictionary<string, string> _mimeTypes;
    private CancellationTokenSource _cancellationTokenSource;
    private int _port;
    private Dictionary<string, byte[]> _androidCache = new Dictionary<string, byte[]>();
    private List<string> _scannedFiles = new List<string>();

    public UnityStaticFileServer(string rootPath, int port = 8080)
    {
        _rootPath = rootPath;
        _port = port;
        _listener = new HttpListener();
        
        // 绑定到所有网络接口
        _listener.Prefixes.Add($"http://*:{port}/");
        _listener.Prefixes.Add($"http://+:{port}/");
        
        // 初始化MIME类型
        _mimeTypes = new Dictionary<string, string>
        {
            { ".html", "text/html" },
            { ".css", "text/css" },
            { ".js", "application/javascript" },
            // 添加其他MIME类型
        };
    }
    
    // 设置扫描到的文件列表
    public void SetScannedFiles(List<string> scannedFiles)
    {
        if (scannedFiles != null)
        {
            _scannedFiles = new List<string>(scannedFiles);
            
            // 预加载Android文件
            if (Application.platform == RuntimePlatform.Android)
            {
                foreach (string file in _scannedFiles)
                {
                    string fullPath = Path.Combine(_rootPath, file).Replace("\\", "/");
                    Debug.Log($"Will preload file: {fullPath}");
                }
            }
        }
    }
    
    // 在Android平台上从StreamingAssets读取文件
    private async Task<byte[]> ReadAndroidStreamingAssetAsync(string filePath)
    {
        string fullPath = filePath;
        if (!fullPath.StartsWith("jar:"))
        {
            if (filePath.StartsWith(Application.streamingAssetsPath))
            {
                fullPath = filePath;
            }
            else
            {
                fullPath = Path.Combine(Application.streamingAssetsPath, filePath.TrimStart('/'));
            }
        }

        // 检查缓存
        if (_androidCache.TryGetValue(fullPath, out byte[] cachedData))
        {
            return cachedData;
        }

        // 使用UnityWebRequest读取文件
        using (UnityWebRequest webRequest = UnityWebRequest.Get(fullPath))
        {
            var asyncOperation = webRequest.SendWebRequest();
            while (!asyncOperation.isDone)
            {
                await Task.Delay(10);
            }

            if (webRequest.result == UnityWebRequest.Result.Success)
            {
                byte[] data = webRequest.downloadHandler.data;
                _androidCache[fullPath] = data;
                return data;
            }
            else
            {
                string relativePath = filePath.Replace(_rootPath, "").TrimStart('/');
                if (_scannedFiles.Contains(relativePath))
                {
                    Debug.LogError($"File {relativePath} is in scanned list but cannot be read!");
                }
                return null;
            }
        }
    }
    
    // 处理HTTP请求
    private async Task HandleRequestAsync(HttpListenerContext context)
    {
        try
        {
            var response = context.Response;
            var request = context.Request;

            // 获取本地路径
            var localPath = request.Url?.LocalPath.TrimStart('/') ?? "";
            
            // 路径修正 - 处理web/game/路径的映射
            if (localPath.StartsWith("game/") || localPath.Contains("/game/"))
            {
                string originalPath = localPath;
                
                // 处理game/assets/到assets/的映射
                if (localPath.Contains("game/assets/"))
                {
                    localPath = localPath.Replace("game/assets/", "assets/");
                }
                // 处理game/image/到image/的映射
                else if (localPath.Contains("game/image/"))
                {
                    localPath = localPath.Replace("game/image/", "image/");
                }
                // 处理其他可能的game/目录映射
                else
                {
                    localPath = localPath.Replace("game/", "");
                }
                
                Debug.Log($"Path corrected: {originalPath} -> {localPath}");
            }

            // 如果路径为空,提供index.html
            if (string.IsNullOrEmpty(localPath))
                localPath = "index.html";

            // 构建完整路径
            string fullPath = Path.Combine(_rootPath, localPath).Replace("\\", "/");
            
            // 处理文件请求
            byte[] fileData = null;

            if (Application.platform == RuntimePlatform.Android)
            {
                // 在Android上使用特殊方法读取文件
                fileData = await ReadAndroidStreamingAssetAsync(fullPath);

                // 如果找不到请求的文件,尝试提供index.html(用于SPA路由)
                if (fileData == null && !localPath.Equals("index.html", StringComparison.OrdinalIgnoreCase))
                {
                    string indexPath = Path.Combine(_rootPath, "index.html").Replace("\\", "/");
                    fileData = await ReadAndroidStreamingAssetAsync(indexPath);
                }
            }
            else
            {
                // 在其他平台上使用标准文件IO
                if (File.Exists(fullPath))
                {
                    fileData = File.ReadAllBytes(fullPath);
                }
                else if (!localPath.Equals("index.html", StringComparison.OrdinalIgnoreCase))
                {
                    // 尝试提供index.html(用于SPA路由)
                    string indexPath = Path.Combine(_rootPath, "index.html");
                    if (File.Exists(indexPath))
                    {
                        fileData = File.ReadAllBytes(indexPath);
                    }
                }
            }

            // 如果找不到文件,返回404
            if (fileData == null)
            {
                response.StatusCode = (int)HttpStatusCode.NotFound;
                return;
            }

            // 设置MIME类型
            var extension = Path.GetExtension(fullPath).ToLowerInvariant();
            response.ContentType = _mimeTypes.TryGetValue(extension, out var mimeType)
                ? mimeType
                : "application/octet-stream";

            // 添加CORS头
            response.Headers.Add("Access-Control-Allow-Origin", "*");
            
            // 发送文件数据
            response.ContentLength64 = fileData.Length;
            response.OutputStream.Write(fileData, 0, fileData.Length);
        }
        finally
        {
            context.Response.Close();
        }
    }
}

3. 配置WebView加载器

WebViewLoader.cs负责初始化WebView并加载URL:

using UnityEngine;
using Vuplex.WebView;
using System.Net.NetworkInformation;
using System.Net;
using System.Net.Sockets;

public class WebViewLoader : MonoBehaviour
{
    [SerializeField] private CanvasWebViewPrefab canvasWebView;
    [SerializeField] private bool useLocalServer = true;
    
    private void Start()
    {
        // 初始化WebView
        canvasWebView.InitialUrl = "about:blank";
        canvasWebView.NativeOnScreenKeyboardEnabled = true;
        
        // 设置事件处理
        canvasWebView.WebView.SetDefaultBackgroundEnabled(false);
        
        // 加载网页
        if (useLocalServer)
        {
            // 在Android上使用本地IP地址而不是localhost
            string url = Application.platform == RuntimePlatform.Android 
                ? $"http://{GetLocalIPAddress()}:8080/" 
                : "http://localhost:8080/";
                
            Debug.Log($"Loading local URL: {url}");
            canvasWebView.WebView.LoadUrl(url);
        }
        else
        {
            // 使用远程URL
            canvasWebView.WebView.LoadUrl("https://3zentree.cn/testGlove/");
        }
    }    
   
    // 获取设备的本地IP地址
    private string GetLocalIPAddress()
    {
        string localIP = "127.0.0.1"; // 默认值
        try
        {
            // 获取所有网络接口
            NetworkInterface[] networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
            
            foreach (NetworkInterface networkInterface in networkInterfaces)
            {
                // 只考虑活跃的接口
                if (networkInterface.OperationalStatus != OperationalStatus.Up)
                    continue;
                
                // 跳过虚拟接口和环回接口
                if (networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
                    networkInterface.Description.Contains("Virtual") ||
                    networkInterface.Name.Contains("vEthernet"))
                    continue;
                
                // 获取IP属性
                IPInterfaceProperties ipProperties = networkInterface.GetIPProperties();
                
                // 查找IPv4地址
                foreach (UnicastIPAddressInformation ipInfo in ipProperties.UnicastAddresses)
                {
                    if (ipInfo.Address.AddressFamily == AddressFamily.InterNetwork)
                    {
                        localIP = ipInfo.Address.ToString();
                        return localIP;
                    }
                }
            }
        }
        catch (System.Exception ex)
        {
            Debug.LogError($"Error getting local IP address: {ex.Message}");
        }
        
        return localIP;
    }
}

Android平台特殊处理

1. StreamingAssets访问

在Android平台上,StreamingAssets文件夹被打包到APK中,无法通过标准的File.ExistsFile.ReadAllBytes访问。解决方案:

  1. 使用UnityWebRequest读取文件
  2. 实现文件缓存机制
  3. 扫描并预加载关键文件

2. 网络接口绑定

在Android上,服务器需要绑定到设备的实际IP地址,而不是localhost:

// 在Android上使用本地IP地址而不是localhost
string url = Application.platform == RuntimePlatform.Android 
    ? $"http://{GetLocalIPAddress()}:8080/" 
    : "http://localhost:8080/";

路径映射问题解决

React应用可能使用不同的路径结构,导致在Unity WebView中加载时出现404错误。我们实现了路径映射解决方案:

// 路径修正 - 处理web/game/路径的映射
if (localPath.StartsWith("game/") || localPath.Contains("/game/"))
{
    string originalPath = localPath;
    
    // 处理game/assets/到assets/的映射
    if (localPath.Contains("game/assets/"))
    {
        localPath = localPath.Replace("game/assets/", "assets/");
    }
    // 处理game/image/到image/的映射
    else if (localPath.Contains("game/image/"))
    {
        localPath = localPath.Replace("game/image/", "image/");
    }
    // 处理其他可能的game/目录映射
    else
    {
        localPath = localPath.Replace("game/", "");
    }
    
    Debug.Log($"Path corrected: {originalPath} -> {localPath}");
}

这解决了以下错误:

  • Failed to read file jar:file:///.../base.apk!/assets/web/game/assets/gameConfig.json
  • Failed to read file jar:file:///.../base.apk!/assets/web/game/image/title_memory_card_text_tu.png

网络权限配置

AndroidManifest.xml中添加必要的网络权限:


<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />


<application
    android:allowBackup="true"
    android:icon="@drawable/app_icon"
    android:label="@string/app_name"
    android:usesCleartextTraffic="true">
    
application>

完整实现步骤

  1. 准备React项目

    • 构建React项目:npm run build
    • 将构建输出复制到Assets/StreamingAssets/web/
  2. 配置Unity项目

    • 添加ServerManager.csUnityStaticFileServer.csWebViewLoader.cs
    • 确保AndroidManifest.xml包含必要的网络权限
  3. 设置场景

    • 添加ServerManager组件到场景中的GameObject
    • 添加WebViewLoader组件到包含WebView的GameObject
    • 设置CanvasWebViewPrefab引用
  4. 构建和部署

    • 构建Unity项目为Android APK
    • 在Android设备上安装并运行

常见问题排查

1. 404错误

如果遇到404错误,检查:

  • 文件路径是否正确
  • 文件是否成功复制到StreamingAssets
  • 路径映射是否正确处理了所有情况

2. 网络错误

如果遇到网络错误,检查:

  • AndroidManifest.xml中的权限设置
  • 设备是否连接到网络
  • 本地IP地址是否正确获取

3. 空白WebView

如果WebView显示空白,检查:

  • 服务器是否成功启动(查看日志)
  • WebView是否正确加载URL
  • React应用是否正确构建

4. 文件读取错误

如果遇到文件读取错误,检查:

  • 在Android上是否使用了正确的文件读取方法
  • 文件路径是否包含特殊字符
  • 文件是否被正确包含在APK中

通过以上步骤和解决方案,您应该能够成功将React应用打包到Android,并通过Unity中的本地服务器和WebView加载它。如果仍然遇到问题,请查看Unity控制台日志以获取更详细的错误信息。

你可能感兴趣的:(UNITY,REACT,unity,react.js,游戏引擎)