2024年鸿蒙最全学不会Handler?那是因为你还没有看过这篇文章(1),2024鸿蒙面试题

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

(3)setAsynchronous方法,设置Message是否异步

总之,关于Message,可以这样描述

Message是一个消息类,可以被Handler放到消息队列,也可以被取出,交给Handler来处理。它的what、when、data、target、setAsynchronous等一些属性和方法比较常用,同时它还实现了缓存复用,可以通过obtain来复用Message

2. Handler

老样子,还是看源码的定义

这个源码其实告诉了我非常多的东西。建议仔细研读一遍。我试图做一个归纳

(1)Handler唯一绑定一个线程,同时也绑定其MessageQueue、Looper,属于多对一的关系,即一个线程可以对应多个Handler

(2)主要有两个作用,一是调度Message/Runnable,二是进行线程间通信

针对前者,列举了一些调度Message/Runnable的具体方法,还提了一下延时任务

针对后者,说到主线程会专门维护一个MessageQueue,子线程可以通过Handler和主线程通信

总之,关于Handler,可以这样描述

Handler有两个作用,一是调度Message/Runnable(包括分发和处理),二是进行线程间通信。它和线程、MessageQueue、Looper是多对一的关系

3. MessageQueue

从源码中,我们可以了解到

(1)它持有一个会被Looper分发的Message的列表

(2)Message由Handler添加,而非MessageQueue直接添加

(3)可以通过Looper.myQueue()来获取当前线程的MessageQueue

总之,关于MessageQueue,可以这样描述

MessgaeQueue是一个单链表构成的优先级队列,元素为Message,根据Message的when属性来确定优先级。由Handler来添加Message,由Looper来取出Message

4. Looper

源码依然告诉了我非常非常多的信息,让我试图归纳一下

(1)此类是用来开启,一个线程中,Message的循环的

(2)线程默认是没有消息循环的,需要手动创建,可以通过调用prepareloop,来开启循环

(3)与Looper交互最多的是Handler

(4)给了一个典型的实现了消息循环的线程案例

总之,关于Looper,可以这样描述

可以理解为一个消息循环器,从MessageQueue中取出消息,交给Handler执行。创建方式是prepare,开启循环的方式是调用loop方法

5. ThreadLocal

这里的源码,略显难懂,但我也试图归纳一下

(1)ThreadLocal是与线程相关的,可以提供线程局部变量

(2)举了一个例子,说明ThreadLocal是如何提供线程局部变量的

只看这些,基本很难懂。因地制宜,对于理解ThreadLocal,我们或许可以不看源码的定义,转而看他的使用。也就是它的get方法,和set方法。

我们只看get,因为看懂了get方法,也就能看懂set方法了

ThreadLocal的get方法

get方法就很清晰,比如我们调用ThreadLocal.get()方法,它会这样执行:

首先拿到调用这个方法的线程,然后从这个线程中取出ThreadLocalMap对象,然后将ThreadLocal作为key,找到它对应的value,从而作为返回值返回。

那么这里,问题来了,ThreadLocalMap是什么呢?先不看源码,只看名字,大体能猜到,这是一个Map集合,那么既然是Map集合,key和value又分别是什么呢?我们进去看一下

可以看到,key就是ThreadLocal本身!

所以,我总结一下。

在每次调用ThreadLocal的get方法时,会首先拿到当前执行的线程,这个线程里面有一个变量,是ThreadLocalMap类型的,key是ThreadLocal,value就是我们想要得到的值

画图来看,就是这样

剖析ThreadLocal原理

又有一个问题,同一个ThreadLocal,在不同的线程,调用get方法,得到的value值是不一样的吗?

答案,是的。因为这就是ThreadLocal机制的作用呀,它的作用就是保存线程本地值,在不同的线程,需要映射为不同的值。它的原理,就是因为不同的线程,有不同的ThreadLocalMap,即虽然key一样,但是Map不一样,所以value可以不同

类比我们生活中的例子,就像是我们同一个人(同一个ThreadLocal),在不同的环境(不同的Thread),承担着不同的角色(映射出不同的value)。比如我们在家这个环境中,承担着孩子的角色,为人父母的角色,在职场这个环境中,我们就承担着职工,同事的角色。真可谓是“艺术来源于生活,高于生活”呀

所以,我说,不同的线程之间Looper可以不同,是通过ThreadLocal来保证的,这句话,相信你也能很快理解了。就是因为不同的线程,ThreadLocalMap不同而已。

