EduFlow
أساسيات Swift لتطبيقات iOS
⁦0%⁩

الوحدة 4 · إطلاق تطبيق حقيقي

الشبكات باستخدام URLSession وCodable

17 دقراءة 4 درتّب الخطوات

الدرس في لمحة

اجلب JSON عبر الـ API غير المتزامن في URLSession، وافحص حالة HTTP، وفكّ ترميزه إلى structs من نوع Codable، واعرض حالات التحميل والنجاح والفشل في SwiftUI.

  1. 1اعكس شكل الـ JSON باستخدام Codable
  2. 2اجلب، افحص، فكّ الترميز
  3. 3حالات تحميل يثق بها الـ view

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

يسجّل Pocket Budget المصاريف بالدولار، لكنك تنفق في إجازتك باليورو والجنيه المصري. وللتحويل، يحتاج التطبيق إلى أسعار الصرف لهذا اليوم من خادم. يستبدل هذا الدرس Task.sleep التمثيلية من الدرس السابق بطلب حقيقي: ابنِ URL، واجلب البيانات، وافحص الاستجابة، وفكّ ترميز الـ JSON إلى أنواع Swift، ثم اعرضها. هذه الخطوات هي نفسها مع معظم الـ APIs التي ستستدعيها في حياتك.

تستخدم الأمثلة endpoint بديلًا، https://api.example.com/v1/latest?base=USD. ضع مكانه مزوّدًا حقيقيًا لأسعار الصرف (لمعظمهم باقة مجانية) وعدّل الـ struct ليطابق الـ JSON الخاص به. تبدو الاستجابة هكذا:

JSON
{
  "base": "USD",
  "updated_at": "2026-10-02T06:00:00Z",
  "rates": { "EUR": 0.92, "GBP": 0.79, "EGP": 48.31 }
}

اعكس شكل الـ JSON باستخدام Codable

RatesResponse.swift
import Foundation

struct RatesResponse: Decodable {
    let base: String
    let updatedAt: Date
    let rates: [String: Decimal]
}

يطلب Decodable من الـ compiler أن يولّد الكود الذي يقرأ هذا النوع من صيغة خارجية. يجب أن تطابق أسماء الخصائص مفاتيح الـ JSON وأن تناسب الأنواع: كائن JSON يصبح قاموسًا، والرقم يمكن فكّ ترميزه إلى Decimal، والمفتاح الغائب أو النوع الخاطئ يرمي DecodingError يسمّي المشكلة. استخدم Codable (أي Decodable مع Encodable) حين ترسل النوع أيضًا. والـ struct مبني من أجزاء Sendable، فهو Sendable أيضًا ويستطيع الانتقال بأمان من كود الشبكة إلى الـ main actor.

الـ JSON يقول updated_at، وأسلوب Swift يقول updatedAt. بدل إعادة تسمية الخصائص، اضبط الـ decoder، كما يفعل العميل أدناه.

اجلب، افحص، فكّ الترميز

RateClient.swift
enum RateError: Error {
    case badStatus(Int)
}

struct RateClient {
    let endpoint = URL(string: "https://api.example.com/v1/latest")!

    func latest(base: String) async throws -> RatesResponse {
        let url = endpoint.appending(queryItems: [URLQueryItem(name: "base", value: base)])

        let (data, response) = try await URLSession.shared.data(from: url)

        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        guard status == 200 else {
            throw RateError.badStatus(status)
        }

        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode(RatesResponse.self, from: data)
    }
}

امشِ عليه بالترتيب. تبني appending(queryItems:) العنوان ...latest?base=USD وتطبّق الـ percent-encoding على القيم تلقائيًا، وهذا أكثر أمانًا من لصق النصوص ببعضها. والفكّ القسري على URL(string:) من النوع المقبول الذي رأيته في درس الـ optionals: قيمة كتبتها بنفسك، تعني nil فيها خطأً إملائيًا.

URLSession.shared.data(from:) هو الـ API غير المتزامن: يعلّق التنفيذ حتى تصل الاستجابة كاملة، ويعيد بايتات الجسم مع URLResponse. يرمي خطأً عند فشل النقل، مثل انعدام الاتصال أو انتهاء المهلة أو الإلغاء، لكنه لا يرمي عند أخطاء HTTP. ولهذا وُجدت جملة guard.

يربط .convertFromSnakeCase المفتاح updated_at بـ updatedAt لكل المفاتيح، ويقرأ .iso8601 صيغة الطابع الزمني التي تستخدمها معظم الـ APIs. ثم إما أن تعيد decode كائن RatesResponse مكتمل البناء أو ترمي خطأً.

خمس خطوات من اليسار إلى اليمين: ابنِ الـ URL، واجلب باستخدام data(from:)، وافحص رمز الحالة، وفكّ ترميز الـ JSON إلى RatesResponse، ثم اضبط الـ state على الـ main actor فيتحدّث الـ view. ابنِ الـ URL query items اجلب data(from:) افحص الحالة 200 فكّ الترميز JSONDecoder اعرض main actor أي خطوة قد ترمي خطأً؛ وcatch واحدة في النموذج تحوّله إلى حالة الفشل
من الـ URL إلى البكسلات

وليست كل حالات الفشل تستحق الرسالة نفسها. فالـ URLError الذي يُرمى يحمل code، فتستطيع التمييز بين «غير متصل» (.notConnectedToInternet) و«انتهت المهلة» (.timedOut)، وتقول “You’re offline. Rates will refresh when you reconnect” بدل اعتذار عام. المستخدمون يسامحون التطبيق الذي يشرح نفسه.

حالات تحميل يثق بها الـ view

فكرة الـ enum من درس الـ enums تؤتي ثمارها الآن. نموذج على الـ main actor يملك الـ state، والـ view يكتب switch عليها:

RatesModel.swift
@MainActor
@Observable
final class RatesModel {
    enum LoadState {
        case idle
        case loading
        case loaded(RatesResponse)
        case failed(String)
    }

    var state: LoadState = .idle
    private let client = RateClient()

    func load() async {
        state = .loading
        do {
            let response = try await client.latest(base: "USD")
            state = .loaded(response)
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}
RatesView.swift
struct RatesView: View {
    @State private var model = RatesModel()

    var body: some View {
        List {
            switch model.state {
            case .idle, .loading:
                ProgressView()
            case .loaded(let response):
                ForEach(response.rates.keys.sorted(), id: \.self) { code in
                    LabeledContent(code, value: response.rates[code] ?? 0,
                                   format: .number.precision(.fractionLength(2...4)))
                }
            case .failed(let message):
                ContentUnavailableView("Couldn't load rates",
                                       systemImage: "wifi.exclamationmark",
                                       description: Text(message))
            }
        }
        .navigationTitle("Exchange Rates")
        .task { await model.load() }
        .refreshable { await model.load() }
    }
}

تحمّل .task البيانات مرة واحدة حين تظهر الشاشة، ويضيف .refreshable السحب للتحديث بالدالة نفسها. ولأن RatesModel معلّم بـ @MainActor، يكون إسناد state آمنًا دائمًا للواجهة، وتتحقّق Swift 6 من ذلك عنك. وكل مسار خطأ ينتهي في .failed، فلا يحدّق المستخدم أبدًا في مؤشّر تحميل لن يتوقّف. ومفاتيح القاموس لا ترتيب لها، فيرتّبها الـ view ليحصل على قائمة ثابتة.

يشترط iOS أيضًا HTTPS افتراضيًا عبر App Transport Security، فيفشل الـ URL الذي يبدأ بـ http:// العادي ما لم تُضِف استثناءً. استخدم HTTPS ولن تحتاج إلى استثناء أبدًا.

صار Pocket Budget قادرًا على التحدّث مع العالم الخارجي. لكنه ما زال ينسى مصاريفه حين تغلقه، لذلك ستحفظها بعد ذلك على الجهاز باستخدام SwiftData.

أهم النقاط

  • try await URLSession.shared.data(from: url) يعيد جسم الاستجابة والاستجابة نفسها؛ ويرمي خطأً عند فشل الشبكة، لكن ليس عند أخطاء HTTP مثل 404 أو 500.
  • افحص دائمًا رمز الحالة في HTTPURLResponse قبل فكّ الترميز، وحوّل الحالة السيئة إلى خطأ خاص بك.
  • اعكس شكل الـ JSON في structs من نوع Decodable؛ وkeyDecodingStrategy وdateDecodingStrategy يتعاملان مع المفاتيح بصيغة snake_case والتواريخ بصيغة ISO 8601.
  • صِف دورة حياة الشاشة كـ enum اسمه LoadState، فلا يمكن أبدًا عرض التحميل والبيانات والأخطاء في الوقت نفسه.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

يجيب الخادم على طلب بالحالة HTTP 500 وصفحة خطأ بصيغة HTML. ماذا يفعل try await URLSession.shared.data(from: url)؟

السؤال 2 من 3

في الـ JSON مفتاح اسمه updated_at، وفي الـ struct الخاص بك let updatedAt: Date. يفشل فكّ الترميز مع keyNotFound. ما أصغر إصلاح؟

السؤال 3 من 3

لماذا تستخدم شاشة الأسعار enum واحدًا اسمه LoadState بدل الخصائص isLoading وrates وerrorMessage؟

تدريب

رتّب الخطوات

من ظهور الشاشة إلى الأسعار على الشاشة

رتّب خطوات شاشة أسعار الصرف في Pocket Budget بالترتيب الذي تحدث به وقت التشغيل، من لحظة ظهور الشاشة إلى لحظة ظهور الأسعار.

استخدم أزرار الأسهم لإعادة الترتيب.

  1. 1ابنِ الـ URL: endpoint.appending(queryItems: [URLQueryItem(name: "base", value: "USD")])
  2. 2يضبط النموذج state = .loading، فيعرض الـ view ‏ProgressView
  3. 3let (data, response) = try await URLSession.shared.data(from: url)
  4. 4ينفّذ SwiftUI ‏.task { await model.load() } عند ظهور الـ view
  5. 5try decoder.decode(RatesResponse.self, from: data) مع استراتيجيتَي snake_case وISO 8601
  6. 6افحص (response as? HTTPURLResponse)?.statusCode == 200، وإلا فارمِ RateError.badStatus
  7. 7أسند state = .loaded(response) على الـ main actor، فيعيد SwiftUI رسم القائمة