MFormations
Modern Mobile Engineering

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

  1. Rappels réseau et REST
  2. Retrofit + OkHttp
  3. Alamofire et URLSession
  4. Apollo GraphQL
  5. WebSocket : temps réel
  6. Patterns REST
  7. Gestion des erreurs
  8. Retry logic
  9. Offline queue
  10. Sécurité réseau
  11. 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

VerbeUsageIdempotent
GETLireOui
POSTCréerNon
PUTRemplacer entièrementOui
PATCHModification partielleNon (selon impl)
DELETESupprimerOui

1.3 Les codes de statut

CodeSignificationAction côté client
200-299SuccèsTraiter
300-399RedirectionsSuivre (rare en mobile)
400Requête invalideAfficher erreur de saisie
401Non authentifiéRefresh token puis retry
403InterditGérer l'accès
404IntrouvableMessage adapté
429Rate limitRetry avec backoff
500-503Erreur serveurRetry + 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 :

  1. Application interceptors (ex : auth, logging)
  2. RetryAndFollowUp (retry sur 5xx, redirections)
  3. 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èreURLSessionAlamofire
NativeOuiNon (SwiftPM)
CodePlus verbeuxCompact, déclaratif
Retry/AdapterManuelIntégré (RequestInterceptor)
MultiplatformNonOui (Linux, mais pas mobile Android)
RecommandationIdé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 ?

AvantagesInconvénients
Pas d'over/under-fetchingCourbe 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èreRESTWebSocket
ModèleRequête/réponseConnexion bidirectionnelle persistante
PushNon (polling)Oui
LatenceMoyenneTrès faible
Cas d'usageCRUDChat, 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/pong toutes 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

TypeRequêteRé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 ?

ErreurRetry ?
408, 429, 5xxOui
4xx (hors 408/429)Non (erreur du client)
Timeout réseauOui (avec prudence)
Erreur de parsingNon (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=false sur 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 :

  1. Certificat : épingler le certificat public du serveur.
  2. 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

  • trustManager qui 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

AndroidiOS
android:usesCleartextTraffic="false"ATS (NSAllowsArbitraryLoads = NO)
Network Security ConfigNSExceptionDomains contrôlé
CertificatePinnerURLSessionDelegate pinning

11. Chargement d'images

11.1 Les librairies

PlateformeLibrairiePoints forts
Android (Compose)CoilKotlin-first, Compose, coroutines
Android (Views)GlideMature, GIF, transformations
iOSSDWebImage / KingfisherCache disque+mémoire, placeholders
Fluttercached_network_imageCache disque via flutter_cache_manager
React Nativeexpo-image / FastImageSupport 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 :

  1. Tout le réseau passe par le repository ; l'UI ne voit jamais HTTP.
  2. OkHttp/URLSession gèrent le transport, Retrofit/Alamofire la déclaration.
  3. Retry : 5xx/429/timeout uniquement, avec backoff + jitter.
  4. Offline queue : écrire d'abord, pousser plus tard (idempotence).
  5. TLS + cert pinning sont non négociables.
  6. 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.