JavaScript 游戏开发:手把手实现碰撞物理引擎

这样就成功的绘制了一个圆形,我们在这把它当作一个小球:

image.png

移动小球


不过,这个时候的小球还是静止的,如果想让它移动,那么得修改它的圆心坐标,具体修改的数值则与运动速度有关。在移动小球之前,先看一下 canvas 进行动画的原理:

Canvas 进行动画的原理与传统的电影胶片类似,在一段时间内,绘制图像、更新图像位置或形状、清除画布,重新绘制图像,当在 1 秒内连续执行 60 次或以上这样的操作时,即以 60 帧的速度,就可以产生连续的画面。

那么在 JavaScript 中,浏览器提供了 window.requestAnimationFrame() 方法,它接收一个回调函数作为参数,每一次执行回调函数就相当于 1 帧动画,我们需要通过递归或循环连续调用它,浏览器会尽可能的在 1 秒内执行 60 次回调函数。那么利用它,我们就可以对 canvas 进行重绘,以实现小球的移动效果。

由于 window.requestAnimationFrame() 的调用基本是持续进行的,所以我们也可以把它称为游戏循环(Game loop)。

接下来我们来看如何编写动画的基础结构:

function process() {

window.requestAnimationFrame(process);

}

window.requestAnimationFrame(process);

这里的 process() 函数就是 1 秒钟要执行 60 次的回调函数,每次执行完毕后继续调用 window.requestAnimationFrame(process)进行下一次循环。如果要移动小球,那么就需要把绘制小球和修改圆心 x、y 坐标的代码写到 process() 函数中。

为了方便更新坐标,我们把小球的圆心坐标保存到变量中,以方便对它们进行修改,然后再定义两个新的变量,分别表示在 x 轴方向上的速度 vx,和 y 轴方向上的速度 vy,然后把 context 相关的绘图操作放到 process() 中:

let x = 100;

let y = 100;

let vx = 12;

let vy = 25;

function process() {

ctx.fillStyle = “hsl(170, 100%, 50%)”;

ctx.beginPath();

ctx.arc(x, y, 60, 0, 2 * Math.PI);

ctx.fill();

window.requestAnimationFrame(process);

}

window.requestAnimationFrame(process);

要计算圆心坐标 x、y 的移动距离,我们需要速度和时间,速度这里有了, 那么时间要怎么获取呢? window.requestAnimationFrame() 会把当前时间的毫秒数(即时间戳)传递给回调函数,我们可以把本次调用的时间戳保存起来,然后在下一次调用时计算出执行这 1 帧动画消耗了多少秒,然后根据这个秒数和 x、y 轴方向上的速度去计算移动距离,分别加到 x 和 y 上,以获得最新的位置。注意这里的时间是上一次函数调用和本次函数调用的时间间隔,并不是第 1 次函数调用到当前函数调用总共过去了多少秒,所以相当于是时间增量,需要在之前 x 和 y 的值的基础上进行相加,代码如下:

let startTime;

function process(now) {

if (!startTime) {

startTime = now;

}

let seconds = (now - startTime) / 1000;

startTime = now;

// 更新位置

x += vx * seconds;

y += vy * seconds;

// 清除画布

ctx.clearRect(0, 0, width, height);

// 绘制小球

ctx.fillStyle = “hsl(170, 100%, 50%)”;

ctx.beginPath();

ctx.arc(x, y, 60, 0, 2 * Math.PI);

ctx.fill();

window.requestAnimationFrame(process);

}

process() 现在接收当前时间戳作为参数,然后做了下面这些操作:

  • 计算上次函数调用与本次函数调用的时间间隔,以秒计,记录本次调用的时间戳用于下一次计算。

  • 根据 x、y 方向上的速度,和刚刚计算出来的时间,计算出移动距离。

  • 调用 clearRect() 清除矩形区域画布,这里的参数,前两个是左上角坐标,后两个是宽高,把 canvas 的宽高传进去就会把整个画布清除。

  • 重新绘制小球。

现在小球就可以移动了:

moving-ball.gif

