25、遥控器按键:KeyEvent处理、自定义按键映射与长按事件

遥控器按键处理,说白了就是TV应用和用户交互的「最后一公里」。你想想看,手机应用靠触摸,TV应用全靠遥控器那十几个键。按键响应做不好,用户第一反应就是「这App卡死了」——其实只是你的KeyEvent没处理好。

我个人习惯把按键处理分成三个层次:基础拦截、按键映射、长按扩展。今天咱们一层层拆开讲。

25.1 KeyEvent 基础:从按键到响应

Android里每个遥控器按键都会生成一个KeyEvent对象。系统会把这个事件分发给当前焦点的View。你可以在Activity或View里重写onKeyDown()onKeyUp()来拦截。

先看一个最基础的例子:

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    switch (keyCode) {
        case KeyEvent.KEYCODE_DPAD_UP:
            // 处理上键
            return true;
        case KeyEvent.KEYCODE_DPAD_DOWN:
            // 处理下键
            return true;
        case KeyEvent.KEYCODE_DPAD_CENTER:
            // 处理确认键
            return true;
        default:
            return super.onKeyDown(keyCode, event);
    }
}

这里有个坑——返回值。返回true表示你消费了这个事件,系统不再往下传。返回false则事件会继续传递给父容器。我曾经在项目里忘了返回true,结果按键被上层ViewGroup又处理了一遍,导致焦点乱跳。嗯,调试了整整一个下午。

核心原则:消费了按键事件,一定要返回true。否则系统会认为你没处理,继续寻找下一个消费者。

25.2 按键映射:让遥控器「听懂」你的逻辑

不同品牌的遥控器,按键码可能不一样。比如某些国产盒子把「菜单键」映射成了KEYCODE_MENU,而另一些则用KEYCODE_BUTTON_MODE。这时候就需要做按键映射。

我建议的做法是建一个按键映射表

public class KeyMapper {
    private static final Map<Integer, Integer> KEY_MAP = new HashMap<>();

    static {
        // 统一映射到标准按键
        KEY_MAP.put(KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_DPAD_CENTER);
        KEY_MAP.put(KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_DPAD_CENTER);
        KEY_MAP.put(KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_DPAD_CENTER);
        KEY_MAP.put(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_DPAD_CENTER);
    }

    public static int mapKey(int originalKeyCode) {
        return KEY_MAP.getOrDefault(originalKeyCode, originalKeyCode);
    }
}

然后在onKeyDown里先做映射:

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    int mappedKey = KeyMapper.mapKey(keyCode);
    // 后续逻辑都用 mappedKey
    switch (mappedKey) {
        case KeyEvent.KEYCODE_DPAD_CENTER:
            // 统一处理确认
            return true;
    }
    return super.onKeyDown(keyCode, event);
}

小技巧:映射表最好做成可配置的,比如从SharedPreferences或远程配置读取。这样线上出问题时,可以热修复按键映射,不用发版。

25.3 长按事件:别让用户「按到手指酸」

TV遥控器没有触摸长按的概念,但我们可以用KeyEvent的重复机制来实现。系统在按键按住不放时,会连续发送onKeyDown事件,并且event.getRepeatCount()会递增。

我一般这样处理长按:

private static final long LONG_PRESS_THRESHOLD = 500; // 毫秒
private long mDownTime = 0;
private boolean mIsLongPressed = false;

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (event.getAction() == KeyEvent.ACTION_DOWN) {
        if (event.getRepeatCount() == 0) {
            // 第一次按下
            mDownTime = System.currentTimeMillis();
            mIsLongPressed = false;
        } else {
            // 重复事件,检查是否达到长按阈值
            long elapsed = System.currentTimeMillis() - mDownTime;
            if (elapsed >= LONG_PRESS_THRESHOLD && !mIsLongPressed) {
                mIsLongPressed = true;
                onLongPress(keyCode);
            }
        }
    }
    return true;
}

@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
    if (!mIsLongPressed) {
        // 短按
        onShortPress(keyCode);
    }
    mIsLongPressed = false;
    return true;
}

private void onShortPress(int keyCode) {
    // 处理短按逻辑
}

private void onLongPress(int keyCode) {
    // 处理长按逻辑,比如快进、音量连续调节
}

