高级客户端技术基础:九组高频追问
这篇是高级客户端基础题的口述稿。每组先回答原则,再给诊断路径和项目映射。目标不是背百科定义,而是让面试官看到:你知道状态由谁拥有、对象何时有效、并发如何同步、失败怎样恢复、优化如何证明。
一、C++:生命周期、并发与性能
面试官:RAII 的本质是什么?
RAII 把资源所有权绑定到对象生命周期,使获取和释放落在可组合、可证明的作用域中。资源不仅是堆内存,也包括锁、文件、句柄、注册回调和事务。
class Subscription final {
public:
Subscription(Source& source, Callback callback)
: source_(&source), token_(source.Subscribe(std::move(callback))) {}
~Subscription() { Reset(); }
Subscription(const Subscription&) = delete;
Subscription& operator=(const Subscription&) = delete;
Subscription(Subscription&& rhs) noexcept { Swap(rhs); }
Subscription& operator=(Subscription&& rhs) noexcept {
if (this != &rhs) { Reset(); Swap(rhs); }
return *this;
}
private:
void Reset() noexcept;
void Swap(Subscription&) noexcept;
Source* source_{};
Token token_{};
};真正要回答的是所有权:唯一拥有用 unique_ptr,共享生命周期才用 shared_ptr,非拥有观察要有明确失效策略。为了“避免崩溃”到处换 shared_ptr,会把循环引用、延迟释放和线程析构问题藏起来。
面试官:智能指针能解决悬空引用吗?
只能解决它所建模的所有权。裸指针可能是合法的非拥有引用,shared_ptr 也可能因回调捕获形成环。异步任务还要处理 generation:对象地址仍有效,不代表它仍属于当前页面、World 或会话。
项目里的 PixUI 页面和 VM 回调就是典型案例:销毁时先撤销“提交权”,再取消回调和释放 VM;回调抵达后还要校验页面 generation,而不是只检查指针非空。
面试官:C++ 内存模型里最容易犯什么错?
atomic 只保证相应原子对象的访问语义,不自动让周围复杂状态一致。无锁发布对象时,生产者在完成普通写后用 release 发布指针/状态,消费者用 acquire 读取;若用 relaxed 却依赖其他字段可见,就可能读到半初始化数据。
复杂跨字段不变量通常更适合锁或消息传递。性能优化前先证明锁确实是瓶颈,避免用难验证的 lock-free 结构交换微小收益。
面试官:怎么排查 C++ 性能问题?
先分类 CPU、内存、锁等待、I/O 或缓存局部性,再用 Profile 证明热点。优化顺序通常是减少工作量、降低频率、改数据布局、批处理,最后才是微观指令优化。
项目映射:大规模对象不应每个 Tick;地图打包不能反复复制完整缓冲;大网格使用紧凑数组和分阶段邻接。验收同时看平均值、P95/P99、峰值内存和结果一致性。
二、Unreal:对象、World 与模块生命周期
面试官:UObject 和 Actor 有什么区别?
UObject 是反射、序列化、GC 和编辑器生态的基础对象;Actor 是能进入 UWorld、具有网络和空间生命周期的实体。UActorComponent 给 Actor 组合行为,但并非所有数据对象都应变成 Actor。
UGC2.0 的 Instance 是创作对象身份,Actor 是特定 World 下的运行投影。把对象树中的每个节点永久映射成 Actor,会放大 Tick、组件、复制和 World 生命周期成本。
面试官:UE GC 下什么引用是安全的?
被反射系统追踪的 UPROPERTY 强引用、容器或显式 Root 能保持 UObject 可达;原始指针不会自动成为 GC 根。非拥有引用可用弱对象指针,并在使用前检查有效性。跨线程不能仅凭弱指针有效就操作 UObject,大多数 UObject 逻辑仍需回到 Game Thread。
PendingKill、World teardown 和热重载阶段尤其危险。异步回调除对象有效性外,还要核对 World/会话 generation。
面试官:Subsystem 怎么选?
按真实生命周期选:Engine 级跨进程长期存在,GameInstance 跨地图,World 随世界,LocalPlayer 随本地玩家。不能因为访问方便就把业务放进全局单例。
Engine -> GameInstance -> World -> LocalPlayer / Actor服务的缓存键和异步任务必须包含相应作用域身份,否则 Travel 后旧 World 结果可能写入新 World。
面试官:模块卸载为什么只解绑委托还不够?
模块可能还注册了工厂、命令、菜单、Ticker、Style、Tab、Console Command 或异步回调。注册与撤销必须对称,保存句柄并按逆序清理。Live Coding、自动化和插件重载都会打破“模块只启动一次”的假设。
三、网络:状态、事件与权威
面试官:复制和 RPC 怎么选?
需要晚加入、重连或丢包后仍能恢复的是状态,用复制快照/增量;只在发生瞬间有意义的短时表现可以用 RPC。重要结果不能只靠 Multicast,因为错过事件的新客户端无法重建当前世界。
状态:门已打开、剩余血量、当前回合、物品归属
事件:播放一次音效、命中特效、即时提示有副作用的事件需要幂等 ID;可靠 RPC 只保证传输语义,不自动保证业务恰好一次。
面试官:Relevancy、Priority 和 Frequency 有什么区别?
- Relevancy 决定这个连接是否需要知道该对象。
- Priority 决定预算不足时谁先发送。
- Frequency 决定对象多久进入一次考虑范围。
Replication Graph 负责候选集合组织,不会自动解决属性过多、事件风暴和错误 Dormancy。高密度玩法优化要从候选数、比较字段、实际字节和更新频率分别测量。
面试官:客户端预测的边界是什么?
预测用于隐藏延迟,不改变服务端权威。客户端提交带序号的意图,本地先执行可回滚表现;服务端验证资源、时机、目标和速率并返回确认;客户端按确认快照重放或纠正。
伤害、奖励、库存和胜负必须由服务端决定。动画和 UI 可以预测,但副作用要区分本地预测、权威提交和远端表现,避免确认回声再次执行冲量或发奖。
四、Lua/Luau:VM、Binding 与沙箱
面试官:Lua 和 C++ 怎么通信?
C API 的本质是栈式调用:C++ 压入函数和参数,执行受保护调用,再检查结果;Lua 调 C++ 时,注册的 C 函数从栈读取参数并返回结果数量。生产系统通常在这之上生成 Binding,但所有权、异常和线程边界仍需设计。
原生 UObject 不应直接暴露裸地址。Binding 保存受控句柄和类型信息,访问前检查对象、World 和权限;Lua wrapper 被 GC 时只释放脚本引用,是否销毁原生对象由所有权策略决定。
面试官:多 VM 有什么价值和代价?
Edit、Client、Server 分 VM 可以隔离全局环境、权限和故障,也让 DS 不加载编辑 UI。但 VM 之间不共享 Lua 对象,必须经过明确协议传递可序列化数据或稳定句柄。
代价是生命周期和调试复杂:VM 销毁前要让原生异步回调失去提交权;页面、World 和 VM 都需 generation,拒绝旧消息。
面试官:沙箱是不是删掉几个全局函数?
不是。沙箱包括 API 白名单、资源级权限、CPU/内存/协程预算、网络限流、模块加载、文件/系统访问、异常隔离和审计。独立 VM 只提供隔离容器,不等于最小权限。
热重载也不能只清 package.loaded:旧协程、事件连接、Timer 和世界副作用仍存在。脚本实例需要统一生命周期容器,先撤销副作用再加载新版本。
五、数据:Schema、迁移与恢复
面试官:序列化最重要的原则是什么?
持久化稳定身份和业务语义,不持久化进程地址、UI 状态和运行时临时对象。格式要有版本、确定性输出、未知字段策略和完整性校验。
Authoring Model -> versioned schema -> canonical bytes
-> manifest / hash编辑态可读格式利于调试,发布态二进制利于体积和加载;两者必须由同一 Schema 派生,不能成为两套事实。
面试官:版本迁移怎么做?
先探测源版本,再逐级执行幂等迁移 v1 -> v2 -> v3,每步有冻结样例。读新版本失败时,不应无条件覆盖原文件;可进入只读、兼容回退或提示升级。
删除字段也要考虑旧客户端、脚本和资源引用。版本号只告诉“整体格式”,复杂管线还需要能力矩阵和各子制品版本。
面试官:快照和增量怎么配合?
快照提供完整基线,增量携带 base revision 和 next revision。消费者只在版本连续时应用;发现缺口或校验失败就重新请求快照。高频拖拽可按 key 帧内合并,但最终提交必须形成明确事务。
面试官:怎样保证原子写入?
写临时文件、flush/fsync、校验后原子替换;崩溃时旧文件仍可读。多文件制品需要 manifest 或目录级提交标记,不能逐个覆盖后假装整体成功。远端发布遇到结果未知时先查询任务状态,不自动重放非幂等提交。
六、性能:从证据到验收
面试官:你怎么定位卡顿?
先定义用户可见阶段,再把端到端时间拆开:Game Thread、Render Thread、GPU、I/O、资源等待、网络和 Web/PixUI。平均帧率不能解释长帧,要看 frame time 分布和具体事件关联。
现象 -> 稳定复现 -> trace/counter -> 最大贡献项
-> 最小改动 -> 相同场景 A/B -> 回归与门禁优化前后固定地图、设备档、相机路径、对象数、网络条件和采样窗口。只报告有原始证据的指标。
面试官:CPU、GPU、内存和网络分别先看什么?
| 方向 | 第一批证据 | 常见误区 |
|---|---|---|
| CPU | 线程火焰图、调用次数、单次耗时 | 只看最慢函数,不看调用量 |
| GPU | pass 时间、draw/triangle、overdraw、带宽 | 只减三角形,不看材质与像素成本 |
| 内存 | 分类快照、峰值、生命周期、泄漏增长 | 只看最终常驻,不看瞬时双份 |
| 网络 | 每连接字节、候选对象、更新频率、包分布 | 只看 RPC 数量,不看复制状态 |
优化常见顺序是裁剪不必要工作、降低频率、缓存/批处理、改变数据结构,再考虑并行和底层微调。
七、UI:状态、输入与技术选型
面试官:UI 架构最重要的边界是什么?
页面是状态投影,不是业务状态权威。页面发送意图,领域服务验证并修改模型,页面订阅 revisioned state。异步结果带请求 ID 和页面 generation,防止旧页面覆盖新页面。
输入必须统一路由,处理焦点、弹窗、软键盘、手柄和 3D 世界竞争。页面销毁时解除事件、Timer、音视频和原生回调。
面试官:WebView、PixUI 和原生 UI 怎么选?
| 技术 | 适合 | 不适合 |
|---|---|---|
| 原生 UI | 高频 HUD、强引擎交互、稳定核心流程 | 频繁活动迭代与复杂信息页面 |
| PixUI/嵌入式 Web | 游戏内跨端页面、共享组件、高频配置 | 极高频绘制和强 3D 操作 |
| WebView/SSR | 运营、分享、配置、接口聚合 | 必须离线可用的核心退出与容灾 |
选型看变化频率、性能预算、离线需求、输入复杂度、宿主能力和发布方式。即使用 Web,客户端仍要负责宿主生命周期、JSB、兼容和容灾。
八、构建发布:从源码到可回滚制品
面试官:什么是可靠制品?
可靠制品由固定源码、锁定依赖、确定工具链和声明式环境生成,带 manifest、hash、协议版本和构建身份。相同输入应得到相同语义,最好得到相同字节。
commit + lock + toolchain
-> build / test / package
-> immutable artifact + manifest
-> promote to environment
-> monitor / rollback pointer发布不是在目标机复制目录。覆盖安装前要清理旧文件或按 manifest 差分,避免残留;失败后保持上一版可运行。回滚切历史制品指针,不重新构建旧分支。
面试官:多平台怎么治理?
先定义共同内容语义,再由平台 processor 生成 Android、iOS、PC、DS 或小游戏变体。平台能力、插件和资源格式形成显式矩阵;禁用插件列表不能证明依赖已经被移除,仍需模块与制品扫描验证。
流水线记录输入 revision、工具版本、输出 hash、部署环境和测试结果。灰度按能力和版本开关控制,并有观测与快速回滚。
九、架构设计:从业务目标推导边界
面试官:你做系统设计时从哪里开始?
先澄清用户、规模、延迟、一致性、安全、平台和演进约束,再画核心对象与生命周期。之后确定状态权威、失败模型、协议和可观测,最后才选框架。
业务目标与约束
-> 核心对象 / 身份 / 生命周期
-> 状态权威与所有权
-> 同步协议与失败恢复
-> 性能预算与安全边界
-> 演进、测试、发布和观测面试官:怎么判断边界拆得对不对?
一个模块应对一组同生命周期、同权威、同变化原因的状态负责。若两个模块必须同步修改同一字段,或销毁顺序只能靠隐含约定,边界通常有问题。跨边界传稳定协议,不传内部对象;写操作有 revision、幂等和权限;读模型可投影但不能反向成为事实。
面试官:如何控制架构过度设计?
先实现最小闭环,并给预计会变化的边界留下版本与替换点。只有当抽象消除真实重复、隔离高风险变化或建立必要契约时才引入。对不确定需求,优先记录决策、指标和回退路径,而不是一次构建全能平台。
项目例子:UGC2.0 的三 VM、页面协议和 ArtifactBuilder 是因为隔离、生命周期和多端制品已有真实复杂度;而多人共创是否需要 CRDT,必须先确认产品是轮流、分区还是同对象实时协作,不能先选技术名词。
十、九组题的统一答法
任何技术追问都按四步回答:
- 原则: 一句话定义边界。
- 机制: 画对象、状态或时序。
- 失败: 讲一个最容易错的场景。
- 证据: 映射到真实项目和验收方法。
一分钟总答
我的技术基础不是分散知识点,而是围绕生命周期、所有权和状态权威形成的一套方法。C++ 用 RAII 和明确所有权管理资源;UE 对象还要叠加 GC、World 和模块生命周期;网络区分状态、事件、预测和权威;Lua/C++ 边界要处理 VM、Binding、沙箱和销毁;数据要有 Schema、revision、迁移和原子提交;性能用端到端证据验收;UI 是状态投影;构建发布面向不可变制品。做架构时,我先从业务约束和失败模型推导这些边界,再决定具体技术。