# Падручнік па Swift і API фільмаў з SwiftUI

> Гэты падручнік будуе праграму для iOS 16 на Swift 5.9, якая паказвае трэндавыя фільмы тыдня ў спісе SwiftUI з постэрамі, гадамі і ацэнкамі. Яна выкарыстоўвае URLSession з async/await і AsyncImage, апрацоўвае 401 і 429 і тлумачыць, чаму рэальная праграма павінна звяртацца да amodb праз ваш сервер.

## Што вы пабудуеце

Вы пабудуеце `Trending`, праграму для iOS з адным экранам: спіс трэндавых фільмаў тыдня з постэрам, назвай, годам і ацэнкай. Вам патрэбны Xcode са Swift 5.9 або новей, мэтавая версія iOS 16 і ключ amodb. Праграма выкарыстоўвае `URLSession` з async/await, мадэлі `Decodable` і `AsyncImage`, без пакетаў. Закладзіце каля 20 хвілін.

## Ключ усярэдзіне праграмы можна выцягнуць

Лічыце публічным любы сакрэт, які пастаўляецца ў праграме. Хтосьці можа прачытаць радкі з бінарніка, прагледзець сеткавы трафік або прачытаць сховішча праграмы. У гэтым падручніку ключ бярэцца са зменнай схемы Xcode, і гэта нармальна на вашай уласнай прыладзе. Для праграмы, якую вы выпускаеце, запусціце невялікі сервер, які трымае ключ, перанакіроўвае выклік у amodb і вяртае JSON. Задайце `baseURL` у кліенце ніжэй на гэты сервер і аўтэнтыфікуйце карыстальнікаў там. Ліміт запытаў дзейнічае на ключ, таму сервер таксама дазваляе кэшаваць адзін адказ для ўсіх вашых карыстальнікаў.

## Крок 1: Атрымайце ключ API

