Kommandozeilenprogramme in Go sind gut und schön, aber hin und wieder muss doch eine native Desktop-App mit GUI ran, zum Beispiel um vom Handy heruntergeladene Fotos anzuzeigen, und den User die schlechten aussortieren zu lassen. Letztlich bleiben aus dem Wust von hunderten von Fotos auf dem Handy immer nur einige wenige, für die es sich lohnt, sie andersweitig aufzuheben.
Vor drei Jahren hat der Programmier-Snapshot schon einmal ein ganz ähnliches graphisches Tool vorgestellt, das den User schlechte Fotos händisch ausmustern ließ ([3]), und es lief auf dem Electron-Framework und dem damit ferngesteuerten Chrome-Browser auf Node.js-Basis. Nun hat sich in letzter Zeit das Go-GUI-Framework Fyne ([2]) das Ziel gesteckt, Electron Konkurrenz zu machen, und die Welt der Entwicklung von Cross-Plattform-GUIs zu dominieren. In dieser Ausgabe schauen wir mal, wie einfach sich so ein Ausmusterer in Go und Fyne schreiben lässt.
Abbildung 1: Die simple Fyne-App aus Listing 1 zeigt ein Foto an. |
In [2] hat das Linux-Magazin letztens einfache Beispiele mit Fyne gezeigt, aber eine echte Applikation erfordert noch etwas zusätzlichen Feinschliff. Listing 1 zeigt den ersten Versuch einer Foto-App, die ein JPG-Bild von der Festplatte liest, und es mitsamt einem Quit-Button in einem Fenster anzeigt. Das Foto stammt von meiner letzten Deutschlandtour 2021, wo ich mich aufgemacht habe, die besten Brezenbäcker Deutschlands zwischen Bremen und Bad Tölz zu aufzuspüren. Abbildung 1 zeigt die App kurz nach dem Aufruf von der Kommandozeile mit einer sagenhaften Breze aus Lenggries am südlichsten Zipfel Deutschlands. Am Layout der App mit dem Foto und dem Ausschaltknopf gäb's nichts zu meckern, außer dass es gut 2 Sekunden dauert, um ein Bild der Handykamera in der Auflösung 4032 x 3024 von der Platte zu laden und im Applikationsfenster anzuzeigen. Völlig undenkbar, mit derart schlappem Handling ein brauchbares Tool zur Bildsortierung zu bauen!
01 package main 02 03 import ( 04 "fyne.io/fyne/v2" 05 "fyne.io/fyne/v2/app" 06 "fyne.io/fyne/v2/canvas" 07 "fyne.io/fyne/v2/container" 08 "fyne.io/fyne/v2/widget" 09 "os" 10 ) 11 12 func main() { 13 win := app.New().NewWindow("imgtest") 14 img := 15 canvas.NewImageFromFile("pretz.jpg") 16 img.SetMinSize(fyne.NewSize(600, 400)) 17 18 button := widget.NewButton("Quit", 19 func() { os.Exit(1) }) 20 21 con := container.NewVBox(img, button) 22 win.SetContent(con) 23 win.ShowAndRun() 24 }
Die in dieser Ausgabe vorgestellte blitzschnelle GUI, die die Bilder aus dem aktuellen Verzeichnis nacheinander anzeigt, mittels vi
-artiger Steuerung durch "L" bzw. "H" zum nächsten bzw vorherigen Bild flutscht, und mit "d" (für 'delete') das gerade angezeigte Bild ins Mülleimerverzeichnis "old" wirft, sortiert in Rekordzeit die guten ins Töpfchen und die schlechten ins Kröpfchen ([5]). Wem die vi-Tastaturbindung übrigens sauer aufstößt, und statt dessen lieber die Cursortasten bedient, der kann natürlich den Code einfach anpassen!
Während die Trivial-App aus Listing 1 doch recht gemächlich werkelt, arbeitet sich die Foto-App "inuke" aus Listing 2 viel schneller durch die Fotosammlung. Mit einigen Tricks aus der Performance-Kiste zeigt sie das nächste Foto praktisch sofort an, mit einer Verzögerung von weniger als einer gefühlten 1/10-Sekunde, nachdem es der User durch einen Druck auf die "L"-Taste angefordert hat. Hexenwerk? Nichts läge dem Programmier-Snapshot ferner.
Erstens hilft das Zwischenspeichern bereits geladener Fotos, sodass der GUI-Renderer sie nur noch aus dem Cache in den Video-Speicher schieben muss, falls der User wieder danach verlangt. Doch welche Fotos lohnt es sich, vorzuhalten, wenn nicht alle ins RAM passen, weil zum Beispiel ein Verzeichnis 5000 Fotos a 4 MByte enthält und nicht jeder 20GB RAM verpulvern kann? Die Lösung ist ein LRU-(Last Recently Used)-Cache, der eine vorab definierte Maximalzahl an Einträgen aufnimmt, aber bei Überfüllung einfach diejenigen Elemente hinauswirft, deren letztes Zugriffsdatum am weitesten zurückliegt. So überschreiben neu hinzugekommene Einträge einfach uralte, falls der Cache bereits voll ist.
Als zweites Tuning-Mittel hilft das effiziente Verkleinern der Fotos bei der Anzeige, denn kaum ein Monitor zeigt 4032-Pixel breite Bilder vollständig an. Wer die unverkleinerten Fotos der GUI zur Anzeige übergibt, lässt sie mehr Arbeit erledigen als notwendig, und das rächt sich durch schleppende Interaktion mit dem User, der auf jeden Tastendruck hin verzögerungsfrei ein neues Bild sehen will. Die Go-Library nfnt
auf Github bietet hocheffiziente Routinen zum Schrumpfen von Bildern an, eine performante App verkleinert die Fotos stets auf das auf den Bildschirm passende, schon bevor sie in den Cache wandern.
Und drittens hilft der App ein Preload-Mechanismus auf die Sprünge. Designbeding zeigt sie Fotos immer in einer bestimmen Reihenfolge an, entweder vorwärts oder rückwärts, je nachdem in welcher Richtung der User gerade unterwegs ist. Also kann sie leicht vorhersagen, welches Foto der User beim nächsten Tastendruck auf dem Schirm erscheinen soll. Lädt nun die App das wahrscheinlich gleich folgende Foto bereits vorab im Hintergrund in den Cache, während der User noch auf das aktuelle Foto starrt, kann das Fyne-Framework das nächste Foto praktisch sofort anzeigen, wenn es der User endlich durch Tastendruck anfordert.
Das Ergebnis ist erstaunlich, mit diesen drei Verbesserungen flutscht die Anzeige des Go-Programms in atemraubenden Tempo und schlägt in Punkto Elan so manche professionelle App. In Abbildung 2 hat inuke
gerade ein Bild geladen, das den Autor während seiner Deutschlandtour 2021 als Tourist in Heidelberg zeigt. Das unten anhängende kleine Label-Widget zeigt die in der App nun erlaubten Tastendrücke, mit "H" springt die App zurück zum letzten Bild, mit "L" zur nächsten Aufnahme, mit "D" löscht sie das aktuell Foto und mit "Q" bricht die App das Programm ab. Wie sieht nun die Programmierung des Problems in Go aus?
Abbildung 2: Die mit dem Fyne-Framework generierte App iNuke zeigt ein Foto zur Auswahl. |
001 package main 002 003 import ( 004 "container/list" 005 "os" 006 007 "fyne.io/fyne/v2" 008 "fyne.io/fyne/v2/app" 009 "fyne.io/fyne/v2/canvas" 010 "fyne.io/fyne/v2/container" 011 "fyne.io/fyne/v2/storage" 012 "fyne.io/fyne/v2/widget" 013 "github.com/hashicorp/golang-lru" 014 "path/filepath" 015 ) 016 017 var Cache *lru.Cache 018 019 func main() { 020 win := app.New().NewWindow("iNuke") 021 022 var err error 023 024 Cache, err = lru.New(128) 025 panicOnErr(err) 026 027 cwd, err := os.Getwd() 028 panicOnErr(err) 029 030 dir, err := storage.ListerForURI( 031 storage.NewFileURI(cwd)) 032 panicOnErr(err) 033 034 files, err := dir.List() 035 panicOnErr(err) 036 037 images := list.New() 038 039 for _, file := range files { 040 if isImage(file) { 041 images.PushBack(file) 042 } 043 } 044 045 if images.Len() == 0 { 046 panic("No images found.") 047 } 048 049 cur := images.Front() 050 051 img := canvas.NewImageFromResource(nil) 052 img.SetMinSize( 053 fyne.NewSize(DspWidth, DspHeight)) 054 lbl := widget.NewLabel( 055 "[H] Left [L] Right [D]elete [Q]uit") 056 con := container.NewVBox(img, lbl) 057 win.SetContent(con) 058 059 showURI(win, cur.Value.(fyne.URI)) 060 showImage(img, cur.Value.(fyne.URI)) 061 preloadImage(scrollRight(images, 062 cur).Value.(fyne.URI)) 063 064 win.Canvas().SetOnTypedKey( 065 func(ev *fyne.KeyEvent) { 066 key := string(ev.Name) 067 switch key { 068 case "L": 069 cur = scrollRight(images, cur) 070 case "H": 071 cur = scrollLeft(images, cur) 072 case "D": 073 if images.Len() == 1 { 074 panic("Not enough images!!") 075 } 076 old := cur 077 cur = scrollRight(images, cur) 078 toTrash(old.Value.(fyne.URI)) 079 images.Remove(old) 080 case "Q": 081 os.Exit(0) 082 } 083 showURI(win, cur.Value.(fyne.URI)) 084 showImage(img, 085 cur.Value.(fyne.URI)) 086 preloadImage(scrollRight(images, 087 cur).Value.(fyne.URI)) 088 }) 089 090 win.ShowAndRun() 091 } 092 093 func showURI(win fyne.Window, uri fyne.URI) { 094 win.SetTitle(filepath.Base(uri.String())) 095 } 096 097 func scrollRight(l *list.List, 098 e *list.Element) *list.Element { 099 e = e.Next() 100 if e == nil { 101 e = l.Front() 102 } 103 return e 104 } 105 106 func scrollLeft(l *list.List, 107 e *list.Element) *list.Element { 108 e = e.Prev() 109 if e == nil { 110 e = l.Back() 111 } 112 return e 113 }
Das Hauptprogramm in Listing 2 definiert zuerst in Zeile 16 mit app.New()
ein neues GUI-Fenster und pfercht später mit showImage()
in den Zeilen 58 und 81 neugeladene Bilder hinein.
Erst ermittelt es in Zeile 23 das aktuelle Verzeichnis, liest alle darin enthaltenen JPG-Fotos ein und speichert die Datei-URLs zu deren Pfaden in einer Storage-Struktur des Fyne-Frameworks. Dies dient in Fyne zur Abstraktion von Dateipfaden, denn nicht alle Betriebssysteme bieten Zugriff auf ein Dateisystem. So kann ein mobiles Telefon zum Beispiel Daten aus der Cloud holen oder aus einer lokalen Datenbank, und dank Fynes Abstraktionslayer verarbeiten die nachfolgenden Funktionen die Daten vollkommen transparent.
Die Tastendrücke des Users fängt SetOnTypedKey()
mit einer Callback-Funktion ab Zeile 62 ab. Dies ist für eine GUI, die eher auf Mausklicks wartet, eher exotisch, aber Fyne erlaubt es und ein Tastatur-Cowboy wie ich scheut die Maus wie der Teufel das Weihwasser. Mit "L" geht's nach rechts, mit "H" nach links, und "D" stülpt den Löschnapf über das aktuelle Foto und schickt es mit toTrash()
in den Abfalleimer.
Die Eventschleife des Hauptprogramms, die erst die GUI auf den Schirm bringt und dann auf Eingaben des Users mit blitzschnell nachgeladenen Fotos reagiert, startet Zeile 87 mit win.ShowAndRun()
.
Wie nun soll das Hauptprogramm die Liste mit Fotos effizient verwalten, durch die der User wie ein Derwisch durchfährt, und hie und da einen Eintrag löscht, den er anschließend nicht mehr sehen will? Ein Array wäre hier die falsche Datenstruktur, denn Arrays mit Löchern erfordern aufwändige Renovierungsarbeiten. Statt dessen zieht das Hauptprogramm die Standard-Library container/list
herein und. Hier handelt es sich um eine doppelt verkettete Liste, in der das Programm schnell mit Next()
zum folgenden und mit Prev()
zum vorherigen Element wandern kann, auch wenn zwischenzeitlich Remove()
beliebige Einträge löscht. Der Speicherbedarf für die gesamte Kollektion in images
ist zwar durch die Verkettung etwas höher als bei einem Array, aber das performante Löschen beliebiger Elemente ohne Einbußen beim Durchschreiten ist den Aufwand allemal wert.
Abbildung 3: In einer doppelt verlinkten Liste kann der User trotz Löchern vor- und zurückfahren. |
Die Funktionen scrollRight()
und scrollLeft()
in den Zeilen 90 und 99 liefern das jeweils nächste anzuzeigende Foto, wenn der User nach rechts ("L") oder links "H" manövriert. Selbst falls der User burschikos über die Enden hinweg fährt, tritt kein Fehler auf. Überschießt er nach rechts, springt scrollRight()
mit Front()
wieder zum Listenanfang, wandert er vom ersten Element weiter nach links, springt scrollLeft()
zum letzten Listenelement.
01 package main 02 03 import ( 04 "fyne.io/fyne/v2" 05 "fyne.io/fyne/v2/canvas" 06 "fyne.io/fyne/v2/storage" 07 "github.com/disintegration/imageorient" 08 "github.com/nfnt/resize" 09 "image" 10 "strings" 11 ) 12 13 const DspWidth = 1200 14 const DspHeight = 800 15 16 func dispDim(w, h int) (dw, dh int) { 17 if w > h { 18 // landscape 19 return DspWidth, DspHeight 20 } 21 22 // portrait 23 return DspHeight, DspWidth 24 } 25 26 func isImage(file fyne.URI) bool { 27 ext := 28 strings.ToLower(file.Extension()) 29 return ext == ".jpg" || ext == ".jpeg" 30 } 31 32 func scaleImage( 33 img image.Image) image.Image { 34 dw, dh := dispDim(img.Bounds().Max.X, 35 img.Bounds().Max.Y) 36 return resize.Thumbnail(uint(dw), 37 uint(dh), img, resize.Lanczos3) 38 } 39 40 func preloadImage(file fyne.URI) { 41 if Cache.Contains(file) { 42 return 43 } 44 go func() { 45 img := loadImage(file) 46 Cache.Add(file, img) 47 }() 48 } 49 50 func showImage( 51 img *canvas.Image, file fyne.URI) { 52 e, ok := Cache.Get(file) 53 var nimg *canvas.Image 54 if ok { 55 nimg = e.(*canvas.Image) 56 } else { 57 nimg = loadImage(file) 58 Cache.Add(file, nimg) 59 } 60 img.Image = nimg.Image 61 62 img.FillMode = canvas.ImageFillOriginal 63 64 img.Refresh() 65 } 66 67 func loadImage( 68 file fyne.URI) *canvas.Image { 69 img := canvas.NewImageFromResource(nil) 70 71 read, err := 72 storage.OpenFileFromURI(file) 73 panicOnErr(err) 74 75 defer read.Close() 76 raw, _, err := imageorient.Decode(read) 77 panicOnErr(err) 78 79 img.Image = scaleImage(raw) 80 81 return img 82 }
Die Routinen zum Skalieren und Laden der Bildateien zeigt Listing 3. Um festzustellen, ob eine Datei ein JPG-Foto ist oder nicht, hilft isImage()
ab Zeile 15, das anhand der Endung den Typ feststellt. Das Herunterskalieren großformatiger Handyfotos auf das Format 1200x800 übernimmt scaleImage()
ab Zeile 21, das auf die Funktion resize()
des nfnt
-Pakets von Github zugreift. Der dort implementierte Algorithmus "Lanczos3" schrumpft die Handy-Fotos definitiv schneller als Fyne, wenn es feststellt, dass ein Bild zu groß für die Darstellung in einem zugewiesenen Widget ist.
Neu geladene Fotos zeigt showImage()
ab Zeile 37 an, indem es versucht, sie erst aus dem Cache zu laden, und, falls dies fehlschlägt, sie mit loadImage()
von der Platte kratzt und dekodiert. Das dauert freilich seine Zeit, muss aber eben manchmal sein. Wegen dieser Verzögerung von einer guten Sekunde nutzt preloadImage()
ab Zeile 27 mit go func
eine Goroutine, um den Ladevorgang im Hintergrund zu erledigen, während das Hauptprogramm weiterläuft und auf Usereingaben reagiert. Fordert der User das nächste Bild an, ist es meist schon im Cache und showImage()
holt es ab und schickt es blitzschnell auf den Schirm.
Dank des Pakets hashicorp/golang-lru
von Github braucht sich der in der globalen Variablen Cache
gehaltene LRU-Cache keine Gedanken über verschwendetes RAM zu machen. Zeile 23 in Listing 2 definiert einen Cache mit maximal 128 Einträgen für vorverarbeitete Bilder, in den Add()
(Zeile 45, Listing 3) neue Elemente unter der Dateipfad-URL des Fotos einfügt und Get()
(Zeile 39) sie wieder hervorholt. Zwar bietet der LRU-Cache die Funktion Contains()
, die feststellt, ob sich ein Eintrag im Cache befindet, aber zur Vermeidung von Race-Conditions sollten Programmierer immer versuchen, einen Eintrag zu holen, falls sie ihn tatsächlich brauchen: Sonst könnte es sein, dass zwar Contains()
meldet, dass der Eintrag vorhanden ist, doch ein nebenläufiges Programm könnte ihn bis zum folgenden Get()
auch wieder verschwinden lassen.
Ist der Cache voll, wirft Add()
gemäß den LRU-Regeln einfach den ältesten Eintrag aus dem Cache, bevor es den neuen einfügt. Für die App, die später vielleicht noch den alten Eintrag sucht, weil der User zurück zu diesem alten Foto gefahren ist, bedeutet dies keinen Beinbruch, denn sie kann das Bild einfach wieder von der Platte holen und neu in den Cache einfügen. Das dauert zwar ein bisschen länger, aber in diesem ungünstigen Fall muss der User halt warten.
Übrigens, noch eine Feinheit zu Gos strengem Typsystem: Container wie der LRU-Cache erlauben es, generische Datentypen einzuspeichern. Deswegen muss der Programmierer beim Hervorholen dafür sorgen, dass der Eintrag per Type-Assertion zur Laufzeit auch wieder den korrekten Typ erhält. So konvertiert zum Beispiel Zeile 42 einen gefundenen Cache-Eintrag in einen Pointer auf den Typ canvas.Image
, denn das hervorgeholte Foto ist von diesem Typ, auch wenn der LRU-Cache ihn zwischenzeitlich als generischen interface{}
-Typ gespeichert hatte.
Solcherlei Manipulationen hebeln natürlich Gos striktes Typsystem aus, und was Go normalerweise schon beim Compilieren abfängt, wird so zum ärgerlichen Laufzeitfehler, falls dergleichen Konvertierungen nicht sorgfältig getestet wurden.
Während Fyne übrigens normalerweise absolute Koordinatenwerte bei Layout-Instruktionen meidet und Widgets wie Buttons automatisch skaliert, allein aufgrund ihrer Beschriftung und dem verwendeten Font, ist dies bei Fotos nicht möglich, denn Fyne kann nicht wissen, wie groß das enthaltende Canvas-Objekt tatsächlich auf dem Bildschirm erscheinen soll. Wir Fyne für das Widget keine Mindestgröße in Form von SetMinSize()
mitgibt, wird womöglich mit einer Lupe auf dem Bildschirm nach dem gesuchten Widget suchen. Ohne Vorgaben malt Fyne Bilder mit der Größe Null mal Null, was oft zu verblüffend schwierig zu findenden App-Fenstern führt. Wer die Mindestgröße vorgibt, wie Listing 3 in Zeile 66, sieht auch, was abgeht.
01 package main 02 03 import ( 04 "os" 05 "path/filepath" 06 07 "fyne.io/fyne/v2" 08 ) 09 10 const TrashDir = "old" 11 12 func toTrash(file fyne.URI) { 13 err := os.MkdirAll(TrashDir, 0755) 14 panicOnErr(err) 15 err = os.Rename(file.Name(), 16 filepath.Join(TrashDir, file.Name())) 17 panicOnErr(err) 18 } 19 20 func panicOnErr(err error) { 21 if err != nil { 22 panic(err) 23 } 24 }
Listing 4 schließlich implementiert den virtuellen Mülleimer, der mit toTash()
Fotodateien in ein im Bedarfsfall neu erzeugtes Verzeichnis "old" abschiebt. Das erledigt die Funktion Rename()
aus dem Standardpaket os
, die solange klaglos funktioniert, wie Orignal und Zieldatei auf dem gleichen Speichermedium residieren.
Damit sich Go die in den Listings verwendeten Fyne-Pakete vom Server auf fyne.io
herunterlädt und die ganze Enchilada compiliert, hilft die Sequenz
$ go mod init inuke
$ go mod tidy
Ein darauffolgendes
$ go build inuke.go image.go trash.go
sollte dann fehlerfrei ein Binary inuke
erzeugen, das alle Fotos im aktuellen Verzeichnis der Reihe nach im Fenster anzeigt. Wie üblich in Go kann der Compiler auch für andere Plattformen cross-compilieren, und im Fall Fyne geht das sogar soweit, dass der Cross-Compiler das Look-and-Feel der anderen Plattform anschleppt. Wie das geht, steht ausführlich im Fyne-Buch vom Fyne-Chef persönlich ([5]). Der exakt gleiche Code der drei Listings compiliert auch anstandslos auf einem Mac und resultiert im leicht angpassten Apple-Look-and-Feel, wie aus Abbildung 4 ersichtlich.
Abbildung 4: Aus dem gleichen Code für den Mac compilierte App. |
Die Implementierung graphischer Oberflächen hängt außerdem stark vom verwendeten Betriebssystem ab. Unter Linux klinkt sich Fyne mittels eines C-Wrappers aus Go in die Bibliotheken libx11-dev
, libgl1-mesa-dev
, libxcursor-dev
und xorg-dev
ein, die der User zum Beispiel auf Ubuntu mit sudo apt-get install
nachinstallieren muss, damit ein darauffolgendes go build
einer Fyne-App auch das notwendige Fundament findet.
Perfekt ist die App allerdings noch nicht. Es soll tatsächlich noch Fotografen geben, die im Hochformat fotografieren, dafür wäre eine entsprechende Anpassung der fest eingestellten Fensterdimensionen notwendig. Auch kaprizieren sich manche Mobiltelefone darauf, Fotos unrotiert abzuspeichern und die Rotationsvorgabe lediglich im EXIF-Header des JPG-Fotos zu notieren. Entsprechende Anpassungen sind einfach zu erledigen, wie immer bei Open-Source-Projekten!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/11/snapshot/
Markus Hoffmann, "Fynearbeit", Linux-Magazin 09/21, S. 78, <U>https://www.linux-magazin.de/ausgaben/2021/09/fyne/<U>
Michael Schilli, "Automatisch Ausmustern", Linux-Magazin 08/18, https://www.linux-magazin.de/ausgaben/2018/08/snapshot-5/, https://www.youtube.com/watch?v=ha8Gc2hJFdU
Andrew Williams, "Building Cross-Platform GUI Applications with Fyne", Packt Publishing 2021
Gebrüder Grimm, "Aschenputtel", https://de.wikipedia.org/wiki/Aschenputtel
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc