☰
纯C实现俄罗斯方块:从数据结构到控制台渲染的完整项目实战
2026/10/17 3:39:15 网站建设 项目流程

学完C语言的基础语法,指针、结构体、数组都能看懂,但一到“自己写个项目”就卡住了——这是很多人学C的通病。控制台做完加减乘除计算器觉得太简单,直接上图形界面又发现精力全耗在调库上,C语言本身的建模能力反而一点没练到。我建议所有C语言学习者都做一次俄罗斯方块,而且是用纯C、不依赖任何图形库的那种。这个项目规则足够清晰,又有碰撞检测、旋转算法、状态机这些值得深挖的逻辑,是少数能把C语言基本功和游戏设计结合得恰到好处的练手题目。

这篇文章我会从需求边界、方块数据结构、碰撞判定、控制台渲染、主循环节奏到工程拆分,完整拆解我用纯C实现俄罗斯方块的全过程。适合刚学完C基础、准备做第一个完整项目的读者,也适合想复习数据结构和编程思路的开发者。写完之后你会得到一个单文件或拆分模块都能编译运行的终端游戏,更重要的是把“把游戏规则翻译成数据结构和代码流程”这个能力真正内化。

1. 为什么选俄罗斯方块练C语言:先把需求边界划清楚

1.1 俄罗斯方块比贪吃蛇更适合练建模能力

很多人第一反应是做贪吃蛇,觉得它简单。但贪吃蛇的“蛇身移动”本质上是链表或数组的操作,真正困难的其实在“身体跟随”的动画逻辑,这一点很多初学者反而不容易抓准。俄罗斯方块不同,它天然要求你把问题拆成几个清晰的模块:方块形状、旋转矩阵、碰撞检测、消行、计分、下落计时。每一项都是独立的小函数,接口明确,非常适合用C语言的结构体和数组去建模。

另外俄罗斯方块的验收标准非常客观:10列20行的游戏区,7种方块,能移动、旋转、下落、消行、游戏结束。你对答案的时候不需要任何主观判断,跑起来对不对一目了然。这一点对练手项目来说比什么都重要——项目做得对不对你自己就能验证,不需要别人帮你“看效果”。

1.2 “纯C”的边界到底划在哪里

我见过不少人讨论“纯C”是不是意味着连Windows.h都不能用、连系统API都不能碰。我的观点是:别把纯C理解成跨平台宗教,而是把游戏核心逻辑和平台相关代码拆开。核心逻辑部分只用标准C的数组、指针、结构体、标准库函数,不调用SDL、Raylib这些图形库;输入输出层单独隔离,让它成为唯一可能涉及平台特性的部分。

具体到我这个项目,游戏逻辑部分只用了stdio.h、stdlib.h、string.h、time.h。渲染就用控制台字符画,输入用轮询检测按键(这部分在不同系统上会有差异,我会单独说明怎么隔离)。你完全可以把这看成一种“分层设计”练习:将来做任何项目,平台相关代码都应该被隔离在最外层,核心业务不能和操作系统绑死。

有人觉得没有图形库的俄罗斯方块不够炫酷,但我反而觉得这才是练C的正确姿势——图形库帮你把屏幕当画布随便画,你就永远体会不到“怎么用二维字符数组表达一个游戏画面”的思考过程。先学会用最朴素的手段把事情做出来,后面再上手图形库,你会更清楚每一项封装替你做掉了什么。

1.3 动手前的功能验收清单

开工前先列一个需求清单,当作验收标准。我这个项目的验收项如下:

功能点说明验收标准
基础移动方块左右移动和下落方块能顺畅走到左右边界,不越界、不重叠
旋转顺时针旋转,靠墙时可微调任意位置旋转都不会“穿墙”或卡进已堆叠方块
自动下落无需按键,方块定时下落下落间隔随等级加快,不会突然跳变
快速下落按键让方块瞬间落底落底后准确锁定,不穿障碍物
消行计分满行消除,上方整体下移一次消1、2、3、4行分数正确,无残留
暂停/结束控制游戏流程暂停时画面不刷新,结束后不再响应移动键

我强烈建议你动手前也写这样一个清单,不一定很正式,但心里要有数。我见过太多人写俄罗斯方块写到一半迷失方向,就是因为只想着“实现游戏”,而没有把“游戏具体有哪些行为”定义清楚。

