《游戏引擎实现原理》4.3 桌面打包与可选原生路径

桌面打包不只是「套个壳」:Electron 与 Tauri 两条路怎么共用同一份宿主接口,渲染进程与主进程之间的消息桥怎么收窄,可选原生模块又该放在哪一层。本节给出 L3 接口草图与打包配置形状,真跑 L1 存档桥的纯逻辑,并说明平台 SDK 打包与签名为何属 L4。

4.3 桌面打包与可选原生路径

《微光岛》在浏览器里跑通了,但「能发布」和「能在浏览器打开」是两回事:桌面玩家要一个能双击启动的安装包,要能读本地存档、要有窗口图标与全屏切换。这一节处理桌面交付——但要说清一件事:打包与签名无法在命令行里复现,所以本节给的是接口草图与配置形状(L3),不是「跑一遍就出安装包」的教程。

两条路:Electron 与 Tauri

桌面壳有两条主流路线,差别在「谁来渲染界面」和「谁来管原生能力」:

维度ElectronTauri
渲染内置 Chromium,与浏览器行为一致用系统 WebView(WKWebView / WebView2 / WebKitGTK)
原生层Node.js 主进程Rust 核心进程
包体大(自带 Chromium)小(复用系统 WebView)
跨端一致性高(各平台同一 Chromium)中(各平台 WebView 版本不同)
上手成本低(JS 一套到底)中(需 Rust 工具链)

对《微光岛》这种「渲染全靠 WebGPU、原生需求很薄」的项目,两条路都能走。关键是游戏代码不直接依赖任何一条——它只依赖一个宿主接口,壳是可替换的。

选型时还有一条现实约束:包体。Electron 自带 Chromium,安装包动辄上百 MB;Tauri 复用系统 WebView,通常只有十几 MB。如果发行渠道对体积敏感(比如独立游戏商店),Tauri 的体积优势会盖过它「各平台 WebView 版本不同」的一致性代价。下面给的接口对两条路都成立,选型不影响游戏侧代码。

可运行产物(L3):一份宿主接口草图

src/app/desktop-host.ts 层级 L3(仅类型 + 配置产物)。它把桌面原生能力收敛成 DesktopHost 一个接口,并把「渲染进程 → 主进程」的消息面收窄成 HostMessage 联合类型。本文件不产出可运行二进制,命令行不可复现。

// file: src/app/desktop-host.ts
// tier: L3
//
// 桌面壳的接口草图(仅类型):Electron / Tauri / 纯 Node 都实现同一份
// DesktopHost,游戏代码只依赖这个接口,不 import 任何 electron / tauri 模块。
// 本节需在真实设备上打包并运行才能验证,命令行不可复现——这里只给类型与配置产物。

export type DesktopShell = 'electron' | 'tauri' | 'node';

export interface WindowOptions {
  readonly title: string;
  readonly width: number;
  readonly height: number;
  readonly resizable: boolean;
  readonly fullscreen: boolean;
}

export interface FileFilter {
  readonly name: string;
  readonly extensions: readonly string[];
}

/** 桌面原生能力的最小面:窗口、文件对话框、退出。 */
export interface DesktopHost {
  readonly shell: DesktopShell;
  createWindow(opts: WindowOptions): Promise<void>;
  openFile(filters: readonly FileFilter[]): Promise<string | null>;
  saveFile(defaultName: string): Promise<string | null>;
  quit(): void;
}

/** 渲染进程 <-> 主进程的唯一通道:只允许这几种消息过桥。 */
export type HostMessage =
  | { readonly kind: 'openFile'; readonly filters: readonly FileFilter[] }
  | { readonly kind: 'saveFile'; readonly defaultName: string }
  | { readonly kind: 'quit' };

export interface HostReply {
  readonly kind: 'path' | 'cancelled' | 'ok';
  readonly path?: string;
}

/** 把窗口选项翻译成 Electron BrowserWindow 的配置对象。 */
export function electronWindowConfig(opts: WindowOptions): Record<string, unknown> {
  return {
    title: opts.title,
    width: opts.width,
    height: opts.height,
    resizable: opts.resizable,
    fullscreen: opts.fullscreen,
    webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
  };
}

/** 同一份窗口选项翻译成 Tauri window 配置;字段名不同、语义一致。 */
export function tauriWindowConfig(opts: WindowOptions): Record<string, unknown> {
  return {
    title: opts.title,
    width: opts.width,
    height: opts.height,
    resizable: opts.resizable,
    fullscreen: opts.fullscreen,
  };
}

两个 webPreferences 开关值得单独说:contextIsolation: true 与 nodeIntegration: false 把渲染进程和 Node 彻底隔开,游戏代码拿不到 require,只能通过 HostMessage 这条收窄的桥请求原生能力。这是安全底线——渲染进程一旦能直接读文件系统,一个 XSS 就能拿到整台机器。

electronWindowConfig 与 tauriWindowConfig 吃的是同一份 WindowOptions,输出两份字段名不同但语义一致的配置。游戏侧只构造一次 WindowOptions,壳的差异被这两个纯函数吸收。本机 npx tsc --noEmit 对该文件零错误(它是 L3,只保证类型正确,不保证能启动窗口)。

打包配置的形状

L3 的另一半是打包配置。这些文件不参与类型检查,但它们的形状决定了产物——下面是两份配置的骨架,字段是标准形状,具体版本号与图标路径需按实际工程填写。

Tauri 侧 src-tauri/tauri.conf.json:

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "glimmer-isle",
  "version": "1.0.0",
  "identifier": "com.helio.glimmerisle",
  "build": {
    "frontendDist": "../dist",
    "beforeBuildCommand": "npm run build:web"
  },
  "app": {
    "windows": [
      { "title": "微光岛", "width": 1280, "height": 720, "resizable": true }
    ],
    "security": { "csp": "default-src 'self'" }
  },
  "bundle": {
    "active": true,
    "targets": ["dmg", "app", "msi", "appimage"]
  }
}

Electron 侧 electron-builder.json:

{
  "appId": "com.helio.glimmerisle",
  "productName": "微光岛",
  "files": ["dist/**/*", "main.mjs"],
  "directories": { "output": "release" },
  "mac": { "target": "dmg", "category": "public.app-category.games" },
  "win": { "target": "nsis" },
  "linux": { "target": "AppImage" }
}

两份配置的 identifier / appId 必须一致,否则升级安装时系统会当成两个不同的应用。frontendDist 指向 Web 构建产物,壳只负责把 dist/ 装进去并开一个窗口。

可选原生路径的边界

有些能力 Web 侧做不了:读本地存档目录、调用平台手柄 API、跑一段高性能的离线烘焙。这时需要一条「可选原生路径」。helio 的原则是原生能力永远藏在 DesktopHost 后面,且必须可缺省——浏览器版本没有原生层,游戏逻辑要能在 shell === 'node' 的降级实现上照常跑,只是存档落到内存。

// snippet: 原生能力必须可缺省——探测不到就走纯 Web 实现
export function resolveHost(): DesktopHost | null {
  if (typeof (globalThis as { __helioHost?: unknown }).__helioHost !== 'undefined') {
    return (globalThis as { __helioHost?: DesktopHost }).__helioHost ?? null;
  }
  return null; // 浏览器:无桌面壳,调用方走 Web 兜底
}

把原生能力做成「注入在 globalThis 上的可选对象」,好处是 Web 构建产物里不含任何 Electron/Tauri 代码,包体不膨胀;代价是调用点要处理 null。对《微光岛》来说这个代价很小,因为真正需要原生的只有存档与退出两项。

注入方是壳的主进程。下面这段 Electron 主进程代码把 DesktopHost 实现出来并挂到 IPC 上——它需要在真实 Electron 环境里才能运行,属 L4,这里只作形状示意。

// snippet: Electron 主进程实现 DesktopHost(需真实 Electron 环境,L4)
import { app, BrowserWindow, dialog, ipcMain } from 'electron';

const host = {
  shell: 'electron',
  createWindow: (opts) => {
    const win = new BrowserWindow(opts);
    win.loadFile('dist/index.html');
    return Promise.resolve();
  },
  openFile: (filters) => dialog.showOpenDialog({ filters }).then((r) => r.filePaths[0] ?? null),
  saveFile: (name) => dialog.showSaveDialog({ defaultPath: name }).then((r) => r.filePath ?? null),
  quit: () => app.quit(),
};
ipcMain.handle('helio:host', () => host);

注意原生 API(dialog、app)只出现在主进程里,渲染进程通过 ipcMain.handle / ipcRenderer.invoke 这一条 IPC 拿到结果。这条边界与 contextIsolation: true 是同一件事的两面:原生能力必须跨进程调用,渲染进程拿不到直接的句柄。

可运行产物(L1):存档桥的纯逻辑

存档是「可选原生路径」最典型的例子:桌面上存到文件系统,浏览器里存到 IndexedDB。两者的差异可以收进一个 SaveBridge 接口,而接口之上的逻辑是纯的、可单测的。src/app/save-bridge.ts 层级 L1,给出槽位命名、内存兜底实现与选择器。

// file: src/app/save-bridge.ts
// tier: L1
// verify: npx vitest run src/app/save-bridge.test.ts
//
// 存档桥:游戏只依赖 SaveBridge,桌面壳给原生实现(文件系统),
// 浏览器给 Web 实现。这里给出纯逻辑:槽位命名 + 内存兜底实现 + 选择器。
// 真正读写文件的那部分属 L3/L4,命令行不可复现。

export interface SaveBridge {
  readonly backend: 'native' | 'web';
  read(slot: string): Promise<string | null>;
  write(slot: string, data: string): Promise<void>;
  list(): Promise<readonly string[]>;
}

/** 槽位命名:前缀 + 三位补零,保证字典序等于序号序。 */
export function slotName(index: number): string {
  if (!Number.isInteger(index) || index < 0) throw new RangeError(`slot index: ${index}`);
  return `glimmer-isle-${String(index).padStart(3, '0')}`;
}

/** 浏览器兜底实现:内存 Map;真实工程换成 IndexedDB 持久化。 */
export function makeWebSaveBridge(store: Map<string, string> = new Map()): SaveBridge {
  return {
    backend: 'web',
    read: (slot) => Promise.resolve(store.get(slot) ?? null),
    write: (slot, data) => {
      store.set(slot, data);
      return Promise.resolve();
    },
    list: () => Promise.resolve([...store.keys()].sort()),
  };
}

/** 有原生桥就用原生,没有就退回 Web 实现——原生能力必须可缺省。 */
export function pickSaveBridge(nativeBridge: SaveBridge | undefined): SaveBridge {
  return nativeBridge ?? makeWebSaveBridge();
}

slotName 的「三位补零」不是洁癖:桌面壳的文件系统按名字排序时,glimmer-isle-10 会排在 glimmer-isle-2 前面;补零后字典序等于序号序,列表里槽位顺序才稳定。pickSaveBridge 则是「可缺省」原则的落点——原生桥缺失时静默退回 Web 实现,游戏逻辑一行不用改。

配套单测 src/app/save-bridge.test.ts 层级 L1。本机实跑 npx vitest run src/app/save-bridge.test.ts → 4 passed,npx tsc --noEmit 零错误。覆盖了槽位命名的边界(负数抛 RangeError)、Web 桥的读写列举、以及「有/无原生桥」两条分支。

L4:平台 SDK 打包与签名

配置写对不等于能发布。一个 tauri.conf.json 写得再标准,也无法证明它在目标机器上能装、能开、能读到存档。本节需在真实设备 / 平台上手动验证,命令行不可复现,原因有三:

  • 签名与公证:macOS 的代码签名、公证(notarization)与 Windows 的 Authenticode 签名都需要各自的开发者证书与平台账号,无法在无凭据的 CI 上完成。
  • 平台 SDK 依赖:Tauri 的 macOS 打包依赖 Xcode 命令行工具、Windows 依赖 WebView2 与 MSVC 工具链、Linux 依赖 WebKitGTK,这些都不是 Node 环境能替代的。
  • 产物只能在目标系统上运行验证:.dmg 只能在 macOS 打开、.msi 只能在 Windows 打开,交叉构建出的产物必须到目标机器上双击验证。

因此本节的 L3 只保证「接口与配置形状正确」,把「产物能不能装、能不能启动、签名过不过」全部留给目标平台上的手动验证。

小结

  • 桌面交付的壳是可替换的:游戏代码只依赖 DesktopHost,Electron 与 Tauri 各实现一份。
  • 消息面用 HostMessage 收窄,配合 contextIsolation + nodeIntegration: false 把渲染进程与 Node 隔离。
  • 原生 API 只出现在主进程,渲染进程经 IPC 调用;这条边界和进程隔离是同一件事的两面。
  • 打包配置的关键是一致性(identifier / appId)与产物路径,字段形状可类型化,但生成物不可命令复现。
  • 存档这类「可选原生路径」的纯逻辑(槽位命名、兜底实现、选择器)是 L1,可单测;真正读写文件属 L3/L4。
  • 原生能力必须可缺省,浏览器版本走 Web 兜底;签名、公证与平台 SDK 依赖属 L4。

下一章进入模块化与裁剪:helio 的模块边界怎么划、依赖方向怎么保证、特性开关如何裁掉死代码。

阅读导航:上一节:4.2 移动端与低端设备 · 下一节:5.1 模块边界与依赖方向 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径