الوحدة 4 · إطلاق تطبيق حقيقي
الشبكات باستخدام URLSession وCodable
17 دقراءة 4 درتّب الخطوات
الدرس في لمحة
اجلب JSON عبر الـ API غير المتزامن في URLSession، وافحص حالة HTTP، وفكّ ترميزه إلى structs من نوع Codable، واعرض حالات التحميل والنجاح والفشل في SwiftUI.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
يسجّل Pocket Budget المصاريف بالدولار، لكنك تنفق في إجازتك باليورو والجنيه المصري. وللتحويل، يحتاج التطبيق إلى أسعار الصرف لهذا اليوم من خادم. يستبدل هذا الدرس Task.sleep التمثيلية من الدرس السابق بطلب حقيقي: ابنِ URL، واجلب البيانات، وافحص الاستجابة، وفكّ ترميز الـ JSON إلى أنواع Swift، ثم اعرضها. هذه الخطوات هي نفسها مع معظم الـ APIs التي ستستدعيها في حياتك.
تستخدم الأمثلة endpoint بديلًا، https://api.example.com/v1/latest?base=USD. ضع مكانه مزوّدًا حقيقيًا لأسعار الصرف (لمعظمهم باقة مجانية) وعدّل الـ struct ليطابق الـ JSON الخاص به. تبدو الاستجابة هكذا:
{
"base": "USD",
"updated_at": "2026-10-02T06:00:00Z",
"rates": { "EUR": 0.92, "GBP": 0.79, "EGP": 48.31 }
}اعكس شكل الـ JSON باستخدام Codable
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، كما يفعل العميل أدناه.
اجلب، افحص، فكّ الترميز
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 مكتمل البناء أو ترمي خطأً.
وليست كل حالات الفشل تستحق الرسالة نفسها. فالـ URLError الذي يُرمى يحمل code، فتستطيع التمييز بين «غير متصل» (.notConnectedToInternet) و«انتهت المهلة» (.timedOut)، وتقول “You’re offline. Rates will refresh when you reconnect” بدل اعتذار عام. المستخدمون يسامحون التطبيق الذي يشرح نفسه.
حالات تحميل يثق بها الـ view
فكرة الـ enum من درس الـ enums تؤتي ثمارها الآن. نموذج على الـ main actor يملك الـ state، والـ view يكتب switch عليها:
@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)
}
}
}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، فلا يمكن أبدًا عرض التحميل والبيانات والأخطاء في الوقت نفسه.