Wer aus einer Digitalsammlung nur gute Fotos behalten will, muss die misslungenen finden und löschen. Mike Schilli schreibt dafür mit Go und dem Fyne-Framework eine grafische Applikation.
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, die es lohnt aufzuheben.
Vor drei Jahren hat der Programmier-Snapshot schon einmal ein ganz ähnliches grafisches Tool vorgestellt, das den Nutzer schlechte Fotos händisch ausmustern ließ [1]. 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 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.
Letztens hat das Linux-Magazin einfache Beispiele [2] mit Fyne gezeigt, aber eine echte Applikation erfordert noch etwas zusätzlichen Feinschliff. Listing 1 zeigt den ersten Versuch einer Foto-App, die ein JPEG-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 Brezelbä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 Rande Deutschlands. Am Layout der App mit dem Foto und dem Ausschaltknopf gäbe es nichts zu meckern, außer dass es gut zwei Sekunden dauert, um ein Bild der Handykamera mit 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.
Listing 1
img.go
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"os"
)
func main() {
win := app.New().NewWindow("imgtest")
img :=
canvas.NewImageFromFile("pretz.jpg")
img.SetMinSize(fyne.NewSize(600, 400))
button := widget.NewButton("Quit",
func() { os.Exit(1) })
con := container.NewVBox(img, button)
win.SetContent(con)
win.ShowAndRun()
}

Abbildung 1: Die simple Fyne-App aus Listing 1 zeigt ein Foto an.
Schnell dank Profitricks
Die in dieser Ausgabe vorgestellte blitzschnelle GUI, die die Bilder aus dem aktuellen Verzeichnis nacheinander anzeigt, mittels Vi-artiger Steuerung durch [L]+ beziehungsweise [H]+ zum nächsten beziehungsweise 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 [4]. Wer sich an der Vi-Tastaturbindung übrigens stört und stattdessen lieber die Cursortasten bedient, der kann 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 Zehntelsekunde, nachdem man es durch einen Druck auf [L] gefordert 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 Anwender wieder danach verlangt. Doch welche Fotos lohnt es sich vorzuhalten, wenn nicht alle ins RAM passen, weil zum Beispiel ein Verzeichnis 5000 Fotos mit je 4 MByte enthält und nicht jeder 20 GByte RAM verpulvern kann? Die Lösung ist ein LRU-Cache (Last Recently Used), der eine vorab definierte Maximalzahl an Einträgen aufnimmt, aber bei Überfüllung einfach diejenigen Elemente hinauswirft, deren letztes Zugriffsdatum am längsten 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 leistungsfähige App verkleinert die Fotos stets auf Bildschirmgröße schon bevor sie in den Cache wandern.
Und drittens hilft der App ein Preload-Mechanismus auf die Sprünge. Designbedingt zeigt sie Fotos immer in einer bestimmten Reihenfolge an, entweder vorwärts oder rückwärts, je nachdem in welcher Richtung der Nutzer gerade unterwegs ist. Also kann sie leicht vorhersagen, welches Foto beim nächsten Tastendruck auf dem Schirm erscheinen soll. Lädt nun die App das wahrscheinlich gleich folgende Foto bereits im Hintergrund in den Cache, während das aktuelle noch zu sehen ist, kann das Fyne-Framework das nächste Foto praktisch sofort anzeigen, sobald der Tastendruck endlich erfolgt.
Zappzarapp!
Das Ergebnis ist erstaunlich, mit diesen drei Verbesserungen flutscht die Anzeige des Go-Programms in atemberaubendem Tempo und schlägt 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 macht die in der App nun erlaubten Tastendrücke sichtbar: Mit [H]+ springt die App zurück zum letzten Bild, mit [L]+ zur nächsten Aufnahme, mit [D]+ löscht sie das aktuelle Foto, und mit [Q] bricht die App das Programm ab. Wie sieht nun die Programmierung des Problems in Go aus?
Listing 2
inuke.go
package main
import (
"container/list"
"os"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/widget"
"github.com/hashicorp/golang-lru"
)
var Cache *lru.Cache
func main() {
win := app.New().NewWindow("iNuke")
var err error
Cache, err = lru.New(128)
panicOnErr(err)
cwd, err := os.Getwd()
panicOnErr(err)
dir, err := storage.ListerForURI(
storage.NewFileURI(cwd))
panicOnErr(err)
files, err := dir.List()
panicOnErr(err)
images := list.New()
for _, file := range files {
if isImage(file) {
images.PushBack(file)
}
}
if images.Len() == 0 {
panic("No images found.")
}
cur := images.Front()
img := canvas.NewImageFromResource(nil)
img.SetMinSize(
fyne.NewSize(DspWidth, DspHeight))
lbl := widget.NewLabel(
"[H] Left [L] Right [D]elete [Q]uit")
con := container.NewVBox(img, lbl)
win.SetContent(con)
showImage(img, cur.Value.(fyne.URI))
preloadImage(scrollRight(images,
cur).Value.(fyne.URI))
win.Canvas().SetOnTypedKey(
func(ev *fyne.KeyEvent) {
key := string(ev.Name)
switch key {
case "L":
cur = scrollRight(images, cur)
case "H":
cur = scrollLeft(images, cur)
case "D":
if images.Len() == 1 {
panic("Not enough images!!")
}
old := cur
cur = scrollRight(images, cur)
toTrash(old.Value.(fyne.URI))
images.Remove(old)
case "Q":
os.Exit(0)
}
showImage(img,
cur.Value.(fyne.URI))
preloadImage(scrollRight(images,
cur).Value.(fyne.URI))
})
win.ShowAndRun()
}
func scrollRight(l *list.List,
e *list.Element) *list.Element {
e = e.Next()
if e == nil {
e = l.Front()
}
return e
}
func scrollLeft(l *list.List,
e *list.Element) *list.Element {
e = e.Prev()
if e == nil {
e = l.Back()
}
return e
}
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 neu geladene Bilder hinein.
Erst ermittelt es in Zeile 23 das aktuelle Verzeichnis, liest alle darin enthaltenen JPEG-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 fängt »SetOnTypedKey()« mit einer Callback-Funktion ab Zeile 62 ab. Dies ist für eine GUI, die eher auf Mausklicks wartet, recht exotisch. Doch Fyne erlaubt es, und ein Tastatur-Cowboy wie ich scheut die Maus wie der Teufel das Weihwasser. Mit [L]+ geht es 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 Event-Schleife des Hauptprogramms, die erst die GUI auf den Schirm bringt und dann auf Eingaben mit blitzschnell nachgeladenen Fotos reagiert, startet Zeile 87 mit »win.ShowAndRun()«.
Array mit Löchern
Wie nun soll das Hauptprogramm die Liste mit Fotos effizient verwalten, durch die der Nutzer wie ein Derwisch fä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 aufwendige Renovierungsarbeiten. Stattdessen zieht das Hauptprogramm die Standard-Library »container/list« herein. 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 zügige Löschen beliebiger Elemente ohne Einbußen beim Durchschreiten ist den Aufwand allemal wert.

