《Cocos 游戏开发入门》17.2 多端发布:H5 / 微信小游戏 / 原生

同一份《方块冲刺》产物要发到三个目标:H5、微信小游戏、原生。本节先对照三端的包体与能力约束,再分别给出 H5 的静态托管与 CDN 缓存策略、微信小游戏 4MB 首包限制下的分包方案、原生 WebView 壳与平台 API 桥接,最后写一层纯 TS 平台抽象把差异收敛到一处。

17.2 多端发布:H5 / 微信小游戏 / 原生

上一节把《方块冲刺》打成了一个 dist/ 目录。这个目录本身是平台中立的——它就是一堆静态文件。但「静态文件」发到不同目标上,约束完全不同:H5 丢进 CDN 就完事,微信小游戏卡在 4 MB 首包的硬门槛上,原生则要套一层 WebView 壳再接平台 API。这一节把三端拆开讲,最后写一层纯 TS 的平台抽象,让游戏逻辑不必到处写 if (isWeChat)。

17.2.1 三端约束对照

先把差异摆到一张表上,后面每个小节都是这张表的展开:

维度H5微信小游戏原生(WebView 壳)
运行容器浏览器微信运行时原生 App 内嵌 WebView
产物形态静态文件目录主包 + 分包 + 远程资源打包进 App 的本地资源
包体限制无硬限制首包 4 MB / 总包 20 MB 量级受 App 商店上限约束
网络资源自由需后台配置域名白名单自由(或走原生下载)
平台能力受限分享/振动/支付/广告最全(含系统级能力)
主要难点缓存与首屏包体拆分桥接与 WebView 性能

一个关键认知:三端不是三套代码,而是同一份产物 + 三套「外壳」。游戏逻辑(帧循环、碰撞、关卡)完全复用,差异被压缩到「资源怎么加载」「能力怎么调用」这两个点上。这正是平台抽象层要解决的事。

17.2.2 H5:静态托管与 CDN

H5 是最省心的一端。dist/ 里全是静态文件,丢给任意静态服务器、对象存储或 CDN 即可。真正要花心思的是缓存策略,因为它直接决定「玩家第二次进来要下多少字节」。

上一节给产物文件名加了内容哈希(main-8f3a1c.js),这个哈希是缓存策略的基石:

location ~* \.(js|css|woff2|png|webp|mp3)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
  gzip on;
  gzip_types application/javascript text/css image/svg+xml;
}

location = /index.html {
  add_header Cache-Control "no-cache";
}

第一条规则针对带哈希的产物(JS/CSS/字体/图片/音频),用「永久 + immutable」缓存:内容变了文件名就变,浏览器见到同名文件直接读本地缓存,连一次 304 询问都不发。第二条针对入口 HTML,用「不缓存」:每次发布它都指向新的哈希文件名,于是玩家下次打开自动拉到新版本。这就是「内容寻址」的威力——不需要任何版本号管理,缓存失效由文件名自己保证。

