实战 Groovy: SwingBuilder 和 Twitter API,第 1 部分

构建基于 Swing 的 GUI 从未如此简便

在这一期 实战 Groovy 中,Scott Davis 要讨论一个令大多数服务器端 Java™ 开发人员畏惧的主题:Swing。Groovy 的 SwingBuilder 可以让这个强大但复杂的 GUI 框架使用起来简单一些。

我最近会见了 Ted Neward,他是 IBM developerWorks 文章系列 面向 Java 开发人员的 Scala 指南 的作者(见 参考资料)。我们讨论了他在这个系列中构建的一个有意思的 Twitter 库,Scitter (Scala + Twitter)。Scitter 的重点在于 Scala 的 Web 服务和 XML 解析功能,Ted 承认他不太关心为这个 API 提供前端。当然,这启发我考虑用 Groovy 编写一个 Twitter GUI 会怎么样?Gwitter (Groovy + Twitter) 是个不错的名字吧?

在本文中我不打算讨论 Scala 和 Groovy 的集成,尽管在这两种语言之间确实有许多协作的可能性。相反,我要讨论 Java 领域中常常被 Java 开发人员忽视的一个主题:Swing。但是,在此之前,我先谈谈 Groovy 的 XmlSlurper 如何简化 Twitter 的 Atom feed。

Twitter Search API

看一下 Twitter Search API 的在线文档(见 参考资料)。文档表明可以通过发出简单的 HTTP GET 请求搜索 Twitter。查询通过查询字符串中的 q 参数传递,结果以 Atom(一种 XML 联合格式)或 JavaScript Object Notation (JSON) 的形式返回。因此,要想以 Atom 的形式得到所有提到 thirstyhead 的条目,需要发出下面这样的 HTTP GET 请求:http://search.twitter.com/search.atom?q=thirstyhead

如清单 1 所示,返回的结果是嵌套在 <feed> 元素中的一系列 <entry> 元素:

清单 1. Twitter 搜索 Atom 结果
<feed xml:lang="en-US" xmlns="http://www.w3.org/2005/Atom">
  <entry>
    <title>thirstyhead: New series from Andrew Glover: Java Development 2.0
           http://bit.ly/bJX5i</title>
    <content type="html">thirstyhead: New series from Andrew Glover: Java
                         Development 2.0 http://bit.ly/bJX5i</content>
    <id>tag:twitter.com,2007:
        http://twitter.com/thirstyhead/statuses/3419507135</id>
    <published>2009-08-20T02:54:54+00:00</published>
    <updated>2009-08-20T02:54:54+00:00</updated>
    <link type="text/html" rel="alternate"
          href="http://twitter.com/thirstyhead/statuses/3419507135"/>
    <link type="image/jpeg" rel="image"
          href="http://s3.amazonaws.com/twitter_production/profile_images/
          73550313/flame_normal.jpg"/>
    <author>
      <name>ThirstyHead.com</name>
      <uri>http://www.thirstyhead.com</uri>
    </author>
  </entry>

  <entry>...</entry>
  <entry>...</entry>  
  <!-- snip -->
</feed>

在 “实战 Groovy:构建和解析 XML” 中,可以看到很容易使用 Groovy 的 XmlSlurper 处理 XML 结果。既然了解了这些结果的形式,就来创建一个名为 searchCli.groovy 的文件,见清单 2:

清单 2. 解析 Atom 结果的 Groovy 脚本
if(args){
 def username = args[0]
 def addr = "http://search.twitter.com/search.atom?q=${username}"
 def feed = new XmlSlurper().parse(addr)
 feed.entry.each{
   println it.author.name
   println it.published
   println it.title
   println "-"*20
 }  
}else{
 println "USAGE: groovy searchCli <query>"
}

在命令行上输入 groovy searchCli thirstyhead,就会显示简洁的 Atom 结果,见清单 3:

清单 3. 运行 searchCli.groovy 脚本
$ groovy searchCli thirstyhead

