מדריך API לסרטים ב-Swift עם SwiftUI
המדריך בונה אפליקציית iOS 16 ב-Swift 5.9 שמציגה את הסרטים הפופולריים של השבוע ברשימת SwiftUI עם פוסטרים, שנים וציונים. היא משתמשת ב-URLSession עם async/await וב-AsyncImage, מטפלת ב-401 וב-429, ומסבירה למה אפליקציה בייצור צריכה לקרוא ל-amodb דרך השרת שלכם. הקוד כתוב ב-Swift 5.9 ומיועד ל-iOS 16 ומעלה.
מה בונים
בונים את 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: שמירת הרשימה במטמון
הקצה שומר רשימות טרנדים במטמון 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)
}
}
}