Посібник зі Swift API фільмів із SwiftUI

Цей посібник створює застосунок для iOS 16 на Swift 5.9, що показує трендові фільми тижня у списку SwiftUI з постерами, роками й оцінками. Він використовує URLSession з async/await та AsyncImage, обробляє 401 і 429 та пояснює, чому робочий застосунок має викликати amodb через ваш власний сервер.

.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 і оголосіть властивості в camelCase. Поля, яких ви не оголосили, ігноруються, що пасує полям, які з'являться пізніше. poster_path може бути null, а release_date може бути відсутнім або порожнім рядком, тож модель оголошує обидва необов'язковими.

poster_path починається з похилої риски, тож рядок вище правильно з'єднує розмір і назву файлу. Постери беруться з img.amodb.dev, який не потребує ключа й кешує зображення на 30 днів. Сторінка API зображень перелічує розміри, а списки трендів описують ендпоінт.

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.