Von C nach Java, Teil 5: Wie eine grafische Oberfläche entsteht

Seite 3: Menüs

Inhaltsverzeichnis

Eine Anwenderklasse kann entweder die Methoden eines Interface implementieren oder aber die implementierende Klasse in den jeweiligen Code einbetten. In dem Fall braucht es nicht der Implements-Klausel zu Beginn der Klasse. Zu der Variante, die nicht gerade die Übersichtlichkeit des Codes fördert, sei nun das obige Beispiel entsprechend abgewandelt gezeigt:

public void buildGUI() {
...
jbOk.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
...
}
});
...
}

Diese Ad-hoc-Definition hat den Vorteil, dass die Implements-Klausel entfällt und sich der Code bequem und "handlich" integrieren lässt. Sie eignet sich allerdings allenfalls dann, wenn nur wenig Code in actionPerformed() untergebracht ist und es sich dabei um einen von bestenfalls wenigen Kontrollelementen handelt. Wenn viele bedient werden sollen, ist es empfehlenswert – allein schon aus Gründen der Übersichtlichkeit –, die Methoden der Interfaces sauber zu implementieren. Ein weiterer Aspekt ist, dass der Java-Compiler eingebettete Klassen als eigene Klassendateien speichert, die im Dateinamen einen "$" und dahinter den Index der eingebetteten Klasse (Inner Class) enthalten. Wäre die obige eingebettete Klasse die erste im Quelltext, wäre der Klassenname:

CFileCrypterGUI$1.class

Normalerweise speichert der Java-Compiler den Namen einer eingebetteten Klasse hinter dem "$" ab. Da allerdings bei diesen implementierten Interfaces keine Namen vorhanden sind (die Klassen sind anonym), bekommen sie halt im Dateinamen Nummern zugewiesen.

Hier eine kleine Übersicht von Pro und Kontra zur Verwendung eingebetteter Klassen:

Pro:

  • schnell im Code integriert.
  • ideal für eine Ad-hoc-Implementierung eines Kontrollelements mit nur einem behandelten Event.

Contra:

  • sehr unübersichtlich.
  • Klasse lässt sich nicht für andere Fälle an anderer Stelle im Code mitbenutzen.
  • Der Zugriff auf dynamische Variablen der enthaltenen Methode ist nicht möglich.

Im weiteren Verlauf erstellt der Autor ein Haupt- und ein Kontextmenü, das auf das Hauptkontrollelement des Dialogs (Liste mit den Dateinamen) gelegt wird. Ist die GUI erst einmal gebaut, obliegt die weitere Steuerung des Programms den Listenern. Die ihnen zugeordneten Kontrollelemente bedient der Benutzer, der Buttons, Scrollbars, Checkboxen usw. betätigt. Man beendet das Programm durch explizites Auswählen von Exit aus dem Hauptmenü oder durch das Schließen des Fensters.

Wie ordnet man nun einer grafischen Anwendung ein Hauptmenü zu? Ein in einer oberen Leiste platziertes Menü bildet die Swing-Klasse JMenuBar ab. Das Programm verwendet eine Methode zum Aufbau des Hauptmenüs, nämlich initMainMenu(), die ein JMenuBar-Objekt zurückgibt und sich auch in anderen Programmen verwenden lässt. Als Argument akzeptiert die Methode eine Liste mit Strings, wobei jeder String einen Hauptmenüpunkt (immer sichtbar in der Menüleiste) beschreibt. Jeder Hauptmenüpunkt enthält ein Pop-up-Menü, das bei Anwahl des jeweiligen Menüpunkts aufklappt und die weitere Auswahl zulässt. Jeder String umfasst demzufolge eine weitere Liste an Strings, die – mit einem Semikolon voneinander getrennt – die weiteren Menüpunkte enthalten. Ein Separator (Trennlinie) wird durch ein "-" ausgedrückt.

