- 实战
- 改进
- 框架化
- 拓展
- 完整代码
说起使用 Java 实现快捷键,很多人都能想到,借助 Java 领域的 GUI 技术(如 Java Swing、JavaFX 等)就可以实现快捷键的功能。但是,使用这种方法实现的快捷键有一个致命的缺陷,那就是如果光标焦点此 UI 界面,则所有的快捷键都将失效。这是一个很大的限制。本文就来探究一下如何设置一个后台运行的全局快捷键。
给出的解决方案必须要让别人知道这个解决方案帮助别人解决了什么问题。本文的全局快捷键可实现如下功能:
-
程序可后台运行,无需依赖界面来维持。
-
快捷键不会因为光标及界面切换而失效。
充分利用上面的功能就已经可以实现绝大多数的需求了。如何实现呢?这里有一个叫 JIntellitype 的三方件,借助于它就可以轻松实现上述功能。
-
JIntellitype 官网:https://melloware.com/
-
JIntellitype 开源地址:https://github.com/melloware/jintellitype
JIntellitype 初次使用起来稍微有此麻烦。使用它需要 2 样东西,一是 JIntellitype 的 JAR 包、二是一种 DLL 文件。
【踩坑提醒】
JIntellitype JAR 包的版本需要与 DLL 文件相匹配,否则将导致程序运行时异常中止。
如何保证 JIntellitype JAR 包的版本需要与 DLL 文件相匹配呢?这一点,JIntellitype 官网做得并不好,还好 JIntellitype 官方的开源代码中提供了相应的示例程序以及相匹配 DLL 文件。因此,读者只需要在 JIntellitype 官方开源代码中分别拷贝官方 JIntellitype 的 Java 代码以及相匹配 DLL 文件即可。不过,笔者这里已经帮读者完成了这个工作,完整代码将在文末给出。
【提示】
-
JIntellitype 的 Java 源代码只需要拷贝 Java 包目录 com.melloware.jintellitype 中的即可,其它的比如 Maven 的各种配置信息不需要拷贝。
-
与之相匹配的 DLL 文件位于 Maven 项目的资源目录 resources 中,这里有两个文件(JIntellitype.dll、JIntellitype64.dll),拷贝后的相对路径也不能改变。
实战
为了便于演示,笔者编写了一个示例代码(位于包 org.wangpai.demo 下的 GlobalShortcutsDemo.java 中),该代码模拟了使用快捷键进行某程序的开启和关闭功能,其中演示了使用单个快捷键以及组合键,和使用多线程来避免阻塞的问题。该程序相当容易理解,所以不作过多阐述,只是有一些注意事项如下。
【注意事项】
-
如果想要设置组合键,应使用加号 + 而不是异或 |。
-
应该将快捷键触发后的耗时任务置入其它线程来执行,否则就会阻塞快捷键线程,从而导致此时所有的快捷键均失效。
-
设置快捷键应考虑其与已有快捷键之间的冲突的问题。
笔者的运行环境:
Windows 10 专业版
JDK 17
Maven 3.8.3
IntelliJ IDEA 2021.3 (Ultimate Edition)
GlobalShortcutsDemo.java
package org.wangpai.demo;
import com.melloware.jintellitype.HotkeyListener;
import com.melloware.jintellitype.JIntellitype;
import org.wangpai.commonutil.multithreading.CentralDatabase;
import org.wangpai.commonutil.multithreading.Multithreading;
public class GlobalShortcutsDemo {
public static final int FIRST_SHORTCUT = 1; // 模拟任务开始快捷键
public static final int SECOND_SHORTCUT = 2; // 模拟手动结束任务快捷键
private static HotkeyListener hotkeyListener = null;
public static void main(String[] args) {
addGlobalShortcuts();
}
private static void run() {
try {
Thread.sleep(10000); // 模拟耗时任务(10 秒)
} catch (InterruptedException exception) {
System.out.println("任务运行在完成前被中止!");
exception.printStackTrace();
}
}
private static void addGlobalShortcuts() {
JIntellitype.getInstance().registerHotKey(FIRST_SHORTCUT, 0, 'K'); // K
JIntellitype.getInstance().registerHotKey(SECOND_SHORTCUT,
JIntellitype.MOD_SHIFT + JIntellitype.MOD_ALT, 'L'); // SHIFT + ALT + L
hotkeyListener = code -> {
switch (code) {
case FIRST_SHORTCUT -> {
System.out.println("快捷键 K 被触发,任务开始");
Multithreading.execute(GlobalShortcutsDemo::run); // 开启子线程来运行耗时任务
}
case SECOND_SHORTCUT -> {
System.out.println("快捷键 SHIFT + ALT + L 被触发,任务结束");
JIntellitype.getInstance().removeHotKeyListener(hotkeyListener); // 移除快捷键触发后的动作
JIntellitype.getInstance().unregisterHotKey(FIRST_SHORTCUT); // 移除快捷键 FIRST_SHORTCUT
JIntellitype.getInstance().unregisterHotKey(SECOND_SHORTCUT); // 移除快捷键 SECOND_SHORTCUT
CentralDatabase.multithreadingClosed(); // 中断子线程,回收子线程资源
System.exit(0); // 关闭主程序
}
default -> System.out.println("监听了此快捷键但是未定义其行为");
}
};
JIntellitype.getInstance().addHotKeyListener(hotkeyListener); // 添加监听
System.out.println("正在监听快捷键...");
}
}
改进
上面的功能显然有一个缺陷,那就是当用户使用快捷键关闭另一个线程的运行时,那个线程直接就停止了。有时候,我们希望这种中止只是一种暂停,并且我们希望可以通过快捷键来反复控制同一线程的暂停与继续执行。
一个问题是,如何通过一个线程(不妨称为线程 A)来控制另一个线程(不妨称为线程 B)的暂停与恢复呢?
有的读者可能想到使用多线程技术中所谓的 生产者-消费者 模式来实现,该模式借助于悲观锁中的一种 wait(阻塞调用 wait 方法的线程,并使该线程放弃锁)、notify(使之前因 wait 方法而阻塞的线程可以在调用 notify 方法的线程放弃锁之后解除阻塞) 之类的函数来实现。
但是很遗憾,本问题不能通过此技术来实现,原因是,这种技术旨在实现多线程的串行化有序执行。也就是说,通过这项技术,可以让原本互相之间相对独立的线程转化为依次执行的线程,这会使得在任何时间,就只有一个线程可以运行。而对于本文的问题,是需要一个线程来控制另一个线程的运行,这两个线程都需要同时运行,所以,本方的这个问题不能通过此技术来实现。具体来说,上面的 wait、notify 方法都要求每个线程在获取锁之后才能调用,而这种悲观锁是一种独占锁,因此,在任何时间,就只有一个线程可以运行。本质来说,这种技术是用于实现线程的之间的互相协调(互相控制)运行,而本文的要求是一个线程控制另一个线程,这里不能使用这个技术。
另一种简单的思路是,使用一种 布尔类型 的开关变量,线程 A 通过改变此变量的值来表示线程 B 的所被要求进入的状态。然后线程 B 不断监听这个布尔类型的值,并根据值的不同来调整自己的运行状态。不过,这个设计有一个问题。当线程 B 进入“暂停状态”时,对于线程 B 的实际行为应该如何设计呢?一般来说,此时应该让线程 B 进入休眠状态,这样线程 B 就不会占用 CPU 资源。不过,问题在于,当线程 B 进入“暂停状态”时,线程 A 如何使用线程 B 进入“恢复运行状态”呢?因为此时线程 B 已经休眠,所以它无法查看上面开关变量的值,也无关改变自已的状态。一种调整方法是,使线程 B 的休眠为一种“循环休眠”,并使用每次休眠的时间不太长,这样当每次退出休眠时就可以查看上面开关变量的值了。不过,虽然这种方法可以做到控制线程的暂停与恢复,但是这会使得线程的恢复出现延迟。而且休眠时间越短,对 CPU 的不必要消耗也就越大,因此,这种方法不是一种最好的方法。
那么,应该如何优化呢?可以借助线程的 休眠 与 中断 技术。线程有一个内置机制是,如果一个线程正在休眠,然后其它线程如果对其设置了中断状态,那么该线程就会立刻解除休眠并抛出一个 InterruptedException 异常。利用此机制,就可以解决上面的“恢复延迟”问题。
下面改进之后的代码就实现了这样的功能。
GlobalShortcutsDemo.java
package org.wangpai.demo;
import com.melloware.jintellitype.HotkeyListener;
import com.melloware.jintellitype.JIntellitype;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Accessors;
public class GlobalShortcutsDemo {
private static final int FIRST_SHORTCUT = 1; // 模拟任务开始快捷键
private static final int SECOND_SHORTCUT = 2; // 模拟手动结束任务快捷键
private static final int THIRD_SHORTCUT = 3; // 模拟手动结束任务快捷键
private static final State state = new State();
private static boolean started = false;
private static int suspendWaitedLoop = 1;
private static int runningLoop = 1;
private static HotkeyListener hotkeyListener = null;
public static void main(String[] args) {
new Thread(GlobalShortcutsDemo::addGlobalShortcuts).start(); // 开启子线程来运行
}
private static void run() {
while (true) {
if (state.isRunning()) {
try {
suspendWaitedLoop = 0;
System.out.println(String.format("-----第 %d 圈任务开始执行------", runningLoop++));
Thread.sleep(10000); // 模拟耗时任务(10 秒)
System.out.println(String.format("-----第 %d 圈任务执行完毕------", runningLoop));
} catch (InterruptedException exception) {
exception.printStackTrace();
runningLoop = 1;
System.out.println("-----本圈任务未完成就被打断------");
}
} else {
runningLoop = 1;
if (suspendWaitedLoop == 0) {
System.out.println("-----任务暂停------");
}
++suspendWaitedLoop;
try {
Thread.interrupted(); // 在休眠前清除中断标志,否则有可能导致接下来的那次休眠失败
Thread.sleep(10000); // 休眠等待恢复运行
} catch (InterruptedException exception) {
exception.printStackTrace();
suspendWaitedLoop = 0;
System.out.println("-----任务恢复运行-----");
}
}
}
}
private static void addGlobalShortcuts() {
JIntellitype.getInstance().registerHotKey(FIRST_SHORTCUT, 0, 'K'); // K
JIntellitype.getInstance().registerHotKey(SECOND_SHORTCUT,
JIntellitype.MOD_SHIFT + JIntellitype.MOD_ALT, 'L'); // SHIFT + ALT + L
JIntellitype.getInstance().registerHotKey(THIRD_SHORTCUT,
JIntellitype.MOD_SHIFT + JIntellitype.MOD_ALT, 'J'); // SHIFT + ALT + J
final var mainTaskThread = new Thread(GlobalShortcutsDemo::run); // 开启子线程来运行
hotkeyListener = code -> {
switch (code) {
case FIRST_SHORTCUT -> {
if (started) {
if (state.isRunning()) {
System.out.println("快捷键 K 被触发:任务正在运行,不需要再开始");
} else {
System.out.println("快捷键 K 被触发,任务恢复运行");
synchronized (state) { // 此处必须上锁
state.setRunning(true);
mainTaskThread.interrupt();
}
}
} else {
System.out.println("快捷键 K 被触发,任务开始");
synchronized (state) { // 此处必须上锁
state.setRunning(true);
mainTaskThread.start();
}
started = true;
}
}
case SECOND_SHORTCUT -> {
if (state.isRunning()) {
System.out.println("快捷键 SHIFT + ALT + L 被触发,任务暂停");
synchronized (state) { // 此处必须上锁
state.setRunning(false);
mainTaskThread.interrupt();
}
} else {
System.out.println("快捷键 SHIFT + ALT + L 被触发:任务已暂停,不需要再暂停");
}
}
case THIRD_SHORTCUT -> {
System.out.println("快捷键 SHIFT + ALT + J 被触发,任务中止");
JIntellitype.getInstance().removeHotKeyListener(hotkeyListener); // 移除快捷键触发后的动作
JIntellitype.getInstance().unregisterHotKey(FIRST_SHORTCUT); // 移除快捷键 FIRST_SHORTCUT
JIntellitype.getInstance().unregisterHotKey(SECOND_SHORTCUT); // 移除快捷键 SECOND_SHORTCUT
JIntellitype.getInstance().unregisterHotKey(THIRD_SHORTCUT); // 移除快捷键 THIRD_SHORTCUT
mainTaskThread.interrupt();
System.exit(0); // 关闭主程序
}
default -> System.out.println("监听了此快捷键但是未定义其行为");
}
};
JIntellitype.getInstance().addHotKeyListener(hotkeyListener); // 添加监听
System.out.println("正在监听快捷键...");
}
@Getter
@Setter
@ToString
@Accessors(chain = true)
static class State {
private boolean running = false; // true 代表正在运行,false 代表暂停运行
}
}
框架化
笔者在之前的很多博客中已经提到了,框架是软件开发中最终的必然产物。任何软件在最终开发成熟之后都会框架化,框架是避免造轮子行为而必然诞生的产物。当前面已经编写好了设置全局快捷键的基本固定模式后,很容易感觉到,如果每次对一个新的功能就重新按照这种模式进行开发,就算能通过提取函数来重用公共代码,这依然使得每次都需要关注很多底层细节。比方说,每次都要记得需要在 XX 文件 XX 行增加新的内容等等。说实话,这很底层。虽然有人可能已经习惯干这样的事情,不过已经有技术可以避免每次都记住这些细节。
IoC 是一个广为流传的思想,可以借助函数式编程来实现。这里不打算详细介绍函数式编程,笔者已经在之前的博客中深入探讨过了:【Java 函数式编程入门:https://blog.csdn.net/wangpaiblog/article/details/122762637】。
函数式编程的基本步骤如下:
-
将公共的程序运行流水线抽取出来,制成一套公共框架。
-
将所有非通用代码以接口方法的形式设置在这套公共框架中,并将接口方法对外暴露。
-
非通用代码以依赖注入的形式将自己的代码注入到这套公共框架中。
需要用户自定义的部分是触发快捷键之后的行为。当然,快捷键一般也需要自定义,但制定快捷键的文字格式的标准略微繁琐。简便起见,这里选择将快捷键耦合到快捷键框架代码中。实际上不应该这么做,但学会函数式编程的读者对其进行改造起来应该很简单。
现在就可以将设置全局快捷键的基本固定模式制成一套框架了。为此,需要一个对外暴露的接口,而且考虑编程的方便,最好能是 函数式接口。可以自行定义出这个接口,但由于类 Thread 已经附带了这个接口了,这个接口就是 Runnable,所以这里就干脆就借用这个接口好了。
然后,需要将这个接口作为类字段以供框架类保存注入的代码,并将所有需要用户自定义的行为用这个接口的方法调用来代替。
最后,在用户代码中将用户代码注入到这个框架类中。
核心代码如下。
框架类 GlobalShortcutsFX
package org.wangpai.globalshortcuts;
import com.melloware.jintellitype.HotkeyListener;
import com.melloware.jintellitype.JIntellitype;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Accessors;
public class GlobalShortcutsFX {
private static final int FIRST_SHORTCUT = 1; // 模拟任务开始快捷键
private static final int SECOND_SHORTCUT = 2; // 模拟手动结束任务快捷键
private static final int THIRD_SHORTCUT = 3; // 模拟手动结束任务快捷键
private final State state = new State();
private boolean started = false;
private int suspendWaitedLoop = 1;
private int runningLoop = 1;
private HotkeyListener hotkeyListener = null;
private Thread globalShortcutsThread;
private Thread mainTaskThread;
private Runnable customActivity;
public void execute(Runnable function) {
this.customActivity = function;
this.globalShortcutsThread = new Thread(this::addGlobalShortcuts);
this.globalShortcutsThread.start(); // 开启子线程来运行
}
private void run() {
while (true) {
if (this.state.isRunning()) {
this.suspendWaitedLoop = 0;
System.out.println(String.format("-----第 %d 圈任务开始执行------", this.runningLoop++));
this.customActivity.run();
System.out.println(String.format("-----第 %d 圈任务执行完毕------", this.runningLoop));
} else {
this.runningLoop = 1;
if (this.suspendWaitedLoop == 0) {
System.out.println("-----任务暂停------");
}
++this.suspendWaitedLoop;
try {
Thread.interrupted(); // 在休眠前清除中断标志,否则有可能导致接下来的那次休眠失败
Thread.sleep(10000); // 休眠等待恢复运行
} catch (InterruptedException exception) {
exception.printStackTrace();
this.suspendWaitedLoop = 0;
System.out.println("-----任务恢复运行-----");
}
}
}
}
private void addGlobalShortcuts() {
JIntellitype.getInstance().registerHotKey(FIRST_SHORTCUT, 0, 'K'); // K
JIntellitype.getInstance().registerHotKey(SECOND_SHORTCUT,
JIntellitype.MOD_SHIFT + JIntellitype.MOD_ALT, 'L'); // SHIFT + ALT + L
JIntellitype.getInstance().registerHotKey(THIRD_SHORTCUT,
JIntellitype.MOD_SHIFT + JIntellitype.MOD_ALT, 'J'); // SHIFT + ALT + J
this.mainTaskThread = new Thread(this::run); // 开启子线程来运行
this.hotkeyListener = code -> {
switch (code) {
case FIRST_SHORTCUT -> {
if (this.started) {
if (this.state.isRunning()) {
System.out.println("快捷键 K 被触发:任务正在运行,不需要再开始");
} else {
System.out.println("快捷键 K 被触发,任务恢复运行");
synchronized (this.state) { // 此处必须上锁
this.state.setRunning(true);
this.mainTaskThread.interrupt();
}
}
} else {
System.out.println("快捷键 K 被触发,任务开始");
synchronized (this.state) { // 此处必须上锁
this.state.setRunning(true);
this.mainTaskThread.start();
}
this.started = true;
}
}
case SECOND_SHORTCUT -> {
if (this.state.isRunning()) {
System.out.println("快捷键 SHIFT + ALT + L 被触发,任务暂停");
synchronized (this.state) { // 此处必须上锁
this.state.setRunning(false);
this.mainTaskThread.interrupt();
}
} else {
System.out.println("快捷键 SHIFT + ALT + L 被触发:任务已暂停,不需要再暂停");
}
}
case THIRD_SHORTCUT -> {
System.out.println("快捷键 SHIFT + ALT + J 被触发,任务中止");
JIntellitype.getInstance().removeHotKeyListener(this.hotkeyListener); // 移除快捷键触发后的动作
JIntellitype.getInstance().unregisterHotKey(FIRST_SHORTCUT); // 移除快捷键 FIRST_SHORTCUT
JIntellitype.getInstance().unregisterHotKey(SECOND_SHORTCUT); // 移除快捷键 SECOND_SHORTCUT
JIntellitype.getInstance().unregisterHotKey(THIRD_SHORTCUT); // 移除快捷键 THIRD_SHORTCUT
this.mainTaskThread.interrupt();
System.exit(0); // 关闭主程序
}
default -> System.out.println("监听了此快捷键但是未定义其行为");
}
};
JIntellitype.getInstance().addHotKeyListener(this.hotkeyListener); // 添加监听
System.out.println("正在监听快捷键...");
}
@Getter
@Setter
@ToString
@Accessors(chain = true)
static class State {
private boolean running = false; // true 代表正在运行,false 代表暂停运行
}
}
用户代码:GlobalShortcutsDemoMain.java
package org.wangpai.demo;
import org.wangpai.globalshortcuts.GlobalShortcutsFX;
public class GlobalShortcutsDemoMain {
private static void customActivity() {
try {
Thread.sleep(10000); // 模拟耗时任务(10 秒)
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
new GlobalShortcutsFX().execute(GlobalShortcutsDemoMain::customActivity);
}
}
拓展
不过,暂停是不是也可能出现延迟呢?没错,是这样。所以就要求线程 B(被控制线程)在循环运行时,在每次循环中,都不能运行一种耗时任务。因为线程 B 只有在执行检查开关变量的代码后才能改变运行状态,如果循环中存在耗时任务,那么相邻两次检查的时间间隔就会变长,就会带来“暂停延迟”的问题。
我们当然不能期望每次循环中都只运行不耗时任务,如何解决这个问题呢?这个问题想一个简单而完美的解决方案很困难。一方面,编程人员需要合理的安排每次循环中的任务,另一方面,可以借助 Reactor 模式 中的思想,引入一个中介者作为中间层,介于线程 A(控制线程)与线程 B(被控制线程)之间,来协调线程 B 的资源。关于这方面的内容,本文没有给出具体实现,读者可自行摸索。
完整代码已上传至 GitCode 中,可免费下载:https://gitcode.net/wangpaiblog/global-shortcuts_java



