阅读源码八原则 - WangTingZheng/mcp940 GitHub Wiki
- 先看属性后看方法
- 看方法时归类阅读
- 拒绝递归阅读,集中于一个类
- 集中于一个模块,切勿拿了西瓜丢了玉米
- 实践出真知,运行游戏验证你的想法
- 多做注释、多写文档
- 多向他人讲解你学到的知识
- 通过阶段性的项目巩固你的学习成果
Minecraft是使用Java编写的,作为一门面向对象的编程语言,类是代码的最小单位,一个类一般由属性和方法组成,属性储存数据,描述类的性质,方法定义方法,操作属性,参与与其它类的交互。所以在阅读一个类时必定先看属性后看方法。
我们首先来看看一个类的属性,我们可以看到,mcp对绝大部分的属性进行了注释,通过这些注释,结合Minecraft的玩法,我们需要了解每一个属性的情况,那么我们应当如何阅读一个属性呢?
- 先看属性的注释,从注释中了解这个属性的大概用处
- 再看属性的类型,看属性的类型主要看两部分:
- 属性的类是什么,如果你不熟悉这个类,可以按住
Ctrl
键然后点击类名,进入这个类,然后大概浏览一下这个类是干嘛的 - 如果一下子看不懂,前往别硬看,你可以使用搜索引擎搜索这个类的关键词,一般你能在知乎,mcbbs,csdn,GitHub等地方搜索到别人对这个类的解释
- 再看属性的数据结构,看看它是数组还是Map
- 属性的类是什么,如果你不熟悉这个类,可以按住
- 再看类中的方法
在IDEA中,左边的侧边栏有一个叫Structure
的按钮,点击它,IDEA将向你展示编辑器中的类的所有的枚举类型、类、属性和方法:
你可以点击上方的各个图标激活/取消各种功能,你可以通过点击一个黄色的写着f
的图标取消属性的显示,只看方法(当然会显示枚举类型,但是可以忽略),你还可以点击第二个图标,带箭头和a,z的那个来以首字母排序整个类的方法,你还应该点击激活第六个图标,带锁的那一个来显示不是public的方法,这样你就可以以它为导航,阅读类里的方法了。
上面我们已经对整个类里的方法以首字母进行了排序,现在我们可以归类阅读方法了,我们看到,经过排序,此类里的所有get方法都已经放在了一起。我们先从get方法开始。
我们知道,一个get方法大概是这样的:
public IBlockState getBlockState(final int x, final int y, final int z)
{
//do something
return blockStateObject;
}
- 对于功能来讲,get方法一般是通过传入某些值(也可以不传),经过一些列操作,获取需要的对象
- 对于函数名字,一般是
get
+想拿到的东西,比如说我想要个方块状态对象,那我就取名getBlockstate
- 对于参数,我们一般会传入一些获得对象所需要的参数,就像你去买零食得拿着钱去一样,当然也可以不传
- 对于函数体,一般是一些列能把输入值转化为所需要值的操作
- 对于返回值一般是想要的对象
所有的get方法都是一样的格式,我们一起看get方法的话只需要关心这么一套思路就行,这样我们会更有精力把注意力放在具体的函数体里;还有,一般一个类里的所有get方法的集合,刚好是实现从这个类里获得它所有的类属性。所以归类阅读方法是很有必要的。
在一个类的方法中,常常会引入其它类的对象,我们在阅读函数体的时候,如果不搞清除这些类的对象和对象的方法的话,我们就不能理解整个函数,所以我们通常需要去这个类的定义或者这个类的用到的方法的具体实现内看看,这里我们容易犯一个错误,就是一定要逼迫自己看懂一个类或一个方法的具体实现才肯跳回来,我们可以想象,其它类和其它类的方法中肯定有可能含有不是本类的对象和方法,如果你还是遵循着这么一种递归阅读的方法的话,你将很难完整阅读完一个类,这很容易导致阅读者产生厌烦的情绪,因为你看了这么久,会发现连一个完整的方法都没看懂,不容易产生正循环,所以我们应当适可而止,拒绝递归阅读。
我们应当给递归设定一个清晰的界限,我们必须得明确一个目标,那就是我们应当全力摸透一个类,所以我们规定:
- 只有当阅读本类的时候,才关心一个方法的具体实现
- 如果跳转到了一个新的类或者一个新的类的方法,我们只需要关注类的大概含义、用法即可,不用关注其内部的具体实现
只有这样,我们才能集中精力阅读完一个完整的方法。
Minecraft作为一个商业项目,其代码规模不算小,因此Minecraft被设计者们分成了好几个模块,你可以从包的分类可以看出来,比如world文件夹下的代码主要负责的是地图元素的封装、世界的生成,network下的代码负责联机下的网络通信。每一个模块的代码量都不小,有些人喜欢在多个模块之间反复横跳,我是不赞同的,主要原因如下:
- 每个模块代码量都不小,而且风格迥异,之间风格相差较大,切换的适应成本太高
- 每个模块之间缺乏强联系,之间引用的也较少,如果集中在一个模块下的话,几个类之间会互相引用,阅读的时候能加强记忆
- 在多个模块之间横跳,统一时间内积累的知识不能很快用项目的方式巩固,在别人看完一个模块的时候,你只看了两个模块的各一块,每个模块都不能独当一面,用所学的制作一个完整的项目
纸上谈兵终觉浅,人类总是对具体的事物更感兴趣,如果你对某一个具体的技术细节有疑惑,最好的办法就是修改代码,然后在游戏中验证你的猜想。
我们以下面Chunk.java里的这个函数为例,讲解如何从实践里,出真知:
public BlockPos getPrecipitationHeight(BlockPos pos)
{
int i = pos.getX() & 15;
int j = pos.getZ() & 15;
int k = i | j << 4; //根据x,z轴获得在一个chunk平面的序号
BlockPos blockpos = new BlockPos(pos.getX(), this.precipitationHeightMap[k], pos.getZ()); //根据方块的x,z和降水高度获得接收到降水的区块的位置
if (blockpos.getY() == -999) //TODO: 这啥意思??
{
int l = this.getTopFilledSegment() + 15; //取最上面非空section的最上面一层方块
blockpos = new BlockPos(pos.getX(), l, pos.getZ()); //获取该方块的位置对象
int i1 = -1; //定义虚空的降水高度
while (blockpos.getY() > 0 && i1 == -1) //如果方块存在且刚刚开始
{
IBlockState iblockstate = this.getBlockState(blockpos); //获得方块
Material material = iblockstate.getMaterial(); //获得材质
if (!material.blocksMovement() && !material.isLiquid()) //如果方块不是固体且方块不是液体
{
blockpos = blockpos.down(); //那就是空的呗,位置下降一格,大概是模拟雨滴下降的动作吧
}
else //如果是固体或者是液体,雨滴收到了遮挡
{
i1 = blockpos.getY() + 1; //降水高度应该是接触方块的上面一格
}
}
//如果你想获得0层以及0层以下的降水高度,那么降水高度是-1
this.precipitationHeightMap[k] = i1; //把降水高度赋值给列表
}
return new BlockPos(pos.getX(), this.precipitationHeightMap[k], pos.getZ());
}
我们看这个函数,从它的名字来看,这个函数的功能是获得降水高度,从返回值的类型来看,它需要得到的是一个封装了方块位置的BlockPos类的对象。
前面我们大概能理解它的思路,先是从输入的BlockPos对象里获得所要求降水高度的方块的x轴和z轴,&
上15的目的主要是让坐标值处于0-15之间,毕竟我们是在一个chunk里进行操作的,得到x轴和z轴的坐标后,使用i | j << 4
获得该方块在它所在平面的序号,这个序号从0-255,然后根据这个序号获得方块所在列(垂直的一列)的降水高度,这个是一个y坐标值,其含义是该列接到的降水的方块的y坐标值,有了它后加上x轴和z轴坐标,新建一个BlockPos对象来代表这个列的降水所在方块的位置。
然后判断,如果这个降水位置的y坐标是-999的时候,说明从数组precipitationHeightMap
获得的降水处于初始化状态,-999是一个无意义的初始化值,只作判定用,我们可以使用IDEA的Find Usage
来查看这个变量的使用的地方:
我们发现,只有上面这个函数的第1262行有一个给``precipitationHeightMap`赋非-999值的地方,也就是说如果碰到有降水高度是-999的情况,就说明还没有执行这个函数。
我们需要更新它的值,我们先获得此方块所在section的最上面一层方块的y坐标值,再用这个y坐标值替换原来无意义的y坐标值-999,给blockpos赋一个新的值,接下来,我们定义一个虚空世界的降水高度值,这个值为-1,所有处于虚空的方块的降水高度都是这个值,也就是说,虚空的降水最多只能下到-1层。什么,你不相信??我们来分析分析。
我们假设我们现在要利用这个函数获取一个处在虚空的方块的降水高度,以此为依据,控制降水到什么地方停下,虚空方块的坐标的特点是,其y坐标为负数,那我们就可以随便找一个虚空世界的方块坐标,比如说是(131,-22,12)
,把它封装成一个BlockPos对象后传入中国函数,并获取返回值,我们看看会发生什么。
前面的几行没有什么疑问,获取坐标值,获得本列降水下一个方块的位置对象,由于我们没有执行过这个函数,所以下面的if (blockpos.getY() == -999)
这个判断我们是符合的。
首先我们获得最上面的非空section的最顶层y坐标,在生成对应的方块位置对象,然后我们定义了一个虚空降水高度-1,然后我们进入了一个while循环,这个while循环的进入条件是blockpos.getY() > 0 && i1 == -1
我们显然不符合第一条,所以我们不进入这个while循环,然后我们就直接就把i1
也就是-1的值赋值给precipitationHeightMap
数组,当成是这个列的降水高度,最后封装成BlockPos对象返回出来,所以虚空(包括基岩)的降水高度都是-1格,也就是基岩下面的一格。
那不是虚空也不是基岩的方块的降水高度是然后计算的呢?我们就得看while循环内部的东西了,我们知道,非虚空和基岩的y坐标值肯定是大于0的,同时如果在第一次while循环中,i1的值还是-1没有变,所以while循环的进入条件是符合的,我们进入了while循环。
首先我们根据方块的坐标对象,获得了方块的状态对象,然后我们使用方块状态获得了方块的材质,然后我们判断了,如果方块的材质不能让人移动且不是液体的话,就执行blockpos = blockpos.down();
就是把blockpos的y坐标值减1,把指针
指向下一格方块,它的意思是,这个方块可以使雨滴透过,所以不可能是降水高度所在点,所以我们得往下找,等进入下一个循环,我们依旧这么判断,如果还是能使雨滴透过的方块,我们就继续找,知道我们遇到一个雨滴透不过的方块,我们就把这个方块的上一格的y轴坐标加1,赋值给i1,此时i1就是我们要找的降水高度,因为雨水在这个方块上面停住了。下面就是虚空下的降水的实物图,我们可以看到,虚空的降水停在了-1层。
i1被赋了非-1值,也就不满足while循环的条件,自然就退出了循环,退出循环后,就说明我们找到了真实的降水高度,也就是雨滴应该停止的地方,我们再把i1赋值给precipitationHeightMap
,然后把这个雨滴停留的方块的位置封装成BlockPos对象返回就可以了。
那如何验证我们这套理论呢?Minecraft真的就这么运行的吗?我们可以把i1 = blockpos.getY() + 1;
的+1去掉或者减去1,看看会发生什么,我们应当在一个蜘蛛网下方放一个玻璃方块,当我们保留+1的时候,当while循环从蜘蛛网遍历到玻璃方块时候,会运行这条语句,此时记录的雨滴停留方块应当在蜘蛛网的这一格上,也就是在玻璃方块和蜘蛛网交界的平面,会出现雨滴撞击的深蓝色粒子特效,如果我们去掉+1,那么雨滴的降水高度将会判定在玻璃方块的位置上,雨滴的碰撞效果也会在玻璃方块的底部产生,但是由于玻璃方块是透明的,我们依然可以看到这个粒子效果。减一同理,但是粒子效果会在最底层的玻璃方块的下表面产生,下图就是不加一和加一和减一的实际效果图。
我们可以看到,现实情况和我们预想的一样,当不加1的时候,游戏判定的降水高度是玻璃方块这一层,粒子特效也产生于第一个玻璃方块和第二个玻璃方块之间,也就是最左边这幅图所展示的情况,这种情况是不正常的,因为玻璃方块不透雨,雨滴不应该落在两个玻璃方块之间,如果加1,也就是正常情况,也就是中间这幅图的情况,降水判定在蜘蛛网和玻璃方块之间,碰撞产生的粒子效果也产生在玻璃方块和蜘蛛网之间。如果减1,那就是右边这幅图的情况,此时,降水判定在正常位置的下面两格,也就是最底层玻璃方块的下面表面。
看起来会和能写出来是不一样的概念,想必大家上学做题目的时候应该有类似的体验,看着一道题很容易,没动笔钱觉得很容易,当真正做起来,发觉会遇到很多问题。所以在阅读的过程中,不能光看不写,需要做一些文档整理。
文档分为两部分:
- 代码注释和javadoc
- wiki
代码注释和Javadoc类似于文章的批注吧,看一个函数的函数体的某一行时,可以把这一行的作用以及如何实现这个作用写在注释里,这样有利于整个函数的思路的梳理,当阅读完一整个函数后,你可以利用Javadoc为整个函数做注释,讲明白函数的功能,实现的主要思路、各个参数的意义、返回值的意义。
wiki一般是在代码注释和javadoc之后的,一般是在有一些阶段性成果的时候开始的工作,比如说你看完了一个类里的所有的属性和方法,你就应当用wiki的方式阐明整个类的细节。
你以为你整理完文档后就真正掌握了吗?当你给一个小白讲解你学会的知识的时候,你百分之百会遇到以下两种情况
- 讲着讲着发现自己以前的理解有问题
- 当对方提出一个问题时,你发现你只是知道,但是不清楚是怎么来的
第一种的情况你有可能在做文档的生活出现,但是那个时候你实际上是给你自己讲一遍知识点,所以会有思维定势,之前没注意的点,写文档的时候也很难找到,当你向他人讲解时,他人对知识点的理解不同,他是一个完全独立于你的人,所以很容易跳出思维定势,找出之前不知道的错误。比方说很多时候,我会突然发现我找不到我的眼镜了,当我着急忙慌地找四处找眼镜时,我地同学会笑着对我说:眼镜不是在你鼻梁上吗,还用找吗?这就是与他人交流知识点地价值,没有同学地提醒,我估计得找好一阵子。
第二种情况往往只出现在向他人讲解知识点的时候,我们知道,当我推导一些公式的时候,我们没必要一定要从公理开始推,有些时候借助一些定理(定理一般也是从公理推导而来的)来推导我们的结论,这样更加省事。我们看一个项目的原理也一样,一些功能的实现原理,是建立在其它算法的基础上的,我们自己阅读的时候往往只会关注我们用到的算法,而不会关心算法的具体的实现,当你向一个小白讲解一个功能的原理时,他会对有关内部算法的具体实现更感兴趣,这个时候就会驱使你去了解一些你之前因为想当然而忽略的底层实现。我并不是说我们每了解一个知识点的时候都要死磕到底,我只是希望我们能尽量了解地深入一点,很显然,向他人讲解知识点有助于这一点。
此成果非彼成果,前面一个成果是一个具体的项目,说的更通俗一点就是利用学到的系统知识解决一个实际问题,后面一个成果是你学习minecraft源码的阶段性成果。
学习的最高境界就是灵活使用它,使用它解决各种问题,只有在实践中解决问题了,才能证明你掌握了知识,才能体现它的价值,这种唯解决问题论是工科领域所要追求的。是工科一切知识的根本目的。所以最后一步到了。
我之所以选择花费大力气阅读Minecraft源码,就是看中了它的进可攻,退可守,进,可以学习各个领域的算法,从数学计算,到计算机图形学,再到设计模式,这些都是能给我们带来实际经济效益的知识,因为它们在脱离Minecraft的商业环境中依然有市场,依然能解决实际问题,退,可以学习制作Minecraft的mod,丰富游戏内容,让自己玩游戏更加快乐。前者是较为抽象的,学习的知识都是偏理论的,后者是较为具体的,学习的知识都是偏实践的,实践,又离不开理论的支持。
所以,在学习完Minecraft的一个模块后,你就可以认为你完成了一个模块的学习,你就可以通过实践,也就是制作一些程序来巩固你的阶段性成果了。
比如在写这篇文章时,我正在看world这个模块,也就是地图元素的组织方式(从Block到Section,再到Chunk,再到World)、地形的生成、实体、降水、光线的更新。那么当我完成这个模块后,我就可以做两个项目来巩固我学习的知识:
- 制作一个mod,使玩家能够在一个范围通过两个点来确定一个长方体,再通过多个立方体确定一个范围,把这个范围内的红石机关、建筑的数据单独保存在一个文件中,在地图的其它地方或者在其它地图内,玩家都可以重新拿出保存的红石机关、建筑来放在新地点上使用,达到红石机关、建筑的复用的效果。
- 制作一个latex插件,能在latex页面内显示一小块可交互的红石机关或建筑,供作者表达自己的思路,用于红石、建筑爱好者向别人说明自己作品的思路。