Skip to content

卡顿排查

玩家反馈战斗中偶发卡 1 秒,你怎么查?

unity-combat-one-second-hitch-diagnosis

标准答案

我不会先凭感觉说是 GC,而是先把“偶发卡 1 秒”变成可定位的数据:哪台机型、哪个版本、哪个战斗场景、哪个技能或怪物行为触发、那一帧到底耗时多少、尖峰发生在 Main Thread、Render Thread、GC、Loading 还是 GPU 等待。

排查流程

  1. 先确认现象:收集机型、系统、包版本、资源版本、关卡、战斗人数、技能 ID、卡顿时间点。
  2. 加长帧日志:比如帧耗时超过 200ms / 500ms / 1000ms 时记录上下文。
  3. 用 Development Build 连 Profiler,看 Timeline 里尖峰在哪个线程。
  4. 看是否有 GC.Collect、大量 GC.AllocInstantiateDestroyResources.LoadAddressables.WaitForCompletion
  5. 如果 Main Thread 在等 Render Thread 或 Gfx.WaitForPresent,再查 GPU、Shader、Overdraw、粒子、后处理。
  6. 如果是战斗逻辑尖峰,就给技能、AI、寻路、伤害结算、Buff、特效创建加 ProfilerMarker
  7. 定位后修复,再用优化前后数据证明:P99 帧耗时、长帧次数、GC Alloc、卡顿发生率是否下降。

常见原因

  • GC:战斗中频繁 new、LINQ、字符串拼接、闭包、装箱、临时 List。
  • 同步加载:战斗中首次加载特效、音效、Prefab、Shader Variant。
  • 实例化峰值:同一帧创建大量子弹、怪物、飘字、特效。
  • UI 重建:血条、伤害数字、战斗面板触发大量 Canvas Rebuild。
  • Physics:大量碰撞体、射线检测、刚体模拟集中在同一帧。
  • AI/寻路:大量怪物同帧决策、寻路、目标筛选。
  • Shader 首次编译:第一次释放技能或出现新材质时卡顿。
  • GPU 等待:透明特效、Overdraw、阴影、后处理导致渲染线程阻塞。

示例代码:长帧日志

c
using UnityEngine; // 引入 Unity 基础 API。
using Unity.Profiling; // 引入 ProfilerMarker。

public sealed class CombatFrameSpikeLogger : MonoBehaviour // 定义战斗长帧记录组件。
{ // 类开始。
    private static readonly ProfilerMarker TickMarker = new ProfilerMarker("Combat.FrameSpikeLogger.Tick"); // 创建 Profiler 标记。
    [SerializeField] private float spikeThresholdMs = 200f; // 设置长帧阈值,单位毫秒。
    private int _frameIndex; // 记录当前帧编号。
    
    private void Update() // 每帧检查一次帧耗时。
    { // 方法开始。
        using (TickMarker.Auto()) // 把本段逻辑标记到 Profiler。
        { // Profiler 作用域开始。
            _frameIndex++; // 累加帧编号。
            float frameMs = Time.unscaledDeltaTime * 1000f; // 计算当前帧耗时毫秒。
            if (frameMs < spikeThresholdMs) return; // 如果没有超过阈值,直接返回。
            Debug.LogWarning( // 输出长帧警告日志。
                $"FrameSpike frame={_frameIndex}, ms={frameMs:F1}, scene={UnityEngine.SceneManagement.SceneManager.GetActiveScene().name}"); // 记录帧号、耗时和场景名。
        } // Profiler 作用域结束。
    } // 方法结束。
} // 类结束。

面试加分说法

NOTE

我会先抓证据再下结论。比如 Profiler 看到长帧里 GC.Collect 占了 800ms,那我会查这一段前面是否有大量分配;如果看到 Loading.ReadObjectAssetBundle.LoadAsset,说明战斗中有同步加载;如果看到 Canvas.BuildBatch,说明 UI 改动触发了重建;如果看到 Gfx.WaitForPresent,就不能继续怪 CPU,要查 GPU 压力。

进入背包界面卡顿,你怎么查?

unity-inventory-ui-hitch-diagnosis

标准答案

进入背包界面卡顿,我会先确认卡顿发生在哪个阶段:打开瞬间、首次加载、数据刷新、排序筛选、滚动,还是关闭后释放资源。背包是典型 UI 密集场景,常见瓶颈是大量 Item 实例化、Layout 重建、Canvas 重建、图标加载、字符串和临时列表造成 GC。

排查步骤

  1. 先复现:确认道具数量、背包分类、是否首次打开、是否低端机更明显。
  2. 用 Profiler 抓打开背包那一帧,看 Timeline 里的 Main Thread 尖峰。
  3. 看 UI 指标:Canvas.BuildBatchLayout.RebuildGraphic.Rebuild 是否高。
  4. 看 CPU:是否一次性 Instantiate 几百个格子。
  5. 看 Memory:是否有大量 GC.Alloc、字符串拼接、LINQ、临时 List。
  6. 看 Loading:是否打开时同步加载图标、Prefab、图集。
  7. 看 Texture:是否有图标首次上传 GPU 或碎图过多。
  8. 定位后优化,再对比打开耗时、GC Alloc、P99 帧耗时。

常见原因和修法

  • Item 太多:用 ScrollView 虚拟列表,只创建可见区域 Item。
  • Prefab 创建多:Item 对象池预热,关闭时回收,不频繁 Destroy。
  • 自动布局重:减少 VerticalLayoutGroupContentSizeFitter 嵌套,固定格子尺寸。
  • Canvas 重建:静态背景、动态格子、弹窗拆不同 Canvas。
  • 图标同步加载:背包打开前预加载图集,Item 只绑定已缓存 Sprite。
  • 数据刷新粗暴:不要每次全量清空重建,改成增量刷新。
  • GC 高:缓存字符串、复用 List、避免 LINQ 和闭包。

