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 量级。超了直接传不上去——这不是性能建议,是平台红线。
应对策略是三层,从内到外:
- 首包只放必需品:引擎代码、首屏 UI、第一个关卡。目标是「点开就能玩」。
- 其余内容拆进分包:后续关卡、Boss 关、高分辨率素材。分包在玩家触发时下载。
- 更大的资源走 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 对照:构建面板与多平台发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。