2. 方块的数据结构与旋转算法:从游戏规则到C语言矩阵

2.1 为什么用4×4矩阵而不是直接画形状

俄罗斯方块共7种方块:I、O、T、S、Z、J、L。最直观的表示法是用一个4行4列的矩阵,把方块所占格子的值设为1。比如T方块的4×4表示如下:

0 1 0 0 1 1 1 0 0 0 0 0 0 0 0 0

S、Z、J、L、I也都能塞进4×4的矩阵里,O方块则是:

1 1 0 0 1 1 0 0 0 0 0 0 0 0 0 0

你可能会问,为什么不直接用每个方块的“最小包围盒”尺寸,节省点内存?因为4×4矩阵有一个巨大的好处:旋转算法可以统一处理,不需要对每种方块写特例。所有方块的旋转中心都是矩阵的正中心——严格说是4×4格子形成的对称中心,这样I方块横变竖、T方块转90度都遵循同一套矩阵变换,代码量大幅减少,也几乎不会出越界错误。

我用一个二维数组保存所有方块:

#define SHAPE_COUNT 7 #define CELL_SIZE 4 static const int SHAPES[SHAPE_COUNT][CELL_SIZE][CELL_SIZE] = { // O {{1,1,0,0},{1,1,0,0},{0,0,0,0},{0,0,0,0}}, // I {{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, // T {{0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, // S {{0,1,1,0},{1,1,0,0},{0,0,0,0},{0,0,0,0}}, // Z {{1,1,0,0},{0,1,1,0},{0,0,0,0},{0,0,0,0}}, // J {{1,0,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, // L {{0,0,1,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, };

注意我用4行4列是为了描述简单,实际代码里完全可以展平成一维数组,用SHAPES[type][row * 4 + col]访问。一维数组在C里初始化更紧凑,传参也更简单,这是很多开源项目的惯用写法。我建议你先用二维写好、调试通过,再考虑优化成一位数组——不要一开始就追求最优写法,先把逻辑弄扎实。

2.2 旋转算法:转置加水平翻转

旋转是俄罗斯方块里最有数学味道的部分。对于4×4矩阵,顺时针旋转90度可以拆成两步:先沿主对角线转置,再水平翻转每一行。转置的意思是把行列互换,matrix[row][col]变成matrix[col][row];水平翻转就是第0列和第3列交换、第1列和第2列交换。

对应C代码如下:

void rotate_clockwise(int shape[CELL_SIZE][CELL_SIZE]) { // 第一步:转置 for (int r = 0; r < CELL_SIZE; r++) { for (int c = r + 1; c < CELL_SIZE; c++) { int temp = shape[r][c]; shape[r][c] = shape[c][r]; shape[c][r] = temp; } } // 第二步:每行水平翻转 for (int r = 0; r < CELL_SIZE; r++) { for (int c = 0; c < CELL_SIZE / 2; c++) { int temp = shape[r][c]; shape[r][c] = shape[r][CELL_SIZE - 1 - c]; shape[r][CELL_SIZE - 1 - c] = temp; } } }

如果你不想原地修改,也可以先拷贝一份再操作。注意这个函数操作的对象是脱离游戏场的“形状矩阵”,它只负责数学旋转,不负责判断旋转之后能不能放得下。能不能放得下是下一章“碰撞检测”的事,两个问题必须分开处理。很多人一开始把这两件事写在一起,结果旋转逻辑里还夹着边界判断,后面调试起来特别痛苦。

有个小细节:O方块怎么旋转都还是自己,但仍然建议让O方块走统一的旋转流程,不要为它特判“不旋转”。因为特判意味着代码里多一个分支,而分支越多越容易在后续改版时漏掉。统一处理的好处是旋转逻辑永远只有一条路径,测试一次就全部覆盖了。

2.3 贴墙旋转:最小可用的Wall Kick方案

只做矩阵旋转还不够,因为实际游戏里方块可能贴着左墙或右墙。这时候矩阵本身旋转是合法的,但旋转后方块会有一部分跑出10列游戏区,或者和已经堆叠的固定方块重叠。

简单做法是在旋转后尝试几个偏移位置。比如当前方块列坐标为x,旋转后先按原x试一次,如果碰撞就试x-1、x+1、x-2、x+2,找到第一个不碰撞的偏移就用它,全部失败就不旋转。这个思路就是专业玩家常说的Wall Kick,入门级做这个就够了。

伪代码如下:

int try_rotate_with_offset(Game *game, int shape[4][4]) { int rowoff, coloff; rotate_clockwise(shape); for (int offset = 0; offset <= 2; offset++) { int candidates[4] = {0, -offset, offset, -offset * 2}; // 实际用两个方向:0, -1, +1, -2, +2 } }

完整实现看起来啰嗦,核心就是穷举试探。为什么不建议新手去实现专业规则里的完整墙踢表(比如某些竞技规则有20多种偏移组合)?因为那个表是为竞技玩家设计的,需要旋转状态和踢墙偏移精确匹配,新手实现起来容易漏组合、出bug,而且你做的是练手项目,不是竞技平台。能把“旋转+少量偏移补偿”跑通,体验已经足够顺滑,后面真有需要再加复杂表。

3. 碰撞检测、锁定消行:游戏逻辑的三层判定

3.1 游戏场的数据模型:固定场和活动方块分离

俄罗斯方块的数据模型我建议分成两部分:一部分是已经落定、不再移动的方块,存在一个field[ROWS][COLS]二维数组里;另一部分是当前正在控制的活动方块,记录它的形状、当前位置行row和列col。

这两者必须分离,而且最好不要把活动方块直接写进field里。如果你把活动方块也写进同一个数组,移动和旋转时就得频繁修改数组内容,很容易和已落定的方块搞混。活动的逻辑通过一个独立的结构体保存,渲染时才临时组合,逻辑上最清晰:

typedef struct { int shape[4][4]; int row; int col; int type; } ActiveBlock; typedef struct { int cells[ROWS][COLS]; int score; int level; int lines; } GameField;

在C语言里,这种“数据分离”的建模能力比怎么写具体函数更值得练习。多年以后你会碰到各种业务系统,它们本质上也需要你把“临时变化的状态”和“持久化的数据”区分开,这个思想在这里就能打好底子。

3.2 canMove函数:移动、旋转、下落共用同一个判定

碰撞检测在俄罗斯方块里只需要一个核心函数:判断某个形状放在某个位置是否合法。移动、旋转、自动下落都调用它,只是传入的参数不同。

合法条件很简单:形状中的每个1格子,映射到游戏场的行列后,必须在0到ROWS-1、0到COLS-1范围内,并且目标格子是空(值为0)。注意边界判断要先于点阵读取,否则数组下标会越界,读到一个未定义值,可能有时运行正常、有时崩溃——这是C里最阴险的一类bug。

代码大致如下:

int can_place(GameField *field, int shape[4][4], int row, int col) { for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (!shape[r][c]) continue; int fr = row + r; int fc = col + c; if (fr < 0 || fr >= ROWS || fc < 0 || fc >= COLS) return 0; if (field->cells[fr][fc]) return 0; } } return 1; }

这里需要理解为什么要用fr < 0而不是只判断行下边界。因为方块旋转后有可能会出现负的行坐标(接近顶部时旋转),如果不判断上边界,就可能在数组头部之前读取内存,直接未定义行为。我第一次写的时候只判断了下边界,结果方块到顶部时偶尔直接闪没,排查了很久才发现是负下标越界。

整个游戏里碰撞逻辑就只认这一个函数——左右移动传入新列、旋转传入旋转后的矩阵和当前坐标、下落传入row+1。这样出问题时你只需要看这一个函数,不需要在三个地方分别修bug。

3.3 锁定与消行:先落定,再消行,再判断结束

方块无法继续下落时,就要“锁定”它:把活动方块写进field。锁定建议用一个独立函数,写完马上检查消行。

锁定代码的核心是:

void lock_block(GameField *field, ActiveBlock *block) { for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) if (block->shape[r][c]) field->cells[block->row + r][block->col + c] = block->type + 1; }

我在开发时会把存进field的值设为方块类型加一,而不是只标1。这样对将来加颜色、消除特效都非常方便。不过如果只是纯文字版,存1完全够用,不必给自己加戏。