示例代码:给背包打开加 Profiler 标记

c
using UnityEngine; // 引入 Unity 基础 API。
using Unity.Profiling; // 引入 ProfilerMarker。
public sealed class InventoryPanel : MonoBehaviour // 定义背包面板组件。
{ // 类开始。
    private static readonly ProfilerMarker OpenMarker = new ProfilerMarker("Inventory.Open"); // 创建打开背包的性能标记。
    private static readonly ProfilerMarker BindMarker = new ProfilerMarker("Inventory.BindItems"); // 创建绑定 Item 的性能标记。
    public void Open() // 打开背包界面。
    { // 方法开始。
        using (OpenMarker.Auto()) // 在 Profiler 中标记打开流程。
        { // 标记作用域开始。
            gameObject.SetActive(true); // 显示背包界面。
            RefreshItems(); // 刷新背包道具列表。
        } // 标记作用域结束。
    } // 方法结束。
    private void RefreshItems() // 刷新背包道具。
    { // 方法开始。
        using (BindMarker.Auto()) // 在 Profiler 中标记 Item 绑定流程。
        { // 标记作用域开始。
            BindVisibleItemsOnly(); // 只绑定当前可见区域的 Item。
        } // 标记作用域结束。
    } // 方法结束。
    private void BindVisibleItemsOnly() { } // 示例:虚拟列表只刷新可见 Item。
} // 类结束。

面试加分说法

TIP

我不会只说“背包 Item 太多”。我会先用 Profiler 证明具体卡在哪:如果是 Instantiate,就做对象池和虚拟列表;如果是 Canvas.BuildBatch,就拆 Canvas;如果是 Layout.Rebuild,就减少自动布局;如果是 Loading.ReadObject,就把图标和图集提前加载;如果是 GC.Collect,就查绑定数据时的临时分配。

切场景时黑屏 10 秒,你怎么查?

unity-scene-switch-black-screen-diagnosis

标准答案

TIP

切场景黑屏 10 秒,我会先把整个过程拆成时间轴,而不是只看 LoadSceneAsync.progress:点击切场景、显示 Loading、下载/加载资源、加载 Scene、allowSceneActivation 激活、Awake/OnEnable/Start 执行、首帧渲染、隐藏 Loading。黑屏 10 秒一定要先定位卡在哪一段。

排查步骤

  1. 加时间戳日志:记录开始切场景、Loading 显示、资源加载完成、场景加载到 0.9、场景激活、首帧显示的耗时。
  2. 用 Profiler Timeline 看 Main Thread 是否被 LoadingGC.CollectUnloadUnusedAssetsAwake/StartTexture Upload 阻塞。
  3. 如果黑屏发生在 Loading 出现前,通常是还没渲染加载页就开始同步加载。
  4. 如果进度卡在 0.9,要查 allowSceneActivation 是否没放开,或激活前还有资源预加载没完成。
  5. 如果激活后卡住,重点查新场景里对象的 Awake/OnEnable/Start 是否做了大量初始化。
  6. 如果首帧才卡,重点查 Shader 首次使用、贴图上传、Canvas 重建、特效预热。
  7. 如果内存峰值高,要查是否加载新场景前没有释放旧场景,或 UnloadUnusedAssets 和加载挤在同一段。

常见原因

  • Loading UI 没先显示:一点击就同步加载,导致屏幕黑着等主线程。
  • 同步资源加载:Resources.Load、同步 Addressables、AssetBundle 同步读。
  • 场景依赖过重:Bundle 依赖链太大,一次性拉了很多贴图、材质、Prefab。
  • UnloadUnusedAssets 太重:资源卸载和 GC 会阻塞主线程。
  • Awake/Start 太重:新场景对象初始化、寻路、配置解析、怪物生成都挤在激活阶段。
  • Shader/Texture 首帧开销:首帧上传 GPU、Shader 变体首次加载。
  • 网络等待:切场景过程中等服务端房间、战斗数据、地图信息返回。
  • 加载进度假进度:UI 进度条没有真实绑定各阶段耗时,玩家看到黑屏或假死。

示例代码:切场景打点

c
using System.Collections; // 引入协程接口。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.SceneManagement; // 引入场景加载 API。

public sealed class SceneLoadProfiler : MonoBehaviour // 定义场景加载打点组件。
{ // 类开始。
    public IEnumerator LoadSceneWithLog(string sceneName) // 定义带日志的切场景协程。
    { // 协程开始。
        float start = Time.realtimeSinceStartup; // 记录切场景开始时间。
        Debug.Log("SceneLoad begin"); // 输出开始日志。
        ShowLoadingPanel(); // 先显示加载界面。
        yield return null; // 等一帧,确保 Loading UI 先渲染出来。
        Debug.Log($"Loading shown cost={Time.realtimeSinceStartup - start:F2}s"); // 记录显示 Loading 耗时。
        yield return PreloadSceneResources(sceneName); // 异步预加载场景需要的资源。
        Debug.Log($"Resources ready cost={Time.realtimeSinceStartup - start:F2}s"); // 记录资源加载完成耗时。
        AsyncOperation op = SceneManager.LoadSceneAsync(sceneName); // 异步加载目标场景。
        op.allowSceneActivation = false; // 先不自动激活场景,方便控制切换时机。
        while (op.progress < 0.9f) // 等待场景加载到可激活状态。
        { // 循环开始。
            UpdateProgress(op.progress); // 更新加载进度 UI。
            yield return null; // 等待下一帧继续检查。
        } // 循环结束。
        Debug.Log($"Scene loaded cost={Time.realtimeSinceStartup - start:F2}s"); // 记录场景加载到 0.9 的耗时。
        op.allowSceneActivation = true; // 允许 Unity 激活场景。
        while (!op.isDone) // 等待场景真正激活完成。
        { // 循环开始。
            yield return null; // 等待下一帧。
        } // 循环结束。
        Debug.Log($"Scene activated cost={Time.realtimeSinceStartup - start:F2}s"); // 记录场景激活完成耗时。
        yield return null; // 等一帧,观察新场景首帧是否卡顿。
        HideLoadingPanel(); // 隐藏加载界面。
        Debug.Log($"First frame shown cost={Time.realtimeSinceStartup - start:F2}s"); // 记录首帧显示总耗时。
    } // 协程结束。
    