ThreadLocal总结

综上,关于ThreadLocal,可以这样描述

能够映射线程本地变量,映射的原理,就是不同的线程,ThreadLocalMap不同

总结

ok,以上就是针对Handler机制几大角色的非常详细又通俗易懂的解释。这些都只是一个开胃菜,更详细,更有含金量的,还在后面。

接下来,让我们来介绍一下,关于Handler机制的运行原理。打起精神来!各位!!!

三. Handler消息机制的运行原理

在此部分,我会先捋源码,然后给出一个非常详细的图加深理解,以此来讲清楚Handler消息机制的运行原理

一图以蔽之

这张图,可以说非常详细、生动地展现了Handler机制整体的运行原理。再结合上文,对各个角色的解释,相信你很容易就能看懂了。那么,对自己要求高的人,肯定还是不满于此,是不是还想扒开源码看看?OK,我来满足你的需求。

Handler消息机制源码

看源码,有个问题,即我们看的线索,怎么找,我们从哪一截开始探入,会比较好理解,不会迷乱在纷乱的源码世界中?这个,不同的人有不同的理解,我认为,可以从Handler放消息,即我们调用sendXXX、postXXX方法开始看起,从Looper交给Handler进行消息处理作为结束,正好按照时间顺序,同时也形成闭环。

所以,先从这里看起

Handler怎么放的消息

也就是图中红色区域

在前文,Handler源码的定义中,有介绍到Handler发送消息的几种方式,我再贴一下

一般来说呢,我们使用post、sendMessage这两种方法会多一些,看方法名我们也可以猜出来,其他几种方法,基本都是这两种方式的变形体,所以呢,我们只关注post方法,和sendMessage方法。

sendMessage方法的执行过程

由简到繁,我们先看sendMessage方法的执行过程

这里调用了sendMessageDelayed()方法,将msg传进去了,然后有一个delayMills为0,这个delayMills的意思就是延时任务的延时时间,前面也介绍了,Handler有一个功能是可以执行延时任务。那么延时的时间,就是sendMessageDelayeddelayMills参数。

那进去sendMessageDelayed方法

这个方法又调用了一个sendMessageAtTime

传入了一个uptimeMills参数,这个参数,是之前的delayMills,加上启动后的时间。所以前面的delayMills是一个相对时间,然后再加上SystemClock.uptimeMillis(),组成了一个绝对时间uptimeMills。(其实这在后面会被赋值为Message的when属性)

然后调用了Handler的enqueueMessage方法

最后,调用到了MessageQueue的enqueueMessage方法。自此,就把Message放入消息队列了。

一图以蔽之

让我来总结一下,调用sendMessage,需要传入一个Message对象,然后通过层层传递,最终调用到了MessageQueue的enqueueMessage方法,将Message加入消息队列。

post方法的执行过程

看完sendMessage,我们再看post方法。

一般来说呢,我们是这样调用post方法的

我们进去post方法,看一看

what???它居然又调用了sendMessageDelayed方法!我们需要看下这个getPostMessage做了啥

哦~,原来它把Runnable封装为了Message,并且将自身作为了Message的callback属性!

后续流程都是一样的了,就不继续跟了。

一图以蔽之

好,以上我们就把Handler将Message传递给MessageQueue的过程,捋清楚了。

MessageQueue怎么调度的消息

接下来,我们看MessageQueue是怎么把Message加进去的,怎么把Message取出来的,即图中的这一部分

MessageQueue的放Message的方法,是enqueueMessage,看名字就知道,它的意思是将Message放入队列,里面一定包含了一些策略。MessageQueue的取出Message方法,是next。下面,我们还是按照时间顺序,先enqueueMessage方法,然后再看next方法。

enqueueMessage方法的执行过程

enqueueMessage方法,有点大,截图截不开,我就直接贴代码了。别担心,我会在关键地方,留下注释

