0x1、引言
上节我们学习了AccessibilityService无障碍的基础知识,并写了一个简单的微信自动登录的小案例。相信大家都意犹未尽,所以本节安排一波实战 —— 微信僵尸好友检测。
啥是 僵尸好友?
在微信里,对方把你删除/拉黑了,并不会从你的好友列表消失,只有你给他/她发消息,看到红色感叹号才知道。而他/她如果把 加好友验证选项关闭,你发消息不会有红色感叹号,而对方却能看到你发的消息:
他/她顺手点这个把你加回来,你这边是不会有任何提醒的,所以被删这件事你可能永远都不知道~
对于我这种有强迫症的人来说,既然对方删了我,那我也要删了他/她。直接粗暴地给每个好友 群发消息看会不会出现红色感叹号 的方案显然不太行,浪费自己时间不说,还打扰了别人,万一发给了一些不得不加,但平时无天可聊的人,就尴尬了。
随手搜了一下,看到一个 拉群 的方案:拉群时被删的好友会提示不是好友关系:
但每次只能检测50人 (好像是这个值),超过50需要对方同意才可以入群,就是会 收到邀请通知。而少于50的话,只要不往群里发消息,被拉的人是不知道群存在的。当然,要是被别人知道了的话,就是社死了~
又随手搜了一下:自动清理微信僵尸好友
九块九解君愁?也有些免费帮你清理的公号 (哪有那么多天上掉馅饼的好事),看了下演示视频,需要扫码登录,猜测用的是PC端协议。登录后,他们可以用你的账号随便发消息,这就存在 风险 了,万一他们:以生病、出车祸、急用钱等各种理由向你的朋友 群发诈骗信息,又或者 群发色情信息 给你造成不良影响,导致封号呢?
所以,但凡涉及到 要你登录 的,都请不要尝试,以免造成不必要的损失。那,有没有 免费安全又好用 的工具呢?还真有,网上很多文章都提到了它李跳跳的 真实好友
,界面长这样:
用法简单:
打开无障碍权限,点击开始检测,晾一边等它自动检测完,最后会输出正常/异常好友到列表。点击可以复制微信号,打开微信自行搜索,按需删除关系异常的好友即可。
笔者简单体验了一下,很赞,虽然没开源,但APP没申请任何权限(不联网),所以你不需要担心隐私泄露啥的。如果懒得折腾,完全可以放心使用,当然,建议到官方公号「大小姐李跳跳」下载。毕竟破解APP后加点广告、引流信息等恶意内容很常见,比如我就见过跳过广告的APP反而被加入了开屏广告,23333~
em… 好像扯得有点远了,本文的目的不是教会大家使用这款软件,而是 借(chao)鉴(xi) 它, 利用上节所学的AccessibilityService基础,自己实现一个检测微信僵尸好友的工具!本节某些工具代码get√了,也可以为你开发其它无障碍服务脚本提供一些助力哦~ 话不多说,赶紧开始!!!
0x2、如何判断被删除/拉黑?—— 假转账法
上面说了 群发消息 和 拉群 验证好友关系都不太靠谱,所以这里采用真实好友用的—— 假转账法,无感,不打扰对方,也不会产生真实的转账行为。它的判定流程如下:
- 进入好友转账页,呢称后面出现真实姓名,说明是 正常好友关系;
- 呢称后没有真实姓名,进行 “假转账” 进一步确认关系,可能会出现四种情况:
- ① 提示:你不是收款方好友,对方添加你为好友后才能发起转账 → 说明被删除了;
- ② 提示:请确认你和他(她)的好友关系是否正常 → 说明被拉黑了;
- ③ 提示:对方微信号已被限制登录,为保障你的资金安全,暂时无法完成交易 → 对方账号异常;
- ④ 弹出:输入支付密码 界面,说明是正常好友关系
核心难点解决了,记者就是编写脚本来实现自动化了~
0x3、实战环节
① 界面设计
设置页 直接复用上节的熊猫头,添加一个 去清理的Button,点击跳转到 清理僵尸好友页,基本UI样式如下:
一个重新检测的Button + 一个显示结果的RecyclerView,非常简洁(lou)~
② 跳转微信
跳转外部APP的方式有两种:Intent指定启动APP包名和Activity名
或 URL Scheme请求
,直接给出工具代码:
/**
* 跳转其它APP
* @param packageName 跳转APP包名
* @param activityName 跳转APP的Activity名
* @param errorTips 跳转页面不存在时的提示
* */
fun Context.startApp(packageName: String, activityName: String, errorTips: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW).apply {
component = ComponentName(packageName, activityName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
})
} catch (e: ActivityNotFoundException) {
shortToast(errorTips)
} catch (e: Exception) {
e.message?.let { logD(it) }
}
}
/**
* 跳转其它APP
* @param urlScheme URL Scheme请求字符串
* @param errorTips 跳转页面不存在时的提示
* */
fun Context.startApp(urlScheme: String, errorTips: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(urlScheme)))
} catch (e: ActivityNotFoundException) {
shortToast(errorTips)
} catch (e: Exception) {
e.message?.let { logD(it) }
}
}
读者可能对这里的捕获 ActivityNotFoundException 感到奇怪,为啥不通过 getPackageManager().getInstalledPackages(0)
读已安装应用列表,然后再遍历判断?
答:因为这样不仅需要权限,还涉及到了隐私,为了简化处理,直接捕获这个异常,然后给出未安装的提示。因为如果设备安装了,一般只要你不写错包名啥的,是不会触发这个异常的!调用示例如下:
startApp("com.tencent.mm", "com.tencent.mm.ui.LauncherUI", "未安装微信")
startApp("weixin://", "未安装微信")
另外,URL Scheme对于一些内嵌浏览器页面的APP跳转有奇效,比如之前某东双11活动页的跳转的scheme如下:
"openApp.jdMobile://virtual?params={"category":"jump","action":"to","des":"m","sourceValue":"JSHOP_SOURCE_VALUE","sourceType":"JSHOP_SOURCE_TYPE","url":"https://u.jd.com/kIrrQ3H","M_sourceFrom":"mxz","msf_type":"auto"}'})"
执行后会跳转到下述页面:(活动已过期,正常情况下你是进不了这个页面的~)
③ 搞清Event的触发链条
按照上节所说,可以先把无障碍配置文件里的 android:accessibilityEventTypes
设置为 typeAllMask
,监听所有类型的Event。在 onAccessibilityEvent()
里把日志打印出来,然后筛选自己关注的Event类型,最后再把 android:accessibilityFeedbackType
设置为这些类型。
点击重新检测,跳转微信,输出日志如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0DsrafjB-1670470679475)(https://upload-images.jianshu.io/upload_images/27208505-e6c697b0fe9f3f5e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.ui.LauncherUI
- 说明:进入微信首页
此时点击底部的 通讯录,输出日志如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ol36sBKq-1670470679476)(https://upload-images.jianshu.io/upload_images/27208505-140d259469828fb3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
当:EventType → TYPE_VIEW_CLICKED,Text → 通讯录 时说明点击了通讯录。接着随意 点击一个联系人,输出日志如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ElCkRuNY-1670470679477)(https://upload-images.jianshu.io/upload_images/27208505-cbe27418e7f23b37.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.plugin.profile.ui.ContactInfoUI
- 说明:进入联系人信息页
接着 点击发消息,输出日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.ui.chatting.ChattingUI
- 说明:进入聊天页
接着 点击加号更多按钮,输出日志如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XtrtDh3V-1670470679479)(https://upload-images.jianshu.io/upload_images/27208505-5d13b37ab4ce697d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
当:EventType → TYPE_VIEW_CLICKED,Text → 更多功能按钮,已折叠 说明:点击了更多按钮,接着 点击转账按钮,输出日志如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pZ3meFip-1670470679480)(https://upload-images.jianshu.io/upload_images/27208505-863e6db1332b68e7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → om.tencent.mm.plugin.remittance.ui.RemittanceUI
- 说明:进入转账页,输入0.01,接着 点击转账按钮,输出日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.ui.widget.dialog.f
- 说明:出现异常弹窗,好友关系不正常,比如这里的Text就显示:你不是收款方好友,对方添加你为好友后才能发起转账, 我知道了。
接着再试试正常转账,需要输入支付密码的情况,输入日志如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LFd2uilL-1670470679482)(https://upload-images.jianshu.io/upload_images/27208505-563c19711b484a2e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → android.widget.LinearLayout - 说明:出现输入支付密码的页面
因为LinearLayout不是特别的Activity或者类,所以等下还得特别处理一下。当然,这只是大概的Event触发流程,实际开发过程可能出现某些Event不触发的情形,随机应变咯。接着就是在适当的Event,获取相应节点,执行对应的交互,如点击、滑动等。不过再次之前,还得先改动下我们的 无障碍配置文件~。
④ 修改无障碍配置文件
笔者突然有点好奇 真实好友 的配置,那就开扒,定位到它的配置文件:
咦,跟我的配置不一样,没设置 android:packageNames,上节说过不设置这个属性的话,是监听所有App的,检测僵尸好友,不是只应该监听 com.tencent.mm 微信的吗?还有监听的事件类型只监听 typeWindowsChanged,关于这种类型,官方文档中这样介绍到:
API 21新增,系统窗口事件改变会触发,难不成这中event类型更高效?写一个顶几个?于是我Copy了它的配置,并加上 android:packageNames=“com.tencent.mm”,运行后却发现没有日志输出。
接着把它删掉再试,此时有日志信息输出:
但packageName和className都是null,这样能区分哪个APP?哪个页面?真是好友是咋做的?
简单脱下壳导出dex,丢电脑里用jadx反编译成java,直接定位到它的无障碍服务类 → MyAccessibilityService
,搜 setServiceInfo()
,我感觉它是不是在代码里又进行了动态配置,结果没找着,接着搜 onAccessibilityEvent()
:
往线程池里丢了线程实例t,跟下t的代码实现:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1wzQBg5C-1670470679487)(https://upload-images.jianshu.io/upload_images/27208505-6200ed7d16dc0a18.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
第一段不难看出大概得逻辑:
getRootInActiveWindow() 获得节点树,然后判断packageName是否为com.tencent.mm,是执行微信相关校验逻辑
第二段稍微难猜一点,应该是用来 判断用户是否退出微信,只在 onServiceConnected() 调用一次,断点了一下for循环:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FS8uTK2E-1670470679488)(https://upload-images.jianshu.io/upload_images/27208505-b24d7fd54cedbce4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
这里拿到了Launcher(桌面启动器) 和 设置的包名信息,尔后的com.android.incallui一般是拨号软件的包名,再加上真实好友的包名。如果包名和这四个匹配,说明用户退出微信页面,中断任务执行。
另外,在阅读源码时还发现了作者不同版本的兼容方式,需要通过id定位节点的,把每个版本节点对应id存一个数组中,遍历查找:
虽然没细看完整代码,不过大概能猜到作者的意图,这样处理的好处,不用区分Event类型,根据页面特征点进行匹配,当前处于哪一步,执行对应的处理逻辑,实属牛啤!
但我们这里不这样做,毕竟练手,2333,还是特意区分event来玩耍,后面再改进亦可,给出我们的无障碍服务配置如下:
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_desc"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewClicked"
android:accessibilityFeedbackType="feedbackSpoken"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:canPerformGestures="true"
android:packageNames="com.tencent.mm"
android:settingsActivity="cn.coderpig.clearcorpse.SettingActivity" />
另外,真实好友配置文件中的 android:accessibilityFlags=“flagRetrieveInteractiveWindows” 这个是用来搭配 TYPE_WINDOWS_CHANGE 事件类型使用的:
⑤ 点击通讯录Tab
跳转微信后,定位到通讯录节点,触发点击,运行打印节点树的python脚本,输出结果如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l3ZYR08j-1670470679491)(https://upload-images.jianshu.io/upload_images/27208505-03e29063902f80a5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
可以看到id未f2s得节点有多个,故这里通过文本匹配,而它的clickable为false,说明是不可点击的,得调用 parent()
获得他的父节点才能点击。
这种情况很常见,获取到的节点不支持点击,有时得连续调用好几个 parent()
才能拿到可点击的节点,跟连体蜈蚣一样。所以这里封装下点击的方法,递归获取能点击的父节点,具体代码如下:
// 点击
fun AccessibilityNodeInfo?.click() {
if (this == null) return
if (this.isClickable) {
this.performAction(AccessibilityNodeInfo.ACTION_CLICK)
return
} else {
this.parent.click()
}
}
// 长按
fun AccessibilityNodeInfo?.longClick() {
if (this == null) return
if (this.isClickable) {
this.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
return
} else {
this.parent.longClick()
}
}
补全下点击代码:
class ClearCorpseAccessibilityService : AccessibilityService() {
companion object {
const val LAUNCHER_UI = "com.tencent.mm.ui.LauncherUI" // 首页
}
override fun onAccessibilityEvent(event: AccessibilityEvent) {
if (event.eventType == TYPE_WINDOW_STATE_CHANGED) {
when (event.className.toString()) {
LAUNCHER_UI -> {
event.source?.let { source ->
source.getNodeByText("通讯录").click()
}
}
}
}
}
override fun onInterrupt() { }
}
可以,运行后自动点击通讯录了。
关于结点查找的两个方法: findAccessibilityNodeInfosByViewId()
和 findAccessibilityNodeInfosByText()
的返回类型都是 List,而我们大部分时候只要第一个结点,每次得写一堆判空然后取第一个的重复代码显得不太美观,同样封装下,顺带加上轮询,因为有时页面可能还没load完,此时拿不到节点,过一会儿就能拿到了,封装后的代码如下:
/**
* 根据id查找单个节点
* @param id 控件id
* @return 对应id的节点
* */
fun AccessibilityNodeInfo.getNodeById(id: String): AccessibilityNodeInfo? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByViewId(id).let {
if (!it.isNullOrEmpty()) return it[0]
}
sleep(100)
count++
}
return null
}
/**
* 根据id查找多个节点
* @param id 控件id
* @return 对应id的节点列表
* */
fun AccessibilityNodeInfo.getNodesById(id: String): List<AccessibilityNodeInfo>? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByViewId(id).let {
if (!it.isNullOrEmpty()) return it
}
sleep(100)
count++
}
return null
}
/**
* 根据文本查找单个节点
* @param text 匹配文本
* @param allMatch 是否全匹配,默认false,contains()方式的匹配
* @return 匹配文本的节点
* */
fun AccessibilityNodeInfo.getNodeByText(
text: String,
allMatch: Boolean = false
): AccessibilityNodeInfo? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByText(text).let {
if (!it.isNullOrEmpty()) {
if (allMatch) {
it.forEach { node -> if (node.text == text) return node }
} else {
return it[0]
}
}
sleep(100)
count++
}
}
return null
}
/**
* 根据文本查找多个节点
* @param text 匹配文本
* @param allMatch 是否全匹配,默认false,contains()方式的匹配
* @return 匹配文本的节点列表
* */
fun AccessibilityNodeInfo.getNodesByText(
text: String,
allMatch: Boolean = false
): List<AccessibilityNodeInfo>? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByText(text).let {
if (!it.isNullOrEmpty()) {
return if (allMatch) {
val tempList = arrayListOf<AccessibilityNodeInfo>()
it.forEach { node -> if (node.text == text) tempList.add(node) }
if (tempList.isEmpty()) null else tempList
} else {
it
}
}
sleep(100)
count++
}
}
return null
}
/**
* 获取结点的文本
* */
fun AccessibilityNodeInfo?.text(): String {
return this?.text?.toString() ?: ""
}
封装好的代码等下直接调,美滋滋~
⑥ 好友列表点击
来到好友列表,还是运行打印节点树的python脚本:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eE1kBzKi-1670470679492)(https://upload-images.jianshu.io/upload_images/27208505-1dafb51d52b2366f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
直接就定位到了列表节点,但我突然有点厌烦这种获取方式了,每次找节点都得运行一次脚本,得想办法简化下~
突然心生一计,我直接写个递归遍历结点的方法,把要用到的信息打印出来不就好了?说干就干:
/**
* 遍历打印结点
* */
fun AccessibilityNodeInfo?.fullPrintNode(
tag: String,
spaceCount: Int = 0
) {
if (this == null) return
val spaceSb = StringBuilder().apply { repeat(spaceCount) { append(" ") } }
logD("$tag: $spaceSb$text | $viewIdResourceName | $className | Clickable: $isClickable")
if (childCount == 0) return
for (i in 0 until childCount) getChild(i).fullPrintNode(tag, spaceCount + 1)
}
// 调用下
source.fullPrintNode("首页")
运行后,输出日志信息如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WEuke8YG-1670470679493)(https://upload-images.jianshu.io/upload_images/27208505-e0afb4fbb6c71c49.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
舒服了啊,列表项的id也get√了,继续完善下代码:
const val CONTACT_LIST_ID = "js"
const val CONTACT_ITEM_ID = "hg4"
override fun onAccessibilityEvent(event: AccessibilityEvent) {
when (event.eventType) {
TYPE_WINDOW_STATE_CHANGED -> {
when (event.className.toString()) {
LAUNCHER_UI -> {
event.source?.let { source -> source.getNodeByText("通讯录").click() }
}
}
}
TYPE_VIEW_CLICKED -> {
if (event.text[0] == "通讯录") {
// 这里不能用event的getSource(),只能获取到发生改变的节点
// 需要调用getRootInActiveWindow()获得所有结点
rootInActiveWindow?.let { source ->
val contactList = source.getNodeById(wxNodeId(CONTACT_LIST_ID))
if (contactList != null) {
contactList.getNodeById(wxNodeId(CONTACT_ITEM_ID)).click()
} else {
logD("未能获取好友列表")
}
}
}
}
else -> logD("$event")
}
}
杠杠滴!有了上面的工具代码,后续的开发也变得简单了许多~
⑦ 联系人信息页点击发消息
来到联系人信息页,获取联系人微信号,然后点击发消息,代码如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XSbs0NHs-1670470679494)(https://upload-images.jianshu.io/upload_images/27208505-f285461f0921cc2a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
运行后正常点击,控制台看到联系人微信号也打印出来了~
⑧ 聊天页点击加号+转账
来到聊天页,点击更多按钮,底部弹出窗口点击转账。但这里有些奇怪,并没有走到上面的ChattingUI,所以这里换成监听点击了发消息,然后再执行这些操作。
理论上是这样,但实际上并没有点击转账,打断点发现,点击的确实是clickable的父节点。应该是微信做了什么防护,拦截了节点的点击行为。这种情况得变通下了,用手势的方式来实现模拟点击,同样给出直接就能用的工具代码:
/**
* 利用手势模拟点击
* @param node: 需要点击的节点
* */
fun AccessibilityService.gestureClick(node: AccessibilityNodeInfo?) {
if (node == null) return
val tempRect = Rect()
node.getBoundsInScreen(tempRect)
val x = ((tempRect.left + tempRect.right) / 2).toFloat()
val y = ((tempRect.top + tempRect.bottom) / 2).toFloat()
dispatchGesture(
GestureDescription.Builder().apply {
addStroke(GestureDescription.StrokeDescription(Path().apply { moveTo(x, y) }, 0L, 200L))
}.build(),
object : AccessibilityService.GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
super.onCompleted(gestureDescription)
logD("手势点击完成: 【$x - $y】")
}
},
null
)
}
修改下调用处:
可以,手势模拟点击正常~
⑨ 转账页处理逻辑
来到转账页,判断昵称后面是否有真实姓名,是说明好友关系正常。没有的话,转账0.01,出现异常状态弹窗(被删、拉黑、对方账号异常),出现输入密码的弹窗说明关系正常。逻辑非常清楚,就直接给出代码吧:
日志输出如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2pWDG1UZ-1670470679498)(https://upload-images.jianshu.io/upload_images/27208505-c9645bb87e744a83.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Rni6EW5d-1670470679499)(https://upload-images.jianshu.io/upload_images/27208505-33c85325dbf22d84.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
可以,到此整条检测链条的基本流程就实现啦~
0x4、小结
不知不觉又到文尾,限于篇幅,并没有实现完整功能,目前还差:遍历所有好友执行上述逻辑和检测结果保存了,当然可能还有一些bug,后续会完善下更新到Github上:ClearCorpse,感兴趣的可以先Star,也可以自己续着写,师傅领进门,修行靠自身,多练多总结才是真,感谢,我们下节再见~
作者:coder_pig
链接:https://juejin.cn/post/7170340157185327118
最后
如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。
如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。
全套视频资料:
一、面试合集
二、源码解析合集
三、开源框架合集
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