Salta al contenuto principale

Applicazioni desktop in Go con Fyne: dashboard mercato azionario

·7 minuti
Indice dei contenuti
Fyne e il miglior toolkit UI cross-platform per Go. Un unico codebase compila su Windows, macOS e Linux, producendo un binario nativo senza dipendenze runtime. Questo articolo costruisce una vera dashboard per il mercato azionario, coprendo il sistema di widget di Fyne, il layout engine, le regole di thread safety per le goroutine e la distribuzione.

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
#

terminale
# 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@latest
Importante

Fyne 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.

main.go
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:

widgets.go
// 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 fetch

Layout
#

I layout determinano come i widget sono disposti all’interno dei container. I layout piu comuni:

Impila i widget verticalmente.

layout.go
content := container.NewVBox(
    widget.NewLabel("Simbolo azionario"),
    entry,
    button,
    label,
)

Impila i widget orizzontalmente.

layout.go
searchBar := container.NewHBox(
    entry,
    button,
)

Posiziona i widget sui bordi con un’area di fill al centro. Ottimo per il layout principale.

layout.go
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.

api.go
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.

display.go
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.

chart.go
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:

fetch.go
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))
            }
        })
    }()
}
Avviso

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
#

main.go
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
#

terminale
# 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
L’errore piu comune nelle app Fyne. Qualsiasi chiamata a 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
Il piano gratuito consente 25 richieste API al giorno. Se fai richieste aggressive durante lo sviluppo esaurirai rapidamente la quota. Metti in cache le risposte su disco durante lo sviluppo e aggiungi un controllo prima di fare una chiamata di rete.
Nessuno stato di caricamento
Senza uno spinner o un pulsante disabilitato, l’utente puo cliccare Cerca piu volte mentre una richiesta e in corso, causando goroutine concorrenti che cercano tutte di aggiornare gli stessi widget. Mostra ProgressBarInfinite prima di avviare la goroutine e nascondila dentro CallOnMainThread quando hai finito.
Hardcoding della chiave API
Non incorporare mai una chiave API nel codice sorgente. Leggila da una variabile d’ambiente (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.

Articoli correlati