    private void ShowLoadingPanel() { } // 示例:显示加载界面。
    private void HideLoadingPanel() { } // 示例:隐藏加载界面。
    private void UpdateProgress(float progress) { } // 示例:刷新加载进度。
    private IEnumerator PreloadSceneResources(string sceneName) { yield break; } // 示例:异步预加载资源。
} // 类结束。

修复方向

  • 先显示 Loading,再开始加载,至少 yield return null 一帧。
  • 战斗或大场景资源提前预加载,避免切场景时同步读。
  • 新场景初始化拆成分帧执行,不把所有逻辑塞进 Awake/Start
  • 大资源分包,按场景和玩法拆依赖,避免一次拉太多 Bundle。
  • UnloadUnusedAssets 放到合适时机,避免和加载、激活、首帧挤在一起。
  • Shader Variant、常用特效、关键 UI 图集提前预热。
  • 网络等待要有超时、重试和可见 Loading 状态,不要让玩家看黑屏。
  • 用优化前后数据证明:黑屏时长、首帧耗时、峰值内存、加载失败率是否下降。

首次释放技能卡顿,可能原因是什么?

unity-first-skill-cast-hitch-causes

标准答案

首次释放技能卡顿,核心原因通常是“第一次才发生的成本”集中在同一帧:技能资源首次加载、特效 Prefab 首次实例化、Shader 变体首次使用、贴图首次上传 GPU、音效首次解码、技能逻辑首次初始化、以及临时分配触发 GC。

常见原因

  1. 技能资源没预加载:第一次释放才加载特效、音效、子弹、材质、配置。
  2. 对象池没预热:第一次 Instantiate 粒子、碰撞盒、飞行物、飘字。
  3. Shader 变体没预热:特效材质首次渲染时触发变体加载或编译。
  4. 贴图首次上传:技能特效贴图第一次被 GPU 使用,出现 Texture Upload
  5. 音频首次解码:技能音效第一次播放,音频加载类型不合适。
  6. 技能逻辑首次初始化:第一次构建技能运行数据、Buff、HitBox、目标筛选缓存。
  7. GC 分配过高:释放技能时 new List、LINQ、字符串拼接、闭包、装箱。
  8. UI 反馈首次创建:CD 特效、伤害数字、技能提示、状态图标第一次创建。

怎么查

我会用 Profiler 抓首次释放技能那一帧,看 Timeline 里尖峰属于哪类:

  • 看到 LoadingAssetBundle.LoadAsset:说明战斗中同步加载资源。
  • 看到 Instantiate:说明对象池没预热。
  • 看到 Shader.CreateGPUProgram 或渲染线程尖峰:查 Shader 变体。
  • 看到 Texture.Upload:查贴图首次上传和图集预热。
  • 看到 Audio 相关尖峰:查音频加载和解码。
  • 看到 GC.AllocGC.Collect:查技能逻辑临时分配。
  • 看到自定义 Skill.Cast Marker 很高:查技能目标筛选、伤害结算、Buff 创建。

修复方向

  • 进入战斗前根据角色技能表预加载技能资源。
  • 对子弹、特效、碰撞盒、飘字做对象池并提前预热。
  • 常用 Shader Variant 收集后提前 WarmUp。
  • 技能相关贴图、图集、材质在加载阶段提前触发使用。
  • 短音效可以设置合适加载类型,避免首次播放卡顿。
  • 技能配置和运行时数据提前构建,复杂初始化分帧。
  • 目标筛选和伤害结算复用容器,避免 LINQ、装箱和临时字符串。
  • 用优化前后数据证明:首次释放帧耗时、GC Alloc、加载尖峰是否下降。

示例代码:技能预热入口

c
using System.Collections; // 引入协程接口。
using UnityEngine; // 引入 Unity 基础 API。

public sealed class SkillPrewarmService : MonoBehaviour // 定义技能预热服务。
{ // 类开始。
    [SerializeField] private GameObject skillEffectPrefab; // 保存技能特效 Prefab。
    [SerializeField] private int warmCount = 5; // 设置预热数量。
    
    public IEnumerator PrewarmSkill() // 定义技能预热协程。
    { // 协程开始。
        for (int i = 0; i < warmCount; i++) // 按预热数量循环创建对象。
        { // 循环开始。
            GameObject obj = Instantiate(skillEffectPrefab); // 提前实例化技能特效对象。
            obj.SetActive(false); // 关闭对象,避免直接显示到场景里。
            ReturnToPool(obj); // 把对象放回对象池,等待真正释放技能时复用。
            yield return null; // 每帧预热一部分,避免预热本身造成卡顿。
        } // 循环结束。
    } // 协程结束。
    
    private void ReturnToPool(GameObject obj) { } // 示例:把对象归还到对象池。
} // 类结束。

面试加分说法

TIP

