Посібник зі 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 і оголосіть властивості в camelCase. Поля, яких ви не оголосили, ігноруються, що пасує полям, які з'являться пізніше. poster_path може бути null, а release_date може бути відсутнім або порожнім рядком, тож модель оголошує обидва необов'язковими.
poster_path починається з похилої риски, тож рядок вище правильно з'єднує розмір і назву файлу. Постери беруться з img.amodb.dev, який не потребує ключа й кешує зображення на 30 днів. Сторінка API зображень перелічує розміри, а списки трендів описують ендпоінт.
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: кешуйте список
Кеш на периферії зберігає списки трендів 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.