Параўнайце планы на старонцы [цэны](https://amodb.dev/be/pricing/). Кожны план вяртае ўсе даныя, таму выбар залежыць ад запытаў у хвіліну. Стварыце ключ у асабістым кабінеце. Праверце яго з тэрмінала, перш чым пісаць Swift:

```bash
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"
```

`200` азначае, што ключ працуе. `401` азначае, што ён няправільны. Старонка [аўтэнтыфікацыя](https://amodb.dev/be/authentication/) тлумачыць абодва спосабы перадачы ключа.

## Крок 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` можа адсутнічаць або быць пустым радком, таму мадэль абвяшчае абодва неабавязковымі.

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

`poster_path` пачынаецца з косай рыскі, таму радок вышэй правільна злучае памер і імя файла. Постэры бяруцца з `img.amodb.dev`, якому ключ не патрэбны і які кэшуе выявы 30 дзён. Старонка [выявы](https://amodb.dev/be/images-api/) пералічвае памеры, а [трэнды і спісы](https://amodb.dev/be/trending-lists/) апісваюць эндпоінт.

## Крок 5: Апрацоўка памылак і лімітаў

Супастаўце кожны статус з памылкай, якую можа паказаць экран.

| Статус      | Значэнне                                   | Што робіць праграма                                                                         |
| ----------- | ------------------------------------------ | ------------------------------------------------------------------------------------------- |
| 401         | Ключ адсутнічае, няправільны або прыпынены | Паказаць паведамленне, без паўтору.                                                         |
| 402         | Тэрмін дзеяння плана скончыўся             | Паказаць паведамленне. Прадоўжыце доступ на старонцы [цэны](https://amodb.dev/be/pricing/). |
| 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)
}
```

Усе коды гл. на старонках [памылкі](https://amodb.dev/be/errors/) і [ліміты запытаў](https://amodb.dev/be/rate-limits/).

## Крок 6: Кэшуйце спіс

Edge кэшуе спісы трэндаў на 30 хвілін, таму паўторны запыт раней вяртае тыя ж фільмы. Ліміт запытаў выклік усё роўна ўлічвае. Трымайце апошні вынік у мадэлі выгляду і перазагружайце, толькі калі ён старэйшы за 30 хвілін або карыстальнік пацягнуў для абнаўлення.

## Крок 7: Поўная праграма

Гэты адзіны файл змяшчае мадэлі, кліент, мадэль выгляду і выгляды. Устаўце яго замест створанага файла праграмы.

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

Запусціце праграму ў сімулятары. Спіс запаўняецца пасля першага выкліку, а пацягванне ўніз прымушае перазагрузку. Ацэнкі гэта зводны `vote_average` са спісу, а назва без галасоў пакуль паказвае 0.0. Для ацэнак IMDb або Kinopoisk запытайце фільм з `append_to_response=ratings`. Аб’ект `ratings` змяшчае `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` і `tmdb`, кожны або `null`, або аб’ект з `value` і `votes`, як паказвае старонка [рэйтынгі](https://amodb.dev/be/ratings/).

## Наступныя крокі

- Дадайце экран дэталяў, які загружае `/movie/{id}` з `append_to_response=credits,ratings`, а палі гл. на старонцы [фільмы](https://amodb.dev/be/movies/).
- Пераключыце элемент запыту `language` на мову прылады, выкарыстоўваючы 14 базавых моў са старонкі [мовы](https://amodb.dev/be/languages/).
- Перанясіце ключ на свой сервер і накіруйце `baseURL` на яго, перш чым публікаваць праграму.
- Зрабіце той жа спіс для вэба на [Next.js](https://amodb.dev/be/nextjs-movie-app/).

## Пытанні і адказы

### Ці бяспечна класці ключ API ў праграму для iOS?

Не. Усё, што захоўваецца ў бінарніку праграмы або яе наладах, можна выцягнуць. Трымайце ключ на серверы, які вы кантралюеце, няхай праграма выклікае гэты сервер, а ключ у самой праграме выкарыстоўвайце толькі для лакальнага тэсціравання.

### Якія версіі iOS і Swift патрэбныя для гэтага падручніка?

Swift 5.9 або новей з Xcode і мэтавая версія iOS 16. Гэта дае NavigationStack, Task.sleep з працягласцю і AsyncImage. Мадэль выгляду выкарыстоўвае ObservableObject, таму працуе і на iOS 16.

### Як паказаць постэры без дадатковых бібліятэк?

Складзіце URL як https://img.amodb.dev/t/p/w185 плюс значэнне poster_path і перадайце яго ў AsyncImage. Хосту выяў ключ не патрэбны. Для буйнейшых макетаў выкарыстоўвайце w342 або w500, а фазу збою апрацуйце запаўняльнікам.

### Што павінна рабіць праграма пасля адказу 429?

Прачытаць загаловак Retry-After, пачакаць столькі секунд з абмежаваннем і паўтарыць не болей за двойчы. Калі ўсё роўна збой, паказаць паведамленне і даць карыстальніку пацягнуць для абнаўлення. Не рабіце цыкл без мяжы.

### Ці можна выкарыстаць іншую мову для назваў?

Так. Дадайце элемент запыту language, напрыклад ru-RU або pt-BR. Падтрымліваюцца чатырнаццаць базавых моў, а любы іншы тэг замяняецца англійскай. Назвы вяртаюцца лакалізаванымі, калі пераклад існуе.

## Звязаныя старонкі

- [Падручнік па праграме фільмаў на Next.js](https://amodb.dev/be/nextjs-movie-app/): Створыце праграму фільмаў на Next.js 15 на чыстым JavaScript: серверныя кампаненты бяруць трэнды, пошук і дэталі з amodb праз revalidate і хаваюць ключ.
- [Эндпоінты трэндаў і спісаў](https://amodb.dev/be/trending-lists/): API трэндавых фільмаў з шляхамі TMDB v3: чарты за дзень і тыдзень, спісы зараз у пракаце, папулярных, лепшых і хутка ў пракаце, кэш 30 хвілін.
- [API выяў і хост выяў](https://amodb.dev/be/images-api/): API выяў фільмаў з шляхамі TMDB v3: спісы постэраў, фонавых выяў, лагатыпаў і кадраў назвы, а потым загрузка з img.amodb.dev у 10 памерах без ключа.
- [Аўтэнтыфікацыя](https://amodb.dev/be/authentication/): Як перадаваць ключ amodb: api_key у запыце, загаловак Authorization Bearer або apikey для выклікаў OMDb. Фармат ключа, захоўванне, замена і памылкі 401.
- [Ліміты запытаў](https://amodb.dev/be/rate-limits/): amodb абмяжоўвае ключ па запытах у хвіліну ў слізгальным акне 60 секунд: 20, 40 або 120 па плане. Чытайце загалоўкі лімітаў і апрацоўвайце 429 праз Retry-After.
- [Хуткі старт](https://amodb.dev/be/quickstart/): Першы выклік да amodb: атрымайце ключ, запытайце дэталі фільма ў curl, JavaScript, Python, PHP або Go і загрузіце постэр з img.amodb.dev. Менш за 2 хвіліны.

Абноўлена: 2026-10-18. Source: https://amodb.dev/be/swift-movie-api/