重构代码


上边的代码适合只有一个小球的情况,如果有多个小球需要绘制,就得编写大量重复的代码,这时我们可以把小球抽象成一个类,里边有绘图、更新位置等操作,还有坐标、速度、半径等属性,重构后的代码如下:

class Circle {

constructor(context, x, y, r, vx, vy) {

this.context = context;

this.x = x;

this.y = y;

this.r = r;

this.vx = vx;

this.vy = vy;

}

// 绘制小球

draw() {

this.context.fillStyle = “hsl(170, 100%, 50%)”;

this.context.beginPath();

this.context.arc(this.x, this.y, this.r, 0, 2 * Math.PI);

this.context.fill();

}

/**

  • 更新画布

  • @param {number} seconds

*/

update(seconds) {

this.x += this.vx * seconds;

this.y += this.vy * seconds;

}

}

里边的代码跟之前的一样,这里就不再赘述了,需要注意的是,Circle 类的 context 画笔属性是通过构造函数传递进来的,更新位置的代码放到了 update() 方法中。

对于整个 canvas 的绘制过程,也可以抽象成一个类,当作是游戏或引擎控制器,例如把它放到一个叫 Gameboard 的类中:

class Gameboard {

constructor() {

this.startTime;

this.init();

}

init() {

this.circles = [

new Circle(ctx, 100, 100, 60, 12, 25),

new Circle(ctx, 180, 180, 30, 70, 45),

];

window.requestAnimationFrame(this.process.bind(this));

}

process(now) {

if (!this.startTime) {

this.startTime = now;

}

let seconds = (now - this.startTime) / 1000;

this.startTime = now;

for (let i = 0; i < this.circles.length; i++) {

this.circles[i].update(seconds);

}

ctx.clearRect(0, 0, width, height);

for (let i = 0; i < this.circles.length; i++) {

this.circles[i].draw(ctx);

}

window.requestAnimationFrame(this.process.bind(this));

}

}

new Gameboard();

在 Gameboard 类中:

  • startTime 保存了上次函数执行的时间戳的属性,放到了构造函数中。

  • init() 方法创建了一个 circles 数组,里边放了两个示例的小球,这里先不涉及碰撞问题。然后调用 window.requestAnimationFrame() 开启动画。注意这里使用了 bind() 来把 Gameboard 的 this 绑定到回调函数中,以便于访问 Gameboard 中的方法和属性。

  • process() 方法也写到了这里边,每次执行时会遍历小球数组,对每个小球进行位置更新,然后清除画布,再重新绘制每个小球。

  • 最后初始化 Gameboard 对象就可以开始执行动画了。

这个时候有两个小球在移动了。

two-moving-balls.gif

碰撞检测


为了实现仿真的物理特性,多个物体间碰撞会有相应的反应,第一步就是要先检测碰撞。我们先再多加几个小球,以便于碰撞的发生,在 Gameboard 类的 init() 方法中再添加几个小球:

this.circles = [

new Circle(ctx, 30, 50, 30, -100, 390),

new Circle(ctx, 60, 180, 20, 180, -275),

new Circle(ctx, 120, 100, 60, 120, 262),

new Circle(ctx, 150, 180, 10, -130, 138),

new Circle(ctx, 190, 210, 10, 138, -280),

new Circle(ctx, 220, 240, 10, 142, 350),

new Circle(ctx, 100, 260, 10, 135, -460),

new Circle(ctx, 120, 285, 10, -165, 370),

new Circle(ctx, 140, 290, 10, 125, 230),

new Circle(ctx, 160, 380, 10, -175, -180),

new Circle(ctx, 180, 310, 10, 115, 440),

new Circle(ctx, 100, 310, 10, -195, -325),

new Circle(ctx, 60, 150, 10, -138, 420),

new Circle(ctx, 70, 430, 45, 135, -230),

new Circle(ctx, 250, 290, 40, -140, 335),

];

然后给小球添加一个碰撞状态,在碰撞时,给两个小球设置为不同的颜色:

