1. 从零搭建一个微信小游戏:整体架构与选型思路
微信小游戏这两年热度一直没降过,尤其是“羊了个羊”式的小体量玩法跑通之后,很多独立开发者和中小团队都想自己搭一套。但真上手就会发现,小游戏和普通H5、和原生App都不一样,它有一套自己的运行环境、包体限制和审核逻辑。我前后搭过三四个小游戏项目,从最开始的“能跑就行”到后来形成一套相对稳定的搭建流程,中间踩的坑不算少。这篇就把整套搭建文档拆开讲清楚,从架构选型到实操落地,再到问题排查,尽量让第一次做小游戏的人也能照着走一遍。
先说清楚这套文档是给谁看的:如果你是想做一个休闲类、体量不大、以玩法取胜的小游戏,那这套流程基本可以直接抄;如果你要做的是重度3D或者强联网对战,那部分选型需要调整,但整体骨架仍然适用。核心关键词就几个——微信小游戏、搭建流程、包体优化、适配、审核发布,下面会反复围绕它们展开。
1.1 为什么小游戏不能直接套用H5那一套
很多人第一反应是:小游戏不就是H5吗,我用普通前端那套不就行了?我一开始也这么想,结果第一次提交就卡在包体上。微信小游戏虽然底层是JavaScript运行时,但它和浏览器环境差别很大。
最直观的几点差异:小游戏没有DOM和BOM,你熟悉的document、window、localStorage这些在逻辑层里根本不存在;渲染层用的是Canvas,不是HTML标签;网络请求走的是wx.request而不是fetch或XMLHttpRequest。这意味着你如果拿一个现成的H5项目直接搬,大概率是跑不起来的。
更关键的是包体限制。微信小游戏主包有明确的大小上限,超过就得做分包。这个限制直接决定了你的资源组织方式——图片要不要压缩、音频要不要外置、代码要不要拆包,全都要在架构阶段就想清楚,而不是等做完再优化。
1.2 技术选型:原生、引擎还是自研框架
搭建小游戏第一步是选技术路线,我把它分成三类,各有适用场景。
| 路线 | 代表方案 | 适合场景 | 上手难度 | 包体表现 |
|---|---|---|---|---|
| 原生开发 | 直接用微信小游戏API + Canvas | 轻量休闲、逻辑简单 | 中 | 最优 |
| 游戏引擎 | 主流2D/3D引擎导出小游戏 | 中重度、有美术需求 | 低 | 偏大 |
| 自研框架 | 自己封装渲染和资源管理 | 有积累的团队 | 高 | 可控 |
我的建议是:第一次做、玩法简单,直接上原生。原因很实在——引擎导出的包体往往偏大,你还没写几行业务逻辑,光引擎运行时就把主包撑满了,后面优化起来非常被动。原生虽然要自己处理渲染循环、资源加载这些,但胜在可控,包体想压多小就压多小。
如果你确实需要引擎(比如要做骨骼动画、物理系统),那选型时一定要确认它支持小游戏导出,并且能配置分包和资源外置。我见过有人用了一个不支持小游戏的引擎,做到一半发现导不出来,只能推倒重来,这个成本太高了。
1.3 目录结构设计:一开始就要为分包留位置
目录结构这件事,做H5的时候很多人是随手建的,但小游戏不行。因为分包机制的存在,你的目录结构直接决定了哪些代码进主包、哪些进子包。
我常用的结构是这样的:
project/ ├── game.js // 入口文件 ├── game.json // 全局配置 ├── project.config.json // 项目配置 ├── js/ │ ├── main.js // 主逻辑 │ ├── base/ // 基础模块(进主包) │ └── utils/ // 工具函数(进主包) ├── subpackages/ // 分包目录 │ ├── level1/ │ └── level2/ ├── res/ // 主包资源(尽量小) └── audio/ // 音频(建议外置)这里有个经验:主包里只放启动必需的代码和资源。什么叫必需?就是用户打开游戏到看到第一个可交互界面之间要用到的东西。其余的关卡、皮肤、音效,全部丢进分包或者走远程加载。我一般会把主包控制在很小的范围内,给后续迭代留足空间。
注意:分包不是随便分的,微信对分包数量和单个分包大小也有约束,分之前先查清楚当前平台的限制,别分完发现超了。
2. 核心模块拆解与关键实现细节
架构定下来之后,就是各个核心模块的落地。小游戏虽然体量小,但“麻雀虽小五脏俱全”,渲染、资源、状态、适配、音频这几块一个都不能少。这一章逐个拆开讲,重点说清楚每个模块为什么这么设计,以及实操中容易出问题的地方。
2.1 渲染循环:requestAnimationFrame的正确用法
小游戏的画面更新靠的是渲染循环,核心API是requestAnimationFrame。这个和浏览器里用法类似,但有几个细节要注意。
首先是帧率控制。不是所有游戏都需要60帧,休闲类游戏30帧完全够用,而且能省电、省性能。我一般会做一个简单的帧率限制:
let lastTime = 0; const targetInterval = 1000 / 30; // 30帧 function loop(timestamp) { const delta = timestamp - lastTime; if (delta >= targetInterval) { lastTime = timestamp - (delta % targetInterval); update(delta); render(); } requestAnimationFrame(loop); }这段代码的关键在于lastTime的修正方式。如果你直接写lastTime = timestamp,长时间运行后帧率会漂移。用取模的方式修正,能让帧率稳定在目标值附近。
其次是逻辑更新和渲染分离。update负责改数据,render负责画画面,两者不要混在一起。我见过有人把碰撞检测写在渲染函数里,结果帧率一波动,物理表现就乱了。分离之后,即使渲染掉帧,逻辑仍然是按固定步长走的,表现更稳定。
2.2 资源加载:图片、音频、JSON的统一管理
资源加载是小游戏里最容易出乱子的地方。图片没加载完就画、音频没准备好就播、JSON解析失败没兜底,这些问题我都遇到过。
我的做法是做一个统一的资源管理器,所有资源走同一个加载队列,加载完成后再进入游戏主流程。核心思路是:
class ResourceManager { constructor() { this.cache = {}; this.loadingCount = 0; this.totalCount = 0; } loadImage(key, src) { this.totalCount++; const img = wx.createImage(); img.onload = () => { this.cache[key] = img; this.loadingCount++; this.checkComplete(); }; img.onerror = () => { console.error(`资源加载失败: ${src}`); this.loadingCount++; this.checkComplete(); }; img.src = src; } checkComplete() { if (this.loadingCount === this.totalCount) { this.onComplete && this.onComplete(); } } }这里有个细节:加载失败也要计数。如果失败不计数,loadingCount永远到不了totalCount,游戏就卡在加载界面了。失败时给个占位图或者跳过,保证流程能继续。
音频资源更特殊。小游戏里音频建议外置,不要打进包体,因为音频文件通常比较大。用wx.createInnerAudioContext创建音频实例,注意用完要destroy,不然实例多了会出问题。
2.3 状态管理:小游戏也需要“数据层”
很多人觉得小游戏逻辑简单,状态随便放全局变量就行。我一开始也这么干,结果做到第三个关卡的时候,全局变量满天飞,改一个地方崩三个地方。
后来我加了一个极简的状态管理,就是一个对象加一组读写方法:
const GameState = { data: { score: 0, level: 1, isPlaying: false, settings: {} }, set(key, value) { this.data[key] = value; }, get(key) { return this.data[key]; }, reset() { this.data.score = 0; this.data.level = 1; this.data.isPlaying = false; } };别看它简单,作用很大。所有状态变更都走set,方便加日志、方便调试、方便做存档。存档的时候直接序列化GameState.data就行,不用去满世界找变量。
实操心得:状态里不要存Canvas对象、图片对象这类不可序列化的东西,只存纯数据。渲染用的对象单独管理,这样存档和读档才不会出问题。
2.4 屏幕适配:一次搞定各种机型
小游戏的适配是个绕不开的坎。不同手机屏幕比例差别很大,从传统的16:9到现在的各种全面屏比例,如果只按一个尺寸设计,在别的机型上要么拉伸变形,要么留黑边。
我的适配方案是以宽度为基准,高度自适应。具体做法是设计一个基准宽度(比如750),然后根据实际屏幕宽度算缩放比例:
const systemInfo = wx.getSystemInfoSync(); const designWidth = 750; const scale = systemInfo.windowWidth / designWidth; const designHeight = systemInfo.windowHeight / scale;这样算出来的designHeight就是当前机型在基准宽度下的逻辑高度。UI布局时,横向按750来排,纵向根据designHeight动态调整。关键元素(比如按钮)要避开刘海屏和底部手势区,用safeArea信息来定位。
const safeArea = systemInfo.safeArea; // 顶部安全距离 const topSafe = safeArea.top; // 底部安全距离 const bottomSafe = systemInfo.windowHeight - safeArea.bottom;这套方案我用了好几个项目,基本覆盖了市面上主流机型,没出现过严重的适配问题。
3. 完整搭建流程与实操步骤
前面讲的是模块和原理,这一章把整个搭建过程串起来,从环境准备到跑通第一个可玩版本,一步步来。我会把每步的操作意图和注意事项都标出来,方便你对照着做。
3.1 环境准备与项目初始化
第一步是装工具。你需要一个支持小游戏开发的IDE,以及一个用于调试的账号。工具装好后,新建项目,选择小游戏类型,填好项目名称和目录。
初始化完成后,你会得到几个核心文件:game.js是入口,game.json是配置,project.config.json是项目设置。先别急着写逻辑,把game.json配置清楚:
{ "deviceOrientation": "portrait", "showStatusBar": false, "networkTimeout": { "request": 5000 }, "subpackages": [] }deviceOrientation决定横屏还是竖屏,这个一旦定了后面很难改,想清楚再填。subpackages先留空,等有分包需求再加。
3.2 入口文件与主循环搭建
game.js是整个游戏的起点。我习惯在这里做三件事:初始化Canvas、加载基础资源、启动主循环。
const canvas = wx.createCanvas(); const ctx = canvas.getContext('2d'); // 初始化状态 GameState.reset(); // 加载启动资源 ResourceManager.load([ { key: 'logo', src: 'res/logo.png' } ], () => { // 资源加载完成后启动 requestAnimationFrame(loop); });这里要注意wx.createCanvas()的调用时机。它必须在游戏启动的早期调用,而且全局最好只调一次。我见过有人在多个模块里重复调用,导致渲染错乱。
主循环的骨架前面讲过了,这里补充一点:把update和render的耗时打出来。小游戏性能有限,如果某一帧耗时突然飙高,你要能第一时间发现是哪个环节的问题。
function loop(timestamp) { const start = Date.now(); update(timestamp); const updateCost = Date.now() - start; const renderStart = Date.now(); render(); const renderCost = Date.now() - renderStart; if (updateCost > 16 || renderCost > 16) { console.warn(`帧耗时偏高: update=${updateCost}ms, render=${renderCost}ms`); } requestAnimationFrame(loop); }3.3 第一个可交互界面的实现
跑通循环之后,做一个最简单的可交互界面来验证整条链路。比如一个点击加分的按钮。
let button = { x: 100, y: 200, w: 200, h: 80, text: '点击加分' }; function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 画按钮 ctx.fillStyle = '#4a90d9'; ctx.fillRect(button.x, button.y, button.w, button.h); // 画文字 ctx.fillStyle = '#fff'; ctx.font = '24px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(button.text, button.x + button.w / 2, button.y + button.h / 2); // 画分数 ctx.fillStyle = '#333'; ctx.fillText(`分数: ${GameState.get('score')}`, canvas.width / 2, 100); } wx.onTouchStart((e) => { const touch = e.touches[0]; if (touch.clientX >= button.x && touch.clientX <= button.x + button.w && touch.clientY >= button.y && touch.clientY <= button.y + button.h) { GameState.set('score', GameState.get('score') + 1); } });这段代码虽然简单,但把渲染、状态、交互三条链路都串起来了。跑通它,后面的复杂功能都是在这个基础上叠加。
注意:
wx.onTouchStart的坐标是物理像素还是逻辑像素,不同基础库版本可能有差异,实测一下再写逻辑,别想当然。
3.4 分包配置与资源外置实操
当主包快满的时候,就要上分包了。分包的配置在game.json里:
{ "subpackages": [ { "name": "levelPack", "root": "subpackages/level/" } ] }配置好之后,subpackages/level/目录下的代码就不会打进主包。加载分包用wx.loadSubpackage:
wx.loadSubpackage({ name: 'levelPack', success: () => { console.log('分包加载成功'); // 加载完成后才能require分包里的模块 const levelModule = require('./subpackages/level/index.js'); levelModule.start(); }, fail: (err) => { console.error('分包加载失败', err); } });这里有个坑:分包里的模块不能在主包里提前require,否则会被打进主包,分包就白分了。我一开始没注意,主包里写了个require('./subpackages/level/index.js'),结果主包一点没小,查了半天才发现。
资源外置则是把图片、音频放到远程服务器,用的时候下载。适合那些不常变、体积又大的资源。下载后可以缓存到本地,下次直接用。
4. 常见问题排查与避坑经验实录
做小游戏这几年,遇到的问题五花八门,但真正高频的就那么几类。这一章把典型问题和排查思路整理出来,做成速查表,再补充一些文档里不会写的经验。
4.1 启动黑屏与白屏问题排查
黑屏白屏是最常见也最让人头疼的问题,因为现象一样,原因可能完全不同。我一般按这个顺序排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 一直黑屏 | 主循环没启动 | 检查requestAnimationFrame是否被调用 |
| 白屏后闪退 | 入口报错 | 看控制台是否有JS异常 |
| 加载界面卡住 | 资源加载未完成 | 检查loadingCount是否等于totalCount |
| 画面不刷新 | render未执行 | 在render里打日志确认 |
有一次我遇到黑屏,查了半天代码没问题,最后发现是game.json里deviceOrientation配错了,导致Canvas尺寸异常。这种配置问题最容易被忽略,排查时先确认配置文件。
另一个高频原因是资源路径写错。小游戏里的路径是相对于项目根目录的,不是相对于当前文件。我习惯在加载失败的回调里把完整路径打出来,一眼就能看出问题。
4.2 性能问题:卡顿与内存泄漏
小游戏跑在手机上,性能比PC差很多。卡顿通常来自两个地方:渲染太频繁和对象创建太多。
渲染方面,能合并的绘制操作就合并。比如多个文字,不要一个个fillText,可以先算好位置再统一画。clearRect也不要全屏清,只清需要更新的区域。
对象创建方面,避免在循环里new对象。比如每帧都new Date()、每帧都创建数组,这些都会给垃圾回收造成压力。我的做法是提前创建好对象池,循环里复用。
内存泄漏在小游戏里很隐蔽,因为不像浏览器有开发者工具那么方便。我一般用计数法:在创建和销毁的地方打日志,跑一段时间看数量是否平衡。音频实例、定时器、事件监听是泄漏重灾区,用完一定要清理。
// 错误示范 setInterval(() => { ... }, 1000); // 没有清理 // 正确做法 const timer = setInterval(() => { ... }, 1000); // 不用的时候 clearInterval(timer);4.3 审核被拒的常见原因与应对
代码写得再好,审核不过也白搭。我整理了几个高频被拒原因:
- 功能不完整:审核人员点进去发现玩不了,或者只有个空壳。提交前自己完整走一遍流程。
- 诱导分享:强制分享才能继续玩,这个现在查得很严,别碰。
- 内容违规:文字、图片里有敏感内容,提交前自查一遍。
- 类目不符:游戏类目和实际内容对不上,选类目时看清楚说明。
实操心得:提交审核时,在备注里写清楚玩法说明和操作步骤,能减少审核人员的困惑,通过率会高一些。我一般会附一段简短的文字说明,告诉他们怎么玩、哪里是重点。
4.4 版本更新与灰度发布策略
小游戏更新不像App那样需要用户手动下载,但也不是无感的。用户下次打开时会加载新版本,但如果有缓存,可能还是旧版本。
我的做法是在启动时检查版本号,如果发现是新版本,提示用户重启或者自动刷新。版本号存在game.json或者远程配置里,每次发版更新。
灰度发布方面,小游戏平台支持按比例放量。新版本先放一小部分用户,观察崩溃率和反馈,没问题再全量。这个机制一定要用,别一上来就全量,出问题就是全量事故。
5. 上线后的维护与迭代建议
搭起来只是开始,真正的工作在上线之后。这一章聊聊维护和迭代的一些经验,都是实际项目中总结出来的。
5.1 数据埋点:知道玩家在哪里流失
没有埋点的游戏就是盲人摸象。我一般会在几个关键节点埋点:启动、加载完成、进入关卡、通关、失败、退出。通过这些数据能看出玩家在哪一步流失最多。
埋点不要贪多,先埋核心流程,跑一段时间看数据,再决定要不要加细。数据上报用wx.request发到自己的服务器,注意别太频繁,可以攒一批再发。
5.2 热更新与配置化
小游戏审核有周期,如果每次改个数值都要重新提交,效率太低。我的做法是把可变的数值配置化,放到远程,游戏启动时拉取。比如关卡难度、道具价格、活动开关,这些都可以远程控制。
这样改数值不用发版,运营起来灵活很多。但要注意,配置拉取失败时要有兜底,用本地默认值,不能让游戏因为拉不到配置就玩不了。
5.3 用户反馈的收集与处理
小游戏里加一个反馈入口,让玩家能直接反馈问题。反馈信息里带上设备型号、系统版本、游戏版本,排查问题时能省很多事。
我一般会在反馈里加一个“附带日志”的选项,玩家勾选后把最近的运行日志一起提交。这样即使复现不了,也能从日志里找线索。
6. 我个人在实际搭建中的几点体会
最后说几个零散但我觉得挺重要的点。
第一,别过度设计。小游戏的核心是玩法,不是架构。我见过有人花两周搭框架,结果玩法三天就做完了,框架根本没用上。先跑通最小可玩版本,再根据需求重构。
第二,包体优化要趁早。不要等主包满了才想起来优化,那时候改造成本很高。从第一天就养成习惯:大资源外置、代码分包、图片压缩。
第三,多真机测试。模拟器和真机差别很大,尤其是性能和触摸响应。我一般会在三台不同档次的手机上测,低端机跑得动,高端机才不会有问题。
第四,留好日志。小游戏线上出问题很难调试,日志是唯一的线索。关键流程都打上日志,出问题时能快速定位。
这套搭建流程我用了几个项目,基本能覆盖大部分休闲小游戏的需求。当然具体项目具体分析,遇到特殊需求还是要灵活调整。如果你也在搭小游戏,希望这些经验能帮你少走点弯路。