Android Input系统分析和调试手段介绍

一、背景

Android系统中有很多的系统服务管理不同的内容,比如WMS(WindowManagerService)负责Android的窗口管理、布局及动画的工作机制。窗口不仅是内容绘制的载体,同时也是用户输入事件的目标。
这里将讨论下Andorid输入系统的原理,包括输入设备的管理、输入事件的加工方式以及派发流程。
主要内容涉及两方面:

  1. 输入设备
  2. 输入事件

触摸屏与键盘是Android最普遍也是最标准的输入设备。其实Android所支持的输入设备的种类不止这两个,鼠标、游戏手柄均在支持之列。当输入设备可用时,Linux内核会在/dev/input/下创建对应的名为event0~n或其他名称的设备节点。
当用户操作输入设备时,Linux内核接收到对应的硬件中断,然后将中断加工成原始的输入事件数据并写入其对应的设备节点中,在用户空间可以用过read()函数将事件数据读出。
Android输入系统的工作原理概括来说,就是监控/dev/input/下的所有设备节点,当某个节点有数据可读时,将数据读出并进行一系列的翻译加工,然后在所有的窗口中寻找合适的事件接收者,并派发给它。
我们要怎么直观的看到一直输入操作会产生什么样的事件数据呢?获取答案的最简单的办法就是用getevent工具

getevent监听输入设备节点的内容,当输入事件被写入节点时,getevent会将其读出并打印在屏幕上。由于getevent不会对事件数据做任何加工,因此其输出的内容是由内核提供的最原始的事件。

比如我们可以通过adb shell getevent -lrt来通过点击Power键查看打印:

generic_x86_64:/dev/input $ getevent -t                                                        
add device 1: /dev/input/event0
  name:     "Power Button"
add device 2: /dev/input/event1
  name:     "qwerty2"
add device 3: /dev/input/event2
  name:     "goldfish_rotary"
[    5850.489165] /dev/input/event1: 0001 0074 00000001
[    5850.489165] /dev/input/event1: 0000 0000 00000000
[    5850.549444] /dev/input/event1: 0001 0074 00000000
[    5850.549444] /dev/input/event1: 0000 0000 00000000

----------------------------------------------------------------

generic_x86_64:/dev/input $ getevent -lrt
add device 1: /dev/input/event0
  name:     "Power Button"
add device 2: /dev/input/event1
  name:     "qwerty2"
add device 3: /dev/input/event2
  name:     "goldfish_rotary"

[    5391.608726] /dev/input/event1: EV_KEY       KEY_POWER            DOWN                
[    5391.608726] /dev/input/event1: EV_SYN       SYN_REPORT           00000000            
[    5391.678479] /dev/input/event1: EV_KEY       KEY_POWER            UP                  
[    5391.678479] /dev/input/event1: EV_SYN       SYN_REPORT           00000000      

每条数据有5项信息:

  1. 产生事件时的事件戳([ 5850.489165])
  2. 产生事件的设备节点(/dev/input/event1)
  3. 事件类型(0001)
  4. 事件代码(0074)
  5. 事件的值(00000001)
    注意它的输出是十六进制的,0x01EV_KEY表示此事件为一条按键事件,代码0x74表示电源键的扫描码,值0x01表示按下,0x00表示抬起。

二、Android输入系统简介

1. 输入系统总体流程

背景中讲述了输入事件的源头是位于/dev/input/下的设备节点,而输入系统的终点是WMS管理的某个窗口。最初的输入事件为内核生成的原始事件,而最终交付给窗口的则是KeyEventMotionEvent对象。因此Android输入系统的主要工作就是读取设备节点中的原始事件,将其加工封装,然后派发给一个特定的窗口以及窗口中的控件。这个过程由InputManagerService(以下简称IMS)系统服务为核心的多个参与者共同完成。
输入系统的总体流程和参与者如图所示:

在这里插入图片描述

  • Linux内核,接受输入设备的中断,并将原始事件的数据写入设备节点中。
  • 设备节点,作为内核与IMS的桥梁,它将原始事件的数据暴露给用户空间,以便IMS可以从中读取事件。
  • InputManagerService,一个Android系统服务,它分为Java层和Native层两部分。Java层负责与WMS通信。而Native层则是InputReaderInputDispatcher两个输入系统关键组件的运行容器。
  • EventHub,直接访问所有的设备节点。并且正如其名字所描述的,它通过一个名为getEvents()的函数将所有输入系统相关的待处理的底层事件返回给使用者。这些事件包括原始输入事件、设备节点的增删等。
  • InputReader,是IMS中的关键组件之一。它运行与一个独立的线程中,负责管理输入设备的列表与配置,以及进行输入事件的加工处理。它通过其线程循环不断地通过getEvents()函数从EventHub中将事件取出并进行处理。对于设备节点的增删事件,它会更新输入设备列表与配置。对于原始输入事件,InputReader对其进行翻译、组装、封装为包含更多信息、更具可读性的输入事件,然后交给InputDispatcher进行派发。
  • InputReaderPolicy,它为InputReader的事件加工处理提供一些策略配置,列入键盘布局信息等。
  • InputDispatcher,是IMS中另一个关键组件。它也运行于一个独立的进程中。InputDispatcher中保管了来自WMS的所有窗口的信息,其收到来自InputReader的输入事件后,会在其保管的窗口中寻找合适的窗口,并将事件派发给此窗口。
  • InputDispatcherPolicy,它为InputDispatcher的派发过程提供策略控制。例如截取某些特定的输入事件用作特殊用途,或者阻止将某些事件派发给目标窗口。一个典型的例子就是HOME键被InputDispatcherPolicy截取到PhoneWindowManager中进行处理,并阻止窗口收到HOME键按下的事件。
  • WMS,虽说不是输入系统中的一员,但是它却对InputDispatcher的正常工作起到了一个至关重要的作用。当新建窗口时,WMS为新窗口创建了事件传递所用的通道。另外,WMS还将所有窗口的信息,包括窗口的可点击区域、焦点窗口等信息,实时地更新到IMSInputDispatcher中,使得InputDispatcher可以正确地将事件派发到指定的窗口。
  • ViewRootImpl,对某些窗口,如壁纸窗口、SurfaceView的窗口来说,窗口就是输入事件派发的终点。而对其他的如Activity、对话框等使用了Android控件系统的窗口来说,输入事件的终点就是控件ViewRootImpl将窗口所接收到的输入事件沿着控件树将事件派发给感兴趣的控件。

简单来说,内核将原始事件写入设备节点中,InputReader不断地通过EventHub将原始事件取出来并翻译加工成Android输入事件,然后交给InputDispatcherInputDispatcher根据WMS提供的窗口信息将事件交给合适的窗口。窗口的ViewRootImpl对象再沿着控件树将事件派发给感兴趣的控件。控件对其收到的事件做出相应,更新自己的画面、执行特定的动作。所有这些参与者以IMS为核心,构建了Android庞大而复杂的输入体系。

2.IMS结构

在这里插入图片描述

InputManager 是输入控制中心,含有两个关键线程:

  • InputReaderThread:主要功能在 InputReader,负责解析获取的输入事件,并分发给对应的 Mapper 进行处理。其中,EventHub 是输入设备的控制中心,直接与 Input 驱动交互,负责处理输入设备的增减、查询,处理输入事件并向上层提供 getEvents()接口接收事件。
    EventHub 的构造函数主要做三件事:

    • 创建 epoll 对象,之后就可以把多路输入设备的 fd(类似设备名称)挂到 epoll 上,等待输入事件。
    • 建立用于唤醒的 pipe,把读端挂到 epoll 上,如果有设备参数的变化需要处理,而 getEvents()又阻塞在设备上,调用 wake()pipe 的写端写入,可以让线程从等待中返回。
    • 利用 inotify 机制监听/dev/input 目录下的变更,如有则标识着设备的变化,需要处理。
  • InputDispatcherThread:主要功能在 InputDispacher,用于将事件分发给目标窗口。
    由于事件的处理是流水线模式,需要需要 EventHub 先读取事件,InputReader 解析处理事件,然后InputDispatcher 才能进一步分发。

