游戏内 Web 工程:PixUI 多应用、JSB 与构建发布
这篇回答“你为什么一个客户端工程师会做 Web”以及“你做的 PixUI 封装到底有什么技术含量”。主线是游戏内多应用工程、JSB 通信和构建发布,不把工作描述成写页面。
面试官:先介绍一下你的 Web 与跨端工作
30 秒回答
我的 Web 工作不是传统官网前端,而是游戏客户端中的跨端 UI 与工具工程,主要有三类:第一类是 PixUI 多应用,把高频迭代页面运行在游戏宿主内;第二类是 SSR/BFF 页面,承接配置、活动和接口聚合;第三类是可视化编程,涉及 Blockly 内核、积木生产和 IDE。我的核心价值在宿主边界、JSB 协议、共享 SDK、构建制品、多端兼容和发布治理,而不只是 React 页面开发。
90 秒回答
在 PixUI 项目里,最初不同应用各自复制平台 API、事件和构建脚本,应用一多就会出现 callback ID 冲突、事件重复、依赖版本不一致和发错 AppID 等问题。我参与把它整理成 monorepo:业务放在 apps,共享协议、SDK、UI、工具和 CLI 放在 packages,应用身份与发布目标放在配置层。
运行时,TypeScript 页面不能直接调用 UObject。页面通过共享 SDK 生成全局唯一 callback ID,把请求交给游戏宿主;宿主异步返回后由统一分发器找到回调,完成一次消费并清理。长期事件另走订阅模型,并按页面或 bundle owner 在销毁时统一解绑。
构建侧也不是 webpack 输出一个目录就结束,而是源码构建、PFBS 二进制化、漏拷资产补齐、Gamelet bundle、引擎 PAK 合并,再进入 CI 上传、白名单和发布。我的工作重点是把多应用从“每个项目各自能跑”推进到“共享能力可治理、制品身份可追踪、生命周期可回收”。
面试官:为什么游戏客户端要用 Web?
Web 适合高频变化、列表和表单密集、跨平台复用价值高的 UI;原生客户端更适合 3D 交互、高帧率 HUD、复杂输入和强引擎依赖。选型不是“Web 开发快,所以全做 Web”,而是根据变化频率、交互复杂度、性能预算、离线需求和宿主能力划边界。
更适合 Web:活动页 / 配置页 / 复杂信息流 / 创作辅助面板
更适合原生:高频战斗 HUD / 3D Gizmo / 强输入反馈 / 核心启动与容灾只要页面运行在游戏里,客户端仍要负责原生 View、输入焦点、前后台、内存、JSB 权限、版本兼容、离线包和故障兜底。所以这不是从客户端转去“只写页面”,而是把客户端边界扩展到嵌入式 Web 运行时。
面试官:PixUI 多应用工程怎样设计?
分层结构
flowchart TB
APPS["apps/*<br/>独立业务应用"]
PKG["packages/*<br/>SDK / UI / Protocol / Utils / CLI"]
CFG["configs<br/>AppID / target / 环境 / 白名单"]
BUILD["统一构建编排<br/>workspace + task graph"]
ART["PFBS / Gamelet Bundle / PAK"]
HOST["游戏客户端宿主"]
APPS --> PKG
APPS --> CFG
PKG --> BUILD
CFG --> BUILD
APPS --> BUILD
BUILD --> ART --> HOST边界原则
| 层 | 应该放什么 | 不应该放什么 |
|---|---|---|
apps | 页面业务、应用路由、应用级状态和资源 | 私有复制一份平台 SDK |
packages/sdk | 宿主 API、回调、事件、环境和协议适配 | 某个页面的业务判断 |
packages/ui | 可复用且稳定的组件 | 强绑定某应用数据结构的组件 |
packages/cli | 构建、制品和校验能力 | 运行时业务逻辑 |
configs | 应用身份、目标环境和发布映射 | 隐藏在脚本中的散落常量 |
共享的判断标准不是“代码长得像”,而是它是否拥有相同契约和发布节奏。过早共享业务组件会让一个应用的改动牵动全部应用;平台能力如果不共享,则会复制出多套不兼容实现。
面试官:为什么要做共享 SDK 重构?
原先每个应用内都有本地平台 API 封装,短期容易接入,长期会产生四类问题:
- 同一个宿主接口有多种参数和错误约定。
- callback ID 在多个 bundle 中可能冲突。
- 页面销毁后回调和事件没有统一回收。
- 修复要在多个应用重复合入,版本逐渐漂移。
共享 SDK 把宿主协议、回调注册、事件订阅、超时、环境探测和错误归一集中治理。迁移时不能只替换 import,还要检查旧模块是否残留全局监听、返回结构是否一致,以及新旧 SDK 同时存在时是否重复响应。
面试官:JS、Lua、C++ 是怎么通信的?
先澄清两个场景
旧 PixUI 多应用项目的核心链路是“JavaScript/TypeScript 页面 <-> 游戏宿主 API”。宿主内部可能继续进入 Lua 或 C++,但 Web 仓库拥有的是 JSB 协议与 SDK,并不拥有全部客户端实现。
UGC2.0 则在此基础上进一步建设了 PageManager、EditorService、原生页面宿主和可靠消息协议。两者面临相似问题,但不能把后来的 UGC2.0 设计倒推成旧项目当时已经完整具备。
请求与回调链
sequenceDiagram
participant P as TypeScript 页面
participant S as 共享 SDK
participant H as PixUI / 游戏宿主
participant N as Lua 或 C++ 业务
P->>S: 调用平台 API(payload)
S->>S: 创建 callbackId<br/>登记 owner 与 timeout
S->>H: method + payload + callbackId
H->>N: 原生分发与业务处理
N-->>H: result / error
H-->>S: callbackId + result
S->>S: 查找、删除、清理定时器
S-->>P: resolve / rejectTypeScript 在运行前已经编译成 JavaScript。页面不持有 UObject 指针,也不直接调用任意 Lua 函数,而是只依赖稳定的平台协议。
面试官:callback ID 为什么不能只是模块内自增变量?
一个页面单独运行时,模块内自增看似够用;多个 bundle 或多份 SDK 同时加载后,每份模块都可能从相同初值开始,宿主返回时就无法唯一定位回调。
因此回调表放在页面运行环境共享的全局命名空间中,ID 生成也跨 bundle 唯一。每条记录至少包含回调函数、owner、创建时间和超时句柄。结果到达后执行 take 语义:取出即删除,保证一次性回调不会重复消费。
还要限制 pending 数量。否则宿主异常或页面持续请求会让回调表无界增长。达到上限时应拒绝或淘汰最旧项并留下诊断,而不是静默泄漏。
面试官:页面销毁后回调回来怎么办?
回调必须有 owner,可以是页面实例、bundle 或业务作用域。页面销毁时按 owner 批量取消 pending callback、清理 timeout 并解绑事件。之后即使宿主返回,分发器也只记录“回调已失效”,不能再修改旧页面状态。
如果请求结果会改变跨页面业务真值,正确做法不是强行保活页面回调,而是让宿主或领域服务拥有结果,再由新页面重新读取状态。
面试官:请求回调和事件订阅有什么区别?
| 模型 | 生命周期 | 典型用途 | 清理方式 |
|---|---|---|---|
| 请求/回调 | 一次返回或超时 | 查询、提交、拉取数据 | 返回即删除,超时取消 |
| 事件订阅 | 多次推送 | 登录变化、前后台、宿主状态 | 显式 unsubscribe 或按 owner 清理 |
不能把长期事件伪装成永不结束的 callback。事件还要防止同一 handler 重复注册,并明确是否允许多个页面同时订阅。
面试官:线程问题怎样处理?
JSB 看起来是函数调用,实际可能跨 Web 运行线程、游戏线程和网络回调线程。协议层只传纯值,C++ 侧涉及 UObject 或 Widget 的操作必须调度到合法线程;回调进入页面前还要确认 View 仍有效。
线程切换不能靠调用方猜测。宿主 API 要定义“在哪个线程接收、在哪个线程完成、页面回调是否异步”,否则偶发崩溃和重入问题很难复现。
面试官:PixUI 的构建发布链是怎样的?
完整链路
flowchart LR
SRC["TypeScript / React / Assets"]
DIST["Webpack 构建 dist"]
PFBS["PFBS 编译<br/>PIX0 / PIX2"]
SYNC["补齐未被 PFBS 收集的资产<br/>含 ASTC 等"]
BUNDLE["Gamelet Bundle"]
PAK["按目标合入引擎 PAK"]
CI["CI 上传 / 白名单 / 发布"]
CLIENT["客户端按 AppID 加载"]
SRC --> DIST --> PFBS --> SYNC --> BUNDLE
PAK --> BUNDLE
BUNDLE --> CI --> CLIENTdist 是 Web 构建结果,不是最终游戏制品。PFBS 会把页面内容转换成运行时需要的二进制格式;随后补齐编译器未覆盖的普通资产,再由 Gamelet 工具把页面与目标平台资源打成 bundle。CI 负责上传、环境、白名单与最终发布。
面试官:为什么还要补齐资产?
构建器通常只理解它认识的依赖类型。某些运行时资源、动态引用文件或 ASTC 产物未必会被 PFBS 自动收集。如果只看编译成功,页面可能到真机才出现缺图。
所以制品阶段要比较源资产和已生成清单,按明确规则补齐遗漏,同时排除报告、临时文件和不应发布内容。更进一步应让动态资源也进入显式 manifest,逐步减少“兜底复制”的范围。
面试官:一个源码应用为什么会对应多个 AppID?
同一套业务源码可能面向不同入口、环境或发布目标,但宿主按 AppID 识别应用。配置层需要把“源码目录”和“发布身份”拆开:一个 source 可以映射多个 target,每个 target 有自己的 AppID、注册名、PAK 和环境参数。
当映射不唯一时,命令必须显式指定目标,不能默认挑一个。否则最危险的问题不是构建失败,而是成功构建后发布到了错误应用。
面试官:如何做白名单、灰度和回滚?
制品必须有不可变身份,例如内容哈希、版本号和目标 AppID。发布系统在制品之上维护环境、白名单和生效指针:先上传不可变制品,再让测试白名单引用它,验证通过后扩大范围;回滚只切回上一已验证制品,不重新现场构建。
页面自身也要暴露版本和宿主协议版本。出现问题时才能回答“哪个用户、哪个 AppID、加载了哪个 bundle、对应什么宿主版本”。
面试官:多应用工程最容易坏在哪里?
我会优先检查的六件事
- 共享 SDK 出现破坏性变更,但应用没有锁定兼容范围。
- 同一宿主事件被新旧 SDK 重复注册。
- AppID、注册名和 PAK 目标发生错配。
- 本地开发读取源码资产,真机制品却漏打动态资源。
- 页面退出后 callback、timer、audio 或 listener 没有清理。
- 低版本 WebView 不支持新语法或 API,测试环境却只覆盖现代浏览器。
对应的门禁包括依赖图检查、类型与协议测试、按应用构建、制品清单校验、真机冒烟、宿主兼容矩阵以及发布前目标确认。
面试官:你如何证明自己不是只会写页面?
我的工作对象不是单个 React 页面,而是页面如何作为游戏客户端的一部分可靠运行。我处理过多应用依赖治理、共享 SDK、跨 bundle callback 唯一性、页面销毁后的资源回收、JSB 线程和协议边界,以及从 Web 产物到 PFBS、Gamelet、PAK 和发布目标的完整制品链。页面业务是入口,真正的工程问题是运行时、生命周期、兼容性和交付。
面试官:这套方案还有什么不足?
第一,共享 SDK 会扩大改动半径,需要协议版本、兼容测试和分阶段迁移,不能靠“全仓一起升级”长期维持。第二,资产补拷是必要兜底,但理想状态是所有动态依赖都进入 manifest。第三,旧链路的 callback 能解决请求返回,却不自动解决页面状态可靠恢复;这也是后来在 UGC2.0 中进一步引入页面代际、序号、版本和 ACK 的原因。
快速背诵版
我做的 PixUI 不是简单页面封装,而是游戏内多应用基础设施。工程上用 monorepo 把业务应用与共享 SDK、UI、协议和构建工具分开;运行时由共享 SDK 管理跨 bundle 唯一 callback、超时、事件订阅和按页面 owner 清理,再通过 JSB 进入 Lua/C++ 宿主;构建时从 Web dist 继续经过 PFBS、资产补齐、Gamelet bundle 和目标 PAK,最后由 CI 做白名单和发布。我的优势是能同时处理 Web 工程和客户端宿主边界,而不是只会写前端页面。
责任边界
| 内容 | 面试表述 |
|---|---|
| 多应用工程、共享 SDK 重构与应用迁移 | 可说“我参与推进/负责关键改造” |
| callback、事件、超时和 owner 清理 | 可结合具体源码说“我实现/治理” |
| PFBS、Gamelet、PAK 的项目构建接入 | 可说“我做过完整链路接入与排障” |
| 宿主内部全部 Lua/C++ 实现 | 只说“上下游协作”,除非有直接提交证据 |
| 当前仓库中的全部应用 | 不说成全部由我开发 |
| SSR/BFF 与 Coding 的完整专题 | 本文只给全景,后续单独展开 |