Skip to content

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

作者: Eric_Lian

TIP

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

详情参见 Unity 文档

摘要

HTTP 是服务之间通信极其重要的协议,因此 Unity 提供了 UnityWebRequest 类来发起 HTTP 请求。然而 UnityWebRequest 在某些场景相当难用。此外,我们也知道 .NET 提供的 HttpClientUnityWebRequest 更强大灵活,但根据 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 运行时的架构。RestsharpHttpClient 的包装,而 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、返回 HttpResponseMessageSendAsync 方法。

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 出现的间歇性卡死问题。这个方案在大量设备上验证有效,问题得以解决。