首次释放技能卡顿不能只说“做预加载”。预加载解决的是资源读入,预热解决的是第一次实例化、Shader、贴图、音频和对象池首创问题。真实项目里我会在进入战斗加载阶段按技能清单预加载资源,在倒计时或加载页阶段分帧预热对象池和 Shader,释放技能时只做轻量逻辑和对象复用。

怪物数量一多帧率下降,怎么定位?

unity-many-monsters-fps-diagnosis

标准答案

怪物数量一多帧率下降,我不会先下结论说“AI 太多”,而是先做数量阶梯测试:20、50、100、200 只怪分别跑同一场景,记录帧耗时、CPU、GPU、GC、DrawCall、Animator、Physics、AI 时间。核心是找出哪个系统成本随着怪物数量增长。

定位流程

  1. 固定测试条件:同一地图、同一镜头、同一怪物类型、同一技能密度。
  2. 做数量阶梯:逐步增加怪物数量,看帧率从哪个数量开始明显下降。
  3. 用 Profiler 先区分 CPU 还是 GPU:看 Main Thread、Render Thread、GPU 时间。
  4. 如果 CPU 高,看 BehaviourUpdateAnimator.UpdatePhysics.SimulateGC.Alloc
  5. 如果 GPU 高,看 DrawCall、SetPass、SkinnedMesh、阴影、Overdraw、粒子。
  6. 给怪物系统加 ProfilerMarker,把 AI、寻路、目标筛选、动画、技能、受击、血条分开看。
  7. 找到瓶颈后优化,再对比 P99 帧耗时、平均帧耗时、怪物上限是否提升。

常见瓶颈

  • AI:每只怪每帧决策、行为树 Tick、仇恨搜索、目标筛选。
  • 寻路:大量怪同帧 A* 或 NavMesh 请求。
  • 动画:大量 Animator 状态机、骨骼更新、RootMotion。
  • 渲染:SkinnedMesh 多、材质多、DrawCall 多、阴影多。
  • 物理:碰撞体、刚体、射线检测、触发器过多。
  • UI:怪物血条、飘字、状态图标导致 Canvas 重建。
  • 特效:受击特效、死亡特效、范围技能粒子过多。
  • GC:怪物 Update 中 new、LINQ、字符串、临时 List。

示例代码:给怪物系统加 ProfilerMarker

c
using System.Collections.Generic; // 引入 List 容器。
using Unity.Profiling; // 引入 ProfilerMarker。

public sealed class MonsterUpdateSystem // 定义怪物更新系统。
{ // 类开始。
    private static readonly ProfilerMarker AiMarker = new ProfilerMarker("Monster.AI"); // 创建 AI 性能标记。
    private static readonly ProfilerMarker MoveMarker = new ProfilerMarker("Monster.Move"); // 创建移动性能标记。
    private static readonly ProfilerMarker SkillMarker = new ProfilerMarker("Monster.Skill"); // 创建技能性能标记。
    
    public void Tick(List<Monster> monsters, int frameIndex) // 每帧更新怪物列表。
    { // 方法开始。
        using (AiMarker.Auto()) // 标记 AI 更新耗时。
        { // AI 标记开始。
            for (int i = frameIndex % 4; i < monsters.Count; i += 4) // 每帧只更新四分之一怪物 AI。
            { // 循环开始。
                monsters[i].UpdateAI(); // 更新单个怪物 AI。
            } // 循环结束。
        } // AI 标记结束。
        using (MoveMarker.Auto()) // 标记移动更新耗时。
        { // 移动标记开始。
            for (int i = 0; i < monsters.Count; i++) // 遍历全部怪物。
            { // 循环开始。
                monsters[i].UpdateMove(); // 更新单个怪物移动。
            } // 循环结束。
        } // 移动标记结束。
        using (SkillMarker.Auto()) // 标记技能更新耗时。
        { // 技能标记开始。
            for (int i = 0; i < monsters.Count; i++) // 遍历全部怪物。
            { // 循环开始。
                monsters[i].UpdateSkill(); // 更新单个怪物技能。
            } // 循环结束。
        } // 技能标记结束。
    } // 方法结束。
} // 类结束。

public sealed class Monster // 定义怪物示例类。
{ // 类开始。
    public void UpdateAI() { } // 示例:更新怪物 AI。
    public void UpdateMove() { } // 示例:更新怪物移动。
    public void UpdateSkill() { } // 示例:更新怪物技能。
} // 类结束。

优化方向

  • AI 降频:远处怪物不必每帧决策,可以 5 到 10 帧一次。
  • 分帧执行:目标筛选、寻路、Buff Tick 不要所有怪同帧做。
  • LOD:远处怪物降低动画频率、隐藏血条、关闭复杂特效。
  • 视野剔除:屏幕外怪物减少逻辑、动画、渲染更新。
  • 对象池:怪物、子弹、特效、飘字都池化。
  • 简化碰撞:远处怪物用简单 Collider,减少射线和触发器。
  • 渲染优化:减少材质种类,使用 GPU Instancing、SRP Batcher、LOD。
  • UI 优化:血条只更新可见怪物,批量刷新,避免每帧触发布局。
  • 数据结构优化:目标筛选用空间划分,避免每只怪遍历全场对象。

面试加分说法

NOTE

我会先证明瓶颈属于哪一类。如果 BehaviourUpdate 随怪物数量上涨,说明逻辑侧有每怪成本;如果 Animator.Update 和 Skinning 高,说明动画和骨骼成本高;如果 Physics.Simulate 高,说明碰撞或射线太多;如果 DrawCall、SetPass 或 GPU 时间高,说明渲染侧瓶颈。不同瓶颈对应完全不同的优化手段。

打开排行榜卡顿,怎么定位?

unity-leaderboard-open-hitch-diagnosis

标准答案