boolean enqueueMessage(Message msg, long when) {
if (msg.target == null) {
// 不允许发送target为null的消息,也就是说这个方法放不了同步屏障消息
throw new IllegalArgumentException(“Message must have a target.”);
}

synchronized (this) {
if (msg.isInUse()) {
throw new IllegalStateException(msg + " This message is already in use.");
}

if (mQuitting) {
IllegalStateException e = new IllegalStateException(
msg.target + " sending message to a Handler on a dead thread");
Log.w(TAG, e.getMessage(), e);
msg.recycle();
return false;
}

msg.markInUse();
msg.when = when; // 这里将when赋值给了message的when属性
Message p = mMessages;
boolean needWake;
if (p == null || when == 0 || when < p.when) {
// 把传入的message,作为新的头节点
msg.next = p;
mMessages = msg;
needWake = mBlocked; // 如果之前是阻塞状态,则唤醒
} else {
// 下面的英文注释说的非常详细,我可以再试图总结一下
// 只有队列的头节点为同步屏障消息,并且当前message,是最早加进来的异步消息时,才有可能需要唤醒
// Inserted within the middle of the queue. Usually we don’t have to wake
// up the event queue unless there is a barrier at the head of the queue
// and the message is the earliest asynchronous message in the queue.
needWake = mBlocked && p.target == null && msg.isAsynchronous();
Message prev;
for (;😉 {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
// 把传进来的message插入了队列中
msg.next = p;
prev.next = msg;
}

if (needWake) {
nativeWake(mPtr);
}
}
return true;
}

建议你仔细看几遍以上代码,然后,看下面的流程图

一图以蔽之

此方法执行完后,如果一切正常的话,就可以将Message入队了。

next方法的执行过程

下面,我们来看,从MessageQueue中取出Message的部分,也就是next方法的执行过程

一样的,我还是直接贴代码,不截图了,因为一屏截不下

Message next() {
// Return here if the message loop has already quit and been disposed.
// This can happen if the application tries to restart a looper after quit
// which is not supported.
// 如果应用程序试图在退出后重新启动looper(这是不支持的),就会发生这种情况。
final long ptr = mPtr; // mPtr是native代码使用的
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0; // 阻塞时间
for (;😉 { // 注意,这里是一个死循环
if (nextPollTimeoutMillis != 0) {
// 将当前线程中挂起的Binder命令刷新到内核驱动程序。
// 在执行可能会阻塞很长时间的操作之前调用此方法非常有用,
// 以确保所有挂起的对象引用已被释放,以防止进程持有对象的时间超过所需时间。
// 总之,这个方法在执行阻塞任务之前调用会好,又因为下面有可能会阻塞,所以在这里调用了这个方法。
Binder.flushPendingCommands();
}

// 阻塞,阻塞时长为nextPollTimeoutMillis
nativePollOnce(ptr, nextPollTimeoutMillis);

synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
// 发现了一个同步屏障消息,然后遍历,在队列中找到第一个异步消息
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
// 此时,msg要么为null,要么为队首消息,要么为第一个异步消息
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// -1表示一直阻塞
nextPollTimeoutMillis = -1;
}

// 当处理完所有该处理的message时,才去处理要不要quit
if (mQuitting) {
dispose();
return null;
}

// idle handles 只能在MessageQueue为空,或者队首的Message的when不符合要求时,才会执行
// 下面的英文其实说的更详细
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
// 如果第一次循环,处理了mIdleHandlers里面的任务,那么第二次循环时
// pendingIdleHandlerCount为0,所以直接continue,而不会重新处理mIdleHandlers
// 注意:这里的循环指的是某一次next方法执行时,里面for死循环中的一次循环。
// 而又因为next方法会执行多次,所以第二次next方法执行时,又会重新启动一次for死循环,
// 那么这时,还是会判断是否要处理mIdleHandlers里面的任务。
if (pendingIdleHandlerCount <= 0) {
// No idle Handlers to run. Loop and wait some more.
mBlocked = true;
continue; // 注意一下这个continue。
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// 运行IdleHandler里面的代码,只有第一次迭代的时候会执行到这里
// Run the idle Handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the Handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, “IdleHandler threw exception”, t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

// 注意这里,不管上面的mIdleHandlers是否还有元素,都重置。因为queueIdle都已经执行过一次了
// Reset the idle Handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;

// 设置阻塞时长为0,进入下一次循环
// While calling an idle Handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}

一图以蔽之

出现了!!!你绝没见过的流程图!!! 这个就稍显复杂了,不过多看几遍代码,和我画的流程图做对照,相信你很快就能理解。

需要注意的点,是IdleHandler。在这里,可以看到,它执行的时机,是取不出合适的Message,在阻塞线程之前,执行。同时,执行完之后,会刷新阻塞时长。

所以在这里,它也有坑,

一是有可能永远都不会执行,因为有可能一直都能取出合适的Message。

二是有可能delay正常的Message。比如队首的Message,需要等待1ms就可以执行,然后在阻塞1ms之前,发现有idleHandler可以执行,那么就去执行idleHandler,如果执行idleHandler的耗时为10ms,那么就delay了队首的Message的执行时机,delay了9ms。

尽管它有坑,但还是会在性能优化的场景中用到,用于在线程空闲时执行一些优化任务。

好,以上,关于MessageQueue的拿到Message,和交付Message,就全部介绍完了。

Looper怎么调度的消息

下面介绍Looper的拿到Message,和交付Message的过程,也就是这一部分

如何拿到消息

拿到Message,是在开启消息循环,即Looper.loop中实现的

可以看到这个方法的注释,谷歌官方特意提示,一定要调用!所以我们在开启子线程的消息循环的时候,一定要调用Looper.loop方法。里面有一个核心逻辑,是死循环,每一次循环,会调用loopOnce方法,让我们进入到这个loopOnce方法中,看一看

private static boolean loopOnce(final Looper me,
final long ident, final int thresholdOverride) {
Message msg = me.mQueue.next(); // 这里有可能阻塞
if (msg == null) {
// msg为null,只有两种情况,即MessageQueue正在退出或者已经退出
return false;
}

msg.target.dispatchMessage(msg); // 把message交付给Handler

msg.recycleUnchecked(); // 回收message,缓存起来,以便下次复用

return true;
}

在每次loopOnce中,一切正常的话,就可以取出一个消息。

如何交付给Handler的

message交付给Handler是怎么交付的呢?Message的target属性就是Handler,所以在这里就是调用了Handler的dispatchMessage方法,进去看下

这里就非常明朗了,在这里,进行最终关于message的处理,然后形成了闭环。

一图以蔽之(Looper的loop)

细看Handler对Message的处理

其实,这里值得再强调下,我们看到,这里有三种方式来处理message,分别是

我们一个一个地看。

处理方式一:handleCallback方法

第一个,handleCallback,是Message的callback属性不为null时,调用。在前面的内容中有过介绍,Message的callback属性,就是调用Handler.post()时,传入的Runnable。可以看下图

所以,如果此Message,是通过Handler.post方式传进来的,那么就直接执行Runnable里面的run方法。

这是第一种处理message的方式。

处理方式二:mCallback的handleMessage方法

第二种,是看Handler的mCallback属性是否为null,如果不为null,则调用mCallbackhandleMessage方法。这里的mCallback和前面说的Message的callback属性不同。这里的mCallback是创建Handler的时候,通过构造方法得到的,

这个Callback和Runnable没有关系,而是一个自定义的接口,里面有一个方法,是handleMessage

所以第二种处理方式,是调用mCallbackhandleMessage方法。如果返回了true,则处理结束,如果返回了false,那么就进入到第三种处理方式,即handleMessage

处理方式三:子类实现handleMessage方法

这里默认实现为空,所以此方式,一般是调用的子Handler的handleMessage方法,来处理的Message。

一图以蔽之

为什么分了三种处理方式?

我个人认为,有两个原因。

(1)保证消息最终能够进行处理

Handler可以通过多种方式进行创建:比如可以直接new,或者new一个子类,同时呢,Handler的构造函数还有好几种。每一种创建方式,对应的消息处理方式,不一定是相同的。所以,分了三种,原因之一,是要把所有的消息处理方式全部覆盖到,尽可能防止有消息处理不到的情况。

(2)有些场景,可能需要对message多次处理,有第二种和第三种处理方式,可以满足此需求。

在代码上,关于第二种和第三种处理方式,分别是这样实现的

// 第二种处理方式,赋值给Handler的mCallback属性
val Handler1 = Handler(object : Handler.Callback {
override fun handleMessage(msg: Message): Boolean {
return true/false
}
})

// 第三种处理方式,派生子类,重写handleMessage方法
val Handler2 = object : Handler(){
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
}
}

这两种处理方式有一种关系,就是如果第二种处理方式返回了false,那么第三种处理方式还要执行。即有可能两种处理都会执行,这在某些降级处理场景,可能会被用到。

但其实呢,谷歌提示我们尽量避免创建子类,进而避免调用子类的消息处理方法,而优先用前两种处理Message的方式。原因来自这个注释:

在Callback的类注释中,有“尽量避免不得不实现Handler的子类”这样的字眼,所以得出了上面那个结论。

所以,为什么分了三种处理方式,总结下,就是两个原因

  • 保证消息最终能够进行处理
  • 有些场景,可能需要对Message多次处理,有第二种和第三种处理方式,可以满足此需求。

以上,就把Handler机制的全貌,都捋了一遍了。

三. 加餐:Handler那些事儿

1. 为什么子线程不能访问UI,非要整一个Handler?

因为在Android中,关于UI操作,是没有加锁的,所以不是线程安全的,所以只能是单线程访问才可以。因此Android只能单线程修改UI,而且这个单线程只能是主线程。

那么,为什么不加锁呢?

我能想到的一个很重要的原因,就是流畅性,UI修改一定要是瞬时就要变化的,如果有多线程互相抢着锁,那UI的访问及修改效率都会低很多,手机就会表现的很卡。

而且像UI这种,非常基础的能力,没必要做的这么复杂,如果访问一个UI控件,修改一个UI控件都要加锁的话,那就太复杂了,完全没必要。

总结

Android只能允许单线程修改UI,然后设计了Handler机制来实现子线程修改UI的需求。

2. MessageQueue的阻塞唤醒机制是如何实现的?

其实就是:epoll、pipe机制

什么是pipe?

在类Unix-like操作系统中,一切皆文件,包括管道pipe。管道可以像文件一样在文件系统中存在,并且可以使用文件描述符来引用它们。

管道的作用之一,就是可以实现,线程间的通信。一个线程与另外一个线程之间发生的读、写操作,都可以通过管道,来实现。

什么是epoll?

epoll 是 Linux 中的高效的 I/O 多路复用机制,它会监听一个或多个文件描述符的多个事件类型,其中之一是文件描述符的写入事件。

epoll如何实现阻塞、唤醒?

epoll能够实现阻塞、唤醒的原理,就是能够监听管道这个文件描述符的读操作和写操作,实现阻塞和唤醒。

比如,一个MessageQueue中,没有了消息,那么就应该被阻塞了,那么怎么保持一直阻塞的?什么时候会被唤醒?答案就是:epoll可以监听,pipe的文件描述符的写操作。

当有新的消息写入时,epoll就可以监听到,从而通知线程,实现唤醒。没有写入时,就能保持一直阻塞。

因此,可以这么理解:在没有数据可读时,MessageQueue主要使用了 native 层的 epoll 机制来监听文件描述符(通常是pipe)的写入事件,以实现持续的阻塞和唤醒

一个更好理解的例子

举个生活中的例子,当有人给我打过来电话时,我的手机会通过亮屏,响手机铃声或者震动等方式来通知我。

OK,这里面,包含了两个方面。一是可以打电话,有电话线这条“管道”。二是可以通知我,让我知道,有人打电话过来了。

在这里,电话线就像是pipe,通知就像是epoll。在没有电话打来时,且我当前没有正在接听电话时,属于“读阻塞”的状态,此时如果有写入操作,则会进行唤醒。这里的写入,就是有新的人给我打来了电话,然后epoll通过通知我,唤醒了我,然后我就可以接听电话,即对写入的这条消息进行处理。同时呢,手机不光是在收到电话的时候可以通知,在比如收到微信视频通话的通知时,来了一条短信时,都可以发起通知,这对应epoll的可以监听多个文件描述符的事件的特点。

在MessageQueue中,哪些地方进行了阻塞

next方法中,总共有两个地方,对mBlocked进行了赋值。

  • 一是消息正常取出时,将mBlocked赋值为false,表示不阻塞。
  • 二是当没有可取出的消息,且也没有可以处理的IdleHandler时,赋值为true,表示阻塞。

3. 同步屏障消息和异步消息?

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

像是epoll。在没有电话打来时,且我当前没有正在接听电话时,属于“读阻塞”的状态,此时如果有写入操作,则会进行唤醒。这里的写入,就是有新的人给我打来了电话,然后epoll通过通知我,唤醒了我,然后我就可以接听电话,即对写入的这条消息进行处理。同时呢,手机不光是在收到电话的时候可以通知,在比如收到微信视频通话的通知时,来了一条短信时,都可以发起通知,这对应epoll的可以监听多个文件描述符的事件的特点。

在MessageQueue中,哪些地方进行了阻塞

next方法中,总共有两个地方,对mBlocked进行了赋值。

  • 一是消息正常取出时,将mBlocked赋值为false,表示不阻塞。
  • 二是当没有可取出的消息,且也没有可以处理的IdleHandler时,赋值为true,表示阻塞。

3. 同步屏障消息和异步消息?

[外链图片转存中…(img-nwS8MWgj-1715731443008)]
[外链图片转存中…(img-JFzlg42a-1715731443009)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值