تطوير الجوّال

استهلاك واجهة REST في Kotlin مع Retrofit 3 وOkHttp خطوة بخطوة

6 دقائق للقراءة

🔝 الدليل الرئيسي: Kotlin وJetpack Compose في 2026

كل تطبيق Android جاد ينتهي بالتحدّث مع API REST بعيدة. الطبقة الشبكية القياسية في 2026 تُسمى Retrofit 3، مرفقة بـ OkHttp 4.12 للنقل وkotlinx.serialization لتحليل JSON. هذا الثلاثي يحل نهائيًا محل Gson وMoshi وVolley في غالبية المشاريع الجديدة. يُفصّل هذا الدرس الإعداد الكامل من الصفر: تبعيات Gradle، إعداد OkHttp، واجهة Retrofit، تسلسل JSON، إدارة الأخطاء، والربط بـ Repository يُغذّي ViewModel.

المتطلبات

  • مشروع Android Compose مُعَدّ (راجع تثبيت Android Studio)
  • ViewModel + StateFlow في مكانه (راجع ViewModel وStateFlow)
  • أساسيات coroutines وsuspend functions
  • API REST عامة للاختبار (مثل jsonplaceholder.typicode.com)
  • الوقت المُقدَّر: 90 دقيقة

الخطوة 1 — إضافة التبعيات

Retrofit 3 صدر في مايو 2025 ويبقى الحل المرجعي في مايو 2026. يعتمد على OkHttp 4.12. لـ JSON، نأخذ kotlinx.serialization، أسرع وأأمن من Gson بفضل توليد الكود عند التجميع.

[versions]
retrofit = "3.0.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.10.0"
kotlinxSerializationConverter = "1.0.0"

[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

في app/build.gradle.kts: أضف alias(libs.plugins.kotlin.serialization) إلى كتلة plugins والتبعيات الأربع في dependencies. Plugin kotlin-serialization يُفعّل توليد serializers عند التجميع لكل data class مُعلَّقة @Serializable.

الخطوة 2 — إعلان نماذج JSON

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ArticleDto(
    val id: Int,
    val title: String,
    @SerialName("body") val contenu: String,
    @SerialName("userId") val auteurId: Int
)

@Serializable
data class CommentaireDto(
    val id: Int,
    val name: String,
    val email: String,
    val body: String
)

التعليق @SerialName يُتيح إعادة تسمية حقل بين JSON (body، userId) وKotlin (contenu، auteurId) — مفيد عندما نريد اسمًا داخليًا مع استهلاك API أجنبية. الـ DTOs تبقى في طبقة بيانات منفصلة، نمضي إلى نموذج Article الأعمال في Repository.

الخطوة 3 — تعريف واجهة Retrofit

interface BlogApi {

    @GET("posts")
    suspend fun listerArticles(): List<ArticleDto>

    @GET("posts/{id}")
    suspend fun obtenirArticle(@Path("id") id: Int): ArticleDto

    @GET("posts/{id}/comments")
    suspend fun listerCommentaires(@Path("id") articleId: Int): List<CommentaireDto>

    @POST("posts")
    suspend fun publierArticle(@Body article: ArticleDto): ArticleDto

    @GET("posts")
    suspend fun rechercher(@Query("userId") userId: Int): List<ArticleDto>
}

أربعة تعليقات رئيسية. @GET، @POST، @PUT، @DELETE تُعرّف طريقة HTTP. @Path يستبدل متغيّرًا في URL. @Query يُضيف معاملًا ?userId=1. @Body يُسلسل كائنًا إلى JSON لجسم الطلب. Retrofit يُدير تلقائيًا الـ headers Content-Type: application/json.

الخطوة 4 — إعداد OkHttp وRetrofit

import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit

object RetrofitFactory {

    private const val BASE_URL = "https://jsonplaceholder.typicode.com/"

    private val json = Json {
        ignoreUnknownKeys = true
        coerceInputValues = true
        encodeDefaults = true
    }

    private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(20, TimeUnit.SECONDS)
        .retryOnConnectionFailure(true)
        .apply {
            if (BuildConfig.DEBUG) {
                addInterceptor(HttpLoggingInterceptor().apply {
                    level = HttpLoggingInterceptor.Level.BODY
                })
            }
        }
        .build()

    val blogApi: BlogApi by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
            .build()
            .create(BlogApi::class.java)
    }
}

ثلاثة اختيارات إعداد مهمة. ignoreUnknownKeys = true يتجنّب تعطّلًا إذا أضافت API حقلًا لا نقرأه. HttpLoggingInterceptor في debug فقط يعرض الطلبات/الردود في Logcat. مهل 15-20 ثانية متوازنة.

الخطوة 5 — تحويل DTO إلى domaine في Repository

data class Article(
    val id: Int,
    val titre: String,
    val contenu: String
)

class ArticlesRepository(
    private val api: BlogApi
) {
    suspend fun listerArticles(): List<Article> {
        return api.listerArticles().map { it.toArticle() }
    }

    suspend fun obtenirArticle(id: Int): Article {
        return api.obtenirArticle(id).toArticle()
    }

    private fun ArticleDto.toArticle() = Article(
        id = id,
        titre = title,
        contenu = contenu
    )
}

Repository يكشف عقدًا نظيفًا (Article domaine، لا ArticleDto) لـ ViewModel. دالة الامتداد toArticle() تعزل التحويل. لـ mock في اختبار: حقن FakeBlogApi أو FakeArticlesRepository.