打开排行榜卡顿,我会先把链路拆开:网络请求、协议解析、数据排序、头像加载、Item 创建、ScrollView 布局、Canvas 重建、GC。排行榜不是单纯 UI 问题,也可能是接口慢、数据太大、头像下载阻塞、JSON 解析在主线程、一次性创建太多行导致卡顿。

定位步骤

  1. 先确认卡顿发生在哪:点击后立刻卡、等数据时卡、数据显示时卡、滚动时卡,还是头像逐个出现时卡。
  2. 给各阶段打点:请求耗时、下载字节数、解析耗时、排序耗时、创建 Item 数量、头像加载数量。
  3. 用 Profiler 看 Main Thread:是否有 InstantiateCanvas.BuildBatchLayout.RebuildGC.Collect
  4. 看 Memory:是否有大量 GC.Alloc,比如 JSON 字符串、临时 List、字符串拼接。
  5. 看 Loading/Texture:头像是否同步下载、解码、创建 Texture、上传 GPU。
  6. 看网络日志:接口是否慢、包体是否过大、是否弱网重试。
  7. 定位后优化,再对比打开耗时、GC Alloc、首帧耗时、滚动帧率。

常见原因

  • 接口慢:服务端查询排行榜慢,客户端等待时 UI 像卡死。
  • 数据太大:一次拉几百上千条,JSON 解析和排序在主线程。
  • Item 太多:一次性 Instantiate 很多排行榜行。
  • Layout 太重:VerticalLayoutGroupContentSizeFitter 导致布局重算。
  • Canvas 重建:排行榜刷新触发大 Canvas 重新合批。
  • 头像加载:头像下载、解码、创建 Texture、上传 GPU。
  • GC:字符串拼接、LINQ、临时容器、频繁刷新文本。
  • 滚动卡:没有虚拟列表,所有 Item 都参与布局和渲染。

优化方向

  • 排行榜分页,只拉前 N 条和自己附近排名。
  • 用虚拟列表,只创建可见区域 Item。
  • Item 对象池复用,不反复 Instantiate/Destroy。
  • 头像异步加载,先显示占位图,磁盘缓存和内存缓存。
  • 服务端排序,客户端只做轻量展示。
  • 减少 JSON 字段,必要时换二进制协议。
  • 拆 Canvas,排行榜动态区域不要影响整个 UI。
  • 固定 Item 高度,减少 LayoutGroup 和 ContentSizeFitter 嵌套。
  • 打开界面先显示骨架屏,不阻塞主线程等数据回来。

示例代码:排行榜打开打点

c
using System.Collections.Generic; // 引入 List 容器。
using Unity.Profiling; // 引入 ProfilerMarker。
using UnityEngine; // 引入 Unity 基础 API。

public sealed class LeaderboardPanel : MonoBehaviour // 定义排行榜面板。
{ // 类开始。
    private static readonly ProfilerMarker OpenMarker = new ProfilerMarker("Leaderboard.Open"); // 创建打开排行榜标记。
    private static readonly ProfilerMarker BindMarker = new ProfilerMarker("Leaderboard.BindItems"); // 创建绑定排行榜 Item 标记。
    private readonly List<RankData> _cache = new List<RankData>(); // 缓存排行榜数据,减少临时分配。
    
    public void Open() // 打开排行榜界面。
    { // 方法开始。
        using (OpenMarker.Auto()) // 在 Profiler 中标记打开流程。
        { // 标记作用域开始。
            gameObject.SetActive(true); // 显示排行榜面板。
            ShowSkeleton(); // 先显示骨架屏,避免玩家感觉假死。
            RequestRankData(); // 异步请求排行榜数据。
        } // 标记作用域结束。
    } // 方法结束。
    
    private void OnRankDataReady(List<RankData> data) // 排行榜数据返回后的回调。
    { // 方法开始。
        using (BindMarker.Auto()) // 在 Profiler 中标记绑定流程。
        { // 标记作用域开始。
            _cache.Clear(); // 清空旧缓存。
            _cache.AddRange(data); // 复用缓存列表保存新数据。
            BindVisibleItemsOnly(_cache); // 只绑定可见区域 Item。
        } // 标记作用域结束。
    } // 方法结束。
    
    private void ShowSkeleton() { } // 示例:显示加载占位界面。
    private void RequestRankData() { } // 示例:异步请求排行榜数据。
    private void BindVisibleItemsOnly(List<RankData> data) { } // 示例:虚拟列表绑定可见 Item。
} // 类结束。

public sealed class RankData // 定义排行榜数据。
{ // 类开始。
    public string Name; // 保存玩家名。
    public int Score; // 保存分数。
    public string AvatarUrl; // 保存头像地址。
} // 类结束。

面试加分说法

TIP

我会把排行榜拆成“网络慢”和“客户端卡”两类。网络慢用接口耗时、包体大小、弱网重试定位;客户端卡用 Profiler 看 JSON 解析、Item 创建、Layout、Canvas、头像 Texture、GC。最后用分页、虚拟列表、头像缓存、骨架屏和异步加载解决,而不是一次性把所有数据和头像都塞进 UI。

滚动列表掉帧,怎么优化?

unity-scroll-list-fps-optimization

标准答案

滚动列表掉帧,核心思路是先定位掉帧来源,再针对性优化。常见原因包括:一次性创建太多 Item、滚动中频繁触发 Layout 和 Canvas Rebuild、图片同步加载、头像 Texture 上传、Raycast Target 太多、Mask/透明背景 Overdraw 高、以及绑定数据时产生 GC。

