Poradnik API filmów w Swift z SwiftUI

Ten poradnik buduje aplikację na iOS 16 w Swift 5.9, która pokazuje filmy będące w tym tygodniu w trendach na liście SwiftUI z plakatami, latami i wynikami. Używa URLSession z async/await i AsyncImage, obsługuje 401 i 429 oraz wyjaśnia, czemu aplikacja produkcyjna powinna wywoływać amodb przez twój własny serwer.

.swift Swift Zaktualizowano Zobacz jako Markdown

Co zbudujesz

Zbudujesz Trending, aplikację na iOS z jednym ekranem: listą filmów będących w tym tygodniu w trendach z plakatem, tytułem, rokiem i wynikiem. Potrzebujesz Xcode ze Swift 5.9 lub nowszym, docelowej wersji iOS 16 i klucza amodb. Aplikacja używa URLSession z async/await, modeli Decodable i AsyncImage, bez pakietów. Zaplanuj około 20 minut.

Klucz w aplikacji da się wyciągnąć

Każdy sekret dostarczany w aplikacji traktuj jak publiczny. Ktoś może czytać ciągi z pliku binarnego, podglądać ruch sieciowy lub czytać pamięć aplikacji. W tym poradniku klucz pochodzi ze zmiennej schematu Xcode, co jest w porządku na twoim własnym urządzeniu. W aplikacji, którą wydajesz, uruchom mały serwer, który trzyma klucz, przekazuje wywołanie do amodb i zwraca JSON. Ustaw baseURL w kliencie poniżej na ten serwer i tam uwierzytelniaj swoich użytkowników. Limit żądań obowiązuje na klucz, więc serwer pozwala też zapisać jedną odpowiedź w cache dla wszystkich twoich użytkowników.

Krok 1: Zdobądź klucz API

Porównaj plany na stronie cennik. Każdy plan zwraca wszystkie dane, więc wybór dotyczy żądań na minutę. Utwórz klucz w panelu konta. Sprawdź go z terminala, zanim napiszesz jakikolwiek kod Swift:

200 oznacza, że klucz działa. 401 oznacza, że jest błędny. Strona uwierzytelnianie wyjaśnia oba sposoby wysyłania klucza.

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"

Krok 2: Utwórz projekt Xcode

Utwórz projekt aplikacji iOS o nazwie Trending z interfejsem SwiftUI i ustaw minimalną docelową wersję na iOS 16. Usuń wygenerowany ContentView.swift. W swoim schemacie otwórz Edit Scheme, wybierz Run, potem Arguments i dodaj zmienną środowiskową o nazwie AMODB_KEY z twoim kluczem. Xcode przekazuje ją aplikacji tylko wtedy, gdy uruchamiasz z Xcode.

Krok 3: Wyślij pierwsze żądanie

Odczytaj klucz ze środowiska i wyślij go jako nagłówek Bearer. Ustaw 10-sekundowy timeout, by zawieszone połączenie się kończyło.

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)

Krok 4: Zdekoduj odpowiedź i pokaż plakaty

Odpowiedź to strona z tablicą results z 20 pozycjami. Każdy film ma id, title, release_date, poster_path i vote_average, a także media_type ustawione na movie. Ustaw keyDecodingStrategy na .convertFromSnakeCase i zadeklaruj właściwości w camel case. Pola, których nie zadeklarujesz, są ignorowane, co pasuje do pól pojawiających się później. poster_path może być null, a release_date może brakować lub być pustym ciągiem, więc model deklaruje oba jako opcjonalne.

poster_path zaczyna się od ukośnika, więc ciąg powyżej poprawnie łączy rozmiar i nazwę pliku. Plakaty pochodzą z img.amodb.dev, który nie wymaga klucza i trzyma obrazy w cache przez 30 dni. Rozmiary wymienia strona obrazy, a endpoint opisuje strona trendy i listy.

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)")
    }
}

Krok 5: Obsłuż błędy i limity żądań

Zmapuj każdy status na błąd, który ekran może pokazać.

StatusZnaczenieCo robi aplikacja
401Brak klucza, jest błędny lub zawieszonyPokazuje komunikat, bez ponawiania.
402Okres planu dobiegł końcaPokazuje komunikat. Odnów go na stronie cennik.
429Przekroczono żądania na minutęCzeka Retry-After sekund, maksymalnie 30, ponawia najwyżej dwa razy.
500 lub 504Awaria po stronie serweraPokazuje status i proponuje odświeżenie przeciągnięciem w dół.
URLErrorOffline, timeout, utracone połączeniePokazuje localizedDescription.

Wszystkie kody znajdziesz na stronach błędy i limity żądań.

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)
}

Krok 6: Zapisuj listę w cache

Edge trzyma listy trendów w cache przez 30 minut, więc wcześniejsze ponowne zapytanie zwraca te same filmy. Limit żądań nadal liczy to wywołanie. Trzymaj ostatni wynik w modelu widoku i ładuj ponownie tylko wtedy, gdy jest starszy niż 30 minut albo użytkownik odświeża przeciągnięciem w dół.

Krok 7: Kompletna aplikacja

Ten pojedynczy plik zawiera modele, klienta, model widoku i widoki. Wklej go na wygenerowany plik aplikacji.

Uruchom aplikację w symulatorze. Lista wypełnia się po pierwszym wywołaniu, a przeciągnięcie w dół wymusza ponowne załadowanie. Wyniki to zagregowane vote_average z listy, a tytuł bez głosów pokazuje 0.0. Dla wyników z IMDb lub Kinopoisk zażądaj filmu z append_to_response=ratings. Obiekt ratings zawiera imdb, kinopoisk, metacritic, rotten_tomatoes i tmdb, każde to null albo obiekt z value i votes, jak pokazuje strona oceny.

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)
        }
    }
}

Kolejne kroki

  • Dodaj ekran szczegółów, który ładuje /movie/{id} z append_to_response=credits,ratings, a pola znajdziesz na stronie filmy.
  • Przełącz element zapytania language na język urządzenia, korzystając z 14 języków bazowych ze strony języki.
  • Przenieś klucz na swój serwer i skieruj na niego baseURL, zanim opublikujesz aplikację.
  • Zbuduj tę samą listę dla internetu z Next.js.