الخطوة 6 — إدارة أخطاء الشبكة بنظافة

sealed interface Resultat<out T> {
    data class Succes<T>(val data: T) : Resultat<T>
    data class ErreurReseau(val message: String) : Resultat<Nothing>
    data class ErreurServeur(val code: Int, val message: String) : Resultat<Nothing>
}

suspend fun <T> appelSecurise(bloc: suspend () -> T): Resultat<T> {
    return try {
        Resultat.Succes(bloc())
    } catch (e: HttpException) {
        Resultat.ErreurServeur(e.code(), e.message())
    } catch (e: IOException) {
        Resultat.ErreurReseau(e.message ?: "Connexion impossible")
    }
}

Retrofit يرفع عائلتي استثناءات. HttpException عند رد خادم 4xx/5xx. IOException عند فشل النقل (لا شبكة، timeout، DNS). نُغلّف الاستدعاءات بـ Resultat مُنمَّط. في ViewModel: pattern-match على Resultat وحدّث UiState.

الخطوة 7 — إعداد الأمن الشبكي (HTTPS وcleartext)

منذ Android 9 (API 28)، الـ cleartext HTTP محجوب افتراضيًا. لاستدعاء API HTTPS إنتاج، لا شيء لإعداده. للتطوير ضد خادم محلي (http://10.0.2.2:8080 من المحاكي)، أنشئ res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">10.0.2.2</domain>
        <domain includeSubdomains="true">192.168.1.10</domain>
    </domain-config>
</network-security-config>

أشِر إلى هذا الملف في AndroidManifest.xml عبر android:networkSecurityConfig="@xml/network_security_config" على وسم application. في الإنتاج، لا تأذن أبدًا بـ cleartext عام.

الخطوة 8 — مصادقة بـ token وrefresh تلقائي

class AuthInterceptor(
    private val tokenProvider: TokenProvider
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenProvider.lireToken()
        val requete = chain.request().newBuilder()
            .apply { if (token != null) addHeader("Authorization", "Bearer " + token) }
            .build()
        return chain.proceed(requete)
    }
}

class TokenAuthenticator(
    private val tokenProvider: TokenProvider,
    private val refreshApi: RefreshApi
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val ancienToken = tokenProvider.lireToken() ?: return null
        val nouveau = runBlocking { refreshApi.rafraichir(ancienToken) } ?: return null
        tokenProvider.ecrireToken(nouveau.token)
        return response.request.newBuilder()
            .header("Authorization", "Bearer " + nouveau.token)
            .build()
    }
}

الـ Authenticator يُستدعى تلقائيًا من OkHttp عند كل رد 401. إرجاع null يوقف retry. إرجاع طلب جديد بـ token طازج يُعيد إطلاق الاستدعاء الأصلي. هذه الآلية تتجنّب أن تُدير كل شاشة يدويًا انتهاء token.

أخطاء شائعة

العَرَض السبب الحل
SerializationException Field X is required حقل إلزامي غائب من JSON اجعل الحقل nullable أو أضف قيمة افتراضية
UnknownHostException لا اتصال أو URL غير صحيح تحقق من الاتصال، BASE_URL، وإذن INTERNET
CLEARTEXT communication not permitted HTTP غير مسموح HTTPS أو network_security_config
رد 401 بدون token header مصادقة مفقود أضف Interceptor يحقن Bearer
رد فارغ أو null غير متوقع نوع Kotlin غير nullable مقابل null في JSON استخدم String? أو coerceInputValues = true

التكيّف مع الاتصالات غير المستقرة

في سياق التنقل الحقيقي، الاتصال يتغيّر بسرعة. ثلاث ممارسات تُحسّن المرونة. أولًا، إضافة Interceptor retry يُعيد إطلاق الطلبات idempotente (GET) بـ backoff تصاعدي. ثانيًا، تخزين ردود GET بـ Cache المُدمج في OkHttp (10 MB في cacheDir). ثالثًا، حفظ البيانات الناجحة في Room لتوفير قراءة فورية offline.

الأسئلة الشائعة

Retrofit أم Ktor Client؟
Retrofit في منصة Android الأحادية. Ktor Client لـ Kotlin Multiplatform.

kotlinx.serialization أم Moshi؟
kotlinx.serialization لأي مشروع جديد: مُدمج في مُجمِّع Kotlin، أسرع، ومتوافق KMP.

كيف نُضيف JWT لكل الطلبات؟
Interceptor OkHttp مُضاف للعميل: يلتقط كل طلب، يقرأ token، يُضيفه في Authorization: Bearer.

هل اختبار وحدوي لكل endpoint؟
لا. نختبر Repository بـ FakeBlogApi. اختبارات تكامل قليلة مع MockWebServer تُغطّي التسلسل.

كيف نرفع ملفًا؟
بـ @Multipart و@Part MultipartBody.Part. للملفات الكبيرة، فضّل URL موقّع مسبق وPUT مباشر إلى S3.

كم طلبًا متوازيًا نتسامح؟
OkHttp يحدّ بـ 64 طلب متوازي عالمي و5 لكل host افتراضيًا. إذا فرضت API rate-limit صارمًا، خفّض عبر Dispatcher().maxRequestsPerHost = 2.

Logging interceptor في الإنتاج؟
أبدًا. يُسجّل كل شيء. شرّطه بصرامة بـ BuildConfig.DEBUG.

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité