OrangeFox 适配 Redmi K60 Pro:从源头解决 Recovery 点击卡顿和振动问题

日期:2026-05-10
设备:Redmi K60 Pro,代号 socrates
系统环境:WSL Ubuntu,源码目录 /home/builduser/fox_12.1
目标:OrangeFox/TWRP Recovery

背景

在 Redmi K60 Pro 的 OrangeFox 适配过程中,Recovery 已经可以正常刷入和启动,但交互体验有一个很明显的问题:切换选项卡、点击按钮等操作非常卡顿,像是每次点击都会被阻塞几秒;但滑动列表并不算卡,最多只是有掉帧感。

这个现象很关键。列表滑动和按钮点击走的不是完全一样的路径,如果 GPU、触摸驱动或整体渲染性能有问题,滑动列表通常也会一起严重卡顿。现在只有“点击类操作”特别卡,就要优先怀疑点击事件里附带的同步动作,比如振动、声音、存储写入、页面动作前置检查等。

最终定位到的问题是:Recovery 点击时触发了振动逻辑,而当前设备树启用了 AIDL haptics 路径。这个路径会同步等待一个在 Recovery 环境里不存在或没有正确启动的 vibrator 服务,导致 UI 线程被阻塞。

现象和第一轮判断

点击按钮或切换选项卡时,logcat 中可以看到类似信息:

Waiting for service 'android.hardware.vibrator.IVibrator/vibratorfeature'...
Service android.hardware.vibrator.IVibrator/vibratorfeature didn't start. Returning NULL

这条日志几乎已经把问题说透了:每次点击都会触发一次振动,振动代码去 ServiceManager 里同步取 AIDL vibrator service;但 Recovery 环境没有这个服务,于是等待超时。用户看到的就是按钮响应卡顿。

为了验证这个判断,先做了一个最小化修复:在设备树中关闭 haptics。

TW_NO_HAPTICS := true

重新构建刷入后,点击卡顿消失。这一步不是最终方案,但它证明了问题链路:

点击按钮 -> 触发振动 -> 等待不存在的 AIDL vibrator service -> UI 阻塞 -> 体感卡顿

也就是说,真正要修的是振动路径,而不是触摸、显示或 OrangeFox 主题层。

为什么不直接启动 Xiaomi/QCOM AIDL 振动 HAL

理论上可以把系统里的 Xiaomi/QCOM vibrator HAL 搬进 Recovery,让 android.hardware.vibrator.IVibrator/vibratorfeature 这个服务真的跑起来。但这个方向风险比较高:

  1. Recovery 环境比 Android system/vendor 环境小很多,很多 HAL 的依赖不一定齐全。
  2. AIDL HAL 需要正确的 service binary、init rc、VINTF manifest、权限和依赖库。
  3. 当前卡顿来自同步等待服务,任何服务名、manifest 或 init 时序不一致,都会让卡顿回来。
  4. 为了一个点击振动引入完整 binder HAL,维护成本偏高。

Recovery 的需求其实很简单:按钮点击时让马达短震一下。既然内核已经暴露了 haptics 设备,就没有必要绕一大圈去起完整 HAL。

关键发现:设备已经暴露 input force-feedback 节点

在设备上检查 input 设备,发现了 qcom-hv-haptics

N: Name="qcom-hv-haptics"
H: Handlers=event1
B: EV=200001
B: FF=120270000 0

getevent -il /dev/input/event1 也能看到它支持 force-feedback:

name: "qcom-hv-haptics"
events:
  FF: FF_RUMBLE FF_PERIODIC FF_CONSTANT FF_DAMPER FF_CUSTOM FF_GAIN

这说明内核侧的振动能力已经在那里了。QCOM 的 HAL 最终也会落到类似的底层设备控制上。对 Recovery 来说,直接通过 Linux input force-feedback 驱动它,是更短、更稳定的路径。

最终方案就是:保留 TWRP/OrangeFox 原有的 vibrate() 调用入口,但不要再走 AIDL binder service;改成在普通 sysfs 振动节点不存在时,自动扫描 /dev/input/event*,找到 qcom-hv-hapticsqti-haptics,通过 EVIOCSFF 上传振动效果,再写入 EV_FF 事件播放。

最终实现思路

实现放在 bootable/recovery/minuitwrp/events.cpp,核心逻辑分成三层:

  1. 打开 haptics input 设备,并缓存 fd。
  2. 根据设备能力选择 FF_CONSTANTFF_RUMBLE
  3. 每次振动前移除旧 effect,上传新 effect,然后播放。

关键代码如下,省略了非核心分支:

static int qcom_input_ff_open()
{
    if (qcom_haptics_fd >= 0)
        return qcom_haptics_fd;
    if (qcom_haptics_scanned)
        return -1;

    qcom_haptics_scanned = true;

    DIR *dir = opendir("/dev/input");
    if (!dir)
        return -1;

    struct dirent *de;
    while ((de = readdir(dir))) {
        if (strncmp(de->d_name, "event", 5))
            continue;

        int fd = openat(dirfd(dir), de->d_name, O_RDWR | O_CLOEXEC);
        if (fd < 0)
            continue;

        char name[64] = {0};
        if (ioctl(fd, EVIOCGNAME(sizeof(name)), name) < 0) {
            close(fd);
            continue;
        }

        if (strcmp(name, "qcom-hv-haptics") && strcmp(name, "qti-haptics")) {
            close(fd);
            continue;
        }

        unsigned long ff_bitmask[NBITS(FF_MAX)];
        memset(ff_bitmask, 0, sizeof(ff_bitmask));
        if (ioctl(fd, EVIOCGBIT(EV_FF, sizeof(ff_bitmask)), ff_bitmask) < 0) {
            close(fd);
            continue;
        }

        if (test_bit(FF_CONSTANT, ff_bitmask) || test_bit(FF_RUMBLE, ff_bitmask)) {
            qcom_haptics_fd = fd;
            qcom_haptics_uses_rumble = !test_bit(FF_CONSTANT, ff_bitmask);
            closedir(dir);
            return qcom_haptics_fd;
        }

        close(fd);
    }

    closedir(dir);
    return -1;
}

播放振动时,用标准 input force-feedback ioctl:

static int qcom_input_ff_vibrate(int timeout_ms)
{
    int fd = qcom_input_ff_open();
    if (fd < 0)
        return -1;

    qcom_input_ff_remove_effect();

    struct ff_effect effect;
    memset(&effect, 0, sizeof(effect));
    effect.id = -1;
    effect.replay.length = timeout_ms;
    effect.replay.delay = 0;

    if (qcom_haptics_uses_rumble) {
        effect.type = FF_RUMBLE;
        effect.u.rumble.strong_magnitude = 0x7fff;
    } else {
        effect.type = FF_CONSTANT;
        effect.u.constant.level = 0x5fff;
    }

    if (ioctl(fd, EVIOCSFF, &effect) < 0)
        return -1;

    qcom_haptics_effect_id = effect.id;

    struct input_event play;
    memset(&play, 0, sizeof(play));
    play.type = EV_FF;
    play.code = qcom_haptics_effect_id;
    play.value = 1;

    return write(fd, &play, sizeof(play)) == sizeof(play) ? 0 : -1;
}

最后把它接回原来的 vibrate() fallback 链路:

if (std::ifstream(LEDS_HAPTICS_ACTIVATE_FILE).good()) {
    write_to_file(LEDS_HAPTICS_DURATION_FILE, tout);
    write_to_file(LEDS_HAPTICS_ACTIVATE_FILE, "1");
} else if (qcom_input_ff_vibrate(timeout_ms) != 0) {
    write_to_file(VIBRATOR_TIMEOUT_FILE, tout);
}

这样做的好处是,不破坏其他设备原本能用的 sysfs haptics。如果设备有 /sys/class/leds/vibrator/activate,仍然优先走旧逻辑;只有旧逻辑不可用时,才尝试 socrates 这类 QCOM input FF 设备。

BoardConfig 调整

设备树里不再关闭全部振动,也不再启用 AIDL haptics:

# Haptics
# Socrates exposes qcom-hv-haptics as an input force-feedback device in recovery.
# Use minuitwrp's direct input-ff fallback instead of waiting on an absent AIDL HAL.
# TW_SUPPORT_INPUT_AIDL_HAPTICS := true
# TW_SUPPORT_INPUT_AIDL_HAPTICS_FQNAME := "IVibrator/vibratorfeature"
# TW_SUPPORT_INPUT_AIDL_HAPTICS_FIX_OFF := true

这里的重点是:不要再定义 TW_NO_HAPTICS := true,否则所有按钮振动都会在编译期被去掉;也不要启用 TW_SUPPORT_INPUT_AIDL_HAPTICS,否则又会回到等待 binder service 的老问题。

TW_NO_HAPTICS := true 已验证可彻底消除点击卡顿(证明问题在振动路径),但代价是失去全部振动反馈。最终方案不保留该行。

init rc 调整

之前 init 中尝试启动过 vibratorfeature-hal-service。既然现在 Recovery 直接驱动 qcom-hv-haptics,就不需要再主动启动这个服务:

# Recovery drives qcom-hv-haptics directly through input force-feedback.
# Do not start the absent Xiaomi AIDL vibrator service here.
# start vibratorfeature-hal-service

这一步的目的不是“让振动可用”,而是避免未来有人看到 vendor 里有 vibratorfeature rc 后又把服务启动回来,导致 binder 路径重新混进来。

构建过程中的一个坑:ramdisk 里的 libminuitwrp 可能是旧的

第一次构建 direct input FF 版本后,检查发现 out/target/product/socrates/system/lib64/libminuitwrp.so 已经包含新代码,但 out/target/product/socrates/recovery/root/system/lib64/libminuitwrp.so 仍然是旧的,里面还能搜到 AIDL vibrator 相关字符串。

这会导致一个很迷惑的情况:源码和 system 输出都对了,但打进 recovery ramdisk 的库还是旧的。

手动同步 recovery root 里的库后再重新打包:

bootable/recovery/prebuilt/relink.sh \
  out/target/product/socrates/recovery/root/system/lib64 \
  out/target/product/socrates/system/lib64/libminuitwrp.so

bootable/recovery/prebuilt/relink.sh \
  out/target/product/socrates/recovery/root/system/lib \
  out/target/product/socrates/system/lib/libminuitwrp.so

cp device/xiaomi/socrates/recovery/root/init.recovery.qcom.rc \
  out/target/product/socrates/recovery/root/init.recovery.qcom.rc

然后删除旧的 ramdisk/recovery 镜像产物,重新构建 recoveryimage

提示:增量编译时,relink.sh 可能不会自动覆盖 recovery root 里的旧 .so。根本解决方案是 rm -rf out/target/product/socrates/recovery 后再 rebuild,或者直接 clean build。

构建和刷入

本次构建使用已有脚本:

wsl.exe -e bash /mnt/f/twrp/build_minimal_twrp.sh

刷入流程:

adb reboot bootloader
fastboot flash recovery output\recovery_v18_ffhaptics_fixedramdisk.img
fastboot reboot recovery

最终产物:

recovery_v18_ffhaptics_fixedramdisk.img
OrangeFox-R12.0_260510-Unofficial-socrates-ffhaptics-fixedramdisk.img
OrangeFox-R12.0_260510-Unofficial-socrates-ffhaptics-fixedramdisk.zip

其中 recovery_v18_ffhaptics_fixedramdisk.img 的 MD5:

C0FAAF49C11DD2BFAA3BF10F276F875C

验证结果

刷入后设备成功回到 Recovery:

c913c50a recovery product:fox_socrates model:Redmi_K60_Pro device:socrates

再次检查日志,没有再出现原来的阻塞等待:

Waiting for service 'android.hardware.vibrator.IVibrator/vibratorfeature'

实际体验验证:

  1. 切换选项卡不再卡顿。
  2. 点击按钮不再出现几秒等待。
  3. 振动已经恢复正常。
  4. 列表滑动保持正常。

这说明最终路径已经从:

UI 点击 -> AIDL vibrator service -> 等待失败 -> 卡顿

变成了:

UI 点击 -> minuitwrp vibrate() -> /dev/input/event1 qcom-hv-haptics -> 立即振动

为什么有时看不到 input FF 的日志

实现中有一条日志:

LOGI("Using input FF haptics device '%s' at /dev/input/%s\n", name, de->d_name);

但实际验证时不一定能在 logcat 里看到它。这不代表代码没有生效,可能原因有几个:

  1. 这条日志只在第一次扫描并打开 haptics 设备时打印一次,后续会复用 fd。
  2. Recovery 里的 LOGI 不一定稳定进入 logcat,可能只进入 /tmp/recovery.log 或被 OrangeFox 的日志系统处理。
  3. 如果第一次振动发生在清日志或 grep 之前,后续点击不会再次打印“Using input FF haptics device”。

判断是否成功,最可靠的指标不是这条日志,而是:

  1. 没有 Waiting for service 'android.hardware.vibrator...'
  2. UI 点击不再阻塞。
  3. 实机振动正常。

总结

这次问题的本质不是 Recovery 渲染慢,也不是触摸驱动慢,而是点击时的振动路径把 UI 线程拖进了一个不存在的 AIDL HAL 等待里。

短期关闭 TW_NO_HAPTICS 可以证明问题,但会牺牲振动体验。最终修复选择直接使用内核已经暴露的 qcom-hv-haptics input force-feedback 设备,绕过 Recovery 环境里不可靠的 binder HAL 依赖。

这个方案更适合 Recovery 场景:路径短,依赖少,失败时也能自然 fallback 到旧的 sysfs 逻辑,不需要维护一套完整的 Xiaomi/QCOM AIDL vibrator 服务启动链路。

对类似设备的适配也有参考价值:当 Recovery 中点击卡顿,而日志里出现 vibrator service 等待时,不要一开始就从显示或触摸方向排查。先关掉 haptics 做 A/B 验证,再看内核是否已经提供 /dev/input/event* force-feedback 节点,通常能更快找到真正的源头。

快速诊断:判断设备是否适用此方案

用以下命令检查内核是否暴露了 haptics 输入设备的 force-feedback 能力:

adb shell getevent -il /dev/input/event* | grep -A5 -i "haptic\|vibra"

如果输出中包含 FF: 行(如 FF_RUMBLEFF_CONSTANT),说明内核侧已就绪,可以直接使用 input FF 路径,无需部署完整 AIDL vibrator HAL。

已知限制

FF_CONSTANTFF_RUMBLE 的强度值(0x5fff / 0x7fff)目前是硬编码的,未从 BoardConfig 读取。如需按设备调节振感强度,可改为编译期变量:

# 可选:自定义 input FF 振动强度(0x0001 ~ 0x7fff)
TW_HAPTICS_FF_CONSTANT_LEVEL := 0x5fff
TW_HAPTICS_FF_RUMBLE_MAGNITUDE := 0x7fff

然后在 events.cpp 中用编译宏或 BoardConfig 导出属性替换硬编码值。