欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

荐 反思|Android 输入系统 & ANR机制的设计与实现

程序员文章站 2022-03-05 14:53:21
反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里 。概述对于Android开发者而言,ANR是一个老生常谈的问题,站在面试者的角度,似乎说出 「不要在主线程做耗时操作」 就算合格了。但是,ANR机制到底是什么,其背后的原理究竟如何,为什么要设计出这样的机制?这些问题时时刻刻会萦绕脑海,而想搞清楚这些,就不得不提到Android自身的 输入系统 (Input System)。Android自身的 输入系统 又是什么?一言以蔽之,任何与Android设备的交互——我们称之为 输....

反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里

概述

对于Android开发者而言,ANR是一个老生常谈的问题,站在面试者的角度,似乎说出 「不要在主线程做耗时操作」 就算合格了。

但是,ANR机制到底是什么,其背后的原理究竟如何,为什么要设计出这样的机制?这些问题时时刻刻会萦绕脑海,而想搞清楚这些,就不得不提到Android自身的 输入系统Input System)。

Android自身的 输入系统 又是什么?一言以蔽之,任何与Android设备的交互——我们称之为 输入事件,都需要通过 输入系统 进行管理和分发;这其中最靠近上层,并且最典型的一个小环节就是View事件分发 流程。

这样看来,输入系统 本身确实是一个非常庞大复杂的命题,并且,越靠近底层细节,越容易有一种 只见树木不见树林 之感,反复几次,直至迷失在细节代码的较真中,一次学习的努力尝试付诸东流。

因此,控制住原理分析的粒度,在宏观的角度,系统地了解输入系统本身的设计理念,并引申到实际开发中的ANR现象的原理和解决思路 ,是一个非常不错的理论与实践相结合的学习方式,这也正是笔者写作本文的初衷。

本文篇幅较长,思维导图如下:

荐
                                                        反思|Android 输入系统 & ANR机制的设计与实现

一、自顶向下探索

谈到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非常重要,其负责管理IMSWindowActivityManager之间的通信,这里点到为止,后文再进行补充,我们先来看IMS

顾名思义,IMS服务的作用就是负责输入模块在Java层级的初始化,并通过JNI调用,在Native层进行更下层输入子系统相关功能的创建和预处理。

JNI的调用过程中,IMS创建了NativeInputManager实例,NativeInputManager则在初始化流程中又创建了EventHubInputManager:

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层级相通信,可以说是非常重要。而在该层级中,EventHubInputManager又是最核心的两个角色。

这两个角色的职责又是什么呢?首先来说EventHub,它是底层 输入子系统 中的核心类,负责从物理输入设备中不断读取事件(Event),然后交给InputManager,后者内部封装了InputReaderInputDispatcher,用来从EventHub中读取事件和分发事件:

InputManager::InputManager(...) {
    mDispatcher = new InputDispatcher(dispatcherPolicy);
    mReader = new InputReader(eventHub, readerPolicy, mDispatcher);
    initialize();
}

简单来看,EventHub建立了Linux与输入设备之间的通信,InputManager中的InputReaderInputDispatcher负责了输入事件的读取和分发,在 输入系统 中,两者的确非常重要。

这里借用网上的图对此进行一个简单的概括:

荐
                                                        反思|Android 输入系统 & ANR机制的设计与实现

二、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或者kqueue的原理是什么?

参考上文,这里我们对epoll机制进行一个简单的总结:

epoll可以理解为event poll,不同于忙轮询和无差别轮询,在 多个输入流 的情况下,epoll只会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。

EventHub中使用epoll的恰到好处——多个物理输入设备对应了多个不同的输入流,通过epoll机制,在EventHub初始化时,分别创建mEpollFdmINotifyFd;前者用于监听设备节点是否有设备文件的增删,后者用于监听是否有可读事件,创建管道,让InputReader来读取事件:

荐
                                                        反思|Android 输入系统 & ANR机制的设计与实现

三、事件的读取和分发

本章节将对InputReaderInputDispatcher进行系统性的介绍。

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中选择打开某些功能,以 手势识别 为例,AndroidAccessbilityManagerService(辅助功能服务) 可能会根据需要转换成新的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建立通信

关于 跨进程通信的建立 这一节,笔者最初打算作为一个大的章节来讲,但是对于整个 输入系统 而言,其似乎又只是一个 重要非必需 的知识点。最终,笔者将其放在一个小节中进行简单的描述,有兴趣的读者可以在文末的参考链接中查阅更详尽的资料。

我们知道,InputReaderInputDispatcher运行在system_server 系统进程 中,而用户操作的应用都运行在自己的 应用进程 中;这里就涉及到跨进程通信,那么 应用进程 是如何与 系统进程 建立通信的呢?

让我们回到文章最初WindowManagerService(WMS)InputManagerService(IMS)初始化的流程中来,当IMS以及其他的系统服务初始化完成之后,应用程序开始启动。