消行逻辑要从下往上扫,每一整行都为非空就删除。删除的动作用“上方所有行下移一格”完成,而不是交换行的顺序。这里有一个新手最容易犯的错:只用一层循环从下往上扫,删除一行后当前行索引不调整,导致连续消行时漏掉上面一行。正确处理是:从最底行开始向上遍历,遇到满行就消除并让所有上面的行下移一行,然后继续从同一行索引检查(因为上方的行移动下来了)。

消完以后根据消除行数加分。我用的标准计分表是:

消除行数得分
1100
2300
3500
4800

分数累加后可以顺便更新等级,我用的是每1000分升一级,每级把下落间隔缩短一些。这个属于个人偏好,不同版本设定差异很大,你可以按自己喜好调。

最后不要忘记:锁定并消行之后,要立刻生成下一个方块,然后检查这个新方块在初始位置是否能放置,如果不能,游戏结束。这个“结束后置检查”很多人会漏,他们会在方块叠到顶部后仍然继续生成,玩家眼睁睁看着界面顶出屏幕,体验很差。

4. 控制台渲染与输入:没有图形库也能把游戏“画”出来

4.1 字符矩阵渲染:一次组装,一次输出

没有图形库,游戏画面就用字符拼。我的做法是准备一个更大的字符二维数组screen[ROWS * 2][COLS * 2 + 1],为什么是两倍宽?因为单个字符在高和宽上视觉比例不一样,一个方块用两个字符宽(比如[])看起来才接近正方形。

每一帧渲染时,先把整个画面组装到screen里,边界字符填好,再根据field里的固定方块和当前活动方块往screen里填内容,最后一次性输出整个画面。关键是不要一个字符一个字符地printf,那样输出函数调用特别多,终端会闪得很厉害。把整个画面先拼成一个二维数组,最后用几个printf("%s", screen[i])整行输出,闪烁问题立刻减轻一大半。

这个“双缓冲”的思想跟图形编程里的双缓冲是一模一样的——先在后台缓冲里画完,再一次性提交到前台。即便你是用字符界面,这个思想也完全适用。

4.2 光标控制和渲染优化细节

为了让画面不闪,还要用到两个转义序列:printf("\033[H")把光标移到左上角,printf("\033[?25l")隐藏光标。这样每帧就可以原地覆盖重绘,而不是先清屏再画,肉眼基本上看不到闪烁。

需要提醒的是,这套转义序列在主流Linux终端和多数现代Windows终端都支持,但老旧的Windows自带控制台可能要打开ANSI支持才能生效。如果你的目标系统不支持,可以用清屏命令替代,但画面会有轻微闪烁,功能不受影响。我最开始写的时候没加这两个转义序列,每一帧都清屏再重画,方块下落时整个画面都在抖,加了光标控制和原地重绘之后顺滑了很多。

另一个经验是:游戏区域外最好留一行提示信息,显示当前分数、等级、下一个方块形状。这些信息不要跟游戏区混在一个数组里,单独一行行输出即可,反正刷新是整体刷的,不会产生切屏效果。

4.3 按键读取:为什么标准getchar会坑你

真正写起来最别扭的是输入。标准getchar()会阻塞,程序等在那边直到用户按下回车,这对俄罗斯方块来说等于画面彻底卡死。你要的是“非阻塞读取”或者至少是“按下立刻反应”的输入方式。

可问题是,非阻塞输入本身不是一个纯C标准的事,它依赖操作系统。我在项目里是这样处理的:把输入处理隔离到一个input.c文件里,对外只暴露一个get_action()函数,业务逻辑完全不管按键是怎么读到的。里面用了类似轮询的方式:没按键就返回“无操作”,有按键就返回对应的游戏动作(左移、右移、旋转、下落、暂停)。

在POSIX环境可以用设置终端为非规范模式的方式去实现,在Windows则可以用控制台API或_kbhit()。具体代码各家平台不同,我就不贴一份了,核心是要做到“某一帧只读一次按键,读不到就返回0”。我实测下来,如果不用非阻塞读取,就算你用getchar()加回车,玩起来也会有半拍延迟,感觉特别难受,所以这一层千万别偷懒。