class Circle {

constructor(context, x, y, r, vx, vy) {

// 其它代码

this.colliding = false;

}

draw() {

this.context.fillStyle = this.colliding

? “hsl(300, 100%, 70%)”
“hsl(170, 100%, 50%)”;

// 其它代码

}

}

现在来判断小球之间是否发生了碰撞,这个条件很简单,判断两个小球圆心的距离是否小于两个小球的半径之和就可以了,如果小于等于则发生了碰撞,大于则没有发生碰撞。圆心的距离即计算两个坐标点的距离,可以用公式:

( x 1 − x 2 ) 2 + ( y 1 − y 2 ) 2 \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} (x1​−x2​)2+(y1​−y2​)2 ​

x1、y1 和 x2、y2 分别两个小球的圆心坐标。在比较时,可以对半径和进行平方运算,进而省略对距离的开方运算,也就是可以用下方的公式进行比较:

( x 1 − x 2 ) 2 + ( y 1 − y 2 ) 2 ≤ ( r 1 + r 2 ) 2 (x_1 - x_2)^2 + (y_1 - y_2)^2 \leq (r_1 + r_2)^2 (x1​−x2​)2+(y1​−y2​)2≤(r1​+r2​)2

r1 和 r2 为两球的半径。

在 Circle 类中,先添加一个isCircleCollided(other)方法,接收另一个小球对象作为参数,返回比较结果:

isCircleCollided(other) {

let squareDistance =

(this.x - other.x) * (this.x - other.x) +

(this.y - other.y) * (this.y - other.y);

let squareRadius = (this.r + other.r) * (this.r + other.r);

return squareDistance <= squareRadius;

}

再添加 checkCollideWith(other) 方法,调用 isCircleCollided(other) 判断碰撞后,把两球的碰撞状态设置为 true:

checkCollideWith(other) {

if (this.isCircleCollided(other)) {

this.colliding = true;

other.colliding = true;

}

}

接着我们需要使用双循环两两比对小球是否发生了碰撞,由于小球数组存放在 Gameboard 对象中,我们给它添加一个 checkCollision() 方法来检测碰撞:

checkCollision() {

// 重置碰撞状态

this.circles.forEach((circle) => (circle.colliding = false));

for (let i = 0; i < this.circles.length; i++) {

for (let j = i + 1; j < this.circles.length; j++) {

this.circles[i].checkCollideWith(this.circles[j]);

}

}

}

因为小球在碰撞后就应立即弹开,所以我们一开始要把所有小球的碰撞状态设置为 false,之后在循环中,对每个小球进行检测。这里注意到内层循环是从 i + 1 开始的,这是因为在判断 1 球和 2 球是否碰撞后,就无须再判断 2 球 和 1 球了。

之后在 process() 方法中,执行检测,注意检测应该发生在使用 for 循环更新小球位置的后边才准确:

for (let i = 0; i < this.circles.length; i++) {

this.circles[i].update(seconds);

}

this.checkCollision();

现在,可以看到小球在碰撞时,会改变颜色了。

collision-detect.gif

边界碰撞


上边的代码在执行之后,小球都会穿过边界跑到外边去,那么我们先处理一下边界碰撞的问题。检测边界碰撞需要把四个面全部都处理到,根据圆心坐标和半径来判断是否和边界发生了碰撞。例如跟左边界发生碰撞时,圆心的 x 坐标是小于或等于半径长度的,而跟右边界发生碰撞时,圆心 x 坐标应该大于或等于画布最右侧坐标(即宽度值)减去半径的长度。上边界和下边界类似,只是使用圆心 y 坐标和画布的高度值。在水平方向上(即左右边界)发生碰撞时,小球的运动方向发生改变,只需要把垂直方向上的速度 vy 值取反即可,在垂直方向上碰撞则把 vx 取反。

edge-collision-diagram.png

现在看一下代码的实现,在 Gameboard 类中添加一个 checkEdgeCollision() 方法,根据上边描述的规则编写如下代码:

checkEdgeCollision() {

this.circles.forEach((circle) => {

// 左右墙壁碰撞

if (circle.x < circle.r) {

circle.vx = -circle.vx;

circle.x = circle.r;

} else if (circle.x > width - circle.r) {

circle.vx = -circle.vx;

circle.x = width - circle.r;

}

// 上下墙壁碰撞

if (circle.y < circle.r) {

circle.vy = -circle.vy;

circle.y = circle.r;

} else if (circle.y > height - circle.r) {

circle.vy = -circle.vy;

circle.y = height - circle.r;

}

});

}

在代码中,碰撞时,除了对速度进行取反操作之外,还把小球的坐标修改为紧临边界,防止超出。接下来在 process() 中添加对边界碰撞的检测:

this.checkEdgeCollision();

this.checkCollision();

这时候可以看到小球在碰到边界时,可以反弹了:

edge-collision.gif

但是小球间的碰撞还没有处理,在处理之前,先复习一下向量的基本操作,数学好的同学可以直接跳过,只看相关的代码。

向量的基本操作


由于在碰撞时,需要对速度向量(或称为矢量)进行操作,向量是使用类似坐标的形式表示的,例如 < 3, 5 > (这里用 <> 表示向量),它有长度和方向,对于它的运算有一定的规则,本教程中需要用到向量的加法、减法、乘法、点乘和标准化操作。

向量相加只需要把两个向量的 x 坐标和 y 坐标相加即可,例如: < 3 , 5 > + < 1 , 2 > = < 4 , 7 > ❤️, 5> + <1, 2> = <4, 7> ❤️,5>+<1,2>=<4,7>

减法与加法类似,把 x 坐标和 y 坐标相减,例如: < 3 , 5 > − < 1 , 2 > = < 2 , 3 > ❤️, 5> - <1, 2> = <2, 3> ❤️,5>−<1,2>=<2,3>

乘法,这里指的是向量和标量的乘法,标量指的就是普通的数字,结果是把 x 和 y 分别和标量相乘,例如: 3 × < 3 , 5 > = < 9 , 15 > 3\times<3, 5> = <9, 15> 3×<3,5>=<9,15>。

点乘是两个向量相乘的一种方式,类似的还有叉乘,但是在本示例中用不到,点乘其实计算的是一个向量在另一个向量上的投影,它的计算方式为两个向量的 x 的积加上 y 的积,它返回的是一个标量,即第 1 个向量在第 2 个向量上投影的长度,例如: < 3 , 5 > ⋅ < 1 , 2 > = 3 × 1 + 5 × 2 = 13 ❤️, 5> \cdot <1, 2> = 3 \times 1 + 5 \times 2 = 13 ❤️,5>⋅<1,2>=3×1+5×2=13

标准化是除掉向量的长度,只剩下方向,这样的向量它的长度为 1,称为单位向量,标准化的过程是让 x 和 y 分别除以向量的长度,因为向量表示的是和原点(0, 0)的距离,所以可以直接使用 ( x 2 + y 2 ) \sqrt{(x^2 + y^2)} (x2+y2) ​ 计算长度,例如 < 3, 4 > 标准化后的结果为: < 3 , 5 > ⋅ < 1 , 2 > = 3 × 1 + 5 × 2 = 13 ❤️, 5> \cdot <1, 2> = 3 \times 1 + 5 \times 2 = 13 ❤️,5>⋅<1,2>=3×1+5×2=13。

了解了向量的基本运算后,我们来创建一个 Vector 工具类,来方便我们进行向量的运算,它的代码就是实现了这些运算规则:

class Vector {

constructor(x, y) {

this.x = x;

this.y = y;

}

/**

  • 向量加法

  • @param {Vector} v

*/

add(v) {

return new Vector(this.x + v.x, this.y + v.y);

}

/**

  • 向量减法

  • @param {Vector} v

*/

substract(v) {

return new Vector(this.x - v.x, this.y - v.y);

}

/**

  • 向量与标量乘法

  • @param {Vector} s

*/

multiply(s) {

return new Vector(this.x * s, this.y * s);

}

/**

  • 向量与向量点乘(投影)

  • @param {Vector} v

*/

dot(v) {

return this.x * v.x + this.y * v.y;

}

/**

  • 向量标准化(除去长度)

  • @param {number} distance

*/

normalize() {

let distance = Math.sqrt(this.x * this.x + this.y * this.y);

return new Vector(this.x / distance, this.y / distance);

}

}

