Chapitre 12
12 - Networking Mobile
12 - Networking Mobile
12 - Networking Mobile : Cours complet
Niveau : Intermédiaire → Avancé Durée estimée : 6 h Objectif : maîtriser la couche réseau d'une application mobile : REST, GraphQL, WebSocket, retry, sécurité et images.
Table des matières
- Rappels réseau et REST
- Retrofit + OkHttp
- Alamofire et URLSession
- Apollo GraphQL
- WebSocket : temps réel
- Patterns REST
- Gestion des erreurs
- Retry logic
- Offline queue
- Sécurité réseau
- Chargement d'images
1. Rappels réseau et REST
1.1 Le modèle client-serveur
Diagramme en cours de génération...
1.2 Les verbes HTTP
| Verbe | Usage | Idempotent |
|---|---|---|
| GET | Lire | Oui |
| POST | Créer | Non |
| PUT | Remplacer entièrement | Oui |
| PATCH | Modification partielle | Non (selon impl) |
| DELETE | Supprimer | Oui |
1.3 Les codes de statut
| Code | Signification | Action côté client |
|---|---|---|
| 200-299 | Succès | Traiter |
| 300-399 | Redirections | Suivre (rare en mobile) |
| 400 | Requête invalide | Afficher erreur de saisie |
| 401 | Non authentifié | Refresh token puis retry |
| 403 | Interdit | Gérer l'accès |
| 404 | Introuvable | Message adapté |
| 429 | Rate limit | Retry avec backoff |
| 500-503 | Erreur serveur | Retry + message |
1.4 JSON — la lingua franca
Le JSON est le format standard d'échange. On le sérialise/désérialise avec :
- Android : Moshi ou kotlinx.serialization
- iOS : Codable (JSONDecoder)
- Flutter :
json_serializable/freezed - RN : JSON natif + libs de schéma (zod, valibot)
2. Retrofit + OkHttp
2.1 Rôle des briques
Diagramme en cours de génération...
- OkHttp : la machine HTTP (connexions, pooling, HTTP/2, cache, interceptors).
- Retrofit : la couche déclarative qui transforme une interface Kotlin en requêtes.
2.2 Interface déclarative
interface TicketApi {
@GET("tickets")
suspend fun getTickets(
@Query("page") page: Int,
@Query("size") size: Int
): List<TicketDto>
@POST("tickets")
suspend fun createTicket(@Body body: CreateTicketRequest): TicketDto
@PATCH("tickets/{id}")
suspend fun updateTicket(@Path("id") id: String, @Body body: UpdateTicketRequest): TicketDto
@DELETE("tickets/{id}")
suspend fun deleteTicket(@Path("id") id: String)
}
2.3 Interceptors
L'interceptor est un middleware : il voit la requête avant envoi et la réponse avant traitement.
class AuthInterceptor(
private val tokenProvider: TokenProvider
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Authorization", "Bearer ${tokenProvider.accessToken()}")
.build()
return chain.proceed(request)
}
}
Ordre des interceptors dans OkHttp :
- Application interceptors (ex : auth, logging)
- RetryAndFollowUp (retry sur 5xx, redirections)
- Network interceptors (voir les headers réels, après connexion)
val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor(tokenProvider))
.addInterceptor(HttpLoggingInterceptor().apply { level = BASIC })
.addNetworkInterceptor(CacheInterceptor())
.cache(Cache(context.cacheDir, 50 * 1024 * 1024))
.build()
2.4 Converters
val retrofit = Retrofit.Builder()
.baseUrl("https://api.taskflow.app/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
val api: TicketApi = retrofit.create(TicketApi::class.java)
kotlinx.serialization avec @SerialName :
@Serializable
data class TicketDto(
@SerialName("id") val id: String,
@SerialName("title") val title: String,
@SerialName("is_done") val done: Boolean
)
2.5 Coroutines + Result
suspend fun fetchTickets(): Result<List<Ticket>> =
runCatching { api.getTickets(page = 0, size = 20) }
.map { list -> list.map { it.toDomain() } }
2.6 Testabilité — MockWebServer
@Test
fun `le parse se fait correctement`() {
mockWebServer.enqueue(
MockResponse().setBody("""[{"id":"1","title":"A","is_done":false}]""")
)
val api = buildApi(mockWebServer.url("/").toString())
val tickets = api.getTickets(0, 20)
assertEquals(1, tickets.size)
}
3. Alamofire et URLSession
3.1 URLSession — le client HTTP natif iOS
struct TicketDTO: Decodable {
let id: String
let title: String
}
final class TicketRemoteDataSource {
private let session: URLSession
private let baseURL = URL(string: "https://api.taskflow.app")!
init(session: URLSession = .shared) { self.session = session }
func getTickets(page: Int, size: Int) async throws -> [TicketDTO] {
var url = baseURL.appending(path: "/tickets")
url.append(queryItems: [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "size", value: "\(size)"),
])
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
200..<300 ~= http.statusCode else {
throw NetworkError.badStatus((response as? HTTPURLResponse)?.statusCode ?? -1)
}
return try JSONDecoder().decode([TicketDTO].self, from: data)
}
}
3.2 Alamofire — l'AFNetworking moderne
Alamofire est une surcouche de URLSession : requêtes déclaratives, interceptors (RequestAdapter), validation.
import Alamofire
struct AuthInterceptor: RequestInterceptor {
let tokenProvider: TokenProvider
func adapt(_ urlRequest: URLRequest,
for session: Session,
completion: @escaping (Result<URLRequest, Error>) -> Void) {
var request = urlRequest
request.setValue("Bearer \(tokenProvider.accessToken())",
forHTTPHeaderField: "Authorization")
completion(.success(request))
}
func retry(_ request: Request,
for session: Session,
dueTo error: Error,
completion: @escaping (RetryResult) -> Void) {
if request.retryCount < 2 {
completion(.retryWithDelay(1.0))
} else {
completion(.doNotRetry)
}
}
}
final class TicketAPI {
private let session: Session
init() {
session = Session(interceptor: AuthInterceptor(tokenProvider: AppTokens.shared))
}
func getTickets(page: Int) async throws -> [TicketDTO] {
try await session.request(
"https://api.taskflow.app/tickets",
parameters: ["page": page, "size": 20]
)
.validate()
.serializingDecodable([TicketDTO].self)
.value
}
}
3.3 URLSession vs Alamofire : que choisir ?
| Critère | URLSession | Alamofire |
|---|---|---|
| Native | Oui | Non (SwiftPM) |
| Code | Plus verbeux | Compact, déclaratif |
| Retry/Adapter | Manuel | Intégré (RequestInterceptor) |
| Multiplatform | Non | Oui (Linux, mais pas mobile Android) |
| Recommandation | Idéal pour apprendre, projets simples | Équipes productives, projets moyens |
En 2026, de nombreuses équipes reviennent à URLSession pur (modernisé par async/await), ce qui est légitime pour les petits projets.
3.4 Codable en pratique
struct TicketDTO: Decodable {
let id: String
let title: String
let done: Bool
enum CodingKeys: String, CodingKey {
case id, title
case done = "is_done"
}
}
4. Apollo GraphQL
4.1 Pourquoi GraphQL ?
- Une seule endpoint
POST /graphql - Le client demande exactement les champs voulus (anti over-fetching)
- Queries (lecture), Mutations (écriture), Subscriptions (temps réel)
query GetTickets($projectId: ID!) {
tickets(projectId: $projectId) {
id
title
done
}
}
4.2 Apollo Kotlin (Android/KMP)
// apollo: génère les classes à partir des .graphql
val apolloClient = ApolloClient.Builder()
.serverUrl("https://api.taskflow.app/graphql")
.okHttpClient(client)
.build()
// Query
val query = GetTicketsQuery(projectId = "p1")
val response = apolloClient.query(query).execute()
response.dataOrThrow().tickets.forEach { t ->
println("${t.id} - ${t.title}")
}
// Mutation
val mutation = CreateTicketMutation(title = "Étudier Apollo")
apolloClient.mutation(mutation).execute()
4.3 Apollo iOS (Swift)
import Apollo
let client = ApolloClient(url: URL(string: "https://api.taskflow.app/graphql")!)
// Query async/await
let result = try await client.fetch(query: GetTicketsQuery(projectId: "p1"))
if let tickets = result.data?.tickets {
for t in tickets { print(t.id, t.title) }
}
// Watch (re-fetch automatique du cache Apollo)
let watcher = client.watch(query: GetTicketsQuery(projectId: "p1")) { result in
// UI mise à jour
}
4.4 Le cache Apollo (normalisé)
Apollo possède un cache normalisé : chaque objet est stocké par type+id. Une mutation sur un objet met à jour automatiquement toutes les queries qui le référencent — c'est un avantage énorme pour l'UX temps réel.
4.5 Subscriptions (temps réel)
apolloClient.subscription(TicketAddedSubscription())
.toFlow()
.collect { result ->
result.dataOrThrow()?.let { println("Nouveau ticket : ${it.ticket.title}") }
}
4.6 Quand choisir GraphQL ?
| Avantages | Inconvénients |
|---|---|
| Pas d'over/under-fetching | Courbe d'apprentissage |
| Cache normalisé | Complexité serveur (schéma, resolvers) |
| Temps réel intégré | Outillage plus lourd |
Verdict : GraphQL brille quand l'API est consommée par beaucoup de clients hétérogènes avec des besoins différents.
5. WebSocket : temps réel
5.1 REST vs WebSocket
| Critère | REST | WebSocket |
|---|---|---|
| Modèle | Requête/réponse | Connexion bidirectionnelle persistante |
| Push | Non (polling) | Oui |
| Latence | Moyenne | Très faible |
| Cas d'usage | CRUD | Chat, live updates, sync temps réel |
5.2 OkHttp WebSocket (Android)
class TicketSocketClient(
private val url: String,
private val onMessage: (String) -> Unit
) : WebSocketListener() {
private var socket: WebSocket? = null
fun connect() {
val request = Request.Builder().url(url).build()
socket = client.newWebSocket(request, this)
}
override fun onMessage(webSocket: WebSocket, text: String) {
onMessage(text) // par exemple : parse JSON → StateFlow
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
scheduleReconnect() // reconnect avec backoff
}
}
5.3 URLSessionWebSocketTask (iOS)
let session = URLSession(configuration: .default)
let ws = session.webSocketTask(with: URL(string: "wss://api.taskflow.app/ws")!)
ws.resume()
func send(_ message: String) async throws {
try await ws.send(.string(message))
}
func listen() async {
while let result = try? await ws.receive() {
if case .string(let text) = result {
await MainActor.run { handle(text) }
}
}
}
5.4 Kotlin Flow + WebSocket
fun observeTicketsSocket(): Flow<String> = callbackFlow {
val listener = object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
trySend(text)
}
}
val request = Request.Builder().url(url).build()
val socket = client.newWebSocket(request, listener)
awaitClose { socket.cancel() }
sendBlocking("hello")
}.flowOn(Dispatchers.IO)
5.5 Reconnection et heartbeat
- Heartbeat : envoyer un
ping/pongtoutes les 30 s pour détecter la coupure. - Reconnection : backoff exponentiel (1 s → 2 s → 4 s → plafond).
- Résilience : au reconnexion, rejouer l'état (requête de bootstrap) pour ne pas rater d'événements.
6. Patterns REST
6.1 Versioning de l'API
https://api.taskflow.app/v1/tickets (dans le path)
https://api.taskflow.app/tickets (header: Accept: application/vnd.taskflow.v2+json)
https://v2.api.taskflow.app/tickets (sous-domaine)
6.2 Pagination
| Type | Requête | Réponse |
|---|---|---|
| Page-based | ?page=0&size=20 | {"items": [...], "totalPages": 5} |
| Cursor-based | ?cursor=abc123&limit=20 | {"items": [...], "nextCursor": "def456"} |
| Keyset | ?before=2026-01-01T00:00:00Z | {"items": [...]} |
La cursor-based est préférée pour les feeds (insensible aux insertions).
6.3 Idempotence côté client
Pour les créations, envoyer un Idempotency-Key (UUID) que le serveur dé-duplique — essentiel pour le retry et l'outbox.
6.4 ETag / Cache HTTP
OkHttp supporte le cache HTTP : envoyer l'ETag/Last-Modified, répondre 304 Not Modified si rien n'a changé → économie de bande passante.
7. Gestion des erreurs
7.1 Une erreur applicative, pas seulement technique
sealed class ApiError : Exception() {
data object NoNetwork : ApiError()
data object Unauthorized : ApiError()
data class HttpError(val code: Int, val message: String) : ApiError()
data class ServerError(val message: String) : ApiError()
data object Timeout : ApiError()
}
7.2 Mapping erreurs techniques → domaines
fun Throwable.toApiError(): ApiError = when (this) {
is SocketTimeoutException, is ConnectException -> ApiError.Timeout
is IOException -> ApiError.NoNetwork
is HttpException -> when (code()) {
401 -> ApiError.Unauthorized
in 400..499 -> ApiError.HttpError(code(), message())
else -> ApiError.ServerError(message())
}
else -> ApiError.ServerError(message ?: "Erreur inconnue")
}
7.3 Le repository masque tout ça
override suspend fun getTickets(): Result<List<Ticket>> =
runCatching { api.getTickets() }
.mapCatching { it.map { dto -> dto.toDomain() } }
L'UI reçoit un Result/UiState, jamais d'exception réseau brute.
7.4 iOS — enum d'erreurs
enum NetworkError: Error {
case badStatus(Int)
case noData
case decoding(Error)
case noNetwork
}
8. Retry logic
8.1 Quoi retry, quoi pas ?
| Erreur | Retry ? |
|---|---|
| 408, 429, 5xx | Oui |
| 4xx (hors 408/429) | Non (erreur du client) |
| Timeout réseau | Oui (avec prudence) |
| Erreur de parsing | Non (bug à corriger) |
8.2 Backoff exponentiel
suspend fun <T> retryWithBackoff(
maxAttempts: Int = 3,
baseDelayMs: Long = 1000,
shouldRetry: (Throwable) -> Boolean = { true },
block: suspend () -> T
): T {
var attempt = 0
while (true) {
try {
return block()
} catch (e: Exception) {
if (++attempt >= maxAttempts || !shouldRetry(e)) throw e
val delay = baseDelayMs * (1L shl (attempt - 1)) // 1, 2, 4...
delay(delay)
}
}
}
Ajouter du jitter (aléatoire) pour éviter l'effet de thundering herd quand tous les clients retentent ensemble.
8.3 Retry du refresh token (401)
class Authenticator(
private val tokenProvider: TokenProvider,
private val authApi: AuthApi
) : okhttp3.Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
synchronized(this) {
val newToken = tokenProvider.refreshToken() ?: return null
val newRequest = response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
// Ne pas retry la requête d'auth elle-même
return if (response.request.header("Authorization") == null) newRequest else null
}
}
}
9. Offline queue
9.1 Le principe (rappel chapitre 11)
Quand on écrit hors-ligne : on stocke l'opération et on l'exécute quand le réseau revient. La file réseau est le pendant côté networking de l'outbox.
9.2 Implémentation minimaliste
class OfflineQueue(
private val store: QueueStore, // SQLite/DataStore
private val api: TicketApi
) {
suspend fun enqueue(operation: PendingOperation) {
store.add(operation)
process()
}
suspend fun process() {
store.getPending().forEach { op ->
runCatching { execute(op) }
.onSuccess { store.markDone(op.id) }
.onFailure { store.markFailed(op.id) }
}
}
private suspend fun execute(op: PendingOperation) {
when (op.kind) {
"CREATE_TICKET" -> api.createTicket(json.parse(op.payload))
"UPDATE_TICKET" -> api.updateTicket(op.entityId, json.parse(op.payload))
else -> throw IllegalArgumentException("unknown ${op.kind}")
}
}
}
9.3 Priorités et ordre
- Ordonner par
createdAt(FIFO) pour préserver la séquence des actions. - Bloquer les opérations dépendantes d'une opération en échec (ex : update d'un ticket dont le create a échoué).
- Réessayer avec backoff, puis abandonner en marquant
FAILED(visible par l'utilisateur).
10. Sécurité réseau
10.1 TLS de bout en bout
- HTTPS obligatoire partout (ATS sur iOS,
usesCleartextTraffic=falsesur Android). - TLS 1.2 minimum, 1.3 préféré.
- Ne jamais désactiver la validation de certificat même en dev (use a test server with valid cert).
10.2 Certificate Pinning
Le pinning garantit que le client ne parle qu'aux certificats connus (anti MITM). Deux approches :
- Certificat : épingler le certificat public du serveur.
- Empreinte (hash) : épingler le hash SHA-256 de la clé publique.
// OkHttp CertificatePinner
val certificatePinner = CertificatePinner.Builder()
.add("api.taskflow.app", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
// iOS : URLSessionDelegate
final class PinningDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
if isPinned(serverTrust) {
completionHandler(.useCredential,
URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
Piège : penser à mettre à jour les hashes lors de la rotation des certificats (avec date d'expiration et fallback).
10.3 Les anti-patterns
trustManagerqui accepte tout (TrustAllCerts) — interdit même en debug (sauf réseau local contrôlé).- Tokens en query string (loggés par les proxies).
- Chiffrement maison (« rolling ciphers ») — utiliser TLS standard.
10.4 Contrôles de sécurité réseau
| Android | iOS |
|---|---|
android:usesCleartextTraffic="false" | ATS (NSAllowsArbitraryLoads = NO) |
| Network Security Config | NSExceptionDomains contrôlé |
| CertificatePinner | URLSessionDelegate pinning |
11. Chargement d'images
11.1 Les librairies
| Plateforme | Librairie | Points forts |
|---|---|---|
| Android (Compose) | Coil | Kotlin-first, Compose, coroutines |
| Android (Views) | Glide | Mature, GIF, transformations |
| iOS | SDWebImage / Kingfisher | Cache disque+mémoire, placeholders |
| Flutter | cached_network_image | Cache disque via flutter_cache_manager |
| React Native | expo-image / FastImage | Support expo / natif |
11.2 Coil (Android)
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://cdn.taskflow.app/avatar/123.jpg")
.crossfade(true)
.build(),
contentDescription = "Avatar",
placeholder = painterResource(R.drawable.placeholder),
error = painterResource(R.drawable.error)
)
11.3 Pourquoi toujours une lib ?
- Cache mémoire + disque automatique
- Chargement asynchrone hors main thread
- Placeholders / errors
- Downsampling (décodage à la bonne taille)
- Transformation (crop, blur)
- LIFECYCLE : annulation automatique quand l'écran disparaît
11.4 Bonnes pratiques images
1. Redimensionner côté serveur (CDN ?width=400&height=400)
2. Format moderne : WebP/AVIF + compression
3. Ne jamais charger une image 2000px pour un avatar 100px
4. `blurUp` / progressive loading pour le UX
5. Purger le cache disque selon une taille max
11.5 cached_network_image (Flutter)
CachedNetworkImage(
imageUrl: "https://cdn.taskflow.app/img.jpg",
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
12. Synthèse
Diagramme en cours de génération...
Points à retenir :
- Tout le réseau passe par le repository ; l'UI ne voit jamais HTTP.
- OkHttp/URLSession gèrent le transport, Retrofit/Alamofire la déclaration.
- Retry : 5xx/429/timeout uniquement, avec backoff + jitter.
- Offline queue : écrire d'abord, pousser plus tard (idempotence).
- TLS + cert pinning sont non négociables.
- Les images passent toujours par une lib de cache.
Aller plus loin
- Chapitre 13 (Testing-Mobile) : MockWebServer, stub des requêtes.
- Chapitre 15 (Sécurité-Mobile) : tokens, Keychain, OWASP.
- Chapitre 18 (Projet-Fil-Rouge) : API REST + WebSocket de TaskFlow.