高级客户端技术基础:九组高频追问

这篇是高级客户端基础题的口述稿。每组先回答原则,再给诊断路径和项目映射。目标不是背百科定义,而是让面试官看到:你知道状态由谁拥有、对象何时有效、并发如何同步、失败怎样恢复、优化如何证明。

一、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线程火焰图、调用次数、单次耗时只看最慢函数,不看调用量
GPUpass 时间、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,必须先确认产品是轮流、分区还是同对象实时协作,不能先选技术名词。

十、九组题的统一答法

任何技术追问都按四步回答:

  1. 原则: 一句话定义边界。
  2. 机制: 画对象、状态或时序。
  3. 失败: 讲一个最容易错的场景。
  4. 证据: 映射到真实项目和验收方法。

一分钟总答

我的技术基础不是分散知识点,而是围绕生命周期、所有权和状态权威形成的一套方法。C++ 用 RAII 和明确所有权管理资源;UE 对象还要叠加 GC、World 和模块生命周期;网络区分状态、事件、预测和权威;Lua/C++ 边界要处理 VM、Binding、沙箱和销毁;数据要有 Schema、revision、迁移和原子提交;性能用端到端证据验收;UI 是状态投影;构建发布面向不可变制品。做架构时,我先从业务约束和失败模型推导这些边界,再决定具体技术。