GUI编程和Java Swing开发快速指南

2021年3月8日16:25:57 发表评论 1,651 次浏览

本文概述

前言

Java GUI程序开发

许久了,终于又回头学下Java GUI编程,当初学的时候就觉得用处不大。Java GUI确实应用场合很少,开发桌面端它比不上QT,而且默认界面比较丑,当然现在出了JavaFX界面漂亮了很多。但是目前还是很少接触Java GUI的开发,所以学习Java GUI编程的理由是什么?从应用上说,平时需要用到的一些工具可以用Java开发,GUI辅助,还可以作为MVC和MVP的项目练习,对于我个人来说主要是这个作用,所以我不打算花过多时间在Java GUI上,本文针对Java Swing一次性把核心的东西分析完。

另外的问题是,既然有JavaFX了为什么还要用Java Swing?因为我目前还是用Java 8,而JavaFX在Java 8中还不算成熟(参考知乎),如果你要用JavaFX,则最好使用Java 8以后的版本,而且我打算长期都是使用Java 8。

既然使用Java 8,那Swing这丑陋的界面该如何解决?使用主题或者你可以设置Look And Feel,这里使用FlatLaf主题。再说,牛逼轰轰的IDEA也是用Java Swing,这大概是看过最好看的Java Swing界面了,FlatLaf也是模仿它的。

Event Loop事件循环

如果只是讨论Java Swing的使用,那本文就可以提前结束了,因为就GUI布局来说并不难。这里想要说一下普遍意义上的GUI中的事件循环Event Loop,GUI平台一般包括:桌面端、移动端和Web。而这些GUI程序基本都有一个共同的特征:Event Loop。

正常情况,我们编写一个程序,然后启动运行,执行完就结束了,例如命令行程序。但是GUI程序启动运行并不会马上结束,原因主要是因为程序中有一个Loop,这个Loop保证只有用户请求退出程序的时候才结束,简单来说这个Loop就是一个while循环。

GUI程序有两个主要任务:绘制界面和事件处理,其主要代码可简化为:

while(!exit()) {
        drawWidget();
        Events events = requestEvent(eventProvider);
        dispatch(eventHandler, events);
}

GUI程序需要不断重绘UI组件,我们平时很少接触UI重绘,但是有些情况例如自定义绘制控件会接触到重绘的方法,包括iOS、Android、Web和桌面端都有,重绘控件保证我们在屏幕上看到的控件是友好的。

另外,尤其是在游戏开发中,对重绘的要求比较高,例如重绘的频率,如果界面来不及重绘,那么界面就卡住了。

除了绘制界面,另外一个就是处理事件或消息,这些事件包括所有输入程序的消息,例如键盘、鼠标等,事件会有对应的事件提供者提供给当前程序。Event Loop运行时会向事件提供者发送请求,如果没请求到事件,则会一直阻塞。如果请求到事件,则把该事件分发给事件处理器进行处理,如下图:

GUI event-loop事件循环

由上面的代码你可以看到,如果用户一直没有输入,则程序处于阻塞状态。一般地,多数平台的GUI其UI重绘和事件分发都是在同一个线程中的,或者你听过类似UI线程的概念。

问题是,如果事件处理器和当前Loop处于同一个线程,例如用户点击按钮(输入事件)请求HTTP数据(在事件处理器中进行),这时HTTP请求时间过长,当前程序就一直卡住了,为什么?因为这个HTTP请求和UI绘制是在同一个线程中。你要输入程序的其它操作要等待当前HTTP请求完成,程序才会继续响应事件。一般GUI平台可能不允许你这么做,或超时会直接抛出异常,告诉你:耗时操作不要在UI线程上运行。

普通任务、耗时任务和UI更新

上图中的事件提供者(event provider)提供的事件或消息,其主要数据结构相当于一个任务队列,任何用户输入会被封装成一个任务添加到队列中。当前线程从队列中取出任务进行处理,而这些任务通常使用类似Runnable的封装。

对于一些普通的任务:例如设置界面、更新字体等等,这些都是UI更新的操作,UI更新操作只能在UI线程中进行,这些都属于非耗时任务。GUI程序只是简单地更新一下UI组件,然后重绘UI,接着继续等待用户输入,这是没有问题的。

但是如果用户请求的是一些耗时的任务,如HTTP请求,耗时的数据库操作,或其它一些比较耗时的逻辑,则不能在UI线程中操作。一般地,我们的编程方式是:执行耗时任务,然后更新UI。解决方法是:新建子线程处理耗时任务,但是更新UI仍然只能在UI线程中更新(不能在子线程中直接更新UI,例如直接调用Button的相关函数更新)。

