反思系列:深入探索ANR机制的设计与实现
本文作者
作者:却把清梅嗅
链接:
https://juejin.im/post/6864555867023343623
本文由作者授权发布。
反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里 。
https://github.com/qingmei2/blogs/blob/master/src/反思系列/反思|系列目录.md
对于Android开发者而言,ANR是一个老生常谈的问题,站在面试者的角度,似乎说出 「不要在主线程做耗时操作」 就算合格了。
但是,ANR机制到底是什么,其背后的原理究竟如何,为什么要设计出这样的机制?
这些问题时时刻刻会萦绕脑海,而想搞清楚这些,就不得不提到Android自身的 输入系统 (Input System)。
Android自身的 输入系统 又是什么?
一言以蔽之,任何与Android设备的交互——我们称之为 输入事件,都需要通过 输入系统 进行管理和分发;这其中最靠近上层,并且最典型的一个小环节就是View的 事件分发 流程。
这样看来,输入系统 本身确实是一个非常庞大复杂的命题,并且,越靠近底层细节,越容易有一种 只见树木不见树林 之感,反复几次,直至迷失在细节代码的较真中,一次学习的努力尝试付诸东流。
因此,控制住原理分析的粒度,在宏观的角度,系统地了解输入系统本身的设计理念,并引申到实际开发中的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非常重要,其负责管理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负责了输入事件的读取和分发,在 输入系统 中,两者的确非常重要。
这里借用网上的图对此进行一个简单的概括:
对于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的原理是什么?
https://www.zhihu.com/question/20122137/answer/14049112
参考上文,这里我们对epoll机制进行一个简单的总结:
epoll可以理解为event poll,不同于忙轮询和无差别轮询,在 多个输入流 的情况下,epoll只会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。
EventHub中使用epoll的恰到好处——多个物理输入设备对应了多个不同的输入流,通过epoll机制,在EventHub初始化时,分别创建mEpollFd和mINotifyFd;前者用于监听设备节点是否有设备文件的增删,后者用于监听是否有可读事件,创建管道,让InputReader来读取事件:
本章节将对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传过来的事件:
这里我们对该次 跨进程通信建立流程 有了初步的认知,对于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处理完成并分发过来的 输入事件 了。
至此,我们来到了最熟悉的应用层级事件分发流程。对于这之后 应用层级的事件分发,可以阅读下述笔者的另外两篇文章,本文不赘述。
Android 事件分发机制的设计与实现
https://juejin.im/post/6844903926446161927
Android 事件拦截机制的设计与实现
https://juejin.im/post/6844904128397705230
对 输入系统 有了更初步整体的认知之后,接下来本文将针对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),便于后续的案件侦破(调试分析),最后是炸毁目标。
http://gityuan.com/2019/04/06/android-anr/
将组件的ANR机制比喻为 定时炸弹 非常贴切,以Service为例,对于Android系统而言,启动一个服务其本质是进程间的异步通信,那么,如何判断Service是否启动成功,如果一直没有成功,那么如何处理?
因此Android设计了一个 置之死地而后生 的机制,在尝试启动Service时,让中控系统system_server埋下一个 定时炸弹 ,当Service完成启动,拆掉炸弹;否则在system_server的ActivityManager线程中引爆炸弹,这就是组件类ANR机制的原理:
接下来简单了解一下 输入系统 流程中ANR机制的原理。
2、第二类原理概述
Input类型的ANR在日常开发中更为常见且更复杂,比如用户或者测试反馈,点击屏幕中的UI元素导致「卡死」。
少数情况下开发者能够很快定位到问题,但更常见的情况是,该问题是 随机 且 难以复现 的,导致该问题的原因也更具有综合性,比如低端设备的系统本身资源已非常紧张,或者多线程相互持有彼此需要的资源导致 死锁 ,亦或其它复杂的情况,因此处理这类型问题就需要开发者对 输入系统 中的ANR机制有一定的了解。
与组件类ANR不同的是,Input类型的超时机制并非时间到了一定就会爆炸,而是处理后续上报事件的过程才会去检测是否该爆炸,所以更像是 扫雷 的过程。
什么叫做 扫雷 呢,对于 输入系统 而言,即使某次事件执行时间超过预期的时长,只要用户后续没有再生成输入事件,那么也不需要ANR。
而只有当新一轮的输入事件到来,此时正在分发事件的窗口(即App应用本身)迟迟无法释放资源给新的事件去分发,这时InputDispatcher才会根据超时时间,动态的判断是否需要向对应的窗口提示ANR信息。
这也正是用户在第一次点击屏幕,即使事件处理超时,也没有弹出ANR窗口,而当用户下意识再次点击屏幕时,屏幕上才提示出了ANR信息的原因。
由此可见,组件类ANR和Input ANR原理上确实有所不同;除此之外,前者是在ActivityManager线程中处理的ANR信息,后者则是在InputDispatcher线程中处理的ANR,这里通过一张图简单了解一下后者的整体流程:
现在我们对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
http://gityuan.com/2019/04/06/android-anr/
2、Input系统—ANR原理分析 @Gityuan
http://gityuan.com/2017/01/01/input-anr/
3、理解Android ANR的触发原理 @Gityuan
http://gityuan.com/2016/07/02/android-anr/
深入学习ANR机制资料,Gityuan的ANR博客系列绝对是先驱级别的,尤其是第1篇文章中,其对于 定时炸弹 和 扫雷 的形容,贴切且易理解,这种 举重若轻 的写作风格体现了作者本身对整个知识体系的深度掌握;而后两篇文章则针对两种类型的ANR分别进行了源码级别的分析,非常下饭。
4、图解Android-Android的 Event Input System @漫天尘沙
https://www.cnblogs.com/samchen2009/p/3368158.html
笔者曾经想写一个 图解Android 系列,后来因为种种原因放弃了,没想到若干年前已经有先驱进行过了这样的尝试,并且,内容质量极高。笔者相信,能够花费非常大精力总结的文章一定不会被埋没,而这篇文章,注定会成为经典中的经典。
5、Android Input系列 @Stan_Z
https://www.jianshu.com/p/5a879b7ad3b2
一个笔者最近关注非常优秀的作者,文章非常具有深度,其Input系列针对整个输入系统进行了更细致源码级别的分析,非常值得收藏。
6、Android 信号处理面面观 之 信号定义、行为和来源 @rambo2188
https://blog.csdn.net/rambo2188/article/details/6998349
如果读者对「Android系统信号处理的行为」感兴趣,那么这篇文章绝对不能错过。
7、Android开发高手课 @张绍文
实战中的经典之作,该课程每一小结都极具深度,价值不可估量。因或涉及到利益相关,而且推荐了也从张老师那里拿不到钱,因此本文不加链接并放在最下面(笑)。
关于我
Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!