08.02 物理按键事件处理
上一节(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"(清单中声明键盘弹出时压缩布局)控制页面表现。
# 八、常见问题与陷阱
- 返回键拦截失效:
onKeyDown里处理完返回键忘记return true,事件落到 super 又 finish 了;或 Fragment 场景直接在 Activity 拦截导致 Fragment 收不到——多级返回用OnBackPressedDispatcher按序分发。 - onKeyDown 中长按重复触发:没检查
repeatCount,长按时逻辑被调用 N 次;单次动作要判断getRepeatCount() == 0。 - Home 键监听不到:设计如此,普通应用无权拦截 Home,用
onUserLeaveHint/ 生命周期感知替代。 - TV 页面方向键无反应:目标控件没设
focusable,或焦点被带requestFocus的兄弟控件抢走。 - onKeyLongPress 不触发:忘记在
onKeyDown里对首次 DOWN 调用event.startTracking()。 - 手势导航下 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)。