Mit der Methode lassen sich einfache Hauptmenüs einer grafischen Anwendung implementieren. Ein Nachteil ist, dass sich weitere Untermenüs nicht ohne weiteres einfügen lassen. Hierzu müsste zunächst der String, der einen Hauptmenüpunkt enthält, etwas anders aufgebaut sein und wäre dann die Methode initMainMenu() entsprechend zu erweitern, was für den Leser sicherlich eine weitere interessante Aufgabe wäre.

Hier nun die Methode initMainmenu():

public JMenuBar initMainMenu(String[] sMenuList) {
JMenuBar jb=new JMenuBar();
for (int i=0;i<sMenuList.length;i++) {
String[] sl = sMenuList[i].split(";");
JMenu jm = new JMenu(sl[0]);
for (int j=1;j<sl.length;j++) {
if (sl[j].charAt(0)=='-') {
jm.addSeparator();
} else {
JMenuItem jmi = new JMenuItem(sl[j]);
jmi.addActionListener(this);
jm.add(jmi);
}
}
jb.add(jm);
}
return jb;
}

In der ersten Zeile wird ein neues JMenuBar-Objekt erzeugt. Dann wertet man in einer for-Schleife jeden String der übergebenen Liste aus, indem zunächst eine Teilliste mit weiteren Untermenüpunkten, dann ein neues Untermenü (JMenu) erzeugt wird. In einer weiteren for-Schleife werden die jeweiligen Untermenü-Punkte angelegt und dem soeben erzeugten Menü hinzugefügt. Sollte der Menütext "-" sein, erzeugt man statt eines Menü-Items einen Separator. Damit das Programm auf die Menüauswahl reagiert, ist jedem Menü-Item ein ActionListener zuzuordnen. MenuItems rufen – genau wie JButton auch – die Methode actionPerformed() des ActionListener auf. Das bietet sich allein deshalb an, da nur ein Event bei beiden Kontrollelementen interessant ist, nämlich der, der es auslöst (Klick auf Button bzw. auf MenuItem).

Der Einfachheit halber ist der ActionListener hier this, also die Anwendungsklasse, die demzufolge die Methoden des Interface implementieren muss. Hieraus wird ersichtlich, dass die Methode initMainMenu() nicht statisch sein kann, weil es sonst kein this-Objekt gäbe. Sollte die Methode statisch implementiert werden, wäre der ActionListener am sinnvollsten als Argument mit zu übergeben. Zum Ende der äußeren for-Schleife wird das Untermenü dem JMenuBar-Objekt hinzugefügt.

Dreh- und Angelpunkt des FileCrypter-Programms sind die zu verschlüsselnden und zu komprimierenden Dateien. Das ideale Objekt zum Bearbeiten und Speichern von Informationen über Dateien ist eine dynamische Liste, die Swing mit JTable umsetzt. Das Pendant der Win32 API wäre die ListView. Genau wie dieses Kontrollelement kann das Objekt zweidimensionale Datensätze verwalten. Jedes Element (im Beispiel die Datei) wird in einer Zeile (Row) abgelegt und hat die gleiche Anzahl an Spalten, die die jeweiligen Attribute des Eintrags aufnehmen. Die Anzahl der Zeilen soll dynamisch sein, die der Spalten hingegen ist fix. Zur Verwaltung eines JTable-Objekts, inklusive seines Aussehens und seiner Daten, haben die Java-Entwickler einige Hilfsklassen und Interfaces bereitgestellt. JTable ist ein gutes Beispiel dafür, dass Dinge, wenn sie möglichst allgemeingültig umgesetzt werden sollen, oft kompliziert sind und ungleich viel Code zur Folge haben. Optimiert man das Programm aber auf die exakt benötigten Elemente hin, wird schnell deutlich, dass auch erheblich weniger Code zum gleichen Ziel führt.

Mehrfach hat sich der Ansatz bewährt, eine kleine, allgemeingültige Routine zur Erzeugung eines komplexeren Objekts zu entwickeln, und genau das ist auch bei JTable der Fall. Das Objekt wird mit kaum Aufwand erzeugt:

JTable jtFileTable = new JTable();

Die Liste soll mit einer Scrollbar versehen sein, die ein bequemes Navigieren durch die Elemente ermöglicht. Das erreicht der Entwickler dadurch, dass er sie auf eine Scrollpane legt, die mit ihrer Position und Größe gleichzeitig die Position und Größe der Liste beschreibt. (Das JTable-Objekt, das einer Scrollpane zugeordnet ist, liegt quasi deckungsgleich auf der Scrollpane.) Ähnlich wie für das Erstellen von Menüeinträgen soll ein String Array an die zu erzeugende Methode übergeben werden, wobei jeder String des Arrays eine Spalte beschreibt. Diese soll einen Namen, die Größe, den Datentyp und ein Flag enthalten, ob der Inhalt der Spalte editierbar ist oder nicht. Die Liste mit den Konfigurations-Strings für die Spalten der Tabelle sieht folgendermaßen aus:

String[] sColumnList = {
"40;I;Nr.;0","320;S;Filename;0","50;B;Marked;1","60;B;
Processed;0","100;I;Filesize;0"
};

Jeder String enthält eine weitere Liste mit Attributen für die jeweilige Spalte. Die folgende Tabelle liefert eine detaillierte Erklärung:

Element Typ Beispiel Beschreibung
1 Zahl 40 Spaltenbreite in Pixel
2 Buchstabe I I=Numerisch, S=String, B=Boolean (Checkbox)
3 String Nr. Spaltentext
4 Ziffer 0 Editierbar? 0=Nein, 1=Ja

Hier nun das Beispiel der Methode, die ein JTable-Objekt fertig initialisiert:

public static void initTable(JTable jt, String[] sColumnList, 
int maxRows, boolean bEdit, Font font) {
int n=sColumnList.length;
Object[][] objArray = new Object[maxRows][n];
boolean[] bCanEdit = new boolean[n];
@SuppressWarnings("rawtypes")
Class[] cTypes = new Class[n];
int[] iColSizeList = new int[n];
String[] sColumnTextList = new String[n];
for (int i=0;i<n;i++) {
String[] sl=sColumnList[i].split(";");
switch (sl[1].toUpperCase().charAt(0)) {
case 'B': cTypes[i]=Boolean.class; break;
case 'I': cTypes[i]=Integer.class; break;
case 'S': cTypes[i]=String.class; break;
case 'D': cTypes[i]=Date.class; break;
default : cTypes[i]=String.class; break;
}
bCanEdit[i]=bEdit;
if (sl.length>3) {
bCanEdit[i]=sl[3].startsWith("1");
}
iColSizeList[i]=Integer.parseInt(sl[0]);
sColumnTextList[i] = sl[2];
}
@SuppressWarnings("rawtypes")
final Class[] types=cTypes.clone();
final boolean[] canEdit = bCanEdit.clone();
jt.removeAll();
jt.setModel(new DefaultTableModel(objArray, sColumnTextList) {
private static final long serialVersionUID = 7L;
@SuppressWarnings({ "unchecked", "rawtypes" })
public Class getColumnClass(int i) { return types[i]; }
public boolean isCellEditable(int i, int ci)
{ return canEdit[ci]; }
});
for (int i=0;i<n;i++) {
jt.getColumnModel().getColumn(i).setMaxWidth
(iColSizeList[i]);
jt.getColumnModel().getColumn(i).setResizable(false);
}
if (font!=null) jt.setFont(font);
}

Die Methode erhält mehrere Parameter. Für die eigene Verwendung wäre zu überlegen, ob alle Parameter zwingend notwendig sind. Die Methode lässt sich also ohne weiteres mit weniger Parametern implementieren. Die folgende Tabelle gibt eine Übersicht zu den verwendeten Variablen:

Variable Typ Beispiel Bedeutung
JTable JTable-Objekt N/A JTable-Objekt, das initialisiert werden soll
String[] String Array "40;I;Nr.;0","320;S;Filename;0","... Dieses String Array beschreibt die Spalten der Tabelle
maxRows Integer 100 Die initial festgelegt maximale Anzahl an Zeilen
bEdit Boolean True Legt für alle Spalten fest, ob sie editierbar sind (true) oder nicht (false)
Font Fint-Objekt Courier Font Legt den Font zur Verwendung mit dieser Tabelle fest

Da hier einige Kniffe zur Anwendung kommen, folgt eine detaillierte Erklärung zur Methode. Zu Beginn wird ein zweidimensionales Feld mit Objects angelegt, das die einzelnen Zellen der Tabelle enthalten soll. Anschließend legt man noch weitere Felder für die jeweiligen Spalten an, die die Variablentypen, Breite, Spaltennamen und das Edit-Flag enthalten.

Nun folgt eine for-Schleife, die für jede Spalte durchlaufen wird und die Parameter der Spalte setzt, die sich teils in den "frisch" angelegten Arrays, teils im übergebenen String für die Spalte befinden. Nun löscht das Programm mit der Methode removeAll() alle Elemente der Tabelle, was den Vorteil hat, dass sich initTable() auch während der Programmausführung aufrufen lässt und damit alle etwaigen Einträge der Tabelle gelöscht werden.

Mit einem Trick, der zugegebenermaßen zu Lasten der Übersicht geht, passt der Entwickler das Modell der Tabelle auf die übergebenen Parameter hin an. Das geht nur, wenn die Klasse DefaultTableModel neu erzeugt und an die Tabelle "angeheftet" wird. Die Klasse ließe sich auch als separate Klasse in einer separaten Java-Quelldatei implementieren, nur bläht das den Code nicht unerheblich auf. Da das TableModel nur einmal zu erzeugen ist, nämlich genau an der oben abgebildeten Stelle, reicht es vollkommen aus, den Code der Klassenerzeugung wie oben gezeigt zu implementieren. Der Java-Compiler macht daraus eine anonyme innere Klasse und erzeugt hierfür eine separate Klassendatei (z. B. CFileCrypterGUI$2.class).

Im Beispiel dient TableModel in erster Linie dem Bestimmen des Datentyps der Zelle und der Entscheidung, ob eine Zelle durch den Benutzer editierbar sein soll oder nicht. Letzteres kann in der "Erstellungsmethode" des JTables auf zwei Wegen erfolgen. Einmal sozusagen "global" für alle Zellen, wobei das Flag bEdit als direktes Argument mit übergeben wird. Den anderen Weg beschreitet das oben beschriebene String Array. Hier ist als optionales viertes Element eine Boolean-Variable vorgesehen, die mit einem Wert von "1" das Editieren erlaubt, ansonsten nichts.

Der Artikel hat gezeigt, wie sich ein zuvor erstelltes Kommandozeilenprogramm mit einer kleinen grafischen Oberfläche versehen lässt. Hierbei wurden die wesentlichsten Elemente und Methoden vorgestellt, wobei der Autor das Hauptaugenmerk auf das Erstellen der GUI gerichtet hat.

Ein nächste Teil beschreibt die Reaktion auf Benutzereingaben, den Kompressions-/Verschlüsselungsvorgang und den Aufruf der früher vorgestellten Kompressionsklasse. Dazu gehört die Beschreibung professioneller Methoden wie das Nutzen von Threads, um sicherzustellen, dass eine GUI auch bei länger andauernden Verarbeitungsschritten "flüssig" bedienbar bleibt.

Andreas Nieden
blickt auf fast drei Jahrzehnte an Erfahrung mit Programmiersprachen wie Assembler, C und C++ zurück und ist in den letzten 15 Jahren als Berater im Netzwerk- und Systemmanagement-Umfeld bei Großunternehmen tätig.
(ane)