几个常被忽略的细节:

  • base 路径。如果产物不放在域名根目录(比如挂在 https://cdn.example.com/game/),必须把 vite.config.ts 里的 base 设成对应路径,否则所有绝对路径的资源都会 404。设成 './' 相对路径最保险。
  • 文本压缩。.js/.css/.json 一定要开 gzip 或 Brotli,.png/.mp3 别再压(已压缩格式,白费 CPU)。
  • 字体跨域。自定义字体(.woff2)默认受 CORS 限制,CDN 要回 Access-Control-Allow-Origin,否则控制台报错、字体静默不生效。
  • HTTPS。音频自动播放、Service Worker、部分平台 API 都要求 HTTPS,本地测完别忘了线上证书。

17.2.3 微信小游戏:包体限制与分包

微信小游戏是约束最强的一端。它对首包(主包)大小有硬限制,量级在 4 MB;包含所有分包在内的总包限制在 20 MB 量级。超了直接传不上去——这不是性能建议,是平台红线。

应对策略是三层,从内到外:

  1. 首包只放必需品:引擎代码、首屏 UI、第一个关卡。目标是「点开就能玩」。
  2. 其余内容拆进分包:后续关卡、Boss 关、高分辨率素材。分包在玩家触发时下载。
  3. 更大的资源走 CDN:音频、视频、大图集放远程,运行时按需下载(需在小游戏后台配置资源域名白名单)。

首包那 4 MB 该怎么分?把预算拆成几块,比「尽量少放」更有操作性:

首包内容预算量级理由
引擎运行时1.0–1.5 MB固定开销,压不动
首屏 UI 与图标300 KB必须立刻显示
第一个关卡400 KB点开就能玩
首关音效300 KB缺失会让首屏体验残缺
余量1 MB留给后续迭代,别顶满

关键是别把 4 MB 顶满。留出一段余量,后续加一关、改一版 UI 时才不至于立刻超标返工;真需要更多内容,宁可再多做一层分包。远程资源下载后可以落进小游戏的文件系统缓存,第二次进入不必重下——但缓存不保证命中(用户清缓存、换设备都会失效),所以关键的首屏资源仍应放进主包,远程资源只用于「缺失也能降级」的部分。

分包由小游戏根目录的 game.json 声明:

{
  "deviceOrientation": "landscape",
  "showStatusBar": false,
  "subpackages": [
    { "name": "levels-a", "root": "levels/a/" },
    { "name": "levels-b", "root": "levels/b/" }
  ]
}

root 是该分包相对根目录的路径,构建时把对应文件塞进去即可。运行时用 wx.loadSubpackage 按需拉取,它和主包一样是异步的,要给玩家进度反馈:

// src/platform/wechat-subpackage.ts
interface SubPackageTask {
  onProgressUpdate(cb: (res: { progress: number }) => void): void;
}

interface WeChatApi {
  loadSubpackage(options: { name: string }, onDone: (err: unknown) => void): SubPackageTask;
}

function getWeChat(): WeChatApi | null {
  const g = globalThis as { wx?: WeChatApi };
  return g.wx ?? null;
}

export function loadPack(name: string, onProgress: (p: number) => void): Promise<void> {
  return new Promise((resolve, reject) => {
    const wx = getWeChat();
    if (!wx) {
      // 非微信环境(本地调试)直接放行,避免逻辑分叉
      resolve();
      return;
    }
    const task = wx.loadSubpackage({ name }, (err) => (err ? reject(err) : resolve()));
    task.onProgressUpdate((res) => onProgress(res.progress));
  });
}

这里用结构化的接口描述 wx,而不是引入官方类型包——mini2d 的依赖清单里没有它,用最小接口 + globalThis 断言就能既通过类型检查、又保持零运行时依赖。注意在非微信环境下直接 resolve,否则本地用浏览器调试时会卡在「永远等不到分包」上。

17.2.4 原生:WebView 壳 + 平台 API

「原生发布」听起来像是要重写一遍,其实不是。对《方块冲刺》这类 2D 游戏,最经济的做法是把 WebView 当运行容器:产物还是那份 dist/,只不过塞进 App 的本地资源里,由一个原生壳加载。

壳工程做三件事:

  • 加载本地资源。iOS 用 WKWebView.loadFileURL、Android 用 WebView.loadUrl("file:///android_asset/..."),指向打进 App 的产物。不要加载远程 URL,否则离线白屏、启动受网络影响。
  • 暴露平台 API。原生侧把振动、分享、支付、广告、录屏等能力通过 JSBridge 暴露给 JS。iOS 走 window.webkit.messageHandlers.<name>.postMessage,Android 往 WebView 注入一个全局对象。JS 侧统一用「能力探测」判断是否可用。
  • 处理生命周期。App 切后台、来电打断时,要通知 JS 暂停游戏(对应第 4 章的暂停与时间缩放),回前台再恢复。

代价是性能。WebView 里的 Canvas 渲染比原生渲染后端慢一截,重画布、粒子特效密集的场景尤其明显。如果实测帧率不达标,再考虑换原生渲染后端——但那已经是「另一套渲染层」的工作量,不应作为第一选择。

平台抽象层要能识别这三种环境。下面用能力探测而不是 UA 字符串,因为 UA 可以被伪造、也会随平台更新而变:

// src/platform/detect.ts
interface WeChatApi {
  getStorageSync(key: string): string;
  setStorageSync(key: string, value: string): void;
  vibrateShort(options: { type: 'heavy' | 'medium' | 'light' }): void;
}

function getWeChat(): WeChatApi | null {
  const g = globalThis as { wx?: WeChatApi };
  return g.wx ?? null;
}

function hasAndroidBridge(): boolean {
  return typeof (globalThis as { AndroidBridge?: unknown }).AndroidBridge !== 'undefined';
}

export type PlatformName = 'h5' | 'wechat' | 'native';

export function detectPlatform(): PlatformName {
  if (getWeChat()) return 'wechat';
  if (hasAndroidBridge()) return 'native';
  return 'h5';
}

17.2.5 平台抽象层:把差异收敛到一处

三端的差异只落在两个能力上:存储与振动。把这两件事抽象成接口,游戏逻辑就再也不用关心自己跑在哪:

// src/platform/types.ts
export interface Storage {
  get(key: string): string | null;
  set(key: string, value: string): void;
}

export interface Platform {
  readonly name: PlatformName;
  readonly storage: Storage;
  vibrate(ms: number): void;
}

H5 用 localStorage,微信走同步存储 API,原生若桥接了则优先用原生存储、否则退回 localStorage:

// src/platform/h5.ts
import type { Platform } from './types';

export function createH5Platform(): Platform {
  return {
    name: 'h5',
    storage: {
      get: (key) => globalThis.localStorage.getItem(key),
      set: (key, value) => globalThis.localStorage.setItem(key, value),
    },
    vibrate: (ms) => {
      // 浏览器振动 API,部分桌面浏览器不支持,静默忽略即可
      globalThis.navigator?.vibrate?.(ms);
    },
  };
}
// src/platform/wechat.ts
import type { Platform } from './types';

interface WeChatApi {
  getStorageSync(key: string): string;
  setStorageSync(key: string, value: string): void;
  vibrateShort(options: { type: 'heavy' | 'medium' | 'light' }): void;
}

export function createWeChatPlatform(wx: WeChatApi): Platform {
  return {
    name: 'wechat',
    storage: {
      get: (key) => wx.getStorageSync(key) || null,
      set: (key, value) => wx.setStorageSync(key, value),
    },
    // 小游戏只提供「短振动」,把毫秒数映射到强度档位
    vibrate: (ms) => wx.vibrateShort({ type: ms >= 40 ? 'heavy' : 'light' }),
  };
}

最后由一个工厂按 detectPlatform() 的结果装配。游戏代码只依赖 Platform 接口,测试时注入一个假的实现即可——这正是第 16 章「纯逻辑单测」里用假对象替换真实依赖的同一套思路:

// src/platform/index.ts
import { detectPlatform } from './detect';
import { createH5Platform } from './h5';
import { createWeChatPlatform } from './wechat';
import type { Platform } from './types';

interface WeChatLike {
  getStorageSync(key: string): string;
  setStorageSync(key: string, value: string): void;
  vibrateShort(options: { type: 'heavy' | 'medium' | 'light' }): void;
}

let cached: Platform | null = null;

export function getPlatform(): Platform {
  if (cached) return cached;
  const wx = (globalThis as { wx?: WeChatLike }).wx;
  // 微信环境走小游戏实现;其余(含未桥接存储的原生)统一退回 H5 实现
  cached = detectPlatform() === 'wechat' && wx ? createWeChatPlatform(wx) : createH5Platform();
  return cached;
}

cached 做单例,是因为平台在进程生命周期内不会变,反复探测没有意义。而 getPlatform() 这个统一入口,让「振动一次」在三种平台上都只是一句 getPlatform().vibrate(30)——调用方永远不知道自己在哪端。

17.2.6 发布检查清单

三端都能发之后,把每次发布前要过一遍的点固化成清单,比靠记忆可靠:

检查项H5微信小游戏原生
产物体积在预算内✓✓(首包 4 MB)✓
哈希文件名 + 入口不缓存✓不适用不适用
分包声明与首包分离不适用✓不适用
资源域名白名单不适用✓视方案
JSBridge 能力探测不适用不适用✓
后台切前台暂停/恢复建议建议必须
首屏可在弱网下启动✓✓✓

这张表也解释了为什么「一套代码多端发布」不是免费的:逻辑复用是真的,但每多一个目标,就多一行「平台特有的验证项」。把这些验证项自动化,就是下一节 Cocos 构建面板在做的事——它把「选平台、拆分包、开压缩」变成勾选项,代价是你得接受它的默认约定。

Cocos Creator 3.8 的构建面板到底长什么样、每个平台产出什么结构,下一节对照着讲。

阅读导航:上一节:17.1 构建流程与产物体积分析 · 下一节:17.3 Cocos 对照:构建面板与多平台发布 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画