Návod na filmové API ve Swiftu se SwiftUI

Tento návod vytvoří aplikaci pro iOS 16 ve Swiftu 5.9, která ukazuje filmy, jež jsou tento týden v trendech, v seznamu SwiftUI s plakáty, roky a skóre. Používá URLSession s async/await a AsyncImage, ošetřuje 401 a 429 a vysvětluje, proč má produkční aplikace volat amodb přes váš vlastní server.

.swift Swift Aktualizováno Zobrazit jako Markdown

Co vytvoříte

Vytvoříte Trending, aplikaci pro iOS s jednou obrazovkou: seznam filmů, které jsou tento týden v trendech, s plakátem, názvem, rokem a skóre. Potřebujete Xcode se Swiftem 5.9 nebo novějším, cílovou verzi iOS 16 a klíč amodb. Aplikace používá URLSession s async/await, modely Decodable a AsyncImage, bez balíčků. Počítejte s asi 20 minutami.

Klíč uvnitř aplikace lze získat

Každé tajemství, které se dodává v aplikaci, berte jako veřejné. Někdo může číst řetězce z binárního souboru, zkoumat síťový provoz nebo číst úložiště aplikace. V tomto návodu pochází klíč z proměnné schématu Xcode, což je na vašem vlastním zařízení v pořádku. Pro vydávanou aplikaci spusťte malý server, který klíč drží, přepošle volání do amodb a vrátí JSON. Nastavte baseURL v klientovi níže na tento server a uživatele ověřujte tam. Limit požadavků platí na klíč, takže server také umožňuje cachovat jednu odpověď pro všechny vaše uživatele.

Krok 1: Získejte API klíč

Porovnejte tarify na stránce ceník. Každý tarif vrací všechna data, volba se tedy týká požadavků za minutu. Vytvořte klíč v panelu účtu. Než napíšete jakýkoli kód ve Swiftu, ověřte ho z terminálu:

200 znamená, že klíč funguje. 401 znamená, že je nesprávný. Stránka autentizace vysvětluje oba způsoby odeslání klíče.

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: Vytvořte projekt v Xcode

Vytvořte projekt aplikace pro iOS s názvem Trending s rozhraním SwiftUI a nastavte minimální cílovou verzi na iOS 16. Smažte vygenerovaný ContentView.swift. Ve svém schématu otevřete Edit Scheme, zvolte Run, pak Arguments a přidejte proměnnou prostředí s názvem AMODB_KEY a svým klíčem. Xcode ji předá aplikaci jen tehdy, když ji spouštíte z Xcode.

Krok 3: Odešlete první požadavek

Přečtěte klíč z prostředí a pošlete ho jako hlavičku Bearer. Nastavte 10sekundový timeout, aby se zaseknuté připojení ukončilo.

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: Dekódujte odpověď a ukažte plakáty

Odpověď je stránka s polem results s 20 položkami. Každý film má id, title, release_date, poster_path a vote_average, plus media_type nastavené na movie. Nastavte keyDecodingStrategy na .convertFromSnakeCase a deklarujte vlastnosti v camel case. Pole, která nedeklarujete, se ignorují, což se hodí pro pole, jež se objeví později. poster_path může být null a release_date může chybět nebo být prázdný řetězec, proto model deklaruje obě jako volitelná.

poster_path začíná lomítkem, takže řetězec výše správně spojí velikost a název souboru. Plakáty pocházejí z img.amodb.dev, který nepotřebuje klíč a cachuje obrázky 30 dní. Velikosti uvádí stránka obrázky a endpoint popisuje stránka trendy a seznamy.

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: Ošetřete chyby a limity požadavků

Namapujte každý stav na chybu, kterou může obrazovka zobrazit.

StavVýznamCo dělá aplikace
401Klíč chybí, je nesprávný nebo pozastavenýZobrazí zprávu, bez opakování.
402Platnost tarifu skončilaZobrazí zprávu. Obnovte ho na stránce ceník.
429Překročili jste požadavky za minutuPočká Retry-After sekund, nejvýše 30, opakuje nejvýše dvakrát.
500 nebo 504Selhání na straně serveruZobrazí stav a nabídne obnovení tažením dolů.
URLErrorOffline, timeout, ztracené připojeníZobrazí localizedDescription.

Všechny kódy najdete na stránkách chyby a limity požadavků.

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: Cachujte seznam

Edge cachuje seznamy trendů 30 minut, takže dřívější nový dotaz vrátí stejné filmy. Limit požadavků volání stále počítá. Držte poslední výsledek ve view modelu a načtěte znovu, jen když je starší než 30 minut nebo uživatel obnoví tažením dolů.

Krok 7: Kompletní aplikace

Tento jediný soubor obsahuje modely, klienta, view model a pohledy. Vložte ho přes vygenerovaný soubor aplikace.

Spusťte aplikaci v simulátoru. Seznam se po prvním volání naplní a tažení dolů vynutí opětovné načtení. Skóre je souhrnné vote_average ze seznamu a titul, který zatím nemá žádné hlasy, ukazuje 0.0. Pro skóre z IMDb nebo Kinopoisk vyžádejte film s append_to_response=ratings. Objekt ratings obsahuje imdb, kinopoisk, metacritic, rotten_tomatoes a tmdb, každý buď null, nebo objekt s value a votes, jak ukazuje stránka hodnocení.

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

Další kroky

  • Přidejte obrazovku detailu, která načte /movie/{id} s append_to_response=credits,ratings, a pole najdete na stránce filmy.
  • Přepněte položku dotazu language na jazyk zařízení s využitím 14 základních jazyků na stránce jazyky.
  • Přesuňte klíč na svůj server a před zveřejněním aplikace na něj nasměrujte baseURL.
  • Vytvořte stejný seznam pro web pomocí Next.js.