简介:这是一份面向C#开发者的Java Access Bridge小应用源码包,用于解决非Java程序获取与操作Java Swing/FX界面元素的难题。资源包共27个文件,占用仅53KB,主体为7个cs源码文件,附带exe可执行程序、resx/resources界面资源、settings/csproj/sln工程配置以及调试生成的cache、pdb、tlog等文件,结构完整便于直接编译运行。已有648人学习下载。通过这个示例,可以直观理解Java Access Bridge的加载与调用流程,学会在C#中初始化JVM、注册AccessibleEventListener监听UI事件,并利用AccessibleContext、AccessibleValue等接口读取组件属性、修改按钮状态或抓取表格数据。代码中还包含Form1窗体设计,适合作为自动化测试与无障碍工具开发的入门模板,帮助开发者快速打通Java与C#之间的信息交互链路。
1. 为什么一个Java Access Bridge小应用值得你亲手复现
Java Access Bridge这个名词在Windows下的Java开发者圈子里,通常被当成一个无障碍选项,很少有人意识到它是一条可以直接读取Swing/JF组件树的侧门。我最初是在做老项目的自动化回归时被逼到用它——Robot抓不到JTable的单元格,Selenium又管不了桌面程序。这个小应用做得很克制:不注入、不改被测代码,只通过jaccess.jar把运行中的Java窗口和控件树完整拉出来。你做UI自动化、无障碍改造,或者单纯想搞懂一个Swing应用的内部结构,都会需要它。下文从原理到代码把这条管道摊开,最后列出我翻过车的五个坑。
2. 运行机制与环境准备:JAB是一根连接JVM和Windows的无障碍管道
2.1 先理解JAB的工作链路:从JVM组件到辅助技术程序
Java Access Bridge不是某个单独的库,而是一整套从JVM内部到Windows辅助功能接口的通信链路。要写出能用的程序,至少要明白这条链路里四个角色。最内层是JVM里的组件树,Swing组件实现javax.accessibility.Accessible接口,每一个组件都能提供对应的AccessibleContext。中间层是Java Access Bridge的DLL,当一个Java窗口在Windows系统上被创建时,这个DLL会被加载到该Java进程内,把AccessibleContext映射为Windows的AccessibleObject。第三层是Windows桥接组件,它对外暴露出一组标准接口,辅助技术程序通过这些接口拿到对象。最外层才是我们写的应用,它通过JDK自带的jaccess.jar与中间层通信,不需要自己写JNI。
我一开始也想直接JNI调WindowsAccessBridge-64.dll,后来发现jaccess.jar里已经封装好了这层操作。这个jar是JDK 8默认携带的,位于$JAVA_HOME/lib目录下,里面最核心的是com.sun.java.accessibility.util包,它维护了一个“本机所有Java窗口”的注册表,帮我们省掉了大量底层通信代码。理解这条链路后,再写抓取逻辑就只是遍历容器层级的问题了。
AccessibleContext是这条链路里最常见的对象,它对应组件树上的一个节点。可以把它想象成每个Swing组件暴露给外部世界的一个“访问卡”,卡片上写着这个组件叫什么、是什么角色、有哪些动作。平时我们看一个按钮,看到的是文字和颜色;通过AccessibleContext,我们拿到的是名称、角色和作用域。
下表列出几个最常用的属性,后面的抓取代码基本都围绕它们:
| AccessibleContext属性 | 获取方法 | 对应UI含义 |
|---|---|---|
| 名称 | getAccessibleName() | 控件的可用名称,通常是按钮文本或Label文字 |
| 角色 | getAccessibleRole() | 按钮(BUTTON)、表格(JTABLE)、菜单(MENU)等 |
| 值 | getAccessibleValue() | 滚动条、滑块等当前取值 |
| 动作 | getAccessibleAction() | 可点击控件支持的动作集合 |
2.2 安装准备:JAB组件与JDK的匹配关系
环境准备上,第一条件是操作系统必须是Windows,Java Access Bridge目前没有稳定的Linux版本。第二条件是系统已经安装Java Access Bridge 2.0以上的组件。一般来说,JDK 8的安装包允许勾选“附加功能”来一并安装,如果你用的是精简版JDK,则需要单独运行JAB的安装程序。第三条件是JDK位数与JAB组件位数一致。这一点极其关键,32位JDK配64位桥接DLL会直接找不到JavaAccessBridge.dll。
安装完成后,可以用两个命令行动作确认状态。jabswitch.exe是JDK bin目录下的开关工具,用来启用或禁用JAB,注意必须以管理员权限运行。验证DLL是否注册,则用where命令找一下JavaAccessBridge.dll的路径。常见文件清单如下:
| 文件 | 作用 | 常见位置 |
|---|---|---|
JavaAccessBridge.dll | JAB核心DLL,注入到Java进程侧 | JDK安装目录\bin 或系统公共文件目录 |
WindowsAccessBridge-32.dll | 供32位辅助技术程序调用的桥 | 系统公共文件目录 |
WindowsAccessBridge-64.dll | 供64位辅助技术程序调用的桥 | 系统公共文件目录 |
jabswitch.exe | 启用/禁用JAB的命令行工具 | JDK安装目录\bin |
启用命令和验证命令如下:
# 以管理员身份打开命令行,启用Java Access Bridge jabswitch.exe -enable # 确认JAB核心DLL在系统搜索路径中 where JavaAccessBridge.dll第一行命令的本质是修改注册表,把JAB的启用状态从0改为1。第二行命令如果找不到DLL,不代表一定有问题,因为DLL可能只存在于某个固定目录且不在PATH中。更靠谱的验证方式是直接启动一个小型JAB窗口追踪类,看能否枚举到窗口,我们会在第3章实现这个类。
还需要注意,jaccess.jar只随JDK 8及更早版本一起发布。如果你使用了JDK 9以上,编译时会直接报package com.sun.java.accessibility.util does not exist。常见做法是把JDK 8的jaccess.jar复制到当前工程的lib目录,再通过编译参数引入。这条经验在后面的避坑章节里会详细展开。
3. 用jaccess.jar写一个跨进程UI抓取器:三个类完成窗口枚举与树遍历
3.1 建立工程并引入jaccess.jar
我这里习惯用一个最简单的目录结构,不引入Maven,避免额外的仓库配置问题。工程目录如下:
jab-inspector/ ├── lib/jaccess.jar ├── src/Main.java ├── src/TreePrinter.java └── run.batlib/jaccess.jar从JDK 8的安装目录复制过来,src下只放两个Java文件。由于jaccess.jar是JDK内部jar,IDE可能默认把它屏蔽,所以最好直接使用命令行编译。对应的run.bat脚本这样写:
@echo off rem 如果你的默认java不是JDK8,请先切换到JDK8环境 set "CLASSPATH=lib\jaccess.jar;out" if not exist out mkdir out javac -encoding UTF-8 -cp "%CLASSPATH%" src\Main.java src\TreePrinter.java -d out java -cp "out;%CLASSPATH%" Main %*脚本里先设置classpath,把jaccess.jar和输出目录out都加进去。javac使用-encoding UTF-8是为了防止中文控件名在编译时变成乱码,这一步在Windows上尤其重要。运行时同样把out放在classpath最前面,确保加载我们编译的新类而不是JDK里可能的同名类。
3.2 初始化EventQueueMonitor并枚举所有Java窗口
核心代码从事件队列监视器开始。EventQueueMonitor是jaccess.jar里最实用的一个类,它会随着JAB初始化而开始跟踪本机所有Java进程的窗口。我们只需要调用maybeInitialize(),然后就能拿到窗口数组:
import com.sun.java.accessibility.util.EventQueueMonitor; import java.awt.Window; public class Main { public static void main(String[] args) { // 触发JAB初始化,开始跟踪本机所有Java窗口 EventQueueMonitor.maybeInitialize(); Window[] windows = EventQueueMonitor.getWindows(); if (windows == null || windows.length == 0) { System.err.println("没有发现任何Java窗口,请检查JAB是否启用"); return; } System.out.println("检测到 " + windows.length + " 个Java窗口"); for (Window w : windows) { javax.accessibility.AccessibleContext ctx = w.getAccessibleContext(); if (ctx == null) { continue; } System.out.println("窗口: " + ctx.getAccessibleName() + " / " + ctx.getAccessibleRole()); TreePrinter.dump(ctx, 0); } } }maybeInitialize()内部会去加载并连接Java Access Bridge的native部分,只有在JAB已启用的环境中才会成功。getWindows()返回的窗口数组来自JAB追踪的窗口注册表,不限于当前JVM进程,这一点是它比Window.getWindows()更强大的地方。拿到窗口后通过getAccessibleContext()获取组件树的根节点,再交给TreePrinter递归输出。
3.3 递归遍历AccessibleContext树并提取控件信息
遍历AccessibleContext树与遍历普通树结构并无区别,每个节点都有getAccessibleChildrenCount()和getAccessibleChild(i)两个方法。但这里有个容易忽略的点:子对象返回类型是Accessible接口,需要再取一次getAccessibleContext(),而且这个上下文可能为null,必须判空。
public class TreePrinter { public static void dump(javax.accessibility.AccessibleContext ctx, int depth) { if (ctx == null || depth > 10) { return; } int count = ctx.getAccessibleChildrenCount(); for (int i = 0; i < count; i++) { javax.accessibility.Accessible child = ctx.getAccessibleChild(i); if (child == null) { continue; } javax.accessibility.AccessibleContext cctx = child.getAccessibleContext(); if (cctx == null) { continue; } StringBuilder sb = new StringBuilder(); for (int j = 0; j < depth; j++) { sb.append(" "); } sb.append(cctx.getAccessibleRole()) .append(" : ") .append(cctx.getAccessibleName()); System.out.println(sb.toString()); dump(cctx, depth + 1); } } }代码中depth > 10的上限是为了防止极深布局导致的递归过深或栈溢出。根据我抓过的JTree和JTable的实际表现,正常界面深度不会超过10层,一旦超过,多半是遇到循环引用或异常节点。输出格式先只打印角色和名称,角色用AccessibleRole的toString返回,名称是真正显示给用户看到的文本。这里的缩进用两个空格,复制到笔记软件里也算清楚。
3.4 按名称定位目标控件的深度优先搜索
枚举整个树之后,下一步常常是“帮我在这个大窗口下面找到那个确定按钮”。这时写一个深度优先搜索方法,按accessibleName匹配控件。注意同名按钮可能出现多次,所以更稳妥的做法是同时限定角色。
public static javax.accessibility.AccessibleContext findByName( javax.accessibility.AccessibleContext ctx, String name) { if (ctx == null) { return null; } if (name.equals(ctx.getAccessibleName())) { return ctx; } int count = ctx.getAccessibleChildrenCount(); for (int i = 0; i < count; i++) { javax.accessibility.Accessible child = ctx.getAccessibleChild(i); if (child == null) { continue; } javax.accessibility.AccessibleContext childCtx = child.getAccessibleContext(); javax.accessibility.AccessibleContext result = findByName(childCtx, name); if (result != null) { return result; } } return null; }这个方法的参数含义很简单:ctx是当前子树的根节点,name是要匹配的控件可用名称。首次调用时传入窗口根节点的上下文即可。如果同一窗口里有多个相同名称的按钮,建议改成收集所有匹配项的List版本,或者在上层先把角色限定为BUTTON,再对这个集合做二次筛选。注意getAccessibleName()返回的可能不是稳定的UI文本,它有可能被JAB的翻译层加工过,所以不要用严格相等来判断。
4. 从抓取到监听:用AccessibilityEventMonitor捕获焦点与值变化
4.1 注册属性变化监听器
静态抓取适合查看完整树,但它看不到动态变化。实际做自动化时,我们需要知道某个输入框何时获得了焦点,或者某个进度条数值什么时候变了。jaccess.jar提供了AccessibilityEventMonitor,可以用标准PropertyChangeListener接收来自所有被JAB跟踪的Java组件的属性变化事件。
import com.sun.java.accessibility.util.AccessibilityEventMonitor; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import javax.accessibility.AccessibleContext; public class ListenerDemo { public static void start() { AccessibilityEventMonitor.addPropertyChangeListener(new PropertyChangeListener() { @Override public void propertyChange(PropertyChangeEvent e) { Object src = e.getSource(); if (src instanceof AccessibleContext) { AccessibleContext ctx = (AccessibleContext) src; System.out.println("变化属性: " + e.getPropertyName() + " 节点: " + ctx.getAccessibleName() + " 新值: " + e.getNewValue()); } } }); } }这里的e.getPropertyName()可能是AccessibleContext.ACCESSIBLE_NAME_PROPERTY、ACCESSIBLE_VALUE_PROPERTY等常量对应的字符串。实际打印时会看到value或name这类短名。重点在于,事件来源不一定是组件本身,也可能是某个子节点,所以回调里最好再走一次getAccessibleName()来确认是哪个节点。如果你只想监听数值变化,可以在回调里增加一个属性名过滤。
4.2 避免事件风暴的节流策略
事件监听的代价是频率不可控。我曾在一个带实时进度条的Swing应用上观察过,一秒钟内同一个进度条触发了几十次value变化。如果直接打印,控制台会被刷爆。常见做法是把事件丢进一个有界队列,由后台消费者批量处理。这里我贴一个简化版:
import java.util.concurrent.*; public class ThrottledListener { private static final BlockingQueue<PropertyChangeEvent> QUEUE = new LinkedBlockingQueue<>(500); public static void startConsumer() { ExecutorService pool = Executors.newSingleThreadExecutor(); pool.execute(() -> { while (true) { try { PropertyChangeEvent e = QUEUE.take(); System.out.println("消费事件: " + e.getPropertyName() + " 目标: " + e.getSource()); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); break; } } }); } // 在propertyChange回调中调用QUEUE.offer(e) }这个方案的核心是用一个LinkedBlockingQueue做缓冲区,容量限制为500。当事件涌入速度超过消费速度时,队列会积压;如果积压到500条,新事件就会被舍弃,避免内存爆炸。消费线程使用单线程池,保证事件按顺序处理。如果你需要保留所有事件,可以把容量调大,并用JDBC批量写入,但实测中丢弃低价值的重复进度事件影响不大。
4.3 监听开始时机的两个注意点
第一,AccessibilityEventMonitor的注册必须在EventQueueMonitor.maybeInitialize()之后,否则可能错过JAB初始化时期的早期事件。第二,监听回调线程并不属于AWT事件分派线程,这一点在操作Swing组件时要特别小心。比如你在回调里直接调用某个JButton的setText(),大概率会抛出java.lang.IllegalStateException: Not an event listener之类的异常,解决方式稍后在第5章里详细说。
5. 运行JAB小应用的避坑手册:五个让程序静默失效的深坑
5.1 现象:EventQueueMonitor.getWindows() 始终返回空数组
我刚开始编译好这个小应用,兴致勃勃地点运行,结果控制台只打出一行“0个Java窗口”。当时被测的Swing程序明明开着。
原因有两层:第一层是JAB没有被启用,jabswitch.exe -enable没有执行或者执行时不在管理员终端;第二层是运行小应用的Java进程位数与JAB组件不一致,比如JDK是64位,但系统里只装了32位的JavaAccessBridge组件。解决方法是先以管理员身份运行jabswitch.exe -enable,再用java -version确认当前JDK位数,如果还不生效,打开注册表编辑器找到HKEY_LOCAL_MACHINE\SOFTWARE\JavaSoft\Java Runtime Environment查看JAB键值是否为1。我在某台只装了32位JRE的机器上折腾了两个小时,最后换回64位JDK就好了。
5.2 现象:启动就报 UnsatisfiedLinkError,找不到 JavaAccessBridge.dll
这个报错通常出现在直接调用native库的代码里,但jaccess.jar内部封装后也可能会在maybeInitialize()时触发。原因是JAB的DLL不在系统搜索路径中,或者JVM的java.library.path没包含DLL所在目录。
解决方法是先确认DLL的物理位置,然后把它的目录加入java.library.path。命令行运行时的做法是:java -Djava.library.path="C:\Program Files\...\Java Access Bridge" -cp ... Main。如果不想每次带参数,更省事的是把JavaAccessBridge.dll复制到JDK的bin目录下,因为bin通常已经被JVM默认识别为库路径。注意复制时一定要匹配当前JDK的位数。
5.3 现象:遍历AccessibleContext时卡死或大量返回null
当抓取对象是一个正在播放动画的Swing窗口时,递归遍历可能会停在某个节点上不动,或者某个子节点反复拿到null。前者通常是JAB在等待目标进程响应,而目标进程的UI线程被长时间任务阻塞。后者是某些自定义组件没有正确实现Accessible接口,本身就没有上下文。
解决卡死最直接的办法是给遍历加超时。我通常把整个抓取操作放到一个线程池里,用Future.get(timeout)控制最长等待时间,超时后干脆放弃这次抓取,避免主线程被拖死。对于大量null的节点,需要在代码里做两层判空:第一层判断getAccessibleChild(i)返回的对象是否为null,第二层判断其getAccessibleContext()是否为null。一定不要省,这两个null在真实应用里出现的频率远超预期。
5.4 现象:事件回调里操作Swing组件抛异常
我第一次把监听器收到的事件用来更新一个JTextArea,结果控制台直接抛java.awt.IllegalComponentStateException。原因是JAB的事件回调线程不是AWT事件分派线程,直接在回调里触碰Swing组件,破坏了Swing的单线程规则。
解决方式是使用SwingUtilities.invokeLater()把UI更新操作投递到EDT。代码结构如下:
SwingUtilities.invokeLater(() -> { textArea.append("属性变化: " + e.getPropertyName()); });注意这里必须捕获e的副本,因为它是final引用。从这以后,我的所有JAB回调里只做数据收集,所有UI操作一律打包交给EDT。这个习惯也避免了很多偶发性的随机崩溃。
5.5 现象:JDK 11以上运行直接ClassNotFoundException
换到高版本JDK后,编译时找不到com.sun.java.accessibility.util包。这是JDK模块化的后果,JDK 9以后这个包不再默认随JDK分发,而小应用依赖它。
解决方法是把JDK 8的jaccess.jar手动提取出来放到工程lib目录,然后在编译和运行时都把它加入classpath。我一般在工程里同时保留一个README,写明“需要从JDK 8安装目录中获取jaccess.jar,且运行环境必须使用与DLL位数一致的JDK”。如果你不想依赖旧JDK,就只能改写为JNI直接调WindowsAccessBridge-64.dll,工作量会大不少,性价比不一定高。
6. 把抓取能力变成可用工具:参数化输出、角色过滤与双通道验证
6.1 用命令行参数控制角色过滤与输出格式
如果只是打印到控制台,这个工具只能算demo。我把它改成命令行工具后,最常用的动作是传入两个参数:目标窗口标题和要过滤的角色。比如Main "登录窗口" BUTTON会只输出该窗口下所有按钮。实现思路是在Main里把第二个参数解析为AccessibleRole实例,在遍历时做一次角色判断。
6.2 输出CSV文件便于外部分析
抓出来的树直接打印成CSV,就能用Excel或脚本进一步处理。CSV每一行包含路径、角色、名称三列,路径由遍历深度拼接而成。输出时注意转义逗号和引号,否则中文和特殊字符会破坏格式。
6.3 双通道交叉验证抓取结果
最让我受益的验证方法是把JAB抓出来的控件树与屏幕截图做对比。先截图,再在截图上标注抓取到的控件的bounding box,一张图就能看出AccessibleName与视觉位置是否匹配。很多自动化测试失败,根本原因是控件名称被JAB返回成了内部变量名,而不是界面上显示的文字。我在一个复杂权限管理界面上就遇到过,按钮文本是“提交”,AccessibleName却是空的,最后是靠角色和相邻Label才准确定位。从那以后,我每次做完抓取都会强制走一遍“树落盘、截图、坐标比对”的流程,宁可多花五分钟,也不让一个错误定位混进自动化脚本。希望帮到你。
本文还有配套的精品资源,点击获取