Appearance
线上问题
线上某机型崩溃率很高,怎么处理?
标准答案
线上某机型崩溃率很高,我会先做两件事:线上止血和证据定位并行。不能等完全定位后才处理,因为崩溃已经影响玩家。
处理顺序是:
- 先看崩溃平台数据,确认是不是某个机型、系统版本、GPU、渠道、资源版本集中异常。
- 立刻做止血:远程配置关闭高风险功能、降低画质、关闭后处理/特效、回滚热更资源。
- 拉取崩溃日志,Android 看
logcat、tombstone、native crash;iOS 看崩溃日志和dSYM符号化结果。 - 按调用栈聚合,判断是 Unity C# 异常、IL2CPP native 崩溃、OOM、GPU 驱动、SDK、资源加载还是系统兼容问题。
- 找同机型真机复现,保证同包体、同资源版本、同配置、同玩法路径。
- 修复后灰度发布,观察该机型崩溃率、Crash-free、留存和投诉是否恢复。
常见根因
某机型崩溃率高,通常是兼容性或资源压力问题:
- 低内存机型加载峰值过高,触发 OOM。
- 某 GPU 驱动对 Shader、RenderTexture 格式、后处理不兼容。
- 特定 Android 系统版本 WebView、SDK、权限、JNI 崩溃。
- IL2CPP native 崩溃,需要符号表还原调用栈。
- 该机型不支持某纹理压缩格式或图形 API。
- 资源包损坏、热更版本不一致、AB 依赖缺失。
- 后台切前台、切场景、释放技能这类路径触发 native crash。
- 低端机发热降频后内存或线程调度问题更明显。
排查重点
我会重点补充这些上下文,否则只有调用栈很难定位:
deviceModeloperatingSystemgraphicsDeviceNamegraphicsDeviceTypesystemMemorySizeprocessorTypeappVersionresourceVersion- 当前场景
- 最后一次 UI、战斗、资源加载操作
- 画质档
- 图形 API
- 是否热更版本
辅助代码
c
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.SceneManagement; // 引入场景管理 API。
public sealed class CrashContextReporter : MonoBehaviour // 定义崩溃上下文上报组件。
{ // 类开始。
private void Awake() // 对象初始化时执行。
{ // 方法开始。
DontDestroyOnLoad(gameObject); // 跨场景保留该日志组件。
Application.logMessageReceived += OnLogMessageReceived; // 监听 Unity 日志和异常。
ReportDeviceContext(); // 启动时输出设备上下文。
} // 方法结束。
private void OnDestroy() // 对象销毁时执行。
{ // 方法开始。
Application.logMessageReceived -= OnLogMessageReceived; // 取消日志监听,避免重复订阅。
} // 方法结束。
private void ReportDeviceContext() // 输出设备基础信息。
{ // 方法开始。
Debug.Log($"Device={SystemInfo.deviceModel}"); // 输出设备型号。
Debug.Log($"OS={SystemInfo.operatingSystem}"); // 输出系统版本。
Debug.Log($"GPU={SystemInfo.graphicsDeviceName}"); // 输出 GPU 名称。
Debug.Log($"API={SystemInfo.graphicsDeviceType}"); // 输出图形 API。
Debug.Log($"Memory={SystemInfo.systemMemorySize}MB"); // 输出系统内存。
Debug.Log($"Version={Application.version}"); // 输出客户端版本。
} // 方法结束。
private void OnLogMessageReceived(string condition, string stackTrace, LogType type) // 处理日志回调。
{ // 方法开始。
if (type != LogType.Exception && type != LogType.Error) return; // 只处理异常和错误日志。
string sceneName = SceneManager.GetActiveScene().name; // 获取当前场景名。
Debug.Log($"CrashContext scene={sceneName}, device={SystemInfo.deviceModel}, error={condition}"); // 输出崩溃上下文。
} // 方法结束。
} // 类结束。面试关键句
TIP
我会这样回答:“某机型崩溃率高,我会先分桶确认影响面,然后用远程配置或热更先止血。定位时必须拿符号化堆栈、设备信息、资源版本和最后操作路径,判断是 OOM、GPU 驱动、Shader、SDK、资源包还是 IL2CPP native 崩溃。修复不能直接全量发,要同机型真机复现,再灰度观察 Crash-free 和崩溃率是否下降。”
玩家反馈资源下载失败,怎么排查?
标准答案
玩家反馈资源下载失败,我会先拿到失败上下文,再按链路分层排查。不要只看一句“下载失败”,要明确失败发生在:拉 Manifest、请求 CDN、下载文件、写入本地、Hash 校验、替换缓存、加载资源中的哪一步。
排查顺序
- 收集客户端日志:用户 ID、机型、系统、网络类型、地区、渠道、App 版本、资源版本、URL、状态码、错误码、重试次数。
- 查 Manifest:资源版本、Hash、文件大小、依赖关系是否正确。
- 查 CDN:是否 403、404、超时、证书错误、DNS 解析失败、节点缓存未刷新。
- 查客户端落盘:磁盘空间、读写权限、缓存目录、临时文件、断点续传文件是否损坏。
- 查校验:下载大小是否一致,Hash 是否一致,失败后是否删除坏文件。
- 查弱网:是否支持超时重试、断点续传、备用 CDN、失败恢复。
- 查发布流程:是否资源上传不完整、Manifest 先发了但资源没同步完、灰度版本拿到错误配置。
- 修复后观察资源下载失败率、重试成功率、启动卡死率和客服反馈。
常见原因
- Manifest 指向了不存在的资源,导致 404。
- CDN 节点缓存旧文件,Manifest 和资源不一致。
- 防盗链、权限、签名过期导致 403。
- 玩家弱网、DNS 异常、运营商网络问题导致超时。
- HTTPS 证书、系统时间、TLS 兼容问题导致请求失败。
- 磁盘空间不足,下载成功但写文件失败。
- 断点续传临时文件损坏,后续一直校验失败。
- Hash 或文件大小校验失败,没有清理坏缓存。
- Android 分渠道配置错,拿到错误资源服地址。
- 灰度资源和正式资源版本混用。
工程处理
下载系统要设计成“可诊断、可恢复”:
- 每次下载生成
traceId,客户端和服务端日志能对上。 - 错误码分层:网络错误、HTTP 错误、写文件错误、校验错误、加载错误。
- 下载先写
.tmp文件,校验通过后再替换正式文件。 - Hash 失败要删除临时文件或坏缓存。
- 支持重试,但要限制次数,避免无限卡住。
- 支持备用 CDN。
- Manifest 发布要保证“资源先上传完成,再发布清单”。
- 启动阶段失败要给玩家重试入口,不要直接卡死。
辅助代码
c
using System.Collections; // 引入 IEnumerator 协程类型。
using System.IO; // 引入文件读写 API。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Networking; // 引入 UnityWebRequest 网络 API。
public sealed class ResourceDownloadProbe : MonoBehaviour // 定义资源下载排查组件。
{ // 类开始。
public IEnumerator DownloadFile(string url, string savePath) // 定义下载文件协程。
{ // 方法开始。
string traceId = System.Guid.NewGuid().ToString("N"); // 创建本次下载追踪 ID。
string tempPath = savePath + ".tmp"; // 使用临时文件路径,避免坏文件覆盖正式文件。
Debug.Log($"DownloadStart traceId={traceId}, url={url}"); // 输出下载开始日志。
using UnityWebRequest request = UnityWebRequest.Get(url); // 创建 GET 下载请求。
request.timeout = 15; // 设置超时时间,避免弱网下无限等待。
yield return request.SendWebRequest(); // 发送请求并等待完成。
if (request.result != UnityWebRequest.Result.Success) // 判断请求是否失败。
{ // if 开始。
Debug.LogError($"DownloadFail traceId={traceId}, code={request.responseCode}, error={request.error}, url={url}"); // 输出失败状态码和错误信息。
yield break; // 结束协程。
} // if 结束。
byte[] data = request.downloadHandler.data; // 获取下载到的字节数据。
Directory.CreateDirectory(Path.GetDirectoryName(savePath)); // 确保目标目录存在。
File.WriteAllBytes(tempPath, data); // 先写入临时文件。
File.Move(tempPath, savePath); // 校验通过后应替换正式文件,这里演示直接替换。
Debug.Log($"DownloadSuccess traceId={traceId}, bytes={data.Length}, path={savePath}"); // 输出下载成功日志。
} // 方法结束。
} // 类结束。面试关键句
IMPORTANT
“资源下载失败我会先把错误分层,不会只报一个失败弹窗。客户端必须上报 traceId、URL、状态码、资源版本、Hash、文件大小、网络类型和落盘结果。排查时先看 Manifest 和 CDN,再看网络、证书、权限、磁盘、断点续传和校验。修复后要看下载失败率、重试成功率和是否还有坏缓存残留。”
热更新后部分玩家进不去,怎么回滚?
标准答案
热更新后部分玩家进不去,我会先做线上止血,再做回滚。重点是:回滚不是简单删除 CDN 上的新文件,而是让客户端重新拿到稳定版本的 Manifest,并处理已经缓存坏补丁的玩家。
处理顺序
- 先冻结发布:暂停继续灰度、暂停全量推送。
- 远程配置止血:关闭新功能入口、关闭高风险玩法、禁用坏资源或坏脚本。
- 确认影响范围:App 版本、热更版本、资源版本、渠道、机型、地区、灰度分组。
- 服务端把热更 Manifest 指回上一稳定版本。
- 如果部分玩家已经下载坏补丁,要发布更高
revision的修复 Manifest,让客户端覆盖坏缓存。 - 如果是资源坏包,要重新下发正确 Hash 和正确依赖。
- 如果是 Lua/脚本热更错误,要回滚脚本包或发修复脚本包。
- 如果底包更新器本身崩溃,热更救不了,只能临时配置止血并准备新包。
- 灰度回滚后观察登录成功率、崩溃率、下载失败率。
- 稳定后再扩大回滚范围或重新发布修复版本。
底层原理
热更新通常依赖一个版本清单:
客户端启动 -> 拉 Manifest -> 对比本地版本 -> 下载 Patch -> 校验 Hash -> 加载资源或脚本所以回滚的核心是控制 Manifest。客户端不是“看到 CDN 有什么就下载什么”,而是根据 Manifest 里写的资源版本、Hash、大小、依赖和补丁列表来决定下载什么。
如果玩家还没下载坏补丁,服务端把 Manifest 指回稳定版本即可。
如果玩家已经缓存了坏补丁,只回滚服务端指针可能不够。客户端可能继续使用本地缓存,所以要:
- 提高 Manifest revision。
- 修改 Hash,让客户端发现本地缓存不匹配。
- 删除或覆盖坏补丁。
- 必要时发一个“修复包”清理错误缓存。
- 启动阶段优先拉紧急配置,再执行热更代码。
不同类型怎么回滚
配置热更出错: 直接改远程配置,关闭入口或恢复旧配置。这是最快的止血方式。
资源热更出错: 回滚资源 Manifest 到上一稳定版本,刷新 CDN,确保旧 Bundle 没被删除。坏缓存玩家要通过 Hash 或 revision 重新拉正确资源。
Lua 热更出错: 回滚 Lua 脚本包,或者发一个更高版本的修复 Lua 包。注意脚本入口不能在拉配置前就执行崩溃逻辑。
HybridCLR/ILRuntime 这类代码热更出错: 要看是不是 AOT 元数据、泛型裁剪、程序集版本不兼容。如果是底包不支持的新代码能力,单纯回滚资源可能不够。
数据或配置表结构变更出错: 最危险。因为回滚代码后,玩家本地数据可能已经按新结构写过。这里要考虑数据兼容、迁移回滚或修复脚本。
辅助代码
c
using UnityEngine; // 引入 Unity 基础 API。
public sealed class PatchVersionSelector : MonoBehaviour // 定义热更版本选择组件。
{ // 类开始。
[SerializeField] private string localPatchVersion; // 保存本地已经缓存的热更版本。
[SerializeField] private string remotePatchVersion; // 保存服务端下发的热更版本。
[SerializeField] private int localRevision; // 保存本地 Manifest 修订号。
[SerializeField] private int remoteRevision; // 保存服务端 Manifest 修订号。
public bool ShouldUseRemoteManifest() // 判断是否应该使用服务端 Manifest。
{ // 方法开始。
if (remoteRevision > localRevision) return true; // 服务端修订号更高,说明可能有修复或回滚清单。
if (remotePatchVersion != localPatchVersion) return true; // 版本号不同,需要重新对齐服务端版本。
return false; // 否则继续使用本地缓存。
} // 方法结束。
public void ApplyRollbackManifest() // 应用回滚 Manifest。
{ // 方法开始。
Debug.Log($"Apply patch manifest: {remotePatchVersion}, revision={remoteRevision}"); // 输出当前应用的 Manifest 信息。
localPatchVersion = remotePatchVersion; // 更新本地热更版本记录。
localRevision = remoteRevision; // 更新本地 Manifest 修订号。
} // 方法结束。
} // 类结束。面试关键句
TIP
“热更新事故我会先止血,冻结发布并用远程配置关闭风险入口。真正回滚不是删文件,而是把服务端 Manifest 指向上一稳定版本,同时提高 revision 或修改 Hash,确保已经缓存坏补丁的玩家也会重新拉正确资源。回滚后必须灰度验证登录成功率、崩溃率和下载失败率,最后复盘补上预检、灰度、兼容矩阵和回滚演练。”
战斗中偶现角色卡死,怎么收集信息?
标准答案
战斗中偶现角色卡死,第一步不是猜“动画问题”或“碰撞问题”,而是先设计信息采集。偶现问题必须记录卡死前几秒发生了什么,否则线上日志只会看到“最后角色不动了”,很难定位。
我会采集三类信息:环形时间线、卡死瞬间快照、可复现输入。
需要收集什么
角色状态:
- 当前 FSM 状态。
- 上一个状态。
- 是否锁输入。
- 是否锁移动。
- 是否处于攻击、受击、硬直、击退、死亡、复活。
- 状态进入时间。
- 状态是否超时未退出。
动画信息:
- 当前 Animator State。
normalizedTime。- 当前动画参数。
- Trigger 是否残留。
- Root Motion 是否开启。
- 动画事件是否触发。
- 是否卡在攻击后摇、受击、翻滚、死亡等状态。
移动和物理信息:
- 角色位置、朝向、速度。
CharacterController.isGrounded。Rigidbody.velocity。- 是否被约束冻结。
- 是否卡进 Collider。
- 当前地面 Layer。
- NavMeshAgent 是否 stopped。
- 是否正在等待路径。
战斗上下文:
- 当前技能 ID。
- 技能阶段:前摇、命中、后摇、收招。
- 当前 Buff。
- 是否被沉默、眩晕、冰冻、击退。
- 技能是否被打断。
- 目标是否丢失。
- 伤害结算是否异常。
网络信息:
- 当前帧号或 Tick。
- 延迟、丢包、重连状态。
- 最近服务器校正位置。
- 最近收到的控制指令。
- 客户端预测是否被回滚。
- 是否等待服务端确认。
环境信息:
- 地图 ID。
- 坐标点位。
- 周围怪物数量。
- 周围碰撞体数量。
- 是否在墙边、斜坡、狭窄区域。
- 当前场景资源版本。
采集方式
我会做一个环形日志,只保留最近 5 到 10 秒。平时不大量写文件,只在检测到卡死或玩家点反馈按钮时,把最近日志、当前快照、截图、设备信息一起上报。
卡死检测可以这样定义:
- 玩家有输入,但角色位置连续几秒不变。
- 状态机卡在某个状态超过预期时间。
- 动画状态不切换,且移动被锁。
- NavMeshAgent 有路径但速度为 0。
- 角色不是死亡、眩晕、剧情控制等合法不可动状态。
辅助代码
c
using System.Collections.Generic; // 引入队列集合类型。
using UnityEngine; // 引入 Unity 基础 API。
public sealed class CombatStuckRecorder : MonoBehaviour // 定义战斗卡死信息采集组件。
{ // 类开始。
[SerializeField] private Transform actor; // 保存角色 Transform 引用。
[SerializeField] private float stuckSeconds = 3f; // 设置连续不移动多少秒判定为疑似卡死。
[SerializeField] private float minMoveDistance = 0.05f; // 设置判定移动的最小距离。
private readonly Queue<string> recentLogs = new Queue<string>(); // 保存最近几秒的环形日志。
private Vector3 lastPosition; // 保存上一帧检查位置。
private float stillTimer; // 保存角色不移动的累计时间。
private void Start() // 初始化时执行。
{ // 方法开始。
lastPosition = actor.position; // 记录角色初始位置。
} // 方法结束。
private void Update() // 每帧检测角色是否疑似卡死。
{ // 方法开始。
Record($"t={Time.time:F2}, pos={actor.position}, frame={Time.frameCount}"); // 记录当前时间、位置和帧号。
float distance = Vector3.Distance(actor.position, lastPosition); // 计算角色本帧和上次记录位置的距离。
if (distance < minMoveDistance) stillTimer += Time.deltaTime; // 移动距离很小就累计静止时间。
else stillTimer = 0f; // 角色发生移动就清空静止计时。
lastPosition = actor.position; // 更新上一次位置。
if (stillTimer < stuckSeconds) return; // 静止时间不足阈值就不处理。
ReportStuck(); // 达到阈值后上报卡死信息。
stillTimer = 0f; // 重置静止计时,避免每帧重复上报。
} // 方法结束。
public void Record(string message) // 记录一条环形日志。
{ // 方法开始。
recentLogs.Enqueue(message); // 把新日志放入队列尾部。
while (recentLogs.Count > 120) recentLogs.Dequeue(); // 超过容量就移除最旧日志。
} // 方法结束。
private void ReportStuck() // 上报疑似卡死信息。
{ // 方法开始。
Debug.LogWarning("Combat stuck detected."); // 输出卡死告警。
foreach (string log in recentLogs) // 遍历最近日志。
{ // 循环开始。
Debug.Log(log); // 输出每一条最近日志。
} // 循环结束。
} // 方法结束。
} // 类结束。面试关键句
NOTE
“战斗中偶现角色卡死,我会先加采集,不会只看最后一条报错。我要记录最近几秒输入、状态机、动画、移动、碰撞、技能、Buff、网络帧和服务器校正,再在卡死瞬间抓快照。最好还能保存 battleId、随机种子和输入流,用回放复现。这样才能判断卡在状态机、动画事件、Root Motion、碰撞、NavMesh、技能打断还是网络同步。”
支付成功但道具没到账,怎么定位?
标准答案
支付成功但道具没到账,我会先强调一点:客户端显示支付成功,不等于服务端已经完成发货。最终是否到账,要以服务端验单和发货流水为准。
定位时必须用同一个 orderId 或 transactionId 把链路串起来:
客户端下单 -> SDK 支付 -> 平台支付成功 -> 服务端验单 -> 幂等发货 -> 背包落库 -> 客户端刷新怎么定位
- 先收集订单信息:
uid、orderId、transactionId、商品 ID、金额、渠道、App 版本、资源版本、支付时间。 - 查客户端日志:是否创建订单成功,是否收到 SDK 成功回调,是否把 receipt 或 token 发给服务端。
- 查平台订单:平台侧是否真的支付成功,是否退款、取消、风控、延迟通知。
- 查服务端验单:收据是否有效,金额、商品、账号、区服是否匹配。
- 查订单状态:卡在
Created、Paid、Verified、Granted还是Failed。 - 查发货幂等:同一订单是否已经发过货,是否因为幂等锁或事务失败没发。
- 查背包流水:道具是否写入背包,是否写入邮件,是否因为背包满转邮件失败。
- 查客户端展示:道具可能已到账,但客户端背包没刷新、缓存没更新、红点没同步。
- 如果支付成功但未发货,要走补单系统自动或人工补发。
底层原则
支付发货必须是服务端权威,不能信客户端一句“支付成功”。客户端只能提交凭证,服务端要向平台验单,确认订单真实有效后再发货。
还必须做幂等。因为支付平台可能重复回调,客户端也可能重复请求补单。如果没有幂等,同一笔订单可能重复发货;如果幂等写错,也可能导致订单被标记处理过但实际没发货。
正确状态机一般是:
c
Created -> Paid -> Verified -> Granted如果中途失败,要保留失败原因,并允许补单扫描:
Paid但未Verified:可能验单失败或平台延迟。Verified但未Granted:可能发货事务失败。Granted但玩家看不到:可能客户端刷新或展示问题。- 平台成功但服务端无订单:可能客户端下单回传失败,需要按平台交易号补单。
辅助代码
c
using System; // 引入基础类型。
using System.Collections.Generic; // 引入字典集合。
public sealed class PaymentGrantService // 定义支付发货服务。
{ // 类开始。
private readonly HashSet<string> grantedOrders = new HashSet<string>(); // 保存已经发货的订单号,防止重复发货。
public bool TryGrant(string uid, string orderId, string productId) // 尝试给玩家发货。
{ // 方法开始。
if (string.IsNullOrEmpty(uid)) return false; // 玩家 ID 为空则拒绝发货。
if (string.IsNullOrEmpty(orderId)) return false; // 订单号为空则拒绝发货。
if (string.IsNullOrEmpty(productId)) return false; // 商品 ID 为空则拒绝发货。
if (grantedOrders.Contains(orderId)) return true; // 订单已经发过货,直接返回成功,保证幂等。
bool inventoryOk = AddItemToInventory(uid, productId); // 把商品对应道具写入背包。
if (!inventoryOk) return false; // 背包写入失败则返回失败,等待补单。
grantedOrders.Add(orderId); // 发货成功后记录订单已处理。
return true; // 返回发货成功。
} // 方法结束。
private bool AddItemToInventory(string uid, string productId) // 模拟背包写入方法。
{ // 方法开始。
Console.WriteLine($"Grant product={productId} to uid={uid}"); // 输出发货日志。
return true; // 示例中默认写入成功。
} // 方法结束。
} // 类结束。补单怎么做
补单系统应该定期扫描异常订单:
- 平台支付成功但服务端未发货。
- 服务端验单成功但背包写入失败。
- 客户端反馈未到账但订单已支付。
- 平台回调延迟或丢失的订单。
补单也必须幂等,不能因为玩家点了多次“恢复购买”就发多份。
面试关键句
CAUTION
“支付成功但未到账,我会用订单号贯穿客户端、支付平台、服务端验单、幂等发货、背包流水和客户端展示。客户端成功回调只能作为现象,发货必须以服务端验单为准。定位时看订单状态卡在哪一步,并且补单系统要能处理平台成功但未发货的订单,同时保证同一订单只发一次。”
断线重连后状态错乱,怎么排查?
标准回答
断线重连后状态错乱,我会按“服务端权威状态 → 消息边界 → 客户端重建 → UI 表现”来排查。核心不是先猜哪里错,而是先确认:服务端状态到底对不对;客户端重连时有没有把旧消息、重复消息、本地预测、UI 缓存混在一起。
常见原因
- 服务端快照是对的,但客户端没有清理本地临时状态。
- 重连后先处理了增量消息,后应用快照,顺序反了。
- 断线前的旧包、重复包、乱序包又被处理了一次。
sessionId、seq、tick、snapshotVersion边界不清。- 数据层已经正确,但 UI 没有全量刷新,显示的是旧缓存。
- 对象池复用对象时状态没重置,导致表现错乱。
排查流程
先打日志:traceId、sessionId、lastAckSeq、snapshotSeq、messageSeq、stateHash、roomId、tick。 然后抓完整链路:断线前状态、断线期间本地变化、重连请求、服务端快照、快照后的增量消息、UI 刷新结果。 如果服务端快照正确,客户端应用后错误,重点查本地缓存、旧消息、重复处理。 如果数据层正确但画面错,重点查 UI 绑定、对象池复用、事件漏发。
关键代码思路
c
using UnityEngine; // 引入 Unity 基础 API。
public sealed class ReconnectStateGuard : MonoBehaviour // 定义重连状态保护组件。
{ // 类开始。
private long lastAppliedSeq; // 记录客户端已经处理到的最大消息序号。
private string currentSessionId; // 记录当前有效连接会话 ID。
public void BeginReconnect(string newSessionId) // 开始一次新的重连流程。
{ // 方法开始。
currentSessionId = newSessionId; // 替换为新的会话 ID。
lastAppliedSeq = 0; // 清空旧的消息边界。
ClearTransientState(); // 清理本地预测、旧队列和临时表现状态。
Debug.Log($"Reconnect begin session={currentSessionId}"); // 打印重连开始日志。
} // 方法结束。
public void ApplySnapshot(long snapshotSeq) // 应用服务端全量快照。
{ // 方法开始。
lastAppliedSeq = snapshotSeq; // 用快照序号作为新的同步边界。
RefreshAllViews(); // 快照覆盖数据后刷新所有表现层。
Debug.Log($"Snapshot applied seq={snapshotSeq}"); // 打印快照应用日志。
} // 方法结束。
public bool CanApplyMessage(string sessionId, long messageSeq) // 判断增量消息是否允许处理。
{ // 方法开始。
if (sessionId != currentSessionId) return false; // 丢弃旧会话的消息。
if (messageSeq <= lastAppliedSeq) return false; // 丢弃重复或过期消息。
lastAppliedSeq = messageSeq; // 更新已经处理的消息序号。
return true; // 允许业务层处理这条消息。
} // 方法结束。
private void ClearTransientState() // 清理重连前的临时状态。
{ // 方法开始。
Debug.Log("Clear local prediction and pending messages."); // 输出清理日志。
} // 方法结束。
private void RefreshAllViews() // 刷新 UI 和表现对象。
{ // 方法开始。
Debug.Log("Refresh UI and presentation after reconnect."); // 输出刷新日志。
} // 方法结束。
} // 类结束。面试表达
IMPORTANT
断线重连错乱,本质是“权威状态”和“本地临时状态”的合并边界出了问题。我的排查方法是先拿服务端快照做基准,再用 sessionId + seq + snapshotVersion 过滤旧包和重复包,最后确认客户端数据层和 UI 表现层是否一致。
新版本包体突然变大,怎么查?
标准回答
新版本包体突然变大,我不会先直接删资源,而是先做“版本 Diff”。把上一版和当前版的 APK/AAB/IPA/AssetBundle/Addressables Catalog 拆开对比,先确认变大的是资源、代码库、Shader 变体、首包资源,还是平台构建配置。
排查顺序
- 先比最终产物:看
apk/aab/ipa总大小变化,再拆包看assets/bin/Data/lib/res哪部分变大。 - 看 Unity
BuildReport和Editor.log:找 Top N 大资源。 - 查纹理:是否
Max Size变大,是否从ASTC/ETC2变成RGBA32,是否误开 Read/Write。 - 查音频:长音频是否被设成 Decompress On Load,压缩率是否改了。
- 查 Shader Variant:关键字是否暴涨,变体是否没有裁剪。
- 查资源依赖:公共资源是否被多个 Bundle 重复打包。
- 查分包:热更资源、活动资源、高清资源是否误进首包。
- 查插件和代码:新增 SDK、Native Library、调试符号、架构包是否被带进去了。
底层原理
包体大,本质是“被构建系统收集进最终产物的文件变多或变大”。Unity 会根据场景引用、Resources、Addressables/AssetBundle 规则、插件目录、平台配置,把资源和代码打进包里。 所以排查时要从“构建输入”追到“构建输出”,不要只看 Project 面板里的资源大小。
Unity 工程实践
我会做一个包体巡检表:每次 CI 构建后输出资源大小 Top 表、Bundle 重复依赖表、Shader Variant 数量、首包资源清单。超过阈值就报警,这样能避免“上线前才发现包体大了 300MB”。
简单 Editor 检查脚本
c
using UnityEditor; // 引入 Unity 编辑器 API。
using UnityEditor.Build.Reporting; // 引入构建报告相关 API。
using UnityEngine; // 引入 Unity 日志输出 API。
public static class BuildSizeCheckTool // 定义一个编辑器包体检查工具类。
{ // 类开始。
[MenuItem("Tools/Build/Print Build Size")] // 在 Unity 菜单栏添加工具入口。
public static void PrintBuildSize() // 定义打印构建大小的方法。
{ // 方法开始。
string[] scenes = { "Assets/Scenes/Main.unity" }; // 指定要参与构建的场景路径。
BuildPlayerOptions options = new BuildPlayerOptions(); // 创建构建参数对象。
options.scenes = scenes; // 设置构建场景列表。
options.locationPathName = "Build/TestBuild.apk"; // 设置输出包路径。
options.target = BuildTarget.Android; // 设置构建目标平台为 Android。
options.options = BuildOptions.None; // 使用普通构建选项。
BuildReport report = BuildPipeline.BuildPlayer(options); // 执行构建并拿到构建报告。
long totalBytes = report.summary.totalSize; // 读取最终构建产物大小。
float totalMB = totalBytes / 1024f / 1024f; // 把字节转换成 MB。
Debug.Log($"Build Size = {totalMB:F2} MB"); // 输出最终包体大小。
} // 方法结束。
} // 类结束。面试关键句
NOTE
我会说:包体突然变大,我会先用构建报告和产物 Diff 定位增量来源,再查资源导入设置、重复依赖、Shader 变体、首包分包规则和新增 SDK。最后用 CI 做阈值报警,防止同类问题再次发生。
Shader 在部分机型显示粉色,怎么处理?
标准回答
Shader 在部分机型显示粉色,通常表示这个材质在当前设备上没有可用的 Shader。不是颜色问题,而是 Unity 找不到能成功运行的 SubShader/Pass,或者 Shader 在该平台编译失败、变体被裁剪、资源包里的 Shader 不匹配。
我会按这个顺序排查:
- 先确认是否只在某些机型、某个图形 API、某个资源包、某个材质上出现。
- 用真机日志查
shader compile error、unsupported shader、variant missing。 - 检查 Render Pipeline 是否匹配,比如 URP 项目用了 Built-in Shader。
- 检查 Shader Variant 是否被裁剪,运行时用到的关键字组合有没有进包。
- 检查 AssetBundle 或 Addressables 是否按正确平台构建。
- 检查低端 GPU 是否不支持某些特性,比如高 Shader Model、特殊采样、过多纹理、精度问题。
- 加 fallback 材质或降级 Shader,避免线上直接粉色。
底层原理
一个材质渲染时大概是:
Material -> Shader -> SubShader -> Pass -> 编译成当前平台 GPU 能执行的程序如果 Unity 找不到合适的 SubShader,或者当前平台编译失败,就会用粉色错误材质提示。 “部分机型才粉色”通常说明编辑器或高端机可以编译,但某些移动 GPU、图形 API 或驱动不支持。
Unity 工程处理
项目里我会做两层处理:第一层是构建期检查,把 Shader 编译错误、变体数量、AB 构建平台纳入 CI;第二层是运行期保护,发现 shader.isSupported == false 就替换成兜底 Shader,并上报机型和 Shader 名称。
c
using UnityEngine; // 引入 Unity 运行时 API。
public sealed class ShaderFallbackGuard : MonoBehaviour // 定义 Shader 兜底保护组件。
{ // 类开始。
[SerializeField] private Renderer[] renderers; // 保存需要检查的渲染器数组。
[SerializeField] private Shader fallbackShader; // 保存兜底 Shader。
private void Awake() // 在对象初始化时执行检查。
{ // 方法开始。
if (fallbackShader == null) // 判断是否没有配置兜底 Shader。
{ // 判断开始。
fallbackShader = Shader.Find("Universal Render Pipeline/Lit"); // 尝试查找 URP 默认 Lit Shader。
} // 判断结束。
for (int i = 0; i < renderers.Length; i++) // 遍历所有渲染器。
{ // 外层循环开始。
Renderer target = renderers[i]; // 取出当前渲染器。
if (target == null) continue; // 跳过空渲染器。
Material[] materials = target.sharedMaterials; // 读取该渲染器使用的材质数组。
for (int j = 0; j < materials.Length; j++) // 遍历当前渲染器的所有材质。
{ // 内层循环开始。
Material material = materials[j]; // 取出当前材质。
if (material == null) continue; // 跳过空材质。
Shader shader = material.shader; // 取出当前材质使用的 Shader。
if (shader != null && shader.isSupported) continue; // 如果 Shader 可用就不处理。
Debug.LogError($"Unsupported shader on {target.name}, material = {material.name}"); // 打印错误信息用于定位。
if (fallbackShader != null) material.shader = fallbackShader; // 使用兜底 Shader 避免显示粉色。
} // 内层循环结束。
} // 外层循环结束。
} // 方法结束。
} // 类结束。面试关键句
WARNING
粉色不是单纯材质问题,而是当前平台没有可用 Shader。部分机型出现时,我会优先查真机编译日志、图形 API、Shader 变体裁剪、Render Pipeline 匹配和 AssetBundle 构建目标,最后加 fallback 和机型上报做线上保护。
配置表更新后出现空引用,怎么防止?
标准回答
配置表更新后出现空引用,本质是“配置引用关系断了”,比如怪物表引用了一个技能 skillId,但技能表里这行被删了;或者配置里写了一个资源 Key,但资源包没一起更新。防止方式不是靠运行时报错兜底,而是把配置表当成“小型数据库”处理:发布前做字段校验、ID 外键校验、资源 Key 校验、版本兼容校验,运行时再做 TryGet 和降级保护。
常见原因
- 某行配置被删除,但其他表还引用它。
- 字段改名或新增字段,旧客户端读取不到。
- 配置更新了,但资源包、图标、Prefab、音效没同步更新。
- 热加载时一边读旧表,一边替换新表,产生中间状态。
- 运行时代码直接
dict[id],缺失时直接异常。 - 缓存的旧配置对象没有清理,索引表没有重建。
防止方案
发布前:用工具检查 schema、必填字段、数值范围、枚举合法性、表间 ID 引用、资源 Key 是否存在。 发布时:CI 校验失败直接禁止发布。配置和资源必须有同一套版本号或 Manifest。 运行时:用 TryGetValue,缺配置时上报日志并使用默认配置或阻止进入对应玩法。 热更新:先把新配置加载到临时表,全部校验通过后再一次性替换旧表,不能边加载边生效。 版本兼容:新增字段要有默认值,删除字段要做迁移,老客户端不能读新表崩溃。
代码示例
c
using System.Collections.Generic; // 引入字典和列表容器。
using UnityEngine; // 引入 Unity 日志 API。
public sealed class SkillConfig // 定义技能配置数据。
{ // 技能配置类开始。
public int Id; // 技能唯一 ID。
public string Name; // 技能名称。
} // 技能配置类结束。
public sealed class MonsterConfig // 定义怪物配置数据。
{ // 怪物配置类开始。
public int Id; // 怪物唯一 ID。
public int SkillId; // 怪物引用的技能 ID。
public string PrefabKey; // 怪物引用的资源 Key。
} // 怪物配置类结束。
public static class ConfigValidator // 定义配置表校验工具。
{ // 校验工具类开始。
public static bool Validate( // 定义校验入口。
Dictionary<int, SkillConfig> skills, // 传入技能表索引。
List<MonsterConfig> monsters, // 传入怪物表列表。
HashSet<string> resourceKeys) // 传入资源系统已有 Key 集合。
{ // 方法开始。
bool success = true; // 记录整体校验是否通过。
foreach (MonsterConfig monster in monsters) // 遍历每一条怪物配置。
{ // 循环开始。
if (monster == null) // 判断怪物配置行是否为空。
{ // 判断开始。
Debug.LogError("MonsterConfig row is null."); // 输出空行错误。
success = false; // 标记校验失败。
continue; // 跳过当前空行。
} // 判断结束。
if (!skills.ContainsKey(monster.SkillId)) // 检查怪物引用的技能 ID 是否存在。
{ // 判断开始。
Debug.LogError($"Monster {monster.Id} missing skill {monster.SkillId}."); // 输出缺失技能引用。
success = false; // 标记校验失败。
} // 判断结束。
if (string.IsNullOrEmpty(monster.PrefabKey)) // 检查资源 Key 是否为空。
{ // 判断开始。
Debug.LogError($"Monster {monster.Id} prefab key is empty."); // 输出资源 Key 为空。
success = false; // 标记校验失败。
} // 判断结束。
else if (!resourceKeys.Contains(monster.PrefabKey)) // 检查资源 Key 是否存在。
{ // 判断开始。
Debug.LogError($"Monster {monster.Id} missing prefab {monster.PrefabKey}."); // 输出资源缺失错误。
success = false; // 标记校验失败。
} // 判断结束。
} // 循环结束。
return success; // 返回最终校验结果。
} // 方法结束。
} // 校验工具类结束。面试关键句
TIP
配置表更新后的空引用要在发布前解决,而不是等运行时报错。我的做法是把配置当数据库处理,做 Schema 校验、外键校验、资源 Key 校验、版本兼容校验;客户端热更时先加载到临时表,全部通过后再原子替换,运行时访问配置统一走 TryGet 和上报兜底。
如何设计客户端日志方便线上定位?
标准回答
客户端日志的目标不是“多打几行”,而是线上出问题时能还原现场:哪个玩家、哪个版本、哪个设备、哪个场景、做了什么操作、当时网络和性能状态怎样、请求链路走到哪里失败。
我会把日志设计成结构化系统,而不是散落的 Debug.Log。核心是:结构化字段 + 上下文快照 + 本地缓冲 + 异常触发上传 + 服务端可检索 + 隐私脱敏和限流。
日志里必须带什么
基础字段:uid、roleId、serverId、appVersion、resVersion、channel、deviceModel、osVersion。 场景字段:sceneName、levelId、battleId、frame、time。 链路字段:traceId、requestId、protocolId、seq、errorCode。 状态字段:fps、memory、gcAlloc、rtt、reconnectCount。 业务字段:当前玩法、当前 UI、技能 ID、道具 ID、配置 ID、资源 Key。
底层原理
线上定位最怕只有一句:
c
NullReferenceException这不够。真正有用的是:
玩家 10086,在 Android 低端机,资源版本 1.2.3,进入背包后点击 itemId=3001,加载 icon_key=sword_01 失败,然后 UI 访问空对象。这类日志能把问题从“猜”变成“复现链路”。所以客户端日志要保留最近 N 条关键行为,异常时一起上传。
Unity 工程实践
普通日志不要每条都实时上传,成本太高。一般做本地环形缓冲,保留最近几十到几百条关键日志;遇到异常、卡顿、资源加载失败、支付失败、战斗不同步时,再把上下文批量上传。 同时日志要分级:Debug 本地看,Info 抽样,Warn 批量,Error/Fatal 立即上传。
简单代码示例
c
using System; // 引入 Serializable 特性。
using System.Collections.Generic; // 引入 Queue 容器。
using UnityEngine; // 引入 Unity API。
public enum ClientLogLevel // 定义客户端日志等级。
{ // 枚举开始。
Info, // 普通信息日志。
Warn, // 警告日志。
Error // 错误日志。
} // 枚举结束。
[Serializable] // 允许 JsonUtility 序列化该类。
public sealed class ClientLogEntry // 定义一条结构化日志。
{ // 日志实体开始。
public string level; // 日志等级。
public string module; // 业务模块名。
public string message; // 日志正文。
public string traceId; // 链路追踪 ID。
public string scene; // 当前场景名。
public string version; // 客户端版本号。
public string device; // 设备型号。
public int frame; // 当前帧号。
public float time; // 当前游戏时间。
} // 日志实体结束。
public sealed class OnlineLogger : MonoBehaviour // 定义线上日志管理器。
{ // 类开始。
private const int MaxCachedLogs = 100; // 设置本地最多缓存 100 条日志。
private readonly Queue<ClientLogEntry> recentLogs = new Queue<ClientLogEntry>(); // 创建最近日志环形队列。
public void Log(ClientLogLevel level, string module, string message, string traceId) // 写入一条日志。
{ // 方法开始。
ClientLogEntry entry = new ClientLogEntry(); // 创建日志对象。
entry.level = level.ToString(); // 写入日志等级。
entry.module = module; // 写入模块名。
entry.message = message; // 写入日志内容。
entry.traceId = traceId; // 写入链路 ID。
entry.scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; // 写入当前场景。
entry.version = Application.version; // 写入客户端版本。
entry.device = SystemInfo.deviceModel; // 写入设备型号。
entry.frame = Time.frameCount; // 写入当前帧号。
entry.time = Time.realtimeSinceStartup; // 写入启动后的真实时间。
recentLogs.Enqueue(entry); // 把日志加入本地队列。
if (recentLogs.Count > MaxCachedLogs) recentLogs.Dequeue(); // 超过上限就移除最旧日志。
Debug.Log(JsonUtility.ToJson(entry)); // 本地输出 JSON 格式日志。
if (level == ClientLogLevel.Error) UploadRecentLogs(); // 错误日志触发上下文上传。
} // 方法结束。
private void UploadRecentLogs() // 上传最近日志上下文。
{ // 方法开始。
foreach (ClientLogEntry entry in recentLogs) // 遍历最近缓存的日志。
{ // 循环开始。
string json = JsonUtility.ToJson(entry); // 把日志序列化成 JSON。
Debug.Log($"UploadLog {json}"); // 示例中用 Debug 模拟上传。
} // 循环结束。
} // 方法结束。
} // 类结束。常见坑
不要把日志写成大量自然语言字符串,服务端不好检索。 不要上传密码、Token、手机号等敏感信息。 不要在高频 Update 里疯狂打日志,会影响性能和产生 GC。 不要只记录错误本身,要记录错误前后的关键操作。 不要客户端和服务端各打各的,最好用同一个 traceId/requestId 串起来。
面试关键句
NOTE
客户端日志要服务线上定位,不是越多越好。我会用结构化日志记录用户、版本、设备、场景、链路 ID、性能和网络上下文;本地用环形缓冲保留最近操作,异常时批量上传,并在服务端支持按 uid、traceId、version、device 检索。