Skip to content

构建一个更好的 Unity HTTP 客户端 ​

作者: Eric_Lian

TIP

如果您正在使用 Unity 6000.5 或更高版本,可以使用 Unity 自带的 UnityHttpMessageHandler 来代替本文的自定义实现。

详情参见 Unity 文档。

摘要 ​

HTTP 是服务之间通信极其重要的协议,因此 Unity 提供了 UnityWebRequest 类来发起 HTTP 请求。然而 UnityWebRequest 在某些场景相当难用。此外,我们也知道 .NET 提供的 HttpClient 比 UnityWebRequest 更强大灵活,但根据 Unity 社区的讨论,IL2CPP 环境下 HttpClient 在某些情况下处理请求时可能会卡住、没有响应。

本文介绍 Milthm 团队在 Unity 中使用 HTTP 时遇到的问题、我们采取的手段、背后的原因,以及最终的解决办法。

背景 ​

Milthm 自 2023 年 11 月 22 日 起引入了 HTTP 协议栈,当时底层使用的是 UnityWebRequest。说实话,UnityWebRequest 使用体验相当差,尤其是很难组织出一个合理的请求处理管线来向上层业务提供服务。

另一个常用的库是 RestSharp,它是 .NET 上很流行的 HTTP 客户端库,提供了简单直观的 API 来发起请求与处理响应。Milthm 从 1.10.0 版本 开始,将所有的 HTTP 请求都迁移到了 RestSharp,并维护了一个 Unity 版本的 RestSharp 分支,尽量保持与上游差异最小。

看起来好像解决了所有的烦恼,但在 Unity 中使用 RestSharp 时我们遇到了一些新的问题。

奇怪的问题 ​

用了一段时间 RestSharp 后,有用户反馈 Milthm 打不开,具体表现为一直卡在加载界面,没有任何报错。

这个反馈令我感到莫名其妙,因为在构建 Milthm 的网络协议栈时候,我们的行为完全遵守了常见的客户端开发规范, 比如说设置了请求超时,并处理了所有可能出现的异常。按理说不应该卡住。

c#
using var client = new RestClient(configureRestClient: cfg =>
{
    cfg.FollowRedirects = false;
    cfg.MaxTimeout = 2000;
});
var req = new RestRequest("https://google.com");
var resp = await client.ExecuteAsync(req);

并且更惨的是,没人能稳定复现这个问题,也就是说找不到必现条件。唯一的解决办法就是重启游戏、重启设备网络、更换网络环境(从 WLAN 切到蜂窝数据,或者换个 WiFi)、重启设备等。但这些方法都不可靠。

从那时起,Milthm 与 HTTP 请求之间开始了一场持久战。

一些尝试 ​

这个问题非常难复现。我只在我的电脑上调试 Milthm 时亲眼看到过一次。 那次我抓紧机会用调试器对他进行断点。 单步执行,进入 ExecuteAsync 行后发现代码挂在了 RestSharp 的内部。但另一个问题是:被卡住的函数外围包了 CancellationToken,而且 RestSharp 的整体流程写得也不错,从逻辑上看,RestSharp 的代码不像会有任何卡住的可能。 所以我猜可能是 RestSharp 或 Unity IL2CPP 运行时,或者 .NET 网络库里的某些 bug。

由于 2024 年 Milthm 对 HTTP 的需求不高,我决定暂时忽略这个问题,只是在有新版本时升级 RestSharp,并尽量跟进 Unity 的最新版本。

分析 ​

很遗憾,到了 2025 年 8 月问题依旧存在。此时别无选择,只能正面对决。

Milthm 自 4.0.0 起提供了更多在线功能,包括用户账户、云存档、歌曲兑换等。 如果这些功能不稳定,用户体验会大受影响,我必须想办法。

略加思索,我梳理了 RestSharp 运行时的架构。Restsharp 是 HttpClient 的包装,而 HttpClient 会根据平台选择 HttpMessageHandler 来处理请求。

mermaid
flowchart TB
    App[Application Code]

    App --> RestSharp
    RestSharp --> RestClient
    RestClient --> HttpClient

    HttpClient --> HttpMessageHandler

    HttpMessageHandler -->|Non-Browser| SocketsHttpHandler
    HttpMessageHandler -->|Browser / WASM| BrowserHttpHandler

    SocketsHttpHandler --> OSStack[OS Networking Stack]
    BrowserHttpHandler --> BrowserAPI[Browser Networking APIs]

    subgraph RS[RestSharp Layer]
        RestClient
    end

    subgraph NET[System.Net.Http]
        HttpClient
        HttpMessageHandler
    end

    subgraph PLATFORM[Platform Specific Handlers]
        SocketsHttpHandler
        BrowserHttpHandler
    end

从上图可以看出,HttpClient 会根据平台选择不同的 HttpMessageHandler。于是我想,如果我直接把 HttpMessageHandler 换成一个由 UnityWebRequest 组成的版本会怎样,能成吗?

