简介:这是为Java MDI(多文档界面)应用程序设计的一套即用型开源架构,面向需要快速搭建桌面工具、编辑器或管理平台的Java开发者,可省去从零设计窗口管理与子界面协调的重复劳动,尤其适合对Swing技术栈有一定基础、希望掌握传统桌面应用组织方式的中高级开发者,同时也可作为教学培训的配套素材。压缩包共222个文件、2.07MB,其中HTML文档多达159个,包含完整的API索引、类说明与包结构(如AbstractMDIApplication等关键入口页面),配合PNG/JPG截图、JS/CSS辅助资源以及8个JAR库文件,既可用于离线文档查阅,也能直接分析运行依赖与界面实现;少量txt与json文件则补充了配置说明,方便快速上手。该架构源自2006年启动的java.net项目,经长期迭代而稳定,现以开源镜像的形式发布,代码与文档分离清晰,目录结构便于按需检索。目前已有91人学习下载,借助其中的菜单整合、多窗口切换与布局组织等设计示例,开发者既可整体嵌入项目快速成型,也可抽取某部分作为定制化模板,进而深入理解Java桌面MDI模式下的窗体复用、事件分发与子窗口管理机制,是提升桌面开发效率的实用参考。 之前在一个客户现场翻一个维护期的Java桌面系统,最让我头疼的不是业务逻辑散落,而是主窗体的构造函数里密密麻麻写了三百多行。里面有创建JDesktopPane、给内部窗体排位置、维护窗口菜单、监听子窗体关闭后再通知其他模块更新状态……这些都是典型的MDI应用地基逻辑,偏偏写得非常相似,每个项目都得重新来一遍。后来我养成一个习惯:动手写业务模块之前,先确认手头有没有一套现成的MDI架构。
今天聊的MDIFramework就是这类开源项目。它跟那种只给你一个控件、剩下全靠自己拼的组件库不一样,它把Java多文档界面应用的整体骨架直接搭好了,你拿到之后只需要在这个骨架上填业务模块。下面我会按“它到底解决了什么”“核心模块怎么切分”“最小工程怎么接入”“哪几个细节容易翻车”“往团队底座扩展该往哪使劲”这几条线展开,既有这个框架本身的设计思路,也有我在类似场景里替大家趟过的一些经验。
1. 先说清:一个“现成架构”在MDI领域意味着什么
1.1 自己从零写MDI,大家其实都在重复造同一批轮子
我见过太多Swing工程,前三个月开发速度飞快,到了第六个月就开始乱:谁打开了哪个内部窗体没人记录,菜单栏里的窗口列表不同步,关掉一个窗体之后内存里的引用还不清干净,再来一次点击直接打开一个残留状态的旧窗体。这些问题都不是业务,但它们会消耗掉你大量排期。
如果你想完全不依赖现成架构,从零搭一套MDI基础,至少要处理这六件事:
- 主窗体外壳:JFrame、JDesktopPane、菜单栏、工具栏、状态栏的创建与组合
- 内部窗体工厂:根据菜单点击或业务动作创建对应的JInternalFrame
- 窗体注册表:用Map维护当前所有打开的内部窗体,避免重复实例
- 窗口菜单联动:谁打开了、谁激活了、谁关闭了,菜单项要跟着变
- 布局计算:级联、平铺、最小化、还原,每种操作都要重新算坐标
- 状态持久化:重开程序后恢复每个窗口的位置、大小、最大化状态
这六条把每个业务窗体都绑在一堆基础设施代码上。用MDIFramework这类项目,等于把前三条变成了框架职责,业务开发只需要关心“我这个窗口里要放什么组件”,不需要关心“我这个窗口被创建之后怎么登记、怎么进菜单”。
1.2 架构和组件库的本质区别
很多人把MDI理解成“用JDesktopPane + JInternalFrame拼一个界面”,这个理解没错,但那是组件视角。MDIFramework走的不是“给你一个控件”的路线,它给的是“一套约定 + 一个生命周期 + 一组服务”的骨架。
组件库解决的是某一个界面的呈现问题,你这么用也行,那么用也行;架构解决的是整个工程的结构约束问题,你必须在它定义的流程里写代码。它的好处是团队里每个人写出来的窗体长得基本一致,新人接手不用从三百行初始化代码里猜逻辑,坏处是你得先花半天时间理解它的约定,不能上来就胡乱调用。
我个人的观点是,对多数业务型Java桌面项目来说,接受这套约定是划算的。你损失的是一点自由度,换来的是窗体管理逻辑不再到处重复、窗口生命周期可追踪、后续加插件机制也好做。
2. 核心三角:外壳、注册中心与窗口协作服务
拿到这种开源骨架,我一般不会着急往里面塞业务,而是先把源码里的角色划分看明白。MDIFramework这类项目虽然版本不同、类名可能有差异,但最终的模块边界通常逃不出下面这个三角结构。
2.1 外壳层:从main方法到主窗体的启动编排
外壳是框架的启动器,负责把JFrame和JDesktopPane创建好,并且把菜单栏、工具栏、状态栏这些固定区域组装起来。它还会提供两个关键钩子:启动前配置和退出前清理。
我把这类外壳类理解成“整个应用总导演”。它负责在EDT(事件分发线程)上启动界面,保证不会出现在main线程里创建组件这种低级问题。常见的写法是这样:
public abstract class MDIApplication { protected final JFrame mainFrame; protected final JDesktopPane desktop; public MDIApplication() { mainFrame = new JFrame(); desktop = new JDesktopPane(); // 外壳初始化:设置默认关闭操作、创建菜单栏、组装桌面 } public final void launch(String[] args) { // 在SwingUtilities.invokeLater中执行真正启动 } protected abstract void configure(ApplicationContext context); }外壳层有一个容易忽略的好处:它统一了退出逻辑。有些窗体在关闭时需要提示保存、有些后台任务需要中止,如果每个业务窗体自己处理,你很快就会看到“主窗体关了,JVM进程却还在跑”的拖尾程序。框架外壳会提供一个退出钩子,让所有窗体先执行各自的清理动作,再真正销毁主窗体。
2.2 注册中心:内部窗体的登记、复用与回收
注册中心是我每次看MDI架构时最先关注的部分。它本质上是一个按windowId维度的Map,但框架会在上面叠加两层逻辑:懒创建和生命周期状态机。
懒创建的意思是,OrderWindow不是应用启动时全部new出来,而是第一次点菜单时再由工厂方法创建。这个设计对你的内存占用非常友好,尤其是窗体内部带复杂表格、需要加载大量数据时,启动速度不会被拖垮。
状态机则规定一个窗体有哪几种状态:未创建、已打开、已激活、已最小化、已关闭。注册中心会根据这些状态决定菜单项的显示和动作行为。比如你点“订单管理”时它发现这个窗体已经打开且处于最小化状态,那它不会重新new一个,而是把老窗体还原并移到前台。
public class WindowRegistry { private final Map<String, InternalWindow> windows = new HashMap<>(); public InternalWindow open(String windowId) { // 已存在:还原并激活 // 不存在:调用工厂创建后登记 return windows.computeIfAbsent(windowId, this::create); } public void close(String windowId, boolean dispose) { // 根据关闭策略决定是隐藏还是释放 } }这个模块最直接解决的就是“我点多少次菜单都不会开出多个一模一样的窗口”的问题。没有注册中心的MDI代码,十有八九会在某个版本里出现重复窗口叠在一起的诡异场景。
2.3 协作服务:布局、状态持久化、窗口菜单同步
除了外壳和注册中心,框架还需要一组服务类来处理“多个窗口之间的协作”。这组服务往往是区分“成熟架构”和“能跑的demo”的关键。
布局服务负责级联、平铺、全部最小化、全部还原。真正的难点不在算法,而在边界计算:内部窗体不应该盖住主窗体的菜单栏和状态栏,多显示器环境下还要考虑JDesktopPane的实际可视区域。很多自研代码在这里写得非常糙,直接拿desktop.getSize()算坐标,忽略toolbar高度,导致平铺后底下那排窗体被状态栏挡住。
窗口菜单同步是个看着不起眼、实际很烦的活。菜单栏里通常有“窗口”这一项,下面动态列出当前打开的所有内部窗体,而且当前激活的那个窗口要打勾或者加粗。这个逻辑跟注册中心的状态变更必须联动,框架会在注册中心的事件回调里自动刷新菜单项。
状态持久化属于锦上添花但有价值的功能。好的框架会把每个窗体的位置、尺寸、是否最大化存成JSON或properties,下次启动时按记录恢复,而不是让每次都回到初始位置。这块如果自己写,容易踩Java内置序列化的坑,用JSON格式存更可控。
3. 把业务窗体接进骨架:最小工程接入实录
概念说太多不如直接跑一次。这一节我用一个“订单管理 + 客户管理”的后台管理小系统为例,演示怎么把业务窗体接进这套MDI框架。这是通用接入过程,具体类名会跟不同版本的框架略有出入,但流程本身是稳定的。
3.1 引入依赖与准备目录
如果你走Maven路线,按项目README里的坐标依赖加进pom.xml即可,版本号以仓库Release页为准,这里不臆造具体版本。如果公司内网不允许拉外网仓库,也可以把仓库代码clone下来,用maven install到本地私服。依赖引入之后,先确认一个事:jar包里的核心包需要能被你的启动类引用。
我建议在正式编码之前,先在源码里扫一眼MDIApplication和AbstractMDIWindow这两个类的构造方法。因为不同版本对“是否允许缩放”“是否显示图标”这类参数可能会定义在前面还是后面,你直接肉眼对一遍,比编译报错再翻源码要节省时间。
3.2 业务窗体继承统一基类
业务窗体现在要做的第一件事是继承框架提供的内部窗体基类,而不是直接继承JInternalFrame。这么做的好处是,你的窗体天然具备框架需要的能力,比如生命周期回调、优雅关闭、注册中心的类型约束。
public class OrderWindow extends AbstractMDIWindow { private final JTable orderTable = new JTable(); public OrderWindow() { super("order", "订单管理", true, true, true); // 参数说明:窗口标识、标题、是否可关闭、是否可最小化、是否可最大化 } @Override protected JComponent buildContent() { JToolBar toolBar = new JToolBar(); toolBar.add(new JButton("刷新")); JPanel panel = new JPanel(new BorderLayout()); panel.add(toolBar, BorderLayout.NORTH); panel.add(new JScrollPane(orderTable), BorderLayout.CENTER); return panel; } @Override public void onWindowOpened() { loadData(); } @Override public void onWindowClosing() { // 释放后台查询资源、取消未完成的任务 } }注意onWindowOpened和onWindowClosing这两个回调:前者做数据加载,后者做资源释放。它们是框架给你定好的业务落点,你在自己乱七八糟的构造函数里写加载逻辑要规整得多。
3.3 在启动类里注册窗体并绑定菜单
接下来是把窗体告诉框架。这一步在启动类里完成,也是整个应用唯一的装配入口。
public class MyApplication extends MDIApplication { @Override protected void configure(ApplicationContext context) { context.setTitle("进销存管理系统"); context.setWindowSize(1200, 800); context.registerWindow("order", OrderWindow::new); context.registerWindow("customer", CustomerWindow::new); context.menu("窗口") .addItem("订单管理", "order", KeyStroke.getKeyStroke("ctrl O")) .addItem("客户管理", "customer", KeyStroke.getKeyStroke("ctrl M")); } public static void main(String[] args) { new MyApplication().launch(args); } }这里最关键的约定是:registerWindow里的工厂方法OrderWindow::new必须是懒加载的,框架在窗口第一次被打开时才调用它。菜单绑定也同样简单,把windowId传进去,框架会自动把菜单动作跟注册中心的open方法串联起来。
跑起来之后,你会发现“窗口”菜单下面会自动出现当前打开的所有窗体列表,激活某个窗体时菜单勾选状态会同步,主窗体关闭时所有内部窗体都会收到关闭回调。这是这套骨架帮你省下的第一波重复工作。
4. 做桌面端最容易翻车的三个并发与生命周期细节
4.1 EDT线程问题:异步加载数据别直接碰UI
第一次用这类框架的人,很容易在窗体基类里写类似这样的代码:在onWindowOpened回调中直接new一个线程,在线程里查询数据库,再把结果设置到JTable。这是桌面开发最典型的线程事故。
Swing的UI组件不是线程安全的,所有界面修改都必须在EDT上执行。正确做法是用SwingWorker:
@Override public void onWindowOpened() { new SwingWorker<List<Order>, Void>() { @Override protected List<Order> doInBackground() { return orderService.loadAll(); } @Override protected void done() { try { orderTable.setModel(new OrderTableModel(get())); } catch (Exception e) { // 显示错误提示 } } }.execute(); }这个问题的隐蔽性在于:本地数据量小的时候,后台线程偶尔快速执行完,看起来一切正常;一旦数据量上来,或者数据库查询变慢,就会出现偶发的界面卡顿、组件错乱。MDI框架本身不会帮你挡掉这个坑,因为这个问题发生在业务层,但框架把onWindowOpened这个生命周期点留给你,就是提醒你把它当成“数据准备”而不是“界面渲染”的入口。
4.2 内部窗体的关闭语义:隐藏还是释放
在使用JInternalFrame时,必须搞清楚一个事情:关闭按钮默认不一定销毁窗体,它可能只是把窗体隐藏了。如果注册中心没有正确识别“关闭”是隐藏还是释放,内存里会积累大量不用的窗体对象,界面也会出现“打不开新窗口”的错觉。
我在项目里一般是这么定策略的:列表型、轮廓型的窗体用隐藏,关闭后保留状态,方便用户快速切回;表单型、报表型的窗体用释放,因为这类窗口的数据每次打开都需要重新加载,留着缓存反而容易展示过期内容。框架需要在窗口注册时提供一个配置项来控制释放策略,接入时别只写windowId和工厂方法,顺手把释放策略一起定好。
另外注意一点:窗体关闭释放之后,注册中心里的记录必须同步清掉,不然下次打开时会发现工厂方法不会被调用,而是直接拿到一个已释放的无效引用。这个bug用起来相当迷惑,因为逻辑上看起来完全没有问题。
4.3 焦点与快捷键:多窗口下的键盘争夺战
MDI还有一层很隐蔽的体验问题:焦点。当多个内部窗体同时打开时,用户按Ctrl+O或者Ctrl+M,到底应该响应全局菜单动作,还是响应当前激活窗体内部的按钮快捷键?
很多自研代码在这里用的是全局KeyListener或者设置组件的InputMap,结果经常出现焦点在表格里时,方向键被窗体本身消费掉,或者全局Alt菜单快捷键失效。框架层面解决这个问题通常靠两个手段:一是基于Action注册菜单键位而不是基于KeyListener,让Swing的键盘焦点系统自己去分发;二是确保内部窗体激活时把菜单栏的选中状态同步到当前窗体。
这块我的经验是,接入框架后不要马上叠一层自定义全局快捷键。先跑通框架自带的菜单快捷键,再按需求在业务窗体里用ActionMap补充局部快捷键。全局键位和局部键位的优先级,在Swing体系里本身是有明确规则的,你要利用规则,而不是绕过规则。
5. 从开源骨架到团队底座:几条可行的扩展路径
说完了坑,聊点正向的。MDIFramework这类项目最值钱的地方,是你可以在它基础上长出适合自己的团队底座。下面几条路我都试过或看别人用过,按改动量从小到大排列。
5.1 插件化注册:所有窗体自动被发现
如果你有一个多模块工程,每个模块都往主应用里贡献几个窗体,最直接的做法是用Java自带的ServiceLoader机制。让每个模块提供一个WindowProvider实现,主应用遍历所有Provider,把窗体批量注册进去。
for (WindowProvider provider : ServiceLoader.load(WindowProvider.class)) { provider.getWindows().forEach(context::registerWindow); }这样主应用就不需要每接一个模块就改一遍configure方法。新模块丢到classpath里就能被主界面识别,对团队内部“框架统一、业务隔离”是非常合拍的组合方式。
5.2 多屏与DPI适配:桌面工程不能回避的现代问题
老式Swing工程默认单屏开发,到了企业现场全是扩展屏,平铺布局经常跑到副屏不可见区域去。扩展MDI框架时,布局服务里要显式处理GraphicsConfiguration和屏幕Insets,至少要保证新打开的窗体初始位置不超过当前屏幕可视区域。
DPI适配更隐蔽。高分屏下,如果不引入系统缩放感知,内部窗体的表格文字会发虚或者尺寸过小。可以考虑接入FlatLaf这类支持DPI的现代LookAndFeel,把主题颜色、字体尺寸、窗体图标的定义从业务代码里抽出来,通过框架的配置项统一注入。
5.3 与Spring Boot整合:让桌面端和后台共用一套服务
很多Java桌面项目后面都会长出后台服务端。如果你们已经有Spring Boot,就不要在Swing里再手写一套依赖注入,直接想办法让Spring容器接管业务对象,MDI框架这边只负责界面层。
要注意的是启动时序:Spring容器初始化是普通线程,Swing窗口创建必须在EDT,所以通常先在main里启动Spring,等ApplicationContext就绪后,再通过SpringUtils拿到ApplicationContext构造MDIApplication并launch。这个顺序反了会出问题,尤其是窗体里引用Spring的Service时,你会在窗口第一次打开时看到空指针。
5.4 保留、扩展还是魔改:另一种路径
如果框架本身的设计跟你们团队风格不太搭,其实完全可以不引依赖,而是把它当参考实现:把核心类抽出来,简化成你们自己的版本。开源项目的价值不只是“开箱即用”,也有“可以作为蓝本二次开发”这一层。我见过不少团队嘴上说着用了某个框架,实际代码里已经把框架类改得面目全非,这并没有错,但要注意保留对外行为的兼容性,不然下次升级框架版本时,你的魔改代码会全面冲突。
6. 最后聊一点选型与魔改的成本账
MDIFramework这类开源MDI骨架,适合的场景很清晰:你有一个真正的桌面产品要维护,界面是多文档风格,团队希望把精力放在业务功能而不是基础窗口管理上。在这种场景下,它带来的收益不是省几行代码,而是让你的工程从第一个月就有稳定的结构约束。
反过来,如果你只是做一个十几个窗体的内部工具,或者产品形态完全是自定义的标签页风格,那没必要硬套MDI架构。框架也是成本,它有学习成本、约定成本、升级维护成本。我有一次就是脑子发热,为一个只有五个窗体的工具强上了一套重型框架,结果团队写代码之前还要先看半小时架构文档,完全得不偿失。
选型这件事,我现在的判断标准很简单:先看这个项目的核心边界跟你的业务模态是不是一致。MDI框架的核心假设是“业务以内部窗体的形式存在”,你的界面如果不符合这个假设,再好的框架也是负资产。
如果决定用了,我的建议是先把它的启动流程和注册中心这两个部分彻底读明白。这两个类搞清楚了,你基本上就知道这个框架能干什么、不能干什么,也就能判断哪些地方该顺着它,哪些地方该用前面说的SPI方式绕过。
开源项目通常胜在结构透明、思路可借鉴,但也需要你花时间去维护与跟踪。把它的架构消化成自己的东西,再用业务代码去丰富它,这个骨架才算真正长在你的项目里。
本文还有配套的精品资源,点击获取