מדריך API לסרטים ב-Swift עם SwiftUI

המדריך בונה אפליקציית iOS 16 ב-Swift 5.9 שמציגה את הסרטים הפופולריים של השבוע ברשימת SwiftUI עם פוסטרים, שנים וציונים. היא משתמשת ב-URLSession עם async/await וב-AsyncImage, מטפלת ב-401 וב-429, ומסבירה למה אפליקציה בייצור צריכה לקרוא ל-amodb דרך השרת שלכם. הקוד כתוב ב-Swift 5.9 ומיועד ל-iOS 16 ומעלה.

.swift Swift עודכן צפייה כ-Markdown

מה בונים

בונים את Trending, אפליקציית iOS עם מסך אחד: רשימה של הסרטים הפופולריים של השבוע עם פוסטר, כותרת, שנה וציון. צריך Xcode עם Swift 5.9 ומעלה, יעד פריסה של iOS 16 ומפתח amodb. האפליקציה משתמשת ב-URLSession עם async/await, במודלים של Decodable וב-AsyncImage, בלי חבילות. תכננו כ-20 דקות.

מפתח בתוך אפליקציה אפשר לחלץ

התייחסו לכל סוד שנשלח בתוך אפליקציה כאל ציבורי. מישהו יכול לקרוא מחרוזות מקובץ ההרצה, לבדוק תעבורת רשת או לקרוא את האחסון של האפליקציה. במדריך הזה המפתח מגיע ממשתנה של סכמת Xcode, וזה בסדר במכשיר שלכם. באפליקציה שאתם משחררים, הפעילו שרת קטן שמחזיק את המפתח, מעביר את הקריאה ל-amodb ומחזיר את ה-JSON. הגדירו את baseURL בלקוח שלמטה לשרת הזה ואמתו שם את המשתמשים שלכם. מגבלת הקצב חלה לכל מפתח, ולכן שרת גם מאפשר לשמור במטמון תשובה אחת לכל המשתמשים שלכם.

שלב 1: קבלת מפתח API

השוו מסלולים בעמוד תמחור. כל מסלול מחזיר את כל הנתונים, ולכן הבחירה נוגעת לבקשות לדקה. צרו מפתח בלוח הבקרה של החשבון. בדקו אותו מהטרמינל לפני שכותבים שורת Swift:

200 אומר שהמפתח עובד. 401 אומר שהוא שגוי. עמוד אימות מסביר את שתי הדרכים לשלוח את המפתח.

Shell
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.amodb.dev/3/trending/movie/week?api_key=$AMODB_KEY"

שלב 2: יצירת פרויקט Xcode

צרו פרויקט iOS App בשם Trending עם ממשק SwiftUI והגדירו את יעד הפריסה המינימלי ל-iOS 16. מחקו את ContentView.swift שנוצר. בסכמה שלכם פתחו Edit Scheme, בחרו Run ואז Arguments, והוסיפו משתנה סביבה בשם AMODB_KEY עם המפתח שלכם. Xcode מעביר אותו לאפליקציה רק כשמריצים מ-Xcode.

שלב 3: שליחת הבקשה הראשונה

קראו את המפתח מהסביבה ושלחו אותו ככותרת Bearer. קבעו פסק זמן של 10 שניות כדי שחיבור תקוע יסתיים.

Swift
let key = ProcessInfo.processInfo.environment["AMODB_KEY"] ?? ""
var request = URLRequest(url: URL(string: "https://api.amodb.dev/3/trending/movie/week?language=en-US")!)
request.timeoutInterval = 10
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")

let (data, response) = try await URLSession.shared.data(for: request)
print((response as? HTTPURLResponse)?.statusCode ?? 0, data.count)

שלב 4: פענוח התגובה והצגת פוסטרים

התגובה היא עמוד עם מערך results של 20 פריטים. לכל סרט יש id, title, release_date, poster_path ו-vote_average, ובנוסף media_type שמוגדר ל-movie. הגדירו את keyDecodingStrategy ל-.convertFromSnakeCase והצהירו על מאפיינים ב-camel case. שדות שלא הצהרתם עליהם מתעלמים מהם, וזה מתאים לשדות שמופיעים בהמשך. poster_path יכול להיות null ו-release_date יכול להיות חסר או מחרוזת ריקה, ולכן המודל מצהיר על שניהם כאופציונליים.

poster_path מתחיל בלוכסן, ולכן המחרוזת שלמעלה מחברת נכון את הגודל ואת שם הקובץ. פוסטרים מגיעים מ-img.amodb.dev, שלא דורש מפתח ושומר תמונות במטמון 30 יום. רשימת הגדלים בעמוד תמונות, ונקודת הקצה מוסברת בעמוד טרנדים ורשימות.

Swift
struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}

שלב 5: טיפול בשגיאות ובמגבלות קצב

מפו כל סטטוס לשגיאה שהמסך יכול להציג.

סטטוסמשמעותמה האפליקציה עושה
401המפתח חסר, שגוי או מושעהלהציג הודעה, בלי ניסיון חוזר.
402תקופת המסלול הסתיימהלהציג הודעה. לחדש בעמוד תמחור.
429מעל הבקשות שלכם לדקהלהמתין Retry-After שניות, עד 30, ולנסות שוב פעמיים לכל היותר.
500 או 504כשל בצד השרתלהציג את הסטטוס ולהציע משיכה לרענון.
URLErrorלא מקוון, פסק זמן, חיבור שאבדלהציג את localizedDescription.

את כל הקודים ראו בעמודים שגיאות ומגבלות קצב.

Swift
switch status {
case 200..<300:
    return try Self.decoder.decode(T.self, from: data)
case 401:
    throw AmodbError.invalidKey
case 429 where attempt < 2:
    let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
    try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
    attempt += 1
case 429:
    throw AmodbError.rateLimited
default:
    throw AmodbError.server(status)
}

שלב 6: שמירת הרשימה במטמון

הקצה שומר רשימות טרנדים במטמון 30 דקות, ולכן בקשה מוקדמת יותר מחזירה את אותם סרטים. מגבלת הקצב עדיין סופרת את הקריאה. שמרו את התוצאה האחרונה במודל התצוגה וטענו מחדש רק כשהיא ישנה מ-30 דקות או כשהמשתמש מושך לרענון.

שלב 7: האפליקציה המלאה

קובץ יחיד זה מחזיק את המודלים, הלקוח, מודל התצוגה והתצוגות. הדביקו אותו על קובץ האפליקציה שנוצר.

הריצו את האפליקציה בסימולטור. הרשימה מתמלאת אחרי הקריאה הראשונה, ומשיכה למטה כופה טעינה מחדש. הציונים הם vote_average המצטבר מהרשימה, וכותר בלי הצבעות עדיין מציג 0.0. לציונים של IMDb או Kinopoisk, בקשו סרט עם append_to_response=ratings. האובייקט ratings מכיל את imdb, kinopoisk, metacritic, rotten_tomatoes ו-tmdb, וכל אחד מהם null או אובייקט עם value ו-votes, כפי שמוצג בעמוד דירוגים.

Swift
import SwiftUI

// MARK: Models

struct TrendingPage: Decodable {
    let results: [Movie]
}

struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var year: String { String((releaseDate ?? "").prefix(4)) }

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}

// MARK: Client

enum AmodbError: LocalizedError {
    case missingKey
    case invalidKey
    case rateLimited
    case server(Int)

    var errorDescription: String? {
        switch self {
        case .missingKey: return "Set AMODB_KEY in the Xcode scheme."
        case .invalidKey: return "The API key was rejected (401)."
        case .rateLimited: return "Too many requests. Try again in a minute."
        case .server(let status): return "The service answered with status \(status)."
        }
    }
}

struct AmodbClient {
    var baseURL = URL(string: "https://api.amodb.dev/3")!
    let apiKey: String

    private static let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }()

    func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
        guard !apiKey.isEmpty else { throw AmodbError.missingKey }
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
        components.queryItems = query
        var request = URLRequest(url: components.url!, timeoutInterval: 10)
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")

        var attempt = 0
        while true {
            let (data, response) = try await URLSession.shared.data(for: request)
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            switch status {
            case 200..<300:
                return try Self.decoder.decode(T.self, from: data)
            case 401:
                throw AmodbError.invalidKey
            case 429 where attempt < 2:
                let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
                try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
                attempt += 1
            case 429:
                throw AmodbError.rateLimited
            default:
                throw AmodbError.server(status)
            }
        }
    }
}

// MARK: View model

@MainActor
final class TrendingViewModel: ObservableObject {
    @Published private(set) var movies: [Movie] = []
    @Published private(set) var errorMessage: String?
    @Published private(set) var isLoading = false

    private let client: AmodbClient
    private var loadedAt: Date?

    init(client: AmodbClient) {
        self.client = client
    }

    func load(force: Bool = false) async {
        if !force, let last = loadedAt, !movies.isEmpty, Date().timeIntervalSince(last) < 30 * 60 {
            return
        }
        isLoading = true
        defer { isLoading = false }
        do {
            let page: TrendingPage = try await client.get(
                "trending/movie/week",
                query: [URLQueryItem(name: "language", value: "en-US")]
            )
            movies = page.results
            loadedAt = Date()
            errorMessage = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: Views

struct MovieRow: View {
    let movie: Movie

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: movie.posterURL) { phase in
                switch phase {
                case .success(let image):
                    image.resizable().scaledToFill()
                case .failure:
                    Color.gray.opacity(0.2)
                default:
                    ProgressView()
                }
            }
            .frame(width: 60, height: 90)
            .clipShape(RoundedRectangle(cornerRadius: 6))

            VStack(alignment: .leading, spacing: 4) {
                Text(movie.title).font(.headline)
                Text(movie.year).font(.subheadline).foregroundStyle(.secondary)
                Label(String(format: "%.1f", movie.voteAverage), systemImage: "star.fill")
                    .font(.caption)
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var model: TrendingViewModel

    init(client: AmodbClient) {
        _model = StateObject(wrappedValue: TrendingViewModel(client: client))
    }

    var body: some View {
        NavigationStack {
            List(model.movies) { movie in
                MovieRow(movie: movie)
            }
            .overlay {
                if model.isLoading && model.movies.isEmpty {
                    ProgressView()
                } else if let message = model.errorMessage, model.movies.isEmpty {
                    Text(message).multilineTextAlignment(.center).padding()
                }
            }
            .navigationTitle("Trending")
            .refreshable { await model.load(force: true) }
            .task { await model.load() }
        }
    }
}

@main
struct TrendingApp: App {
    private let client = AmodbClient(apiKey: ProcessInfo.processInfo.environment["AMODB_KEY"] ?? "")

    var body: some Scene {
        WindowGroup {
            ContentView(client: client)
        }
    }
}

הצעדים הבאים

  • הוסיפו מסך פרטים שטוען את /movie/{id} עם append_to_response=credits,ratings, וראו את השדות בעמוד סרטים.
  • החליפו את פריט השאילתה language לשפת המכשיר, עם 14 שפות הבסיס שבעמוד שפות.
  • העבירו את המפתח לשרת שלכם והפנו אליו את baseURL לפני שמפרסמים את האפליקציה.
  • בנו את אותה רשימה לאינטרנט עם Next.js.