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 API

TypeScript 只依赖消息协议,不直接引用 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同名页面关闭重开后,旧上下文消息迟到只接受当前代际
domainscene、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 表示“前端已经观察到该传输消息”,不等于业务事务已经成功提交。

面试官:页面销毁时要清理什么?

直接回答

销毁不是把页面隐藏起来,而是按所有权反向释放:

  1. PageRuntimeClient 停止领域订阅和消息 handler。
  2. Lua PageManager 将页面标记为 disposed,拒绝后续旧代际消息。
  3. NativePageHost 禁用交互并从页面层或 Viewport 移除 Widget。
  4. C++ 解绑 PixUI Delegate,释放 Lua registry callback 和 forwarder。
  5. 清理按 viewId 保存的引用、队列和脚本全局上下文。

顺序的关键是先阻断新事件,再释放回调和资源。否则常见问题是旧 View 回调写入新页面、Lua state 已关闭后异步回调重入,或者 forwarder 被错误保活造成泄漏。

面试官:跨语言通信的性能问题怎样处理?

先定位链路

一次页面更新可能依次经过 Lua 状态生成、JSON 编码、C++ 传输、JavaScript 解析、React 更新和 PixUI 绘制。只看某一层的耗时,很容易把渲染问题误判为通信问题。因此我会给消息和帧加关联标识,分别记录生成、编码、传输、分发和渲染阶段。

治理顺序

  1. 先确认状态权威与一致性,不用“丢消息”掩盖架构问题。
  2. 按 scene、properties、viewport 等 domain 订阅,未订阅领域不做无意义序列化和发送。
  3. 首次进入拉快照,后续只发带 revision 的增量。
  4. 对拖拽、光标和属性预览等高频消息按 key 帧内合并。
  5. 区分预览态和提交态,拖拽中只发必要视觉反馈,结束时提交终态。
  6. 大对象树使用分页或懒加载,避免一次跨边界发送整个世界。

项目的性能记录中,放置拖拽曾在约 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。我的工作重点不只是把消息打通,而是补齐多页面生命周期、旧消息拒绝、双通道去重确认和销毁解绑。

如果面试官继续追问,再按下面四句话展开:

  1. pageId + generation 判断消息还属不属于当前页面。
  2. domain + revision 判断状态是否更新、是否需要补快照。
  3. seq + ACK 处理传输重复、队列裁剪和单槽覆盖。
  4. dispose 统一释放 JS 订阅、Lua callback、C++ Delegate 和原生 View。

事实与职责口径

内容面试中的主语
页面协议、页面栈、页面生命周期、旧消息拒绝、状态分域“我设计并实现或直接交付”
NativePageHost、PixUI Binding、双向通信和销毁解绑“我直接参与并能结合代码说明”
制品接入、多端构建、PixUI DOM 与自动化“我负责其中的具体链路”
完整三 VM、Instance 对象模型、网络复制“团队架构和我的上下游;我基于源码做了系统复盘”
未经个人数据验证的性能收益不报百分比,只说明问题、机制和验证方法

相关材料:UGC 客户端架构总答、UGC2.0 事实底座、高级客户端高频问答。