优化重点

  1. 用虚拟列表:只创建可见区域 Item,加少量缓冲。
  2. 用对象池:滚动时复用 Item,不频繁 Instantiate/Destroy。
  3. 固定 Item 高度:减少 LayoutGroupContentSizeFitter 的运行时计算。
  4. 拆 Canvas:列表动态区域单独 Canvas,避免影响整个界面重建。
  5. 图片异步加载:先显示占位图,图片加载完成后再替换。
  6. 关闭不必要的 Raycast Target:非按钮、非交互图片和文本都关闭。
  7. 降低 Overdraw:减少大面积半透明背景、Mask 嵌套和无意义叠图。
  8. 减少 GC:滚动中不拼字符串、不用 LINQ、不创建临时 List。

Profiler 怎么看

  • Canvas.BuildBatch 高:Canvas 重建或合批重算严重。
  • Layout.Rebuild 高:自动布局嵌套太多。
  • Instantiate 高:没有虚拟列表或对象池。
  • GC.Alloc 高:绑定 Item 时产生临时对象。
  • GraphicRaycaster.Raycast 高:可射线检测 UI 太多。
  • GPU 时间高:可能是 Mask、透明 UI、头像和背景导致 Overdraw。

示例代码:虚拟列表核心思路

c
using System.Collections.Generic; // 引入 List 容器。
using UnityEngine; // 引入 Unity 基础 API。

public sealed class VirtualList : MonoBehaviour // 定义虚拟列表组件。
{ // 类开始。
    [SerializeField] private RectTransform content; // 保存 ScrollView 的 Content 节点。
    [SerializeField] private GameObject itemPrefab; // 保存列表 Item 预制体。
    [SerializeField] private float itemHeight = 100f; // 保存单个 Item 的固定高度。
    [SerializeField] private int visibleCount = 12; // 保存屏幕可见 Item 数量。
    private readonly List<GameObject> _items = new List<GameObject>(); // 保存复用中的 Item 对象。
    
    public void Init(int dataCount) // 初始化虚拟列表。
    { // 方法开始。
        content.sizeDelta = new Vector2(content.sizeDelta.x, dataCount * itemHeight); // 设置 Content 总高度。
        for (int i = 0; i < visibleCount; i++) // 只创建可见数量的 Item。
        { // 循环开始。
            GameObject item = Instantiate(itemPrefab, content); // 创建一个可复用 Item。
            _items.Add(item); // 把 Item 加入复用列表。
        } // 循环结束。
    } // 方法结束。
    
    public void Refresh(float scrollY, List<string> data) // 根据滚动位置刷新可见 Item。
    { // 方法开始。
        int startIndex = Mathf.FloorToInt(scrollY / itemHeight); // 计算当前第一个可见数据下标。
        for (int i = 0; i < _items.Count; i++) // 遍历所有复用 Item。
        { // 循环开始。
            int dataIndex = startIndex + i; // 计算当前 Item 对应的数据下标。
            if (dataIndex >= data.Count) // 判断是否超过数据范围。
            { // 超出分支开始。
                _items[i].SetActive(false); // 隐藏没有数据的 Item。
                continue; // 继续处理下一个 Item。
            } // 超出分支结束。
            _items[i].SetActive(true); // 显示当前 Item。
            RectTransform rect = (RectTransform)_items[i].transform; // 获取 Item 的 RectTransform。
            rect.anchoredPosition = new Vector2(0f, -dataIndex * itemHeight); // 移动 Item 到正确位置。
            BindItem(_items[i], data[dataIndex]); // 重新绑定当前行数据。
        } // 循环结束。
    } // 方法结束。
    
    private void BindItem(GameObject item, string value) { } // 示例:绑定 Item 文本和图片。
} // 类结束。

面试加分说法

IMPORTANT

滚动列表不是“加对象池”就完了。对象池只能减少创建销毁,真正能解决大量数据列表的是虚拟列表:数据可以有几千条,但场景里只存在十几个 Item。再配合固定布局、图片异步、关闭 Raycast Target、拆 Canvas 和减少 GC,才能让滚动稳定。

每隔几秒固定卡一下,可能是什么?

unity-periodic-hitch-diagnosis

标准答案

每隔几秒固定卡一下,优先怀疑“周期性任务”在主线程集中执行。比如 GC、网络心跳、日志/埋点上报、自动保存、资源清理、对象池清理、计时器扫描、Buff Tick、AI 批量更新、InvokeRepeating 或协程轮询。

可能原因

  1. GC 周期回收:几秒内持续分配,堆压力到阈值后触发 GC.Collect
  2. 网络心跳:每隔几秒发心跳,序列化、加密、回调解析在主线程做重了。
  3. 日志/埋点上报:批量 JSON、压缩、写文件、上传都挤到一帧。
  4. 自动保存:定时序列化存档并写磁盘。
  5. 资源清理:定时 UnloadUnusedAssets、清理缓存、释放对象池。
  6. 计时器管理器:大量 Timer 同一帧到期,集中触发回调。
  7. Buff/技能 Tick:每 1 秒或 5 秒统一结算全场 Buff、DOT、回血。
  8. AI 批量更新:所有怪物每隔几秒一起重新寻路或重新选目标。
  9. 物理补帧:某次卡顿后 FixedUpdate 追帧,表现为周期性抖动。
  10. 后台任务回主线程:异步加载、网络、解压完成后同一帧回调太多。

怎么查

  1. 先记录长帧时间点,比如 t=5.1s、10.1s、15.1s,确认间隔是否稳定。
  2. 打开 Profiler Timeline,看长帧里具体是 GC.CollectScriptsLoadingPhysicsNetwork 还是 UI
  3. 搜索项目里的周期任务:InvokeRepeatingWaitForSeconds、TimerManager、Heartbeat、AutoSave、UploadLog。
  4. 给可疑模块加 ProfilerMarker,把心跳、上报、存档、Buff Tick、AI Tick 分开看。
  5. 对照日志时间戳,如果卡顿时间和某个任务完全一致,基本就锁定了。
  6. 修复后看长帧次数、P99 帧耗时、GC Alloc、该任务单次耗时是否下降。

