Appearance
移动端性能
发热和帧率下降怎么排查?
标准答案
发热和帧率下降要按“真机长时间采样”排查。先看帧时间是否持续变大,再判断是 CPU、GPU、内存、IO,还是发热后触发温控降频。移动端最常见的情况是:前几分钟流畅,机身变热后 CPU/GPU 频率下降,帧率开始掉。
底层原理
手机有功耗和温度限制。游戏长时间让 CPU/GPU 满负载,芯片温度升高后系统会降频,降频后同样的逻辑和渲染工作就需要更久,帧时间变大,FPS 就下降。60 FPS 每帧预算约 16.6ms,30 FPS 每帧预算约 33.3ms,超过预算就会掉帧。
排查流程
先确认现象:是打开某界面瞬间卡,还是玩几分钟后越来越低。如果是后者,优先怀疑持续高负载和温控降频。
然后接真机 Profiler,看 Main Thread、Render Thread、GC Alloc、Camera.Render、Canvas.BuildBatch、Physics.Simulate。CPU 高就拆脚本、AI、物理、动画、UI;GPU 高就看分辨率、阴影、后处理、透明特效、Overdraw;内存高就看贴图、Bundle、场景切换峰值和 GC。
最后做降规格实验:降低分辨率、关闭阴影、降低粒子数量、关闭后处理、减少透明层。如果降某项后发热和帧率明显改善,就能证明瓶颈方向。
简单监控脚本
c
using UnityEngine; // 引入 Unity 引擎命名空间
public sealed class FrameTimeLogger : MonoBehaviour // 定义帧时间监控组件
{ // 类开始
private const int SampleFrames = 60; // 每 60 帧统计一次平均值
private float totalTime; // 累加未缩放时间,避免受 timeScale 影响
private int frameCount; // 记录当前采样窗口内的帧数
private void Update() // 每帧调用一次
{ // 方法开始
totalTime += Time.unscaledDeltaTime; // 累加真实帧间隔
frameCount++; // 增加采样帧数
if (frameCount >= SampleFrames) // 达到采样帧数后输出统计
{ // 判断开始
float avgMs = totalTime * 1000f / frameCount; // 计算平均每帧耗时毫秒
float fps = frameCount / totalTime; // 计算平均 FPS
Debug.Log($"FPS={fps:F1}, AvgFrame={avgMs:F2}ms"); // 输出平均 FPS 和平均帧时间
totalTime = 0f; // 清空累计时间
frameCount = 0; // 清空帧计数
} // 判断结束
} // 方法结束
} // 类结束面试重点
NOTE
不要只说“用 Profiler 看”。要说清楚看哪些指标,怎么判断 CPU/GPU,怎么确认温控降频,以及优化前后数据怎么对比。比较好的说法是:我先建立基线,再真机长时间跑,记录平均 FPS、P95 帧时间、温度、功耗和频率变化,然后逐项降规格验证瓶颈。
低端机内存爆了怎么定位?
标准答案
低端机内存爆了,先不要直接猜“贴图太大”。我会先判断是加载峰值过高,还是退出后内存不回落。前者通常是新旧场景、资源、Bundle 同时存在;后者通常是引用没断、Addressables 没 Release、事件或静态变量持有对象。
底层原理
Unity 内存不只有 C# 托管堆,还包括 Native 内存、Texture、Mesh、Audio、AssetBundle、GfxDriver 等。GC 只能处理托管对象,贴图、网格、音频、Bundle 这类资源通常在 Native 侧,必须走正确的资源卸载流程。
排查流程
- 看崩溃日志:Android 可能是低内存杀进程,iOS 可能是 Jetsam,Unity 可能有
Out of memory。 - 用低端真机复现:记录进入场景前、加载中、进入后、退出后的内存。
- 用 Memory Profiler 对比快照:按大小排序,看 Texture、Mesh、Audio、Managed Object、Native Object。
- 判断峰值还是泄漏:加载瞬间暴涨是峰值;退出后不降是泄漏或引用残留。
- 查引用链:静态字段、事件、对象池、缓存、UI、Addressables Handle、AssetBundle 依赖。
- 修复后验证:低端机峰值内存下降,退出场景后内存能回落,OOM 率下降。
简单监控脚本
c
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.Profiling; // 引入 Unity Profiler 内存 API
public sealed class MemoryLogTool : MonoBehaviour // 定义内存日志工具组件
{ // 类开始
public void PrintMemory(string tag) // 打印当前内存信息
{ // 方法开始
long total = Profiler.GetTotalAllocatedMemoryLong(); // 获取 Unity 已分配内存
long reserved = Profiler.GetTotalReservedMemoryLong(); // 获取 Unity 保留内存
long mono = Profiler.GetMonoUsedSizeLong(); // 获取托管堆已使用内存
Debug.Log($"{tag} Total={ToMB(total)}MB Reserved={ToMB(reserved)}MB Mono={ToMB(mono)}MB"); // 输出关键内存指标
} // 方法结束
private long ToMB(long bytes) // 将字节转换成 MB
{ // 方法开始
return bytes / 1024L / 1024L; // 返回 MB 数值
} // 方法结束
} // 类结束优化方向
TIP
贴图优先查尺寸、压缩格式、Mipmap、重复资源;场景切换优先查旧场景和旧 Bundle 是否还在;Addressables 要成对 Load 和 Release;对象池要有上限,不能无限常驻;大资源加载要拆阶段,避免峰值把低端机打爆。
移动端纹理格式怎么选?
标准答案
移动端纹理格式优先选 GPU 原生压缩格式。一般思路是:能用 ASTC 就优先 ASTC;Android 兼容兜底常用 ETC2;老 iOS 设备可考虑 PVRTC;RGBA32 只给少量必须无损的特殊图,不能大面积使用。
底层原理
纹理占用主要由“宽 × 高 × 每像素位数”决定。比如 1024×1024 RGBA32 不带 Mipmap 约 4MB,带 Mipmap 约 5.3MB。ASTC 6x6 大约只有 RGBA32 的一小部分显存,但质量会比 4x4 差一些。所以格式选择本质是:画质、显存、带宽、包体、兼容性的取舍。
常用选择
UI 图:通常关闭 Mipmap,图标可以 ASTC 4x4 或 6x6,背景大图可以 8x8。
角色和场景贴图:通常开启 Mipmap,ASTC 6x6 常作为平衡选择,远景或大面积低频纹理可以 8x8。
法线图、Mask、LUT:注意关闭 sRGB,因为它们是数据,不是颜色。压太狠会导致法线失真、金属度粗糙度异常。
透明图:需要支持 Alpha 的格式。ASTC 支持 Alpha;ETC2 也有 RGBA 格式;老格式不支持 Alpha 时可能要拆 Alpha 通道。
Unity 工程实践
c
#if UNITY_EDITOR // 只在 Unity 编辑器环境中编译这段导入脚本
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 引擎基础 API
public sealed class MobileTextureImportRule : AssetPostprocessor // 定义纹理导入规则处理类
{ // 类开始
private void OnPreprocessTexture() // 纹理导入前自动调用
{ // 方法开始
TextureImporter importer = (TextureImporter)assetImporter; // 获取当前纹理的导入器
bool isUiTexture = assetPath.Contains("/UI/"); // 判断是否是 UI 目录下的纹理
importer.mipmapEnabled = !isUiTexture; // UI 通常关闭 Mipmap,场景图通常开启
importer.isReadable = false; // 关闭 Read/Write,避免 CPU 侧保留一份纹理数据
TextureImporterPlatformSettings android = importer.GetPlatformTextureSettings("Android"); // 获取 Android 平台设置
android.overridden = true; // 启用 Android 平台覆盖设置
android.format = TextureImporterFormat.ASTC_6x6; // Android 优先使用 ASTC 6x6 作为平衡档
android.maxTextureSize = isUiTexture ? 1024 : 2048; // UI 控制尺寸,场景图允许更大
importer.SetPlatformTextureSettings(android); // 写回 Android 平台纹理设置
TextureImporterPlatformSettings ios = importer.GetPlatformTextureSettings("iPhone"); // 获取 iOS 平台设置
ios.overridden = true; // 启用 iOS 平台覆盖设置
ios.format = TextureImporterFormat.ASTC_6x6; // iOS 新设备也优先使用 ASTC 6x6
ios.maxTextureSize = isUiTexture ? 1024 : 2048; // 根据用途限制最大尺寸
importer.SetPlatformTextureSettings(ios); // 写回 iOS 平台纹理设置
} // 方法结束
} // 类结束
#endif // 结束编辑器条件编译面试重点
IMPORTANT
不要只说“用 ASTC”。要补充:看最低机型是否支持,看是否透明,看是否 UI,看是否需要 Mipmap,看 sRGB 是否正确,看 Read/Write Enabled 是否关闭,看真机画质和显存数据。Crunch 更偏向减少包体和下载体积,不等于直接减少运行时显存。
ASTC 4x4、6x6、8x8 如何取舍?
标准答案
ASTC 4x4、6x6、8x8 的取舍,本质是画质换内存和带宽:
4x4:质量最好,显存最高,适合 UI 图标、角色脸、法线图、细节密集图。6x6:质量和内存比较平衡,适合大多数角色、场景、普通 Albedo。8x8:最省显存和带宽,但更容易糊、出块,适合远景、大背景、低频贴图。
底层原理
ASTC 每个压缩块固定是 128 bit。 所以:
4x4:16 个像素共用 128 bit,约8 bpp6x6:36 个像素共用 128 bit,约3.56 bpp8x8:64 个像素共用 128 bit,约2 bpp
块越大,每个像素分到的信息越少,显存和带宽越低,但压缩痕迹越明显。
Unity 项目里怎么选
我一般会先把 6x6 当默认档,然后按用途调整:重要角色、UI、近景物件升到 4x4;大背景、远景、低频贴图降到 8x8。法线图、Mask、渐变、文字类图不要压太狠,因为很容易出现法线失真、色带、边缘糊。
面试关键词
WARNING
不要回答“4x4 清晰,8x8 省内存”就停。要补一句:最终要看真机,尤其看近景、暗部、透明边缘、运动中是否闪烁,以及显存和发热是否下降。
为什么移动端要减少透明 Overdraw?
标准答案
移动端要减少透明 Overdraw,因为透明物体会让同一个像素被重复绘制、重复跑片元着色器、重复做混合。透明层越多,GPU 像素工作量、显存带宽、功耗和发热都会上升,最后表现就是掉帧、发烫、耗电。
底层原理
不透明物体通常可以靠深度测试提前丢弃被挡住的片元。但透明物体为了和背景混合,常见做法是 ZWrite Off,并按从远到近绘制。这样后面的透明层不能简单被前面的透明层挡掉,同一个像素可能被烟雾、火焰、UI 遮罩、粒子连续画很多次。
透明混合还不只是“写颜色”,它通常需要:
- 读取当前 framebuffer 里的背景颜色
- 执行片元 Shader
- 按 alpha 做混合
- 再写回结果
移动端 GPU 对带宽和功耗很敏感,所以透明 Overdraw 的代价会比桌面端更明显。
Unity 项目里怎么优化
粒子特效:减少粒子数量、缩小粒子尺寸、减少全屏烟雾,远处用低配特效。
UI:避免多层全屏半透明遮罩,弹窗背景不要一层套一层,关闭无用 Image 的 Raycast Target 只能省 UI 射线,不直接省 Overdraw。
贴图和网格:用更贴合轮廓的 Sprite Mesh,减少透明空白区域;大面积透明图尽量裁切。
材质:能用不透明就不透明;能用 Alpha Test 解决的,不一定要 Alpha Blend;透明 Shader 尽量减少采样和复杂计算。
定位工具:Unity Scene 视图看 Overdraw,Frame Debugger 看绘制顺序,RenderDoc 看像素被绘制了多少次。
面试关键词
CAUTION
透明 Overdraw 的核心公式可以说成:成本 = 屏幕面积 × 透明层数 × Shader 复杂度。移动端优化不是只减 DrawCall,还要看像素填充率、带宽、透明面积和发热。
为什么移动端慎用实时阴影?
标准答案
移动端慎用实时阴影,因为实时阴影不是单纯“让灯光投影”,它通常要先从光源视角渲染一张 Shadow Map,再在主相机渲染时对每个像素采样阴影图并比较深度。它会同时增加 CPU 提交、GPU 渲染、显存占用、带宽和发热压力。
底层原理
实时阴影常见流程是:
- 光源先渲染能投影的物体,生成阴影深度图。
- 主相机正常渲染物体。
- Shader 把当前像素变换到光源空间。
- 用当前深度和 Shadow Map 里的深度比较。
- 如果被挡住,就认为在阴影里。
所以它至少多了一个 ShadowCaster Pass。方向光如果开 CSM 级联阴影,会生成多张阴影图;点光源阴影更贵,因为可能要向多个方向生成阴影;软阴影还会增加多次采样。
Unity 项目里怎么处理
移动端通常不会全场景开高质量实时阴影。常见做法是:场景静态阴影用烘焙,角色脚下用 Blob Shadow 或简单投影阴影,关键主角和 Boss 才允许实时阴影。还要限制 Shadow Distance、阴影分辨率、级联数量、投影物数量,低端机关闭软阴影或关闭实时阴影。
常见优化点
- 缩短 Shadow Distance
- 降低 Shadow Resolution
- 减少 Cascade Count
- 关闭不重要物体的 Cast Shadows
- 静态物体用 Baked Shadow
- 角色脚底用假阴影
- 低端机用画质档关闭实时阴影
- 用 Frame Debugger 看 ShadowCaster Pass
- 用 Profiler 看 GPU 时间和 Camera.Render
面试关键词
NOTE
一句话总结:实时阴影贵在“额外渲染阴影图 + 主渲染采样比较”。移动端的带宽、功耗和散热预算小,所以阴影距离、分辨率、级联数量和投影物数量都必须严格控制。
粒子系统在移动端如何裁剪?
标准答案
移动端粒子系统裁剪,不是只把看不见的 Renderer 关掉,而是要分层处理:先少生成,再少模拟,最后少渲染。因为粒子贵的地方主要在透明 Overdraw、Shader 采样、粒子模拟、排序、带宽和发热。
底层原理
粒子通常是透明面片,多个粒子叠在一起时,同一个屏幕像素会被反复绘制和混合。移动端 GPU 对带宽和功耗很敏感,所以大面积烟雾、火焰、爆炸、全屏特效很容易造成发热和掉帧。
可以把成本理解成:
粒子成本 ≈ 粒子数量 × 屏幕面积 × 透明层数 × Shader 复杂度Unity 工程实践
常用裁剪策略:
- 距离裁剪:离相机太远就不生成或不播放。
- 视锥裁剪:相机看不到就暂停或停止。
- 屏幕占比裁剪:屏幕上很小的特效切低配。
- 质量档裁剪:低端机降低 Emission、Max Particles、贴图采样和软粒子。
- 生命周期裁剪:播完立即回对象池,切场景统一停止。
- Bounds 检查:Bounds 太大无法被裁剪,Bounds 太小会突然消失。
简单脚本示例
c
using UnityEngine; // 引入 Unity 引擎命名空间
public sealed class ParticleDistanceCuller : MonoBehaviour // 定义粒子距离裁剪组件
{ // 类开始
public Camera targetCamera; // 保存用于距离判断的相机
public float maxDistance = 30f; // 超过这个距离就停止粒子
private ParticleSystem particle; // 缓存当前物体上的粒子系统
private void Awake() // 组件初始化时调用
{ // 方法开始
particle = GetComponent<ParticleSystem>(); // 获取当前物体上的粒子系统
if (targetCamera == null) // 如果外部没有手动指定相机
{ // 判断开始
targetCamera = Camera.main; // 使用主相机作为默认目标相机
} // 判断结束
} // 方法结束
private void Update() // 每帧检查粒子是否需要播放
{ // 方法开始
if (targetCamera == null || particle == null) // 如果相机或粒子系统不存在
{ // 判断开始
return; // 直接返回,避免空引用
} // 判断结束
float distance = Vector3.Distance(transform.position, targetCamera.transform.position); // 计算粒子到相机的距离
bool shouldPlay = distance <= maxDistance; // 判断是否在允许播放的距离内
if (shouldPlay && !particle.isPlaying) // 如果应该播放但当前没有播放
{ // 判断开始
particle.Play(true); // 播放粒子并包含子粒子系统
} // 判断结束
else if (!shouldPlay && particle.isPlaying) // 如果不该播放但当前还在播放
{ // 判断开始
particle.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); // 停止发射并清掉现有粒子
} // 判断结束
} // 方法结束
} // 类结束面试重点
TIP
要强调“裁剪越早越好”。最好的优化是在业务层就不创建远处特效,而不是等它已经生成、模拟、排序、渲染之后才关掉。移动端还要重点关注大面积透明粒子、软粒子、Distortion、Trail、全屏 Bloom 叠加这些高风险点。
UI 全屏半透明为什么贵?
标准答案
UI 全屏半透明贵,主要贵在面积大 + 必须 Blend + 容易多层 Overdraw。一个全屏暗幕会覆盖几乎所有屏幕像素,每个像素都要读取背景色、计算透明混合、再写回结果。移动端带宽和功耗紧张,所以这种全屏透明层很容易带来发热和掉帧。
底层原理
不透明 UI 通常可以直接覆盖写入;半透明 UI 不能直接覆盖,因为它要和后面的颜色混合。典型公式类似:
最终颜色 = 前景颜色 × alpha + 背景颜色 × (1 - alpha)这意味着 GPU 需要读背景、算混合、写结果。全屏遮罩影响的是整个屏幕,如果再叠弹窗背景、毛玻璃、粒子特效、多层 Image,同一个像素会被重复绘制很多次。
Unity 项目里怎么优化
常见优化方式:
- 弹窗栈只保留一层全屏暗幕,不要每个弹窗都生成一层。
- 不需要全屏透明时,尽量缩小 Image 的 RectTransform。
- 动态变化的遮罩单独放 Canvas,避免影响整套静态 UI 重建。
- 不需要点击检测的半透明 Image 关闭
Raycast Target。 - 能用不透明背景就不要用半透明。
- 毛玻璃、模糊背景、全屏渐变在低端机上要慎用。
- 用 Scene Overdraw、Frame Debugger、Profiler 定位。
面试关键词
IMPORTANT
一句话总结:全屏半透明的成本约等于 屏幕像素数 × 透明层数 × Shader 复杂度。移动端优化 UI 时,不能只看 DrawCall,还要看 Overdraw、Canvas Rebuild、Raycast Target 和带宽压力。
加载场景时如何控制峰值内存?
标准答案
加载场景控制峰值内存,核心是减少“同一时刻共存的资源”。最危险的情况是:旧场景还没释放,新场景已经加载,依赖资源、解压缓存、Loading UI、对象池、音频、特效也都还在,峰值就会被顶爆。
底层原理
场景切换不是瞬间替换。异步加载期间,新资源会逐步进内存;旧场景对象和旧资源如果还有引用,就不会释放。allowSceneActivation = false 只能延迟激活,不代表资源没加载进内存,所以不能靠它解决峰值问题。
工程做法
- 加载前先清理:停止特效、音频、AI、临时 UI,裁剪对象池。
- 释放引用:Addressables 要
Release,Bundle 依赖要降引用计数。 - 先卸后载:能先卸旧场景就先卸,不能就拆成轻量过渡场景。
- 分阶段加载:首屏必需资源先加载,非关键资源进场后异步补。
- Loading 界面要轻量:不要用大图、复杂动画、全屏特效。
- 加载后验证:用 Memory Profiler 看加载前、加载中、加载后快照。
简单代码
c
using System.Collections; // 引入协程需要的 IEnumerator
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.SceneManagement; // 引入场景管理 API
public sealed class SafeSceneLoader : MonoBehaviour // 定义安全场景加载组件
{ // 类开始
public IEnumerator SwitchScene(string sceneName) // 切换到目标场景的协程
{ // 方法开始
ClearTemporarySystems(); // 先停止临时 UI、特效、音频和对象池引用
yield return Resources.UnloadUnusedAssets(); // 释放已经没有引用的 Unity 资源
System.GC.Collect(); // 在加载界面阶段主动回收托管堆垃圾
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single); // 异步加载目标场景并替换旧场景
while (!operation.isDone) // 等待场景加载完成
{ // 循环开始
yield return null; // 每帧等待,避免阻塞主线程
} // 循环结束
yield return Resources.UnloadUnusedAssets(); // 场景切换完成后再次清理旧资源
} // 方法结束
private void ClearTemporarySystems() // 清理切场景前的临时系统
{ // 方法开始
} // 方法结束
} // 类结束面试关键词
WARNING
好的回答要说“削峰”,不是只说异步加载。重点是:释放引用、拆分加载、轻量 Loading、资源依赖去重、低端机内存预算、Memory Profiler 前中后三段对比。
如何设计高中低画质档?
标准答案
高中低画质档要按“性能预算”设计,不是只调分辨率。通常我会把画质拆成几个模块:分辨率、帧率、阴影、后处理、抗锯齿、贴图质量、粒子数量、LOD、Shader 复杂度、UI 特效、内存预算。
设计思路
高画质:保视觉效果,开较高分辨率、阴影、后处理、高贴图和更多特效。 中画质:保稳定帧率,适当降低阴影距离、后处理和特效数量。 低画质:保可玩性和不崩溃,降低渲染分辨率,关闭实时阴影、复杂后处理、大量粒子和高贴图。
Unity 工程实践
c
using UnityEngine; // 引入 Unity 引擎命名空间
public enum QualityTier // 定义画质档枚举
{ // 枚举开始
Low, // 低画质档
Mid, // 中画质档
High // 高画质档
} // 枚举结束
[System.Serializable] // 允许配置在 Inspector 中序列化显示
public sealed class QualityProfile // 定义单个画质配置
{ // 类开始
public int targetFrameRate = 30; // 当前档位的目标帧率
public float renderScale = 0.8f; // 当前档位的渲染缩放比例
public float shadowDistance = 0f; // 当前档位的阴影距离
public int antiAliasing = 0; // 当前档位的抗锯齿等级
public int shaderLod = 200; // 当前档位允许的 Shader LOD 上限
} // 类结束
public sealed class QualityApplier : MonoBehaviour // 定义画质应用组件
{ // 类开始
public QualityProfile low; // 保存低画质配置
public QualityProfile mid; // 保存中画质配置
public QualityProfile high; // 保存高画质配置
public void Apply(QualityTier tier) // 应用指定画质档
{ // 方法开始
QualityProfile profile = tier == QualityTier.High ? high : tier == QualityTier.Mid ? mid : low; // 根据档位选择配置
Application.targetFrameRate = profile.targetFrameRate; // 设置目标帧率
QualitySettings.shadowDistance = profile.shadowDistance; // 设置阴影距离
QualitySettings.antiAliasing = profile.antiAliasing; // 设置抗锯齿等级
Shader.globalMaximumLOD = profile.shaderLod; // 限制全局 Shader LOD
ScalableBufferManager.ResizeBuffers(profile.renderScale, profile.renderScale); // 调整渲染缓冲缩放
} // 方法结束
} // 类结束关键点
画质档最好配置表驱动,别写死在代码里。低端机不只是关阴影,还要控制内存峰值、贴图尺寸、粒子 Overdraw、帧率和发热。运行时还可以做动态降级:如果连续多秒帧时间过高或设备发热,就自动降低分辨率、粒子数量或关闭后处理。
面试关键词
CAUTION
高档保效果,中档保稳定,低档保可玩。验证时看平均 FPS、P95 帧时间、内存峰值、温度、耗电和低端机崩溃率。