顺带说一句:按键检测要放在主循环的固定位置,每帧最多读有效按键一次,否则同一帧里可能连续触发两次旋转,玩家明明按了一下,方块却转了180度。这个是我玩的时候发现的老毛病,新手尤其容易踩。

5. 主循环与下落节奏:轮询时钟代替多线程

5.1 游戏主循环的基本骨架

纯C写的游戏,主循环就是一个while,每一帧做四件事:输入处理、逻辑更新、检查是否到下落时机、渲染。我建议不要一上来就上多线程,线程同步在这个项目里带来的麻烦远大于收益,轮询一个时间戳就完全够用。

主循环的C结构大致如下:

while (running) { int action = read_action(); // 非阻塞读取按键 handle_action(&game, action); // 移动、旋转、暂停 if (time_to_drop(&game)) { // 检查下落计时 drop_block(&game); } if (game.state == GAMEOVER) break; render(&game); // 组装并输出画面 sleep_ms(16); // 大约60帧/秒 }

不要小看这个sleep_ms(16),它决定了画面的流畅度。纯C标准库没有跨平台的sleep函数,POSIX下用nanosleep,Windows下可以用Sleep,所以又得把这个小功能放到平台相关层。如果你不想碰平台API,也可以用一个忙等循环消耗时间,不过会占满CPU,不推荐。

5.2 下落速度与等级曲线

俄罗斯方块的节奏感来自下落速度慢慢加快。我设定某个等级的标准下落间隔如下,仅供你参考:

等级每下落一格所需毫秒数
1800
2700
3600
4500
5400
6300
7200
8150
9100
10+80

实现方法是在每次循环时记录当前时间,跟上次下落时间比较。如果两者差值大于当前等级的下落间隔,就执行一次下落,并更新“上次下落时间”。

这里要特别提醒:不要用clock()而要用“墙上时钟时间”。clock()统计的是进程消耗的CPU时间,在游戏这类“大部分时间在sleep”的程序里,它走得很慢,而且在不同平台上的含义都不太一样。用time()可以精确到秒,做俄罗斯方块绰绰有余;如果以后想做需要毫秒级计时的项目,可以用C11的timespec_get,这个函数是标准库的一部分,比平台API干净得多。

5.3 游戏状态机:开始、游戏、暂停、结束

游戏并不是时时刻刻都在响应所有按键。当玩家按下暂停键时,方块应该停在空中,计时器也不该走。所以主循环里最好有一个状态变量:

typedef enum { STATE_PLAYING, STATE_PAUSED, STATE_GAMEOVER } GameState;

我的做法是:清空缓冲后第一帧进入游戏状态;游戏状态下响应全部操作按键;按下P切换到暂停,暂停时按下P恢复;GAMEOVER时只显示数据,任何操作不再改变方块位置。

有了状态机,主循环的逻辑就简单了:先根据状态决定哪些操作有效,再做逻辑更新。很多新手会把暂停写成“一个标志位”,结果暂停时按下左右键方块还会偷偷移动,就是因为没有把状态机的概念跟“按键响应条件”统一起来。这是我建议你用枚举而不是布尔标志的原因——状态一多,布尔变量根本无法表达。

有个小细节值得说:暂停时也最好不要完全退出循环,否则画面会黑掉。正确做法是继续循环,只是不推进方块下落,同时按P时在界面上显示“已暂停”字样。这样玩家看到的是一个静止画面,而不是黑屏。

6. 工程组织、编译脚本与实测踩坑记录

6.1 工程文件怎么拆分才不越拆越乱

我见过很多学C的人,做项目到后期整个工程就一个巨长的源文件,找函数要翻半天。这次我建议从一开始就拆好模块。我的文件划分是:

文件职责对外接口
main.c主循环、状态机调度main函数
game.c / game.h游戏核心逻辑,移动、旋转、消行game_init、game_update
tetromino.c / tetromino.h方块形状定义、旋转算法shape_get、shape_rotate
field.c / field.h游戏场数组与碰撞检测field_clear、field_can_place、field_clear_lines
input.c / input.h非阻塞按键读取input_init、read_action
render.c / render.h字符画面组装与输出render_draw

拆分原则就一条:按“职责”拆,不按“流程”拆。比如你能想到的“按游戏流程”拆成“初始化阶段”“移动阶段”“结束阶段”,这种拆法后续加功能会很痛苦。按模块拆,每个头文件只暴露少量接口,其他细节都藏在.c文件的静态函数里,这样别人看你的头文件就能知道这个模块能干吗,不用读实现。

头文件里还要加头文件保护宏,这种细节在团队项目里很重要,别嫌啰嗦。如果只是单文件练手,我建议单文件版本先跑通,再拆模块,先有能玩的东西,再谈可维护性。我自己是先写一个300行的单文件版本,确认逻辑没错,再拆成多文件,调试效率更高。

6.2 从单文件到Makefile:编译命令别靠手敲

项目小的时候直接一条gcc命令搞定。我的编译命令是:

gcc -std=c11 -Wall -Wextra main.c game.c tetromino.c field.c input.c render.c -o tetris

在写代码阶段务必打开-Wall -Wextra,把所有警告都当成潜在bug,不要无视。C语言不像一些脚本语言有强提示,一个未初始化的变量可能蛰伏很久才爆发,警告里往往已经告诉你了。

到模块多一点、想省事的时候,可以写一个极简Makefile:

CC = gcc CFLAGS = -std=c11 -Wall -Wextra OBJS = main.o game.o tetromino.o field.o input.o render.o tetris: $(OBJS) $(CC) $(CFLAGS) $(OBJS) -o tetris %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f tetris $(OBJS)

这个Makefile我实测够用,不用引入自动依赖生成那些高级技巧,先把“编译、运行、清理”三件事自动化就可以。你要是用的是其他编译器,把CC改成你本地的工具链名字即可。

6.3 我实测踩过的六个坑,提前帮你绕开

整个项目写下来,最耗时间的问题往往不是算法本身,而是一些看似不起眼的细节。我把踩过最深的坑列在这里,每个都是真实Debug到头疼才解决的:

  1. 旋转边界检查顺序错了。原先是先查目标格子内容,再查是否越界,结果方块靠墙旋转时读到了数组外的内存,行为时好时坏。后来改成先越界检查、后点阵检查,稳了。C语言里数组越界是未定义行为,什么时候炸全看运气,千万别赌。

  2. 消行的方向写反了。第一次我从上往下扫满行,删除一行后上面行往下移,结果被移动下来的行又扫了一遍,连续消两行时漏掉一行。改成从下往上扫、消完继续检查同一个行号后解决。

  3. 锁定后没同步清理“活动方块”状态。渲染时先把活动方块画上去再画固定方块,锁定帧就会同时出现两个方块的重影。修复办法是在锁定函数里把活动方块标记为“无”,渲染逻辑只在方块有效时才绘制它。

  4. 按键放到了下落判断之前和渲染之后。原本想着“输入-逻辑-渲染”的顺序没错,但补丁越加越乱,同一帧旋转和下落同时发生,方块落到底部时还跟着转了90度。把按键处理严格放到每帧最前面,然后按状态机分发,问题才消失。

  5. 忙等延时占满CPU。为了图省事用了空循环等高损耗方式掌控帧率,后果是电脑风扇狂转。换成交给系统调度的sleep方式后,CPU占用率几乎可以忽略不计。

  6. 时间源用了clock()导致下落忽快忽慢。前文已经说过原因了,换成墙上时钟后,即使系统在高负载下,下落节奏也基本稳定。这一点在你开着其他程序调试时体会尤其明显。

走完这一圈你会发现,写俄罗斯方块真正得到的不是“会写俄罗斯方块”这个标签,而是“把一个模糊的游戏想法,拆成数据结构、算法和干净接口”的完整训练。以后再看到贪吃蛇、五子棋这类经典小游戏,你就能一眼看出它大概需要哪些模块、哪些核心函数,心里有谱得多。

最后分享一个我自己的使用习惯:做完基础版之后,继续往项目里加“下一个方块预览”和“消行闪烁”只会影响渲染层和一楼界面调用,核心的碰撞、旋转、消行逻辑可以一行不改。这其实就是你模块拆得好不好的试金石——如果加一个小功能要改五个模块,那拆得就有问题;如果只在render加点东西,说明核心逻辑的接口设计是好的。建议你也试试这个“加功能测试”,它比多写几个算法题更能训练工程直觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询