UGC2.0 客户端基建:三 VM、PixUI 与跨语言通信
这篇是 UGC2.0 客户端基建的面试背诵稿,重点回答四个问题:三 VM 怎样分工,编辑器各层怎样协作,TypeScript、Lua、C++ 怎样通信,页面切换时怎样保证消息可靠。
正文可以直接说。回答时先给结论,再按面试官的追问展开,不要一上来罗列类名和接口。
面试官:先概括一下 UGC2.0 的客户端架构
30 秒回答
UGC2.0 不是给旧编辑器换一套 Web UI,而是在做一套可编程内容的创作与运行基础设施。我会把它分成六层:TypeScript/React 创作界面、PixUI 页面运行层、Lua 编辑领域层、Instance 对象与脚本层、Edit/Client/Server 多角色运行时,以及制品和多端交付层。核心边界是四个:页面不拥有领域真值,编辑态和试玩态不共享活对象,Lua 通过 C++ Binding 使用引擎能力,作者源必须经过冻结和构建才能成为运行制品。
90 秒回答
最上层是对象树、属性、Gizmo、资源和脚本等创作界面。界面由 TypeScript/React 实现,但页面只保存交互状态,真正的对象和编辑状态由 Lua 编辑服务及底层对象模型管理。
页面之下是 PixUI 运行层,它负责页面注册、replace/push/overlay、原生 View、页面生命周期和跨语言消息。再往下是编辑领域层,负责选择、属性修改、场景操作、命令和事务;对象模型用稳定身份、属性、信号和服务表达内容。进入试玩后,Edit、Client、Server 是不同执行角色:编辑脚本、客户端表现和服务端权威逻辑分别运行,不能直接传递 Lua 引用或 World 对象。
保存发布侧则把作者数据冻结成确定快照,经过处理、校验和制品化,再由客户端与服务端加载。我直接做得比较深的是页面协议、页面生命周期、C++ 原生 PixUI 宿主、JS/C++/Lua 通信、制品接入、多端构建和 UI 自动化。完整对象模型和三 VM 属于团队架构,我能讲清上下游,但不会说成由我独立设计。
白板图
flowchart TB
UI["TypeScript / React 创作界面<br/>对象树 / 属性 / Gizmo / 资源 / 脚本"]
PAGE["PixUI 页面运行层<br/>Registry / PageManager / NativePageHost / Protocol"]
EDIT["Lua 编辑领域层<br/>EditorService / 命令 / 事务 / 选择 / 属性"]
MODEL["对象与脚本层<br/>Instance / Property / Signal / Service / Binding"]
PLAY["多角色运行时<br/>Edit VM / Client VM / Server VM"]
BUILD["内容交付层<br/>冻结 / Artifact / Manifest / 发布 / 多端加载"]
UI <--> PAGE
PAGE <--> EDIT
EDIT <--> MODEL
MODEL --> PLAY
MODEL --> BUILD
BUILD --> PLAY记忆顺序:界面 -> 页面 -> 编辑服务 -> 对象模型 -> 三 VM -> 制品。
面试官:你在 UGC2.0 中具体做了什么?
直接回答
我主要做了四块。
第一块是页面基础设施。我把业务页面和 PixUI 细节拆开,补了页面注册、页面栈、replace/push/overlay、created/active/suspended/disposed 生命周期,以及 C++ 原生 View 的统一宿主。
第二块是跨语言通信。我梳理了 TypeScript/JS、Lua 和 C++ 的双向链路,给消息增加页面代际、传输序号和状态版本,处理旧页面消息、乱序、重复消费、队列确认以及销毁解绑。
第三块是内容交付,包括作者数据到二进制格式和 ArtifactBuilder 的接入,以及构建失败后的清理和校验。第四块是 Android、iOS 构建和自动化,包含 PixUI DOM 暴露、稳定测试 ID 和真机链路。
三 VM、完整 Instance 对象模型和网络运行时是团队平台能力,也是我的直接上下游。我在源码复盘后能够解释它们的职责和接口,但面试中会与个人交付分开。
不要这样回答
我做了 PixUI 封装,还做了一些工具和出包。
问题在于它只有技术名词,没有业务问题、系统边界和结果。正确展开顺序是:
为什么需要多页面 -> 谁拥有原生 View -> 消息怎样双向流动 -> 页面销毁怎样止血 -> 如何测试。
面试官:Edit、Client、Server 三 VM 分别做什么?
30 秒回答
三 VM 首先是三种执行角色,不等于三个 VM 永远同时驻留在一个进程。Edit VM 承载编辑服务和编辑态脚本;Client VM 承载本机输入、表现和客户端玩法逻辑;Server VM 位于 DS,承载权威规则和可复制状态。三者共享内容语义和稳定对象身份,但不共享 Lua 引用、UObject 指针或 World 生命周期。
进一步展开
| 角色 | 主要职责 | 不能越过的边界 |
|---|---|---|
| Edit VM | 对象创建、属性编辑、工具逻辑、保存和预览编排 | 不能把编辑权限或活对象直接带入正式玩法 |
| Client VM | 输入、镜头、UI、表现预测和本机脚本 | 不能成为胜负、资源和关键状态的最终权威 |
| Server VM | 权威玩法、校验、复制和多人会话 | 不依赖编辑器 UI,也不信任客户端提交的结果 |
从编辑进入试玩时,跨边界传递的应该是稳定 ID、序列化数据、制品身份和纯值启动参数,而不是 Lua table 引用、闭包、Actor 或 World 指针。退出试玩后也不是把旧 Edit VM 原样唤醒,而是按保存的纯值上下文重建编辑环境和订阅。
白板图
flowchart LR
SOURCE["作者数据<br/>稳定 ID / Schema / 资产引用"]
ARTIFACT["冻结并构建的制品"]
EDIT["Edit VM<br/>创作与编辑服务"]
CLIENT["Client VM<br/>输入与表现"]
SERVER["Server VM / DS<br/>权威规则与复制"]
SOURCE <--> EDIT
SOURCE --> ARTIFACT
ARTIFACT --> CLIENT
ARTIFACT --> SERVER
CLIENT <-->|RPC / replication| SERVER常见追问:为什么不能复用一个 VM?
因为三种角色的权限、World、模块准入和销毁时机不同。复用同一 VM 会把编辑器能力带入运行环境,也容易残留全局变量、回调和对象引用。隔离的代价是切换和恢复更复杂,但换来权限清晰、运行可复现和故障域可控。
常见追问:三个 VM 是否一定对应三个进程?
不一定。VM role 是逻辑执行域,process 是部署拓扑。Server 通常在独立 DS;Client 可以位于宿主或独立客户端;不同试玩模式也可能改变 Edit 与 Client 的共存关系。回答时先说明“角色与进程解耦”,再描述具体模式,不能死背固定进程数。
面试官:UGC 编辑器的页面、编辑服务和对象模型怎样协作?
直接回答
页面不是状态权威,而是命令入口和状态投影。用户在对象树、属性面板或 Gizmo 上操作后,TypeScript 页面发出结构化命令;消息进入 Lua EditorService,由领域 handler 做对象查找、参数校验,并通过命令或事务修改对象模型;修改成功后,编辑服务按 scene、properties、viewport 等状态域生成快照或增量,再推回页面更新。
这条链保证对象树、属性面板和 3D 视口看到的是同一份领域状态,也让撤销、保存、脚本修改和自动化可以复用同一业务入口。页面不能直接把某个 React state 当作项目真值,否则刷新、返回或崩溃后就无法恢复一致状态。
sequenceDiagram
participant U as 用户
participant TS as TypeScript 页面
participant ES as Lua EditorService
participant M as 对象模型 / 编辑事务
participant V as Viewport / Runtime
U->>TS: 修改属性或操作 Gizmo
TS->>ES: typed command(type, payload)
ES->>ES: 校验页面、对象和参数
ES->>M: 执行命令 / 提交事务
M->>V: 更新运行投影
M-->>ES: 领域状态变化
ES-->>TS: snapshot / revisioned delta
TS-->>U: 更新对象树、属性和提示常见追问:撤销应该放在哪里?
撤销属于编辑领域,不属于某个页面。一次用户意图应形成明确事务,记录可逆操作或前后状态;拖拽过程中可以预览和合并,结束时提交一个事务。否则属性面板、Gizmo 和脚本入口各自修改底层对象,会形成三套无法统一回滚的路径。
面试官:你说的 PixUI 封装,具体封装了什么?
30 秒回答
我封装的不是一个 SendMessage 函数,而是页面运行基础设施:TypeScript 侧有平台适配和类型化协议;Lua 侧有 PageManager、EditorService 和页面 Bridge;C++ 侧有 NativePageHost 与 PixUI Widget Binding。它统一负责页面创建和销毁、页面栈、双向消息、页面身份、状态版本、失败兜底和回调清理。
分层回答
| 层 | 负责什么 | 不负责什么 |
|---|---|---|
| TypeScript/React | 页面组件、交互状态、typed transport、订阅与渲染 | 不直接持有 UObject,不作为项目数据真值 |
| Lua PageManager | 页面栈、模式、层级、代际和生命周期编排 | 不直接实现原生 Widget 细节 |
| Lua EditorService | 命令分发、领域状态、序号、版本和调度 | 不长期拥有页面组件状态 |
| C++ NativePageHost/Binding | 创建销毁 PixUI View、原生消息、Delegate 和 Lua callback | 不承载编辑业务规则 |
这样拆分的价值是,页面可以在浏览器 mock 环境中测试,Lua 可以独立维护编辑领域,C++ 只处理原生资源与引擎边界。代价是协议治理和序列化成本上升,所以必须同时做版本、批量、日志和自动化。
面试官:TypeScript、Lua、C++ 到底是怎么通信的?
先给结论
运行时真正执行的是编译后的 JavaScript,不是 TypeScript 本身。完整系统有四条不同的通信链,不能混成一句“TS 调 C++,C++ 再调 Lua”。
第一条:JavaScript 向 Lua 发送页面命令
TypeScript 源码编译为 JavaScript
-> typed transport.send(type, payload)
-> window.external.sendMessage / postMessage 兼容入口
-> C++ PixUI DelegateOnMessage
-> C++ message forwarder
-> 当前 Edit VM 的 Lua callback
-> EditorService 解码并分发 handler
-> 编辑领域或 QZUGC C APITypeScript 只依赖消息协议,不直接引用 Lua 函数或 C++ 类。C++ 的职责是把 PixUI 原生回调安全送入当前 Lua VM,Lua 再负责业务校验和编排。
第二条:Lua 向 JavaScript 推送状态
Lua 领域状态变化
-> 生成 {type, data, seq, meta}
-> 主通道:C++ PixUIPostMessage -> UPixUIWidget::PostPxMessage
-> 兜底通道:注入 _qzugc.MsgQueue + MsgVersion
-> JavaScript bridge 接收并按 seq 去重
-> MessageRouter / PageRuntimeClient 校验页面和 revision
-> React controller / store 更新
-> JavaScript 回写 AckSeq,Lua 裁剪已确认队列原生消息通道负责低延迟,脚本全局队列承担兼容和可靠兜底。两条通道可能观察到同一消息,因此共享单调 seq 并在消费端去重。这里是“至少可重试、消费端幂等”的思路,不能宣称分布式意义上的 exactly-once。
第三条:Lua 与 C++ 的引擎 Binding
C++ 在对应 Lua VM 中注册 C 闭包。Lua 通过这些 API 创建和销毁 PixUI View、切换可见与交互状态、发送消息、绑定回调和读写脚本全局;C++ 实际持有 Widget、Delegate forwarder、弱引用和 Lua registry callback。
这条链解决“Lua 怎样使用引擎能力”,与页面消息协议不是同一层。
第四条:Client Lua 与 Server Lua 的网络通信
客户端和服务端脚本通过独立的 RPC/复制链路通信,涉及端点、序列化、权限校验和异步完成。它解决的是跨进程、跨权威域问题,不能复用 PixUI 的页面 Bridge。
一张图说清四条链
flowchart LR
JS["JavaScript 页面"]
PIX["C++ PixUI Host / Forwarder"]
EDIT["Edit Lua<br/>PageManager / EditorService"]
CPP["C++ Engine API"]
CL["Client Lua"]
SL["Server Lua / DS"]
JS -->|页面命令| PIX
PIX -->|Lua callback| EDIT
EDIT -->|状态消息 / 全局队列| PIX
PIX -->|PostPxMessage / globals| JS
EDIT <-->|C closure / userdata| CPP
CL <-->|RPC / replication| SL面试官:为什么不让 TypeScript 直接调用 C++?
直接回答
因为界面、领域和引擎资源的变化周期不同。页面需要浏览器预览和组件测试;Lua 需要承载编辑业务编排;C++ 需要控制 UObject、Widget、Delegate 和线程边界。如果 TypeScript 直接绑定大量 C++ 业务接口,页面生命周期会和引擎对象耦合,浏览器环境难以模拟,接口演进也会扩散到所有页面。
消息协议虽然增加了序列化成本,但换来了边界稳定、可测试和可观测。对于高频数据不能简单逐条 JSON 发送,而要用快照、增量、分域订阅和帧内合并控制成本。
面试官:一个页面从打开到销毁经历什么?
直接回答
逻辑页面有四个核心状态:created、active、suspended 和 disposed。PageManager 决定页面采用 replace、push 还是 overlay,并为每次创建分配新的页面身份和 generation;NativePageHost 是原生 View 的唯一 owner,负责创建、显示、交互门控和释放;页面激活后注入上下文并建立领域订阅,被覆盖时暂停订阅或交互,恢复时按最后 revision 补状态,销毁时彻底解绑。
| 操作 | 页面栈行为 | 旧页面处理 |
|---|---|---|
| replace | 新页面替换当前页面 | dispose 旧页面并销毁原生 View |
| push | 新页面压栈 | 旧页面 suspend,返回时 resume |
| overlay | 在独立层显示覆盖页 | 主页面保留,但交互按层级门控 |
| back/close | 弹出当前页面 | 恢复上一页或关闭 overlay |
为什么生命周期必须进入协议?
因为 React 组件、JavaScript 上下文、Lua 服务和原生 View 的寿命并不天然一致。只靠组件的挂载/卸载约定,会出现页面已经关闭但异步消息仍到达、旧回调写入新页面、重复订阅或不可见页面继续吃输入。页面身份和销毁顺序必须由跨层协议统一定义。
面试官:pageId、generation、domain、revision、seq 和 ACK 各解决什么?
直接回答
这些字段分别处理不同维度的问题,不能只留一个全局版本号。
| 字段 | 解决的问题 | 判断规则 |
|---|---|---|
pageId | 消息应该发给哪个逻辑页面 | 目标页面不匹配就拒绝 |
generation | 同名页面关闭重开后,旧上下文消息迟到 | 只接受当前代际 |
domain | scene、properties、viewport 等状态独立演进 | 页面只订阅自己需要的领域 |
revision | 同一状态领域内的乱序和恢复 | 只接受更新版本;缺口时重拉快照 |
seq | 传输层的排序、跨通道重复和确认 | 同一序号只消费一次 |
AckSeq | 兜底队列哪些消息已经被 JS 观察到 | Lua 据此裁剪已确认消息 |
一句话记忆:pageId + generation 管对象生命周期,domain + revision 管业务状态顺序,seq + ACK 管传输可靠性。
为什么 generation 和 revision 不能合并?
generation 变化表示页面已经换了一个生命周期实例;revision 变化表示同一个页面订阅的某个状态领域向前演进。旧 generation 的消息即使 revision 更大也必须丢弃;不同 domain 的 revision 也不能互相比较。
面试官:为什么要双通道?会不会重复?
直接回答
双通道是为了兼容不同 PixUI 运行环境并给原生消息失败保留兜底,而不是为了同时执行两次业务。原生 PostPxMessage 低延迟;全局 MsgQueue 可以在页面初始化或特定环境下补取。重复问题由共享 seq 和消费端去重解决,队列消费后通过 AckSeq 告诉 Lua 可以裁剪。
旧方案如果只写一个 LastMsg 槽位,同一个 Lua tick 连续产生多条消息时,后写会覆盖前写。改为队列后不会因单槽覆盖而丢消息,但还要处理容量上限、确认、重复、旧代际过滤和销毁清理。ACK 表示“前端已经观察到该传输消息”,不等于业务事务已经成功提交。
面试官:页面销毁时要清理什么?
直接回答
销毁不是把页面隐藏起来,而是按所有权反向释放:
- PageRuntimeClient 停止领域订阅和消息 handler。
- Lua PageManager 将页面标记为 disposed,拒绝后续旧代际消息。
- NativePageHost 禁用交互并从页面层或 Viewport 移除 Widget。
- C++ 解绑 PixUI Delegate,释放 Lua registry callback 和 forwarder。
- 清理按 viewId 保存的引用、队列和脚本全局上下文。
顺序的关键是先阻断新事件,再释放回调和资源。否则常见问题是旧 View 回调写入新页面、Lua state 已关闭后异步回调重入,或者 forwarder 被错误保活造成泄漏。
面试官:跨语言通信的性能问题怎样处理?
先定位链路
一次页面更新可能依次经过 Lua 状态生成、JSON 编码、C++ 传输、JavaScript 解析、React 更新和 PixUI 绘制。只看某一层的耗时,很容易把渲染问题误判为通信问题。因此我会给消息和帧加关联标识,分别记录生成、编码、传输、分发和渲染阶段。
治理顺序
- 先确认状态权威与一致性,不用“丢消息”掩盖架构问题。
- 按 scene、properties、viewport 等 domain 订阅,未订阅领域不做无意义序列化和发送。
- 首次进入拉快照,后续只发带 revision 的增量。
- 对拖拽、光标和属性预览等高频消息按 key 帧内合并。
- 区分预览态和提交态,拖拽中只发必要视觉反馈,结束时提交终态。
- 大对象树使用分页或懒加载,避免一次跨边界发送整个世界。
项目的性能记录中,放置拖拽曾在约 9 秒内产生 327 次属性变化,并伴随较大的属性定义更新,JSON、消息传输和 React 渲染被叠加放大。处理思路不是盲目换更快的序列化,而是先在交互期间抑制无关属性与选择刷新,再在结束时发送终态,同时对必要更新做分域和合并。
面试官:这套方案最难的地方是什么?
直接回答
最难的不是打通一条消息,而是处理四套不同生命周期:React 组件、JavaScript 上下文、Lua 编辑服务和 C++ Widget。正常路径很容易跑通,问题通常发生在快速切页、覆盖页、试玩切换、Lua VM 关闭或同帧多消息这些边界上。
我的处理原则是:原生 View 只能有一个 owner;每次页面创建都有新代际;状态按领域维护版本;传输按序号去重确认;销毁时先阻断事件再解绑资源;最后用协议测试、浏览器组件测试、UE 自动化和真机链路分层验证。
一个适合面试的复盘
早期如果只把通信理解成“能从 Lua 发到 JS”,单页面和正常流程确实可以工作,但多页面后会暴露三个问题:旧消息污染新页面、同 tick 消息被单槽覆盖、页面退出后回调仍然存活。后续把页面身份、generation、消息队列、seq/ACK 和统一 dispose 加入协议,才把通信从接口封装提升成可管理的页面运行时。
这件事给我的反思是:跨语言系统首先要设计所有权和失效语义,再设计调用接口。只画成功路径,无法支撑长期运行的编辑器。
面试官:如果让你重新设计,还会补什么?
直接回答
我会继续补四类能力。
第一,协议 Schema 和兼容矩阵,明确字段新增、废弃、版本协商和未知消息策略。第二,背压和容量治理,为队列长度、单消息大小、每帧预算和降级策略设硬限制。第三,端到端 tracing,让同一个 command、领域事务和状态回推共享关联 ID。第四,故障注入测试,主动覆盖切页中断、重复消息、乱序、VM 关闭和原生 View 创建失败。
同时我会把页面协议、编辑事务和制品 generation 统一纳入会话级诊断,但不会用一个全局版本替代各层版本。不同版本号应该各自表达页面生命周期、状态顺序和内容身份。
高频追问速答
| 问题 | 一句话回答 |
|---|---|
| TypeScript 能直接运行在 PixUI 里吗? | 不能,TypeScript 先编译为 JavaScript,运行时执行的是 JavaScript 产物。 |
| 页面状态由 React store 负责吗? | React store 负责展示和交互缓存,项目对象与编辑领域状态不能只存在页面里。 |
| C++ 为什么不处理编辑业务? | C++ 控制原生资源和引擎边界,业务编排留在 Lua 可降低页面与引擎的耦合。 |
| generation 能替代 seq 吗? | 不能;generation 判定页面是否仍有效,seq 处理同一代际中的传输重复与确认。 |
| ACK 是否代表修改成功? | 不代表;它只确认消息已被消费,业务成功应有独立响应或状态版本。 |
| 双通道能保证 exactly-once 吗? | 不能承诺;它通过重试、去重和幂等把重复影响控制住。 |
| suspend 和 dispose 有什么区别? | suspend 保留可恢复上下文,dispose 永久解绑订阅并释放原生 View。 |
| Edit VM 和 Client VM 能传 Lua table 吗? | 不应传活引用,只传稳定 ID、纯值数据和已冻结的内容身份。 |
| UI Bridge 和网络 RPC 是一套吗? | 不是;前者是进程内页面桥接,后者跨客户端与权威服务端。 |
| 性能优化先换二进制吗? | 不一定;先减少无效更新、分域订阅和合并,再判断序列化是否真是瓶颈。 |
卡壳时的最短版本
我先拆成三层讲。TypeScript 页面只负责交互,通过 PixUI 消息把结构化命令送到 C++,C++ forwarder 转给当前 Edit VM 的 Lua EditorService;Lua 修改领域状态后,再通过原生 PostMessage 和队列兜底把带页面代际、状态版本和传输序号的消息推回 JS。Lua 调引擎能力则走 C++ Binding,Client/Server 通信另走 RPC。我的工作重点不只是把消息打通,而是补齐多页面生命周期、旧消息拒绝、双通道去重确认和销毁解绑。
如果面试官继续追问,再按下面四句话展开:
pageId + generation判断消息还属不属于当前页面。domain + revision判断状态是否更新、是否需要补快照。seq + ACK处理传输重复、队列裁剪和单槽覆盖。dispose统一释放 JS 订阅、Lua callback、C++ Delegate 和原生 View。
事实与职责口径
| 内容 | 面试中的主语 |
|---|---|
| 页面协议、页面栈、页面生命周期、旧消息拒绝、状态分域 | “我设计并实现或直接交付” |
| NativePageHost、PixUI Binding、双向通信和销毁解绑 | “我直接参与并能结合代码说明” |
| 制品接入、多端构建、PixUI DOM 与自动化 | “我负责其中的具体链路” |
| 完整三 VM、Instance 对象模型、网络复制 | “团队架构和我的上下游;我基于源码做了系统复盘” |
| 未经个人数据验证的性能收益 | 不报百分比,只说明问题、机制和验证方法 |
相关材料:UGC 客户端架构总答、UGC2.0 事实底座、高级客户端高频问答。