Perche Fyne#
Le applicazioni desktop in Go sono storicamente state scomode. I binding CGo a GTK o Qt funzionano, ma la complessita di build e considerevole. Fyne adotta un approccio diverso: usa OpenGL per il rendering, mantenendo l’API in puro Go e il binario risultante completamente autonomo.
Il compromesso e che le app Fyne assomigliano alle app Fyne piuttosto che alle app native della piattaforma. Se l’integrazione pixel-perfect con la piattaforma e importante, valuta Gio o gli approcci basati su webview. Se vuoi un’app cross-platform che si distribuisce facilmente e ha un aspetto coerente, Fyne e lo strumento giusto.
Setup#
# CGo e necessario. Su macOS, viene con gli Xcode Command Line Tools:
xcode-select --install
# Su Ubuntu/Debian:
# sudo apt-get install gcc libgl1-mesa-dev xorg-dev
go mod init stock-dashboard
go get fyne.io/fyne/v2@latestFyne richiede CGo. Se vedi cgo: C compiler "gcc" not found, installa i prerequisiti per la tua piattaforma prima di eseguire go get.
App e finestre di base#
Ogni applicazione Fyne inizia con app.New(). La finestra e il contenitore di primo livello.
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
a := app.New()
w := a.NewWindow("Stock Dashboard")
w.Resize(fyne.NewSize(800, 600))
w.SetContent(widget.NewLabel("Loading..."))
w.ShowAndRun()
}ShowAndRun blocca l’esecuzione fino alla chiusura della finestra. Tutto cio che accade nell’applicazione deve essere coordinato attorno a questo loop principale.
Widget#
Fyne fornisce un insieme di widget standard. Quelli usati in questa dashboard:
// Input di testo
entry := widget.NewEntry()
entry.SetPlaceHolder("AAPL")
// Pulsante con callback
button := widget.NewButton("Cerca", func() {
// gira sul main thread, sicuro aggiornare la UI qui
})
// Label di sola lettura
label := widget.NewLabel("Prezzo: --")
// Selezione dropdown
selector := widget.NewSelect(
[]string{"1D", "5D", "1M", "3M"},
func(selected string) {
// gestisci il cambio di selezione
},
)
// Indicatore di progresso durante il fetch
progress := widget.NewProgressBarInfinite()
progress.Hide() // mostrato solo durante il fetchLayout#
I layout determinano come i widget sono disposti all’interno dei container. I layout piu comuni:
Impila i widget verticalmente.
content := container.NewVBox(
widget.NewLabel("Simbolo azionario"),
entry,
button,
label,
)Impila i widget orizzontalmente.
searchBar := container.NewHBox(
entry,
button,
)Posiziona i widget sui bordi con un’area di fill al centro. Ottimo per il layout principale.
main := container.NewBorder(
searchBar, // top
nil, // bottom
nil, // left
nil, // right
content, // fill centrale
)Recuperare i dati da Alpha Vantage#
Alpha Vantage ha un piano gratuito (25 richieste al giorno) e un’API REST semplice. Registrati su alphavantage.co per ottenere una chiave.
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type DailyResponse struct {
MetaData map[string]string `json:"Meta Data"`
TimeSeries map[string]map[string]string `json:"Time Series (Daily)"`
}
type DayData struct {
Date string
Close float64
}
func fetchDailyPrices(symbol, apiKey string) ([]DayData, error) {
url := fmt.Sprintf(
"https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=%s&outputsize=compact&apikey=%s",
symbol, apiKey,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
var data DailyResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("decode failed: %w", err)
}
var result []DayData
for date, values := range data.TimeSeries {
var close float64
fmt.Sscanf(values["4. close"], "%f", &close)
result = append(result, DayData{Date: date, Close: close})
}
// Ordina per data decrescente
sort.Slice(result, func(i, j int) bool {
return result[i].Date > result[j].Date
})
return result, nil
}Visualizzare i dati: label e tabella#
La label del prezzo si aggiorna dopo ogni fetch. La tabella mostra gli ultimi 10 giorni di contrattazione.
func buildPriceTable(days []DayData) *widget.Table {
table := widget.NewTable(
func() (int, int) {
return len(days), 2 // righe, colonne
},
func() fyne.CanvasObject {
return widget.NewLabel("")
},
func(id widget.TableCellID, obj fyne.CanvasObject) {
label := obj.(*widget.Label)
switch id.Col {
case 0:
label.SetText(days[id.Row].Date)
case 1:
label.SetText(fmt.Sprintf("%.2f", days[id.Row].Close))
}
},
)
table.SetColumnWidth(0, 120)
table.SetColumnWidth(1, 100)
return table
}Disegnare una sparkline dei prezzi#
Il package canvas di Fyne fornisce primitive per il disegno. Usiamo canvas.Line per disegnare una sparkline normalizzando i prezzi rispetto alle dimensioni del widget.
package main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/widget"
)
type SparklineWidget struct {
widget.BaseWidget
prices []float64
}
func NewSparkline(prices []float64) *SparklineWidget {
s := &SparklineWidget{prices: prices}
s.ExtendBaseWidget(s)
return s
}
func (s *SparklineWidget) CreateRenderer() fyne.WidgetRenderer {
lines := make([]*canvas.Line, 0, len(s.prices)-1)
for range s.prices[1:] {
l := canvas.NewLine(color.RGBA{R: 0, G: 180, B: 120, A: 255})
l.StrokeWidth = 2
lines = append(lines, l)
}
return &sparklineRenderer{lines: lines, spark: s}
}
type sparklineRenderer struct {
lines []*canvas.Line
spark *SparklineWidget
}
func (r *sparklineRenderer) Layout(size fyne.Size) {
if len(r.spark.prices) < 2 {
return
}
min, max := r.spark.prices[0], r.spark.prices[0]
for _, p := range r.spark.prices {
if p < min { min = p }
if p > max { max = p }
}
span := max - min
if span == 0 { span = 1 }
n := float32(len(r.spark.prices) - 1)
for i, line := range r.lines {
x1 := float32(i) / n * size.Width
x2 := float32(i+1) / n * size.Width
y1 := size.Height - float32((r.spark.prices[i]-min)/span)*size.Height
y2 := size.Height - float32((r.spark.prices[i+1]-min)/span)*size.Height
line.Position1 = fyne.NewPos(x1, y1)
line.Position2 = fyne.NewPos(x2, y2)
}
}
func (r *sparklineRenderer) MinSize() fyne.Size { return fyne.NewSize(200, 60) }
func (r *sparklineRenderer) Refresh() { canvas.Refresh(r.spark) }
func (r *sparklineRenderer) Destroy() {}
func (r *sparklineRenderer) Objects() []fyne.CanvasObject {
objs := make([]fyne.CanvasObject, len(r.lines))
for i, l := range r.lines { objs[i] = l }
return objs
}Goroutine e thread safety della UI#
La UI di Fyne deve essere aggiornata solo dal main thread. Se chiami label.SetText() da una goroutine, otterrai race condition o un crash.
Il pattern per aggiornamenti asincroni sicuri:
func onFetchClicked(symbol string, apiKey string, priceLabel *widget.Label, progress *widget.ProgressBarInfinite) {
// Mostra lo spinner -- siamo sul main thread, e sicuro
progress.Show()
go func() {
days, err := fetchDailyPrices(symbol, apiKey)
// TUTTI gli aggiornamenti UI devono tornare al main thread
fyne.CurrentApp().Driver().CallOnMainThread(func() {
progress.Hide()
if err != nil {
priceLabel.SetText("Errore: " + err.Error())
return
}
if len(days) > 0 {
priceLabel.SetText(fmt.Sprintf("%.2f USD (ultima chiusura)", days[0].Close))
}
})
}()
}Non aggiornare mai i widget Fyne direttamente da una goroutine. Usa fyne.CurrentApp().Driver().CallOnMainThread() per eseguire gli aggiornamenti UI sul main thread. Saltare questo passaggio causa crash intermittenti difficili da riprodurre.
Tutto insieme#
func main() {
apiKey := os.Getenv("ALPHAVANTAGE_API_KEY")
if apiKey == "" {
log.Fatal("imposta la variabile d'ambiente ALPHAVANTAGE_API_KEY")
}
a := app.New()
w := a.NewWindow("Stock Dashboard")
w.Resize(fyne.NewSize(800, 600))
entry := widget.NewEntry()
entry.SetPlaceHolder("AAPL")
priceLabel := widget.NewLabel("Prezzo: --")
progress := widget.NewProgressBarInfinite()
progress.Hide()
button := widget.NewButton("Cerca", func() {
onFetchClicked(entry.Text, apiKey, priceLabel, progress)
})
searchBar := container.NewBorder(nil, nil, nil, button, entry)
layout := container.NewVBox(
searchBar,
progress,
priceLabel,
)
w.SetContent(container.NewBorder(layout, nil, nil, nil, widget.NewLabel("")))
w.ShowAndRun()
}Distribuzione cross-platform#
# Installa il Fyne CLI
go install fyne.io/tools/cmd/fyne@latest
# Package per la piattaforma corrente
fyne package -name "Stock Dashboard" -appID com.example.stockdashboard
# Cross-compile (richiede Docker o un toolchain corrispondente)
fyne package -os windows -name "Stock Dashboard"
fyne package -os linux -name "Stock Dashboard"Il comando fyne package produce un bundle .app su macOS, un .exe con le risorse integrate su Windows e un tarball su Linux. Tutte le dipendenze sono incluse nell’output.
Aggiornare la UI direttamente da una goroutine
widget.SetText, widget.Show, widget.Hide o simili da una goroutine causa una race condition. Usa sempre fyne.CurrentApp().Driver().CallOnMainThread() per eseguire gli aggiornamenti UI sul main thread.Raggiungere i limiti di rate di Alpha Vantage sul piano gratuito
Nessuno stato di caricamento
ProgressBarInfinite prima di avviare la goroutine e nascondila dentro CallOnMainThread quando hai finito.Hardcoding della chiave API
os.Getenv) o da un file di configurazione elencato in .gitignore. Le chiavi hardcoded finiscono nella storia git e sono difficili da ruotare.Se vuoi approfondire uno qualsiasi di questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.