返回全部文章

Unity 加载时间应该量到哪里?从场景加载到可以操作

LoadSceneAsync 完成后,加载画面可能还没有消失。 存档恢复、玩家生成或输入初始化可能仍在进行。 要缩短加载时间,先定义 什么时候停止计时。

本文把加载请求到可以操作的时间作为整体指标,再用中间时点帮助定位等待。 计测设计依据 Unity 6.4 API 和 Framedash Unity SDK 0.1.8 的行为。

进度 0.9 不等于可以操作

当 allowSceneActivation = false 时,Unity 将进度停在 0.9,并保持 isDone 为 false。 这是场景激活前的等待点,不代表字节数或 CPU 工作量已完成 90%。 阻止激活还可能让其他 AsyncOperation 操作等待。 参阅 Unity 激活文档,不要仅为计测而给原本不等待的加载器增加这一步。

sceneLoaded 也是另一个独立信号。 Unity 在 OnEnable 之后、Start 之前调用它。 如果把它当作可以操作的时点,Start 和后续异步初始化就会落在计测区间之外。 应将 引擎回调顺序与游戏的准备完成条件分开。

用同一个时钟记录四个时点

从加载请求,经可选的激活许可和场景操作完成,到可以操作的时间线
  • T0:加载请求。即将调用加载器的时点
  • T1:允许激活。仅当现有加载器阻止激活时,在将 allowSceneActivation 设为 true 之前记录
  • T2:观察到场景操作完成。确认 LoadSceneAsync.isDone 为 true 的时点
  • T3:可以操作。游戏定义的条件成立,例如必要初始化结束、加载遮罩移除且开始接受输入

T0 到 T1 可能包含加载过程,也可能包含过场表现或等待“继续”按钮的时间。 T1 到 T2 同样是经过时间,而不是纯粹的激活 CPU 时间。 主线程中的其他工作,以及协程下次检查前的间隔,都可能包含在内。 需要定位到具体处理时,应结合 Unity Profiler。

如果加载器没有手动阻止激活,就不必记录 T1。 只记录 T0 到 T2、T2 到 T3,也能分出场景操作之后的等待。 如果准备完成信号早于 T2,调用方需要保存该状态,并在观察到 T2 后再次调用 TryReportPlayable。 辅助类不会保存提前到达的信号。T3 指观察到两个条件均已成立并进行报告的时点。

记录一次总时间,另用事件记录分段

比较构建时,保持 map_load 的起止定义一致。 这里将 T0 到 T3 记录为一次加载。 如果基准构建在 T2 结束、候选构建在 T3 结束,即使工作相同,计测结果也会改变。

Framedash 加载计时 API 提供 BeginMapLoad / EndMapLoad 和 ReportMapLoad。 让 SDK 负责计时可使用前者;加载器已有毫秒结果时使用后者。 再次调用 BeginMapLoad 会替换尚未结束的测量,因此并发加载需要各自的时钟和 ReportMapLoad。

下面的辅助类使用后者。 传入已初始化的 SDK,在 T0 创建实例;在 T1 可选调用 MarkActivationRequested,T2 调用 MarkOperationDone,T3 调用 TryReportPlayable。 实例应由不会随场景切换而销毁的加载器持有。

SDK 调用已与 0.1.8 源码核对。 在 Unity 主线程执行所有调用;后台加载器完成后,先切回主线程再报告。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using Framedash;

// One instance per load. Call every method on Unity's main thread.
public sealed class LoadProbe
{
    private readonly TelemetrySDK sdk;
    private readonly string mapName;
    private readonly Stopwatch clock = Stopwatch.StartNew();
    private double? activationRequestedMs;
    private double? operationDoneMs;
    private bool reported;

    public LoadProbe(TelemetrySDK initializedSdk, string mapName)
    {
        if (initializedSdk == null)
            throw new ArgumentNullException(nameof(initializedSdk));
        if (string.IsNullOrWhiteSpace(mapName))
            throw new ArgumentException("A stable map name is required.", nameof(mapName));
        sdk = initializedSdk;
        this.mapName = mapName;
    }