thirstyhead (ThirstyHead.com)
2009-08-20T02:54:54Z
New series from Andrew Glover:
Java Development 2.0 http://bit.ly/bJX5i
--------------------
kung_foo (kung_foo)
2009-08-18T12:33:32Z
ThirstyHead interviews Venkat Subramaniam:
http://blip.tv/file/2484840 "Groovy and Scala are good friends..."
(via @mittie). very good.

//snip

创建最初的 Gwitter 类

Groovy 脚本很适合编写非正式的实用程序和证实概念,但是编写 Groovy 类也不太困难。另外,可以编译 Groovy 类并从 Java 代码调用它们。

例如,可以编写清单 4 所示的 Tweet.groovy:

清单 4. Tweet.groovy
class Tweet{
  String content
  String published
  String author

  String toString(){
    return "${author}: ${content}"
  }
}

这是一个 Plain Old Groovy Object (POGO),是非常复杂的 Plain Old Java Object (POJO) 的替代品。

现在,把 清单 2 中的搜索脚本转换为 Search.groovy,见清单 5:

清单 5. Search.groovy
class Search{
  static final String addr = "http://search.twitter.com/search.atom?q="

  static Object[] byKeyword(String query){
    def results = []
    def feed = new XmlSlurper().parse(addr + query)
    feed.entry.each{entry->
      def tweet = new Tweet()
      tweet.author = entry.author.name
      tweet.published = entry.published
      tweet.content = entry.title
      results << tweet
    }
    return results as Object[]    
  }
}

通常情况下,我会让结果保持 java.util.ArrayList 的形式。但是,本文后面使用的 javax.swing.JList 需要一个 Object[],所以这里提前做一些准备。

注意,我在 Search.groovy 中去掉了 main() 方法。现在如何与这个类交互呢?当然可以通过单元测试!创建 SearchTest.groovy,见清单 6:

清单 6. SearchTest.groovy
class SearchTest extends GroovyTestCase{
  void testSearchByKeyword(){
    def results = Search.byKeyword("thirstyhead")
    results.each{
      assertTrue it.content.toLowerCase().contains("thirstyhead") ||
                 it.author.toLowerCase().contains("thirstyhead")
    }    
  }
}

如果在命令提示上输入 groovy SearchTest,然后看到 OK (1 test)(见清单 7),就说明已经成功地把搜索脚本转换为可重用的类了:

清单 7. 成功测试的运行结果
$ groovy SearchTest
.
Time: 4.64

OK (1 test)

现在底层基础结构已经就位了,下一步是开始为它提供漂亮的前端。


SwingBuilder 简介

Swing 是一个极其强大的 GUI 工具集。但糟糕的是,有时候其复杂性会影响开发人员挥发它的能力。如果您刚接触 Swing,会觉得像是在学习开波音 747,而您实际上只需要开单引擎的 Cessna 或滑翔机。

Groovy 的 SwingBuilder 并不能降低各种任务内在的复杂性,比如选择适当的 LayoutManager 或处理线程问题。它降低的是语法复杂性。Groovy 的命名参数/变量参数构造器非常适合需要实例化的各种 JComponent,然后马上可以为它们配置一系列设置器。(关于 SwingBuilder 的更多信息,请参见 参考资料)。

但是,同样有价值的是 Groovy 对闭包的使用。对于 Swing,我长期关注的问题是自然的层次结构似乎在实现细节中消失了。在 Java 代码中,会得到一组相互脱节的组件,看不出哪个组件属于哪个组件。可以以任意次序声明 JFrameJPanelJLabel。在代码中,它们看起来是平等的;但是,实际上 JFrame 包含 JPanelJPanel 进而包含 JLabel。清单 8 给出一个示例:

清单 8. HelloJavaSwing.java
import javax.swing.*;

