Урок за 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. Задайте timeout от 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.