再深入查资料,发现可行!在使用时候可以通过 HttpClient 的构造函数 里配置自定义 HttpMessageHandler。

继续往下看,HttpMessageHandler 本质是一个处理 HTTP 请求的简单委托,只需要实现接收 HttpRequestMessage、返回 HttpResponseMessage 的 SendAsync 方法。

c#
// https://github.com/dotnet/runtime/blob/5535e31a712343a63f5d7d796cd874e563e5ac14/src/libraries/System.Net.Http/src/System/Net/Http/HttpMessageHandler.cs

namespace System.Net.Http

public abstract class HttpMessageHandler : IDisposable
{
    protected internal abstract Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
}

一路绿灯,直接三下五除二实现一个 UnityWebRequest 版的 HttpMessageHandler,然后给他们拼起来就齐活了。

实现 ​

下面是一个 UnityWebRequest 版的 UnityHttpMessageHandler 实现。

c#
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks; // UniTask
using UnityEngine.Networking;  // Unity Networking

public class UnityWebRequestHandler : HttpMessageHandler
{
    public class Option
    {
        public int MaxRedirects { get; set; } = 32;
    }

    private readonly Option _option;

    public UnityWebRequestHandler(Action<Option> option)
    {
        _option = new Option();
        option.Invoke(_option);
    }


    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        await UniTask.SwitchToMainThread(cancellationToken);
        using var uwr = await CreateUnityWebRequest(request);

        // Process the request
        var operation = uwr.SendWebRequest();

        // Support CancellationToken
        while (!operation.isDone)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                uwr.Abort();
                throw new TaskCanceledException();
            }

            await Task.Yield();
        }

        // After request is done, construct HttpResponseMessage

        // Construct HttpResponseMessage
        var response = new HttpResponseMessage((HttpStatusCode)uwr.responseCode);

        // Respons Body
        if (uwr.downloadHandler != null)
        {
            var bytes = uwr.downloadHandler.data ?? Array.Empty<byte>();
            response.Content = new ByteArrayContent(bytes);
        }

        // Clone Headers
        foreach (var kv in uwr.GetResponseHeaders())
        {
            response.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
        }

        // Finish Response
        response.RequestMessage = request;
        response.ReasonPhrase = uwr.error;
        return response;
    }

    /// <summary>
    /// Construct Unity Web Request
    /// </summary>
    /// <param name="request"></param>
    /// <returns></returns>
    private async Task<UnityWebRequest> CreateUnityWebRequest(HttpRequestMessage request)
    {
        UnityWebRequest uwr;

        // Method
        var method = request.Method.Method;

        // Constuct UnityWebRequest instance and set body
        if (request.Content != null)
        {
            var body = await request.Content.ReadAsByteArrayAsync();
            uwr = new UnityWebRequest(request.RequestUri, method)
            {
                uploadHandler = new UploadHandlerRaw(body),
                downloadHandler = new DownloadHandlerBuffer()
            };
        }
        else
        {
            uwr = new UnityWebRequest(request.RequestUri, method)
            {
                downloadHandler = new DownloadHandlerBuffer()
            };
        }

        // Copy Settings
        uwr.redirectLimit = _option.MaxRedirects;

        // Copy Headers
        foreach (var header in request.Headers)
        {
            foreach (var value in header.Value)
            {
                uwr.SetRequestHeader(header.Key, value);
            }
        }

        if (request.Content != null)
        {
            foreach (var header in request.Content.Headers)
            {
                foreach (var value in header.Value)
                {
                    uwr.SetRequestHeader(header.Key, value);
                }
            }
        }

        return uwr;
    }
}

有了这个 UnityWebRequestHandler,我们可以创建一个使用 UnityWebRequest 处理 HTTP 请求的 HttpClient 实例。

c#
 // 先创建 UnityWebRequestHandler 实例
using var handler = new UnityWebRequestHandler((it) =>
{
    it.MaxRedirects = 0;
});

// 再创建 HttpClient 实例
using var httpClient = new HttpClient(handler);

// 如果你喜欢 RestSharp,最后拼装一个 RestClient
var entrestClient = new RestClient(httpClient, false,
    configureRestClient: cfg =>
    {
        cfg.FollowRedirects = false;
        // 注意: 这个设置是没用的,他不会复制到 HttpClient 里
        // 原因见下
        // https://github.com/restsharp/RestSharp/blob/261d94eba7e6ddd2ed59a17f420809d1346e92e7/src/RestSharp/RestClient.cs#L93
        // https://github.com/restsharp/RestSharp/blob/261d94eba7e6ddd2ed59a17f420809d1346e92e7/src/RestSharp/RestClient.cs#L232
    }
);

这样,所有关于在 Unity IL2CPP 环境中使用 HttpClient 而偶发卡死的问题的问题就解决了。

结论 ​

该特性已在 Milthm 4.4 中作为实验性功能发布。启用基于 UnityWebRequest 的 HttpMessageHandler 可以稳定规避 IL2CPP/HttpClient 出现的间歇性卡死问题。这个方案在大量设备上验证有效,问题得以解决。