Abbildung 3: In einer doppelt verlinkten Liste kann der Anwender 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 man nach rechts ([L]+) oder links ([H]) manövriert. Selbst wenn der Nutzer 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.
Listing 3
image.go
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/storage"
"github.com/nfnt/resize"
"image"
"strings"
)
const DspWidth = 1200
const DspHeight = 800
func isImage(file fyne.URI) bool {
ext :=
strings.ToLower(file.Extension())
return ext == ".jpg" || ext == ".jpeg"
}
func scaleImage(
img image.Image) image.Image {
return resize.Thumbnail(DspWidth,
DspHeight, img, resize.Lanczos3)
}
func preloadImage(file fyne.URI) {
if Cache.Contains(file) {
return
}
go func() {
img := loadImage(file)
Cache.Add(file, img)
}()
}
func showImage(
img *canvas.Image, file fyne.URI) {
e, ok := Cache.Get(file)
var nimg *canvas.Image
if ok {
nimg = e.(*canvas.Image)
} else {
nimg = loadImage(file)
Cache.Add(file, nimg)
}
img.Image = nimg.Image
img.Refresh()
}
func loadImage(
file fyne.URI) *canvas.Image {
img := canvas.NewImageFromResource(nil)
read, err :=
storage.OpenFileFromURI(file)
panicOnErr(err)
defer read.Close()
raw, _, err := image.Decode(read)
panicOnErr(err)
img.Image = scaleImage(raw)
img.FillMode = canvas.ImageFillContain
img.SetMinSize(
fyne.NewSize(DspWidth, DspHeight))
return img
}
Die Routinen zum Skalieren und Laden der Bilddateien zeigt Listing 3. Um festzustellen, ob eine Datei ein JPEG-Foto ist oder nicht, hilft »isImage()« ab Zeile 15, das anhand der Endung den Typ feststellt. Das Herunterskalieren großformatiger Handyfotos auf das Format 1200 x 800 ü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.
Schneller dank Vorarbeit
Neu geladene Fotos zeigt »showImage()« ab Zeile 37 an, indem es versucht, sie erst aus dem Cache zu laden und sie, falls dies fehlschlägt, 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 Eingaben reagiert. Fordert der Anwender das nächste Bild an, ist es meist schon im Cache, und »showImage()« holt es ab und schickt es blitzschnell auf den Schirm.
Umsichtiger Cache
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 Nutzer zurück zu diesem alten Foto gefahren ist, stellt dass keinen Beinbruch dar. 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 man 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 allerdings Gos striktes Typsystem aus, und was Go normalerweise schon beim Kompilieren abfängt, gerät so zum ärgerlichen Laufzeitfehler, falls derartige Konvertierungen nicht sorgfältig getestet wurden.
Normalerweise meidet Fyne übrigens absolute Koordinatenwerte bei Layout-Instruktionen und skaliert Widgets wie Buttons automatisch. 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. Wer 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 müssen. Ohne Vorgaben malt Fyne Bilder mit der Größe null mal null, was oft zu verblüffend schwer zu findenden App-Fenstern führt. Wer die Mindestgröße vorgibt wie Listing 3 in Zeile 66, der sieht auch, was abgeht.
Listing 4
trash.go
package main
import (
"os"
"path/filepath"
"fyne.io/fyne/v2"
)
const TrashDir = "old"
func toTrash(file fyne.URI) {
err := os.MkdirAll(TrashDir, 0755)
panicOnErr(err)
err = os.Rename(file.Name(),
filepath.Join(TrashDir, file.Name()))
panicOnErr(err)
}
func panicOnErr(err error) {
if err != nil {
panic(err)
}
}
Listing 4 schließlich implementiert den virtuellen Mülleimer, der mit »toTash()« Fotodateien in ein bei Bedarf neu erzeugtes Verzeichnis »old/« abschiebt. Das erledigt die Funktion »Rename()« aus dem Standardpaket Os, die so lange klaglos funktioniert, wie das Original und die Zieldatei auf dem gleichen Speichermedium residieren.
Angepasstes Look & Feel
Damit sich Go die in den Listings verwendeten Fyne-Pakete vom Server auf »fyne.io« herunterlädt und die ganze Enchilada kompiliert, hilft die Sequenz aus Listing 5.
Listing 5
trash.go
$ 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 crosskompilieren, und im Fall Fyne geht das sogar so weit, dass der Crosscompiler das Look & Feel der anderen Plattform anschleppt. Wie das geht, steht ausführlich im Fyne-Buch vom Fyne-Chef persönlich [3]. Der Code der drei Listings kompiliert auch anstandslos auf einem Mac und resultiert in einer leicht angepassten Apple-Anmutung, wie Abbildung 4 zeigt.
Die Implementierung grafischer 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, Lbxcursor-dev und Xorg-dev ein. Die muss der Nutzer zum Beispiel auf Ubuntu mit »sudo apt-get install« nachinstallieren, damit ein darauffolgendes »go build« einer Fyne-App auch das notwendige Fundament findet.
Nobody is perfect
Perfekt ist die App allerdings noch nicht. Es soll doch tatsächlich 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 JPEG-Fotos zu notieren. Entsprechende Anpassungen lassen sich einfach erledigen, wie immer bei Open-Source-Projekten. (uba)
Infos
- Snapshot: Mike Schilli, “Schlaues Ausmustern”, LM 08/2018, S. 88, https://www.lm-online.de/41216
- Fyne: Markus Hoffmann, “Fynearbeit”, LM 09/2021, S. 78, https://www.lm-online.de/46405
- “Building Cross-Platform GUI Applications with Fyne”: https://www.packtpub.com/product/building-cross-platform-gui-applications-with-fyne/9781800563162
- Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/11/snapshot/








