08.02 物理按键事件处理

2025/2/10 Android 开发基础

上一节(08.01 触摸事件处理)讲的是屏幕上的触摸交互,本节处理另一类输入:物理按键(HardKey)。它包括设备的实体按键(返回键、音量键、电源键、相机键)、外接键盘、遥控器(TV/盒子)、游戏手柄等。虽然全面屏时代"返回手势"逐渐替代了实体返回键,但物理按键体系在以下场景依然是刚需:

  • 系统级按键响应:音量键、返回键的自定义行为
  • TV / 车机 / 遥控器开发:方向键 + OK 键的焦点导航是核心交互
  • 外设接入:实体键盘、游戏手柄(蓝牙/USB)
  • 软键盘(IME)交互: imeOptions、动作键(发送/搜索)

# 一、KeyEvent 体系

所有按键事件都封装为 KeyEvent,模型与 MotionEvent 类似,但按键没有"移动"概念,只有按下/抬起两个基本动作:

  • KeyEvent.ACTION_DOWN:按键按下
  • KeyEvent.ACTION_UP:按键抬起
  • KeyEvent.ACTION_MULTIPLE:多次重复(输入法合并,少见)

一次按键 = DOWN → (长按则产生重复计数)→ UP。KeyEvent 的关键信息:

方法 含义
getKeyCode() 按键的唯一标识(如 KEYCODE_BACK、KEYCODE_VOLUME_UP)
getRepeatCount() DOWN 事件内部的重复次数(长按时间越长数字越大)
isLongPress() 本次 DOWN 是否构成长按(由系统判定)
getEventTime() 事件时间戳

常用 KeyCode 一览:

  • KEYCODE_BACK:返回键
  • KEYCODE_VOLUME_UP / KEYCODE_VOLUME_DOWN:音量键
  • KEYCODE_HOME:Home 键(不开放给普通应用,见后文)
  • KEYCODE_POWER:电源键(普通应用收不到完整序列)
  • KEYCODE_MENU:菜单键
  • KEYCODE_CAMERA:相机实体键
  • KEYCODE_DPAD_UP/DOWN/LEFT/RIGHT/CENTER:遥控器方向键与 OK 键
  • KEYCODE_ENTER / KEYCODE_SPACE / 字母数字键:键盘字符键
  • KEYCODE_HEADSETHOOK:耳机线控按键(播放/暂停)

# 二、按键事件的传递链路

按键事件不走触摸事件的坐标命中,而是沿着"焦点链"传递:

物理按键事件传递链示意图

与触摸事件(08.01)对比理解:

  • 触摸:Activity.dispatchTouchEvent → ViewGroup 遍历子 View 坐标命中 → 目标 View
  • 按键:Activity.dispatchKeyEvent → 焦点所在的 View →(未消费)→ 回到 Activity 的 onKeyDown/onKeyUp

消费判定:onKeyDown/onKeyUp 返回 true 表示消费,事件停止传递;返回 false 继续回溯给上一级(View → ViewGroup → Activity → 系统)。

# 三、Activity / View 中处理按键

# 3.1 Activity 层:onKeyDown / onKeyUp

public class DemoActivity extends AppCompatActivity {
    private static final String TAG = "DemoActivity";

    // 按键按下(长按时会按repeatCount多次回调)
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        switch (keyCode) {
            case KeyEvent.KEYCODE_BACK:
                Log.i(TAG, "返回键按下,repeatCount=" + event.getRepeatCount());
                // 返回true表示消费:拦截返回键,不再执行默认的finish()
                return true;
            case KeyEvent.KEYCODE_VOLUME_UP:
                Log.i(TAG, "音量+按下");
                break;
            case KeyEvent.KEYCODE_DPAD_CENTER:
                Log.i(TAG, "遥控器OK键");
                break;
            default:
                break;
        }
        // 不消费的按键交给super处理(返回键的finish等默认行为在super里)
        return super.onKeyDown(keyCode, event);
    }

    // 按键抬起
    @Override
    public boolean onKeyUp(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_BACK) {
            Log.i(TAG, "返回键抬起");
            return true;
        }
        return super.onKeyUp(keyCode, event);
    }
}

关键规则:返回键的默认行为(finish)发生在 super.onKeyDown 内部。拦截返回键 = 在调 super 前返回 true;只加逻辑不拦截 = 处理完返回 false 或 super。

# 3.2 长按检测:onKeyLongPress 与 repeatCount

长按有两种处理方式:

// 方式一:使用onKeyLongPress(需要onKeyDown中配合)
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_BACK && event.getRepeatCount() == 0) {
        // repeatCount==0表示第一次DOWN,必须告诉系统"我要长按事件"
        event.startTracking();
        return true;
    }
    return super.onKeyDown(keyCode, event);
}

@Override
public boolean onKeyLongPress(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_BACK) {
        // 长按返回键:如退出到桌面
        moveTaskToBack(true);
        return true;
    }
    return super.onKeyLongPress(keyCode, event);
}

// 方式二:在onKeyDown中用repeatCount自行判断(更灵活)
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
        // 长按音量-:连续触发(类似连拍)
        if (event.getRepeatCount() > 3) {
            burstCapture();
        }
    }
    return super.onKeyDown(keyCode, event);
}

# 3.3 View 层:OnKeyListener

给控件设置 OnKeyListener,在 Activity 之前优先拿到按键:

EditText editText = findViewById(R.id.et_input);
editText.setOnKeyListener(new View.OnKeyListener() {
    @Override
    public boolean onKey(View v, int keyCode, KeyEvent event) {
        // 只处理抬起事件,避免DOWN/UP重复响应
        if (event.getAction() == KeyEvent.ACTION_UP
                && keyCode == KeyEvent.KEYCODE_ENTER) {
            submit();
            return true;  // 消费,阻止默认行为(如换行)
        }
        return false;
    }
});

# 四、返回键处理(重点)

返回键是最常被自定义的按键,处理方式随 Android 版本演进:

# 4.1 传统方式(API 33 之前)

// 方式一:onBackPressed重写(简单直观)
@Override
public void onBackPressed() {
    if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
        drawerLayout.closeDrawer(GravityCompat.START);  // 先关侧边栏
    } else {
        super.onBackPressed();  // 走默认finish
    }
}

// 方式二:onKeyDown拦截KEYCODE_BACK(等价,控制粒度更细)
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_BACK && event.getRepeatCount() == 0) {
        // 弹窗确认"再按一次退出"
        if (System.currentTimeMillis() - mLastBackTime > 2000) {
            mLastBackTime = System.currentTimeMillis();
            Toast.makeText(this, "再按一次退出程序", Toast.LENGTH_SHORT).show();
            return true;  // 拦截,不退出
        }
    }
    return super.onKeyDown(keyCode, event);
}

# 4.2 现代方式:OnBackPressedDispatcher(推荐)

Android 10 起引入手势导航,且从 Android 13(API 33)起 onBackPressed/onKeyDown(KEYCODE_BACK) 拦截被标记废弃——因为"预测性返回手势"(predictive back)需要系统预览返回效果。新代码统一使用 OnBackPressedDispatcher:

// 依赖:androidx.activity:activity-ktx(AppCompat已内置)
public class DemoActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo);

        // 注册返回拦截回调(在onBackPressedDispatcher上注册)
        getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true /* enabled */) {
            @Override
            public void handleOnBackPressed() {
                // 拦截到的返回:执行自定义逻辑
                if (hasUnsavedChanges()) {
                    showSaveConfirmDialog();
                } else {
                    setEnabled(false);          // 暂时关闭拦截
                    getOnBackPressedDispatcher().onBackPressed();  // 走默认返回
                    setEnabled(true);
                }
            }
        });
        // enabled=false时可调用callback.setEnabled(true)动态开启
    }
}

新方式的优势:

  • 按注册顺序反向分发:后注册的先收到(先进后出),天然适配"弹窗在最上层的返回优先关弹窗"
  • enabled 开关可动态控制是否拦截
  • 兼容手势导航与预测性返回

迁移建议:新代码一律用 Dispatcher;存量 onBackPressed 仍可运行(有 deprecation 警告),重构时逐步迁移。

# 4.3 Home 键:监听的边界

与返回键不同,Home 键不开放给普通应用:系统保证"用户随时能回到桌面",应用无法拦截(onKeyDown 收不到 KEYCODE_HOME)。能做的只是"感知":

// 方式一:onUserLeaveHint(用户离开的钩子,如按Home/接电话跳走)
@Override
protected void onUserLeaveHint() {
    super.onUserLeaveHint();
    Log.i(TAG, "用户主动离开(Home键/切后台)");
    // 适合做:暂停视频播放等清理
}

// 方式二:ActivityLifecycleCallbacks监听前后台切换(全局)
// 方式三:注册广播ACTION_CLOSE_SYSTEM_DIALOGS(已受限,不推荐)

电源键同理:普通应用收不到 KEYCODE_POWER 的完整事件(安全设计),只能通过屏幕亮灭广播等间接感知。

# 五、音量键与媒体键

# 5.1 音量键

音量键默认行为是调节媒体音量(super 内处理)。拦截场景较少,典型是"相机应用音量键拍照":

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_VOLUME_UP && event.getRepeatCount() == 0) {
        takePhoto();        // 音量+当快门
        return true;        // 拦截:不调音量
    }
    return super.onKeyDown(keyCode, event);
}

音量调节的另一途径:setVolumeControlStream(AudioManager.STREAM_MUSIC) 声明页面控制的音量流(如游戏页控制媒体音而非铃声音)。

# 5.2 媒体控制键(耳机线控/蓝牙)

耳机线控三键、蓝牙耳机的播放/暂停/上下曲以 KEYCODE_HEADSETHOOK、KEYCODE_MEDIA_PLAY_PAUSE 等形式分发,标准做法不是 onKeyDown,而是 MediaSession(系统级统一管理,通知栏/锁屏/耳机三方联动),详见 19.02 MediaSession 章节。简版兜底:

@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    switch (keyCode) {
        case KeyEvent.KEYCODE_HEADSETHOOK:
        case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
            togglePlayPause();
            return true;
        case KeyEvent.KEYCODE_MEDIA_NEXT:
            playNext();
            return true;
        default:
            return super.onKeyDown(keyCode, event);
    }
}

# 六、焦点系统与遥控器导航(TV 场景)

TV/遥控器没有触摸,导航全靠焦点(Focus):方向键移动焦点、OK 键(KEYCODE_DPAD_CENTER)触发点击。

# 6.1 焦点导航原理

<!-- 可聚焦的控件:默认按布局位置自动推算相邻焦点 -->
<Button android:id="@+id/btn_left"
    android:focusable="true" ... />

<!-- 手动指定相邻焦点的方向(自动推算不符合预期时使用) -->
<Button android:id="@+id/btn_top_right"
    android:nextFocusUp="@+id/btn_upper"
    android:nextFocusDown="@+id/btn_below"
    android:nextFocusLeft="@+id/btn_left_view"
    android:nextFocusRight="@+id/btn_right_view" />
// 代码层面控制焦点
view.setFocusable(true);          // 可聚焦
view.requestFocus();              // 主动请求焦点(页面初始定位)
view.isFocused();                 // 焦点状态
view.setOnFocusChangeListener(new View.OnFocusChangeListener() {
    @Override
    public void onFocusChange(View v, boolean hasFocus) {
        // TV开发常用:焦点变化时放大/高亮(Selector+scale组合)
        v.animate().scaleX(hasFocus ? 1.1f : 1f).scaleY(hasFocus ? 1.1f : 1f).setDuration(150);
    }
});

TV 开发要点:

  • 控件必须 android:focusable="true" 才能被方向键导航(默认 TextView 不可聚焦)
  • 焦点高亮用 selector(state_focused)+ 缩放动画
  • RecyclerView 需处理"一行结束方向键行为"等焦点卡点
  • 页面初始化时用 requestFocus()(或 xml 的 <requestFocus/>)定位默认焦点

# 6.2 焦点与触摸的分发差异

按键事件只发给当前焦点的 View,没有 onInterceptTouchEvent 这类拦截概念,但 View 的 dispatchKeyEvent 依然存在(ViewGroup 会先问自己再传给焦点子 View)。消费语义与触摸一致:返回 true 即终止。

# 七、软键盘(IME)与动作键

软键盘虽然不是"物理"按键,但它的动作键(回车位置的"发送/搜索/完成")走同一套 KeyEvent 体系:

<EditText
    android:id="@+id/et_search"
    android:imeOptions="actionSearch"       <!-- 动作键显示为"搜索" -->
    android:inputType="text" />
// 方式一:监听编辑器动作(推荐)
EditText etSearch = findViewById(R.id.et_search);
etSearch.setOnEditorActionListener(new TextView.OnEditorActionListener() {
    @Override
    public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
        if (actionId == EditorInfo.IME_ACTION_SEARCH) {
            doSearch(v.getText().toString());
            return true;
        }
        return false;
    }
});

// 方式二:键盘显示/隐藏控制
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(v.getWindowToken(), 0);   // 收起键盘

常用 imeOptions:actionSearch(搜索)、actionSend(发送)、actionDone(完成)、actionNext(跳下一输入框)、actionGo(前往)。配合 android:windowSoftInputMode="adjustResize"(清单中声明键盘弹出时压缩布局)控制页面表现。

# 八、常见问题与陷阱

  1. 返回键拦截失效:onKeyDown 里处理完返回键忘记 return true,事件落到 super 又 finish 了;或 Fragment 场景直接在 Activity 拦截导致 Fragment 收不到——多级返回用 OnBackPressedDispatcher 按序分发。
  2. onKeyDown 中长按重复触发:没检查 repeatCount,长按时逻辑被调用 N 次;单次动作要判断 getRepeatCount() == 0。
  3. Home 键监听不到:设计如此,普通应用无权拦截 Home,用 onUserLeaveHint / 生命周期感知替代。
  4. TV 页面方向键无反应:目标控件没设 focusable,或焦点被带 requestFocus 的兄弟控件抢走。
  5. onKeyLongPress 不触发:忘记在 onKeyDown 里对首次 DOWN 调用 event.startTracking()。
  6. 手势导航下 onBackPressed 被废弃警告:API 33+ 的新行为,迁移到 OnBackPressedDispatcher,同时为预测性返回(android:enableOnBackInvokedCallback="true")做好准备。

# 结束

本节介绍了物理按键事件体系:KeyEvent 数据模型(keyCode/repeatCount/长按)、按键沿焦点链的传递机制(与触摸事件坐标命中的本质区别)、Activity/View 两层处理(onKeyDown/onKeyUp、OnKeyListener)、返回键处理的演进(onBackPressed → OnBackPressedDispatcher 与预测性返回)、Home/电源键的系统限制、音量键与媒体键、TV 场景的焦点系统(方向导航/焦点高亮/焦点指定)、软键盘动作键与 imeOptions。物理按键处理的复杂度集中在"返回键语义"和"焦点导航"两块,掌握了这两点,TV/外设开发就上了轨道。更多内容请参考官方文档:处理键盘操作 (opens new window) 与 支持预测性返回手势 (opens new window)。