概述
对于Android
开发者而言,ANR
是一个老生常谈的问题,站在面试者的角度,似乎说出 「不要在主线程做耗时操作」 就算合格了。
但是,ANR
机制到底是什么,其背后的原理究竟如何,为什么要设计出这样的机制?这些问题时时刻刻会萦绕脑海,而想搞清楚这些,就不得不提到Android
自身的 输入系统 (Input System
)。
Android
自身的 输入系统 又是什么?一言以蔽之,任何与Android
设备的交互——我们称之为 输入事件,都需要通过 输入系统 进行管理和分发;这其中最靠近上层,并且最典型的一个小环节就是View
的 事件分发 流程。
这样看来,输入系统 本身确实是一个非常庞大复杂的命题,并且,越靠近底层细节,越容易有一种 只见树木不见树林 之感,反复几次,直至迷失在细节代码的较真中,一次学习的努力尝试付诸东流。
因此,控制住原理分析的粒度,在宏观的角度,系统地了解输入系统本身的设计理念,并引申到实际开发中的ANR
现象的原理和解决思路 ,是一个非常不错的理论与实践相结合的学习方式,这也正是笔者写作本文的初衷。
本文篇幅较长,思维导图如下:
[图片上传中...(image-19b6c9-1609928187220-5)]
一、自顶向下探索
谈到Android
系统本身,首先,必须将 应用进程 和 系统进程 有一个清晰的认知,前者一般代表开发者依托Android
平台本身创造开发的应用;后者则代表 Android
系统自身创建的核心进程。
这里我们抛开 应用进程 ,先将视线转向 系统进程,因为 输入系统 本身是由后者初始化和管理调度的。
Android
系统在启动的时候,会初始化zygote
进程和由zygote
进程fork
出来的SystemServer
进程;作为 系统进程 之一,SystemServer
进程会提供一系列的系统服务,而接下来要讲到的InputManagerService
也正是由 SystemServer
提供的。
在SystemServer
的初始化过程中,InputManagerService
(下称IMS
)和WindowManagerService
(下称WMS
)被创建出来;其中WMS
本身的创建依赖IMS
对象的注入:
// SystemServer.java
private void startOtherServices() {
// ...
InputManagerService inputManager = new InputManagerService(context);
// inputManager作为WindowManagerService的构造参数
WindowManagerService wm = WindowManagerService.main(context,inputManager, ...);
}
复制代码
在 输入系统 中,WMS
非常重要,其负责管理IMS
、Window
与ActivityManager
之间的通信,这里点到为止,后文再进行补充,我们先来看IMS
。
顾名思义,IMS
服务的作用就是负责输入模块在Java
层级的初始化,并通过JNI
调用,在Native
层进行更下层输入子系统相关功能的创建和预处理。
在JNI
的调用过程中,IMS
创建了NativeInputManager
实例,NativeInputManager
则在初始化流程中又创建了EventHub
和InputManager
:
NativeInputManager::NativeInputManager(jobject contextObj, jobject serviceObj, const sp<Looper>& looper) : mLooper(looper), mInteractive(true) {
// ...
// 创建一个EventHub对象
sp<EventHub> eventHub = new EventHub();
// 创建一个InputManager对象
mInputManager = new InputManager(eventHub, this, this);
}
复制代码
此时我们已经处于Native
层级。读者需要注意,对于整个Native
层级而言,其向下负责与Linux
的设备节点中获取输入,向上则与靠近用户的Java
层级相通信,可以说是非常重要。而在该层级中,EventHub
和InputManager
又是最核心的两个角色。
这两个角色的职责又是什么呢?首先来说EventHub
,它是底层 输入子系统 中的核心类,负责从物理输入设备中不断读取事件(Event
),然后交给InputManager
,后者内部封装了InputReader
和InputDispatcher
,用来从EventHub
中读取事件和分发事件:
InputManager::InputManager(...) {
mDispatcher = new InputDispatcher(dispatcherPolicy);
mReader = new InputReader(eventHub, readerPolicy, mDispatcher);
initialize();
}
复制代码
简单来看,EventHub
建立了Linux
与输入设备之间的通信,InputManager
中的InputReader
和InputDispatcher
负责了输入事件的读取和分发,在 输入系统 中,两者的确非常重要。
这里借用网上的图对此进行一个简单的概括:
[图片上传中...(image-94d08c-1609928187218-4)]
二、EventHub 与 epoll 机制
对于EventHub
的具体实现,绝大多数App
开发者也许并不需要去花太多时间深入——简单了解其职责,然后一笔带过似乎是笔划算的买卖。
但是在EventHub
的实现细节中笔者发现,其对epoll
机制的利用是一个非常经典的学习案例,因此,花时间稍微深入了解也绝对是一举两得。
上文说到,EventHub
建立了Linux
与输入设备之间的通信,其实这种描述是不准确的,那么,EventHub
是为了解决什么问题而设计的呢,其具体又是如何实现的?
1、多输入设备与输入子系统
我们知道,Android
设备可以同时连接多个输入设备,比如 屏幕 、 键盘 、 鼠标 等等,用户在任意设备上的输入都会产生一个中断,经由Linux
内核的中断处理及设备驱动转换成一个Event
,最终交给用户空间的应用程序进行处理。
Linux
内核提供了一个便于将不同设备不同数据接口统一转换的抽象层,只要底层输入设备驱动程序按照这层抽象接口实现,应用就可以通过统一接口访问所有输入设备,这便是Linux
内核的 输入子系统。
那么 输入子系统 如何是针对接收到的Event
进行的处理呢?这就不得不提到EventHub
了,它是底层Event
处理的枢纽,其利用了epoll
机制,不断接收到输入事件Event
,然后将其向上层的InputReader
传递。
2、什么是epoll机制
这是常见于面试Handler
相关知识点时的一道进阶题,变种问法是:「既然Handler
中的Looper
中通过一个死循环不断轮询,为什么程序没有因为无限死循环导致崩溃或者ANR
?」
读者应该知道,Handler
简单的利用了epoll
机制,做到了消息队列的阻塞和唤醒。关于epoll
机制,这里有一篇非常经典的解释,不了解其设计理念的读者 有必要 了解一下:
参考上文,这里我们对epoll
机制进行一个简单的总结:
epoll
可以理解为event poll
,不同于忙轮询和无差别轮询,在 多个输入流 的情况下,epoll
只会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。
EventHub
中使用epoll
的恰到好处——多个物理输入设备对应了多个不同的输入流,通过epoll
机制,在EventHub
初始化时,分别创建mEpollFd
和mINotifyFd
;前者用于监听设备节点是否有设备文件的增删,后者用于监听是否有可读事件,创建管道,让InputReader
来读取事件:
[图片上传中...(image-f8e06a-1609928187217-3)]
三、事件的读取和分发
本章节将对InputReader
和InputDispatcher
进行系统性的介绍。
1、InputReader:读取事件
InputReader
是什么?简单理解InputReader
的作用,通过从EventHub
获取事件后,将事件进行对应的处理,然后将事件进行封装并添加到InputDispatcher
的队列中,最后唤醒InputDispatcher
进行下一步的事件分发。
乍得一看,在 输入系统 的Native
层中,InputReader
似乎平凡无奇,但越是看似朴实无华的事物,在整个流程中往往占据绝对重要的作用。
首先,EventHub
传过来的Event
除了普通的 输入事件 外,还包含了设备本身的增、删、扫描 等事件,这些额外的事件处理并没有直接交给InputDispatcher
去分发,而是在InputReader
中进行了处理。
当某个时间发生——可能是用户 按键输入,或者某个 设备插入,亦或 设备属性被调整 ,epoll_wait()
返回并将Event
存入。
这之后,InputReader
对输入事件进行了一次读取,因为不同设备对事件的处理逻辑又各自不同,因此InputReader
内部持有一系列的Mapper
对事件进行 匹配 ,如果不匹配则忽略事件,反之则将Event
封装成一个新的NotifyArgs
数据对象,准备存入队列中,即唤醒InputDispatcher
进行分发。
巧妙的是,在唤醒InputDispatcher
进行分发之前,InputReader
在自己的线程中先执行了一个很特殊的 拦截操作 环节。
2、输入事件的拦截和转换
读者知道,在应用开发中,一些特殊的输入事件是无法通过普通的方式进行拦截的;比如音量键,Power
键,电话键,以及一些特殊的组合键,这里我们通称为 系统按键。
这点无可厚非,虽然Android
系统对于开发者足够的开放,但是一切都是有限制的,绝大多数的 用户按键 通常可以被应用拦截处理,但是 系统按键 绝对不行——这种限制往往能够给予用户设备安全最后的保障。
因此,在InputReader
唤醒InputDispatcher
进行事件分发之前,InputReader
在自己的线程中进行了两轮拦截处理。
首先的第一轮拦截操作就是对 系统按键 级别的 输入事件 进行处理,对于手机而言,这个工作是在PhoneWindowManager
中完成;举例来说,当用户按了Power
(电源)键,Android
设备本身会切唤醒或睡眠——即亮屏和息屏。
这也正是「在技术论坛中,通常对 系统按键 拦截处理的技术方案,基本都是需要修改PhoneWindowManager
的源码」的原因。
接下来输入事件进入到第二轮的处理中,如果用户在Setting->Accessibility
中选择打开某些功能,以 手势识别 为例,Android
的AccessbilityManagerService
(辅助功能服务) 可能会根据需要转换成新的Event
,比如说两根手指头捏动的手势最终会变成ZoomEvent
。
需要注意的是,这里的拦截处理并不会真正将事件 消费 掉,而是通过特殊的方式将事件进行标记(policyFlags
),然后在InputDispatcher
中处理。
至此,InputReader
对 输入事件 完整的一轮处理到此结束,这之后,InputReader
又进入了新一轮等待。
3、InputDispatcher:分发事件
当wake()
函数将在Looper
中睡眠等待的InputDispatcher
唤醒时,InputDispatcher
开始新一轮事件的分发。
准确来说,
InputDispatcher
被唤醒时,wake()
函数实际是在InputManagerService
的线程中执行的,即整个流程的线程切换顺序为InputReaderThread
->InputManagerServiceThread
->InputDispatcherThread
。
InputDispatcher
的线程负责将接收到的 输入事件 分发给 目标应用窗口,在这个过程中,InputDispatcher
首先需要对上个环节中标记了需要拦截的 系统按键 相关事件进行拦截,被拦截的事件至此不再向下分发。
这之后,InputDispatcher
进入了本文最关键的一个环节——调用 findFocusedWindowTargetLocked()
获取当前的 焦点窗口 ,同时检测目标应用是否有ANR
发生。
如果检测到目标窗口处于正常状态,即ANR
并未发生时,InputDispatcher
进入真正的分发程序,将事件对象进行新一轮的封装,通过SocketPair
唤醒目标窗口所在进程的Looper
线程,即我们应用进程中的主线程,后者会读取相应的键值并进行处理。
表面来看,整个分发流程似乎干净简洁且便于理解,但实际上InputDispatcher
整个流程的逻辑十分复杂,试想一次事件分发要横跨3个线程的流程又怎会简单?
此外,InputDispatcher
还负责了 ANR 的处理,这又导致整个流程的复杂度又上升了一个层级,这个流程我们在后文的ANR
章节中进行更细致的分析,因此先按住不提。
接下来,我们来看看整个 输入事件 的分发流程中, 应用进程 是如何与 系统进程 建立相应的通信链接的。
4、通过Socket建立通信
关于 跨进程通信的建立 这一节,笔者最初打算作为一个大的章节来讲,但是对于整个 输入系统 而言,其似乎又只是一个 重要非必需 的知识点。最终,笔者将其放在一个小节中进行简单的描述,有兴趣的读者可以在文末的参考链接中查阅更详尽的资料。
我们知道,InputReader
和InputDispatcher
运行在system_server
系统进程 中,而用户操作的应用都运行在自己的 应用进程 中;这里就涉及到跨进程通信,那么 应用进程 是如何与 系统进程 建立通信的呢?
让我们回到文章最初WindowManagerService(WMS)
和InputManagerService(IMS)
初始化的流程中来,当IMS
以及其他的系统服务初始化完成之后,应用程序开始启动。
如果一个应用程序有Activity
(只有Activity
能够接受用户输入),那么它要将自己的Window
注册到WMS
中。
在这里,Android
使用了Socket
而不是Binder
来完成。WMS
中通过OpenInputChannelPair
生成了两个Socket
的FD
, 代表一个双向通道的两端:向一端写入数据,另外一端便可以读出;反之,如果一端没有写入数据,另外一端去读,则陷入阻塞等待。
最终InputDispatcher
中建立了目标应用的Connection
对象,代表与远端应用的窗口建立了链接;同样,应用进程中的ViewRootImpl
创建了WindowInputEventReceiver
用于接受InputDispatchor
传过来的事件:
[图片上传中...(image-23d1e4-1609928187213-2)]
这里我们对该次 跨进程通信建立流程 有了初步的认知,对于Android
系统而言,Binder
是最广泛的跨进程通信的应用方式,但是Android
系中跨进程通信就仅仅只用到了Binder
吗?答案是否定的,至少在 输入系统 中,除了Binder
之外,Socket
同样起到了举足轻重的作用。
那么新的问题就来了,这里为什么选择Socket
而不是选择Binder
呢,关于这个问题的解释,笔者找到了一个很好的版本:
Socket
可以实现异步的通知,且只需要两个线程参与(Pipe
两端各一个),假设系统有N
个应用程序,跟输入处理相关的线程数目是N+1
(1
是Input Dispatcher
线程)。然而,如果用Binder
实现的话,为了实现异步接收,每个应用程序需要两个线程,一个Binder
线程,一个后台处理线程(不能在Binder
线程里处理输入,因为这样太耗时,将会堵塞住发送端的调用线程)。在发送端,同样需要两个线程,一个发送线程,一个接收线程来接收应用的完成通知,所以,N
个应用程序需要2(N+1)
个线程。相比之下,Socket
还是高效多了。
现在,应用进程 能够收到由InputDispatcher
处理完成并分发过来的 输入事件 了。至此,我们来到了最熟悉的应用层级事件分发流程。对于这之后 应用层级的事件分发,可以阅读下述笔者的另外两篇文章,本文不赘述。
四、ANR机制的设计与实现
对 输入系统 有了更初步整体的认知之后,接下来本文将针对ANR
机制进行更深一步的探索。
通常来讲,ANR
的来源分为Service、Broadcast、Provider
以及Input
两种。
这样区分的原因是,首先,前者发生在 应用进程 组件中的ANR
问题通常是相对好解决的,若ANR
本身容易复现,开发者通常仅需要确定组件的代码中是否在 主线程中做了耗时处理;而后者ANR
发生的原因为 输入事件 分发超时,包括按键和屏幕的触摸事件,通过阅读上一章节,读者知道 输入系统 中负责处理ANR
问题的是处于 系统进程 中的InputDispatcher
,其整个流程相比前者而言逻辑更加复杂。
简单理解了之后,读者需要知道,「组件类ANR
发生原因通常是由于 主线程中做了耗时处理」这种说法实际上是笼统的,更准确的讲,其本质的原因是 组件任务调度超时,而在设备资源紧凑的情况下,ANR
的发生更多是综合性的原因。
而Input
类型的ANR
相对于Service、Broadcast、Provider
,其内部的机制又截然不同。
1、第一类原理概述
具体不同在哪里呢,对于Service、Broadcast、Provider
组件类的ANR
而言,Gityuan 在 这篇文章 中做了一个非常精妙的解释:
ANR
是一套监控Android
应用响应是否及时的机制,可以把发生ANR
比作是 引爆炸弹,那么整个流程包含三部分组成:
- 埋定时炸弹:中控系统(
system_server
进程)启动倒计时,在规定时间内如果目标(应用进程)没有干完所有的活,则中控系统会定向炸毁(杀进程)目标。- 拆炸弹:在规定的时间内干完工地的所有活,并及时向中控系统报告完成,请求解除定时炸弹,则幸免于难。
- 引爆炸弹:中控系统立即封装现场,抓取快照,搜集目标执行慢的罪证(
traces
),便于后续的案件侦破(调试分析),最后是炸毁目标。
将组件的ANR
机制比喻为 定时炸弹 非常贴切,以Service
为例,对于Android
系统而言,启动一个服务其本质是进程间的异步通信,那么,如何判断Service
是否启动成功,如果一直没有成功,那么如何处理?
因此Android
设计了一个 置之死地而后生 的机制,在尝试启动Service
时,让中控系统system_server
埋下一个 定时炸弹 ,当Service
完成启动,拆掉炸弹;否则在system_server
的ActivityManager
线程中引爆炸弹,这就是组件类ANR
机制的原理:
[图片上传中...(image-4e062d-1609928187212-1)]
接下来简单了解一下 输入系统 流程中ANR
机制的原理。
2、第二类原理概述
Input
类型的ANR
在日常开发中更为常见且更复杂,比如用户或者测试反馈,点击屏幕中的UI元素导致「卡死」。
少数情况下开发者能够很快定位到问题,但更常见的情况是,该问题是 随机 且 难以复现 的,导致该问题的原因也更具有综合性,比如低端设备的系统本身资源已非常紧张,或者多线程相互持有彼此需要的资源导致 死锁 ,亦或其它复杂的情况,因此处理这类型问题就需要开发者对 输入系统 中的ANR
机制有一定的了解。
与组件类ANR
不同的是,Input
类型的超时机制并非时间到了一定就会爆炸,而是处理后续上报事件的过程才会去检测是否该爆炸,所以更像是 扫雷 的过程。
什么叫做 扫雷 呢,对于 输入系统 而言,即使某次事件执行时间超过预期的时长,只要用户后续没有再生成输入事件,那么也不需要ANR
。
而只有当新一轮的输入事件到来,此时正在分发事件的窗口(即App
应用本身)迟迟无法释放资源给新的事件去分发,这时InputDispatcher
才会根据超时时间,动态的判断是否需要向对应的窗口提示ANR
信息。
这也正是用户在第一次点击屏幕,即使事件处理超时,也没有弹出ANR
窗口,而当用户下意识再次点击屏幕时,屏幕上才提示出了ANR
信息的原因。
由此可见,组件类ANR
和Input ANR
原理上确实有所不同;除此之外,前者是在ActivityManager
线程中处理的ANR
信息,后者则是在InputDispatcher
线程中处理的ANR
,这里通过一张图简单了解一下后者的整体流程:
[图片上传中...(image-cc4a5e-1609928187210-0)]
现在我们对Input
类型的ANR
机制有了一个简单的了解,下文将针对其更深入性的细节实现进行探讨。
3、事件分发的异步机制
我们再次将目光转回到InputDispatcher
的实现细节。
先抛出一个新的问题,对处于system_server
进程Native
层级的 事件分发 而言,其向下与 应用进程 的通信的过程应该是同步还是异步的?
对于读者而言,不难得出答案是异步的,因为两者之间双向通信的建立是通过SocketPair
,并且,因为system_server
中InputDispatcher
对事件的分发实际上是一对多的,如果是同步的,那么一旦其中一个应用分发超时,那么InputDispatcher
线程自然被卡住,其永远都不可能进入到下一轮的事件分发中,扫雷 机制更是无从谈起。
因此,与应用进程中事件分发不同的是,后者我们通常可以认为是在主线程中同步的,而对于整个 输入系统 而言,因为涉及到 系统进程 与多个 应用进程 之间异步的通信,因此其内部的实现更为复杂。
因为事件分发涉及到异步回调机制,因此InputDispatcher
需要对事件进行维护和管理,那么问题就变成了,使用什么样的数据结构去维护这些输入事件比较合适。
4、三个队列
InputDispatcher
的源码实现中,整体的事件分发流程共使用到3个事件队列:
- mInBoundQueue:用于记录
InputReader
发送过来的输入事件; - outBoundQueue:用于记录即将分发给目标应用窗口的输入事件;
- waitQueue:用于记录已分发给目标应用,且应用尚未处理完成的输入事件。
下文,笔者通过2轮事件分发的示例,对三个队列的作用进行简单的梳理。
4.1 第一轮事件分发
首先InputReader
线程通过EventHub
监听到底层的输入事件上报,并将其放入了mInBoundQueue
中,同时唤醒了InputDispatcher
线程。
然后InputDispatcher
开始了第一轮的事件分发,此时并没有正在处理的事件,因此InputDispatcher
从mInBoundQueue
队列头部取出事件,并重置ANR
的计时,并检查窗口是否就绪,此时窗口准备就绪,将该事件转移到了outBoundQueue
队列中,因为应用管道对端连接正常,因此事件从outBoundQueue
取出,然后放入了waitQueue
队列,因为Socket
双向通信已经建立,接下来就是 应用进程 接收到新的事件,然后对其进行分发。
如果 应用进程 事件分发正常,那么会通过Socket
向system_server
通知完成,则对应的事件最终会从waitQueue
队列中移除。
4.2 第二轮事件分发
如果第一轮事件分发尚未接收到回调通知,第二轮事件分发抵达又是如何处理的呢?
第二轮事件到达InputDispatcher
时,此时InputDispatcher
发现有事件正在处理,因此不会从mInBoundQueue
取出新的事件,而是直接检查窗口是否就绪,若未就绪,则进入ANR
检测状态。
以下几种情况会导致进入ANR
检测状态:
1、目标应用不会空,而目标窗口为空。说明应用程序在启动过程中出现了问题; 2、目标
Activity
的状态是Pause
,即不再是Focused
的应用; 3、目标窗口还在处理上一个事件。
读者需要理解,并非所有「目标窗口还在处理上一个事件」都会抛出ANR
,而是需要通过检测时间,如果未超时,那么直接中止本轮事件分发,反之,如果事件分发超时,那么才会确定ANR
的发生。
这也正是将Input
类型的ANR
描述为 扫雷 的原因:这里的扫雷是指当前输入系统中正在处理着某个耗时事件的前提下,后续的每一次input
事件都会检测前一个正在处理的事件是否超时(进入扫雷状态),检测当前的时间距离上次输入事件分发时间点是否超时。如果前一个输入事件,则会重置ANR
的timeout
,从而不会爆炸。
至此,输入系统 检测到了ANR
的发生,并向上层抛出了本次ANR
的相关信息。
小结
本文旨在对Android
输入系统 进行一个系统性的概述,读者不应将本文作为唯一的学习资料,而应该通过本文对该知识体系进行初步的了解,并根据自身要求进行单个方向细节性的突破。而已经掌握了骨骼架构的读者而言,更细节性的知识点也不过是待丰富的血肉而已。
本文从立题至发布,整个流程耗时近1个半月,在这个过程中,笔者参考了较本文内容数十倍的资料,受益颇深,也深感以 举重若轻 为写文目标之艰难——内容铺展容易,但通过 简洁 且 连贯 的语言来对一个庞大复杂的知识体系进行收拢,需要极强的 克制力 ,在这种严苛的要求下,每一句的描述都需要极高的 精确性 ,这对笔者而言是一个挑战,但真正完成之后,对整个知识体系的理解程度同样也是极高的。
而这也正是 反思 系列的初衷,希望你能喜欢。
参考 & 扩展阅读
正如上文所言,输入系统 和 ANR 本身都是一个非常大的命题,除了宽广的知识体系,还需要亲身去实践和总结,下文列出若干相关参考资料,读者可根据自身需求选择性进行扩展阅读:
1、彻底理解安卓应用无响应机制 @Gityuan
2、Input系统—ANR原理分析 @Gityuan
3、理解Android ANR的触发原理 @Gityuan
深入学习ANR
机制资料,Gityuan
的ANR
博客系列绝对是先驱级别的,尤其是第1篇文章中,其对于 定时炸弹 和 扫雷 的形容,贴切且易理解,这种 举重若轻 的写作风格体现了作者本身对整个知识体系的深度掌握;而后两篇文章则针对两种类型的ANR
分别进行了源码级别的分析,非常下饭。
4、图解Android-Android的 Event Input System @漫天尘沙
笔者曾经想写一个 图解Android 系列,后来因为种种原因放弃了,没想到若干年前已经有先驱进行过了这样的尝试,并且,内容质量极高。笔者相信,能够花费非常大精力总结的文章一定不会被埋没,而这篇文章,注定会成为经典中的经典。
一个笔者最近关注非常优秀的作者,文章非常具有深度,其Input
系列针对整个输入系统进行了更细致源码级别的分析,非常值得收藏。
6、Android 信号处理面面观 之 信号定义、行为和来源 @rambo2188
如果读者对「Android
系统信号处理的行为」感兴趣,那么这篇文章绝对不能错过。
7、Android开发高手课 @张绍文
实战中的经典之作,该课程每一小结都极具深度,价值不可估量。因或涉及到利益相关,而且推荐了也从张老师那里拿不到钱,因此本文不加链接并放在最下面(笑)。
最后
本文在开源项目:https://github.com/xieyuliang/Android-P7-share/blob/master/(点击此处蓝色字体可以查看)
中已收录,里面包含不同方向的自学编程路线、面试题集合/面经、及系列技术文章等,资源持续更新中...
作者:却把清梅嗅
原文地址:https://juejin.cn/post/6864555867023343623
网友评论