如果一个应用程序有Activity(只有Activity能够接受用户输入),那么它要将自己的Window注册到WMS中。

在这里,Android使用了Socket而不是Binder来完成。WMS中通过OpenInputChannelPair生成了两个SocketFD, 代表一个双向通道的两端:向一端写入数据,另外一端便可以读出;反之,如果一端没有写入数据,另外一端去读,则陷入阻塞等待。

最终InputDispatcher中建立了目标应用的Connection对象,代表与远端应用的窗口建立了链接;同样,应用进程中的ViewRootImpl创建了WindowInputEventReceiver用于接受InputDispatchor传过来的事件:

荐
                                                        反思|Android 输入系统 & ANR机制的设计与实现

这里我们对该次 跨进程通信建立流程 有了初步的认知,对于Android系统而言,Binder是最广泛的跨进程通信的应用方式,但是Android系中跨进程通信就仅仅只用到了Binder吗?答案是否定的,至少在 输入系统 中,除了Binder之外,Socket同样起到了举足轻重的作用。

那么新的问题就来了,这里为什么选择Socket而不是选择Binder呢,关于这个问题的解释,笔者找到了一个很好的版本:

Socket可以实现异步的通知,且只需要两个线程参与(Pipe两端各一个),假设系统有N个应用程序,跟输入处理相关的线程数目是 N+1 (1Input 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_serverActivityManager线程中引爆炸弹,这就是组件类ANR机制的原理:

荐
                                                        反思|Android 输入系统 & ANR机制的设计与实现

接下来简单了解一下 输入系统 流程中ANR机制的原理。

2、第二类原理概述

Input类型的ANR在日常开发中更为常见且更复杂,比如用户或者测试反馈,点击屏幕中的UI元素导致「卡死」。

少数情况下开发者能够很快定位到问题,但更常见的情况是,该问题是 随机难以复现 的,导致该问题的原因也更具有综合性,比如低端设备的系统本身资源已非常紧张,或者多线程相互持有彼此需要的资源导致 死锁 ,亦或其它复杂的情况,因此处理这类型问题就需要开发者对 输入系统 中的ANR机制有一定的了解。

与组件类ANR不同的是,Input类型的超时机制并非时间到了一定就会爆炸,而是处理后续上报事件的过程才会去检测是否该爆炸,所以更像是 扫雷 的过程。

什么叫做 扫雷 呢,对于 输入系统 而言,即使某次事件执行时间超过预期的时长,只要用户后续没有再生成输入事件,那么也不需要ANR

而只有当新一轮的输入事件到来,此时正在分发事件的窗口(即App应用本身)迟迟无法释放资源给新的事件去分发,这时InputDispatcher才会根据超时时间,动态的判断是否需要向对应的窗口提示ANR信息。

这也正是用户在第一次点击屏幕,即使事件处理超时,也没有弹出ANR窗口,而当用户下意识再次点击屏幕时,屏幕上才提示出了ANR信息的原因。

由此可见,组件类ANRInput ANR原理上确实有所不同;除此之外,前者是在ActivityManager线程中处理的ANR信息,后者则是在InputDispatcher线程中处理的ANR,这里通过一张图简单了解一下后者的整体流程:

荐
                                                        反思|Android 输入系统 & ANR机制的设计与实现

现在我们对Input类型的ANR机制有了一个简单的了解,下文将针对其更深入性的细节实现进行探讨。

3、事件分发的异步机制

我们再次将目光转回到InputDispatcher的实现细节。

先抛出一个新的问题,对处于system_server进程Native层级的 事件分发 而言,其向下与 应用进程 的通信的过程应该是同步还是异步的?

对于读者而言,不难得出答案是异步的,因为两者之间双向通信的建立是通过SocketPair,并且,因为system_serverInputDispatcher对事件的分发实际上是一对多的,如果是同步的,那么一旦其中一个应用分发超时,那么InputDispatcher线程自然被卡住,其永远都不可能进入到下一轮的事件分发中,扫雷 机制更是无从谈起。

因此,与应用进程中事件分发不同的是,后者我们通常可以认为是在主线程中同步的,而对于整个 输入系统 而言,因为涉及到 系统进程 与多个 应用进程 之间异步的通信,因此其内部的实现更为复杂。

因为事件分发涉及到异步回调机制,因此InputDispatcher需要对事件进行维护和管理,那么问题就变成了,使用什么样的数据结构去维护这些输入事件比较合适。

4、三个队列

InputDispatcher的源码实现中,整体的事件分发流程共使用到3个事件队列:

  • mInBoundQueue:用于记录InputReader发送过来的输入事件;
  • outBoundQueue:用于记录即将分发给目标应用窗口的输入事件;
  • waitQueue:用于记录已分发给目标应用,且应用尚未处理完成的输入事件。

下文,笔者通过2轮事件分发的示例,对三个队列的作用进行简单的梳理。

4.1 第一轮事件分发

首先InputReader线程通过EventHub监听到底层的输入事件上报,并将其放入了mInBoundQueue中,同时唤醒了InputDispatcher线程。

然后InputDispatcher开始了第一轮的事件分发,此时并没有正在处理的事件,因此InputDispatchermInBoundQueue队列头部取出事件,并重置ANR的计时,并检查窗口是否就绪,此时窗口准备就绪,将该事件转移到了outBoundQueue队列中,因为应用管道对端连接正常,因此事件从outBoundQueue取出,然后放入了waitQueue队列,因为Socket双向通信已经建立,接下来就是 应用进程 接收到新的事件,然后对其进行分发。

如果 应用进程 事件分发正常,那么会通过Socketsystem_server通知完成,则对应的事件最终会从waitQueue队列中移除。

4.2 第二轮事件分发

如果第一轮事件分发尚未接收到回调通知,第二轮事件分发抵达又是如何处理的呢?

第二轮事件到达InputDispatcher时,此时InputDispatcher发现有事件正在处理,因此不会从mInBoundQueue取出新的事件,而是直接检查窗口是否就绪,若未就绪,则进入ANR检测状态。

以下几种情况会导致进入ANR检测状态:

1、目标应用不会空,而目标窗口为空。说明应用程序在启动过程中出现了问题;
2、目标Activity的状态是Pause,即不再是Focused的应用;
3、目标窗口还在处理上一个事件。

读者需要理解,并非所有「目标窗口还在处理上一个事件」都会抛出ANR,而是需要通过检测时间,如果未超时,那么直接中止本轮事件分发,反之,如果事件分发超时,那么才会确定ANR的发生。

这也正是将Input类型的ANR描述为 扫雷 的原因:这里的扫雷是指当前输入系统中正在处理着某个耗时事件的前提下,后续的每一次input事件都会检测前一个正在处理的事件是否超时(进入扫雷状态),检测当前的时间距离上次输入事件分发时间点是否超时。如果前一个输入事件,则会重置ANRtimeout,从而不会爆炸。

至此,输入系统 检测到了ANR的发生,并向上层抛出了本次ANR的相关信息。

小结

本文旨在对Android 输入系统 进行一个系统性的概述,读者不应将本文作为唯一的学习资料,而应该通过本文对该知识体系进行初步的了解,并根据自身要求进行单个方向细节性的突破。而已经掌握了骨骼架构的读者而言,更细节性的知识点也不过是待丰富的血肉而已。

本文从立题至发布,整个流程耗时近1个半月,在这个过程中,笔者参考了较本文内容数十倍的资料,受益颇深,也深感以 举重若轻 为写文目标之艰难——内容铺展容易,但通过 简洁连贯 的语言来对一个庞大复杂的知识体系进行收拢,需要极强的 克制力 ,在这种严苛的要求下,每一句的描述都需要极高的 精确性 ,这对笔者而言是一个挑战,但真正完成之后,对整个知识体系的理解程度同样也是极高的。

而这也正是 反思 系列的初衷,希望你能喜欢。

参考 & 扩展阅读

正如上文所言,输入系统ANR 本身都是一个非常大的命题,除了宽广的知识体系,还需要亲身去实践和总结,下文列出若干相关参考资料,读者可根据自身需求选择性进行扩展阅读:

1、彻底理解安卓应用无响应机制 @Gityuan
2、Input系统—ANR原理分析 @Gityuan
3、理解Android ANR的触发原理 @Gityuan

深入学习ANR机制资料,GityuanANR博客系列绝对是先驱级别的,尤其是第1篇文章中,其对于 定时炸弹扫雷 的形容,贴切且易理解,这种 举重若轻 的写作风格体现了作者本身对整个知识体系的深度掌握;而后两篇文章则针对两种类型的ANR分别进行了源码级别的分析,非常下饭。

4、图解Android-Android的 Event Input System @漫天尘沙

笔者曾经想写一个 图解Android 系列,后来因为种种原因放弃了,没想到若干年前已经有先驱进行过了这样的尝试,并且,内容质量极高。笔者相信,能够花费非常大精力总结的文章一定不会被埋没,而这篇文章,注定会成为经典中的经典。

5、Android Input系列 @Stan_Z

一个笔者最近关注非常优秀的作者,文章非常具有深度,其Input系列针对整个输入系统进行了更细致源码级别的分析,非常值得收藏。

6、Android 信号处理面面观 之 信号定义、行为和来源 @rambo2188

如果读者对「Android系统信号处理的行为」感兴趣,那么这篇文章绝对不能错过。

7、Android开发高手课 @张绍文

实战中的经典之作,该课程每一小结都极具深度,价值不可估量。因或涉及到利益相关,而且推荐了也从张老师那里拿不到钱,因此本文不加链接并放在最下面(笑)。


关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub

如果您觉得文章还差了那么点东西,也请通过 关注 督促我写出更好的文章——万一哪天我进步了呢?

本文地址:https://blog.csdn.net/mq2553299/article/details/108210005

相关标签: Android