使用 SwiftUI 的 Swift 电影 API 教程
用 Swift 5.9 构建 iOS 16 应用,在 SwiftUI 列表中显示本周热门电影的海报、年份和评分。用 URLSession 的 async/await 和 AsyncImage,处理 401 和 429,正式应用应经由你的服务器调用 amodb。
你将构建什么
你将构建 Trending,一个只有一个界面的 iOS 应用:本周热门电影列表,带有海报、片名、年份和评分。你需要 Swift 5.9 或更新版本的 Xcode、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 项目
创建一个名为 Trending 的 iOS App 项目,界面选择 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,以及设为 movie 的 media_type。把 keyDecodingStrategy 设为 .convertFromSnakeCase,并声明驼峰命名的属性。未声明的字段会被忽略,这正适合以后才出现的字段。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)
}
}
}