注意:长按事件只在onKeyDown的重复事件里触发一次。如果你在onKeyDown里每次都执行长按逻辑,那用户按一次就会触发几十次——我见过有人这么写,结果用户反馈「按一下音量键,声音直接飙到最大」。

25.4 实战:一个完整的按键处理框架

下面是我在项目中常用的按键处理框架,把基础拦截、映射、长按整合在一起:

public abstract class BaseKeyHandler {
    private static final long LONG_PRESS_THRESHOLD = 500;
    private long mDownTime;
    private boolean mIsLongPressed;
    private int mLastKeyCode;

    public boolean handleKeyEvent(KeyEvent event) {
        int keyCode = KeyMapper.mapKey(event.getKeyCode());

        if (event.getAction() == KeyEvent.ACTION_DOWN) {
            if (event.getRepeatCount() == 0) {
                mDownTime = System.currentTimeMillis();
                mIsLongPressed = false;
                mLastKeyCode = keyCode;
                onKeyDown(keyCode);
            } else {
                long elapsed = System.currentTimeMillis() - mDownTime;
                if (elapsed >= LONG_PRESS_THRESHOLD && !mIsLongPressed) {
                    mIsLongPressed = true;
                    onLongPress(keyCode);
                }
            }
            return true;
        } else if (event.getAction() == KeyEvent.ACTION_UP) {
            if (!mIsLongPressed) {
                onKeyUp(keyCode);
            }
            mIsLongPressed = false;
            return true;
        }
        return false;
    }

    protected abstract void onKeyDown(int keyCode);
    protected abstract void onKeyUp(int keyCode);
    protected abstract void onLongPress(int keyCode);
}

使用时继承这个基类,只需要实现三个抽象方法:

public class MyKeyHandler extends BaseKeyHandler {
    @Override
    protected void onKeyDown(int keyCode) {
        // 按键按下时的反馈,比如改变焦点View的选中状态
    }

    @Override
    protected void onKeyUp(int keyCode) {
        // 短按释放,执行主要逻辑
        switch (keyCode) {
            case KeyEvent.KEYCODE_DPAD_CENTER:
                // 确认操作
                break;
            case KeyEvent.KEYCODE_DPAD_LEFT:
                // 左移
                break;
        }
    }

    @Override
    protected void onLongPress(int keyCode) {
        // 长按操作,比如快速翻页
        if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
            // 快进
        }
    }
}

我的习惯:把按键处理抽成独立的Handler类,而不是写在Activity或Fragment里。这样单元测试好写,也方便复用。我曾经在一个项目里,三个页面都需要同样的按键逻辑,直接复用同一个Handler,省了不少代码。

25.5 知识体系总览

下面这张图把本章的核心逻辑串起来了:

遥控器按键处理知识体系 遥控器按键输入 KeyEvent 事件分发 按键映射 长按事件 焦点管理 KeyMapper 映射表 重复事件 + 阈值判断 FocusSearch + requestFocus 核心:消费事件 → 映射统一 → 区分长短按 → 管理焦点

25.6 避坑指南

最后分享几个我踩过的坑:

  • 重复事件别乱用getRepeatCount()只在按键按住不放时递增。如果你在onKeyDown里每次都执行逻辑,用户按一下就会触发多次。我建议只在onKeyUp里处理主要逻辑,onKeyDown只做状态记录。
  • 别忘了处理ACTION_UP:有些开发者只重写onKeyDown,忽略了onKeyUp。这会导致一个问题——用户按一下键,你的逻辑执行了,但松开时没有重置状态。下次按键就会出bug。
  • 映射表要覆盖全:不同遥控器的按键码差异很大。我建议在测试阶段收集所有主流遥控器的按键码,把映射表做全。漏掉一个,用户就多一个「这个键按了没反应」的抱怨。
  • 长按阈值别太短:500毫秒是我常用的值。太短容易误触,太长用户觉得「按了没反应」。你可以做成可配置的,让用户自己调。

好了,按键处理这块就讲到这里。记住一个核心:让遥控器的每个按键都「有反馈、有响应、不冲突」。做到这三点,你的TV应用交互体验就及格了。

公众号:蓝海资料掘金营,微信deep3321