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.
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.
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.
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.
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ć.
| Status | Znaczenie | Co robi aplikacja |
|---|---|---|
| 401 | Brak klucza, jest błędny lub zawieszony | Pokazuje komunikat, bez ponawiania. |
| 402 | Okres planu dobiegł końca | Pokazuje komunikat. Odnów go na stronie cennik. |
| 429 | Przekroczono żądania na minutę | Czeka Retry-After sekund, maksymalnie 30, ponawia najwyżej dwa razy. |
| 500 lub 504 | Awaria po stronie serwera | Pokazuje status i proponuje odświeżenie przeciągnięciem w dół. |
URLError | Offline, timeout, utracone połączenie | Pokazuje localizedDescription. |
Wszystkie kody znajdziesz na stronach błędy i limity żądań.
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_. Obiekt ratings zawiera imdb, kinopoisk, metacritic, rotten_tomatoes i tmdb, każde to null albo obiekt z value i votes, jak pokazuje strona oceny.
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}zappend_, a pola znajdziesz na stronie filmy.to_ response= credits, ratings - Przełącz element zapytania
languagena 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.