【Android自动化】AccessibilityService实战-微信僵尸好友检测

0x1、引言

上节我们学习了AccessibilityService无障碍的基础知识,并写了一个简单的微信自动登录的小案例。相信大家都意犹未尽,所以本节安排一波实战 —— 微信僵尸好友检测

啥是 僵尸好友

在微信里,对方把你删除/拉黑了,并不会从你的好友列表消失,只有你给他/她发消息,看到红色感叹号才知道。而他/她如果把 加好友验证选项关闭,你发消息不会有红色感叹号,而对方却能看到你发的消息:

他/她顺手点这个把你加回来,你这边是不会有任何提醒的,所以被删这件事你可能永远都不知道~

对于我这种有强迫症的人来说,既然对方删了我,那我也要删了他/她。直接粗暴地给每个好友 群发消息看会不会出现红色感叹号 的方案显然不太行,浪费自己时间不说,还打扰了别人,万一发给了一些不得不加,但平时无天可聊的人,就尴尬了。

随手搜了一下,看到一个 拉群 的方案:拉群时被删的好友会提示不是好友关系

但每次只能检测50人 (好像是这个值),超过50需要对方同意才可以入群,就是会 收到邀请通知。而少于50的话,只要不往群里发消息,被拉的人是不知道群存在的。当然,要是被别人知道了的话,就是社死了~

又随手搜了一下:自动清理微信僵尸好友

扫描二维码关注公众号,回复: 14551396 查看本文章

九块九解君愁?也有些免费帮你清理的公号 (哪有那么多天上掉馅饼的好事),看了下演示视频,需要扫码登录,猜测用的是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)]

  • 当:EventTypeTYPE_WINDOW_STATE_CHANGEDClassNamecom.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)]

当:EventTypeTYPE_VIEW_CLICKEDText通讯录 时说明点击了通讯录。接着随意 点击一个联系人,输出日志如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ElCkRuNY-1670470679477)(https://upload-images.jianshu.io/upload_images/27208505-cbe27418e7f23b37.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]

  • 当:EventTypeTYPE_WINDOW_STATE_CHANGEDClassNamecom.tencent.mm.plugin.profile.ui.ContactInfoUI
  • 说明:进入联系人信息页

接着 点击发消息,输出日志如下:

  • 当:EventTypeTYPE_WINDOW_STATE_CHANGEDClassNamecom.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)]

当:EventTypeTYPE_VIEW_CLICKEDText更多功能按钮,已折叠 说明:点击了更多按钮,接着 点击转账按钮,输出日志如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pZ3meFip-1670470679480)(https://upload-images.jianshu.io/upload_images/27208505-863e6db1332b68e7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]

  • 当:EventTypeTYPE_WINDOW_STATE_CHANGEDClassNameom.tencent.mm.plugin.remittance.ui.RemittanceUI
  • 说明:进入转账页,输入0.01,接着 点击转账按钮,输出日志如下:

  • 当:EventTypeTYPE_WINDOW_STATE_CHANGEDClassNamecom.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)]

  • 当:EventTypeTYPE_WINDOW_STATE_CHANGEDClassNameandroid.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官方认证微信卡片免费领取↓↓↓

猜你喜欢

转载自blog.csdn.net/datian1234/article/details/128234173