这是不是很困惑?在子线程中完成任务后如何在UI线程中更新UI呢?我们可以封装一个Runnable任务,将这个任务丢到事件任务队列中就行了,这样Event Loop会自动从队列中取出,并在UI线程中执行。

在Java Swing中实现UI更新的方法是使用SwingUtilities.invokeLater和SwingUtilities.invokeAndWait,它们的使用的区别说明如下:

SwingUtilities.invokeLater(Runnable);  // 添加任务到队列, 并继续在当前线程中执行
SwingUtilities.invokeAndWait(Runnable);  // 添加任务到队列中, 并在当前线程中等待, 直到该任务运行完成再继续执行

这两个函数一般在子线程中调用,如果在UI线程中调用invokeAndWait,这会把UI线程都阻塞住。

我们可以推测:所有GUI编程更新UI的方式都是:在子线程中把更新UI的逻辑作为一个任务添加回事件/消息任务队列中,让Event Loop取出进行处理。

Event Loop有另外一些不同的称呼,例如Run Loop,iOS中就是这个名称,而iOS开发中子线程更新UI也是类似这个这个逻辑:往UI线程的任务队列添加任务。

在Android中Event Loop称为Looper,它也有自己的任务队列,将更新任务添加到UI线程的任务队列的方式有多种,最直接的是使用Handler.post方法。

Java Swing基础编程

Java Swing主要类结构

Java Swing的主要基类为Component,这本身属于AWT的。任何Component及其子类都是UI组件,都可以显示到屏幕上。Container是Component的直接子类,这是一个容器类,该类及其子类都可以作为容器添加其它的Component组件。

下面是Java Swing的主要类结构:

Java Swing类层次结构

其中Panel是一个面板类,它是不可视的,一般我们将需要的组件添加到Panel中,而不是添加到Container中。一般一个窗口拥有一个或几个Panel,Panel作为控件的基础容器。

Window是一个窗口类,表示一个承接界面元素的组件,常见的窗口组件包括JFrame和JDialog。创建一个操作界面以JFrame为核心,例如可以直接new一个JFrame,或者继承JFrame进行编程。

JComponent及其子类表示Java Swing的所有组件,swing组件相对于AWT的主要区别体现在这里。

另外Swing还提供一些辅助类,这些类封装一些基础信息供组件使用,例如Graphics、Color、Font、Dimension、LayoutManager等。这些类的使用比较简单,你可以查看API文档获得更全面的描述。

GUI布局:Java Swing布局

GUI布局是一件非常烦杂、烦恼的工作,为了使这项工作能够更为顺利一点,首先我建议一定要预先做好需求文档,例如准备好交互原型图。不然的话,这设计界面也不知道在做什么,调来调去,时间很快就过去了。

拿到原型图后,本人建议的方法是根据原型图画一下简单的矩形线框,如下图:

GUI布局矩形线框图

对于这些所有矩形采取集合的方式处理:

  • 任何矩形集合不包含其它矩形的作为基本矩形集合,这样的集合一般对应一个最简单的控件元素。
  • 包含一个或几个矩形的作为一个容器处理,一个界面可能会用到多个容器,另外也不要怕添加了冗余的容器。
  • GUI布局的意思是,确定一个容器,对该容器的直接子集选择一个布局类型进行布局,要注意是直接子集(直接的子容器或控件元素)。
  • 布局的基本步骤为:创建一个容器,然后往容器中添加基本控件元素,最后选择一个布局。布局的方向建议从上而下,意思是先从界面底层的最简单容器开始,最简单容器直接包含控件。
  • 一个可能的快速布局方式是:选择一个容器,一个容器对应一个Panel,分析该容器的直接组件。如果这些组件是连续均匀排布的可以选择流式布局或盒布局或网格,如果是非连续的布局,可以使用边界布局或卡片布局。

Java Swing支持5中布局:FlowLayout、BorderLayout、GridLayout、BoxLayout和CardLayout。

FlowLayout

流式布局,组件在容器中依次连续按行排列,排满一行自动换行。另外可以指定组件对齐方式,例如往左对齐。该布局的主要特点是:容器中的每个组件是水平连续的。

private JPanel flowLayout() {
        JPanel flowPanel = new JPanel();
        flowPanel.add(new JButton("Title"));
        flowPanel.add(new JLabel("Label"));
        flowPanel.add(new JTextField("Input"));
        flowPanel.add(new JTextArea("Text..."));
        FlowLayout flowLayout = new FlowLayout(FlowLayout.RIGHT, 12, 12);
        flowPanel.setLayout(flowLayout);
        return flowPanel;
    }

BorderLayout

该布局将容器中的组件按照靠上下左右的方式布局,该布局的主要特征是:同级组件靠边布局,所以叫做边框布局。

