Skip to content

线上问题

线上某机型崩溃率很高,怎么处理?

unity-device-specific-crash-rate-handling

标准答案

线上某机型崩溃率很高,我会先做两件事:线上止血证据定位并行。不能等完全定位后才处理,因为崩溃已经影响玩家。

处理顺序是:

  1. 先看崩溃平台数据,确认是不是某个机型、系统版本、GPU、渠道、资源版本集中异常。
  2. 立刻做止血:远程配置关闭高风险功能、降低画质、关闭后处理/特效、回滚热更资源。
  3. 拉取崩溃日志,Android 看 logcattombstone、native crash;iOS 看崩溃日志和 dSYM 符号化结果。
  4. 按调用栈聚合,判断是 Unity C# 异常、IL2CPP native 崩溃、OOM、GPU 驱动、SDK、资源加载还是系统兼容问题。
  5. 找同机型真机复现,保证同包体、同资源版本、同配置、同玩法路径。
  6. 修复后灰度发布,观察该机型崩溃率、Crash-free、留存和投诉是否恢复。

常见根因

某机型崩溃率高,通常是兼容性或资源压力问题:

  • 低内存机型加载峰值过高,触发 OOM。
  • 某 GPU 驱动对 Shader、RenderTexture 格式、后处理不兼容。
  • 特定 Android 系统版本 WebView、SDK、权限、JNI 崩溃。
  • IL2CPP native 崩溃,需要符号表还原调用栈。
  • 该机型不支持某纹理压缩格式或图形 API。
  • 资源包损坏、热更版本不一致、AB 依赖缺失。
  • 后台切前台、切场景、释放技能这类路径触发 native crash。
  • 低端机发热降频后内存或线程调度问题更明显。

排查重点

我会重点补充这些上下文,否则只有调用栈很难定位:

  • deviceModel
  • operatingSystem
  • graphicsDeviceName
  • graphicsDeviceType
  • systemMemorySize
  • processorType
  • appVersion
  • resourceVersion
  • 当前场景
  • 最后一次 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 和崩溃率是否下降。”

玩家反馈资源下载失败,怎么排查?

unity-resource-download-failure-diagnosis

标准答案

玩家反馈资源下载失败,我会先拿到失败上下文,再按链路分层排查。不要只看一句“下载失败”,要明确失败发生在:拉 Manifest、请求 CDN、下载文件、写入本地、Hash 校验、替换缓存、加载资源中的哪一步。

排查顺序

  1. 收集客户端日志:用户 ID、机型、系统、网络类型、地区、渠道、App 版本、资源版本、URL、状态码、错误码、重试次数。
  2. 查 Manifest:资源版本、Hash、文件大小、依赖关系是否正确。
  3. 查 CDN:是否 403、404、超时、证书错误、DNS 解析失败、节点缓存未刷新。
  4. 查客户端落盘:磁盘空间、读写权限、缓存目录、临时文件、断点续传文件是否损坏。
  5. 查校验:下载大小是否一致,Hash 是否一致,失败后是否删除坏文件。
  6. 查弱网:是否支持超时重试、断点续传、备用 CDN、失败恢复。
  7. 查发布流程:是否资源上传不完整、Manifest 先发了但资源没同步完、灰度版本拿到错误配置。
  8. 修复后观察资源下载失败率、重试成功率、启动卡死率和客服反馈。

常见原因

  • 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,再看网络、证书、权限、磁盘、断点续传和校验。修复后要看下载失败率、重试成功率和是否还有坏缓存残留。”

热更新后部分玩家进不去,怎么回滚?

unity-hot-update-partial-login-rollback

标准答案

热更新后部分玩家进不去,我会先做线上止血,再做回滚。重点是:回滚不是简单删除 CDN 上的新文件,而是让客户端重新拿到稳定版本的 Manifest,并处理已经缓存坏补丁的玩家。

处理顺序

  1. 先冻结发布:暂停继续灰度、暂停全量推送。
  2. 远程配置止血:关闭新功能入口、关闭高风险玩法、禁用坏资源或坏脚本。
  3. 确认影响范围:App 版本、热更版本、资源版本、渠道、机型、地区、灰度分组。
  4. 服务端把热更 Manifest 指回上一稳定版本。
  5. 如果部分玩家已经下载坏补丁,要发布更高 revision 的修复 Manifest,让客户端覆盖坏缓存。
  6. 如果是资源坏包,要重新下发正确 Hash 和正确依赖。
  7. 如果是 Lua/脚本热更错误,要回滚脚本包或发修复脚本包。
  8. 如果底包更新器本身崩溃,热更救不了,只能临时配置止血并准备新包。
  9. 灰度回滚后观察登录成功率、崩溃率、下载失败率。
  10. 稳定后再扩大回滚范围或重新发布修复版本。