3. 启动时序图
%%
%% InputManagerService启动InputReaderInputDispacher
%% 
sequenceDiagram
    participant SystemServer.java
    participant InputManagerService.java
    participant com_android_server_input_InputManagerService.cpp
    participant InputManager.cpp
    participant InputReaderFactory.cpp

    SystemServer.java ->> InputManagerService.java:new
    InputManagerService.java ->> com_android_server_input_InputManagerService.cpp:nativeInit
    com_android_server_input_InputManagerService.cpp ->> com_android_server_input_InputManagerService.cpp: new NativeInputManager
    com_android_server_input_InputManagerService.cpp ->> InputManager.cpp: new
    InputManager.cpp ->> InputDispatcher.cpp: new InputDispatcher()
    InputDispatcher -->> InputManager.cpp: mDispatcher
    InputManager.cpp ->> InputClassifier.cpp: new InputClassifier()
    InputClassifier.cpp -->> InputManager.cpp: mClassifier
    InputManager.cpp ->> InputReaderFactory.cpp: createInputReader
    InputReaderFactory.cpp ->> EventHub.cpp: new EventHub
    EventHub.cpp ->> EventHub.cpp: add INotifyFd to epoll("/dev/input")
    InputReaderFactory.cpp ->> InputReader.cpp: new InputReader
    InputReader.cpp -->> InputManager.cpp: mReader
    InputManager.cpp ->> InputManager.cpp: initialize
    InputManager.cpp ->> InputManager.cpp: new InputReaderThread(mReader)
    InputManager.cpp ->> InputManager.cpp: new InputDispatchThread(mDispatcher)
    SystemServer.java ->> InputManagerService.java: start
    InputManagerService.java ->> com_android_server_input_InputManagerService.cpp:nativeStart
    com_android_server_input_InputManagerService.cpp ->> InputManager.cpp: start
    InputManager.cpp ->> InputReader.cpp: ThreadLoop
    InputManager.cpp ->> InputDispatcher.cpp: ThreadLoop

三、多点触控协议分析

类型部分:

1.EV_SYN

同步事件,在事件开始或完成时会有
对应的code
SYN_REPORT:代表一个事件的结束 (必要)

2.EV_ABS

事件的一种绝对坐标类型
对应code

  • ABS_MT_SLOT
    本质代表者不同手指,它的value代表手指id

  • ABS_MT_TRACKING_ID
    该类型特有的,实际上,每个slot会和一个ID相对应,一个非负数的表示一次接触,-1表示这是一个无用的slot(或者理解为一次接触的结束) 。无论在接触的类型相对应的slot发生了改变,驱动都应该通过改变这个值来使这个slot失效。并且下一次触摸的ID值会是这次的值加1。

  • ABS_MT_POSITION_X, ABS_MT_POSITION_Y
    相对于屏幕中心的x,y坐标。

  • ABS_MT_TOUCH_MAJOR
    接触部分的长轴长度。相当于椭圆的长轴。

  • ABS_MT_TOUCH_MINOR
    接触部分的短轴长度。相当于椭圆的短轴。

  • ABS_MT_PRESSURE
    代表按下压力,有的设备不一定有

3.EV_KEY

事件的一种类型。表示是按键(不仅仅指的物理按键也包括TOUCH)事件
对应code

  • BTN_TOUCH
    触碰按键。其值是DOWN或者UP

  • BTN_TOOL_FINGER
    按键的是finger,并且其值也是DOWN或者UP

4.案例分析

两个手指分别按下,移动,然后分别抬起:

[     150.532210] EV_ABS       ABS_MT_SLOT          00000000            // 代表第一个手指,其实第一个也可以没有,有的机器就第一次0是没有这个slot
[     150.532210] EV_ABS       ABS_MT_TRACKING_ID   00000013            // 第一个手指对应的TRACKING_ID
[     150.532210] EV_ABS       ABS_MT_POSITION_X    000001ca            // 按下X轴坐标
[     150.532210] EV_ABS       ABS_MT_POSITION_Y    0000019e            // 按下Y轴坐标
[     150.532210] EV_ABS       ABS_MT_TOUCH_MAJOR   00000063            // 按下的椭圆长轴
[     150.532210] EV_KEY       BTN_TOUCH            DOWN                // 触摸按下 
[     150.532210] EV_SYN       SYN_REPORT           00000000            // 同步尾(不省略)
[     150.680774] EV_ABS       ABS_MT_POSITION_X    000001cb            
[     150.680774] EV_SYN       SYN_REPORT           00000000             rate 50
[     150.701444] EV_ABS       ABS_MT_TOUCH_MAJOR   00000069            
[     150.701444] EV_ABS       ABS_MT_SLOT          00000001            // 代表第二手指出来了
[     150.701444] EV_ABS       ABS_MT_TRACKING_ID   00000014            // 第二个手指对应TRACKING_ID
[     150.701444] EV_ABS       ABS_MT_POSITION_X    000002e8            
[     150.701444] EV_ABS       ABS_MT_POSITION_Y    0000020f            
[     150.701444] EV_ABS       ABS_MT_TOUCH_MAJOR   00000040            
[     150.701444] EV_SYN       SYN_REPORT           00000000             rate 48 
[     150.711338] EV_ABS       ABS_MT_SLOT          00000000            
[     150.711338] EV_ABS       ABS_MT_TOUCH_MAJOR   0000006a            
[     150.711338] EV_ABS       ABS_MT_SLOT          00000001            
[     150.811646] EV_ABS       ABS_MT_TOUCH_MAJOR   0000005f            
[     150.811646] EV_SYN       SYN_REPORT           00000000             rate 98
[     150.821404] EV_ABS       ABS_MT_SLOT          00000000            
[     150.821404] EV_ABS       ABS_MT_TOUCH_MAJOR   00000068            
[     150.821404] EV_ABS       ABS_MT_SLOT          00000001            
[     150.821404] EV_ABS       ABS_MT_POSITION_Y    00000210            
[     150.821404] EV_ABS       ABS_MT_TOUCH_MAJOR   00000060            
[     150.821404] EV_SYN       SYN_REPORT           00000000             rate 102
[     150.831514] EV_ABS       ABS_MT_SLOT          00000000            
[     150.831514] EV_ABS       ABS_MT_TOUCH_MAJOR   00000064            
[     150.831514] EV_ABS       ABS_MT_SLOT          00000001            
[     150.831514] EV_ABS       ABS_MT_POSITION_X    000002e9            
[     150.831514] EV_ABS       ABS_MT_TOUCH_MAJOR   00000062            
[     150.831514] EV_SYN       SYN_REPORT           00000000             rate 98
[     150.861588] EV_ABS       ABS_MT_SLOT          00000000            
[     150.861588] EV_ABS       ABS_MT_TOUCH_MAJOR   0000003e            
[     150.861588] EV_ABS       ABS_MT_SLOT          00000001            
[     150.861588] EV_ABS       ABS_MT_TOUCH_MAJOR   00000067            
[     150.861588] EV_SYN       SYN_REPORT           00000000             rate 100
[     150.871400] EV_ABS       ABS_MT_TOUCH_MAJOR   00000068            
[     150.871400] EV_SYN       SYN_REPORT           00000000             rate 101
[     150.881384] EV_ABS       ABS_MT_SLOT          00000000            // 第一个手指有事件 
[     150.881384] EV_ABS       ABS_MT_TOUCH_MAJOR   00000000            
[     150.881384] EV_SYN       SYN_REPORT           00000000             rate 100
[     150.890727] EV_ABS       ABS_MT_TRACKING_ID   ffffffff            // RACKING_ID为-1代表第一个手指抬起消失
[     150.890727] EV_ABS       ABS_MT_SLOT          00000001            // 第二个手指有事件 
[     150.890727] EV_ABS       ABS_MT_TOUCH_MAJOR   00000069            
[     150.890727] EV_SYN       SYN_REPORT           00000000             rate 107
[     151.120431] EV_ABS       ABS_MT_TRACKING_ID   ffffffff            // 第二个手指消失抬起  
[     151.120431] EV_KEY       BTN_TOUCH            UP                  // 抬起
[     151.120431] EV_SYN       SYN_REPORT           00000000             rate 98

四、调试方法

1. 开发者选项,显示点按操作反馈

通过开发者选项——显示点按操作反馈打开
P: X / Y
P就是pointers ; x 是 current number pointers, y 是 max number pointers ,这些都是指在一个完整手势中的。也就是,当同时用三手指触摸时x=y=3,而当只抬起一根手指时,当前屏幕上只有两根手指了,但是整个手势事件中最大pointers数是3,所以,x=2,y=3。显示为P:2/3

X:640.9 Y:1250.9
X是active pointer的X轴坐标;Y是active pointer的Y轴坐标。当多点触摸时只有一个pointer是激活pointerActivePointer),所以X,Y表示的就是这个ActivePointer的X和Y轴坐标。dX和dY分别代表整个手势结束后活动点(ActivePointer)在X轴和Y轴方向上起始点到终止点的差值,其中X轴上从左到右为正值,Y轴上从上到下是正值,否则为负值。

Xv:0.0 Yv:0.0
Xv和Yv分别代表了pointer当前触摸点point的X轴和Y轴方向上的速度,X轴向右,Y轴向下代表了正方向,否则为负数。多点触摸的情况下,Xv和Yv代表了ActivePointer的状态。

Prs:0.50
Prs 表示 Press,代表一个手指或者其他设备作用在屏幕上的压力值。取值范围为0~1。

Size:0.0
描述了设备的最大可探测区域上pointer touch area的近似大小,代表了屏幕被按压区域的近似大小。

2.dumpsys查看信息

通过adb shell dumpsys input打印获取input信息

wylin@wylin-virtual-machine:~$ adb shell dumpsys input
INPUT MANAGER (dumpsys input)

Input Manager State:
  Interactive: true
  System UI Visibility: 0x8008
  Pointer Speed: 0
  Pointer Gestures Enabled: true
  Show Touches: false
  Pointer Capture Enabled: false

Event Hub State:
  BuiltInKeyboardId: 2
  Devices:
    -1: Virtual
      Classes: 0x40000023
      Path: <virtual>
	...
    2: qwerty2 (aka device 0 - built-in keyboard)
      Classes: 0x0000009f
      Path: /dev/input/event1
	...
  Unattached video devices:
    <none>

Input Reader State:
  Device -1: Virtual
    ...
  Device 0: qwerty2
    Generation: 12
    IsExternal: false
    AssociatedDisplayPort: <none>
    HasMic:     false
    Sources: 0x80011107
    KeyboardType: 2
    Motion Ranges:
      ...
    Switch Input Mapper:
      SwitchValues: 0
    Keyboard Input Mapper:
      ...
    Cursor Input Mapper:
      ...
    Touch Input Mapper (mode - direct):
      ...
  Device 1: Power Button
    ...
  Device 3: goldfish_rotary
    ...
Input Classifier State:
  Motion Classifier:
    <nullptr>

Input Dispatcher State:
  DispatchEnabled: true
  DispatchFrozen: false
  InputFilterEnabled: false
  FocusedDisplayId: 0
  FocusedApplications:
    displayId=0, name='AppWindowToken{5834e9c token=Token{92ade0f ActivityRecord{f10076e u0 com.android.gallery3d/.app.GalleryActivity t24}}}', dispatchingTimeout=5000.000ms
  FocusedWindows:
    displayId=0, name='Window{6969d63 u0 com.android.gallery3d/com.android.gallery3d.app.GalleryActivity}'
  TouchStates: <no displays touched>
  Display: 0
    Windows:
      0: name='Window{acc843f u0 NavigationBar0}', displayId=0, portalToDisplayId=-1, paused=false, hasFocus=false, hasWallpaper=false, visible=true, canReceiveKeys=false, flags=0x21840068, type=0x000007e3, layer=0, frame=[0,728][480,800], globalScale=1.000000, windowScale=(1.000000,1.000000), touchableRegion=[0,728][480,800], inputFeatures=0x00000000, ownerPid=2070, ownerUid=10089, dispatchingTimeout=5000.000ms
      1: name='Window{62de810 u0 StatusBar}', displayId=0, portalToDisplayId=-1, paused=false, hasFocus=false, hasWallpaper=false, visible=true, canReceiveKeys=false, flags=0x81840048, type=0x000007d0, layer=0, frame=[0,0][480,36], globalScale=1.000000, windowScale=(1.000000,1.000000), touchableRegion=[0,0][480,36], inputFeatures=0x00000000, ownerPid=2070, ownerUid=10089, dispatchingTimeout=5000.000ms
      2: name='Window{6969d63 u0 com.android.gallery3d/com.android.gallery3d.app.GalleryActivity}', displayId=0, portalToDisplayId=-1, paused=false, hasFocus=true, hasWallpaper=false, visible=true, canReceiveKeys=true, flags=0x01810120, type=0x00000001, layer=0, frame=[0,0][480,800], globalScale=1.000000, windowScale=(1.000000,1.000000), touchableRegion=[0,0][480,800], inputFeatures=0x00000000, ownerPid=25962, ownerUid=10091, dispatchingTimeout=5000.000ms
      3: name='Window{14fdc42 u0 com.android.systemui.ImageWallpaper}', displayId=0, portalToDisplayId=-1, paused=false, hasFocus=false, hasWallpaper=false, visible=false, canReceiveKeys=false, flags=0x00014318, type=0x000007dd, layer=0, frame=[0,0][971,800], globalScale=1.000000, windowScale=(1.280124,1.280000), touchableRegion=[0,0][759,625], inputFeatures=0x00000000, ownerPid=2070, ownerUid=10089, dispatchingTimeout=5000.000ms
  Global monitors in display 0:
    0: 'PointerEventDispatcher0 (server)', 
  RecentQueue: length=10
    KeyEvent(deviceId=0, source=0x00000101, displayId=0, action=DOWN, flags=0x00000008, keyCode=26, scanCode=116, metaState=0x00000000, repeatCount=0), policyFlags=0x02000000, age=16484555.0ms
	...
    MotionEvent(deviceId=0, source=0x00001002, displayId=0, action=DOWN, actionButton=0x00000000, flags=0x00000000, metaState=0x00000000, buttonState=0x00000000, classification=NONE, edgeFlags=0x00000000, xPrecision=68.3, yPrecision=41.0, pointers=[0: (205.0, 494.0)]), policyFlags=0x62000000, age=15328.5ms
  PendingEvent: <none>
  InboundQueue: <empty>
  ReplacedKeys: <empty>
  Connections:
    0: channelName='PointerEventDispatcher0 (server)', windowName='PointerEventDispatcher0 (server)', status=NORMAL, monitor=true, inputPublisherBlocked=false
      OutboundQueue: <empty>
      WaitQueue: <empty>
    1: channelName='2c87510 AssistPreviewPanel (server)', windowName='2c87510 AssistPreviewPanel (server)', status=NORMAL, monitor=false, inputPublisherBlocked=false
      OutboundQueue: <empty>
      WaitQueue: <empty>
    2: channelName='acc843f NavigationBar0 (server)', windowName='acc843f NavigationBar0 (server)', status=NORMAL, monitor=false, inputPublisherBlocked=false
      OutboundQueue: <empty>
      WaitQueue: <empty>
	...
      OutboundQueue: <empty>
      WaitQueue: <empty>
  AppSwitch: not pending
  Configuration:
    KeyRepeatDelay: 50.0ms
    KeyRepeatTimeout: 500.0ms

该命令会打印出Input系统中的一些状态
比如EventHub中会显示当前监听的设备,如上面显示
可以看到触摸屏响应的是/dev/input/event1,它的对应device nameqwerty2,可以在dumpsys根据name找到它的信息,如XScale: 1.000 并且这里可以看到该设备目前有几个可以分发的Mapper,用于通过该节点读出后可能分发到的几个地方

InputDispatcher中显示Display:0 表示当前的屏幕,已经当前Focused的窗口和应用,Windows中显示当前存在的连接,用于分发触摸事件
RecentQueue:显示的是过去消费掉的事件

这里需要注意有三个队列比较重要

  • inboundQueue:还未加工的数据
  • outboundQueue:已经在InputDispatcher中做了处理,等待发送
  • waitQueue:已经通过socket发送了,等待响应
    InboundQueue用于从InputReader处理添加到InboundQueue队列中,而OutboundQueueWaitQueue放在不同的Connections中,说明是分发到哪个连接中,哪里就会进行处理
    最后会放在WaitQueue中,当事件由窗口处理完成后,再通知到这里将其移除,而这里如果出现Input ANR的话,可以看下这里是否有未消费的Input事件,可能就是这的原因,然后再根据这个队列哪里添加哪里移除在对应代码段中排查问题 在这里插入图片描述
3.常见IMS问题排查流程
  1. 驱动层排查 通过adb shell getevent确认硬件驱动已上报事件,未上报需找驱动,硬件确认按键或屏
  2. frameworkIMS->APP排查派发
    通过dumpsys input查看input事件派发情况
  3. APP层 通过log和堆栈检查派发

如果你看到了这里,觉得文章写得不错就给个赞呗?
更多Android进阶指南 可以扫码 解锁更多Android进阶资料


在这里插入图片描述
敲代码不易,关注一下吧。ღ( ´・ᴗ・` )

  • 27
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值