代码中没有什么特殊的语法和操作,这里就不再赘述了,接下来我们看一下小球的碰撞问题。

碰撞处理


碰撞处理最主要的部分就是计算碰撞后的速度和方向。通常最简单的碰撞问题是在同一个水平面上的两个物体的碰撞,称为一维碰撞,因为此时只需要计算同一方向上的速度,而我们现在的程序小球是在一个二维平面内运动的,小球之间发生正面相碰(即在同一运动方向)的概率很小,大部分是斜碰(在不同运动方向上擦肩相碰),需要同时计算水平和垂直方向上的速度和方向,这就属于是二维碰撞问题。不过,其实小球之间的碰撞,只有在连心线(两个圆心的连线)上有作用力,而在碰撞接触的切线方向上没有作用力,那么我们只需要知道连心线方向的速度变化就可以了,这样就转换成了一维碰撞。

collision-diagram.png

计算碰撞后的速度时,遵守动量守恒定律和动能守恒定律,公式分别为:

动量守恒定律

m 1 v 1 + m 2 v 2 = m 1 v 1 ′ + m 2 v 2 ′ m_1v_1 + m_2v_2 = m_1v_1’ + m_2v_2’ m1​v1​+m2​v2​=m1​v1′​+m2​v2′​

动能守恒定律

1 2 m 1 v 1 2 + 1 2 m 2 v 2 2 = 1 2 m 1 v 1 ′ 2 + 1 2 m 2 v 2 ′ 2 \frac{1}{2}m_1v_12+\frac{1}{2}m_2v_22=\frac{1}{2}m_1v_1’2+\frac{1}{2}m_2v_2’2 21​m1​v12​+21​m2​v22​=21​m1​v1′2​+21​m2​v2′2​

m1、m2 分别为两小球的质量,v1 和 v2 为两小球碰撞前的速度向量,v1’ 和 v2’ 为碰撞后的速度向量。根据这两个公式可以推导出两小球碰撞后的速度公式:

v 1 ′ = v 1 ( m 1 − m 2 ) + 2 m 2 v 2 m 1 + m 2 v_1’=\frac{v_1(m_1-m_2)+2m_2v_2}{m_1+m_2} v1′​=m1​+m2​v1​(m1​−m2​)+2m2​v2​​

v 2 ′ = v 2 ( m 2 − m 1 ) + 2 m 1 v 1 m 1 + m 2 v_2’=\frac{v_2(m_2-m_1)+2m_1v_1}{m_1+m_2} v2′​=m1​+m2​v2​(m2​−m1​)+2m1​v1​​

如果不考虑小球的质量,或质量相同,其实就是两小球速度互换,即:

v 1 ′ = v 2 v_1’=v_2 v1′​=v2​

v 2 ′ = v 1 v_2’=v_1 v2′​=v1​

这里我们给小球加上质量,然后套用公式来计算小球碰撞后速度,先在 Circle 类中给小球加上质量 mass 属性:

class Circle {

constructor(context, x, y, r, vx, vy, mass = 1) {

// 其它代码

this.mass = mass;

}

}

然后在 Gameboard 类的初始化小球处,给每个小球添加质量:

this.circles = [

new Circle(ctx, 30, 50, 30, -100, 390, 30),

new Circle(ctx, 60, 180, 20, 180, -275, 20),

new Circle(ctx, 120, 100, 60, 120, 262, 100),

new Circle(ctx, 150, 180, 10, -130, 138, 10),

new Circle(ctx, 190, 210, 10, 138, -280, 10),

new Circle(ctx, 220, 240, 10, 142, 350, 10),

new Circle(ctx, 100, 260, 10, 135, -460, 10),

new Circle(ctx, 120, 285, 10, -165, 370, 10),

new Circle(ctx, 140, 290, 10, 125, 230, 10),

new Circle(ctx, 160, 380, 10, -175, -180, 10),

new Circle(ctx, 180, 310, 10, 115, 440, 10),

new Circle(ctx, 100, 310, 10, -195, -325, 10),

new Circle(ctx, 60, 150, 10, -138, 420, 10),

new Circle(ctx, 70, 430, 45, 135, -230, 45),

new Circle(ctx, 250, 290, 40, -140, 335, 40),

];