示例代码:长帧和周期对齐日志

c
using UnityEngine; // 引入 Unity 基础 API。

public sealed class PeriodicHitchLogger : MonoBehaviour // 定义周期性卡顿记录组件。
{ // 类开始。
    [SerializeField] private float hitchMs = 100f; // 设置长帧阈值,单位毫秒。
    private float _lastHitchTime; // 记录上一次长帧发生时间。
    
    private void Update() // 每帧检查耗时。
    { // 方法开始。
        float frameMs = Time.unscaledDeltaTime * 1000f; // 计算当前帧耗时毫秒。
        if (frameMs < hitchMs) return; // 如果当前帧不算长帧,直接返回。
        float now = Time.realtimeSinceStartup; // 获取游戏启动后的真实时间。
        float interval = now - _lastHitchTime; // 计算和上一次长帧的间隔。
        _lastHitchTime = now; // 更新上一次长帧时间。
        Debug.LogWarning($"Hitch ms={frameMs:F1}, time={now:F2}, interval={interval:F2}"); // 输出长帧耗时和间隔。
    } // 方法结束。
} // 类结束。

修复方向

  • GC:减少周期内分配,复用 List,避免 LINQ、装箱、字符串拼接。
  • 心跳:心跳包轻量化,主线程只派发结果,解析和压缩放后台。
  • 日志:批量上报拆分,限流,后台压缩,避免一帧写大文件。
  • 存档:增量保存,异步写入,不要每次序列化完整大对象。
  • 资源清理:不要频繁 UnloadUnusedAssets,放在切场景或安全点。
  • Timer:大量定时器不要同帧触发,用时间轮、优先队列或分帧处理。
  • Buff/AI:不要全场同一秒 Tick,按对象 ID 分桶错开执行。
  • 对象池清理:分批释放,不要一次性 Destroy 大量对象。

面试加分说法

CAUTION

固定间隔卡顿和“持续低帧率”不一样。持续低帧率通常是每帧成本过高;固定间隔卡顿通常是周期任务把某一帧打爆。所以我会先量出卡顿周期,再用 Profiler 和日志对齐定时任务,最后通过分帧、异步、限流、削峰解决。

Android 不卡但 iOS 卡,怎么查?

unity-android-ok-ios-hitch-diagnosis

标准答案

Android 不卡但 iOS 卡,我不会直接说“iOS 性能差”,而是先保证对比条件一致,再查平台差异。第一步确认:包版本、资源版本、画质档、分辨率、帧率上限、测试场景、角色数量、网络状态是否一致。否则很可能是在拿高分辨率 iPhone 和低画质 Android 做不公平对比。

排查流程

  1. 统一变量:同一场景、同一账号、同一资源版本、同一画质、同一 Render Scale。
  2. 先判断 CPU 还是 GPU:Unity Profiler 看 Main Thread、Render Thread、GC;Xcode Instruments 看 Metal/GPU。
  3. 如果 CPU 高:查脚本逻辑、Animator、Physics、GC、Native 插件回调。
  4. 如果 GPU 高:查分辨率、后处理、透明 Overdraw、阴影、MSAA、Metal 渲染路径。
  5. 如果内存高:查贴图格式、音频加载、AssetBundle 常驻、峰值内存。
  6. 如果运行一会才卡:重点怀疑发热降频、内存压力、后台系统调度。
  7. 如果只有 iOS 某功能卡:查 iOS 原生 SDK、权限回调、AOT/反射、平台宏代码路径。

iOS 常见差异点

  • 分辨率更高:iPhone Retina 下像素数可能更大,GPU 压力更高。
  • Metal 渲染路径:Shader、后处理、MSAA、RenderTexture 在 Metal 下表现不同。
  • 纹理格式:iOS 的 ASTC/PVRTC 配置不合理会增加内存或带宽。
  • Shader 变体:iOS 平台关键字、精度、变体预热可能和 Android 不一致。
  • 热量降频:iOS 设备持续高负载后更容易降频,表现为一开始不卡,几分钟后变卡。
  • 内存压力:iOS 对内存更敏感,峰值过高可能触发卡顿甚至闪退。
  • 原生插件:登录、支付、广告、统计 SDK 的 iOS 回调可能阻塞主线程。
  • AOT 限制:iOS 只有 AOT,反射、泛型、热更路径要额外检查。
  • 音频/视频:iOS 解码路径和 Android 不同,首次播放或切换可能卡顿。

示例代码:记录平台和帧耗时

c
using UnityEngine; // 引入 Unity 基础 API。

public sealed class PlatformFrameLogger : MonoBehaviour // 定义平台帧耗时记录组件。
{ // 类开始。
    [SerializeField] private float hitchMs = 100f; // 设置长帧阈值。
    
    private void Start() // 游戏对象启动时执行。
    { // 方法开始。
        Debug.Log($"Platform={Application.platform}"); // 输出当前运行平台。
        Debug.Log($"Device={SystemInfo.deviceModel}"); // 输出设备型号。
        Debug.Log($"GPU={SystemInfo.graphicsDeviceName}"); // 输出 GPU 名称。
        Debug.Log($"API={SystemInfo.graphicsDeviceType}"); // 输出图形 API 类型。
        Debug.Log($"Memory={SystemInfo.systemMemorySize}MB"); // 输出系统内存大小。
        Debug.Log($"Resolution={Screen.width}x{Screen.height}"); // 输出当前渲染分辨率。
    } // 方法结束。
    
