Gamelet 与 PixUI 实现深挖:制品、宿主、通信与生命周期
这篇专门回答两个容易混淆的问题:Gamelet 与 PixUI 到底是什么关系,以及我所说的 PixUI 封装究竟封装了什么。内容来自旧 Gamelet 工程、Web monorepo、UGC2.0 原生宿主、Lua 页面服务与 TypeScript runtime 的源码交叉核对。
面试时先背前两段。只有面试官继续追问,才展开制品、加载、通信和生命周期,不要一上来报类名。
面试官:Gamelet PixUI 是怎么实现的?
30 秒回答
Gamelet 和 PixUI 不是同一个东西。Gamelet 是应用容器和分发层,按应用身份管理打开、关闭、资源包和窗口;PixUI 是其中的页面渲染运行时;PFBS 是 PixUI 可加载的页面二进制;平台 PAK 则放 Unreal 原生资源。PixUI 不是内嵌 Chrome,而是实现了精简 H5ES:React/Preact 最终仍通过 DOM API 更新元素树,PixUI 再完成 CSS、Yoga Flex 布局和原生绘制。完整链路是 Web 源码先经 webpack 和 PFBS 生成页面制品,再与目标平台 PAK 组装成 Gamelet bundle;客户端按应用身份打开后,平台层准备资源,Gamelet 创建 PixUI Window、加载页面并转发消息,关闭时对称销毁窗口和资源。
90 秒回答
我会把实现分成五层。第一层是 Web 应用,TypeScript/JSX 经 Babel 和 webpack 编译,当前工具链把 React/Preact API 收敛到定制 Preact;组件 diff 最终调用 PixUI 提供的 H5ES DOM,而不是浏览器 DOM。第二层是制品工具链,webpack 先生成明文页面,PFBS 再转换为带格式签名的 PixUI 二进制,同时补齐 ASTC 等未被转换器收集的资源。第三层是 Gamelet bundle,它把 PFBS 页面、应用身份和各平台 Unreal PAK 组装为可发布应用;AppID、注册名和 PAK 前缀必须严格一致。
第四层是客户端容器。Gamelet 对外提供初始化、打开应用、显隐、发消息和关闭接口;平台回调进入客户端后,由窗口管理器创建带唯一 ID 的 PixUI View,再从 URL、游戏资产或内存数据加载页面。原生 PAK 由资源管理器挂载,页面消息则经 Window 转到 PixUI。
第五层是业务宿主。旧活动页主要复用 Gamelet 的应用分发和窗口能力;UGC2.0 不是把编辑器注册成远端 Gamelet,而是把已验证 PFBS 随客户端交付,再用 C++ 原生 View、Lua PageManager 和 TypeScript runtime 管理多页面、双向消息和生命周期。JS 到 Lua 走 PixUI 消息 Delegate,经 C++ 入队后在帧末回调 Lua;Lua 到 JS 主要走 PostPxMessage,全局变量队列是兼容兜底。pageId + generation + domain + revision + seq/ACK 分别解决页面身份、旧实例、状态顺序和双通道去重。
先把五个概念分开
| 概念 | 本质 | 负责什么 | 不负责什么 |
|---|---|---|---|
| Gamelet | 应用容器与分发协议 | 应用身份、打开关闭、窗口、资源准备、消息入口 | 不等于页面渲染器 |
| PixUI | 嵌入游戏客户端的 UI 运行时 | 解析页面、布局绘制、输入和页面消息 | 不决定 AppID、发布目标和 PAK 身份 |
| PFBS | PixUI 页面制品格式 | 把 HTML/JS 等转换为运行时可加载的二进制页面 | 不是完整 Gamelet,也不是 Unreal PAK |
| Unreal PAK | 引擎资源归档 | 平台相关的 UObject、材质、纹理等原生资源 | 不承载页面协议和页面栈 |
| JSB | 页面与宿主的协议边界 | 请求、回调、事件、状态推送和能力适配 | 不应暴露任意 UObject 或 Lua 函数 |
最短记忆句:Gamelet 管应用,PixUI 管页面,PFBS 管页面制品,PAK 管引擎资源,JSB 管通信契约。
为什么这个区分重要?
因为五层的故障完全不同。AppID 错误是应用身份问题,PFBS 签名错误是页面制品问题,真机缺图可能是资产补齐问题,窗口不显示是宿主和层级问题,重复操作则可能是双通道去重问题。把它们统称为“PixUI 出问题”,就无法建立有效排障顺序。
两套实现不能混成一套
| 维度 | 旧 Gamelet 活动页体系 | UGC2.0 编辑器页面体系 |
|---|---|---|
| 产品诉求 | 多个可独立发布、动态打开的游戏内应用 | 与编辑器版本一致、离线可用的核心工作界面 |
| 应用发现 | 平台 AppID 与发布配置 | 本地 pages.manifest 的逻辑页面名 |
| 页面交付 | PFBS 页面与平台 PAK 组装为 Gamelet bundle,再进入发布链 | dist/prod 经校验后进入部署镜像,再随 UE Cook/Stage 进入客户端 |
| 打开入口 | OpenApp(AppID, OpenArgs) | PageManager:Open(logicalPage, args) |
| 宿主 | Gamelet Window Manager | Lua PageManager + NativePageHost + C++ Binding |
| 生命周期 | 应用和 Window 的 open/visible/close | created/active/suspended/covered/disposed |
| 通信重点 | 通用 JSB 请求、回调和事件 | 编辑器领域命令、版本化状态与可靠恢复 |
共同底座是 PixUI 页面运行和宿主通信;不同的是应用身份、交付方式和生命周期所有者。面试时必须先说明当前讲的是哪一套。
PixUI 为什么能渲染 React WebUI?
30 秒回答
React 本身不负责画像素,它只负责把组件状态变成元素描述并计算增量更新。当前 PixUI 工具链把 JSX 编译成定制 Preact 的
createElement调用,Preact reconciliation 最终调用document.createElement、appendChild、setAttribute、style和事件 API。PixUI 实现了这些精简 H5ES DOM/CSS 接口,内部用 Yoga 做 Flex 布局,再把色块、图片、文字、视频、Canvas 和原生 slot 等绘制语义交给 UE PixUIWidget 接入 UMG/Slate。输入则反向从 UE View 进入 PixUI 命中测试和 DOM 事件,触发组件 handler、更新 state,再进行下一轮 diff。所以它支持的是 Web 开发模型和必要 API 子集,不是完整浏览器。
最短记忆句:React 管组件与 diff,PixUI 管 DOM/CSS/布局,UE 插件管绘制与输入,Gamelet 管应用。
构建期究竟做了什么?
真实页面入口是:
import { render } from 'preact';
render(<Guide />, document.getElementById('container'));这段代码能在 PixUI 中运行,不是因为客户端内置了完整 ReactDOM,而是构建工具做了两层适配:
- Babel 去掉 TypeScript 类型,把 JSX 转成
h(...),再由jsx-pragmatic稳定改写为从preact引入的createElement。 - webpack alias 把
react、preact、preact/hooks和preact/compat收敛到工具链自带的定制preact.js,路由也使用定制实现。
TSX / JSX
-> Babel: JSX -> h/createElement
-> webpack: React/Preact alias -> 定制 Preact
-> JS + CSS + HTML + assets
-> PFBS 二进制结构化页面因此面试里说“PixUI 原生支持 React”不够精确。更准确的说法是:PixUI 提供足以承载前端框架的 H5ES DOM/CSS/事件环境,项目构建器再把 React 风格 API 适配到定制 Preact。仓库也带 Svelte loader;官方文档说明 DOM 能力可以承载 React/Vue,但任何框架和 npm 包最终都受 H5ES 能力边界约束。
首次渲染怎样从组件变成屏幕像素?
flowchart LR
PFBS["PFBS 页面"]
VM["PixUI JS VM<br/>ES2020 子集"]
PREACT["Preact<br/>VNode + reconciliation"]
DOM["H5ES DOM 树<br/>属性 / Style / Event"]
CSS["CSS 解析与级联"]
YOGA["Yoga Flex Layout<br/>位置与尺寸"]
PAINT["PixUI Paint<br/>文字 / 图片 / 图元 / Canvas"]
UE["UE PixUIWidget<br/>UMG / Slate / 材质资源"]
SCREEN["游戏画面"]
PFBS --> VM --> PREACT --> DOM --> CSS --> YOGA --> PAINT --> UE --> SCREEN执行顺序可以拆成七步:
- 宿主创建 PixUI View,设置逻辑尺寸、DPI/缩放、字体和资源回调,再加载 URL、游戏资产、内存数据或 PFBS 页面。
- PixUI 创建页面 JS 运行环境,准备精简的
window、document、定时器、网络、事件和宿主扩展。 - 入口脚本调用 Preact
render。组件函数生成 VNode,初次 reconciliation 创建真实 H5ES 元素节点。 - 定制 Preact 通过
document.createElement/createTextNode、appendChild/insertBefore、setAttribute、style和addEventListener操作 PixUI DOM。 - PixUI 解析 HTML/CSS 和内联 style,把样式映射到自己的元素树;布局主要交给 Yoga Flex 计算尺寸与位置。
- PixUI 按布局结果形成文字、色块、边框、图片、视频、2D Canvas 等绘制内容;
pixslot可以在页面指定位置嵌入 UE 原生 Widget 或资源。 - UE 插件把 PixUI View 放进 PixUIWidget/UMG 层级,使用插件材质、字体、纹理和 Slate/引擎绘制能力上屏。
这里要避免一个错误说法:不是“React 的 Virtual DOM 被直接画出来”。VNode 只是框架内部的描述;真正被 PixUI 消费的是 reconciliation 之后对 H5ES DOM 的增删改。
setState 后为什么不用重建整个页面?
state / props change
-> Preact enqueueRender
-> 新 VNode
-> 与旧 VNode diff
-> 最小化 DOM 属性、文本和子树更新
-> PixUI 重新处理受影响的样式、布局与绘制
-> UE 下一次刷新上屏Preact 源码中可以看到 enqueueRender、diff、diffChildren 和 setProperty;更新节点时最终仍落到 insertBefore、removeChild、style、setAttribute 等 DOM 操作。key 稳定性、组件拆分和状态粒度会影响 JS diff 与 DOM 变更量,但最终的布局、文本测量和原生绘制成本属于 PixUI/UE,而不是 React 自己。
性能分析应按阶段拆开:
| 阶段 | 常见成本 | 主要手段 |
|---|---|---|
| JS/组件 | 频繁 state、重复 render、大数组 diff | 合并更新、稳定 key、虚拟列表或分页、减少无效派生 |
| DOM/CSS | 大量节点增删、复杂级联、频繁测量 | 控制节点数、减少动态 innerHTML、显式尺寸 |
| Yoga 布局 | 深层 Flex、反复改变宽高、文本测量 | 降低嵌套、避免布局抖动、批量提交状态 |
| Paint/资源 | 大图、文本、透明叠层、Canvas、视频 | 压缩纹理、预加载、减少过度绘制、控制刷新频率 |
| UE 宿主 | DPI、Widget 层级、输入命中、原生 slot | 固定 View 尺寸和缩放规则,减少重挂载 |
不能把“用了 Virtual DOM”当作性能结论。它减少的是框架层无意义 DOM 写入,并不自动消除 Yoga、文本、纹理和 UE 绘制成本。
点击事件怎样回到 React handler?
sequenceDiagram
participant I as UE 输入系统
participant W as PixUIWidget / PxView
participant D as PixUI DOM
participant P as Preact Component
I->>W: Pointer / Touch / Key
W->>D: 坐标换算、命中测试、构造事件
D->>D: capture / target / bubble
D->>P: Preact event proxy -> onClick
P->>P: setState / hook update
P->>D: diff 后更新 DOM
D-->>W: 新布局与绘制内容页面上的 onClick 最终会被 Preact 注册为 DOM listener。UE 把鼠标、触摸、键盘和手柄输入送到 PixUI View;PixUI 根据当前布局树做命中测试并派发 DOM 事件,Preact event proxy 再调用组件 handler。事件是否由页面消费、透明区域是否穿透、焦点归谁,仍由宿主 View 的 interactive/hit-test 和输入策略决定。
所以“页面显示正常但点不到”通常不先查 React,而应依次检查:UMG ZOrder、Visibility/HitTest、DPI 坐标换算、PixUI 元素布局、事件 listener 和透明遮挡层。
PixUI 与浏览器到底差在哪里?
PixUI 是精简 H5ES UI 引擎,不依赖完整浏览器内核。当前文档给出的主要边界包括:
| 能力 | PixUI 现状 | 对 React 项目的影响 |
|---|---|---|
| 标签 | 重点支持 div、img、text、video/audio、canvas 等;未知标签按 div | 语义标签可显示,但默认样式和行为不能照搬浏览器 |
| 布局 | 只支持 Yoga Flex,并支持有限定位 | Grid、浏览器排版默认值和部分 absolute/fixed 行为不可假设 |
| DOM | H5ES 必要子集,继承关系与浏览器略有不同 | 依赖完整 HTMLElement 体系的库可能失败 |
| CSS | 常用样式、transform、transition、animation | 可动画属性、层级和默认值存在差异 |
| Canvas | 2D | 依赖 WebGL/3D Canvas 的包不能直接使用 |
| 动态代码 | 正式小应用要求 PFBS,不支持运行时明文 eval | 动态模板编译、远端脚本和部分 devtool 方案不可用 |
| 并发能力 | 无 WebWorker,JS 引擎不支持 JIT | 计算密集任务应移到宿主或服务端 |
| 网络与存储 | XHR/WebSocket/LocalStorage 为子集 | npm 包要逐项检查 cookie、Blob、URL、Worker 等依赖 |
正确开发方式是“以 PixUI/H5ES 能力为主,以浏览器预览为辅”。浏览器能跑不代表 PixUI 必然能跑;PixUI 页面也可能使用 pixslot、pixui:// 原生资源协议和 GameletAPI,无法在普通浏览器完整还原。
React、Preact 和 ReactDOM 的关系怎么回答?
| 名称 | 在当前工程中的角色 |
|---|---|
| React API | 组件、props/state、生命周期等开发心智;依赖可通过 alias 兼容 |
| Preact | 实际打入页面的轻量组件与 reconciliation 实现,提供 hooks/compat |
| ReactDOM | 不是这里把像素画进 UE 的渲染器;真实页面入口直接用 Preact render |
| PixUI DOM | Preact diff 的目标,提供精简 document、Element、Style 和 Event |
| UE PixUI 插件 | PixUI 的原生宿主与绘制后端,负责 View、资源、输入和 UMG/Slate 接入 |
面试官问“既然用 React,为什么不需要 Chrome”时,核心回答是:框架只要求一组可用的 host config/DOM 操作,不要求 Chrome。浏览器只是 DOM 的一种实现;PixUI 提供另一套兼容子集。
同步模式、异步模式和刷新节奏
UE PixUIWidget 默认同步运行,复杂页面可以开启异步模式,把较多 HTML 布局和 JS 工作放到后台处理。异步并不等于可以随意访问 UObject:页面与宿主仍要通过消息边界,原生对象操作回到合法线程,并在 View 销毁时停止迟到任务。
Preact effect 调度会使用运行环境提供的 requestAnimationFrame/timer;PixUI 也有重绘和刷新节奏。业务优化时应合并一帧内的多次状态变更,避免为了一个动画或进度在 JS、Yoga 和 UE 三层重复制造工作。
原生 Widget 怎样嵌进 WebUI?
pixslot 是 PixUI 扩展标签。页面通过 pixui:// 资源协议指定 UE WidgetBlueprint、材质或其他宿主资源,PixUI 把该节点的布局矩形交给后端,在正确层级挂载原生对象。它解决了纯 H5ES 不适合实现的复杂引擎 UI、特效或资源展示。
这也是 PixUI 与普通 WebView 的重要区别:页面树不仅能画 Web 风格元素,还能留下原生渲染 slot。但 slot 引入第二套生命周期,节点移除、页面关闭和 View 销毁时必须同步释放原生对象,并处理 Web 图层与原生图层的排序和命中测试。
这一段的 90 秒背诵稿
PixUI 能支持 React,不是因为里面跑了 Chrome,而是因为 React 本身只负责组件模型和增量 diff。我们项目的构建器会把 JSX 编译成 createElement,并把 react、preact、hooks 和 compat 收敛到定制 Preact。入口调用 Preact render 后,VNode reconciliation 最终还是调用 document.createElement、appendChild、setAttribute、style 和 addEventListener。
PixUI 实现了这些精简 H5ES API,并维护自己的 DOM 和样式树;CSS 布局主要走 Yoga Flex,之后生成文字、图片、色块、Canvas、视频等绘制内容。UE 插件用 PixUIWidget 把 View 接到 UMG/Slate,提供字体、纹理、材质、DPI 和输入。点击时路径反过来:UE 输入进入 View,PixUI 按布局命中 DOM 节点并派发事件,Preact handler 更新 state,再 diff 出下一轮 DOM 变化。
所以 React、PixUI 和 UE 是三层责任:React 管状态与组件,PixUI 管 Web 语义、布局和绘制描述,UE 管真正上屏和宿主资源。兼容边界也来自这里:PixUI 只有 H5ES 子集,只支持 Yoga Flex、有限标签和 Web API,没有完整浏览器的 SVG、iframe、WebWorker、WebGL 和运行时 eval。判断 npm 包能不能用,要看它依赖哪些底层 API,不能只看它是不是 React 组件。
旧 Gamelet:Web 源码怎样变成可打开应用?
构建链
flowchart LR
SRC["TypeScript / UI / Assets"]
WEB["Webpack<br/>明文 dist"]
PFBS["PFBS<br/>PIX0 / PIX2"]
SYNC["补齐未转换资产<br/>ASTC 等"]
BUNDLE["Gamelet Bundle<br/>页面 + 应用身份"]
PAK["Android / iOS / Harmony / Windows PAK"]
CI["上传 / 校验 / 发布"]
HOST["客户端 Gamelet 容器"]
SRC --> WEB --> PFBS --> SYNC --> BUNDLE
PAK --> BUNDLE
BUNDLE --> CI --> HOST这里至少有三种产物:
dist是 webpack 的明文构建结果,适合浏览器侧检查,但不是最终 PixUI 发布物。dist/.build是 PFBS 后处理结果,页面入口可检查到PIX0,脚本可检查到对应二进制签名。- Gamelet bundle 是应用级归档,包含 PFBS 页面贡献和目标平台的 Unreal PAK;上传发布属于后续 CI,而不是 webpack 的一部分。
为什么不能直接打包明文 dist?
Gamelet 的 PixUI contribution 必须指向 PFBS 后的目录。如果误把明文 HTML/JS 装入 zip,打包命令可能成功,客户端却无法按 PixUI 格式加载。可靠做法不是只看退出码,而是同时校验:入口存在、二进制签名正确、bundle 内文件来自 .build。
PFBS 为什么从 watch 改为一次性转换?
旧 watch 模式会占用固定端口;多应用由任务图并行构建时会争抢端口,表现为串行化、卡死或不稳定。一次性批量转换不绑定端口,产物格式与初始全量扫描一致,因此更适合 CI 并发。watch 只保留为本地兼容或失败兜底。
这个改动体现的不是“换了一个命令”,而是把开发服务器语义和生产构建语义拆开:生产构建应是有限、确定、可并行且可验证的过程。
为什么 PFBS 完成后还要同步普通资产?
转换器只处理它认识的类型。源码中已经出现过 ASTC 未进入 .build、最终包在真机缺图的问题。处理方式是比较明文产物与 PFBS 产物:目标不存在的普通资源原样补齐,已经转换的文件绝不覆盖,同时排除构建报告和日志。
这只是兼容兜底。更理想的方向是让动态资源进入显式 manifest,减少“发现缺什么就复制什么”的隐式行为。
AppID、registeredName 和 pakName 为什么必须一致?
同一份源码可能发布为多个应用目标,因此源码目录名不等于平台身份。打包器内部又会用应用 ID 与注册名拼接引擎资源前缀。如果目标不唯一仍默认取一个,或者注册名与 PAK 前缀不一致,会出现两种危险结果:打包成功但发布到错误应用,或者页面包存在但对应平台资源没有被收入。
因此构建规则是:
- 一份源码只有一个目标时可以从配置反查;一对多时必须显式选择 AppID。
- 注册名缺失直接失败,不能宽容回退到源码目录名。
- 平台 PAK 先按当前应用前缀过滤,再按平台键装入,避免把同目录中其他目标的资源串包。
- 产物保留应用身份、页面清单和平台资源规则,便于上传前再次校验。
这也是多应用工程中最需要治理的地方:源码身份、发布身份、资源身份 必须分离,但在制品阶段重新对齐。
旧 Gamelet:客户端怎样打开页面?
主时序
sequenceDiagram
participant B as 业务入口
participant G as UGamelet
participant P as 平台 Open SDK
participant D as Gamelet Delegate
participant M as Window Manager
participant W as Gamelet Window / PixUI View
B->>G: Initialize / Open
B->>G: OpenApp(AppID, OpenArgs)
G->>P: 请求打开应用
P-->>D: 创建页面窗口并给出入口
D->>M: CreatePxWindow(AppID, AppInfo)
M->>W: 创建 PixUI Widget,分配 WindowID
M->>W: Init + AddToViewport + 显示
D->>W: LoadFromUrl / GameAsset / Data
B->>G: SendMessageToApp
G->>P: 平台消息
P-->>D: SendMsgToWindow
D->>W: PostMessage各层具体做什么?
UGamelet 是面向业务的门面,提供初始化环境、打开应用、控制显隐、关闭应用和发消息。它不直接管理每个 PixUI Widget,而是把应用级请求交给平台库。
平台库通过 Delegate 回到 Unreal。创建窗口时,Window Manager 生成唯一 WindowID,创建 UGameletWindow,建立 PixUI Widget 或 UMG 外壳,初始化后加入 Viewport,并用映射表管理 WindowID 到 Window 的关系。
页面入口可能来自 URL、游戏资产或内存数据。URL 创建路径会延迟到后续 Tick 再执行加载,避免在窗口刚创建、底层状态尚未就绪时同步重入。平台下发给页面的消息最终调用 Window 的 PostMessage。
原生 PAK 怎样进入运行时?
资源管理器通过 Unreal 的 FPakPlatformFile 挂载 PAK,并维护已挂载路径,避免重复挂载。环境管理器拿到应用资源清单后,只处理匹配的 PAK,挂载成功后再根据内容目录寻找脚本入口并创建对应运行环境。
所以平台 PAK 的作用不是“装网页”,而是给该应用补充引擎可识别的原生内容。它与 PFBS 页面共同组成应用,但由不同加载器消费。
关闭为什么不能只 RemoveFromParent?
窗口管理器的销毁顺序体现了完整生命周期:先从管理映射移除,进入 PreClose,通知 View 即将销毁,关闭 PixUI,再解除 Root 引用。只把 Widget 从视口拿掉,会留下 Window 映射、Delegate、底层 PxView 或原生资源。
反过来,UGC2 页面临时隐藏也不能一律销毁重建。RemoveFromParent + AddToViewport 可能重建 Slate 树并丢失 PixUI View 状态,因此临时切页优先改变 Visibility 和输入状态,真正 dispose 时才做彻底清理。
UGC2.0:为什么没有直接套旧 Gamelet 打开模型?
编辑器页面与客户端版本、对象模型和 Lua 服务强绑定,核心诉求是确定性、离线可用和生命周期可控,而不是独立运营应用的动态分发。因此 UGC2.0 使用固定本地入口:页面登记在 manifest 中,生产 PFBS 经过构建验证后随原生客户端交付,再由页面管理器按逻辑名打开。
flowchart LR
NAME["逻辑页面名"]
REG["PageRegistry<br/>entry / mode / layer / restore"]
PM["PageManager<br/>stack / overlay / generation"]
HOST["NativePageHost"]
BIND["C++ Lua Binding"]
VIEW["UMG Shell + PixUI View"]
FILE["已验证的本地 PFBS"]
NAME --> REG --> PM --> HOST --> BIND --> VIEW
FILE --> VIEW页面为什么必须显式登记?
递归扫描目录只能发现“有什么文件”,无法表达页面模式、层级、是否保留底页和恢复策略。显式 manifest 则把页面入口与 replace/push/overlay、base/modal/play、preserveUnderlay、restorePolicy 放在一个契约中。
这还给构建阶段提供了完整性边界:manifest 中的每个入口都必须存在并通过 PFBS 签名校验;生产目录也不能多出未登记应用。新增页面只改登记和应用构建,不在打包脚本中继续堆应用名特判。
生产制品怎样进入客户端?
Web 源码与共享元数据
-> workspace build:dist/build + dist/dev + dist/prod
-> 校验 manifest、PFBS 签名、产物清单和文件摘要
-> 构造独立 deploy candidate
-> 原子替换 Saved 下的部署镜像
-> UE Cook / Stage
-> Windows / Android 等客户端
-> 桌面壳整体收录原生客户端负载只部署 dist/prod、页面 manifest 和必要共享元数据;源码、node_modules、开发缓存和明文预览产物都不进入正式包。部署先生成完整候选目录,再替换线上镜像,并对短时文件锁做有限重试,避免打包过程看到半新半旧目录。
pfbs-manifest 记录格式版本、模式、入口、工具摘要和文件摘要。它的作用是证明“这批文件由哪种转换规则生成”,不能只靠目录名叫 prod 就相信它是生产制品。
开发模式为什么要有显式会话?
本地开发需要把固定生产路径临时映射到 HTTP dev server,才能热更新;但如果只按端口是否可连就切换,会把旧进程、他人服务或正式环境误判为开发页。
因此原生宿主只在以下条件同时成立时切换:路径位于受管生产前缀内、没有目录穿越、会话标记可读、进程仍存活、地址是合法 loopback 且端口有效。其他情况都回退到本地 PFBS,并记录当前模式与原因。正式入口始终固定,开发映射只是经验证的临时覆盖。
C++ 原生 PixUI 宿主封装了什么?
不是一个 CreateWidget 帮助函数
原生层直接在 Lua 全局命名空间注册一组窄 API,避免让业务通过通用反射任意操作 UObject。能力分为五类:
| 能力 | 代表操作 | 关键约束 |
|---|---|---|
| View 创建 | 创建裸 PixUI View 或 UMG 包装的 PixUI View | 必须在 Game Thread;创建时确定初始 ZOrder |
| 展示控制 | visible、interactive、z-order | 切页不重建底层 PxView |
| 消息 | 发送页面消息、绑定页面消息回调 | Delegate 不直接重入 Lua |
| 全局变量 | 创建命名空间、读写字符串/数字 | 作为环境注入和兼容 fallback,不承载任意对象 |
| 生命周期 | reload、destroy | Delegate、registry ref、Root、UMG 和映射必须对称释放 |
UMG 包装模式解决的是原生外壳、输入和已有 UI 层级兼容;裸 PixUI 模式则更轻。两者最终都返回整数 ViewID,Lua 不持有 UObject 指针,只操作稳定句柄。
为什么创建时传 ZOrder,而不是显示时反复 Remove/Add?
UMG 与 Slate 的重挂载可能重建内部结构,使 PixUI View 丢状态或输入错位。原生宿主在创建时建立外壳和 View 的所有权,后续由状态接口调整可见性、命中测试和必要层级。页面切换因此是生命周期变化,不是 Widget 的随意搬运。
TypeScript、Lua、C++ 到底怎样通信?
先讲三条边,不要说成一条直线
flowchart LR
TS["TypeScript / JavaScript 页面"]
CPP["C++ PixUI 宿主与 Binding"]
LUA["Lua EditorService"]
UE["Unreal Engine"]
TS -->|"external.sendMessage / postMessage"| CPP
CPP -->|"队列,帧末回调"| LUA
LUA -->|"PostPxMessage / globals fallback"| CPP
CPP -->|"页面消息 / JS globals"| TS
LUA -->|"窄 C API"| CPP
CPP -->|"UObject / View / Engine"| UETypeScript 在运行时已经是 JavaScript。它不直接“调用 C++ 函数”,而是发送有类型、有版本语义的纯值消息。C++ 负责 PixUI 通道和引擎对象生命周期,Lua 负责编辑器领域状态和命令处理。
JS/TS -> C++ -> Lua
- 页面把
{type, seq, data}序列化为 JSON,首选 PixUI 官方的window.external.sendMessage。 - 兼容实现还可以发
window.postMessage或写 fallback outbox,但出站消息会加来源标记,避免被当前页面自己的 message 监听器当作入站状态。 - PixUI 的
DelegateOnMessage在 C++ 中接收消息。C++ 绑定 UObject Forwarder,并把消息写入待处理队列。 - Scene Subsystem 在帧末 drain 队列,通过 Lua registry 中的 callback ref 回调 Lua。
- Lua 先校验当前 View、页面代际和消息类型,再分发给 EditorService 的领域 handler。
为什么不在 Delegate 中直接调用 Lua?因为 Delegate 的触发时机可能处在 PixUI 内部调用栈。直接重入 Lua 会把线程、VM 状态和页面销毁交织在一起。入队后在统一 Game Thread 帧点消费,边界更清楚,也便于批处理和销毁拦截。
Lua -> C++ -> JS/TS
主通道由 Lua 调 C++ Binding,最终执行 PixUI View 的 PostPxMessage(json);页面通过统一 message receiver 解包并交给类型 handler。
兼容通道由 C++ 写入页面脚本全局变量。Lua 维护消息队列和版本,JS 轮询读取 MsgQueue,完整消费一个快照后回写 AckSeq。兼容通道不是另一套业务协议:两条通道携带同一个 seq 和 meta,页面按序号精确去重。
为什么需要双通道?
不同 PixUI 版本、平台和页面启动阶段对 PostPxMessage 的送达行为并不完全一致,fallback 能避免页面完全失联。但双通道会引入重复、乱序和队列膨胀,因此必须有统一序号与确认机制。
它也不是严格的 exactly-once 系统。进程崩溃后内存队列不会恢复,业务命令仍要靠幂等或领域层确认;这里保证的是同一页面会话内的去重、过期拒绝和有界重试。
fallback 为什么是队列,不是 LastMsg 单槽?
同一帧可能连续推送属性定义、选择变化和易变属性。单槽只保留最后一次写入,前面的消息会被覆盖。队列保留全部未确认消息;JS 消费完整快照后把 ACK 推到该批最大序号,Lua 再从队首裁剪。
ACK 不能由先到达的主通道消息随意推进。主通道可能乱序,如果收到较大序号就确认,会把尚未通过 fallback 消费的较小消息删掉。ACK 只在完整 drain fallback 快照后推进,主通道只负责按 seq 去重。
队列还必须有上限和单次 JSON 大小预算。超过预算时分批发送并保留 dirty 标记,下一帧继续,而不是生成一个巨型字符串阻塞解析。高频可替换状态进入 Frame Outbox,按 key 每帧合并一次;不可替换命令立即发送。
页面身份和状态版本怎样设计?
| 字段 | 解决的问题 | 校验原则 |
|---|---|---|
pageId | 这条消息属于哪个逻辑页面 | 不属于当前页面则拒绝 |
generation | 同一个逻辑页面的第几次实例 | 关闭重开后,旧实例消息立即失效 |
domain | scene、selection、properties 等状态域 | 各状态域独立演进和订阅 |
revision | 某状态域的新旧顺序 | 只接受严格更新版本 |
seq | 传输消息身份 | 跨主通道与 fallback 精确去重 |
ACK | fallback 已完整消费到哪里 | 仅在完整批次 drain 后推进 |
generation 和 revision 不能合并。前者是页面实例身份,后者是领域状态版本。新页面可以从低 revision 请求全量快照,但不能接受旧页面实例迟到的高 revision。
页面恢复为什么是“订阅 + 快照”,不是缓存所有消息?
页面被覆盖后可能暂停接收高频状态。重新激活时,继续重放所有历史增量成本高且容易遗漏;更稳定的模型是保留订阅意图和已知版本,恢复后按状态域请求最新快照,再继续接收新版本。领域真值留在 Lua 服务,页面只是投影。
多页面生命周期怎样闭环?
页面状态
created -> active -> suspended / covered -> active -> disposedreplace:新页面替换当前主页面,旧页面按恢复策略挂起或销毁。push:压入页面栈;如果保留底页,底页进入 covered,但不接收输入。overlay:独立层叠加,适合试玩覆盖层,不破坏主页面栈。
页面创建时分配新的 generation,建立 Native View 并注入页面上下文;激活时绑定长生命周期服务、重置该 View 的 fallback 投递水位并请求订阅快照;挂起时关闭输入和业务推送;恢复时重新取状态;最终销毁才释放所有原生资源。
销毁清单
一次完整 dispose 至少包括:
- 停止页面订阅、定时器、Frame Scheduler 和未完成 callback。
- 从 PixUI Widget 移除消息与日志 Delegate。
- 释放 Lua registry callback ref,清除待处理消息和按钮回调。
- 解除 Forwarder 的 Root 引用和业务对象引用。
- 移除 UMG 外壳或 PixUI Widget,清理 ViewID 映射。
- 使旧
pageId + generation的迟到消息失效。
生命周期的难点不是“有没有 close 接口”,而是创建过的每一种资源是否都有唯一、可重复调用、顺序正确的反向操作。
性能和稳定性怎样治理?
性能预算按链路拆
| 环节 | 常见成本 | 优先手段 |
|---|---|---|
| 序列化 | 大对象反复 JSON 编解码 | 按状态域裁剪数据,不发送运行对象 |
| 跨语言次数 | 高频鼠标、属性和树状态逐条穿越 | 帧内合并、批量发送、命令与状态分流 |
| 页面渲染 | 大树、复杂布局和频繁重排 | 虚拟列表、增量 patch、稳定 key |
| fallback | 轮询和大字符串解析 | 活跃 33ms、空闲退避;有界批次与 ACK |
| 原生 View | 重建 Slate/PxView、纹理与内存 | 页面覆盖时 suspend,真正退出才 dispose |
| 构建 | 多应用重复 PFBS、共享包失效 | 任务图缓存、一次性并行转换、指纹复用 |
优化顺序应是先量测消息数、字节数、序列化时间、Lua handler 时间和前端渲染时间,再决定批处理还是局部更新。不能只看到 JSON 大就改二进制,也不能只给轮询调大间隔掩盖队列设计问题。
输入、层级和清晰度也属于宿主问题
PixUI 页面看起来是前端,实际显示质量由原生 View 尺寸、DPI、页面缩放、UMG 层级和命中测试共同决定。常见现象包括:页面可见但点不到、透明层拦截 3D 输入、切页后 ZOrder 错乱、缩放小于 1 导致文字模糊。
因此创建 View 时记录屏幕 DPI、页面 scale 和实际像素尺寸;展示状态同时控制 Visibility 与 hit-test;自动化和真机验收都要覆盖 PC 与移动端,而不是只看浏览器截图。
最容易出错的十二个点
| 现象 | 高概率根因 | 第一检查点 |
|---|---|---|
| bundle 成功但页面打不开 | 装入了明文 dist | 入口是否有 PFBS 签名,pagesDir 是否指向 .build |
| 真机页面缺图 | 转换器未收集 ASTC 或动态资源 | bundle 文件清单与源资产差异 |
| 某平台缺引擎资源 | PAK 平台键或前缀不匹配 | AppID、注册名、pakName 三者 |
| 一份源码发错应用 | 多目标时隐式选择 AppID | 构建命令和权威映射配置 |
| 并行构建卡住 | PFBS watch 争抢固定端口 | 是否仍在生产任务中启动 watch |
| 正式包加载了开发页 | 仅按端口存在判断 dev 模式 | 会话标记、PID、loopback、受管路径 |
| 同一操作执行两次 | 主通道和 fallback 双投递 | 两通道是否共用 seq 并统一去重 |
| 页面偶发少状态 | fallback 使用单槽或 ACK 过早推进 | MsgQueue、批次最大 seq、裁剪时机 |
| 返回旧页面后状态倒退 | 缺少 generation 或 revision | 页面身份和领域版本是否同时校验 |
| 切页后无法点击 | covered 页面仍参与命中 | visibility、interactive、ZOrder |
| 反复开关后泄漏或崩溃 | Delegate、Lua ref、Root 未对称释放 | dispose 清单和重复销毁测试 |
| 真机模糊但浏览器正常 | DPI/PageScale/View 尺寸不一致 | 原生缩放诊断,而不是只改 CSS |
怎样验证这套实现?
构建层
- 校验 PFBS 工具摘要与入口签名,不能只校验文件存在。
- 校验 manifest 中每个应用和页面都有产物,生产目录没有未登记应用。
- 解包 Gamelet bundle,核对页面、普通资源和每个平台 PAK 的前缀与数量。
- 同一输入重复构建,比较 manifest 和关键文件摘要;多应用并行构建不能依赖固定监听端口。
协议层
- 单测同一 seq 从两条通道到达只派发一次。
- 构造乱序 revision,旧状态必须被拒绝。
- 页面关闭重开后投递旧 generation,必须被拒绝。
- 在队列裁剪、历史 seq 空洞和大批消息下验证 ACK 仍能推进且不误删未消费消息。
- suspend 时停止非必要推送,resume 后按订阅恢复快照。
宿主层
- 循环打开、覆盖、返回和销毁页面,检查 View、Delegate、Lua ref 与 Root 数量回到基线。
- 验证 PC/Android 的本地 PFBS 入口、UMG 外壳、ZOrder、输入命中和 DPI。
- 开发会话失效后必须回退生产页;非法路径和非 loopback 地址不能触发 dev 映射。
- UI 自动化通过稳定 DOM/TestID 操作真实客户端,不能只在浏览器 mock 宿主。
面试官:你个人具体做了什么?
可以直接说的版本
我在 UGC2.0 中直接做得比较深的是 PixUI 从制品到运行时的接入:验证并统一 PFBS 转换,补齐完整页面产物和增量转换,固定生产入口与显式开发会话,接入 C++ 原生页面宿主,处理 View 的层级、输入和生命周期,并把页面协议接到 Lua 服务与 TypeScript runtime。我还补了浏览器预览、热重载和客户端 UI 自动化需要的 DOM 能力。
旧 Gamelet 平台本身是团队已有基础设施。我通过源码和实际多应用工程把它的 AppID、bundle、PAK、窗口和消息链路梳理清楚,也做过相关构建接入与问题治理;但我不会说整个 Gamelet SDK 和底层 PixUI 引擎都是我设计的。
证据边界
| 内容 | 口径 |
|---|---|
| UGC2 PFBS 转换、固定入口、增量构建、生产 staging | 有个人提交,可说直接实现和治理 |
| 原生 PixUI PageHost、开发会话选择、层级输入与自动化接入 | 有个人提交,可作为个人主线 |
| UGC2 页面协议和 Lua/TS/C++ 生命周期 | 可结合实现讲个人参与与关键改造,具体 ownership 以提交为准 |
| 旧 Web 多应用与 Gamelet 构建问题 | 可说参与接入、排障和治理 |
| Gamelet 平台 SDK、PixUI 内核、全部旧客户端窗口代码 | 只说源码理解与上下游,不归为个人独立设计 |
这比“我封装了 PixUI”更可信,因为它说明了业务目标、分层边界、关键故障和可验证交付。
高频追问速答
Gamelet 是不是一个 WebView?
不是。它是应用容器和分发层,PixUI 只是页面运行时之一。它还管理应用身份、窗口、消息以及与 Unreal PAK 等资源的组合。
PixUI 为什么不用远端 URL 直接加载?
活动页可以按平台能力支持远端或动态分发;编辑器核心页更重视版本一致、离线可用和启动确定性,所以生产环境加载随客户端交付且通过签名校验的 PFBS。本地 URL 只在显式开发会话中使用。
PFBS 是加密吗?
回答时不要把它简单说成安全加密。源码能确认的是格式转换和运行时制品化,并有可识别签名;安全性仍依赖平台包校验、发布权限、协议权限和客户端整体防护。
为什么 Lua 不直接持有 PixUI UObject?
稳定整数句柄能收窄能力面,也避免 Lua 引用跨越 UObject/World 生命周期。对象所有权和线程约束留在 C++,Lua 只通过窄 API 操作 View。
为什么领域真值放 Lua,不放 React?
对象树、选择和属性还要被 3D 视口、保存、撤销和脚本共享。React state 只是页面投影;页面刷新或重建后应从领域服务恢复,而不是让 UI 缓存成为权威。
为什么不用一个通用 RPC 解决全部通信?
请求、命令、状态推送和长期事件的生命周期不同。统一 envelope 可以共享序列化和路由,但仍要区分一次请求、幂等命令、版本化状态和订阅,否则超时、重试和销毁语义会互相污染。
能保证消息不丢吗?
同一页面会话内通过主通道、fallback、seq、ACK 和快照恢复提高可靠性,但不宣称跨进程崩溃的 exactly-once。关键命令必须有业务确认和幂等,状态则可通过最新快照重建。
如果重新设计,你还会补什么?
我会补三件事:第一,用 Schema 生成 TS/Lua 双端类型、校验和协议版本,减少手写字符串;第二,统一传输指标,按页面和状态域观测消息数、字节、延迟、丢弃和队列水位;第三,把 Gamelet bundle、UGC 本地 PFBS 和自动化产物收敛到同一份可追踪 manifest,但保留两种交付模型的独立生命周期。
白板只画这一张
Build:
TS/UI -> webpack -> PFBS -> asset sync -> Gamelet bundle + platform PAK
\
UGC2 local: manifest -> verified dist/prod -> UE Cook/Stage
Runtime:
logical page / AppID
-> PageManager or Gamelet Container
-> C++ Window / NativePageHost
-> PixUI View
-> H5ES DOM/CSS + Yoga + Paint
-> Preact/TypeScript page
Render:
TSX -> Babel createElement -> Preact VNode/diff -> PixUI DOM
-> CSS/Yoga layout -> PixUI paint -> UE UMG/Slate
Input <- DOM event <- PixUI hit-test <- PixUIWidget <- UE pointer/key
Message:
TS --external.sendMessage--> PixUI Delegate --> C++ queue --> Lua domain
TS <--PostPxMessage--------- C++ Binding <--- Lua state
<--globals queue + seq/ACK fallback--讲解顺序固定为:先分概念 -> 再讲制品 -> 再讲加载 -> 再讲通信 -> 最后讲销毁和个人边界。
最短背诵版
Gamelet 是应用容器,PixUI 是页面运行时,PFBS 是页面二进制,PAK 是 Unreal 原生资源。React/Preact 负责组件状态和 VNode diff,最终通过 PixUI 的 H5ES DOM 更新元素;PixUI 用 CSS 与 Yoga 计算布局并生成绘制内容,UE PixUIWidget 再接入 UMG/Slate 上屏和输入。旧体系把 Web 产物经 webpack、PFBS 和资产补齐后,与目标平台 PAK 按 AppID 组装成 Gamelet bundle;客户端打开应用后,由 Gamelet 创建并管理 PixUI Window。UGC2 核心编辑器不走远端应用分发,而是把已验证 PFBS 随客户端交付,用 Lua PageManager、C++ NativePageHost 和 TypeScript runtime 管多页面。JS 到 Lua 经 PixUI Delegate 入 C++ 队列、帧末回调 Lua;Lua 到 JS 主要走 PostPxMessage,并用带 seq/ACK 的全局队列兜底。页面再用 pageId、generation、domain 和 revision 拒绝旧实例与乱序状态,销毁时对称释放 Delegate、Lua ref、View 和 UMG 外壳。