public class HelloJavaSwing {
  public static void main(String[] args) {
    JPanel panel = new JPanel();
    JLabel label = new JLabel("Hello Java Swing");

    JFrame frame = new JFrame("Hello Java Swing");
    panel.add(label);
    frame.add(panel);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(200,300);
    frame.setVisible(true);
  }
}

编译这段代码 (javac HelloJavaSwing.java) 并运行它 (java HelloJava),应该会显示图 1 所示的应用程序:

图 1. HelloJavaSwing

清单 9 给出用 Groovy 编写的同一个应用程序。可以看到 SwingBuilder 使用了闭包,这让我们可以清晰地看出拥有关系链。

清单 9. HelloGroovySwing.groovy
import groovy.swing.SwingBuilder
import javax.swing.*

def swingBuilder = new SwingBuilder()
swingBuilder.frame(title:"Hello Groovy Swing",
                   defaultCloseOperation:JFrame.EXIT_ON_CLOSE,
                   size:[200,300],
                   show:true) {
  panel(){
    label("Hello Groovy Swing")    
  }
}

输入 groovy HelloGroovySwing 会看到图 2 所示的应用程序:

图 2. HelloGroovySwing

注意,在 清单 9 中,所有组件名去掉了开头的 J,方法名中也去掉了多余的 getset。接下来,注意 frame 的命名参数构造器。在幕后,Groovy 调用无参数构造器,然后调用设置器方法,这与前面的 Java 示例没有区别。但是,设置器方法都集中在构造器中,代码更简洁了,去掉 set 前缀和末尾的圆括号也大大减少了视觉干扰。

如果您不了解 Swing,这段代码看起来可能仍然比较复杂。但是,如果您具备哪怕最粗浅的 Swing 经验,就可以看出它具有 Swing 的特征:干净、清晰和高效。

正如在前一节中所做的,通过脚本了解概念,然后把脚本转换为类。创建文件 Gwitter.groovy,见清单 10。这是 Groovy + Twitter 客户机 UI 的起点。

清单 10. Gwitter UI 的骨架
import groovy.swing.SwingBuilder
import javax.swing.*
import java.awt.*

class Gwitter{   
  static void main(String[] args){
    def gwitter = new Gwitter()
    gwitter.show()
  }

  void show(){
    def swingBuilder = new SwingBuilder()  
    swingBuilder.frame(title:"Gwitter",
                       defaultCloseOperation:JFrame.EXIT_ON_CLOSE,
                       size:[400,500],
                       show:true) {
    }    
  }  
}

输入 groovy Gwitter,确认会出现空的框架。如果一切正常,下一步是在应用程序中添加一个简单的菜单。


添加菜单栏

在 Swing 中创建菜单提供另一个具有自然层次结构的组件示例。创建一个 JMenuBar,它包含一个或多个 JMenuJMenu 进而包含一个或多个 JMenuItem

为了创建包含 Exit 菜单项的 File 菜单,在 Gwitter.groovy 中添加清单 11 中的代码:

清单 11. 在 Gwitter 中添加 File 菜单
import groovy.swing.SwingBuilder
import javax.swing.*
import java.awt.*

class Gwitter{   
  static void main(String[] args){
    def gwitter = new Gwitter()
    gwitter.show()
  }

  void show(){
    def swingBuilder = new SwingBuilder()  

    def customMenuBar = {
      swingBuilder.menuBar{
        menu(text: "File", mnemonic: 'F') {
          menuItem(text: "Exit", mnemonic: 'X', actionPerformed: { dispose() })
        }
      }  
    }    

    swingBuilder.frame(title:"Gwitter",
                       defaultCloseOperation:JFrame.EXIT_ON_CLOSE,
                       size:[400,500],
                       show:true) {
      customMenuBar()                         
    }    
  }  
}

请注意 customMenuBar 闭包的嵌套层次结构。为了便于阅读,这里添加了换行和缩进,但是同样很容易在同一行中定义它。定义这个闭包之后,在 frame 闭包中调用它。再次输入 groovy Gwitter,确认会出现 File 菜单,见图 4。选择 File > Exit,关闭这个应用程序。

图 4. Gwitter 的 File 菜单

再看看 清单 11。注意,actionPerformed 处理函数定义为闭包,而不是匿名类。与相应的 Java 代码相比,这样的代码更干净、更容易阅读。

现在,添加一些表单元素以执行搜索。


添加搜索面板

经验丰富的 Swing 开发人员善于用单独的 JPanel 组装出最终的应用程序。这些容器组件可以方便地把相似、相关的组件分组在一起。

例如,Gwitter 需要一个 JTextField(让用户能够输入搜索条件)和一个 JButton (用于提交请求)。把这两个组件分组在一个 searchPanel 闭包中是有意义的,见清单 12:

清单 12. 添加搜索面板
import groovy.swing.SwingBuilder
import javax.swing.*
import java.awt.*

class Gwitter{   
  def searchField

  static void main(String[] args){
    def gwitter = new Gwitter()
    gwitter.show()
  }

  void show(){
    def swingBuilder = new SwingBuilder()  

    def customMenuBar = {
      swingBuilder.menuBar{
        menu(text: "File", mnemonic: 'F') {
          menuItem(text: "Exit", mnemonic: 'X', actionPerformed: {dispose() })
        }
      }  
    }    

    def searchPanel = {
      swingBuilder.panel(constraints: BorderLayout.NORTH){
        searchField = textField(columns:15)
        button(text:"Search", actionPerformed:{ /* TODO */ } )
      }
    }

    swingBuilder.frame(title:"Gwitter",
                       defaultCloseOperation:JFrame.EXIT_ON_CLOSE,
                       size:[400,500],
                       show:true) {
      customMenuBar()                         
      searchPanel()
    }    
  }  
}

开始处理面板之后,就要选择适当的 LayoutManger。在默认情况下,JPanel 使用 FlowLayout。这意味着 textFieldbutton 挨着水平排列。

JFramecontentPane 不太一样 — 它在默认情况下使用 BorderLayout。这意味着在框架中添加 searchPanel 时需要指定它应该出现在哪个区域:NORTHSOUTHEASTWESTCENTER。(如果您的地理知识实在糟糕,也可以使用 PAGE_STARTPAGE_ENDLINE_STARTLINE_ENDCENTER)。关于 Swing 中可用的各种 LayoutManager 的更多信息,请参见 参考资料

注意,searchField 变量是在类级声明的。因此,按钮等其他组件也可以访问它。其他组件都是匿名的。快速浏览一下类属性,就会看出某些组件比较重要。

您可能已经注意到按钮的 actionPerformed 监听器目前没有做任何事情。现在实际上还不需要它做什么。在实现它之前,需要在应用程序中添加另一个面板:用来显示搜索结果的面板。


添加结果面板

如清单 13 所示,像对待 searchPanel 那样,在嵌套的闭包中定义 resultsPanel。但是,这一次在这个面板中嵌套另一个容器:JScrollPane。这个组件可以根据需要显示和隐藏水平和垂直滚动条。Search.byKeyword() 方法调用的结果显示在名为 resultsListJList 中。(JList.setListData() 方法接受一个 Object[]— 这就是 Search.byKeyword() 方法返回的结果)。

清单 13. 添加 resultsPanel
import groovy.swing.SwingBuilder
import javax.swing.*
import java.awt.*

class Gwitter{   
  def searchField
  def resultsList

  static void main(String[] args){
    def gwitter = new Gwitter()
    gwitter.show()
  }

  void show(){
    def swingBuilder = new SwingBuilder()  

    def customMenuBar = {
      swingBuilder.menuBar{
        menu(text: "File", mnemonic: 'F') {
          menuItem(text: "Exit", mnemonic: 'X', actionPerformed: {dispose() })
        }
      }  
    }    

    def searchPanel = {
      swingBuilder.panel(constraints: BorderLayout.NORTH){
        searchField = textField(columns:15)
        button(text:"Search", actionPerformed:{
          resultsList.listData = Search.byKeyword(searchField.text) } )
      }
    }

    def resultsPanel = {
      swingBuilder.scrollPane(constraints: BorderLayout.CENTER){
        resultsList = list()
      }
    }    

    swingBuilder.frame(title:"Gwitter",
                       defaultCloseOperation:JFrame.EXIT_ON_CLOSE,
                       size:[400,500],
                       show:true) {
      customMenuBar()                         
      searchPanel()
      resultsPanel()
    }    
  }  
}

注意,与 searchField 一样,resultsList 变量是在类级定义的。searchPanel 中按钮的 actionPerformed 处理函数使用这两个变量。

添加 resultsPanel 之后,Gwitter 现在有实际功能了。在命令提示上输入 groovy Gwitter,检查它是否工作正常。搜索 thirstyhead 应该会产生图 5 所示的结果:

图 5. 搜索结果

现在可以宣布成功了,但是我想先解决两个问题。第一个问题是搜索按钮的 actionPerformed 处理函数可能会引起线程问题。另一个问题是这个应用程序太一般了。下面两节解决这些问题。


事件分派线程

Swing 的缺点在于,它期望图形设计师能够应付多线程问题,而这是应该由软件工程师处理的,或者期望软件工程师理解图形设计和易用性问题。

我不可能在短短几段文字中讨论 Swing 应用程序中的线程问题这么复杂的主题。只需指出基本的 Swing 应用程序本质上是单线程的。所有活动都在事件分派线程 (EDT) 上进行。当用户抱怨 Swing 应用程序反应迟缓或完全没有反应时,往往是因为某个开发新手在 EDT 上执行长时间的计算密集型的数据库查询或 Web 服务调用 — 这个线程也负责处理屏幕刷新、菜单单击等。我们无意中在搜索按钮的 actionPerformed 处理函数上犯了同样的错误。(您可以看出多么容易犯这种错误)。

好在 javax.swing.SwingUtilities 类提供了几个方便的方法 — invokeAndWait()invokeLater(),它们可以消除某些线程问题。可以使用这两个方法在 EDT 上同步或异步地执行操作。(关于 SwingUtilities 类的更多信息见 参考资料)。SwingBuilder 让我们很容易调用这两个方法,还提供了第三个选择:可以简便地生成新线程以执行处理时间长的操作。

要想在 EDT 上执行同步调用 (SwingUtilities.invokeAndWait()),可以把调用放在 edt{} 闭包中。要想在 EDT 上执行异步调用 (SwingUtilities.invokeLater()),就把调用放在 doLater{} 闭包中。但是,我想让您体验一下第三个选择:生成新线程来处理 Search.byKeyword() 方法调用。为此,需要把代码放在 doOutside{} 闭包中,见清单 14:

清单 14. 使用 doOutside 闭包
def searchPanel = {
  swingBuilder.panel(constraints: BorderLayout.NORTH){
    searchField = textField(columns:15)
    button(text:"Search", actionPerformed:{
      doOutside{
        resultsList.listData = Search.byKeyword(searchField.text)
      }
    } )
  }
}

在像 Gwitter 这样简单的应用程序中,在 EDT 上执行 Web 服务调用很可能没什么不好的效果。但是,如果把这样的代码拿给 Swing 专家看,他们会用鄙视的目光看您,就像是您在快车道里慢慢地开车,或者把车停在商店停车场的残疾人专用车位上了。因为通过使用 SwingBuilder 很容易正确地处理线程,完全没有理由不这么做。

既然解决了线程问题,下面就让这个应用程序更漂亮一些。


给列表增加条纹效果

坦率地说,Gwitter 目前很难看。我要使用一些 HTML 代码做两个简单的改进,让外观和感觉好一些。JLabel 可以显示基本的 HTML。按清单 15 调整 Tweet.groovy 的 toString() 方法。JList 调用 toString() 方法显示结果。

清单 15. 在 toString() 方法中返回 HTML
class Tweet{
  String content
  String published
  String author

  String toString(){
    //return "${author}: ${content}"

    return """<html>
         <body>
           <p><b><i>${author}:</i></b></p>
           <p>${content}</p>
         </body>
       </html>"""
  }
}

下一个改进略微有点复杂。一种常用的 GUI 技巧是给长的列表或表格加上条纹效果。用不同的颜色显示奇数行和偶数行,这样读者更容易阅读。我在搜索引擎中搜索了 JList stripes,采纳了找到的第一篇文章中的建议。作者建议创建一个定制的 DefaultListCellRenderer。我完全赞同他的意见并按原样借用他的示例代码(完整的文章见 参考资料)。

因为 Groovy 语法是 Java 语法的超集,所以可以把 Java 代码复制到 Groovy 文件中,不需要修改。如果有功能全面的构建系统,可以编译 Java 和 Groovy 代码,那么只需把这段代码留在 Java 文件中。但是,通过把代码文件的扩展名改为 .groovy,我可以运行所有未编译的 Gwitter 代码。我再次利用了 Java 语言和 Groovy 之间的无缝集成。可以在 Groovy 应用程序中不加修改地使用任何 Java 解决方案。

创建文件 StripeRenderer.groovy,添加清单 16 中的代码:

清单 16. 创建有条纹效果的 CellRenderer
import java.awt.*;
import javax.swing.*;

class StripeRenderer extends DefaultListCellRenderer {
    public Component getListCellRendererComponent(JList list, Object value,
            int index, boolean isSelected, boolean cellHasFocus) {
        JLabel label = (JLabel) super.getListCellRendererComponent(list, value,
                index, isSelected, cellHasFocus);

        if(index%2 == 0) {
            label.setBackground(new Color(230,230,255));
        }

        label.setVerticalAlignment(SwingConstants.TOP);
        return label;
    }
}

有了 StripeRenderer 类之后,最后需要让 JList 使用它。按清单 17 调整 resultsPanel

清单 17. 在 JList 中添加定制的 CellRenderer
def resultsPanel = {
  swingBuilder.scrollPane(constraints: BorderLayout.CENTER){
    //resultsList = list()
    resultsList =
       list(fixedCellWidth: 380, fixedCellHeight: 75, cellRenderer:new StripeRenderer())
  }
}

在命令提示上再次输入 groovy Gwitter。搜索 thirstyhead 应该会产生图 16 所示的结果:

图 6. 有条纹效果的结果

我可以花更多时间美化 Gwitter 的外观和感觉,但是我希望您对大约 50 行 Swing 代码(当然不包括支持类)所实现的效果印象深刻。


结束语

正如本文中指出的,Groovy 并不能降低 Swing 内在的复杂性,但是它可以显著降低语法复杂性。这让您能够留出时间应付更重要的问题。

如果本文引起了您对 Groovy 和 Swing 的兴趣,您应该好好研究一下 Griffon 项目(见 参考资料)。它提供许多优于 Grails 项目的功能和惯例,但是它基于 SwingBuilder 和 Groovy 而不是 Spring MVC 和 Hibernate。这个项目仍然处于早期阶段(到编写本文时最新版本是 0.2),但是它已经很出色了,在 JavaOne 2009 上赢得了 Scripting Bowl for Groovy。另外,它提供的示例项目之一是 Greet,这是一个用 Groovy 实现的完整的 Twitter 客户机。

下一次,我将在 Gwitter 中添加一些必备特性:发布新 Tweet 的功能。在此过程中,您将学习如何处理基本的 HTTP 身份验证、执行 HTTP POST 以及使用与 XmlSlurper 相似的 ConfigSlurper。在此之前,我希望您探索应用 Groovy 的各种可能性。


下载

描述 名字 大小
文章示例的源代码 j-groovy09299.zip 24KB