Android13 按键kl文件优先级详解

Android13 按键kl文件优先级详解

本文专门讲解一下Android 按键接收和处理作用的键值kl文件的选择过程,有需要的可以了解。

本文具体逻辑和调试是使用Android13代码和系统。

一、前言

1、Android 键值相关的"idc"、“kl”、"kcm"基本定义和作用:

"idc"(Input Device Configuration)为输入设备配置文件,
它包含设备具体的配置属性,这些属性影响输入设备的行为。对于touch screen设备,总是需要一个idc文件来定义其行为。

"kl"(key layout)文件是一个键值布局映射文件,是标准linux与anroid的键值映射文件,

"kcm"(key code mapping)文件意为按键字符映射文件,作用是将 Android按键代码与修饰符的组合映射到 Unicode字符

2、kl文件选择的大概优先级

本文kl流程分析主要参考:https://blog.csdn.net/kc58236582/article/details/52199274

Android kl(key layout)文件是一个映射文件,是标准linux与anroid的键值映射文件,
kl文件可以有很多个,但是它有一个使用优先级:

/system/usr/keylayout/Vendor_XXXX_Product_XXXX_Version_XXXX.kl

/system/usr/keylayout/Vendor_XXXX_Product_XXXX.kl

/system/usr/keylayout/DEVICE_NAME.kl

/data/system/devices/keylayout/Vendor_XXXX_Product_XXXX_Version_XXXX.kl

/data/system/devices/keylayout/Vendor_XXXX_Product_XXXX.kl

/data/system/devices/keylayout/DEVICE_NAME.kl

/system/usr/keylayout/Generic.kl  //**主要使用

/data/system/devices/keylayout/Generic.kl

上面这个优先级也是其他文章提供的,并不准确,估计是分析的代码是Android9或者更早的系统代码。

但是只要你看完后面的分析,自己可以有比较全面认知。

3、adb查看当前按键使用的kl文件

(1)主要命令:
1、通过"getevent"查看事件节点和节点名称;
2、通过"dumpsys input"查看节点的具体使能的kl文件;

(2)命令示例:
①getevent
130|atom:/ # getevent
//(1)这里可以查看到按键的eventX节点,和节点在内核上的命名名称
add device 1: /dev/input/event2
  name:     "aw8624_haptic"
add device 2: /dev/input/event0
  name:     "ACCDET"
add device 3: /dev/input/event3
  name:     "fts_ts"
add device 4: /dev/input/event1
  name:     "mtk-kpd"

//(2)按下音量减按键,这里第二列的0001 对应的数据才是有用的数据,可以看到音量减键对应的按键键值是0x72
/dev/input/event1: 0001 0072 00000001 //(3)1是按下
/dev/input/event1: 0000 0000 00000000
/dev/input/event1: 0001 0072 00000000 //(4)0是抬起
/dev/input/event1: 0000 0000 00000000

//(5)按下音量加按键,可以看到音量加键对应的按键键值是0x73
/dev/input/event1: 0001 0073 00000001
/dev/input/event1: 0000 0000 00000000
/dev/input/event1: 0001 0073 00000000
/dev/input/event1: 0000 0000 00000000

上面可以看到节点的名称和某个按键在上层的键值。

② dumpsys input
atom:/ # dumpsys input
INPUT MANAGER (dumpsys input)

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

Event Hub State: //(1)事件状态信息是主要关注的
  BuiltInKeyboardId: -2
  Devices: //(2)Devices里面的每个信息都是对应不同的节点信息
    -1: Virtual
      Classes: 0x40000023
      Path: <virtual> (3)关注Path字符串,就是节点的位置,这里是虚拟,不清楚具体意义
      Enabled: true
      Descriptor: a718a782d34bc767f4689c232d64d527998ea7fd
      Location:
      ControllerNumber: 0
      UniqueId: <virtual>
      Identifier: bus=0x0000, vendor=0x0000, product=0x0000, version=0x0000
      KeyLayoutFile: /system/usr/keylayout/Generic.kl
      KeyCharacterMapFile: /system/usr/keychars/Virtual.kcm
      ConfigurationFile:
      HaveKeyboardLayoutOverlay: false
      VideoDevice: <none>
    1: aw8624_haptic
      Classes: 0x00000200
      Path: /dev/input/event2
      Enabled: true
      Descriptor: 65195a4ab35c59e79bbba55177be90fc42ed3ae6
      Location:
      ControllerNumber: 0
      UniqueId:
      Identifier: bus=0x0000, vendor=0x0000, product=0x0000, version=0x0000
      KeyLayoutFile:
      KeyCharacterMapFile:
      ConfigurationFile:
      HaveKeyboardLayoutOverlay: false
      VideoDevice: <none>
    2: ACCDET
      Classes: 0x00000081
      Path: /dev/input/event0
      Enabled: true
      Descriptor: 1c78f7e0d16d4dbc8d3ab93943523f379203f90b
      Location:
      ControllerNumber: 0
      UniqueId:
      Identifier: bus=0x0019, vendor=0x0000, product=0x0000, version=0x0000
      KeyLayoutFile: /system/usr/keylayout/Generic.kl
      KeyCharacterMapFile: /system/usr/keychars/Generic.kcm
      ConfigurationFile:
      HaveKeyboardLayoutOverlay: false
      VideoDevice: <none>
    3: fts_ts
      Classes: 0x00000015
      Path: /dev/input/event3
      Enabled: true
      Descriptor: a1cc21cba608c55d28d6dd2b1939004df0e0c756
      Location:
      ControllerNumber: 0
      UniqueId:
      Identifier: bus=0x0018, vendor=0x0000, product=0x0000, version=0x0000
      KeyLayoutFile: /system/usr/keylayout/Generic.kl
      KeyCharacterMapFile: /system/usr/keychars/Generic.kcm
      ConfigurationFile:
      HaveKeyboardLayoutOverlay: false
      VideoDevice: <none>
	  4: mtk-kpd //(4)按键事件的节点命名名称
      Classes: 0x00000001
      Path: /dev/input/event1 //(5)按键事件的节点位置,这个才是主要的,名称可以不看,但是节点必须找对
      Enabled: true
      Descriptor: f0d2e427e7a05eb6d316f5e14800c5ac7b6aee79
      Location:
      ControllerNumber: 0
      UniqueId:
      Identifier: bus=0x0019, vendor=0x2454, product=0x6500, version=0x0010 //(6)各版本号,寻找kl使用到
      KeyLayoutFile: /system/usr/keylayout/mtk-kpd.kl //(7)实际起作用的kl文件
      KeyCharacterMapFile: /system/usr/keychars/Generic.kcm
      ConfigurationFile:
      HaveKeyboardLayoutOverlay: false
      VideoDevice: <none>
...

上面可以看到某个按键event节点的具体使能的kl和kcm文件。

这里是使用的mtk-kpd.kl 文件,可以看到其他有些event使用的是默认的 Generic.kl 文件。

二、选择过程分析

1、选择kl的主要相关文件

选择kl文件逻辑都是在cpp文件中的。

framework\native\services\inputflinger\reader\EventHub.cpp
framework\native\libs\input\InputDevice.cpp  //kl选择的主要逻辑
framework\native\libs\input\Keyboard.cpp

2、kl文件选择主要流程

这个要讲清楚还是比较麻烦的,这里只能介绍中间某一段。
最开始哪里开始和最后哪里结束,还真不好介绍,因为本来对c不熟悉,这里都是强行看到逻辑代码。

建议流程:

	EventHub::getEvents 
	-> EventHub::scanDevicesLocked 
	-> EventHub::scanDirLocked 
	-> EventHub::openDeviceLocked
	-> EventHub::loadKeyMapLocked
		-> Keyboard::KeyMap::load
		-> KeyMap::loadKeyLayout
		-> KeyMap.getPath(deviceIdentifier, name, InputDeviceConfigurationFileType::KEY_LAYOUT) //加载kl文件
			-> InputDevice.getInputDeviceConfigurationFilePathByDeviceIdentifier //文件路径拼接和选择
			-> InputDevice.getInputDeviceConfigurationFilePathByName //目录遍历,匹配kl
		-> KeyMap::loadKeyLayout //未匹配到kl的情况,继续往下走
		-> Keyboard.probeKeyMap(deviceIdentifier, "Generic") // 使用Generic.kl

不同的Android系统可能有差别,我看上面参考网址的是Android6的代码有些定义和现实是不一样的,但是总体流程差不多。

其实懂得大致流程就ok了。具体代码需要自己添加日志打印查看流程,默认打印一点点。

下面是主要过程代码分析:

(1)EventHub 的分析
static const char* DEVICE_INPUT_PATH = "/dev/input"; //系统input子系统目录,按键事件的节点都在这个目录下

//1、这个方法系统系统和每次触发按键都会执行
size_t EventHub::getEvents(int timeoutMillis, RawEvent* buffer, size_t bufferSize) {
    ALOG_ASSERT(bufferSize >= 1);
    ALOGI("getEvents "); //2、自己添加的打印,发现系统启动一次和每次按键触摸等事件都有打印
	...
	for (;;) {
        nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);
        if (mNeedToScanDevices) {
            mNeedToScanDevices = false; //3、设置只加载一次配置文件
            scanDevicesLocked();//4、扫描加载配置文件
            mNeedToSendFinishedDeviceScan = true;
        }
	}
	...
}

//5、扫描加载节点函数
void EventHub::scanDevicesLocked() {
    status_t result;
    std::error_code errorCode;

    if (std::filesystem::exists(DEVICE_INPUT_PATH, errorCode)) {
        result = scanDirLocked(DEVICE_INPUT_PATH);//6、扫描目录 "/dev/input"
        if (result < 0) {
            ALOGE("scan dir failed for %s", DEVICE_INPUT_PATH);
        }
    } else {
        if (errorCode) {
            ALOGW("Could not run filesystem::exists() due to error %d : %s.", errorCode.value(),
                  errorCode.message().c_str());
        }
    }
。。。
}

//7、扫描加载界面目录函数,有兴趣的可以打印下这个 entry.path() 是否某个某个具体的节点信息
status_t EventHub::scanDirLocked(const std::string& dirname) {
    for (const auto& entry : std::filesystem::directory_iterator(dirname)) {
        openDeviceLocked(entry.path());
    }
    return 0;
}


//8、加载界面目录函数,里面具体的判断非常多,这里只展示主要部分
void EventHub::openDeviceLocked(const std::string& devicePath) {
    // If an input device happens to register around the time when EventHub's constructor runs, it
    // is possible that the same input event node (for example, /dev/input/event3) will be noticed
    // in both 'inotify' callback and also in the 'scanDirLocked' pass. To prevent duplicate devices
    // from getting registered, ensure that this path is not already covered by an existing device.

    ALOGV("Opening device: %s", devicePath.c_str()); // 9、Android13 ALOGV是打印不出来的,如果需要查看信息可以修改为 ALOGI

...

    ALOGV("add device %d: %s\n", deviceId, devicePath.c_str()); //这里的ALOGV 也是同理,所以Android13 上加载流程日志是超少的
    ALOGV("  bus:        %04x\n"
          "  vendor      %04x\n"
          "  product     %04x\n"
          "  version     %04x\n",
          identifier.bus, identifier.vendor, identifier.product, identifier.version);
    ALOGV("  name:       \"%s\"\n", identifier.name.c_str());

    // Load the configuration file for the device.
    device->loadConfigurationLocked(); //10、这里是加载kcm配置文件的,有兴趣可以看,这里不去追

	。。。

    // Configure virtual keys.
    if ((device->classes.test(InputDeviceClass::TOUCH))) {
        // Load the virtual keys for the touch screen, if any.
        // We do this now so that we can make sure to load the keymap if necessary.
        bool success = device->loadVirtualKeyMapLocked(); // 11、加载虚拟按键配置,这里也不去追
        if (success) {
            device->classes |= InputDeviceClass::KEYBOARD;
        }
    }

    // Load the key map.
    // We need to do this for joysticks too because the key layout may specify axes, and for
    // sensor as well because the key layout may specify the axes to sensor data mapping.
    status_t keyMapStatus = NAME_NOT_FOUND;
    if (device->classes.any(InputDeviceClass::KEYBOARD | InputDeviceClass::JOYSTICK |
                            InputDeviceClass::SENSOR)) {
        // Load the keymap for the device.
        keyMapStatus = device->loadKeyMapLocked(); //** 12、这里加载才是加载按键配置
    }


}

