#26 使用 async & await 串接 TMDB API
Published in
17 min readJan 11, 2022
練習要點
- 使用 async & await抓取多筆API資料 / 圖片
- 將所有資料controller 定義在同一個檔案
- 以UICollectionView顯示電影資料的水平瀏覽
- 以UITableView或UITableViewController不同應用顯示電影資料的垂直瀏覽
- 將電影資料傳遞到下一頁細節頁面
- 針對不同畫面的圖片尺寸需求,抓取對應尺寸的圖片
- URLCache增加cache大小的應用
- 搜尋電影關鍵字並回傳搜尋結果(此API功能不太穩定,無法完整實現功能)
TMDB 資料型別
電影資料主要顯示在results的arry資料裡。有些API裡的property檔名稱很不Swift,所以利用CodingKey來改寫。
struct MovieItem: Codable {
let id: Int
let title: String
let description: String
let backdropImage: String
let posterImage: String
let releaseDate: String
enum CodingKeys: String, CodingKey {
case backdropImage = "backdrop_path"
case id
case title
case description = "overview"
case posterImage = "poster_path"
case releaseDate = "release_date"
}
}struct MovieResponse: Codable {
let page: Int
let results: [MovieItem]
}
MovieService 串接網路的程式統一定義在一個檔案
透過 static 定義的 shared 常數建立單例 singleton 做一個全域變數
- 將所有抓取資料的程式統一寫在 MovieService的class裡
- 使用private init()強迫只用使用shared引用單一個物件
class MovieService {
static let shared = MovieService()
private init() {}
利用 URLQueryItem 設定 queryItems
由於後續會串接TMDB所提供不同抓取資料的內容,透過URLQueryItem容易管理。
let apikey = "xxxxxxxxx"
let baseURL = URL(string: "https://api.themoviedb.org/3/")!
抓取年度最佳電影資料的程式
func fetchBestMovie(in year: Int) async throws -> MovieResponse {
var urlComponent = URLComponents(string: "\(baseURL)discover/movie")!
urlComponent.queryItems = [
URLQueryItem(name: "with_genres", value: "18"),
URLQueryItem(name: "api_key", value: apikey),
URLQueryItem(name: "primary_release_year", value: "\(year)")
]
return try await movieResonse(urlComponent: urlComponent)
}
抓取最受歡迎電影資料的程式
func fetchPopularMovie() async throws -> MovieResponse {
var urlComponent = URLComponents(string: "\(baseURL)discover/movie")!
urlComponent.queryItems = [
URLQueryItem(name: "sort_by", value: "popularity.desc"),
URLQueryItem(name: "api_key", value: apikey),
]
return try await movieResonse(urlComponent: urlComponent)
}
串接 API 的 function 加上 async & throws,成功時回傳抓到的資料,失敗時丟出錯誤,使用 try await 呼叫 URLSession.shared.data
獨立寫成一個function,可以給不同抓取的程式呼叫。
func movieResonse(urlComponent: URLComponents) async throws -> MovieResponse {
let url = urlComponent.url!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MovieServiceError.movieListNotFound
}
print(httpResponse.statusCode)
let decoder = JSONDecoder()
let movieResponse = try decoder.decode(MovieResponse.self, from: data)
return movieResponse
}
抓取圖片的程式
func fetchImage(from urlStr: String, in size: ImageSize) async throws -> UIImage {
let baseUrl = URL(string: "https://image.tmdb.org/t/p/\(size)")!
let imageUrl = baseUrl.appendingPathComponent("\(urlStr)")
let (data, reponse) = try await URLSession.shared.data(from: imageUrl)
guard let httpResponse = reponse as? HTTPURLResponse, httpResponse.statusCode == 200, let image = UIImage(data: data) else {
throw MovieServiceError.imageDataMissing
}
return image
}
FeatureViewController
利用Task呼叫抓取資料的程式,並使用do-catch執行程式或拋出錯誤訊息
透過asyn/await的結構,不用特別處理執行緒,程式碼結構相當簡潔。
var bestMovieItems = [MovieItem]()override func viewDidLoad() {
super.viewDidLoad()Task {
do {
let movieResponse = try await MovieService.shared.fetchBestMovie(in: 2021)
bestMovieItems = movieResponse.results
movieCollectionView.reloadData()
} catch {
displayError(error, title: "Fail to fetch the Best Movie data")
}
}
將資料顯示在collectionView畫面上
建立collectionView的IBOutlet
@IBOutlet weak var movieCollectionView: UICollectionView!
用extension在原ViewController擴展UICollectionViewDataSource
extension FeatureViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
bestMovieItems.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(BestCollectionViewCell.self)", for: indexPath) as! BestCollectionViewCell
let item = bestMovieItems[indexPath.item]
cell.titleLabel.text = item.title
cell.dateLabel.text = item.releaseDate
cell.movieImage.image = UIImage(systemName: "photo")
一樣透過Task內來執行async/await抓取圖片的程式
Task {
do {
cell.movieImage.image = try await MovieService.shared.fetchImage(from: item.backdropImage, in: .w500)
} catch {
displayError(error, title: "Fail to fetch image")
}
}return cell
}
}
IBSegueAction傳遞資料到下一個頁面
@IBSegueAction func showDetailfromItem(_ coder: NSCoder) -> DetailViewController? {
guard let item = movieCollectionView.indexPathsForSelectedItems?.first?.row else {return nil}
return DetailViewController(coder: coder, movieItem: bestMovieItems[item])
}
DetailViewController
此畫面接收上一個畫面的資料並顯示在畫面上,直接貼上整個程式。
import UIKit@MainActor
class DetailViewController: UIViewController {
let movieItem: MovieItem
init?(coder: NSCoder, movieItem: MovieItem) {
self.movieItem = movieItem
super.init(coder: coder)
}required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var backdropItems = [BackdropItem]() @IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var dateLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!
@IBOutlet weak var posterImage: UIImageView! {
didSet {
posterImage.layer.cornerRadius = 32
posterImage.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
posterImage.clipsToBounds = true
}
}
@IBOutlet weak var backDropCollectionView: UICollectionView!
override func viewDidLoad() {
super.viewDidLoad() // Detail
Task {
do {
updateUI()
self.posterImage.image = try await MovieService.shared.fetchImage(from: movieItem.posterImage, in: .original)
} catch {
displayError(error, title: "Failed to fetch items")
}
}
// Backdrop
Task {
do {
let backgropResponse = try await MovieService.shared.fetchMovieBackdrops(movieID: "\(movieItem.id)")
backdropItems = backgropResponse.backdrops
backDropCollectionView.reloadData()
}catch {
displayError(error, title: "Failed to fetch backgrop")
}
}
}
func updateUI() {
titleLabel.text = movieItem.title
dateLabel.text = movieItem.releaseDate
descriptionLabel.text = movieItem.description
}}extension DetailViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
backdropItems.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(BackDropCollectionViewCell.self)", for: indexPath) as! BackDropCollectionViewCell
let item = backdropItems[indexPath.item]
Task {
do {
cell.backgropImage.image = try await MovieService.shared.fetchImage(from: item.path, in: .w300)
}catch {
displayError(error, title: "Fail to fetch image")
}
}
return cell
}
}extension DetailViewController {
func configureCellSize() {
let itemSpace: CGFloat = 2let flowLayout = backDropCollectionView.collectionViewLayout as? UICollectionViewFlowLayout
let height = backDropCollectionView.bounds.height
flowLayout?.itemSize = CGSize(width: height, height: height)
flowLayout?.estimatedItemSize = .zero
flowLayout?.minimumLineSpacing = itemSpace
flowLayout?.minimumInteritemSpacing = itemSpace
}
func displayError(_ error: Error, title: String) {
guard let _ = viewIfLoaded?.window else {return}
let alert = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil))
self.present(alert, animated: true, completion: nil)
}
}
增加cache大小
主要是發現抓取大圖時,會一直重複抓圖並閃跳,於是研究一下解法,但也同時定義顯示小圖就抓取小圖,不要浪費資源。
import UIKit@main
class AppDelegate: UIResponder, UIApplicationDelegate {func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let temporaryDirectory = NSTemporaryDirectory()
let urlCache = URLCache(memoryCapacity: 25_000_000, diskCapacity: 50_000_000, diskPath: temporaryDirectory)
URLCache.shared = urlCache
return true
}