Appearance
Android / iOS 基础
Android APK 和 AAB 区别是什么?
标准答案
APK 是 Android 的可安装、可执行包;AAB 是 Android 的发布包格式。 简单说:
- APK:给设备安装用,
adb install xxx.apk可以直接装。 - AAB:给应用商店上传用,不能直接安装;商店会根据设备生成对应 APK 或 Split APK。
Android 官方也明确说:APK 是 Android 可安装、可执行格式;AAB 只用于发布,必须由分发平台处理成 APK 后才能安装到设备上。Android Developers
底层区别
APK 通常像一个“完整安装包”,里面可能包含:
- 多种 CPU 架构库:
arm64-v8a、armeabi-v7a - 多种屏幕密度资源:
hdpi、xhdpi、xxhdpi - 多语言资源
- DEX 代码
- Manifest
- native so
- assets / resources
问题是:用户设备可能只需要其中一部分。
AAB 的思路是把应用拆成模块和配置资源,然后让商店根据设备生成更合适的包。比如设备是 arm64-v8a、中文、xxhdpi,就只下这些需要的内容。官方文档里也说明,Google Play 会从 AAB 生成 base APK、feature APK、configuration APK 等 Split APK。Android Developers
对比
| 对比点 | APK | AAB |
|---|---|---|
| 本质 | 安装包 | 发布包 |
| 能否直接安装 | 可以 | 不可以 |
| 谁处理它 | Android 系统 | 应用商店或 bundletool |
| 用户最终拿到 | APK | 由 AAB 生成的 APK / Split APK |
| 包体 | 可能包含多设备资源 | 可按设备裁剪 |
| 测试 | 方便本地安装 | 需要生成 APK 后测试 |
| 发布 | 可用于部分渠道 | Google Play 新应用主要使用 AAB |
Unity 项目里怎么选
开发阶段:
- 快速真机测试,用 APK 更方便。
- 可以直接
adb install。 - 适合本地验证功能、性能、崩溃。
发布 Google Play:
- 通常构建 AAB。
- Google Play 从 2021 年 8 月开始要求新应用使用 Android App Bundle 发布。Play Console Help
- 大资源还要考虑 Play Asset Delivery、Addressables、AssetBundle 分包策略。
国内渠道:
- 很多渠道仍然接收 APK。
- 有些渠道对加固、渠道 SDK、签名流程有自己的要求。
- 所以项目里经常同时维护 APK 和 AAB 构建流程。
面试加分句
TIP
“APK 是安装格式,AAB 是发布格式。AAB 的价值是让商店根据设备配置生成更小的 Split APK,减少用户下载体积。但它不能直接安装,测试时要通过 bundletool 或商店测试轨道生成可安装 APK。Unity 项目里本地调试常用 APK,上 Google Play 通常用 AAB,同时还要结合资源分包和热更新策略控制首包大小。”
iOS 包和 Android 包资源压缩有什么差异?
标准答案
iOS 包和 Android 包资源压缩的差异,不能只看 IPA 和 APK/AAB 外层容器,更要看资源本身的压缩格式。在 Unity 项目里,真正影响包体、运行内存、加载速度和 GPU 性能的,通常是贴图、音频、AssetBundle、ABI、分包策略这些内层资源。
一句话:iOS 设备更收敛,压缩格式选择相对统一;Android 设备碎片化更严重,需要兼顾 GPU 格式、ABI、渠道和分包。
底层区别
安装包外层:
- iOS 输出
IPA,本质上也是压缩归档。 - Android 输出
APK或AAB,APK 可直接安装,AAB 由商店生成 Split APK。
但外层压缩不能替代资源压缩。比如一张贴图如果已经是 ASTC、ETC2、PVRTC 这种 GPU 压缩格式,再用 ZIP 压缩,收益通常有限。因为资源本身已经被压缩过了。
贴图压缩差异
iOS:
- 现代 iPhone / iPad 通常优先考虑
ASTC。 - 老设备可能会考虑
PVRTC。 - 设备范围相对收敛,兼容矩阵比 Android 简单。
- Metal 环境下格式支持更可控。
Android:
- 设备碎片化明显。
- 现代中高端设备通常支持
ASTC。 - 较老或低端设备可能依赖
ETC2或ETC1。 - 不同 GPU、系统版本、渠道设备差异更大。
- 如果目标机型复杂,可能要准备多套纹理包。
常见策略:
- 高端 Android:ASTC。
- 普通 Android:ETC2。
- 很老的 Android:ETC1,但 ETC1 不支持 Alpha,需要额外处理透明通道。
- iOS:优先 ASTC,特殊兼容再考虑 PVRTC。
音频压缩差异
音频上平台差异没有贴图那么强,但策略仍然要区分用途:
- BGM:通常压缩并流式加载。
- 短音效:可以 Decompress On Load 或驻留内存,减少播放延迟。
- 语音:看是否频繁播放,通常压缩存储,按需加载。
Unity 里更重要的是按用途设置:
Load TypeCompression FormatQuality- 是否 Preload
- 是否 Streaming
AssetBundle / Addressables 差异
Unity 的 AssetBundle 是平台相关的。 iOS 构建出来的 AssetBundle,不能拿给 Android 用;Android 的 Bundle 也不能给 iOS 用。
原因包括:
- 贴图压缩格式不同。
- Shader 编译目标不同。
- 平台序列化数据可能不同。
- Native 插件和资源路径策略不同。
所以热更新系统通常要维护:
c
iOS Manifest
Android Manifest
iOS AssetBundle
Android AssetBundleAndroid 更复杂的点
Android 包体压缩更复杂,主要因为:
- ABI 多:
arm64-v8a、armeabi-v7a。 - 屏幕密度多:hdpi、xhdpi、xxhdpi。
- GPU 格式支持差异大。
- 渠道包要求不同。
- Google Play 用 AAB,国内渠道常用 APK。
- 大资源可能要结合 Play Asset Delivery 或自研热更新。
Unity 工程实践
我会这样处理:
- iOS 和 Android 分别设置 Texture Override。
- 贴图优先选 GPU 原生压缩格式,避免运行时解压成 RGBA32。
- Android 根据目标机型决定 ASTC、ETC2 或多纹理包。
- AssetBundle / Addressables 按平台独立构建和维护 Manifest。
- 首包只放启动必要资源,大资源放热更或分包。
- BGM、语音、短音效按使用场景设置不同压缩和加载方式。
- 用 Build Report 或 Addressables Analyze 检查资源占比。
- 真机验证内存、加载时间、发热,而不是只看包体大小。
面试加分句
NOTE
“iOS 和 Android 的资源压缩差异,本质上是平台能力和设备碎片化差异。iOS 设备更收敛,ASTC 策略更容易统一;Android 要考虑 GPU 纹理格式、ABI、屏幕密度、渠道和 AAB/Split APK。Unity 项目里不能只看安装包外层压缩,更要看贴图是否是 GPU 原生压缩、AssetBundle 是否按平台构建、首包和热更包是否拆得合理。”
Android IL2CPP 打包为什么慢?
标准答案
Android IL2CPP 打包慢,核心原因是它不是把 C# 直接打进 APK,而是多走了一条很长的原生编译链路:
C# -> IL -> IL2CPP 生成 C++ -> Android NDK 编译 -> 链接 native so -> Gradle 打包 APK/AAB所以慢点主要在:
- IL2CPP 要扫描 IL、元数据、泛型、反射相关信息。
- IL2CPP 会生成大量 C++ 文件。
- Android NDK 要用 clang 编译这些 C++。
- 还要链接成
libil2cpp.so。 - 如果勾选多个 ABI,会分别编译多份。
- Release 构建还有代码裁剪、优化、符号处理、签名和压缩。
底层原理
Mono/JIT 模式下,C# IL 可以在运行时由虚拟机解释或 JIT 编译。 IL2CPP 是 AOT 方案,运行前就要把托管代码提前转成原生代码。
Android IL2CPP 大致流程是:
- Unity 编译 C# 脚本,生成 IL 程序集。
- Managed Stripping 做托管代码裁剪。
- IL2CPP 读取 IL 和元数据。
- 处理泛型、虚调用、反射元数据、类型信息。
- 生成 C++ 源文件。
- Android NDK 编译 C++。
- 链接成
libil2cpp.so。 - Gradle 打包资源、native so、assets。
- 签名输出 APK 或 AAB。
这里最重的不是“压缩 APK”,而是C++ 生成 + C++ 编译链接。
为什么 Android 特别明显
- 多 ABI 成本 如果同时构建
arm64-v8a和armeabi-v7a,很多原生编译和链接工作要做两遍。 - C++ 编译本来就慢 C++ 编译、模板展开、优化、链接都比普通 C# 编译重。
- 项目代码越多越慢 程序集多、泛型多、反射多、第三方 SDK 多,都会增加 IL2CPP 处理量。
- Release 优化更慢 Release 下可能启用更高优化、符号处理、代码裁剪,构建时间更长。
- 缓存失效会触发重编 改了代码、切换配置、清理 Library、升级插件、变更 IL2CPP 参数,都可能导致增量缓存失效。
Unity 工程里怎么优化
开发阶段:
- 能用 Mono 调试时先用 Mono。
- 只勾一个 ABI,比如只勾
ARM64。 - 用 Development Build 减少 Release 优化成本。
- 避免每次都 Clean Build。
- 保持 Gradle、Library、Bee 构建缓存。
- 合理拆 Assembly Definition,减少不必要重编。
- CI 上缓存 Unity Library、Gradle cache、构建中间产物。
- 发布前再做完整 IL2CPP + 多 ABI + Release 构建。
发布阶段:
- 必须确认 ABI、符号、裁剪、混淆、SDK 都正确。
- 保留符号文件,方便崩溃堆栈还原。
- 谨慎设置 Managed Stripping Level,避免反射代码被裁掉。
- 对热更新方案要处理 AOT 泛型补充和 link.xml。
面试加分句
IMPORTANT
“Android IL2CPP 慢的本质是它把 C# 的托管构建问题变成了 C++ 原生构建问题。IL2CPP 要生成大量 C++,NDK 要编译和链接 native so,多 ABI 会让成本成倍增加。优化上我会区分开发构建和发布构建:开发期减少 ABI、复用缓存、避免全量构建;发布期再做完整 IL2CPP、符号和裁剪验证。”
iOS 为什么不能 JIT?
标准答案
准确说,不是 iOS 技术上完全做不了 JIT,而是普通 iOS App 不能自由 JIT。原因主要是安全策略:iOS 要求可执行代码可签名、可审核、可控;而 JIT 的本质是在运行时生成机器码,再把这段内存当代码执行,这会绕开一部分代码签名和审核边界。
所以 Unity 在 iOS 上通常使用 IL2CPP + AOT,也就是提前把 C# IL 转成 C++,再编译成 iOS 原生代码,而不是运行时 JIT。
底层原理
JIT 的流程是:
IL / bytecode -> 运行时生成机器码 -> 写入内存 -> 执行这段新代码问题在于:
- 代码签名 iOS 强依赖代码签名。App 里要执行的二进制代码,通常应在打包、签名、审核阶段就确定。JIT 运行时生成的机器码不是随 App 一起签名提交的代码。
- W^X 内存保护 JIT 需要一段内存先可写,用来写入机器码;随后又要可执行。Apple 文档也说明,JIT 会涉及可写可执行内存,而这类能力默认受限制,因为有安全风险。Apple Developer
- App Store 审核规则 App Store Guideline 2.5.2 要求 App 自包含,不能下载、安装或执行会引入或改变 App 功能的代码。Apple App Review Guidelines
- 沙箱和安全边界 如果普通 App 可以随意 JIT,就可能绕过审核、加载未审查逻辑、扩大攻击面。对游戏平台来说,这也会影响反作弊和安全边界。
Unity 里的影响
iOS 上 Unity 常用 IL2CPP AOT:
C# -> IL -> IL2CPP -> C++ -> Xcode 编译 -> iOS 原生代码这带来几个结果:
- 运行时不能依赖 JIT 生成原生代码。
System.Reflection.Emit这类动态生成 IL/方法的能力基本不可用。- 某些泛型如果 AOT 阶段没生成,运行时可能出问题。
- 反射可以用,但要注意代码裁剪和
link.xml。 - 热更新不能随意下发新的原生代码执行。
- Lua 这类解释执行方案更符合 iOS 限制。
- HybridCLR 等方案也要处理 AOT 泛型、元数据和平台限制,不能简单理解成“iOS 上随便 JIT”。
面试加分句
IMPORTANT
“iOS 限制 JIT,不是因为 CPU 不能执行动态生成代码,而是因为平台安全模型不希望普通 App 在运行时生成并执行未经签名和审核的新机器码。Unity 所以在 iOS 上走 IL2CPP AOT,把代码提前编译成原生二进制。代价是打包慢、泛型和反射要额外处理,但好处是符合 iOS 的代码签名和审核模型。”
移动端为什么更容易发热降频?
标准答案
移动端更容易发热降频,是因为手机的散热空间小、没有主动风扇、电池功耗受限,但游戏又是 CPU、GPU、内存、屏幕、网络长时间一起工作的高负载场景。温度持续升高后,系统会降低 CPU/GPU 频率来保护芯片、电池和手持温度,这就是降频。
一句话:移动端不是不能跑高性能,而是很难长时间维持高功耗。
底层原理
游戏运行时会持续消耗功耗:
- CPU:逻辑、物理、AI、动画、脚本。
- GPU:渲染、后处理、阴影、粒子、透明混合。
- 内存带宽:贴图采样、RenderTexture 读写、深度和颜色缓冲。
- 屏幕:高亮度、高刷新率。
- 网络和外围:联网、语音、录屏、定位等。
这些功耗最终都会变成热。手机机身体积小,热量不容易散出去。温度接近阈值后,系统热管理会降低频率、限制功耗,结果就是:
帧时间变长 -> FPS 下降 -> 操作延迟变大 -> 画面波动Unity 项目里常见发热点
GPU 侧:
- 分辨率过高。
- 大量透明物体和粒子 Overdraw。
- 实时阴影距离过大。
- 多后处理 Pass。
- HDR、MSAA、Bloom、TAA、SSAO。
- RenderTexture 频繁 Blit。
- Shader 采样和分支过重。
CPU 侧:
- 大量
Update。 - 大量怪物 AI。
- 物理模拟过重。
- 频繁 Instantiate / Destroy。
- 日志输出过多。
- GC Alloc 频繁。
- 主线程资源加载或同步等待。
带宽侧:
- 高精度 RT。
- 多张 G-Buffer。
- Copy Color / Copy Depth。
- 大量全屏 Pass。
- 高分辨率贴图频繁采样。
怎么优化
实际项目里我会先定位再动手:
- 用 Profiler 判断 CPU 还是 GPU 压力。
- 用 Frame Debugger / RenderDoc 看 DrawCall、Overdraw、RT 读写。
- 长时间真机跑 10 到 30 分钟,看 FPS 是否逐渐下降。
- 看设备温度、CPU/GPU 频率、电量消耗。
- 根据瓶颈降负载。
常见策略:
- 限制帧率,比如 30 FPS 或稳定 45 FPS。
- 动态分辨率。
- 降低阴影距离和阴影分辨率。
- 减少后处理和全屏 Blit。
- 控制透明特效面积和层数。
- LOD、Occlusion Culling、对象池。
- AI 和寻路分帧。
- 降低物理频率。
- 减少 GC Alloc。
- 使用 Unity Adaptive Performance 做动态画质调节。
面试加分句
NOTE
“移动端优化不能只看短时间峰值 FPS,要看长时间稳定性。很多机型刚启动能跑 60 FPS,但几分钟后温度上来就降频,最后掉到 40 甚至 30。我的优化思路是先用工具确认 CPU、GPU、带宽还是内存问题,再通过限帧、动态分辨率、LOD、减少后处理和 Overdraw,把功耗控制在设备热预算以内。”
机型碎片化会带来哪些问题?
标准答案
机型碎片化指不同手机在硬件、系统、屏幕、GPU、驱动、权限、渠道和商店分发上差异很大。对游戏客户端来说,它会带来兼容、性能、画质、包体、测试和线上质量治理问题。
一句话:机型碎片化不是只适配屏幕,而是要管理一整套设备差异。
主要问题
- 性能差异大 同一个场景,高端机可能稳定 60 FPS,低端机可能掉到 25 FPS。CPU、GPU、内存、存储速度不同,会导致加载、渲染、AI、物理表现差异明显。
- 渲染兼容问题 Android 不同 GPU 和驱动差异大,Shader、纹理格式、后处理、精度、图形 API 都可能出问题。比如某些机型 ASTC 支持不稳定,某些驱动对 Shader 分支或精度处理有 bug。
- 屏幕适配问题 分辨率、宽高比、刘海屏、挖孔屏、圆角、安全区、DPI 都不同。UI 如果只按固定分辨率做,很容易出现遮挡、拉伸、按钮太小、边缘被裁。
- 包体和资源策略复杂 Android 有 ABI、纹理格式、屏幕密度、渠道差异。可能需要按
arm64-v8a、armeabi-v7a、ASTC、ETC2、渠道包、热更包分别处理。 - 系统权限和生命周期差异 不同 Android 版本和厂商 ROM 对后台、存储、通知、悬浮窗、权限弹窗、进程回收策略都不同。游戏切后台、断线重连、资源恢复都要测。
- 发热降频差异 有些机型短时间性能高,但几分钟后降频明显。性能测试不能只看启动后 1 分钟,要看长时间稳定帧率、温度和功耗。
- 测试成本高 不可能覆盖所有机型,只能建立核心真机池、自动化测试、线上监控和灰度策略。
Unity 项目里怎么应对
我会从工程上做几层处理:
- 建立机型分档:低、中、高、旗舰。
- 根据分档控制画质:分辨率、阴影、后处理、特效数量、帧率。
- 根据 GPU/平台选择贴图格式:ASTC、ETC2、PVRTC。
- UI 使用安全区适配和多比例测试。
- 资源包按平台和渠道拆分。
- 使用远程配置控制画质、功能开关和黑名单。
- 做真机性能测试,不只看 Editor。
- 线上采集 FPS、崩溃、ANR、内存、机型、系统版本。
- 对问题机型灰度降配或临时屏蔽特效。
面试加分句
TIP
“机型碎片化的处理不能靠 if 判断堆逻辑,而要工程化治理。我会建立设备分档、资源策略、远程配置、自动化测试和线上监控。上线后通过机型维度分析 FPS、崩溃、ANR、内存和发热问题,再对具体机型做降配、黑名单或资源替换。”
刘海屏、安全区如何适配?
标准答案
刘海屏、安全区适配的核心是:背景可以铺满全屏,但重要 UI 和可点击区域要放进系统安全区内。Unity 里一般用 Screen.safeArea 拿到安全区像素矩形,再转换成 RectTransform.anchorMin / anchorMax。
底层原理
Screen.safeArea 返回的是屏幕像素坐标,比如:
c
x, y, width, height但 UGUI 的锚点是 0 到 1 的归一化坐标,所以要做转换:
c
anchorMin = safeArea.min / screenSize
anchorMax = safeArea.max / screenSize这样 UI 根节点会自动缩进到安全区内。
Unity 实践
推荐层级:
c
Canvas
FullScreenBackground // 背景、遮罩、全屏特效,可以铺满屏幕
SafeAreaPanel // 挂 SafeAreaFitter
TopBar
Buttons
HPBar
Menu不要把整个游戏画面都缩进安全区。 应该只让交互按钮、文本、状态栏、菜单进入安全区。
代码示例
c
using UnityEngine; // 引入 Unity 引擎命名空间
[ExecuteAlways] // 允许脚本在编辑器和运行时都执行
[RequireComponent(typeof(RectTransform))] // 要求当前对象必须有 RectTransform
public sealed class SafeAreaFitter : MonoBehaviour // 定义安全区适配组件
{ // 类开始
private RectTransform rectTransform; // 缓存当前 UI 节点的 RectTransform
private Rect lastSafeArea; // 记录上一次安全区,避免每帧重复设置
private Vector2Int lastScreenSize; // 记录上一次屏幕尺寸,用于检测横竖屏变化
private void Awake() // 对象初始化时调用
{ // Awake 开始
Cache(); // 缓存 RectTransform 引用
ApplySafeArea(); // 初始化时立即应用安全区
} // Awake 结束
private void OnEnable() // 对象启用时调用
{ // OnEnable 开始
Cache(); // 确保 RectTransform 已缓存
ApplySafeArea(); // 启用时重新应用安全区
} // OnEnable 结束
private void Update() // 每帧检测屏幕变化
{ // Update 开始
Rect currentSafeArea = Screen.safeArea; // 获取当前系统安全区
Vector2Int currentScreenSize = new Vector2Int(Screen.width, Screen.height); // 获取当前屏幕宽高
if (currentSafeArea != lastSafeArea || currentScreenSize != lastScreenSize) // 判断安全区或屏幕尺寸是否变化
{ // if 开始
ApplySafeArea(); // 变化后重新计算锚点
} // if 结束
} // Update 结束
private void Cache() // 缓存组件引用
{ // Cache 开始
if (rectTransform == null) // 如果还没有缓存 RectTransform
{ // if 开始
rectTransform = GetComponent<RectTransform>(); // 获取当前对象上的 RectTransform
} // if 结束
} // Cache 结束
private void ApplySafeArea() // 应用安全区到 UI 锚点
{ // ApplySafeArea 开始
Cache(); // 确保 RectTransform 可用
Rect safeArea = Screen.safeArea; // 获取系统返回的安全区域像素矩形
Vector2 anchorMin = safeArea.position; // 取安全区左下角像素坐标
Vector2 anchorMax = safeArea.position + safeArea.size; // 取安全区右上角像素坐标
anchorMin.x /= Screen.width; // 把左边界转换成 0 到 1 的锚点坐标
anchorMin.y /= Screen.height; // 把下边界转换成 0 到 1 的锚点坐标
anchorMax.x /= Screen.width; // 把右边界转换成 0 到 1 的锚点坐标
anchorMax.y /= Screen.height; // 把上边界转换成 0 到 1 的锚点坐标
rectTransform.anchorMin = anchorMin; // 设置 UI 根节点最小锚点
rectTransform.anchorMax = anchorMax; // 设置 UI 根节点最大锚点
rectTransform.offsetMin = Vector2.zero; // 清掉左下偏移,避免额外位移
rectTransform.offsetMax = Vector2.zero; // 清掉右上偏移,避免额外位移
lastSafeArea = safeArea; // 记录当前安全区
lastScreenSize = new Vector2Int(Screen.width, Screen.height); // 记录当前屏幕尺寸
} // ApplySafeArea 结束
} // 类结束常见坑
- 把整个 Canvas 都缩进安全区,导致背景黑边。
- 只适配竖屏,横屏切换后 UI 错位。
- 用固定像素偏移适配刘海,换机型就失效。
- 按钮贴边太近,Home 条区域容易误触。
- 没测挖孔屏、圆角屏、平板比例和异形屏。
面试加分句
IMPORTANT
“我不会把整个游戏画面缩进安全区,而是分层处理:背景和全屏特效铺满,核心交互 UI 放进 SafeAreaPanel。实现上用 Screen.safeArea 转成归一化 Anchor,并在横竖屏或分辨率变化时重新应用。”
后台切前台要处理什么?
标准答案
后台切前台要处理的核心是:不能假设游戏还能从上一帧无缝继续。移动端进入后台后,网络可能断、音频可能停、系统可能回收资源、时间可能跳过几分钟,回到前台时要重新校验状态。
重点处理
- 存档和关键状态 切后台时保存本地进度、设置、战斗临时状态,避免系统杀进程后数据丢失。
- 网络重连 Socket 可能断开,前台回来要重连、重新鉴权、补拉服务器状态。
- 时间校正 后台期间本地倒计时、体力恢复、技能 CD、活动时间不能完全信本地时间,关键逻辑要用服务器时间校正。
- 音频和表现恢复 回来后恢复 BGM、音效、动画、UI 状态,必要时刷新场景或重建部分资源。
- 防重复结算 前后台切换可能重复触发回调,奖励结算、断线重连、弹窗显示要做幂等。
Unity 回调
常用:
OnApplicationPause(true):进入后台或暂停。OnApplicationPause(false):回到前台。OnApplicationFocus(false):失去焦点。OnApplicationFocus(true):重新获得焦点。
实际项目里通常两个都监听,但核心恢复逻辑要避免重复执行。
代码示例
c
using UnityEngine; // 引入 Unity 引擎命名空间
public sealed class AppLifecycleHandler : MonoBehaviour // 定义应用生命周期处理组件
{ // 类开始
private bool wasPaused; // 记录应用是否进入过后台
private double pauseTime; // 记录进入后台时的真实时间
private void OnApplicationPause(bool pause) // Unity 在应用暂停或恢复时调用
{ // 回调开始
if (pause) // 如果当前是进入后台
{ // 进入后台分支开始
wasPaused = true; // 标记已经进入后台
pauseTime = Time.realtimeSinceStartupAsDouble; // 记录进入后台的真实运行时间
SaveGame(); // 保存本地关键数据
PauseAudio(); // 暂停或降低音频播放
StopHeartbeat(); // 停止或挂起网络心跳
} // 进入后台分支结束
else if (wasPaused) // 如果是从后台恢复到前台
{ // 回到前台分支开始
wasPaused = false; // 清除后台标记
double awaySeconds = Time.realtimeSinceStartupAsDouble - pauseTime; // 计算离开前台的时间
ResumeAudio(); // 恢复音频播放
ReconnectNetwork(); // 重连网络并重新鉴权
SyncServerState(awaySeconds); // 根据离线时长和服务器状态校正数据
RefreshUi(); // 刷新 UI,避免显示旧状态
} // 回到前台分支结束
} // 回调结束
private void SaveGame() { } // 保存本地进度和设置
private void PauseAudio() { } // 暂停背景音乐和音效
private void StopHeartbeat() { } // 停止网络心跳或标记网络挂起
private void ResumeAudio() { } // 恢复背景音乐和音效
private void ReconnectNetwork() { } // 重新连接服务器
private void SyncServerState(double awaySeconds) { } // 根据离线时长同步服务器状态
private void RefreshUi() { } // 刷新界面显示
} // 类结束面试加分句
WARNING
“后台切前台我会按恢复流程处理,而不是直接继续上一帧。关键是保存状态、重连网络、校验服务器时间、恢复音频 UI,并且所有恢复逻辑要幂等,避免重复触发导致重复结算或重复弹窗。”
移动端权限如何影响游戏功能?
标准答案
移动端权限会直接决定某些游戏功能能不能用。比如麦克风影响语音聊天,相机影响扫码和 AR,相册影响头像上传和截图保存,定位影响附近玩家和 LBS 活动,通知影响活动提醒,存储影响日志、截图、资源包读写。
底层原理
Android 和 iOS 都把敏感资源放在系统权限保护后面。Android 的危险权限需要运行时申请,官方也建议在用户触发相关功能时再申请,并且拒绝后要优雅降级。Apple 也要求访问相机、麦克风、定位、相册等受保护资源前提供用途说明,由用户决定是否授权。参考:Android Runtime Permissions、Apple Protected Resources。
Unity 工程实践
核心原则是:权限不要和核心玩法强绑定。比如语音权限被拒绝,游戏应该还能文字聊天;通知权限被拒绝,游戏内红点和邮件系统仍然可用;相册权限被拒绝,头像可以用默认头像或游戏内头像框。
c
using UnityEngine; // 引入 Unity 引擎命名空间
#if UNITY_ANDROID // 只在 Android 平台编译权限相关代码
using UnityEngine.Android; // 引入 Unity 的 Android 权限 API
#endif // 结束 Android 条件编译
public sealed class MicPermissionExample : MonoBehaviour // 定义麦克风权限示例组件
{ // 类开始
public void TryStartVoiceChat() // 用户点击语音聊天按钮时调用
{ // 方法开始
#if UNITY_ANDROID // Android 平台需要运行时检查危险权限
if (Permission.HasUserAuthorizedPermission(Permission.Microphone)) // 判断麦克风权限是否已经授权
{ // 已授权分支开始
StartVoiceChat(); // 已授权时直接开启语音聊天
} // 已授权分支结束
else // 未授权分支开始
{ // 未授权代码块开始
Permission.RequestUserPermission(Permission.Microphone); // 向系统请求麦克风权限
ShowPermissionHint(); // 提示用户授权后才能使用语音聊天
} // 未授权代码块结束
#else // 非 Android 平台分支
StartVoiceChat(); // 其他平台交给对应平台封装处理
#endif // 结束平台条件编译
} // 方法结束
private void StartVoiceChat() { } // 开启语音聊天逻辑
private void ShowPermissionHint() { } // 显示权限说明或降级提示
} // 类结束崩溃日志如何定位?
标准答案
崩溃日志定位不是“看到最后一行就改”,而是先把崩溃信息收全,再做符号化、归类、复现和验证。面试里我会按这条链路讲:崩溃栈、版本号、机型、系统、资源版本、热更版本、场景名、玩家操作路径、最近日志。
定位流程
- 先看影响面:崩溃率、影响用户数、集中版本、集中机型、是否热更后出现。
- 再看崩溃类型:C# 异常、Native 崩溃、OOM、ANR、线程问题。
- 做符号化:IL2CPP、iOS、Android Native 崩溃都需要对应版本的符号文件。
- 看栈顶和业务上下文:栈顶函数负责“哪里炸了”,上下文负责“为什么走到这里”。
- 本地复现:用同版本、同资源包、同机型、同操作路径验证。
- 修复后验证:看崩溃率是否下降,而不是只看本地不崩。
Unity 工程实践
C# 异常常见是空引用、数组越界、字典 Key 不存在、对象已 Destroy 还访问。Native 崩溃可能来自 IL2CPP、第三方 SDK、插件、图形驱动、线程非法调用。OOM 常和场景切换、贴图峰值、AssetBundle 常驻、资源重复加载有关。
c
using UnityEngine; // 引入 Unity 引擎命名空间
using System.Collections.Generic; // 引入泛型集合命名空间
public sealed class CrashContextRecorder : MonoBehaviour // 定义崩溃上下文记录组件
{ // 类开始
private readonly Queue<string> recentLogs = new Queue<string>(); // 保存最近一段日志
private const int MaxLogCount = 30; // 限制日志数量,避免自己制造内存压力
private void OnEnable() // 组件启用时注册日志回调
{ // 方法开始
Application.logMessageReceivedThreaded += OnLogReceived; // 监听主线程和子线程日志
} // 方法结束
private void OnDisable() // 组件禁用时取消日志回调
{ // 方法开始
Application.logMessageReceivedThreaded -= OnLogReceived; // 避免重复注册和对象泄漏
} // 方法结束
private void OnLogReceived(string condition, string stackTrace, LogType type) // Unity 日志回调
{ // 方法开始
recentLogs.Enqueue(type + ": " + condition); // 记录日志类型和内容
while (recentLogs.Count > MaxLogCount) // 超过上限时裁剪旧日志
{ // 循环开始
recentLogs.Dequeue(); // 移除最早的一条日志
} // 循环结束
if (type == LogType.Exception) // 如果捕获到 C# 异常
{ // 判断开始
UploadCrashContext(condition, stackTrace); // 上报异常和最近上下文
} // 判断结束
} // 方法结束
private void UploadCrashContext(string error, string stack) // 上报崩溃上下文
{ // 方法开始
} // 方法结束
} // 类结束常见坑
NOTE
只保存日志不保存符号文件,Native 栈会是一堆地址;只看栈不看版本和资源包,容易误判;只处理 C# 异常,不处理 OOM、ANR 和插件崩溃,也不完整。真正线上定位要形成闭环:收集、归类、符号化、复现、修复、验证。