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.
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:
- Probing Concorrente Parallelo con
withTaskGroup: All'avvio dell'app (o quando si verifica un errore di connettività), l'app spara richieste HTTPGET /api/pingcontemporaneamente a tutti gli IP candidati (Hotspot172.20.10.x, Wi-Fi192.168.1.x, Localhost) con un timeout strettissimo di 1.8 secondi su sessione effimera. - Aggancio Istantaneo al Primo Risponditore: Il primo IP che risponde con
HTTP 200 OKe body valido viene immediatamente impostato comebaseURLattiva e salvato inUserDefaults. - Transparent Retry Interceptor: Se una qualsiasi richiesta ordinaria fallisce con
.cannotConnectToHosto.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
- 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.
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