# 使用 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 密钥

在[定价](https://amodb.dev/zh/pricing/)页比较套餐。每个套餐返回全部数据，所以选择取决于每分钟请求数。在账户后台创建密钥。写任何 Swift 代码之前，先在终端里验证它：

```bash
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"
```

`200` 表示密钥可用，`401` 表示密钥错误。[认证](https://amodb.dev/zh/authentication/)页介绍了发送密钥的两种方式。

## 步骤 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` 可能缺失或是空字符串，所以模型把两者都声明为可选。

```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)")
    }
}
```

`poster_path` 以斜杠开头，所以上面的字符串能正确连接尺寸和文件名。海报来自 `img.amodb.dev`，无需密钥，图片缓存 30 天。尺寸列表见[图片](https://amodb.dev/zh/images-api/)页，接口说明见[热门趋势与列表](https://amodb.dev/zh/trending-lists/)。

## 步骤 5：处理错误和速率限制

把每个状态映射为界面可以显示的错误。

| 状态         | 含义          | 应用的做法                                          |
| ---------- | ----------- | ---------------------------------------------- |
| 401        | 密钥缺失、错误或已停用 | 显示消息，不重试。                                      |
| 402        | 套餐周期已结束     | 显示消息。请在[定价](https://amodb.dev/zh/pricing/)页续费。 |
| 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)
}
```

所有错误码见[错误](https://amodb.dev/zh/errors/)和[速率限制](https://amodb.dev/zh/rate-limits/)。

## 步骤 6：缓存列表

边缘节点把热门趋势列表缓存 30 分钟，所以更早再次请求会返回同样的电影。速率限制仍然会计入这次调用。请在视图模型中保留上一次的结果，只有在它超过 30 分钟或用户下拉刷新时才重新加载。

## 步骤 7：完整应用

这个单文件包含模型、客户端、视图模型和视图。把它粘贴到自动生成的应用文件上。

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

在模拟器上运行应用。第一次调用后列表被填满，下拉会强制重新加载。评分是列表中汇总的 `vote_average`，尚无投票的作品显示 0.0。要获取 IMDb 或 Kinopoisk 评分，请用 `append_to_response=ratings` 请求电影。`ratings` 对象包含 `imdb`、`kinopoisk`、`metacritic`、`rotten_tomatoes` 和 `tmdb`，每个要么是 `null`，要么是带有 `value` 和 `votes` 的对象，详见[评分](https://amodb.dev/zh/ratings/)页。

## 下一步

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

## 常见问题

### 把 API 密钥放进 iOS 应用安全吗？

不安全。存放在应用二进制文件或其设置中的任何内容都可以被提取。请把密钥留在你自己控制的服务器上，让应用调用那台服务器，应用内的密钥只用于本地测试。

### 本教程需要哪些 iOS 和 Swift 版本？

Swift 5.9 或更新版本（配合 Xcode），部署目标为 iOS 16。这样你就有 NavigationStack、带时长的 Task.sleep 和 AsyncImage。视图模型使用 ObservableObject，所以也能在 iOS 16 上运行。

### 不借助额外的库，如何显示海报？

把 URL 构建为 https://img.amodb.dev/t/p/w185 加上 poster_path 的值，再传给 AsyncImage。图片主机无需密钥。较大的布局用 w342 或 w500，并用占位图处理失败状态。

### 收到 429 响应后应用该怎么做？

读取 Retry-After 响应头，按该秒数（设上限）等待，最多重试两次。如果仍然失败，就显示消息并让用户下拉刷新。不要无限制地循环。

### 标题可以使用其他语言吗？

可以。添加 language 查询项，例如 ru-RU 或 pt-BR。提供 14 种基础语言，其他标签会回退到英语。存在译文时，标题会以本地化形式返回。

## 相关页面

- [Next.js 电影应用教程](https://amodb.dev/zh/nextjs-movie-app/): 用纯 JavaScript 构建 Next.js 15 电影应用：服务器组件通过 revalidate 从 amodb 获取热门、搜索和详情，并保护密钥。
- [热门与榜单接口](https://amodb.dev/zh/trending-lists/): 兼容 TMDB v3 路径的热门电影 API：日榜与周榜，以及电影和电视剧的正在上映、热门、高分、即将上映榜单，缓存 30 分钟。
- [图片 API 与图片主机](https://amodb.dev/zh/images-api/): 采用 TMDB v3 路径的电影图片 API：列出作品的海报、背景图、标志和剧照，再从 img.amodb.dev 以 10 种尺寸无密钥加载。
- [认证](https://amodb.dev/zh/authentication/): 如何发送 amodb 密钥：查询中的 api_key、Bearer 请求头，或 OMDb 的 apikey。含密钥格式、保管和 401 错误。
- [速率限制](https://amodb.dev/zh/rate-limits/): amodb 按滑动 60 秒窗口内每分钟请求数限制每个密钥：按套餐为 20、40 或 120。读取速率限制响应头，并用 Retry-After 处理 429。
- [快速开始](https://amodb.dev/zh/quickstart/): 发出第一次 amodb 调用：获取密钥，用 curl、JavaScript、Python、PHP 或 Go 请求电影详情，再加载海报。

更新于: 2026-10-18. Source: https://amodb.dev/zh/swift-movie-api/