    private void Update() // 每帧检查帧耗时。
    { // 方法开始。
        float ms = Time.unscaledDeltaTime * 1000f; // 计算当前帧耗时毫秒。
        if (ms < hitchMs) return; // 没有超过阈值时直接返回。
        Debug.LogWarning($"Hitch platform={Application.platform}, ms={ms:F1}"); // 输出平台和长帧耗时。
    } // 方法结束。
} // 类结束。

修复方向

  • 如果是分辨率问题:降低 Render Scale,按设备档位分配画质。
  • 如果是 GPU 问题:关重后处理、降阴影、减少透明 Overdraw、降低 MSAA。
  • 如果是 Shader 问题:收集 iOS 变体并预热,检查精度和关键字。
  • 如果是贴图问题:重新评估 ASTC 档位,控制贴图尺寸和图集。
  • 如果是内存问题:压缩资源、减少常驻、控制切场景峰值。
  • 如果是热量问题:降低长期负载,动态画质降级,减少持续高 GPU 占用。
  • 如果是原生插件:用 Instruments 看主线程调用栈,避免 SDK 回调阻塞 Unity 主线程。
  • 如果是 AOT/反射:减少运行时反射,提前生成代码或缓存委托。

面试加分说法

NOTE

我会先统一测试条件,再用 Unity Profiler 和 Xcode Instruments 双工具定位。Android 不卡但 iOS 卡,本质是平台路径不同:Metal、贴图压缩、设备分辨率、热量、内存、原生插件和 AOT 都可能不同。只有把 CPU/GPU/内存/IO 归因清楚,才能决定是降画质、改资源、改 Shader,还是修插件。

编辑器不卡真机卡,怎么查?

unity-editor-ok-device-hitch-diagnosis

标准答案

编辑器不卡、真机卡,核心判断是:Editor 不能代表真机性能,必须在真机上复现并采集数据。我会先保证测试条件一致,再用真机 Profiler、设备日志和 GPU 工具定位瓶颈,而不是凭感觉改代码。

排查流程

  1. 先确认差异条件:机型、系统版本、包体版本、画质档、分辨率、帧率上限、资源版本、是否 Development Build。
  2. 真机打包:开启 Development BuildAutoconnect Profiler,连接 Unity Profiler。
  3. 看 CPU:重点看 Main ThreadBehaviourUpdateGC.AllocCanvas.BuildBatchPhysics.SimulateAnimator.Update
  4. 看 GPU:如果 CPU 不高但帧率低,查分辨率、Overdraw、透明特效、后处理、阴影、MSAA、带宽。
  5. 看内存和 IO:Texture 压缩、AssetBundle/Addressables 加载、音频加载方式、峰值内存、频繁读写磁盘。
  6. 看平台差异:Android/iOS 插件、SDK 回调、IL2CPP/AOT、反射、泛型裁剪、权限弹窗、热更新代码。
  7. 看发热降频:真机跑几分钟后才卡,通常要查温度、CPU/GPU 频率下降和持续帧率曲线。

常见原因

Editor 不卡,真机卡,通常不是一个原因:

  • PC 性能远高于手机,Editor 结果偏乐观。
  • Editor 和真机使用的图形 API、Shader Variant、贴图压缩格式可能不同。
  • 移动端 GPU 对透明 Overdraw、后处理、实时阴影、带宽更敏感。
  • 真机 IL2CPP/AOT、SDK、权限、热更新路径可能走了和 Editor 不一样的逻辑。
  • 真机会发热降频,跑一段时间后性能下降。
  • Development Build 本身有开销,所以最终还要用 Release 包复测数据。

面试回答重点

NOTE

“我不会直接说真机卡就是手机性能差。我会先保证资源、画质、版本一致,然后在真机上接 Profiler。先判断是 CPU、GPU、内存、IO 还是平台差异。如果 Main Thread 高,就看脚本、GC、UI 重建、物理、动画;如果 CPU 不高但帧率低,就看 GPU、Overdraw、阴影、后处理和分辨率;如果切换或首次触发卡,就看资源加载、Shader 预热和对象创建。最后用优化前后的帧时间、GC Alloc、内存峰值来证明优化有效。”

辅助代码

c
using UnityEngine; // 引入 Unity 基础 API。
public sealed class DevicePerfLogger : MonoBehaviour // 定义真机性能日志组件。
{ // 类开始。
    [SerializeField] private float hitchMs = 100f; // 设置长帧阈值,超过 100ms 认为明显卡顿。
    private void Start() // 游戏启动时输出设备信息。
    { // Start 方法开始。
        Debug.Log($"Platform={Application.platform}"); // 输出当前运行平台。
        Debug.Log($"Device={SystemInfo.deviceModel}"); // 输出真机设备型号。
        Debug.Log($"GPU={SystemInfo.graphicsDeviceName}"); // 输出 GPU 名称。
        Debug.Log($"API={SystemInfo.graphicsDeviceType}"); // 输出图形 API 类型。
        Debug.Log($"Memory={SystemInfo.systemMemorySize}MB"); // 输出系统内存大小。
        Debug.Log($"Resolution={Screen.width}x{Screen.height}"); // 输出当前渲染分辨率。
    } // Start 方法结束。
    private void Update() // 每帧检测是否出现长帧。
    { // Update 方法开始。
        float ms = Time.unscaledDeltaTime * 1000f; // 把当前帧耗时从秒转换成毫秒。
        if (ms < hitchMs) return; // 没有超过阈值就直接返回。
        Debug.LogWarning($"DeviceHitch ms={ms:F1}, platform={Application.platform}"); // 输出真机长帧日志。
    } // Update 方法结束。
} // 类结束。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0