底层原理

热更新通常依赖一个版本清单:

客户端启动 -> 拉 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,确保已经缓存坏补丁的玩家也会重新拉正确资源。回滚后必须灰度验证登录成功率、崩溃率和下载失败率,最后复盘补上预检、灰度、兼容矩阵和回滚演练。”

战斗中偶现角色卡死,怎么收集信息?

unity-combat-character-stuck-info-collection

标准答案

战斗中偶现角色卡死,第一步不是猜“动画问题”或“碰撞问题”,而是先设计信息采集。偶现问题必须记录卡死前几秒发生了什么,否则线上日志只会看到“最后角色不动了”,很难定位。

我会采集三类信息:环形时间线、卡死瞬间快照、可复现输入

需要收集什么

角色状态:

  • 当前 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、技能打断还是网络同步。”

支付成功但道具没到账,怎么定位?

unity-payment-success-item-missing-diagnosis

标准答案

支付成功但道具没到账,我会先强调一点:客户端显示支付成功,不等于服务端已经完成发货。最终是否到账,要以服务端验单和发货流水为准。

定位时必须用同一个 orderIdtransactionId 把链路串起来:

客户端下单 -> SDK 支付 -> 平台支付成功 -> 服务端验单 -> 幂等发货 -> 背包落库 -> 客户端刷新

怎么定位

  1. 先收集订单信息:uidorderIdtransactionId、商品 ID、金额、渠道、App 版本、资源版本、支付时间。
  2. 查客户端日志:是否创建订单成功,是否收到 SDK 成功回调,是否把 receipt 或 token 发给服务端。
  3. 查平台订单:平台侧是否真的支付成功,是否退款、取消、风控、延迟通知。
  4. 查服务端验单:收据是否有效,金额、商品、账号、区服是否匹配。
  5. 查订单状态:卡在 CreatedPaidVerifiedGranted 还是 Failed
  6. 查发货幂等:同一订单是否已经发过货,是否因为幂等锁或事务失败没发。
  7. 查背包流水:道具是否写入背包,是否写入邮件,是否因为背包满转邮件失败。
  8. 查客户端展示:道具可能已到账,但客户端背包没刷新、缓存没更新、红点没同步。
  9. 如果支付成功但未发货,要走补单系统自动或人工补发。

底层原则

支付发货必须是服务端权威,不能信客户端一句“支付成功”。客户端只能提交凭证,服务端要向平台验单,确认订单真实有效后再发货。

还必须做幂等。因为支付平台可能重复回调,客户端也可能重复请求补单。如果没有幂等,同一笔订单可能重复发货;如果幂等写错,也可能导致订单被标记处理过但实际没发货。

正确状态机一般是:

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

“支付成功但未到账,我会用订单号贯穿客户端、支付平台、服务端验单、幂等发货、背包流水和客户端展示。客户端成功回调只能作为现象,发货必须以服务端验单为准。定位时看订单状态卡在哪一步,并且补单系统要能处理平台成功但未发货的订单,同时保证同一订单只发一次。”

断线重连后状态错乱,怎么排查?

unity-reconnect-state-disorder-diagnosis

标准回答

断线重连后状态错乱,我会按“服务端权威状态 → 消息边界 → 客户端重建 → UI 表现”来排查。核心不是先猜哪里错,而是先确认:服务端状态到底对不对;客户端重连时有没有把旧消息、重复消息、本地预测、UI 缓存混在一起。

常见原因

  1. 服务端快照是对的,但客户端没有清理本地临时状态。
  2. 重连后先处理了增量消息,后应用快照,顺序反了。
  3. 断线前的旧包、重复包、乱序包又被处理了一次。
  4. sessionIdseqticksnapshotVersion 边界不清。
  5. 数据层已经正确,但 UI 没有全量刷新,显示的是旧缓存。
  6. 对象池复用对象时状态没重置,导致表现错乱。

排查流程

先打日志:traceIdsessionIdlastAckSeqsnapshotSeqmessageSeqstateHashroomIdtick。 然后抓完整链路:断线前状态、断线期间本地变化、重连请求、服务端快照、快照后的增量消息、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 表现层是否一致。

新版本包体突然变大,怎么查?

unity-package-size-sudden-increase-diagnosis

标准回答

新版本包体突然变大,我不会先直接删资源,而是先做“版本 Diff”。把上一版和当前版的 APK/AAB/IPA/AssetBundle/Addressables Catalog 拆开对比,先确认变大的是资源、代码库、Shader 变体、首包资源,还是平台构建配置。

排查顺序

  1. 先比最终产物:看 apk/aab/ipa 总大小变化,再拆包看 assets/bin/Data/lib/res 哪部分变大。
  2. 看 Unity BuildReportEditor.log:找 Top N 大资源。
  3. 查纹理:是否 Max Size 变大,是否从 ASTC/ETC2 变成 RGBA32,是否误开 Read/Write。
  4. 查音频:长音频是否被设成 Decompress On Load,压缩率是否改了。
  5. 查 Shader Variant:关键字是否暴涨,变体是否没有裁剪。
  6. 查资源依赖:公共资源是否被多个 Bundle 重复打包。
  7. 查分包:热更资源、活动资源、高清资源是否误进首包。
  8. 查插件和代码:新增 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 在部分机型显示粉色,怎么处理?

unity-shader-pink-on-some-devices-diagnosis

标准回答

Shader 在部分机型显示粉色,通常表示这个材质在当前设备上没有可用的 Shader。不是颜色问题,而是 Unity 找不到能成功运行的 SubShader/Pass,或者 Shader 在该平台编译失败、变体被裁剪、资源包里的 Shader 不匹配。

我会按这个顺序排查:

  1. 先确认是否只在某些机型、某个图形 API、某个资源包、某个材质上出现。
  2. 用真机日志查 shader compile errorunsupported shadervariant missing
  3. 检查 Render Pipeline 是否匹配,比如 URP 项目用了 Built-in Shader。
  4. 检查 Shader Variant 是否被裁剪,运行时用到的关键字组合有没有进包。
  5. 检查 AssetBundle 或 Addressables 是否按正确平台构建。
  6. 检查低端 GPU 是否不支持某些特性,比如高 Shader Model、特殊采样、过多纹理、精度问题。
  7. 加 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 和机型上报做线上保护。

配置表更新后出现空引用,怎么防止?

unity-config-update-null-reference-prevention

标准回答

配置表更新后出现空引用,本质是“配置引用关系断了”,比如怪物表引用了一个技能 skillId,但技能表里这行被删了;或者配置里写了一个资源 Key,但资源包没一起更新。防止方式不是靠运行时报错兜底,而是把配置表当成“小型数据库”处理:发布前做字段校验、ID 外键校验、资源 Key 校验、版本兼容校验,运行时再做 TryGet 和降级保护。

常见原因

  1. 某行配置被删除,但其他表还引用它。
  2. 字段改名或新增字段,旧客户端读取不到。
  3. 配置更新了,但资源包、图标、Prefab、音效没同步更新。
  4. 热加载时一边读旧表,一边替换新表,产生中间状态。
  5. 运行时代码直接 dict[id],缺失时直接异常。
  6. 缓存的旧配置对象没有清理,索引表没有重建。

防止方案

发布前:用工具检查 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 和上报兜底。

如何设计客户端日志方便线上定位?

unity-client-log-online-diagnosis-design

标准回答

客户端日志的目标不是“多打几行”,而是线上出问题时能还原现场:哪个玩家、哪个版本、哪个设备、哪个场景、做了什么操作、当时网络和性能状态怎样、请求链路走到哪里失败。

我会把日志设计成结构化系统,而不是散落的 Debug.Log。核心是:结构化字段 + 上下文快照 + 本地缓冲 + 异常触发上传 + 服务端可检索 + 隐私脱敏和限流

日志里必须带什么

基础字段:uidroleIdserverIdappVersionresVersionchanneldeviceModelosVersion。 场景字段:sceneNamelevelIdbattleIdframetime。 链路字段:traceIdrequestIdprotocolIdseqerrorCode。 状态字段:fpsmemorygcAllocrttreconnectCount。 业务字段:当前玩法、当前 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、性能和网络上下文;本地用环形缓冲保留最近操作,异常时批量上传,并在服务端支持按 uidtraceIdversiondevice 检索。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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