ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Canvas 2D手搓搜打撤游戏:从架构到实战的完整指南

Canvas 2D手搓搜打撤游戏:从架构到实战的完整指南 1. 为什么我放弃了游戏引擎选择 Canvas 2D 手搓搜打撤1.1 从一次“杀鸡用牛刀”的折腾说起去年年底《逃离鸭科夫》这类搜打撤玩法火起来的时候我正处在对 Unity 又爱又恨的阶段。爱的是它确实省事物理、动画、粒子、寻路全都给你打包好了恨的是我只是想做一个 2D 俯视角、核心循环就三件事——搜物资、打架、撤离——的小体量原型结果光是等编辑器加载、配置场景、处理各种包依赖就耗掉了我一半的耐心。更别提每次改一个数值都要重新编译迭代节奏被拖得很难受。后来我干脆做了个实验把同样的玩法逻辑用原生 HTML CSS JavaScript 的 Canvas 2D 重写一遍。结果出乎意料——核心玩法跑通只用了不到两天整个项目打包出来不到 300KB双击一个 html 文件就能玩发给朋友测试连环境都不用配。那一刻我意识到对于这类规则驱动、画面不复杂、强调快速迭代的 2D 小游戏游戏引擎带来的“便利”很多时候反而是负担。这篇文章就是那次实验的完整复盘。我会把“不用游戏引擎用 Canvas 2D 做一个《逃离鸭科夫》风格的搜打撤游戏”这件事从架构设计、核心系统实现、到踩过的坑全部摊开讲清楚。适合两类人看一是想入门游戏开发但被引擎劝退的前端二是有一定 JS 基础、想搞明白“游戏到底是怎么跑起来的”的开发者。哪怕你只会写document.querySelector跟着思路也能搭出一个能玩的雏形。1.2 搜打撤玩法的本质是什么先把玩法拆干净不然后面写代码全是糊的。搜打撤Extraction Shooter 的轻量化变体的核心循环其实非常朴素搜地图上散布着容器、箱子、尸体玩家靠近后交互随机产出物资。打地图里有 AI 敌人会巡逻、会追击、会开枪玩家要决定打还是躲。撤地图边缘有撤离点玩家带着战利品到达并停留一段时间成功带出物资一旦死亡身上东西全丢。这个循环的精髓在于风险与收益的博弈——你搜得越久、打得越多收益越高但被围殴致死的概率也越大。所以游戏设计上必须保证物资有价值梯度、敌人有威胁感、撤离有明确的时间压力。用 Canvas 2D 实现这套东西本质上就是解决四个问题怎么画渲染、怎么动更新循环、怎么交互输入与碰撞、怎么记状态数据管理。把这四件事理顺游戏就立起来了。1.3 Canvas 2D 相比游戏引擎的真实优势与代价我不想把 Canvas 2D 吹成银弹它有明确的适用边界。下面这张表是我实际对比后的结论维度Canvas 2D 手搓主流游戏引擎启动成本一个 html 文件即可需安装编辑器、配置项目包体积几十到几百 KB动辄几十 MB 起迭代速度改完刷新浏览器需编译/热重载等待2D 渲染能力够用需自己写开箱即用功能全物理/寻路需自己实现内置成熟方案3D 与复杂特效基本别想强项跨平台发布浏览器天然跨平台需导出配置结论很清晰如果你的游戏是 2D、规则驱动、画面不追求炫技、且想快速验证玩法Canvas 2D 是性价比极高的选择。反过来如果你要做 3D、要复杂物理、要大量粒子特效老老实实上引擎别跟自己较劲。我选择 Canvas 2D 的核心理由是它把“游戏”还原成了它本来的样子——一个每帧更新状态、然后重绘画面的循环。理解了这一点引擎里那些黑盒对你来说就不再神秘了。2. 整体架构设计把游戏拆成四个互不干扰的层2.1 目录结构与模块划分我不喜欢把所有代码塞进一个文件哪怕它只是个原型。合理的拆分能让后期加功能时不至于崩溃。我的目录大概长这样game/ ├── index.html // 入口只有一个 canvas ├── style.css // 页面样式让 canvas 居中 ├── main.js // 启动入口组装各系统 ├── core/ │ ├── loop.js // 游戏主循环 │ ├── input.js // 键盘鼠标输入 │ └── camera.js // 摄像机跟随 ├── entities/ │ ├── player.js // 玩家 │ ├── enemy.js // 敌人 AI │ └── loot.js // 物资容器 ├── systems/ │ ├── collision.js // 碰撞检测 │ ├── combat.js // 战斗与伤害 │ └── extraction.js // 撤离逻辑 └── data/ ├── map.js // 地图数据 └── items.js // 物资配置表这个结构的核心思想是数据与逻辑分离。地图、物资、敌人配置全部放在data/里改数值不用碰逻辑代码systems/里是纯函数式的处理逻辑输入状态、输出状态变化entities/是实体本身持有自己的数据。这样当你想加一种新物资只需要在items.js里加一行其他代码一行都不用动。2.2 游戏主循环requestAnimationFrame 的正确打开方式游戏的心跳就是主循环。很多人第一次写会直接用setInterval这是大坑——它不跟屏幕刷新同步会撕裂、会卡顿。正确做法是用requestAnimationFrame它会在浏览器每次重绘前调用你天然对齐 60fps或屏幕刷新率。但直接用 rAF 有个问题不同设备帧率不同你的游戏速度会不一致。所以必须引入固定时间步长的概念。我的做法是// core/loop.js const FIXED_DT 1000 / 60; // 逻辑固定按 60fps 推进 let accumulator 0; let lastTime 0; function loop(timestamp) { if (!lastTime) lastTime timestamp; let delta timestamp - lastTime; lastTime timestamp; // 防止切后台回来时 delta 过大导致爆炸 if (delta 250) delta 250; accumulator delta; while (accumulator FIXED_DT) { update(FIXED_DT); // 逻辑更新永远按固定步长 accumulator - FIXED_DT; } render(); // 渲染每帧一次 requestAnimationFrame(loop); }这里的关键点是逻辑更新和渲染分离。逻辑永远按 1/60 秒推进保证物理和 AI 行为在任何设备上一致渲染则跟着屏幕刷新走画面该多顺滑就多顺滑。那个delta 250的钳制也很重要——用户切到别的标签页再切回来delta 可能是好几秒不钳制的话你的角色会瞬移穿墙。提示固定步长是游戏开发里最容易被忽视、但影响最大的一个设计。我早期偷懒直接用可变 delta结果在高刷屏手机上敌人跑得飞快在低端机上又慢如蜗牛排查了半天才发现是这里的问题。2.3 坐标系与摄像机让世界比屏幕大搜打撤游戏的地图肯定比一屏大所以必须有摄像机。我的做法是维护一个“世界坐标系”所有实体坐标都是世界坐标摄像机记录当前视口左上角在世界中的位置渲染时统一做一次平移变换。// core/camera.js const camera { x: 0, y: 0, width: 960, height: 540, follow(target) { // 让目标始终在屏幕中心 this.x target.x - this.width / 2; this.y target.y - this.height / 2; // 限制在地图边界内 this.x Math.max(0, Math.min(this.x, MAP_WIDTH - this.width)); this.y Math.max(0, Math.min(this.y, MAP_HEIGHT - this.height)); } };渲染时这样用ctx.save(); ctx.translate(-camera.x, -camera.y); // 绘制所有世界内的东西 ctx.restore(); // 绘制固定在屏幕上的 UI血条、背包等这个save/translate/restore的模式是 Canvas 2D 做摄像机的标准姿势。save保存当前变换矩阵translate施加平移画完世界内容后restore恢复再画 UI 就不会被平移影响。理解了这个你就理解了 Canvas 2D 里“世界”和“屏幕”两套坐标的切换。2.4 实体管理一个朴素的 ECS 思路我没上完整的 ECS实体组件系统框架那对这个小项目太重了。但我借鉴了它的思想实体只存数据系统只处理逻辑。每个实体就是一个普通对象// 玩家实体 const player { x: 400, y: 300, vx: 0, vy: 0, radius: 14, hp: 100, maxHp: 100, speed: 2.4, inventory: [], state: alive // alive | dead | extracted };然后所有系统都是接收实体数组、修改它们状态的函数。比如碰撞系统遍历所有实体检测圆形重叠并推开战斗系统检测子弹与实体、实体与实体的距离。这种“数据裸奔、逻辑集中”的方式调试起来特别直观——你随时可以在控制台打印player看它当前状态不用去引擎的 Inspector 里翻。3. 核心系统逐个击破搜、打、撤的实现细节3.1 地图与渲染用离屏 Canvas 缓存静态层地图如果每帧都重画一遍草地、墙壁、地砖性能会很浪费。我的优化是把静态地图预渲染到一张离屏 Canvas 上每帧只需要drawImage一次。// 预渲染静态地图 const mapCanvas document.createElement(canvas); mapCanvas.width MAP_WIDTH; mapCanvas.height MAP_HEIGHT; const mctx mapCanvas.getContext(2d); function bakeMap() { // 画地板 mctx.fillStyle #2a2d34; mctx.fillRect(0, 0, MAP_WIDTH, MAP_HEIGHT); // 画网格纹理 mctx.strokeStyle #33373f; for (let x 0; x MAP_WIDTH; x 64) { mctx.beginPath(); mctx.moveTo(x, 0); mctx.lineTo(x, MAP_HEIGHT); mctx.stroke(); } // 画墙壁 walls.forEach(w { mctx.fillStyle #4a4f5a; mctx.fillRect(w.x, w.y, w.w, w.h); }); }渲染时ctx.drawImage(mapCanvas, 0, 0);这一招把每帧几百次绘制调用压缩成一次帧率立刻稳了。离屏 Canvas 是 Canvas 2D 性能优化的第一课凡是静态不变的东西都该烘焙进去。地图数据我用一个二维数组表示可行走区域墙壁单独用矩形列表存碰撞检测时直接遍历墙壁列表做圆-矩形检测。这里有个小技巧地图尺寸别设太大我一开始搞了个 4000x4000 的地图结果摄像机边界计算和碰撞遍历都变慢后来缩到 2400x1600体验反而更好——搜打撤的紧张感来自密度不是面积。3.2 玩家移动与输入手感全在这几个参数里移动看起来简单但手感好坏全在细节。我的输入系统维护一个按键状态表// core/input.js const keys {}; window.addEventListener(keydown, e keys[e.code] true); window.addEventListener(keyup, e keys[e.code] false);玩家更新时读取方向function updatePlayer(p, dt) { let dx 0, dy 0; if (keys[KeyW] || keys[ArrowUp]) dy - 1; if (keys[KeyS] || keys[ArrowDown]) dy 1; if (keys[KeyA] || keys[ArrowLeft]) dx - 1; if (keys[KeyD] || keys[ArrowRight]) dx 1; // 归一化防止斜向移动更快 if (dx ! 0 dy ! 0) { const len Math.sqrt(dx * dx dy * dy); dx / len; dy / len; } // 加速与摩擦做出惯性感 const accel 0.6; p.vx dx * accel; p.vy dy * accel; p.vx * 0.82; // 摩擦系数 p.vy * 0.82; p.x p.vx * (dt / 16.67); p.y p.vy * (dt / 16.67); }这里有两个关键点。第一是斜向归一化——不处理的话同时按 W 和 D 会比只按 W 快 41%这是新手最常见的 bug。第二是用加速度和摩擦代替直接赋值速度这样角色起步和停下都有一点缓冲手感立刻从“生硬”变成“跟手”。摩擦系数 0.82 是我试了十几次定下来的太高会滑太低会顿。提示手感调参没有标准答案但有个经验值——加速时间控制在 0.1 到 0.2 秒之间玩家会觉得“响应快但不突兀”。你可以把 accel 和摩擦系数做成可调参数边玩边调。3.3 碰撞检测圆与矩形够用且高效2D 俯视角游戏里实体用圆形、墙壁用矩形是最省事又够准的方案。圆-矩形碰撞的核心是找到矩形上离圆心最近的点// systems/collision.js function circleRectCollide(cx, cy, r, rect) { const nearestX Math.max(rect.x, Math.min(cx, rect.x rect.w)); const nearestY Math.max(rect.y, Math.min(cy, rect.y rect.h)); const dx cx - nearestX; const dy cy - nearestY; return dx * dx dy * dy r * r; }检测到碰撞后需要把实体推出去否则会卡在墙里。我的做法是计算穿透方向并反向修正function resolveCollision(entity, rect) { const nearestX Math.max(rect.x, Math.min(entity.x, rect.x rect.w)); const nearestY Math.max(rect.y, Math.min(entity.y, rect.y rect.h)); let dx entity.x - nearestX; let dy entity.y - nearestY; const dist Math.sqrt(dx * dx dy * dy) || 0.001; const overlap entity.radius - dist; if (overlap 0) { entity.x (dx / dist) * overlap; entity.y (dy / dist) * overlap; } }这个“先检测、再沿法线推出”的模式是所有 2D 碰撞响应的基础。注意那个|| 0.001是为了防止圆心正好在矩形内部时除零。这种边界情况不处理偶尔就会出现角色被弹飞的诡异现象。3.4 搜刮系统随机产出与交互反馈搜刮是搜打撤的“收益”来源设计上要让人有期待感。每个容器有类型、有产出表、有是否已搜过的标记// data/items.js const LOOT_TABLE { crate: [ { id: ammo, weight: 50, min: 10, max: 30 }, { id: medkit, weight: 20, min: 1, max: 2 }, { id: scope, weight: 5, min: 1, max: 1 } ], corpse: [ { id: ammo, weight: 40, min: 5, max: 15 }, { id: armor, weight: 10, min: 1, max: 1 } ] };按权重随机抽取function rollLoot(type) { const table LOOT_TABLE[type]; const total table.reduce((s, i) s i.weight, 0); let r Math.random() * total; for (const item of table) { r - item.weight; if (r 0) { const count Math.floor(Math.random() * (item.max - item.min 1)) item.min; return { id: item.id, count }; } } }交互逻辑是玩家靠近容器一定距离内屏幕提示“按 F 搜索”按下后播放一个短暂的进度条比如 1.5 秒期间移动会打断。这个“搜索耗时”是搜打撤紧张感的关键——你不能边跑边搜必须停下来承担风险。我一开始没加这个结果玩家一路狂奔把所有箱子秒搜完完全没有博弈感加上之后节奏立刻对了。3.5 敌人 AI状态机比寻路算法更重要很多人一提 AI 就想到 A* 寻路但对搜打撤这种小地图有限状态机 直线追击已经足够。我的敌人有三个状态巡逻、警觉、攻击。// entities/enemy.js function updateEnemy(e, player, dt) { const dx player.x - e.x; const dy player.y - e.y; const dist Math.sqrt(dx * dx dy * dy); switch (e.state) { case patrol: // 沿预设路径点移动 moveToNextWaypoint(e); if (dist e.sightRange hasLineOfSight(e, player)) { e.state alert; e.alertTimer 0; } break; case alert: // 停下、转向玩家、短暂延迟后开火 e.alertTimer dt; if (e.alertTimer 400) e.state attack; if (dist e.sightRange * 1.5) e.state patrol; break; case attack: // 追击并射击 if (dist e.attackRange) { e.x (dx / dist) * e.speed * (dt / 16.67); e.y (dy / dist) * e.speed * (dt / 16.67); } else { fireAtPlayer(e, player); } if (dist e.sightRange * 2) e.state patrol; break; } }那个alert状态是精髓——敌人发现你之后不会立刻开枪而是有一个 0.4 秒的“反应时间”。这给了玩家一个短暂的反击或逃跑窗口体验上比“瞬间被秒”友好太多。hasLineOfSight用射线与墙壁求交实现防止敌人隔墙看到你。提示AI 的“不公平感”是玩家弃游的头号原因。我的经验是敌人的视野、反应时间、射击精度都要留出容错空间。宁可让 AI 显得“有点笨”也不要让它显得“开挂”。3.6 战斗与伤害让每一枪都有反馈战斗系统我拆成三块开火、子弹飞行、命中结算。子弹用简单的直线运动每帧检测与实体和墙壁的碰撞// systems/combat.js function updateBullets(bullets, dt) { for (let i bullets.length - 1; i 0; i--) { const b bullets[i]; b.x b.vx * (dt / 16.67); b.y b.vy * (dt / 16.67); b.life - dt; // 撞墙 if (hitsWall(b.x, b.y)) { bullets.splice(i, 1); continue; } // 命中实体 const target findHitTarget(b); if (target) { applyDamage(target, b.damage); bullets.splice(i, 1); continue; } if (b.life 0) bullets.splice(i, 1); } }伤害结算要考虑护甲减伤function applyDamage(entity, dmg) { const reduced Math.max(1, dmg - (entity.armor || 0)); entity.hp - reduced; entity.hitFlash 120; // 受击闪白计时 if (entity.hp 0) entity.state dead; }hitFlash这个受击反馈非常重要。没有它玩家根本不知道自己打中了没有。我在渲染时根据这个计时给实体叠加一层白色持续 120 毫秒打击感立刻上来了。游戏反馈的本质是“让玩家的操作产生可见、可听、可感的即时结果”这一条比任何画面特效都重要。3.7 撤离系统给紧张感一个出口撤离点是地图边缘的几个区域玩家进入后开始倒计时倒计时结束且仍在区域内就算成功// systems/extraction.js function updateExtraction(player, zones, dt) { let inZone null; for (const z of zones) { if (circleRectCollide(player.x, player.y, player.radius, z)) { inZone z; break; } } if (inZone) { player.extractTimer (player.extractTimer || 0) dt; if (player.extractTimer EXTRACT_DURATION) { player.state extracted; } } else { player.extractTimer 0; // 离开就重置 } }撤离倒计时我设的是 5 秒期间玩家必须待在区域内不能动。这个设计制造了最后的紧张点——你带着一身战利品站在撤离点里听着倒计时祈祷没有敌人摸过来。撤离不是终点而是最后一道风险关口这一点想明白了整个玩法的闭环就成立了。4. 实操全流程从零到能玩我踩过的坑都在这4.1 第一步搭好 HTML 骨架和 Canvas 自适应入口文件极简但有几个细节必须处理好!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title搜打撤原型/title link relstylesheet hrefstyle.css /head body canvas idgame/canvas script typemodule srcmain.js/script /body /htmlCSS 让 canvas 居中并铺满* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #14161a; display: flex; justify-content: center; align-items: center; height: 100vh; overflow: hidden; } canvas { background: #1c1f24; box-shadow: 0 0 40px rgba(0,0,0,0.6); }这里有个坑Canvas 的显示尺寸和绘制尺寸是两回事。如果你用 CSS 把 canvas 拉伸画面会模糊。正确做法是设置canvas.width/height为逻辑分辨率CSS 尺寸按比例缩放。我为了简单直接固定 960x540 的逻辑分辨率用 CSS 的max-width做响应式保证在高分屏上也不糊。4.2 第二步把主循环跑起来先画一个方块别急着写玩法先让一个方块动起来。这是验证“循环 渲染 输入”三件套是否打通的最小闭环// main.js import { startLoop } from ./core/loop.js; const canvas document.getElementById(game); const ctx canvas.getContext(2d); canvas.width 960; canvas.height 540; const player { x: 480, y: 270, vx: 0, vy: 0, radius: 14 }; function update(dt) { // 先留空后面填 } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #4ade80; ctx.beginPath(); ctx.arc(player.x, player.y, player.radius, 0, Math.PI * 2); ctx.fill(); } startLoop(update, render);跑起来看到绿点说明骨架通了。这一步千万别跳过我见过太多人一上来就写一堆系统结果循环有问题排查起来像大海捞针。4.3 第三步接入输入和移动调出跟手的手感把前面讲的输入系统和移动逻辑接上然后重点调参。我的调参方法是在屏幕上放一个实时显示速度的调试文字边跑边看数值直到感觉“起步不拖沓、停下不滑冰”。这个过程大概花了我一个小时但值得——移动手感是玩家接触游戏的第一印象它决定了玩家愿不愿意继续玩下去。4.4 第四步加地图、墙壁和碰撞把地图数据填进去烘焙静态层接上碰撞。这一步最容易出的问题是角色卡在墙角抖动。原因是碰撞推出和移动更新在同一帧里互相打架。我的解法是先更新位置再统一做一次碰撞修正修正后再更新一次位置。顺序错了就会抖。4.5 第五步加敌人、子弹、伤害敌人 AI 和战斗系统是代码量最大的部分。我的建议是先做最简单的版本敌人只会直线追你、只会近战。跑通之后再逐步加视野、加射击、加状态机。一次性写完所有 AI 逻辑调试起来会非常痛苦因为你不知道是哪个环节出的问题。4.6 第六步加搜刮、背包和撤离最后把玩法闭环补上。背包我用一个简单的数组UI 用 Canvas 直接画在屏幕角落。撤离点用半透明色块标出进入后显示倒计时。到这里一个完整的搜打撤循环就跑通了。4.7 性能优化从 30 帧到稳定 60 帧原型跑通后我发现帧率只有 30 多排查后做了三件事优化项做法效果静态地图烘焙离屏 Canvas 预渲染每帧绘制调用减少 90%视野裁剪只渲染摄像机范围内的实体实体多时提升明显对象池子弹复用避免频繁 GC消除卡顿视野裁剪的实现很简单function isVisible(entity, camera) { return entity.x entity.radius camera.x entity.x - entity.radius camera.x camera.width entity.y entity.radius camera.y entity.y - entity.radius camera.y camera.height; }渲染前先判断不可见的直接跳过。地图大了之后这一招能省下大量绘制。5. 常见问题与排查技巧实录5.1 角色穿墙、卡墙、抖动怎么办这是 Canvas 2D 游戏最高频的问题我整理了一张速查表现象可能原因解决方向高速穿墙单帧位移大于墙厚用射线检测或限制最大速度卡在墙里碰撞推出方向错误检查法线计算处理圆心在矩形内的情况贴墙抖动更新与修正顺序冲突先移动后统一修正斜向穿角圆-矩形检测漏判增加对矩形四个角的检测穿墙的根本原因是离散检测的固有缺陷——如果这一帧你在墙左边下一帧在墙右边中间就漏了。解决办法要么限制单帧位移不超过墙的最小厚度要么用连续碰撞检测射线。对这个小项目限制速度就够了。5.2 帧率不稳、切后台回来爆炸前面提到的delta 250钳制就是解决这个的。另外还有一个隐蔽的坑requestAnimationFrame在标签页不可见时会暂停切回来时timestamp会跳变。所以每次循环开始都要检查 delta 是否异常。我还在页面visibilitychange时重置lastTime双保险。5.3 内存泄漏子弹和粒子的隐形杀手子弹、粒子这类高频创建销毁的对象如果不及时清理数组会越来越大。我的做法是倒序遍历 splice因为正序遍历删除元素会跳过下一个。另外所有带生命周期的对象都要有life字段归零就删。上线前我在控制台打印过数组长度发现子弹数组一度涨到几千个就是因为漏删了撞墙的子弹。5.4 输入延迟与按键粘滞按键粘滞的典型表现是松开键后角色还在动。原因是keyup没被正确捕获或者用了keypress它不触发所有键。统一用keydown/keyup配合e.code别用e.key受输入法和大小写影响。另外窗口失焦时要清空按键表否则切出去再回来角色会一直朝一个方向跑。5.5 我的独家避坑清单别用setInterval做游戏循环一定用requestAnimationFrame。所有时间相关的计算都用毫秒且乘以dt/16.67保证不同帧率下速度一致。实体坐标用浮点数渲染时再取整否则移动会有细微抖动。调试时打开一个实时状态面板显示 fps、实体数量、玩家坐标比console.log高效一百倍。数值全部抽到配置文件改平衡性时不用翻逻辑代码。每加一个系统就单独测试别攒着一起测否则出问题不知道从哪查。6. 后续可以怎么扩展这套原型跑通之后能扩展的方向其实很多。我目前在做的是给敌人加不同的武器类型和巡逻路线让地图的威胁分布更有层次下一步想加一个简单的存档系统用localStorage记录玩家带出的物资形成跨局成长。如果你对网络对战感兴趣这套架构也能改成状态同步——因为逻辑和渲染是分离的把update的结果序列化发出去就行。我个人在实际操作中的体会是用 Canvas 2D 做游戏最大的收获不是省了多少工具而是你被迫理解了游戏运行的每一个环节。引擎帮你屏蔽的那些东西——循环、坐标、碰撞、状态——恰恰是游戏开发最核心的知识。等你把这些都手搓过一遍再回头看任何引擎都会觉得它不过是一堆帮你省事的封装而已。最后分享一个小技巧如果你觉得某个系统写得别扭大概率是数据结构和逻辑耦合太紧了试着把数据抽出来单独放问题往往迎刃而解。
返回列表