Appearance
卡顿排查
玩家反馈战斗中偶发卡 1 秒,你怎么查?
标准答案
我不会先凭感觉说是 GC,而是先把“偶发卡 1 秒”变成可定位的数据:哪台机型、哪个版本、哪个战斗场景、哪个技能或怪物行为触发、那一帧到底耗时多少、尖峰发生在 Main Thread、Render Thread、GC、Loading 还是 GPU 等待。
排查流程
- 先确认现象:收集机型、系统、包版本、资源版本、关卡、战斗人数、技能 ID、卡顿时间点。
- 加长帧日志:比如帧耗时超过 200ms / 500ms / 1000ms 时记录上下文。
- 用 Development Build 连 Profiler,看 Timeline 里尖峰在哪个线程。
- 看是否有
GC.Collect、大量GC.Alloc、Instantiate、Destroy、Resources.Load、Addressables.WaitForCompletion。 - 如果 Main Thread 在等 Render Thread 或
Gfx.WaitForPresent,再查 GPU、Shader、Overdraw、粒子、后处理。 - 如果是战斗逻辑尖峰,就给技能、AI、寻路、伤害结算、Buff、特效创建加
ProfilerMarker。 - 定位后修复,再用优化前后数据证明: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.ReadObject 或 AssetBundle.LoadAsset,说明战斗中有同步加载;如果看到 Canvas.BuildBatch,说明 UI 改动触发了重建;如果看到 Gfx.WaitForPresent,就不能继续怪 CPU,要查 GPU 压力。
进入背包界面卡顿,你怎么查?
标准答案
进入背包界面卡顿,我会先确认卡顿发生在哪个阶段:打开瞬间、首次加载、数据刷新、排序筛选、滚动,还是关闭后释放资源。背包是典型 UI 密集场景,常见瓶颈是大量 Item 实例化、Layout 重建、Canvas 重建、图标加载、字符串和临时列表造成 GC。
排查步骤
- 先复现:确认道具数量、背包分类、是否首次打开、是否低端机更明显。
- 用 Profiler 抓打开背包那一帧,看
Timeline里的 Main Thread 尖峰。 - 看 UI 指标:
Canvas.BuildBatch、Layout.Rebuild、Graphic.Rebuild是否高。 - 看 CPU:是否一次性
Instantiate几百个格子。 - 看 Memory:是否有大量
GC.Alloc、字符串拼接、LINQ、临时 List。 - 看 Loading:是否打开时同步加载图标、Prefab、图集。
- 看 Texture:是否有图标首次上传 GPU 或碎图过多。
- 定位后优化,再对比打开耗时、GC Alloc、P99 帧耗时。
常见原因和修法
- Item 太多:用 ScrollView 虚拟列表,只创建可见区域 Item。
- Prefab 创建多:Item 对象池预热,关闭时回收,不频繁 Destroy。
- 自动布局重:减少
VerticalLayoutGroup、ContentSizeFitter嵌套,固定格子尺寸。 - 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 秒,你怎么查?
标准答案
TIP
切场景黑屏 10 秒,我会先把整个过程拆成时间轴,而不是只看 LoadSceneAsync.progress:点击切场景、显示 Loading、下载/加载资源、加载 Scene、allowSceneActivation 激活、Awake/OnEnable/Start 执行、首帧渲染、隐藏 Loading。黑屏 10 秒一定要先定位卡在哪一段。
排查步骤
- 加时间戳日志:记录开始切场景、Loading 显示、资源加载完成、场景加载到 0.9、场景激活、首帧显示的耗时。
- 用 Profiler Timeline 看 Main Thread 是否被
Loading、GC.Collect、UnloadUnusedAssets、Awake/Start、Texture Upload阻塞。 - 如果黑屏发生在 Loading 出现前,通常是还没渲染加载页就开始同步加载。
- 如果进度卡在 0.9,要查
allowSceneActivation是否没放开,或激活前还有资源预加载没完成。 - 如果激活后卡住,重点查新场景里对象的
Awake/OnEnable/Start是否做了大量初始化。 - 如果首帧才卡,重点查 Shader 首次使用、贴图上传、Canvas 重建、特效预热。
- 如果内存峰值高,要查是否加载新场景前没有释放旧场景,或
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 状态,不要让玩家看黑屏。
- 用优化前后数据证明:黑屏时长、首帧耗时、峰值内存、加载失败率是否下降。
首次释放技能卡顿,可能原因是什么?
标准答案
首次释放技能卡顿,核心原因通常是“第一次才发生的成本”集中在同一帧:技能资源首次加载、特效 Prefab 首次实例化、Shader 变体首次使用、贴图首次上传 GPU、音效首次解码、技能逻辑首次初始化、以及临时分配触发 GC。
常见原因
- 技能资源没预加载:第一次释放才加载特效、音效、子弹、材质、配置。
- 对象池没预热:第一次
Instantiate粒子、碰撞盒、飞行物、飘字。 - Shader 变体没预热:特效材质首次渲染时触发变体加载或编译。
- 贴图首次上传:技能特效贴图第一次被 GPU 使用,出现
Texture Upload。 - 音频首次解码:技能音效第一次播放,音频加载类型不合适。
- 技能逻辑首次初始化:第一次构建技能运行数据、Buff、HitBox、目标筛选缓存。
- GC 分配过高:释放技能时 new List、LINQ、字符串拼接、闭包、装箱。
- UI 反馈首次创建:CD 特效、伤害数字、技能提示、状态图标第一次创建。
怎么查
我会用 Profiler 抓首次释放技能那一帧,看 Timeline 里尖峰属于哪类:
- 看到
Loading、AssetBundle.LoadAsset:说明战斗中同步加载资源。 - 看到
Instantiate:说明对象池没预热。 - 看到
Shader.CreateGPUProgram或渲染线程尖峰:查 Shader 变体。 - 看到
Texture.Upload:查贴图首次上传和图集预热。 - 看到
Audio相关尖峰:查音频加载和解码。 - 看到
GC.Alloc、GC.Collect:查技能逻辑临时分配。 - 看到自定义
Skill.CastMarker 很高:查技能目标筛选、伤害结算、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,释放技能时只做轻量逻辑和对象复用。
怪物数量一多帧率下降,怎么定位?
标准答案
怪物数量一多帧率下降,我不会先下结论说“AI 太多”,而是先做数量阶梯测试:20、50、100、200 只怪分别跑同一场景,记录帧耗时、CPU、GPU、GC、DrawCall、Animator、Physics、AI 时间。核心是找出哪个系统成本随着怪物数量增长。
定位流程
- 固定测试条件:同一地图、同一镜头、同一怪物类型、同一技能密度。
- 做数量阶梯:逐步增加怪物数量,看帧率从哪个数量开始明显下降。
- 用 Profiler 先区分 CPU 还是 GPU:看 Main Thread、Render Thread、GPU 时间。
- 如果 CPU 高,看
BehaviourUpdate、Animator.Update、Physics.Simulate、GC.Alloc。 - 如果 GPU 高,看 DrawCall、SetPass、SkinnedMesh、阴影、Overdraw、粒子。
- 给怪物系统加
ProfilerMarker,把 AI、寻路、目标筛选、动画、技能、受击、血条分开看。 - 找到瓶颈后优化,再对比 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 时间高,说明渲染侧瓶颈。不同瓶颈对应完全不同的优化手段。
打开排行榜卡顿,怎么定位?
标准答案
打开排行榜卡顿,我会先把链路拆开:网络请求、协议解析、数据排序、头像加载、Item 创建、ScrollView 布局、Canvas 重建、GC。排行榜不是单纯 UI 问题,也可能是接口慢、数据太大、头像下载阻塞、JSON 解析在主线程、一次性创建太多行导致卡顿。
定位步骤
- 先确认卡顿发生在哪:点击后立刻卡、等数据时卡、数据显示时卡、滚动时卡,还是头像逐个出现时卡。
- 给各阶段打点:请求耗时、下载字节数、解析耗时、排序耗时、创建 Item 数量、头像加载数量。
- 用 Profiler 看 Main Thread:是否有
Instantiate、Canvas.BuildBatch、Layout.Rebuild、GC.Collect。 - 看 Memory:是否有大量
GC.Alloc,比如 JSON 字符串、临时 List、字符串拼接。 - 看 Loading/Texture:头像是否同步下载、解码、创建 Texture、上传 GPU。
- 看网络日志:接口是否慢、包体是否过大、是否弱网重试。
- 定位后优化,再对比打开耗时、GC Alloc、首帧耗时、滚动帧率。
常见原因
- 接口慢:服务端查询排行榜慢,客户端等待时 UI 像卡死。
- 数据太大:一次拉几百上千条,JSON 解析和排序在主线程。
- Item 太多:一次性 Instantiate 很多排行榜行。
- Layout 太重:
VerticalLayoutGroup、ContentSizeFitter导致布局重算。 - 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。
滚动列表掉帧,怎么优化?
标准答案
滚动列表掉帧,核心思路是先定位掉帧来源,再针对性优化。常见原因包括:一次性创建太多 Item、滚动中频繁触发 Layout 和 Canvas Rebuild、图片同步加载、头像 Texture 上传、Raycast Target 太多、Mask/透明背景 Overdraw 高、以及绑定数据时产生 GC。
优化重点
- 用虚拟列表:只创建可见区域 Item,加少量缓冲。
- 用对象池:滚动时复用 Item,不频繁 Instantiate/Destroy。
- 固定 Item 高度:减少
LayoutGroup、ContentSizeFitter的运行时计算。 - 拆 Canvas:列表动态区域单独 Canvas,避免影响整个界面重建。
- 图片异步加载:先显示占位图,图片加载完成后再替换。
- 关闭不必要的
Raycast Target:非按钮、非交互图片和文本都关闭。 - 降低 Overdraw:减少大面积半透明背景、Mask 嵌套和无意义叠图。
- 减少 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,才能让滚动稳定。
每隔几秒固定卡一下,可能是什么?
标准答案
每隔几秒固定卡一下,优先怀疑“周期性任务”在主线程集中执行。比如 GC、网络心跳、日志/埋点上报、自动保存、资源清理、对象池清理、计时器扫描、Buff Tick、AI 批量更新、InvokeRepeating 或协程轮询。
可能原因
- GC 周期回收:几秒内持续分配,堆压力到阈值后触发
GC.Collect。 - 网络心跳:每隔几秒发心跳,序列化、加密、回调解析在主线程做重了。
- 日志/埋点上报:批量 JSON、压缩、写文件、上传都挤到一帧。
- 自动保存:定时序列化存档并写磁盘。
- 资源清理:定时
UnloadUnusedAssets、清理缓存、释放对象池。 - 计时器管理器:大量 Timer 同一帧到期,集中触发回调。
- Buff/技能 Tick:每 1 秒或 5 秒统一结算全场 Buff、DOT、回血。
- AI 批量更新:所有怪物每隔几秒一起重新寻路或重新选目标。
- 物理补帧:某次卡顿后 FixedUpdate 追帧,表现为周期性抖动。
- 后台任务回主线程:异步加载、网络、解压完成后同一帧回调太多。
怎么查
- 先记录长帧时间点,比如
t=5.1s、10.1s、15.1s,确认间隔是否稳定。 - 打开 Profiler Timeline,看长帧里具体是
GC.Collect、Scripts、Loading、Physics、Network还是UI。 - 搜索项目里的周期任务:
InvokeRepeating、WaitForSeconds、TimerManager、Heartbeat、AutoSave、UploadLog。 - 给可疑模块加
ProfilerMarker,把心跳、上报、存档、Buff Tick、AI Tick 分开看。 - 对照日志时间戳,如果卡顿时间和某个任务完全一致,基本就锁定了。
- 修复后看长帧次数、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 卡,怎么查?
标准答案
Android 不卡但 iOS 卡,我不会直接说“iOS 性能差”,而是先保证对比条件一致,再查平台差异。第一步确认:包版本、资源版本、画质档、分辨率、帧率上限、测试场景、角色数量、网络状态是否一致。否则很可能是在拿高分辨率 iPhone 和低画质 Android 做不公平对比。
排查流程
- 统一变量:同一场景、同一账号、同一资源版本、同一画质、同一 Render Scale。
- 先判断 CPU 还是 GPU:Unity Profiler 看 Main Thread、Render Thread、GC;Xcode Instruments 看 Metal/GPU。
- 如果 CPU 高:查脚本逻辑、Animator、Physics、GC、Native 插件回调。
- 如果 GPU 高:查分辨率、后处理、透明 Overdraw、阴影、MSAA、Metal 渲染路径。
- 如果内存高:查贴图格式、音频加载、AssetBundle 常驻、峰值内存。
- 如果运行一会才卡:重点怀疑发热降频、内存压力、后台系统调度。
- 如果只有 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,还是修插件。
编辑器不卡真机卡,怎么查?
标准答案
编辑器不卡、真机卡,核心判断是:Editor 不能代表真机性能,必须在真机上复现并采集数据。我会先保证测试条件一致,再用真机 Profiler、设备日志和 GPU 工具定位瓶颈,而不是凭感觉改代码。
排查流程
- 先确认差异条件:机型、系统版本、包体版本、画质档、分辨率、帧率上限、资源版本、是否 Development Build。
- 真机打包:开启
Development Build、Autoconnect Profiler,连接 Unity Profiler。 - 看 CPU:重点看
Main Thread、BehaviourUpdate、GC.Alloc、Canvas.BuildBatch、Physics.Simulate、Animator.Update。 - 看 GPU:如果 CPU 不高但帧率低,查分辨率、Overdraw、透明特效、后处理、阴影、MSAA、带宽。
- 看内存和 IO:Texture 压缩、AssetBundle/Addressables 加载、音频加载方式、峰值内存、频繁读写磁盘。
- 看平台差异:Android/iOS 插件、SDK 回调、IL2CPP/AOT、反射、泛型裁剪、权限弹窗、热更新代码。
- 看发热降频:真机跑几分钟后才卡,通常要查温度、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 方法结束。
} // 类结束。