在 Circle 类中加上 changeVelocityAndDirection(other) 方法来计算碰撞后的速度,它接收另一个小球对象作为参数,同时计算这两个小球碰撞厚的速度和方向,这个是整个引擎的核心,我们一点一点的来看它是如何实现的。首先把两个小球的速度使用 Vector 向量来表示:

changeVelocityAndDirection(other) {

// 创建两小球的速度向量

let velocity1 = new Vector(this.vx, this.vy);

let velocity2 = new Vector(other.vx, other.vy);

}

因为我们本身就已经使用 vx 和 vy 来表示水平和垂直方向上的速度向量了,所以直接把它们传给 Vector 的构造函数就可以了。velocity1velocity2 分别代表当前小球和碰撞小球的速度向量。

接下来获取连心线方向的向量,也就是两个圆心坐标的差:

let vNorm = new Vector(this.x - other.x, this.y - other.y);

接下来获取连心线方向的单位向量和切线方向上的单位向量,这些单位向量代表的是连心线和切线的方向:

let unitVNorm = vNorm.normalize();

let unitVTan = new Vector(-unitVNorm.y, unitVNorm.x);

unitVNorm 是连心线方向单位向量,unitVTan 是切线方向单位向量,切线方向其实就是把连心线向量的 x、y 坐标互换,并把 y 坐标取反。根据这两个单位向量,使用点乘计算小球速度在这两个方向上的投影:

let v1n = velocity1.dot(unitVNorm);

let v1t = velocity1.dot(unitVTan);

let v2n = velocity2.dot(unitVNorm);

let v2t = velocity2.dot(unitVTan);

计算结果是一个标量,也就是没有方向的速度值。v1nv1t 表示当前小球在连心线和切线方向的速度值,v2nv2t 则表示的是碰撞小球 的速度值。在计算出两小球的速度值之后,我们就有了碰撞后的速度公式所需要的变量值了,直接用代码把公式套用进去:

let v1nAfter = (v1n * (this.mass - other.mass) + 2 * other.mass * v2n) / (this.mass + other.mass);

let v2nAfter = (v2n * (other.mass - this.mass) + 2 * this.mass * v1n) / (this.mass + other.mass);

v1nAfterv2nAfter 分别是两小球碰撞后的速度,现在可以先判断一下,如果 v1nAfter 小于 v2nAfter,那么第 1 个小球和第 2 个小球会越来越远,此时不用处理碰撞:

if (v1nAfter < v2nAfter) {

return;

}

然后再给碰撞后的速度加上方向,计算在连心线方向和切线方向上的速度,只需要让速度标量跟连心线单位向量和切线单位向量相乘:

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

  • (this.mass - other.mass) + 2 * other.mass * v2n) / (this.mass + other.mass);

let v2nAfter = (v2n * (other.mass - this.mass) + 2 * this.mass * v1n) / (this.mass + other.mass);

v1nAfterv2nAfter 分别是两小球碰撞后的速度,现在可以先判断一下,如果 v1nAfter 小于 v2nAfter,那么第 1 个小球和第 2 个小球会越来越远,此时不用处理碰撞:

if (v1nAfter < v2nAfter) {

return;

}

然后再给碰撞后的速度加上方向,计算在连心线方向和切线方向上的速度,只需要让速度标量跟连心线单位向量和切线单位向量相乘:

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-YeetL2pP-1714898165565)]

[外链图片转存中…(img-BEKgIVhb-1714898165566)]

[外链图片转存中…(img-7enQY5jO-1714898165566)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

  • 9
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值