Падручнік па 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
Параўнайце планы на старонцы цэны. Кожны план вяртае ўсе даныя, таму выбар залежыць ад запытаў у хвіліну. Стварыце ключ у асабістым кабінеце. Праверце яго з тэрмінала, перш чым пісаць Swift:
200 азначае, што ключ працуе. 401 азначае, што ён няправільны. Старонка аўтэнтыфікацыя тлумачыць абодва спосабы перадачы ключа.
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 секунд, каб завіслае злучэнне завяршалася.
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 можа адсутнічаць або быць пустым радком, таму мадэль абвяшчае абодва неабавязковымі.
poster_path пачынаецца з косай рыскі, таму радок вышэй правільна злучае памер і імя файла. Постэры бяруцца з img.amodb.dev, якому ключ не патрэбны і які кэшуе выявы 30 дзён. Старонка выявы пералічвае памеры, а трэнды і спісы апісваюць эндпоінт.
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. |
Усе коды гл. на старонках памылкі і ліміты запытаў.
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: Кэшуйце спіс
Edge кэшуе спісы трэндаў на 30 хвілін, таму паўторны запыт раней вяртае тыя ж фільмы. Ліміт запытаў выклік усё роўна ўлічвае. Трымайце апошні вынік у мадэлі выгляду і перазагружайце, толькі калі ён старэйшы за 30 хвілін або карыстальнік пацягнуў для абнаўлення.
Крок 7: Поўная праграма
Гэты адзіны файл змяшчае мадэлі, кліент, мадэль выгляду і выгляды. Устаўце яго замест створанага файла праграмы.
Запусціце праграму ў сімулятары. Спіс запаўняецца пасля першага выкліку, а пацягванне ўніз прымушае перазагрузку. Ацэнкі гэта зводны vote_average са спісу, а назва без галасоў пакуль паказвае 0.0. Для ацэнак IMDb або Kinopoisk запытайце фільм з append_. Аб’ект ratings змяшчае imdb, kinopoisk, metacritic, rotten_tomatoes і tmdb, кожны або null, або аб’ект з value і votes, як паказвае старонка рэйтынгі.
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.