//13、这里调用的是 Keyboard::KeyMap::load 
status_t EventHub::Device::loadKeyMapLocked() {
    return keyMap.load(identifier, configuration.get());
}



下面继续追踪Keyboard.cpp的代码。

(2)Keyboard 的分析

//1、加载配置
status_t KeyMap::load(const InputDeviceIdentifier& deviceIdentifier,
        const PropertyMap* deviceConfiguration) {
    // Use the configured key layout if available.
    if (deviceConfiguration) {
        String8 keyLayoutName;
        if (deviceConfiguration->tryGetProperty(String8("keyboard.layout"),
                keyLayoutName)) {
            status_t status = loadKeyLayout(deviceIdentifier, keyLayoutName.c_str());//2、加载配置具体信息
            if (status == NAME_NOT_FOUND) {
                ALOGE("Configuration for keyboard device '%s' requested keyboard layout '%s' but "
                        "it was not found.",
                        deviceIdentifier.name.c_str(), keyLayoutName.string());
            }
        }
...
    //      generic key map to use (US English, etc.) for typical external keyboards.
    if (probeKeyMap(deviceIdentifier, "Generic")) { //3、如果没找到kl文件,就是用 Generic.kl,这个文件正常是肯定存在的
        return OK;
    }

}

//4、加载配置具体信息
status_t KeyMap::loadKeyLayout(const InputDeviceIdentifier& deviceIdentifier,
        const std::string& name) {
    std::string path(getPath(deviceIdentifier, name, InputDeviceConfigurationFileType::KEY_LAYOUT)); //5、getPath 函数获取路径
    if (path.empty()) {
        return NAME_NOT_FOUND;
    }

    base::Result<std::shared_ptr<KeyLayoutMap>> ret = KeyLayoutMap::load(path);
    if (ret.ok()) {
        keyLayoutMap = *ret;
        keyLayoutFile = path;
        return OK;
    }
。。。
}

//6、getPath 获取路径函数
static std::string getPath(const InputDeviceIdentifier& deviceIdentifier, const std::string& name,
                           InputDeviceConfigurationFileType type) {
    return name.empty()
            ? getInputDeviceConfigurationFilePathByDeviceIdentifier(deviceIdentifier, type) //7、里面包含目录遍历
            : getInputDeviceConfigurationFilePathByName(name, type); //8、具体目录的查找
}


//9、无论是目录遍历函数还是具体目录查找函数都是在 InputDevice 里面实现的,稍后追踪

//10、上面load函数没找到kl的情况,调用的方法
bool KeyMap::probeKeyMap(const InputDeviceIdentifier& deviceIdentifier,
        const std::string& keyMapName) {
    if (!haveKeyLayout()) {
        loadKeyLayout(deviceIdentifier, keyMapName); //11、最后还是调用了getPath,正常情况就是它了!
    }
    if (!haveKeyCharacterMap()) {
        loadKeyCharacterMap(deviceIdentifier, keyMapName);
    }
    return isComplete();
}


接上面9的点,继续往下追踪 InputDevice.cpp

(3)InputDevice 的分析
//1、目录遍历函数
std::string getInputDeviceConfigurationFilePathByDeviceIdentifier(
        const InputDeviceIdentifier& deviceIdentifier, InputDeviceConfigurationFileType type,
        const char* suffix) {
    if (deviceIdentifier.vendor !=0 && deviceIdentifier.product != 0) {
        if (deviceIdentifier.version != 0) {
            // Try vendor product version.
			//2、看看看这里:Vendor_X_Product_X_Version_X的查看
			//3、getInputDeviceConfigurationFilePathByName 具体目录的查找后续分析
            std::string versionPath =
                    getInputDeviceConfigurationFilePathByName(StringPrintf("Vendor_%04x_Product_%"
                                                                           "04x_Version_%04x%s",
                                                                           deviceIdentifier.vendor,
                                                                           deviceIdentifier.product,
                                                                           deviceIdentifier.version,
                                                                           suffix),
                                                              type);
            if (!versionPath.empty()) { //4、Version找到了就返回
                return versionPath;
            }
        }

        // Try vendor product.
		 //5、只找Vendor和Product的情况
        std::string productPath =
                getInputDeviceConfigurationFilePathByName(StringPrintf("Vendor_%04x_Product_%04x%s",
                                                                       deviceIdentifier.vendor,
                                                                       deviceIdentifier.product,
                                                                       suffix),
                                                          type);
        if (!productPath.empty()) {
            return productPath;//6、Version找到了就返回
        }
    }

    // Try device name.
	//7、Version和Vendor都找不到,就找节点命名名称
    return getInputDeviceConfigurationFilePathByName(deviceIdentifier.getCanonicalName() + suffix,
                                                     type);
}


//8、具体目录的查找函数
std::string getInputDeviceConfigurationFilePathByName(
        const std::string& name, InputDeviceConfigurationFileType type) {
    // Search system repository.
    std::string path;

    // Treblized input device config files will be located /product/usr, /system_ext/usr,
    // /odm/usr or /vendor/usr.
    // These files may also be in the com.android.input.config APEX.
    //9、看看看这里,根目录的遍历
	const char* rootsForPartition[]{
            "/product",
            "/system_ext",
            "/odm",
            "/vendor",
            "/apex/com.android.input.config/etc",
            getenv("ANDROID_ROOT"), //10、这个是 system目录
    };
    for (size_t i = 0; i < size(rootsForPartition); i++) {
        if (rootsForPartition[i] == nullptr) {
            continue;
        }
        path = rootsForPartition[i];//11、遍历根目录
        path += "/usr/";//11、遍历根目录+usr
        appendInputDeviceConfigurationFileRelativePath(path, name, type);// 12、拼接path路径:path + name Vendor_xxx那些,type .文件后缀
。。。
        return path;//
        }
    }

    // Search user repository.
    // TODO Should only look here if not in safe mode.//13、如果系统目录找不到,就用date下面的,这里也说了会不太安全
    path = "";
    char *androidData = getenv("ANDROID_DATA"); //14、根目录data
    if (androidData != nullptr) {
        path += androidData;
    }
    path += "/system/devices/"; //14、目录data/system/devices/
    appendInputDeviceConfigurationFileRelativePath(path, name, type);// 15、拼接path路径
。。。
        return path;
}

// 16、拼接path路径函数
static void appendInputDeviceConfigurationFileRelativePath(std::string& path,
        const std::string& name, InputDeviceConfigurationFileType type) {
	//17、path 根目录 + CONFIGURATION_FILE_DIR 文件类型 + 文件名称 + 后缀
	path += CONFIGURATION_FILE_DIR[static_cast<int32_t>(type)];
    path += name;
    path += CONFIGURATION_FILE_EXTENSION[static_cast<int32_t>(type)];
}

// 18、文件类型,不用的文件类型是放在不同的目录下的
static const char* CONFIGURATION_FILE_DIR[] = {
        "idc/", //idc文件目录
        "keylayout/", //kl文件目录
        "keychars/", //kcm文件目录
};

// 19、后缀
static const char* CONFIGURATION_FILE_EXTENSION[] = {
        ".idc",
        ".kl",
        ".kcm",
};

到这里基本代码是已经分析完成了,不清楚的可以自己看下具体代码了。代码挺多估计没几个人会仔细看!

按键相关的代码已上传,有兴趣的可以下载查看:

https://download.csdn.net/download/wenzhi20102321/88366484

3、验证kl选择次序是否正常

直接修改设备当前选择的kl文件名称,或者cp一份kl文件到优先级高的目录,需要重启才能生效。
dumpsys input 查看某类按键键值当前使用的kl文件。

比如上面 dumpsys input 的event1的信息:

  4: mtk-kpd //(1)按键事件的节点命名名称
      Classes: 0x00000001
      Path: /dev/input/event1 //(1)按键事件的节点位置,这个才是主要的,名称可以不看,但是节点必须找对
      Identifier: bus=0x0019, vendor=0x2454, product=0x6500, version=0x0010 //(3)各版本号,寻找kl使用到
      KeyLayoutFile: /system/usr/keylayout/mtk-kpd.kl //(4)实际起作用的kl文件

获取到有用的信息:

event1 按键,节点在底层的名称是 mtk-kpd
event1 按键的版本信息 Identifier:vendor=0x2454, product=0x6500, version=0x0010
event1 按键的实际起作用的kl文件目录 KeyLayoutFile:/system/usr/keylayout/mtk-kpd.kl