private void borderLayout() {
        Container borderPanel = getContentPane();
        borderPanel.add(new JButton("Button1"), BorderLayout.EAST);
        borderPanel.add(new JLabel("Title1"), BorderLayout.WEST);
        borderPanel.add(new JButton("Button2"), BorderLayout.NORTH);
        borderPanel.add(new JLabel("Title2"), BorderLayout.SOUTH);
    }

GridLayout

该布局使所有组件以网格或表格的形式显示。

private void gridLayout() {
        GridLayout gridLayout = new GridLayout(4, 3);
        setLayout(gridLayout);
        for (int i = 0; i < 12; i++) {
            add(new JButton(i + ""));
        }
    }

BoxLayout

将容器中的组件水平或竖直排成一行或一列。

CardLayout

卡片布局,任何时刻只显示一张卡片,该布局可以制作如轮播图的界面效果。

其它更多的布局可以查看API文档,找到AWT中的LayoutManager接口,查看器子类可以获取更多更灵活的布局。

Java Swing图形绘制

Java Swing中使用Graphics类进行绘制,该类是一个抽象类,直接子类为Graphics2D。绘图坐标(在一个容器中):右上角为坐标原点,水平向右为X坐标递增方向,垂直向下为Y坐标递增方向。

任何时候需要显示组件,JVM会自动为该组件创建一个Graphics对象,然后传递该对象来调用paintComponent来显示图像,paintComponent是组件的一个protected方法。为了在组件上绘图,我们需要扩展JPanel类,并覆盖paintComponent方法。

一个简单的例子如下:

public class MyPanel extends JPanel {

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            g.setColor(Color.WHITE);
            g.drawString("My Panel", 12, 12);
            g.drawLine(30, 30, 100, 200);
        }
    }

另外要提到的一个方法是repaint,当你需要更新绘制的方式时,应该使用repaint告诉系统进行重绘。例如提供一个public的方法,自定义设置不同的背景色,这时候应该很实用repaint,但是不能显式调用paintComponent,也不能重写repaint方法。

更多的绘图API你可以查看API文档,这里就不一一描述了,使用代码绘图需要非常细心,并且需要一些数学思维。

GUI事件驱动程序设计

Java Swing事件驱动的主要概念包括:

  • 事件源(Event Source):发生事件的来源,一般是一个组件,例如JButton,JLabel。
  • 事件(Event):事件有很多种类型,而且这里事件的概念是非常广义的,广义上说,能够添加进事件队列的任务都算是事件。UI事件常见的有鼠标、键盘、输入等等。Swing中事件的基类是EventObject。
  • 事件监听器(Event Listener):用来处理事件的回调接口,一般可以在该回调函数中获取事件源。给一个事件源或组件添加事件监听器的方法是使用addXListener方法。

GUI开发模式:MVC和MVP

MVC和MVP是GUI开发中使用的设计模式,也是最为良好的模式。MVC指的是Model-View-Controller,MVP指的是Model-View-Presenter,MVP是MVC的升级版,其中:

  • View:视图,主要负责视图的绘制、获取用户输入、更新视图,Swing中指的是Component,就是主要的视图所在。
  • Controller/Presenter:控制器/处理器,用于处理主要的业务需求,获取用户输入,调用Model提供的数据服务,处理完成后更新View中的视图。
  • Model:数据服务层,用于为当前需求提供主要的数据操作,比较多提到的例如数据库操作。

MVC是一个比较传统的设计模式,其视图可以和控制器或模型一起互动,虽然比较乱,但是我认为这不是用MVP的主要原因。主要原因应该是MVP更适合测试,MVP中的视图只与Presenter互动,对照需求文档,使用TDD开发,MVP是理想的选择,虽然还有其它变体,但是不得不说,MVP适合大多数的GUI开发场景。

另外一个想要说是Model层,很多人把Model作为数据服务层,我认为这是没问题的。但是我认为Model不限于提供数据库的数据,它是广泛意义上的数据服务,例如HTTP请求、数据库、网络服务、复杂算法操作等。反正只要Presenter认为需要一些外部数据服务,在其中调用的其它数据操作都属于Model层,而Presenter是我们直接完成需求的地方。

总结

其它关于Swing组件的使用可以参考Java API文档,整体不难。GUI编程比较重要的概念,包括本文谈到的Event Loop、事件/消息队列、UI线程、UI更新、GUI布局方法和GUI MVP开发模式。

本人的一些经验,在设计界面的这一阶段非常烦人,不知为何,我有时要搞很久,真的是非常讨厌。不过设计界面的话,推荐尽量使用可视化编辑器开发,例如IDEA默认提供Swing UI Designer,还有一个更优秀的插件:JFormDesigner。

好吧,先到这里,设计界面没有太多东西要说的,都是体力活。

木子山

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: