Appearance
移动端发布
Android 多渠道包怎么做?
标准答案
Android 多渠道包,就是同一套 Unity 工程,按不同应用商店或渠道生成不同安装包。差异通常包括:渠道号、登录 SDK、支付 SDK、统计 SDK、包名、图标、权限、热更新地址、资源分包策略。
常见做法
项目里一般不会手动复制工程,而是用配置驱动 + 自动化构建:
- 维护一份渠道配置表,比如
official、huawei、xiaomi、oppo。 - Gradle 用
productFlavors或构建脚本生成不同渠道包。 - 把渠道号写进
AndroidManifest的meta-data,或写进assets/channel.json。 - Unity 启动后读取渠道号,上报给服务器。
- 服务器根据渠道号区分登录、支付、统计、热更新和运营活动。
- CI 批量打包,最后校验渠道号、签名、版本号、SDK 是否正确。
Gradle 示例
c
android { // Android Gradle 配置入口
flavorDimensions "channel" // 定义一个名为 channel 的渠道维度
productFlavors { // 开始定义不同渠道包
official { // 官方渠道配置开始
dimension "channel" // 指定官方渠道属于 channel 维度
manifestPlaceholders = [CHANNEL_ID: "official"] // 写入官方渠道号
} // 官方渠道配置结束
huawei { // 华为渠道配置开始
dimension "channel" // 指定华为渠道属于 channel 维度
manifestPlaceholders = [CHANNEL_ID: "huawei"] // 写入华为渠道号
} // 华为渠道配置结束
xiaomi { // 小米渠道配置开始
dimension "channel" // 指定小米渠道属于 channel 维度
manifestPlaceholders = [CHANNEL_ID: "xiaomi"] // 写入小米渠道号
} // 小米渠道配置结束
} // 结束渠道包定义
} // 结束 Android 配置Unity 工程里要注意
渠道 SDK 要用接口隔离,比如 ILoginService、IPayService、IAnalyticsService,业务层不要直接依赖某个渠道 SDK。否则接十几个渠道后,登录和支付代码会变成一堆 if channel == xxx。
面试关键词
多渠道包重点不是“能打出来”,而是:自动化、渠道号可追踪、SDK 隔离、签名统一、服务端识别、热更新分流、上线前校验。最容易出事故的是渠道号错、签名错、SDK 漏接、包名不匹配、支付回调配置错误。
资源分包和首包限制怎么处理?
标准答案
资源分包和首包限制,核心是:首包只放最小可运行内容,其他资源按需下载。首包里保留启动、登录、更新器、基础 UI、基础配置、首场景、公共 Shader、字体和兜底资源;后续关卡、高清贴图、语音、活动资源、非首日玩法都拆到远端包。
底层原理
首包太大,会影响下载转化、安装成功率和渠道审核;但首包太小,又会导致玩家第一次进游戏要下载太多内容。所以要平衡:首包能完成最小闭环,热更包负责扩展内容,Manifest 负责版本、Hash、大小和依赖关系。
Unity 工程实践
通常会这样拆:
Base:启动、登录、更新、Loading、基础配置、公共 UI。Shared:公共 Shader、公共材质、通用贴图、字体、通用音效。Scene_xxx:按场景或地图分包。Feature_xxx:按玩法系统分包,比如副本、PVP、家园。Activity_xxx:限时活动资源,活动结束后可清理。Language_xxx:多语言文本和语音可按语言拆。Quality_xxx:高低清资源按画质档拆。
关键处理点
IMPORTANT
依赖要去重。公共资源不要被多个 Bundle 重复打进去,否则分包后包体反而变大。可以用 Addressables Analyze 或自研依赖分析工具检查重复资源。
下载要有校验。远端 Manifest 里记录资源版本、Hash、大小、依赖列表。客户端下载后校验 Hash,失败就重试,关键资源失败不能进入玩法。
首包限制要按渠道设预算。不同商店、不同地区、不同网络环境对首包接受度不同,所以不能只按技术拆,还要看运营和渠道要求。
常见坑
分包太细会导致请求数过多,加载慢;分包太粗又会导致下载量大。活动资源忘记清理会占本地存储;Shared 包更新会牵连很多资源;Manifest 出错会导致资源版本不一致。所以必须支持灰度、回滚和版本校验。
热更新审核风险是什么?
标准答案
热更新审核风险,核心是平台担心你绕过商店审核。资源热更一般风险较低,比如修贴图、音频、配置、活动文案;代码热更、Lua/IL 热更、远程动态代码加载风险更高,因为它可能在审核通过后改变 App 的功能和行为。
平台规则重点
iOS 风险更高。Apple App Review Guideline 2.5.2 明确要求 App 应该自包含,不能下载、安装或执行会引入或改变功能的代码;2.3.1 也要求不能有隐藏功能,新功能要在审核说明中明确展示。
Google Play 也限制应用自我更新和远程可执行代码。官方政策说明,Google Play 分发的 App 不能从 Play 以外来源下载 dex、JAR、.so 等可执行代码;运行时加载解释语言也不能导致违反 Google Play 政策。
常见审核风险
- 用热更暗开新玩法、新入口、新商业化功能。
- 绕过 App Store / Google Play 支付。
- 远程下发可执行代码,改变核心功能。
- 热更新新增权限、SDK、埋点或隐私采集。
- 下发违规图片、文案、活动、抽奖、涉敏内容。
- 审核包和线上包表现不一致。
- 补丁没有签名校验,被篡改后有安全风险。
- 热更失败后没有回滚,导致大面积崩溃。
Unity 项目里怎么规避
资源热更只做资源、配置、数值、文案、活动开关。重大功能、支付、隐私、权限、SDK 变化走正式商店更新。补丁必须有 Manifest、Hash、签名、灰度、回滚和日志监控。代码热更要限制能力边界,不能让远端脚本随意访问支付、隐私、权限和系统能力。
面试关键词
WARNING
可以这样回答:热更新不是不能做,但要守住审核边界。资源热更相对安全,代码热更要非常谨慎。所有补丁都要可校验、可灰度、可回滚,不能用热更上线审核时没展示的新功能。
iOS 审核常见问题有哪些?
标准答案
iOS 审核常见问题主要集中在:App 不完整、崩溃、元数据不一致、隐私权限不清楚、IAP 不合规、隐藏功能、热更新越界、UGC 缺少审核机制、审核账号不可用。Apple 官方在提交前检查里也明确要求测试崩溃、补全元数据、提供审核账号、保证后端服务可用,并说明不明显功能和 IAP 内容:App Review Guidelines。
游戏项目高频拒审点
- 崩溃和卡死 启动闪退、登录卡死、弱网无响应、切后台再回来异常。审核员真机跑不过去就会拒。
- 审核账号或服务器不可用 需要登录但没给账号,验证码收不到,服务器只对白名单开放,审核员进不了游戏。
- 元数据不一致 截图、商店描述、年龄分级、实际玩法不一致;宣传了未开放功能;截图里有未审核内容。
- IAP 问题 游戏内金币、月卡、礼包、战令、关卡解锁这类数字商品通常要走 Apple IAP。Apple 指南 3.1.1 明确要求用 IAP 解锁 App 内功能或内容。
- 抽卡概率未披露 Loot Box、卡池、随机虚拟物品购买前要披露获得概率,游戏很容易踩这个点。
- 热更新越界 资源热更通常可控,但不能用 Lua/脚本/远程代码绕审核暗开新功能。Apple 2.5.2 对下载、安装、执行会改变功能的代码非常敏感。
- 隐私权限问题 相机、麦克风、相册、定位、ATT 追踪说明必须清楚。隐私标签要和 SDK 实际采集一致。
- UGC 和聊天缺少治理 玩家昵称、头像、聊天、工会公告、评论等 UGC 要有过滤、举报、屏蔽和处理机制。Apple 指南 1.2 明确要求 UGC 有审核和举报能力。
- 登录和账号删除 如果支持账号创建,通常要提供账号删除入口。用了第三方登录,也要注意 Apple 对等登录能力要求。
- 版权和资质 IP、美术、音乐、字体、广告素材、版号、地区资质都可能被追问。
Unity 项目提审清单
IMPORTANT
提审前我会准备:可用审核账号、测试步骤、服务器环境、IAP 商品说明、隐私权限说明、热更新说明、特殊玩法说明、弱网和后台切换测试结果。上线包和审核包必须一致,不能审核时藏功能,审核后远程打开。
SDK 接入会影响哪些模块?
标准答案
NOTE
SDK 接入影响的不是一个“插件调用点”,而是一整条客户端工程链路。 面试里我会这样回答:SDK 接入通常会影响账号登录、支付、实名、防沉迷、广告、分享、推送、埋点、崩溃日志、隐私合规、多渠道打包、资源热更、服务端验签、测试灰度和线上回滚。
核心原则是:业务层不要直接调用渠道 SDK,而是封装一层 SdkFacade / ISdkAdapter。业务只认统一接口,平台差异放到适配层里。
底层原理
SDK 本质是外部平台能力接入客户端。它通常会带来:
- 原生层依赖:Android 的
aar / jar / AndroidManifest,iOS 的framework / plist / entitlements - 生命周期依赖:启动初始化、授权回调、支付回调、Activity / AppDelegate 回调
- 异步回调:登录、支付、分享、广告播放都不是立即返回结果
- 服务端配合:支付验单、登录 token 校验、防作弊风控不能只信客户端
- 合规约束:隐私协议同意前,不能随便初始化采集设备信息的 SDK
Unity 工程实践
项目里我一般会这样拆:
SdkFacade:统一入口,业务只调用它ISdkAdapter:不同渠道的适配接口AndroidSdkAdapter / IOSSdkAdapter / EditorSdkAdapter:平台实现AccountService:处理登录态PaymentService:处理下单、验单、补单AnalyticsService:统一埋点BuildPipeline:处理渠道参数、签名、权限、包名PrivacyService:控制隐私弹窗和 SDK 初始化时机
示例代码
c
using System; // 引入 Action 回调类型。
public enum SdkResultCode // 定义 SDK 调用结果码。
{ // 枚举开始。
Success = 0, // 表示 SDK 调用成功。
Cancel = 1, // 表示用户主动取消。
Failed = 2, // 表示 SDK 调用失败。
Timeout = 3 // 表示 SDK 调用超时。
} // 枚举结束。
public struct SdkLoginResult // 定义登录结果数据。
{ // 结构体开始。
public SdkResultCode Code; // 保存登录结果码。
public string Token; // 保存平台 SDK 返回的登录 token。
public string Error; // 保存错误信息,便于日志定位。
} // 结构体结束。
public interface ISdkAdapter // 定义平台 SDK 适配接口。
{ // 接口开始。
void Init(Action<SdkResultCode> onDone); // 初始化 SDK,并返回初始化结果。
void Login(Action<SdkLoginResult> onDone); // 发起登录,并返回登录结果。
void Pay(string orderId, Action<SdkResultCode> onDone); // 发起支付,并返回支付结果。
} // 接口结束。
public sealed class SdkFacade // 对业务层暴露统一 SDK 入口。
{ // 类开始。
private readonly ISdkAdapter _adapter; // 保存当前平台的 SDK 适配器。
private bool _loginRunning; // 标记登录流程是否正在进行,避免重复点击。
public SdkFacade(ISdkAdapter adapter) // 通过构造函数注入具体平台适配器。
{ // 构造函数开始。
_adapter = adapter; // 保存适配器引用。
} // 构造函数结束。
public void Login(Action<SdkLoginResult> onDone) // 对业务层提供统一登录方法。
{ // 登录方法开始。
if (_loginRunning) return; // 如果正在登录,直接返回,避免重复拉起 SDK。
_loginRunning = true; // 标记登录流程开始。
_adapter.Login(result => // 调用具体平台 SDK,并接收异步回调。
{ // 回调开始。
_loginRunning = false; // 收到回调后恢复登录状态。
onDone?.Invoke(result); // 把标准化后的登录结果返回给业务层。
}); // 回调结束。
} // 登录方法结束。
} // 类结束。常见坑点
- 支付结果不能只看客户端回调,必须服务端验单后发货。
- SDK 回调可能重复、乱序、超时,要做幂等处理。
- 隐私协议同意前,不要初始化会采集设备信息的 SDK。
- 多渠道包不要手工改配置,应该接入自动化构建。
- SDK 升级可能影响权限、包体、启动耗时、审核和崩溃率。
- 接入前要设计降级和回滚,避免线上 SDK 故障拖垮登录或支付。
支付 SDK 接入要注意什么?
标准答案
WARNING
支付 SDK 接入最重要的不是“能不能拉起支付”,而是保证四件事:订单可信、服务端验单、发货幂等、异常可恢复。
面试里我会这样回答:客户端负责展示商品、请求下单、拉起支付 SDK、上传支付凭证和展示结果;但是否支付成功、是否发货,必须由服务端根据平台收据、订单号、交易号校验后决定。客户端回调只能作为“支付流程结束”的信号,不能直接发货。
要注意的点
- 商品配置要一致:客户端商品 ID、后台商品 ID、渠道商品 ID、价格、币种要一致。
- 订单号必须唯一:一次购买对应一个服务端订单,不能只靠本地状态判断。
- 客户端不可信:金额、商品、支付成功状态都不能只信客户端。
- 必须服务端验单:客户端拿到 receipt / transactionId 后交给服务端校验。
- 发货要幂等:同一交易号只能发一次货,重复回调不能重复发奖励。
- 要有补单机制:断网、闪退、支付成功但未到账时,登录或进商城时主动查单。
- 要处理失败状态:取消、失败、超时、SDK 初始化失败都要有明确 UI 和日志。
- 要接入日志埋点:记录 orderId、productId、transactionId、错误码、SDK 回调时间。
- 要考虑合规:虚拟货币、道具、关卡等数字内容通常要遵守平台支付规则;Apple 指南 3.1.1 要求应用内解锁功能或内容通常使用 IAP,Google Play 对应用内数字商品也有支付系统要求和地区例外,具体按当前渠道政策执行。(Apple, Google Play)
Unity 工程实践
c
using System; // 引入 Action 回调类型。
public enum PayState // 定义支付订单状态。
{ // 枚举开始。
Created, // 表示服务端订单已经创建。
Paying, // 表示客户端已经拉起支付 SDK。
Verifying, // 表示客户端正在等待服务端验单。
Granted, // 表示服务端验单成功并且已经发货。
Failed // 表示支付失败、取消或超时。
} // 枚举结束。
public sealed class PayOrder // 定义一笔支付订单。
{ // 类开始。
public string OrderId; // 保存服务端生成的唯一订单号。
public string ProductId; // 保存玩家购买的商品 ID。
public string TransactionId; // 保存平台返回的交易号。
public PayState State; // 保存当前订单状态。
} // 类结束。
public sealed class PaymentService // 定义支付服务。
{ // 类开始。
private bool _isPaying; // 防止玩家连续点击导致重复拉起支付。
public void StartPay(string productId) // 开始购买指定商品。
{ // 方法开始。
if (_isPaying) return; // 如果已有支付流程在跑,直接拒绝重复请求。
_isPaying = true; // 标记支付流程开始。
RequestServerCreateOrder(productId, order => // 先请求服务端创建订单。
{ // 下单回调开始。
order.State = PayState.Paying; // 标记订单进入支付中。
CallPlatformPaySdk(order, receipt => // 拉起平台支付 SDK。
{ // SDK 回调开始。
order.State = PayState.Verifying; // 标记订单进入验单中。
UploadReceiptToServer(order, receipt, success => // 把收据上传给服务端验单。
{ // 验单回调开始。
order.State = success ? PayState.Granted : PayState.Failed; // 根据服务端结果更新状态。
_isPaying = false; // 结束支付流程,允许下一次购买。
RefreshPayResultUI(order); // 刷新支付结果 UI。
}); // 验单回调结束。
}); // SDK 回调结束。
}); // 下单回调结束。
} // 方法结束。
private void RequestServerCreateOrder(string productId, Action<PayOrder> onDone) { } // 示例:请求服务端创建订单。
private void CallPlatformPaySdk(PayOrder order, Action<string> onDone) { } // 示例:调用平台支付 SDK。
private void UploadReceiptToServer(PayOrder order, string receipt, Action<bool> onDone) { } // 示例:上传收据给服务端。
private void RefreshPayResultUI(PayOrder order) { } // 示例:刷新支付成功或失败界面。
} // 类结束。面试加分说法
CAUTION
我不会让客户端支付回调直接发货。正确做法是:客户端拉起 SDK 后拿到交易凭证,把凭证交给服务端,服务端向平台校验,确认订单未发过货后再发奖励。这样能防止本地篡改、重复回调、断网未到账和重复发货问题。
登录 SDK 如何处理回调?
标准答案
登录 SDK 的回调不能直接拿来进游戏。正确处理方式是:把不同渠道 SDK 的回调统一转换成 LoginResult,再派发回 Unity 主线程,然后检查这次回调是不是当前登录请求,最后把 SDK token 交给服务端验证,服务端通过后才进入游戏。
底层原理
登录 SDK 通常是异步回调,可能出现这些情况:
- 成功、失败、取消、超时都要有统一出口。
- 玩家可能重复点击登录,旧回调可能晚于新回调回来。
- SDK 回调线程不一定适合直接操作 Unity UI。
- 客户端拿到的 token 不能直接当游戏登录态。
- 真正可信的是服务端校验平台 token 后返回的游戏 session。
Unity 工程实践
c
using System; // 引入 Action 回调类型。
public enum LoginCode // 定义登录结果码。
{ // 枚举开始。
Success, // 表示平台 SDK 授权成功。
Cancel, // 表示玩家取消登录。
Failed, // 表示平台 SDK 登录失败。
Timeout // 表示登录流程超时。
} // 枚举结束。
public struct LoginResult // 定义标准化登录结果。
{ // 结构体开始。
public LoginCode Code; // 保存登录结果状态。
public string SdkToken; // 保存平台 SDK 返回的 token。
public string Error; // 保存错误信息,方便打日志。
public int RequestId; // 保存本次登录请求 ID。
} // 结构体结束。
public sealed class LoginService // 封装登录 SDK 回调处理。
{ // 类开始。
private int _currentRequestId; // 记录当前有效的登录请求 ID。
private bool _loggingIn; // 标记当前是否正在登录。
public void StartLogin() // 开始登录流程。
{ // 方法开始。
if (_loggingIn) return; // 防止重复点击导致多次拉起 SDK。
_loggingIn = true; // 标记登录流程正在进行。
_currentRequestId++; // 生成新的登录请求 ID。
int requestId = _currentRequestId; // 保存本次请求 ID。
CallSdkLogin(requestId, OnSdkLoginCallback); // 拉起平台 SDK 登录。
} // 方法结束。
private void OnSdkLoginCallback(LoginResult result) // 处理 SDK 登录回调。
{ // 方法开始。
RunOnMainThread(() => // 把回调逻辑切回 Unity 主线程。
{ // 主线程回调开始。
if (result.RequestId != _currentRequestId) return; // 丢弃过期回调。
if (result.Code != LoginCode.Success) // 判断 SDK 是否登录失败。
{ // 失败分支开始。
_loggingIn = false; // 恢复登录按钮可点击状态。
ShowLoginError(result.Error); // 展示失败或取消提示。
return; // 结束失败流程。
} // 失败分支结束。
VerifyTokenOnServer(result.SdkToken, success => // 把 SDK token 发给服务端验证。
{ // 服务端验证回调开始。
_loggingIn = false; // 登录流程结束。
if (success) EnterGame(); // 验证成功后进入游戏。
else ShowLoginError("服务器验证失败"); // 验证失败时提示玩家。
}); // 服务端验证回调结束。
}); // 主线程回调结束。
} // 方法结束。
private void CallSdkLogin(int requestId, Action<LoginResult> callback) { } // 示例:调用平台 SDK 登录。
private void RunOnMainThread(Action action) { action(); } // 示例:派发到 Unity 主线程。
private void VerifyTokenOnServer(string token, Action<bool> callback) { } // 示例:服务端校验 token。
private void ShowLoginError(string error) { } // 示例:显示登录错误。
private void EnterGame() { } // 示例:进入游戏主流程。
} // 类结束。常见坑点
- 不要在 SDK 回调里直接切场景,先确认服务端验证成功。
- 不要信客户端 token,客户端 token 只能作为服务端验签材料。
- 要处理重复回调,用
requestId或状态机过滤旧回调。 - 要处理取消登录,取消不是异常崩溃,UI 要恢复可点击。
- 要处理超时,否则玩家可能卡在“登录中”。
- 要记录渠道名、错误码、耗时、设备信息,方便查登录失败率。
- 回调里更新 UI、加载场景、访问 Unity API,统一回主线程处理。
面试关键词
标准化回调、主线程派发、状态机、requestId、防重复点击、服务端验 token、超时恢复、登录失败埋点。
防沉迷/实名认证如何接入?
标准答案
IMPORTANT
防沉迷/实名认证接入的核心是:登录后先确认玩家身份状态,未实名不能进入游戏;如果是未成年人,就按服务端策略限制登录时段、在线时长和充值行为。客户端负责展示实名 UI、提示和拦截,真正可信的实名校验和防沉迷策略应放在服务端。
国内网络游戏防沉迷规则要求严格落实实名注册登录,未实名用户不能进入游戏;官方通知还规定未成年人网络游戏服务时段限制为周五、周六、周日和法定节假日每日 20:00 至 21:00。实际项目要以发行地区、渠道和最新政策为准。
来源:国家新闻出版署通知。
接入流程
- 玩家登录账号。
- 客户端请求服务端查询实名状态。
- 如果未实名,弹出实名 UI。
- 用户提交姓名和身份证信息。
- 服务端对接实名验证系统,返回成人、未成年或未知状态。
- 客户端根据服务端结果决定是否进入游戏。
- 未成年人进入前、在线中、充值前都要再次经过策略判断。
- 不满足规则时,客户端提示并阻止进入、踢下线或禁止支付。
Unity 工程实践
c
using System; // 引入 Action 回调类型。
public enum RealNameState // 定义实名状态。
{ // 枚举开始。
Unknown, // 表示实名状态未知。
NotVerified, // 表示玩家尚未实名。
Adult, // 表示玩家已实名且为成年人。
Minor, // 表示玩家已实名且为未成年人。
Blocked // 表示当前策略禁止进入游戏。
} // 枚举结束。
public sealed class AntiAddictionService // 定义防沉迷服务。
{ // 类开始。
private RealNameState _state; // 保存当前账号的实名和防沉迷状态。
public void CheckBeforeEnterGame(Action<bool> onDone) // 进入游戏前检查是否允许。
{ // 方法开始。
QueryStateFromServer(state => // 向服务端查询实名和防沉迷状态。
{ // 查询回调开始。
_state = state; // 保存服务端返回的状态。
if (state == RealNameState.NotVerified) // 判断玩家是否未实名。
{ // 未实名分支开始。
ShowRealNamePanel(); // 弹出实名认证界面。
onDone(false); // 暂时不允许进入游戏。
return; // 结束当前流程。
} // 未实名分支结束。
if (state == RealNameState.Blocked) // 判断当前是否被防沉迷策略拦截。
{ // 拦截分支开始。
ShowBlockedTip(); // 显示禁止进入或超时提示。
onDone(false); // 不允许进入游戏。
return; // 结束当前流程。
} // 拦截分支结束。
onDone(true); // 允许进入游戏。
}); // 查询回调结束。
} // 方法结束。
public void CheckBeforePay(Action<bool> onDone) // 支付前检查是否允许充值。
{ // 方法开始。
QueryPayPermissionFromServer(allowed => // 向服务端查询充值权限。
{ // 查询回调开始。
if (!allowed) ShowPayBlockedTip(); // 如果不允许充值,显示拦截提示。
onDone(allowed); // 把充值权限返回给商城逻辑。
}); // 查询回调结束。
} // 方法结束。
private void QueryStateFromServer(Action<RealNameState> callback) { } // 示例:查询实名和防沉迷状态。
private void QueryPayPermissionFromServer(Action<bool> callback) { } // 示例:查询充值权限。
private void ShowRealNamePanel() { } // 示例:显示实名认证界面。
private void ShowBlockedTip() { } // 示例:显示禁止游戏提示。
private void ShowPayBlockedTip() { } // 示例:显示禁止充值提示。
} // 类结束。常见坑点
- 不要在客户端本地计算年龄后直接放行。
- 不要相信本机时间,防沉迷时段判断应使用服务端时间。
- 未实名不能通过游客模式绕过。
- 充值入口也要接防沉迷策略,不是只限制登录。
- 在线过程中要有心跳检查,跨到禁玩时段要能提示并退出。
- 身份证、姓名属于敏感信息,要最小化采集、加密传输,客户端不要长期保存明文。
- 认证失败、网络失败、服务端异常时要保守处理,不要默认放行。
崩溃上报 SDK 怎么用?
标准答案
NOTE
崩溃上报 SDK 的作用是:在游戏崩溃或发生关键异常时,自动收集堆栈、设备信息、版本信息、日志上下文,并上传到后台,方便开发按版本、机型、渠道和堆栈聚合定位问题。
面试里不能只说“接了 Bugly / Crashlytics / Sentry”。更完整的说法是:我会在启动阶段初始化崩溃 SDK,设置用户、版本、渠道、场景等上下文,运行中记录关键面包屑日志,打包时上传符号表,线上根据崩溃率和影响用户数决定修复优先级。
使用流程
- 游戏启动时尽早初始化 SDK。
- 设置版本号、渠道号、用户 ID、服务器 ID、角色 ID。
- 在关键流程写入上下文,比如登录、切场景、加载资源、进入战斗。
- 捕获 C# 未处理异常,也记录非致命异常。
- IL2CPP / Native 崩溃要上传符号表,否则堆栈可能只有地址。
- 后台按版本、机型、渠道、系统版本、堆栈聚合。
- 修复后发版,再观察崩溃率是否下降。
Unity 工程实践
c
using System; // 引入异常和回调相关类型。
using UnityEngine; // 引入 Unity 日志和设备信息 API。
public static class CrashReportService // 定义崩溃上报服务。
{ // 类开始。
public static void Init(string userId, string channel) // 初始化崩溃上报。
{ // 方法开始。
InitCrashSdk(); // 初始化具体的第三方崩溃 SDK。
SetUserId(userId); // 设置用户 ID,方便定位具体账号问题。
SetCustomKey("channel", channel); // 设置渠道号,方便区分不同渠道包。
SetCustomKey("version", Application.version); // 设置应用版本号,方便按版本聚合。
SetCustomKey("device", SystemInfo.deviceModel); // 设置设备型号,方便定位机型问题。
Application.logMessageReceived += OnUnityLog; // 监听 Unity 日志和异常。
} // 方法结束。
public static void SetScene(string sceneName) // 设置当前场景上下文。
{ // 方法开始。
SetCustomKey("scene", sceneName); // 把当前场景写入崩溃上下文。
AddBreadcrumb("EnterScene:" + sceneName); // 写入一条面包屑日志。
} // 方法结束。
public static void ReportNonFatal(Exception exception) // 手动上报非致命异常。
{ // 方法开始。
AddBreadcrumb("NonFatal:" + exception.GetType().Name); // 记录异常类型。
UploadException(exception); // 调用 SDK 上报异常。
} // 方法结束。
private static void OnUnityLog(string condition, string stackTrace, LogType type) // 处理 Unity 日志回调。
{ // 方法开始。
if (type == LogType.Exception) // 判断是否是异常日志。
{ // 异常分支开始。
SetCustomKey("last_exception", condition); // 记录最后一次异常信息。
AddBreadcrumb("UnityException"); // 写入异常面包屑。
} // 异常分支结束。
} // 方法结束。
private static void InitCrashSdk() { } // 示例:初始化第三方 SDK。
private static void SetUserId(string userId) { } // 示例:设置用户 ID。
private static void SetCustomKey(string key, string value) { } // 示例:设置自定义字段。
private static void AddBreadcrumb(string message) { } // 示例:记录关键操作面包屑。
private static void UploadException(Exception exception) { } // 示例:手动上报异常。
} // 类结束。关键注意点
- 只接 SDK 不够,必须上传符号表。
- 线上包的版本号、渠道号、资源版本要能对应到构建产物。
- 日志不能上传密码、token、身份证、手机号等敏感明文。
- 面包屑日志要短,只记录关键路径,避免上报包过大。
- 非致命异常要限流,否则可能刷爆后台。
- 崩溃修复要看数据闭环,比如 crash free rate、影响用户数、版本趋势。
- IL2CPP 下 Native 崩溃经常需要符号化后才能看懂真实函数名。
线上灰度发布怎么做?
标准答案
线上灰度发布就是:新版本、新资源或新功能不要一次性全量开放,而是先放给一小部分用户,观察崩溃率、登录成功率、支付、资源下载、卡顿等指标,稳定后再逐步扩大;一旦异常,要能快速关闭开关或回滚版本。
在游戏客户端里,灰度通常不是单独发一个包,而是组合使用:应用商店分阶段发布、资源 Manifest 灰度、远程配置灰度、功能开关灰度、服务端接口灰度。
完整流程
- 先准备可回滚产物:客户端包、资源包、配置、服务端版本。
- 定义灰度人群:白名单、渠道、地区、设备、UID 百分比。
- 用稳定分桶:比如
uidHash % 100 < grayPercent,不要每次随机。 - 客户端启动时拉取灰度策略。
- 根据策略决定是否启用新功能、下载新资源、连接新接口。
- 观察指标:崩溃率、登录失败率、下载失败率、卡顿、支付转化。
- 指标稳定后扩大比例:1% -> 5% -> 20% -> 50% -> 全量。
- 指标异常时熔断:关功能开关、回滚 Manifest、切回旧服务端策略。
Unity 工程实践
c
using System; // 引入 Math 和基础类型。
public sealed class GrayReleaseService // 定义灰度发布判断服务。
{ // 类开始。
private readonly int _grayPercent; // 保存灰度比例,例如 5 表示 5% 用户。
public GrayReleaseService(int grayPercent) // 通过构造函数传入灰度比例。
{ // 构造函数开始。
_grayPercent = Math.Clamp(grayPercent, 0, 100); // 限制灰度比例必须在 0 到 100 之间。
} // 构造函数结束。
public bool IsInGrayGroup(string userId) // 判断当前用户是否命中灰度。
{ // 方法开始。
if (string.IsNullOrEmpty(userId)) return false; // 没有用户 ID 时不进入灰度。
int hash = Math.Abs(userId.GetHashCode()); // 根据用户 ID 计算稳定哈希值。
int bucket = hash % 100; // 把用户稳定分到 0 到 99 的桶里。
return bucket < _grayPercent; // 判断桶编号是否落在灰度比例内。
} // 方法结束。
public bool ShouldEnableFeature(string userId, bool remoteSwitch) // 判断功能是否开启。
{ // 方法开始。
if (!remoteSwitch) return false; // 远程总开关关闭时直接禁用功能。
return IsInGrayGroup(userId); // 总开关开启时再判断用户是否命中灰度。
} // 方法结束。
} // 类结束。面试重点
灰度发布一定要讲“闭环”: 不是发出去就完了,而是要有分桶、开关、监控、告警、回滚、复盘。
项目里我会把灰度能力拆成几层:
- 配置层:远程配置控制灰度比例、白名单、功能开关。
- 资源层:Manifest 指向不同资源版本,异常时切回旧 Manifest。
- 业务层:入口、活动、技能、玩法用 feature flag 控制。
- 服务端层:协议和数据结构保持兼容,新老客户端都能跑。
- 监控层:按版本、渠道、机型、灰度组统计指标。
- 回滚层:保留旧包、旧资源、旧配置,支持快速恢复。
常见坑点
TIP
- 每次随机灰度用户,导致玩家今天有功能、明天没功能。
- 只灰度客户端,不考虑服务端协议兼容。
- 新资源发布后立刻删除旧资源,导致回滚失败。
- 没有 kill switch,线上出问题只能重新发包。
- 只看崩溃,不看登录、支付、下载失败和卡顿。
- 灰度组和对照组不区分,无法证明问题是不是新版本造成的。
- 代码热更和动态下发逻辑要注意平台审核边界,不能绕过平台规则。
一句话总结
线上灰度发布 = 稳定分流 + 可版本化产物 + 实时监控 + 快速回滚。 面试里把这四个点讲清楚,就比单纯说“先发 10% 用户”扎实很多。