使用 SwiftUI 的 Swift 电影 API 教程

用 Swift 5.9 构建 iOS 16 应用,在 SwiftUI 列表中显示本周热门电影的海报、年份和评分。用 URLSession 的 async/await 和 AsyncImage,处理 401 和 429,正式应用应经由你的服务器调用 amodb。

.swift Swift 更新于 以 Markdown 查看

你将构建什么

你将构建 Trending,一个只有一个界面的 iOS 应用:本周热门电影列表,带有海报、片名、年份和评分。你需要 Swift 5.9 或更新版本的 Xcode、iOS 16 部署目标和一个 amodb 密钥。应用使用 URLSession 的 async/await、Decodable 模型和 AsyncImage,不需要任何包。预计用时约 20 分钟。

应用内的密钥可以被提取

请把随应用发布的任何密钥都当作公开信息。有人可以从二进制文件中读取字符串、检查网络流量或读取应用的存储。在本教程中,密钥来自 Xcode 方案变量,在你自己的设备上这样做没有问题。对于要发布的应用,请运行一个小型服务器来保存密钥,转发对 amodb 的调用并返回 JSON。把下面客户端中的 baseURL 设为那台服务器,并在那里对你的用户进行认证。速率限制按密钥计算,所以使用服务器还能让你为所有用户缓存同一份结果。

步骤 1:获取 API 密钥

在定价页比较套餐。每个套餐返回全部数据,所以选择取决于每分钟请求数。在账户后台创建密钥。写任何 Swift 代码之前,先在终端里验证它:

200 表示密钥可用,401 表示密钥错误。认证页介绍了发送密钥的两种方式。

Shell
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 秒超时,让卡住的连接能够结束。

Swift
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 天。尺寸列表见图片页,接口说明见热门趋势与列表。

Swift
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。

所有错误码见错误和速率限制。

Swift
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_to_response=ratings 请求电影。ratings 对象包含 imdb、kinopoisk、metacritic、rotten_tomatoes 和 tmdb,每个要么是 null,要么是带有 value 和 votes 的对象,详见评分页。

Swift
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)
        }
    }
}

下一步

  • 添加详情界面,用 append_to_response=credits,ratings 加载 /movie/{id},字段见电影。
  • 把 language 查询项切换为设备语言,可用的 14 种基础语言见语言页。
  • 发布应用之前,把密钥移到你的服务器,并让 baseURL 指向它。
  • 用 Next.js 为网页构建同样的列表。