Den här kursen har en central kärna med kunskaper och färdigheter som alla kommer att behöva, och en stor "periferi" där kunskaper och färdigheter bland annat beror på hur man utformar sitt projekt. Sett över flera års tid är det många uppgifter som återkommer gång på gång, och vi vill gärna hjälpa till med dem. Samtidigt har varje enskild grupp bara nytta av en liten del av detta under kursen – och vet inte i förväg vilken del som behövs.
Därför samlar vi lösningar på dessa ganska vanliga uppgifter här, istället för att alla ska delta i ett antal extra föreläsningar där inget är centralt för kursmålen och bara en bråkdel är nödvändigt för projekten. En del av materialet använder sig av tidigare föreläsningsbilder för att illustrera.
(Sedan kan ju en del av det ändå vara intressant för många, men skulle vi ta upp allt som är intressant skulle kursen ta flera år...)
Vi inkluderar även en del lösningar som många har nytta av, men som rör rena fakta snarare än begrepp att förstå, så att det är bättre att slå upp dem när man behöver dem.
Som vi har sagt på en av de första föreläsningarna skiljer Java på tecken och strängar (0 eller flera tecken):
Python har en hel del specialsyntax för strängar som förenklar hanteringen och indexeringen. Java har mindre sådan syntax, och använder istället vanliga metodanrop för att manipulera strängarna. Därför blir stränghanteringen lite mindre koncis.
Om vi vill plocka ut tecken ur strängar, börjar index på 0, precis som i Python:
>>> s = "Spamalot"
>>> s[1]
'p'
>>> s[-2]
'o'
>>> len(s)
8
>>> s[len(s)-1]
't'
String s = "Spamalot";
s.charAt(1)
s.charAt(s.length() – 2)
s.length()
s.charAt(s.length() – 1)
Om vi vill plocka ut delar av strängar:
>>> s = 'Spamalot'
>>> s[1:]
'pamalot'
>>> s[1:3]
'pa'
>>> s[:5]
'Spama'
>>> s[:-1]
'Spamalo'
>>> s[:]
'Spamalot'
>>> " abc ".strip()
'abc'
>>> longstring.splitlines()
String s = "Spamalot";
s.substring(1)
s.substring(1,3)
s.substring(0,5)
s.substring(0, s.length() – 1)
s
s.strip()
s.lines()
Om vi vill jämföra strängar, använder vi inte operatorn "==". Den finns visserligen, men den testar om två variabler pekar på samma strängobjekt (samma minnesadress), medan equals() testar om två strängar innehåller samma tecken, vilket man oftare är intresserad av. Mer om detta på en föreläsning.
>>> "Aardvark" < "Abbot"
True
>>> "Queen" > "King"
True
>>> "a" * 4 == "aaaa"
True
>>> "a" in "The Holy Grail"
True
>>> "vers" in "universitet"
True
>>> "a" == "b"
False
"Aardvark".compareTo("Abbot") < 0
"Queen".compareTo("King") > 0
("a"+"a"+"a"+"a").equals("aaaa")
"The Holy Grail".contains("a")
"universitet".contains("vers")
"a".equals("b")
För att konvertera text till numeriska värden:
val = int("127")
val = float("218.52")
int val = Integer.parseInt("127");
double val = Double.parseDouble("218.52");
För att konkatenera (sätta ihop) strängar används "+". Detta fungerar även om man sätter ihop strängar med tal. I så fall konverteras talen automatiskt till strängar (kompilatorn sätter in extra anrop för konverteringen).
int height = 100;
int width = 200;
String description = "Height is " + height + " and width is " + width;
Man kan också "multiplicera" strängar, som i Python:
"xyz" * 3
"xyz".repeat(3)
För att skriva ut strängar (eller annat) används System.out.println()
:
int height = 100;
int width = 200;
System.out.println("Height is " + height + " and width is " + width);
System.out.println(description);
System.out.println(width);
För att skriva oskrivbara tecken, sådana som inte "syns" på skärmen eller som man inte hittar på tangentbordet, finns ett antal "escapekoder". En del av dessa har ärvts från C.
'\nnn': Tecken med oktalt värde nnn ('\012' == tecken 10 = line feed)
'\unnnn': Tecken med hex-värde nnnn ('\u03D0' == grekiskt beta)
'\b': Backspace (ASCII 8)
'\t': Tab (ASCII 9)
'\n': Newline (ASCII 10)
'\f': Form feed (ASCII 12)
'\r': Carriage return (ASCII 13)
'\'': Apostrof
'\\': Backslash (\)
För att konvertera något till en sträng används den
statiska hjälpmetoden String.valueOf()
, som är
överlagrad (overloaded) för olika typer.
// Finns redan
public class String {
static String valueOf(char c) { … }
static String valueOf(int i) { … }
static String valueOf(boolean b) { … }
...
}
// I vår egen kod
String str = String.valueOf(127); // "127"
Notera att detta inte behövs när vi konkatenerar strängar, enligt ovan.
Det finns också särskilda metoder för att formatera strängar, t.ex. lägga till inledande nollor:
String str = String.format("%05d", 42) // "00042"
För att konvertera strängar till numeriska värden:
val = int("127")
val = float("218.52")
int val = Integer.parseInt("127");
double val = Double.parseDouble("218.52");
Grunderna i Javas hantering av listor diskuteras under en föreläsning. Här ska vi kortfattat visa mer saker man kan göra med dem. Tanken är främst att sortera ut det som är viktigast inom kursen så att ni inte missar det. Därför hänvisar vi till Javas API-dokumentation för mer detaljer!
Variabeln list står så klart för en lista.
Variabeln coll står för en godtycklig collection,
vilket betyder att en lista fungerar men det kan även vara
t.ex. en mängd (Set). Många av metodnamnen antas vara
uppenbara. Vissa metoder finns inte i själva listan, utan
är statiska hjälpmetoder i Collections
-klassen.
ArrayList – klart vanligast, implementationen liknar föreläsningsexempel
LinkedList – om man har specifika krav på snabb insättning/borttagning mitt i en lista
Stack – gammal klass, används normalt inte
Vector – gammal klass, används normalt inte
För att lägga till eller ändra element:
coll.add(element)
list.add(int index, E value) – lägg till på given position; flytta resten av elementen
list.set(int index, E value) – ersätt elementet på given position; storlek ändras ej
coll.addAll(coll2) – lägg till element från en annan Collection, t.ex. en lista
list.addAll(int index, Collection c)
Collections.fill(list, element) – ersätter existerande objekt
Collections.replaceAll(list, element1, element2)
För att plocka ut specifika element eller delar:
list.get(index)
list.sublist(fromInclusive, toExclusive) – en del av listan
coll.toArray() – en Object[ ] med samma element
För att testa innehållet / leta efter element:
coll.isEmpty()
coll.size()
coll.contains(Object obj)
coll1.equals(Collection c2) – exakt samma element?
coll1.containsAll(Collection c2) – alla element i coll2 finns i coll1?
list.indexOf(Object needle)
list.lastIndexOf(Object needle)
Collections.binarySearch(list, needle, ...) – binärsökning i sorterad lista
För att ta bort element:
coll.clear() – tar bort alla element
coll.remove(Object)
list.remove(index)
För att ändra listan på andra sätt:
list.sort() – sortera, om elementen är Comparable (se föreläsning)
list.sort(comparator) – sortera, annars
Collections.shuffle(list) – flytta om alla elementen i listan
Collections.rotate(list, distance) – rotera listan (flytta element d positioner)
Python stödjer dicts, en förkortning till dictionaries, som associerar nycklar med värden. Detta kan ses som en generalisering av arrayer eller listor, så att man inte bara kan ha heltal som index utan godtyckliga värden. Att använda detta är överkurs, men vi vill ändå peka er rätt om ni skulle ha användning av den sortens funktionalitet.
Java har så klart en motsvarighet, men precis som för listor finns det ingen specialsyntax för detta. Istället används gränssnittet Map och dess implementationer, och alla operationer sker via metodanrop. Därför blir det som vanligt lite mer att skriva i Java.
>>> dict = {'a': 45, 'b': 39, 'c': 19}
>>> dict['a']
45
>>> dict['d'] = 4711
>>> dict
{'a': 45, 'c': 19, 'b': 39, 'd': 4711}
>>> len(dict)
4
public static void main(String[] args) {
Map<Character,Integer> dict =
new HashMap<>();
dict.put('a', 45);
dict.put('b', 39);
dict.put('c', 19);
System.out.println(dict.get('a'));
// 45
dict.put('d', 4711);
System.out.println(dict);
// {a=45, b=39, c=19, d=4711}
System.out.println(dict.size());
// 4
}
Viktigt: En simpel implementation
av Map
skulle visserligen kunna slå upp ett
värde genom att iterera över en lista ända till den hittar
något som matchar, men detta skulle vara enormt ineffektivt.
I en Map med en miljon mappningar (nyckel/värde) skulle vi i
genomsnitt behöva söka genom en halv miljon, och testa om
det var rätt nyckel, innan vi hittade rätt.
Därför måste nyckelklassen uppfylla vissa krav som Map-klassen kan förlita sig på. String, wrappertyper (Character, Integer med mera), enumtyper och många andra klasser uppfyller redan detta, så vill ni ha dessa som nycklar ska det automatiskt fungera. Om en egen klass ska användas som nyckel (första parametern i put), måste ni däremot tänka lite längre. Detta kommer egentligen i TDDD86, så vi kommer inte att förklara alla detaljer här!
TreeMap förlitar sig på att man kan jämföra objekt
med varandra, och se vilket som är "mindre" respektive
"större". Det ställer samma krav som sortering, som kommer
att diskuteras på föreläsningarna: Nyckeltypen ska
implementera Comparable
, eller så får man ge
TreeMap-objektet en Comparator
. Då
kan TreeMap
lagra informationen i ett röd-svart
träd (en struktur som ni får lära er mer om i senare
kurser), och behöver bara titta på ett logaritmiskt antal
nycklar för att hitta rätt (kanske i storleksordningen 20
nycklar vid en miljon mappningar).
HashMap använder sig istället av hashkoder. Detta kan ni läsa mer om på nätet, eller få förklarat i TDDD86.
Med menyer menar vi den här typen av pulldown-menyer:
I Swing (Javas GUI-system) är menyer komponenter precis som knappar och textfält, men de hanteras på ett annat sätt än de flesta komponenter. De "vanliga" komponenterna läggs till med fönstrets egen add()-metod, men då hamnar de i själva verket i en speciell panel som kallas content pane. Ovanför detta finns en särskild plats för en menyrad. Det gör att man inte behöver tänka särskilt på layouthantering för menyerna:
För att lägga till menyer skapar man först en menyrad. Därefter lägger man till menyer i menyraden, och menyval i menyerna. Se följande exempel:
final JMenuBar bar = new JMenuBar();
final JMenu file = new JMenu("File");
file.add(new JMenuItem("Open", 'O')); // Understruket O
file.add(new JMenuItem("Save As", 'A'));
file.addSeparator(); // Separator
file.add(new JMenuItem("Print"));
bar.add(file);
final JMenu edit = new JMenu("Edit");
edit.add(new JMenuItem("Cut"));
edit.add(new JMenuItem("Copy"));
edit.add(new JMenuItem("Paste"));
bar.add(edit);
final JMenu help = new JMenu("Help");
help.add(new JMenuItem("About WordProcessor 1.0"));
bar.add(Box.createHorizontalGlue()); // "Glue" fungerar som en fjäder, trycker "help" till höger
bar.add(help);
frame.setJMenuBar(bar);
Se även Java Tutorial om menyer respektive separatorer.
Händelsehantering för menyer hanteras på samma sätt som för knappar eller andra komponenter: En ActionListener anropas när ett menyval aktiveras, oavsett hur det sker (mus/trackpad, tangentbord, ...).
final JMenuItem open = new JMenuItem("Open");
fileMenu.add(open);
open.addActionListener(...);
Med hjälp av JTabbedPane kan du göra en layout med flikar där användaren själv kan välja flik, enligt nedan. Se även informationen i Java Tutorial.
Om du istället vill att programmet ska byta innehåll i fönstret, utan att användaren kan göra det, går du till nästa avsnitt.
Med hjälp
av CardLayout
kan du göra en layout där du lägger in flera komponenter,
men där bara en av dem i taget är synlig. Till skillnad
från JTabbedPane
ovan är det inte användaren
utan programmet som byter mellan "korten"
i CardLayout
. Detta kan t.ex. användas om du
vill byta mellan att fönstret visar en introskärm, en
spelkomponent, en highscorelista, och så vidare – utan
att du behöver ta bort den gamla komponenten och sedan lägga
till en ny.
CardLayout
är svår att illustrera eftersom den
helt enkelt visar en komponent i taget, utan att ha några
synliga kontroller för att byta komponent. Läs
informationen
i Java
Tutorial för att förstå hur det fungerar.
Om man vill fråga användaren något i ett GUI-program, har Java standardiserat stöd för dialogrutor.
En enkel fråga med en sträng som resultat:
String input = JOptionPane.showInputDialog("Please input a value");
Man kan också ange ett fönster som äger dialogrutan. Då blockeras all inmatning till fönstret under tiden dialogrutan visas, vilket kan vara bra i vissa fall.
JOptionPane.showMessageDialog(frame, "Eggs aren't supposed to be green.");
Det finns många fler parametrar som kan anges.
JOptionPane.showMessageDialog(frame,
"Eggs are not supposed to be green.",
"Inane warning",
JOptionPane.WARNING_MESSAGE);
Många fler...
// Custom button text
Object[] options = {
"Yes, please",
"No, thanks",
"No eggs, no ham!"
};
int optionChosen = JOptionPane.showOptionDialog(
frameParent, // A window that “owns” the dialog
"Would you like some green eggs to go with that ham?",
"A Silly Question",
JOptionPane.YES_NO_CANCEL_OPTION,
JOptionPane.QUESTION_MESSAGE,
null,
options, // Custom text
options[2] // Default choice
)
Det finns färgväljare:
Och det finns givetvis fildialoger.
Mer information finns på Java Tutorial om dialogrutor.
I Java finns det två huvudsakliga sätt att få något att hända när man trycker (eller släpper) en tangent i ett GUI-program.
Använda en KeyListener
som blir informerad när något händer på tangentbordet, och sedan själv ta reda på vad det var som hände.
Sätta upp en InputMap
och en ActionMap
som kopplar tangenthändelser till handlingar.
Det första sättet kan vara ganska knepigt, bland annat för
att det alltid är en specifik GUI-komponent som
har tangentbordsfokus
och man måste se till att just den komponenten har en
lyssnare. InputMap
plus ActionMap
ser mer komplicerade
ut, men brukar leda till färre problem i praktiken. Därför
går vi genom den metoden, och använder föreläsningens
"ordbehandlarexempel" som bas.
I ordbehandlaren kan vi definiera en handling som kan
utföras. Detta är en speciell typ
av ActionListener
. Vi kan få hjälp att skapa den
genom att ärva från hjälpklassen AbstractAction
,
så behöver vi bara implementera
själva actionPerformed
-metoden precis som
"vanligt".
public class WordProcessor20 {
...
private class QuitAction extends AbstractAction {
@Override public void actionPerformed(final ActionEvent e) {
// Gör vad som behövs -- öppna dialogruta och fråga, ...
System.exit(0);
}
}
}
Nu vill vi se till att en QuitAction
anropas
när någon trycker Alt-F4. Tangentkombinationen kallas
en KeyStroke
, och med en InputMap
kopplas den till ett namn på en handling. Sedan
används en ActionMap
för att koppla namnet till
koden, det vill säga objektet av
typen QuitAction
.
Den kopplingen gör vi så här (förklaringar efter):
public class WordProcessor20 {
private JFrame frame;
public WordProcessor20() {
this.frame = new JFrame("Word Processor 1.0");
JComponent pane = frame.getRootPane();
final InputMap in = pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
in.put(KeyStroke.getKeyStroke("Alt F4"), "quit");
final ActionMap act = pane.getActionMap();
act.put("quit", new QuitAction());
}
private class QuitAction extends AbstractAction {
@Override public void actionPerformed(final ActionEvent e) {
System.exit(0);
}
}
}
Bra att veta:
Varje komponent har sina egna InputMaps. I exemplet tar
vi en InputMap hos RootPane
, som innehåller
"allt annat" som finns i ett fönster – jatt förstå
exakt vad detta betyder är överkurs, men den
intresserade kan läsa mer
i Java
Tutorial.
När vi hämtar InputMap anger
vi JComponent.WHEN_IN_FOCUSED_WINDOW
.
Detta betyder att vi är intresserade av
tangenttryckningar när komponentens fönster har fokus
(är "valt" på skärmen).
Väljer vi istället JComponent.WHEN_FOCUSED
måste komponenten själv ha fokus. Det är ju användbart
t.ex. i ett textfält: Då vill vi inte att "pil vänster"
flyttar markören om det är ett annat textfält
som har fokus! Men i den här kursen används
tangenttryckningar oftast i spel där man vill ha en mer
"global" koppling, så man slipper bry sig om var
tangentbordsfokus ligger.
Sedan anger vi att Alt-F4 ska kopplas till strängen "quit". Se KeyStroke och Key Bindings för information om vilka KeyStrokes som kan anges. Exempel är "LEFT", "released SPACE", "control DELETE", "alt shift released X", och "typed a".
Till slut kopplar vi strängen "quit" till en
handling av typen QuitAction
.
För kursens skull behöver vi oftast inte stänga fönster: Det går ju bra att stoppa ett helt program från utvecklingsmiljön. Ändå kanske man vill ha den möjligheten i en del projekt.
För att ifrån koden stänga fönstret frame
anropar
man frame.dispose()
.
För att bestämma vad stäng-knappen gör använder man (så snart
fönstret har skapats) frame.setDefaultCloseOperation(x)
,
där x är...
Ibland kanske man vill bestämma själv vad stäng-knappen ska göra,
t.ex. öppna en dialogruta för att fråga om användaren verkligen
är säker. Då använder
man WindowConstants.DO_NOTHING_ON_CLOSE
och lägger sedan
till en WindowListener
som blir informerad när någon
trycker på knappen. WindowListener ser ut såhär:
package java.awt.event;
public interface WindowListener extends EventListener {
public void windowOpened(WindowEvent e);
public void windowClosing(WindowEvent e); // När stängknappen trycks!
public void windowClosed(WindowEvent e);
public void windowIconified(WindowEvent e);
public void windowDeiconified(WindowEvent e);
public void windowActivated(WindowEvent e);
public void windowDeactivated(WindowEvent e);
}
Man får alltså skapa en lyssnare som implementerar detta
gränssnitt och som ger de flesta metoderna, som man inte är
intresserad av, helt tomma implementationer. Alternativt kan man
låta lyssnaren ärva från klassen WindowAdapter
, som
är en trivial hjälpklass som helt enkelt har tomma
implementationer av alla metoder
från WindowListener
:
package java.awt.event;
public class WindowAdapter implements WindowListener {
public void windowOpened(WindowEvent e) { }
public void windowClosing(WindowEvent e) { }
public void windowClosed(WindowEvent e) { }
public void windowIconified(WindowEvent e) { }
public void windowDeiconified(WindowEvent e) { }
public void windowActivated(WindowEvent e) { }
public void windowDeactivated(WindowEvent e) { }
}
I så fall kan resultatet se ut så här:
public class AskBeforeClosing {
private JFrame frame;
public AskBeforeClosing() {
this.frame = new JFrame("Try to close me");
frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
frame.addWindowListener(new CloseButtonHandler());
}
private class CloseButtonHandler extends WindowAdapter {
public void windowClosing(final WindowEvent e) {
int answer = JOptionPane.showConfirmDialog
(frame, "Do you really want to quit?", "Quit?", JOptionPane.YES_NO_OPTION);
if (answer == JOptionPane.YES_OPTION) {
System.exit(0); // Avsluta hela Java-processen
}
}
}
}
Se även info om dialogrutor.
Java har flera olika sätt att hantera bitmapbilder (JPEG, PNG med mera), beroende på hur avancerad funktionalitet man behöver.
När Java introducerades, i mitten av 90-talet, fanns
klassen java.awt.Image
. Det finns numera också
varianter som BufferedImage
som låter oss
manipulera bilder i minnet. Men Image
anpassades till användning i applets, Java-program som kör
på websidor, och till dåtidens långsamma uppkopplingar.
Därför kan den läsa in en bildfil från nätet, och den läser
alltid asynkront och inkrementellt så man
kan rita ut den del som har kommit fram hittills. Det gör
också att de här klasserna är lite komplicerade att använda.
Istället fokuserar vi
på javax.swing.ImageIcon
,
som implementerar Icon
-gränssnittet. Den är
enklare att hantera och klarar allt man behöver för typiska
kursprojekt. En ImageIcon kan läsas in från minnet, från
disk eller via HTTP när man anger
en URL:
new ImageIcon(byte[] data)
new ImageIcon(String filename)
new ImageIcon(URL location)
Användbara metoder inkluderar:
int getIconHeight()
int getIconWidth()
void paintIcon(Component c, Graphics g, int x, int y)
Vi börjar med ett enkelt exempel. Notera att en bild läses
in en gång för alla, och en pekare lagras i ett fält i
klassen. Vi vill ju inte läsa in bilden varje gång den ska
ritas ut! I paintComponent()
väljer vi att slå
på antialiasing och sedan rita
ut bilden på position (50,50). Resten av koden, i main(),
öppnar helt enkelt ett fönster och lägger in en
IconPainter01-komponent där.
public class IconPainter01 extends JComponent {
final ImageIcon icon = new ImageIcon(ClassLoader.getSystemResource("/javatut.jpg"));
public void paintComponent(final Graphics g) {
final Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
icon.paintIcon(this, g, 50, 50);
}
public static void main(String[] args) {
final JFrame frame = new JFrame("Graphics2D Test");
frame.setLayout(new GridLayout(1,1));
frame.add(new IconPainter());
frame.setSize(300, 300);
frame.setVisible(true);
}
}
Notera att vi använder ClassLoader.getSystemResource()
för att hitta filen. Detta fungerar tillsammans med Javas
stöd för resurser, som i princip är datafiler som
distribueras tillsammans med ett program. På det sättet
undviker vi att behöva ange var filerna finns i en specifik
installation, och filerna kan hämtas även om hela programmet
råkar vara packat i en JAR-fil (liknande ZIP-fil).
Vad blir resultatet då?
Oj, bilden fick inte plats! Det kan ju vara bra att kunna
ändra storlek på den när den ritas ut, men det fanns inget
storleksargument i paintIcon()
. Istället
använder Java en generell transform, något som
ändrar på koordinaterna för allt som ritas ut.
Exempel på att skala allt med en faktor 0.5 i både x-led och
y-led:
public class IconPainter02 extends JComponent {
final ImageIcon icon = new ImageIcon(ClassLoader.getSystemResource("/javatut.jpg"));
public void paintComponent(final Graphics g) {
final Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
final AffineTransform old = g2d.getTransform();
final AffineTransform at = AffineTransform.getScaleInstance(0.5, 0.5);
g2d.setTransform(at);
icon.paintIcon(this, g, 50, 50);
g2d.setTransform(old);
}
}
Notera ovan att vi sparar undan den gamla transformen för att återställa den efter vi ritar!
Originalet till vänster, resultatet till höger:
Ja, transformen skalar verkligen allt -- även koordinaterna (50,50) för övre högra hörnet! Här kan man istället välja att transformera i flera steg. Först kod, sedan förklaring.
public class IconPainter02 extends JComponent {
final ImageIcon icon = new ImageIcon(ClassLoader.getSystemResource("/javatut.jpg"));
public void paintComponent(final Graphics g) {
final Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
final AffineTransform old = g2d.getTransform();
// Steg 4: Gör ingenting (skala faktor 1)
final AffineTransform at = AffineTransform.getScaleInstance(1,1);
// Steg 3: Flytta (i "oskalade" koordinater)
at.translate(50,50);
// Steg 2: Skala ner alla koordinater
at.scale(0.5, 0.5);
g2d.setTransform(at);
// Steg 1: Starta på (0,0)
icon.paintIcon(this, g, 0, 0);
g2d.setTransform(old);
}
}
Detta ser ju väldigt skumt ut. Man måste i princip läsa det baklänges för att förstå. Anta att bildens storlek är 300x400 pixlar.
Vi anger till paintIcon() att vi vill rita ut på virtuella koordinaterna (0,0), istället för (50,50) som vi tidigare använde. Bilden ska då täcka de virtuella punkterna (0,0) till och med (299,399).
Det sista vi la till i den nuvarande transformen är en skalning med en faktor 0.5. Allt skalas ner, så bilden täcker punkterna (0,0) till och med (149.5, 199.5).
Innan detta la vi till en förflyttning på (50,50) punkter, för att få bildens övre vänstra hörn att hamna rätt. Det läggs till överallt, så bilden täcker punkterna (50,50) till (199.5, 249.5).
Det första vi gjorde var att hämta ut en transform som inte gör något alls -- bara för att få en enkel startpunkt. Bilden täcker fortfarande punkterna (50,50) till (199.5, 249.5).
Nu är vi klara, så bilden ritas ut på de angivna punkterna. Bilden är nu placerad på (50,50) och är hälften så stor som originalet, som vi ville.
Originalet till vänster, resultatet till höger:
Transformer kan också göra annat än att flytta och skala bilder. Till exempel kan vi rotera dem. Då får vi tänka på samma sätt i fråga om vad man gör först. Exempel:
...
final AffineTransform old = g2d.getTransform();
final AffineTransform at = AffineTransform.getScaleInstance(1,1);
at.translate(50,50);
at.scale(0.5, 0.5);
at.rotate(Math.PI/4);
g2d.setTransform(at);
icon.paintIcon(this, g, 0, 0);
g2d.setTransform(old);
...
För att förstå vad som händer "fuskar" vi genom att visa allting steg för steg i ett större "fuskfönster" där man även ser det som egentligen faller utanför, där koordinaterna (0,0) finns i skärningspunkten mellan de röda linjerna. Rotationen står sist men sker först. Sedan skalas alla koordinater, och till slut förflyttas resultatet.
Originalet till vänster, det verkliga resultatet till höger:
En textur fungerar som den rutiga färgen i Kalle Anka på julafton (om nu någon fortfarande ser på det). Man anger en bild som ska användas, och den repeteras så många gånger som behövs för att fylla ytan med färg. Ytan kan ha godtycklig form. Exempel:
TexturePaint paint = new TexturePaint(smiley,
new Rectangle2D.Double(0, 0, smiley.getWidth(), smiley.getHeight()));
g2.setPaint(paint);
g2.fill(...polygon...)
Ibland kanske man vill måla halvgenomskinligt – till exempel för att en bakgrundsbild delvis ska synas bakom statusinformation:
Detta görs på följande sätt:
g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.7));
Förenklat kan man säga att vi just nu målar med 30% genomskinlighet (1.0 - 0.7), men den här funktionaliteten är egentligen mycket mer generell: Den talar om precis hur man ska kombinera ett existerande objekt med ett nytt när det målas ut. Mer information finns i dokumentationen för AlphaComposite respektive det ännu mer generella gränssnittet Composite.
Antialiasing (kantutjämning) handlar om olika metoder att minska effekten av en relativt låg upplösning, för att få kanterna på linjer och kurvor att se jämnare ut. Ett exempel är att använda gråskalor istället för bara svart och vitt när text eller linjer ritas ut:
I Java kan man slå på antialiasing separat för text och för andra former än text. Detta görs med hjälp av så kallade Rendering Hints.
g2.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2.setRenderingHint(
RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
Andra rendering hints kan användas för att påverka andra delar av "utritningen".
För nästan alla projekt räcker det säkert med Javas standardtypsnitt:
Dialog, DialogInput för dialoger
Serif (kan motsvara Times eller annan font med seriffer)
SansSerif (kan motsvara Arial, Helvetica eller annan font utan seriffer)
Monospaced (skrivmaskinsstil)
Symbol (symboler, används sällan)
Men i vissa fall kan man vilja använda andra typsnitt, och dessutom ta reda på vilka typsnitt som finns. Det gör man så här:
public class MyClass {
public void checkFonts() {
GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
String[] names = ge.getAvailableFontFamilyNames();
...
}
}
Om man skriver ett spel kan det hända att man behöver testa om
två objekt kolliderar på skärmen. Ett av många sätt bygger på
Javas geometriska Shape
-klasser, som kan
representera ungefärlig eller exakt form för objekt på skärmen.
Man kan till exempel skapa en bounding box som omsluter
ett objekt:
Denna kan skapas med hjälp av Area-klassen, som låter oss kombinera flera former till en ny form, t.ex. en cirkel och en rektangel. Sedan kan man kontrollera om två shapes, som motsvarar stationära eller rörliga objekt och som har fått koordinater motsvarande var de är på skärmen, kolliderar. Detta fungerar även om man inte använder shapes för att rita objekten.
Shape s1 = ...;
Rectangle2D s2 = new Rectangle2D.Double(...);
if (s1.intersects(s2)) {
// Collision!
}
Utöver detta finns många klassbibliotek som kan underlätta spelprogrammering, men vi har inget specifikt sådant bibliotek att rekommendera.
Java har ett relativt avancerat ljudhanteringssystem med stöd för uppspelning, sampling, ljudbehandling, mixning av ljudkanaler, med mera. Detta kommer vi inte att ta upp i någon större detalj i den här kursen, inte minst för att det är objektorientering och inte ljudhantering som är fokus. Det vi vill visa är istället den enklaste möjliga ljudhanteringen, där man kan starta ett ljud eller loopa det ett visst antal gånger.
Ett ljudklipp (en ljudfil) representeras av klassen Clip
Detta klipp måste läsas in innan det kan användas. Lämpligen görs det en gång för alla, inte varje gång det ska spelas upp!
Vid inläsning använder man lämpligen resurser enligt nedan, så att ljudfilen hämtas från den plats där programmet är installerat.
Självklart måste inte ljud hanteras i en separat klass
(SoundPlayer2
), utan detta görs bara för att få ett
litet men relativt fullständigt exempel.
public class SoundPlayer2 {
private final Clip beepSound;
public SoundPlayer2() throws ... {
URL url = SoundPlayer2.class.getResource("/sounds/beep-01a.mp3");
if (url == null) { /* Felhantering eller kasta ett eget exception! */ }
beepSound = AudioSystem.getClip();
beepSound.open(AudioSystem.getAudioInputStream(url));
}
public void beepOnce() { beepSound.start(); }
public void beepTwice() { beepSound.loop(1); }
}
Den intresserade kan se mer
på Java
Tutorial och efterföljande sidor.
Filhantering finns inte med på föreläsningarna, men i kursböcker (t.ex. Java Tutorial) och i gamla föreläsningsbilder.
Utöver detta vill vi också nämna
klassen java.nio.file.Files
,
som innehåller en hel del smått och gott i fråga om
filhantering. Bland annat finns readString()
för att läsa in en hel fil som en sträng,
readAllLines()
för att få den som en lista av
rader, och readAllBytes()
för att få den som en
byte-array.
I många projekt används fördefinierade data av olika slag – inte bara bilder och ljud, utan banor, startpositioner för spelbräden, och många andra sorters information.
När det gäller bilder finns det ju färdiga lagringsformat,
som .PNG-filer, och färdig kod som läser in dem. Där blir
det enklast att lagra dem i filer och läsa in dem
med hjälp av klasser som redan finns,
t.ex. ImageIcon
.
När det gäller andra data kan det istället vara enklast att göra fel – i alla fall i början. Vill man bygga upp olika banor kan man t.ex. hårdkoda dem:
world.place(10, 20, new Rock()); world.place(11, 20, new Water()); ...
Här rekommenderar vi istället att man använder sig av Gson för att lagra informationen i en fil. Då skiljer vi på data (som lätt kan ändras i en fil utan omkompilering) och kod (som definierar beteenden och som kan exekveras). Gson gör bland annat att vi inte själva behöver skriva koden som läser ett visst filformat, och beskrivs närmare i uppgiften "Spara på fil" i Tetris.
Java har grundläggande klasser för att skriva binärdata eller textsträngar till en fil och läsa tillbaka detta till minnet. Om vi vill använda samma grundläggande klasser och metoder för att spara mer komplex information, till exempel en lista med highscores i ett spel, måste vi själva iterera över listorna, se till att alla "delar" av en highscore skrivs respektive läses, och så vidare.
Java låter oss också skriva ner en representation av ett helt objekt till en fil (eller någon annan destination, till exempel en databas). Alla objekt det i sin tur pekar på skrivs också ner, eftersom objektet annars inte skulle "peka rätt" när det lästes tillbaka. Skriver man en lista till en fil skickar Java alltså även med objekten som listan innehåller, precis som man skulle vänta sig.
Detta kallas serialisering, eftersom man har ett antal objekt som ligger placerade på olika platser i minnet och pekar på varandra, och man vill spara tillståndet i alla dessa objekt som en serie av bytes som kan sparas undan någonstans. Detta kan också kallas marshalling (generellt), eller pickling i Python (sylta / marinera). Serialisering finns i många språk, även sådana som inte är objektorienterade: Ett generellt sätt att serialisera data är användbart för alla sorters sammansatta datatyper.
I grunden är serialisering ett enkelt begrepp, men oavsett implementation eller språk finns det en hel del specialfall som man kan behöva känna till. Därför är serialisering överkurs. Vi rekommenderar att man bara använder detta om man tror vinsten är tillräckligt stor, dvs. om man ska skicka "tillräckligt" komplicerad information, och man förstår begreppen nedan. Annars kanske ett enkelt textprotokoll är enklare att förstå och debugga.
Tänk också på att serialisering normalt ger en låsning till ett specifikt språk. Som vi ska se i slutet kan det till och med vara så att en ny version av samma program inte kan läsa en fil som serialiserades med en äldre version.
För att man ska kunna spara ner ett objekt på detta sätt
ställs flera krav, som kan summeras och approximeras till
"objekten och deras fält måste
implementera Serializable
".
Per default kan ett objekt inte
serialiseras. Anledningen är att serialisering skriver ner
all information som lagras i ett objekt, inklusive
information i privata fält. Detta ska ju ingen annan kunna
komma åt! Därför måste den som skriver klassen markera att
serialisering är OK, att det egentligen inte gör något om
andra kan ta reda på "privat" information. Många av Javas
klasser implementerar redan Serializable
.
Hur ska man då markera detta? Det finns några olika
sätt som kod kan "inspektera sig själv", och ett sätt är
att testa om ett objekt är instanceof
ett
gränssnitt eller en klass. Därför införde man
gränssnittet Serializable
, och bestämde att
serialiseringen bara accepterar objekt av klasser som
implementerar Serializable
. Gränssnittet
har inga metoder, så allt som behövs är att man
skriver implements Serializable
! Det
viktiga är att bara den som har kontroll över källkoden
kan skriva detta.
public class Pair implements Serializable {
private Object first;
private Object second;
Pair(Object first, Object second) {
this.first = first;
this.second = second;
}
}
Alla fältvärden måste vara primitiva eller
av Serializable
-typ. Notera att vi säger
"fältvärden", inte "fälttyper". I exemplet ovan kan
vissa Pair-objekt vara serialiserbara, för att deras
first och second pekar på strängar, som är
serialiserbara. Andra Pair-objekt kan vara icke
serialiserbara, för att deras first och/eller second
inte får serialiseras.
Ett undantag finns för fält som
är transient
. Man antar att sådana fält kan
rekonstrueras och att det inte är värt att spara dem. I
exemplet nedan kan värdet på hashCodeCache
räknas ut från first
och second
.
public class Pair implements Serializable {
private Object first;
private Object second;
private transient int hashCodeCache;
Pair(Object first, Object second) {
this.first = first;
this.second = second;
}
}
Superklassen ska normalt också
implementera Serializable
, eftersom dess
fält också lagras.
Det finns ett mer komplicerat alternativ: Superklassen
kan sakna Serializable
men ha en konstruktor
utan argument. Då lagras inte superklassens fält, utan
superklassens konstruktorn utan argument anropas för att
initialisera dess fält, och sedan läses resten in från
fil.
Detta används faktiskt i exemplet
ovan: Pair
har Object
som
superklass, och Object
är inte
serialiserbar men har en konstruktor utan argument.
(Object har inte heller några fält, och dess konstruktor
gör ingenting, så detta spelar ingen roll...)
Java klarar att hantera cirkulära referenser. Till exempel kan vi ha nodobjekt där varje nod pekar på sin förälder och varje förälder pekar på alla sina barn. Man kunde tänka sig att Java serialiserade föräldern, sedan första barnet, men då måste den serialisera föräldern, och sedan första barnet, som ju har en förälder... i all oändlighet. Istället håller den reda på alla objekt den har serialiserat och nästa gång skriver den bara ett ID som pekar tillbaka på det "gamla" objektet.
Så om man ändrar ett objekt och serialiserar det en gång till i samma ström, får man inte riktigt det väntade resultatet:
List list = new ArrayList();
oos.write(list); // Writes object ID + entire list
list.add("Another element");
oos.write(list); // Writes the object ID...
Mottagaren får då se två kopior av objektet som det såg ut första gången. Vill man skicka objektets nya tillstånd måste man nollställa strömmen först:
List list = new ArrayList();
oos.write(list); // Writes object ID + entire list
list.add("Another element");
oos.reset();
oos.write(list); // Writes the object ID...
Om man ändrar i en klass kan det hända att objekt som serialiserats med en äldre version av klassen inte längre kan läsas.
Vissa ändringar är helt förbjudna:
Ändra klasshierarkin på vissa sätt
Ta bort Serializable
...
Andra kan vara tillåtna:
Lägga till nya fält – läser man in ett gammalt serialiserat objekt där fältet saknas sätts fältet till sitt defaultvärde (0, null, false)
Ändra skyddsklass, public / protected / private
Några fler sorters ändringar
Men även dessa ändringar tillåts bara om man deklarerar
samma serialiseringsversion. Per default beräknas
detta med avseende på vissa egenskaper hos klassen på ett
sätt som gör att nästan inga ändringar tillåts. Vill man
själv tillåta framtida ändringar måste man vara förutseende
och deklarera en egen version. Detta är ett statiskt fält i
den klass som är serialiserbar, och heter
exakt serialVersionUID
:
public class Pair implements Serializable {
private Object first;
private Object second;
private transient int hashCodeCache;
private final static long serialVersionUID = 1; // for example
Pair(Object first, Object second) {
this.first = first;
this.second = second;
}
}
Om man i framtiden gör inkompatibla ändringar behöver man
också ändra värdet på serialVersionUID
, så att
serialiseringen vägrar ta emot "gamla" objekt.
Många olika fel kan signaleras med hjälp av olika exception-typer. Ovan ignorerade vi allt detta för att förenkla exemple. Fel som kan uppstå inkluderar:
ClassNotFoundException – received an object of a non-existing class
InvalidClassException
StreamCorruptedException – bad control information in the stream
OptionalDataException – primitive data found instead of objects
NotSerializableException – an object was not Serializable
IOException – the usual Input/Output related exceptions
Java tutorial: Serialization
Specifikationen: Java Object Serialization Specification
Föreslå gärna egna, om du tycker det finns information som saknas och som skulle hjälpa till i kursen.