Ogni sviluppatore iOS prima o poi affronta questo scenario frustrante: sviluppi l'app sul Simulatore o con l'iPhone connesso al Wi-Fi di casa (es. 192.168.1.4), tutto funziona alla perfezione. Poi esci di casa, accendi l'Hotspot personale dell'iPhone o colleghi il cavo USB al Mac in mobilità e all'improvviso... l'app smette di caricare qualsiasi dato. Lo schermo rimane bianco o con spinner infiniti.

In questo articolo analizziamo perché si verifica questo fenomeno (il cambio silente di Subnet da 192.168.1.x a 172.20.10.x), perché l'approccio classico con timeout blocca l'app per decine di secondi e come abbiamo implementato un sistema di Auto-Discovery Concorrente con Swift Concurrency (TaskGroup) capace di trovare e agganciare il server Docker attivo in meno di 1.5 secondi.

1. L'Origine del Problema: Il Cambio di Subnet in Mobilità

Quando il Mac è connesso al router Wi-Fi domestico o dell'ufficio, riceve un indirizzo IP locale tipicamente appartenente alla classe 192.168.1.0/24 o 192.168.0.0/24. Se configuri nel client API dell'app iOS il valore:

let baseURL = "http://192.168.1.4:8001/api"

la comunicazione funziona perché entrambi i dispositivi sono sullo stesso segmento di broadcast.

Tuttavia, non appena esci di casa o disattivi il Wi-Fi e attivi l'Hotspot Personale di iOS (Personal Hotspot), iOS crea una rete ad-hoc isolata con classe 172.20.10.0/28. Il Mac assume l'IP 172.20.10.3 (o simile), mentre l'iPhone agisce da gateway su 172.20.10.1.

Cosa succede all'app sull'iPhone:

L'iPhone invia i pacchetti TCP SYN verso 192.168.1.4. Poiché quell'IP non esiste sull'interfaccia dell'Hotspot, i pacchetti cadono nel vuoto. Il sistema operativo aspetta il timeout predefinito di URLSession (spesso 60 o 15 secondi per richiesta). Con 3 o 4 richieste parallele a cascata, l'interfaccia utente si congela completamente.

2. L'Architettura dell'Auto-Discovery Concorrente in Swift

Per risolvere il problema alla radice senza costringere lo sviluppatore a modificare l'IP nel codice ogni volta che esce di casa, abbiamo progettato un pattern a doppio livello:

  1. Probing Concorrente Parallelo con withTaskGroup: All'avvio dell'app (o quando si verifica un errore di connettività), l'app spara richieste HTTP GET /api/ping contemporaneamente a tutti gli IP candidati (Hotspot 172.20.10.x, Wi-Fi 192.168.1.x, Localhost) con un timeout strettissimo di 1.8 secondi su sessione effimera.
  2. Aggancio Istantaneo al Primo Risponditore: Il primo IP che risponde con HTTP 200 OK e body valido viene immediatamente impostato come baseURL attiva e salvato in UserDefaults.
  3. Transparent Retry Interceptor: Se una qualsiasi richiesta ordinaria fallisce con .cannotConnectToHost o .timedOut, l'interceptor esegue l'auto-discovery in background, riscrive l'URL della richiesta e la ritenta al volo senza mostrare alcun errore all'utente.

3. Implementazione di AppNetworkClient in Swift

Ecco il codice pronto per la produzione di AppNetworkClient che implementa questo pattern:

import Foundation

public final class AppNetworkClient: @unchecked Sendable {
    public static let shared = AppNetworkClient()
    
    /// Lista di IP candidati da testare in parallelo
    private let fallbackCandidates = [
        "http://172.20.10.3:8001/api",  // Hotspot iPhone / Tethering 4G/5G
        "http://192.168.1.4:8001/api",  // Wi-Fi Domestico Principale
        "http://192.168.1.2:8001/api",
        "http://192.168.1.3:8001/api",
        "http://192.168.1.5:8001/api",
        "http://172.20.10.1:8001/api",
        "http://172.20.10.2:8001/api",
        "http://172.20.10.4:8001/api",
        "http://127.0.0.1:8001/api",     // Simulatore iOS locale
        "http://localhost:8001/api"
    ]
    
    /// URL di base dinamico con persistenza locale
    public var baseURL: URL {
        get {
            if let custom = UserDefaults.standard.string(forKey: "app_api_base_url"),
               let url = URL(string: custom) {
                return url
            }
            #if targetEnvironment(simulator)
            return URL(string: "http://127.0.0.1:8001/api")!
            #else
            return URL(string: "http://172.20.10.3:8001/api")!
            #endif
        }
        set {
            UserDefaults.standard.set(newValue.absoluteString, forKey: "app_api_base_url")
        }
    }
    
    private let session: URLSession
    
    private init() {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 6
        config.timeoutIntervalForResource = 15
        config.waitsForConnectivity = true
        self.session = URLSession(configuration: config)
        
        // Esegue auto-discovery non bloccante all'inizializzazione
        Task {
            await self.discoverWorkingBaseURL()
        }
    }
    
    /// Effettua un probe parallelo a bassissima latenza per trovare il server attivo
    @discardableResult
    public func discoverWorkingBaseURL() async -> URL? {
        await withTaskGroup(of: URL?.self) { group in
            for candidate in fallbackCandidates {
                guard let candidateURL = URL(string: candidate) else { continue }
                group.addTask {
                    guard let pingURL = URL(string: "\(candidate)/ping") else { return nil }
                    var req = URLRequest(url: pingURL)
                    req.timeoutInterval = 1.8
                    let config = URLSessionConfiguration.ephemeral
                    config.timeoutIntervalForRequest = 1.8
                    config.timeoutIntervalForResource = 1.8
                    let probeSession = URLSession(configuration: config)
                    
                    if let (data, resp) = try? await probeSession.data(for: req),
                       let http = resp as? HTTPURLResponse,
                       (200...299).contains(http.statusCode), !data.isEmpty {
                        return candidateURL
                    }
                    return nil
                }
            }
            // Restituisce il primo candidato che risponde con successo
            for await result in group {
                if let found = result {
                    self.baseURL = found
                    return found
                }
            }
            return nil
        }
    }
    
    /// Esecutore di rete con retry trasparente e auto-recovery
    private func executeRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        do {
            let (data, response) = try await session.data(for: request)
            if let http = response as? HTTPURLResponse {
                return (data, http)
            }
            throw URLError(.badServerResponse)
        } catch let err as URLError where err.code == .cannotConnectToHost ||
                                          err.code == .timedOut ||
                                          err.code == .networkConnectionLost {
            // Se fallisce, avvia la scansione e ripete la richiesta sull'IP attivo
            if let workingBase = await discoverWorkingBaseURL(),
               let originalURL = request.url {
                let path = originalURL.path.replacingOccurrences(of: baseURL.path, with: "")
                let newURLString = "\(workingBase.absoluteString)\(path)\(originalURL.query != nil ? "?\(originalURL.query!)" : "")"
                if let newURL = URL(string: newURLString) {
                    var retryRequest = request
                    retryRequest.url = newURL
                    if let (retryData, retryResponse) = try? await session.data(for: retryRequest),
                       let http = retryResponse as? HTTPURLResponse {
                        return (retryData, http)
                    }
                }
            }
            throw err
        }
    }
}

4. Configurazione Docker & Nginx per Accettare Connessioni Remote

Affinché l'iPhone possa raggiungere il container Nginx/PHP in esecuzione su macOS, è fondamentale che Docker effettui il binding delle porte su 0.0.0.0 (tutte le interfacce) e non solo su 127.0.0.1.

Nel file docker-compose.yml del backend:

services:
  webserver:
    image: nginx:alpine
    container_name: app_webserver
    ports:
      - "0.0.0.0:8001:80"  # Permette la connessione da Wi-Fi, Hotspot e LAN
    volumes:
      - ./backend:/var/www/html
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
    networks:
      - app_network

E nell'endpoint /api/ping del backend (es. in Laravel routes/api.php):

Route::get('/ping', function () {
    return response()->json([
        'pong' => true,
        'timestamp' => now()->toIso8601String()
    ]);
});

5. Compilazione e Deploy Istantaneo con Xcode CLI

Per verificare immediatamente che l'iPhone fisico riceva i dati senza passare per l'interfaccia grafica di Xcode, possiamo sfruttare la combinazione di xcodebuild e devicectl da terminale:

# 1. Identifica il dispositivo connesso
xcrun devicectl list devices

# 2. Compila il target con firma di sviluppo
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild -scheme MyApp \
-destination "id=" \
-allowProvisioningUpdates build

# 3. Installa ed esegui l'app sull'iPhone fisico
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcrun devicectl device install app --device  /path/to/MyApp.app

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcrun devicectl device process launch --device  com.example.myapp

6. Risultati & Vantaggi Pratici

Vantaggi ottenuti sul campo:
  • Zero Downtime in Mobilità: Passando dal Wi-Fi di casa all'Hotspot 5G in treno o all'aperto, l'app aggancia il backend in 1.2 secondi senza alcun crash.
  • Nessun IP Hardcoded Fisso: Il team di sviluppo può spostarsi su reti diverse senza modificare una singola riga di codice.
  • Esperienza Utente Impeccabile: Profili, soundscapes, feed e notifiche si caricano istantaneamente anche in condizioni di rete instabile.
Diallo Yunus

Scritto da Diallo Yunus

Sviluppatore iOS Indie, creatore di ChaGo e 11+ app native pubblicate sull'Apple App Store. Specializzato in Swift Concurrency, Spatial Audio DSP, architetture Cloud resilienti e strategie di monetizzazione.

Stai sviluppando un'App iOS Nativa o un'Architettura Cloud?

Hai bisogno di consulenza tecnica su Swift Concurrency, Docker backend, notifiche push APNs o pubblicazione su App Store? Parliamone direttamente.

Contattami su WhatsApp