(1)复制kl文件到其他路径进行测试

把 mtk-kpd.kl复制到 /product/usr/keylayout/

重启后,dumpsys input ,查看event1 的信息可以看到,实际起作用的kl文件 :

 KeyLayoutFile: /product/usr/keylayout/mtk-kpd.kl

其他路径我就不一一测试了,有兴趣的可以尝试。

需要注意的是,kl遍历的文件目录 除了 /system/usr/keylayout/这个目录是系统生成的,

其他所有的目录我看了一下都是不存在的,除非你手动创建或者复制文件的时候创建。

所以未特别适配kl文件的系统,kl文件基本都是在 /system/usr/keylayout/ 这个目录,并且里面是有非常多的没啥作用的kl文件。

(2)复制一个version文件到同级目录下测试

保存 /product/usr/keylayout/mtk-kpd.kl 的情况下,复制一个version文件到当前目录下
Version文件: Vendor_0001_Product_0001_Version_0100.kl

重启后,dumpsys input ,查看event1 的信息可以看到,实际起作用的kl文件 :

 KeyLayoutFile: /product/usr/keylayout/Vendor_0001_Product_0001_Version_0100.kl

到这里可以看到,无论是修改文件路径还是文件名称都是有作用的。

(3)其他情况
①Vendor、Product、Version是 0000 的情况

并不是所有的 Version 都有数值的,这种情况如果添加 Vendor_0000_Product_0000_Version_0000.kl
或者 Vendor_0_Product_0_Version_0.kl 那么会选择优先选择这个文件吗?

是不会选择Version_0000/0的,这个我是有测试过的。估计这里0只是没到的检测到Version的默认显示,并不是真正的字符串。

②Generic.kl 有在高优先级目录,会先选哪个?

其实 Generic.kl 是最后无奈的选择!

优先从所有目录下查看 Version文件和节点名称文件kl,如果没有找到最后在所有目录找一遍 Generic.kl 文件。

如果没有Version文件和节点名称文件kl的情况,
高优先级目录有 Generic.kl 文件就直接使用这个文件,不用再去 /system/usr/keylayout/ 查找。

③修改kl文件里面的内容进行测试

上面都是在命令行查看实际使用的kl文件,但是想要测试是否真实准确,其实可以修改kl文件里面的内容确认。

修改:/system/usr/keylayout/mtk-kpd.kl 

key 115      VOLUME_UP //音量加
key 114      VOLUME_UP //音量减,修改成音量加功能字符串

实现修改的方式可以pull文件后再push进去或者使用busybox vi XXX 功能,都是需要root权限的!

这里只是测试验证功能,实际没啥这样改的场景哈。

修改后重启设备测试效果,查看是否符合预期,复制到其他文件尝试。

三、总结

1、kl文件具体选择优先级:

不同的Android系统选择可能不一样,这里以Android13上的代码进行举例:

	"/product/usr/keylayout/name.kl",
	"/system_ext/usr/keylayout/name.kl",
	"/odm/usr/keylayout/name.kl",
	"/vendor/usr/keylayout/name.kl",
	"/apex/com.android.input.config/etc/usr/keylayout/name.kl",
	"/system/usr/keylayout/name.kl"
	"/data/system/devices/keylayout/name.kl"

name.kl 名称的优先级:

Vendor_4x_Product_4x_Version_4x.kl
Vendor_4x_Product_4x.kl
eventName.kl //节点在底层的命名名称
Generic.kl

名称name的优先级选择的代码在
InputDevice.getInputDeviceConfigurationFilePathByDeviceIdentifier函数中体现;

目录path优先级选择的代码在
InputDevice.getInputDeviceConfigurationFilePathByName函数中体现。

优先从所有目录下查看 Version文件和节点名称文件kl,
如果没有找到最后在所有目录找一遍 Generic.kl 文件。

2、其他

定义eventX节点的名称是在内核代码中决定的,具体选择哪个kl文件的逻辑是系统framework中。

并且可以在framework中进行修改kl文件选择的顺序。

这些都是Android系统的流程,正常情况有个了解就行了,一般不需要修改,
但是如果真的出现问题或有相关修改需求还是要分析适配的。

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

峥嵘life

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值