    // Optional: only if the existing loader controls activation.
    public void MarkActivationRequested()
    {
        if (!reported && !activationRequestedMs.HasValue && !operationDoneMs.HasValue)
            activationRequestedMs = clock.Elapsed.TotalMilliseconds;
    }

    public void MarkOperationDone()
    {
        if (!reported && !operationDoneMs.HasValue)
            operationDoneMs = clock.Elapsed.TotalMilliseconds;
    }

    // Call when both readiness and isDone have been observed.
    // An early readiness call is not cached; call again after MarkOperationDone.
    public bool TryReportPlayable()
    {
        if (reported || !operationDoneMs.HasValue) return false;
        double totalMs = clock.Elapsed.TotalMilliseconds;
        double doneMs = operationDoneMs.Value;
        var metrics = new Dictionary<string, float>
        {
            { "scene_operation_ms", (float)doneMs },
            { "after_operation_ms", (float)(totalMs - doneMs) }
        };
        if (activationRequestedMs.HasValue)
        {
            double activationMs = activationRequestedMs.Value;
            metrics["before_activation_ms"] = (float)activationMs;
            metrics["activation_window_ms"] = (float)(doneMs - activationMs);
        }
        reported = true;
        sdk.ReportMapLoad(mapName, totalMs);
        sdk.Track("load_phases", attributes: new Dictionary<string, string>
            { { "map_name", mapName } }, metrics: metrics);
        return true;
    }
}

TryReportPlayable 返回 true,只表示辅助类执行了一次报告流程,并不确认服务器已收到事件。 失败或取消的尝试不要调用它。 如需记录失败,使用单独的事件,避免混入成功加载统计。

load_phases 及其指标名称是本例定义的自定义事件,不会自动生成专门的分段图表或回归门禁。 标准 map_load 的 map_id 为空,地图名称存于 attributes["map_name"],时间存于 metrics["load_time_ms"]。 它本身不会生成空间热力图上的点。

这两个事件都会受采样影响。 若验证时需要在采样阶段保留所有事件,可在初始化后设置以下覆盖值。 采样率为 1 仍不能避免传输故障或缓冲区限制导致的缺失。

TelemetrySDK.Instance.SetEventSamplingRate("map_load", 1f);
TelemetrySDK.Instance.SetEventSamplingRate("load_phases", 1f);

比较前检查结束条件和失败路径

集成到自己的加载器时,请进行以下检查。

  1. 在相同场景和构建设置下确认 T0、T2、T3 的顺序与单位
  2. 对现有激活等待流程,确认主动等待计入 T0 到 T1
  3. 刻意延迟 MarkOperationDone 之后的异步准备完成,确认 T2 到 T3 增长;延迟 Awake 等更早的工作可能使 T2 本身后移
  4. 检查失败、取消、重复完成和并发加载,避免错误增加成功数或混淆不同尝试
  5. 区分冷启动和重新加载,记录设备、存储、缓存条件与构建 ID

示例的验证范围

这个辅助类已使用 Unity 6000.3.25f1(6.3 LTS)和 6000.6.4f1、Framedash Unity SDK 0.1.8 验证。 在同一 Windows x64 主机上,Mono 和 IL2CPP 均分别以非 batchmode 的 D3D12 隐藏窗口和 -batchmode -nographics 运行。 SDK 和 LoadProbe 未作修改,文章示例的测试场景检查通过,Player 在 SDK 关闭处理完成后正常退出。

这次版本验证在本地解码并确认接收了 map_load 和 load_phases 的实际发送数据。 失败和取消的尝试未作为成功加载发送。 另一次 Framedash 生产环境连通性测试通过回读查询,确认两种事件均已存储。 这些结果只确认了被测试的尝试,不保证所有事件都能送达。

验证范围是本文示例,不涵盖 SDK 的完整测试套件或客户游戏的性能。 可见交互式窗口以及 Android / iOS 真机仍未验证。 集成到自己的游戏时,除上述检查外,还应在目标设备上确认实际行为。

先固定 T0 到 T3 的定义,再调查较长的区间。 这样就能用相同指标判断,改动缩短的是场景加载工作,还是玩家开始操作前的等待。