#26 使用 async & await 串接 TMDB API

練習要點

  • 使用 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 做一個全域變數

  1. 將所有抓取資料的程式統一寫在 MovieService的class裡
  2. 使用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 = 2
let 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
}

成果展示

--

--