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 身份
PFBSPixUI 页面制品格式把 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 ManagerLua PageManager + NativePageHost + C++ Binding
生命周期应用和 Window 的 open/visible/closecreated/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,而是构建工具做了两层适配:

  1. Babel 去掉 TypeScript 类型,把 JSX 转成 h(...),再由 jsx-pragmatic 稳定改写为从 preact 引入的 createElement。
  2. 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

执行顺序可以拆成七步:

  1. 宿主创建 PixUI View,设置逻辑尺寸、DPI/缩放、字体和资源回调,再加载 URL、游戏资产、内存数据或 PFBS 页面。
  2. PixUI 创建页面 JS 运行环境,准备精简的 window、document、定时器、网络、事件和宿主扩展。
  3. 入口脚本调用 Preact render。组件函数生成 VNode,初次 reconciliation 创建真实 H5ES 元素节点。
  4. 定制 Preact 通过 document.createElement/createTextNode、appendChild/insertBefore、setAttribute、style 和 addEventListener 操作 PixUI DOM。
  5. PixUI 解析 HTML/CSS 和内联 style,把样式映射到自己的元素树;布局主要交给 Yoga Flex 计算尺寸与位置。
  6. PixUI 按布局结果形成文字、色块、边框、图片、视频、2D Canvas 等绘制内容;pixslot 可以在页面指定位置嵌入 UE 原生 Widget 或资源。
  7. 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 行为不可假设
DOMH5ES 必要子集,继承关系与浏览器略有不同依赖完整 HTMLElement 体系的库可能失败
CSS常用样式、transform、transition、animation可动画属性、层级和默认值存在差异
Canvas2D依赖 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 DOMPreact 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

这里至少有三种产物:

  1. dist 是 webpack 的明文构建结果,适合浏览器侧检查,但不是最终 PixUI 发布物。
  2. dist/.build 是 PFBS 后处理结果,页面入口可检查到 PIX0,脚本可检查到对应二进制签名。
  3. 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、destroyDelegate、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"| UE

TypeScript 在运行时已经是 JavaScript。它不直接“调用 C++ 函数”,而是发送有类型、有版本语义的纯值消息。C++ 负责 PixUI 通道和引擎对象生命周期,Lua 负责编辑器领域状态和命令处理。

JS/TS -> C++ -> Lua

  1. 页面把 {type, seq, data} 序列化为 JSON,首选 PixUI 官方的 window.external.sendMessage。
  2. 兼容实现还可以发 window.postMessage 或写 fallback outbox,但出站消息会加来源标记,避免被当前页面自己的 message 监听器当作入站状态。
  3. PixUI 的 DelegateOnMessage 在 C++ 中接收消息。C++ 绑定 UObject Forwarder,并把消息写入待处理队列。
  4. Scene Subsystem 在帧末 drain 队列,通过 Lua registry 中的 callback ref 回调 Lua。
  5. 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同一个逻辑页面的第几次实例关闭重开后,旧实例消息立即失效
domainscene、selection、properties 等状态域各状态域独立演进和订阅
revision某状态域的新旧顺序只接受严格更新版本
seq传输消息身份跨主通道与 fallback 精确去重
ACKfallback 已完整消费到哪里仅在完整批次 drain 后推进

generation 和 revision 不能合并。前者是页面实例身份,后者是领域状态版本。新页面可以从低 revision 请求全量快照,但不能接受旧页面实例迟到的高 revision。

页面恢复为什么是“订阅 + 快照”,不是缓存所有消息?

页面被覆盖后可能暂停接收高频状态。重新激活时,继续重放所有历史增量成本高且容易遗漏;更稳定的模型是保留订阅意图和已知版本,恢复后按状态域请求最新快照,再继续接收新版本。领域真值留在 Lua 服务,页面只是投影。

多页面生命周期怎样闭环?

页面状态

created -> active -> suspended / covered -> active -> disposed
  • replace:新页面替换当前主页面,旧页面按恢复策略挂起或销毁。
  • push:压入页面栈;如果保留底页,底页进入 covered,但不接收输入。
  • overlay:独立层叠加,适合试玩覆盖层,不破坏主页面栈。

页面创建时分配新的 generation,建立 Native View 并注入页面上下文;激活时绑定长生命周期服务、重置该 View 的 fallback 投递水位并请求订阅快照;挂起时关闭输入和业务推送;恢复时重新取状态;最终销毁才释放所有原生资源。

销毁清单

一次完整 dispose 至少包括:

  1. 停止页面订阅、定时器、Frame Scheduler 和未完成 callback。
  2. 从 PixUI Widget 移除消息与日志 Delegate。
  3. 释放 Lua registry callback ref,清除待处理消息和按钮回调。
  4. 解除 Forwarder 的 Root 引用和业务对象引用。
  5. 移除 UMG 外壳或 PixUI Widget,清理 ViewID 映射。
  6. 使旧 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 外壳。

关联阅读