Sử dụng Closures, Generics, POP và Protocols Associated Types để viết API networking layer [Phần 1]
This post hasn't been updated for 3 years
Là một lập trình viên iOS, với hấù hết các dự án, bạn đều phải thực hiện công việc lấy data từ server và hiển thị lên app. Mặc dù có khá nhiều thư việc xử lý networking để bạn tích hợp vào ứng dụng, chẳng hạn phố biến như Alamofire. Tuy nhiên bài viết này tập trung đi sâu vào việc sử dụng networking sẵn có mà Apple cung cấp cho chúng ta, là URLSession class để xử lý các tác vụ liên quan đến networking. Bài viết này sẽ bạn hiểu được các thư viện networking hoạt động như thế nào, đồng thời cũng chỉ cho bạn biết cách tái sư dụng API cho xử lý lớp networking, phối hợp cùng với các tool khác mà Swift cung cấp. Thêm nữa là giải thích cả cách closure làm việc, và tại sao cần phải sử dụng đến các phương thức xử lý bất đồng bộ.
Chúng ta sẽ tạo một app nhỏ, lấy dữ liệu từ Itunes Movie API, hiển thị danh sách các bộ phim lên một tableview. Chúng ta sẽ làm ứng dụng này từng bước một, đi lướt qua các khái niệm quan trọng trong Swift. Trước khi bắt đầu, tôi liệt kê dưới đây một vài khái niệm giúp bạn nhé...
- Protocol Oriented Programming
- MVVM
- Closures
- Generics
- Asynchronous calls
- Parsing JSON with nested dictionaries
- Reference cycles
- JSON
- URLSession
- Handling network errors in Swift
- PAT’s aka Protocols Associated Types.
Trước hết chúng ta clone repo này, như bạn đã xem trong project, tôi ko hề sử dụng Storyboards mà hoàn toàn xử lý mọi layout trong code, bạn có thể xem chúng trong file Movie.swift, bao gồm 2 structs cho model, và một file để xử lý cell. Giờ thì chạy thử project nhé.
Bạn sẽ thấy tableview với chỉ có một cell với dữ liệu lấy từ hàm dummyMovie bên trong controller MovieFeedVC. Hãy tập trung vào hàm cellforRowAtIndexPath nhé.
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellID) as! MovieCell
let movie = moviesArray[indexPath.row]
cell.movieTitleLabel.text = movie.title.uppercased()
cell.dateLabel.text = "Release date: \(movie.releaseDate)"
cell.priceLabel.text = "\(movie.purchasePrice.amount) \(movie.purchasePrice.currency)"
return cell
}
Như bạn đã thấy, chúng ta mới chỉ hiển thị data từ movie object trong UI của cell, bạn có thể thấy là tôi đang thực hiện một vài thay đổi thứ tự hiển thị data theo một cách "formatted", vấn đề ở đây là, model và view đang bị tightly coupled ( liên kết chặt chẽ). Cách tiếp cận này thực sự gây khó khăn khi thực hiện testing, và đang phụ thuộc vào lượng lớn dữ liệu mà bạn muốn hiển thị, viewController khả năng sẽ phình to ra. Chúng ta sử dụng View model để phân chia mỗi liên hệ giưã model, view và controller. Trước hết, bắt đầu khởi tạo file MovieViewModel, nó sẽ là struct.
struct MovieViewModel {
let title: String
let imageURL: String
let releaseDate: String
let purchasePrice: String
let summary: String
init(model: Movie) {
self.title = model.title.uppercased()
self.imageURL = model.imageURL
self.releaseDate = "Relase date: \(model.releaseDate)"
if let doublePurchasePrice = Double(model.purchasePrice.amount) {
self.purchasePrice = String(format: "%.02f %@", doublePurchasePrice, model.purchasePrice.currency)
} else {
self.purchasePrice = "Not available for Purchase"
}
self.summary = model.summary == "" ? "No data provided" : model.summary
}
}
Struct này bao gồm một vài property của Movie object. Nhưng tại sao chúng ta lại tạo ra các new object? Thế này, chúng ta tạo ra định dạng hiển thị bằng cách sử dụng computed properties trong model (bằng cách trả về định dạng string), nhưng đó không phải là việc của model. Khi sử dụng View Model, bạn sẽ phải làm là pass một model data và data này sẽ được xử lý state cho hiển thị bằng cách đưa ra một thể hiện model. Chúng ta sẽ sửa lỗi này bằng cách tạo ra một method trong MovieCell, chứa một argument là view model và sẽ hiển thị nó, như ví dụ code này:
//1 This goes in MovieCell class
func displayMovieInCell(using viewModel: MovieViewModel) {
movieTitleLabel.text = viewModel.title
dateLabel.text = viewModel.releaseDate
priceLabel.text = viewModel.purchasePrice
}
//2 This goes in MovieFeedVC
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellID) as! MovieCell
let movie = moviesArray[indexPath.row]
let movieViewModel = MovieViewModel(model: movie)
cell.displayMovieInCell(using: movieViewModel)
return cell
}
Tiếp theo, không sử dụng dummy data nữa, chúng ta sẽ lấy dữ liệu thực từ server, bắt đầu tạo một file tên là JSONDownloader, trong đó dùng struct để tạo một object xử lý call network.
struct JSONDownloader {
//1)
let session: URLSession
init(configuration: URLSessionConfiguration) {
self.session = URLSession(configuration: configuration)
}
init() {
self.init(configuration: .default)
}
typealias JSON = [String: AnyObject]
typealias JSONTaskCompletionHandler = (Result<JSON>) -> ()
//4)
func jsonTask(with request: URLRequest, completionHandler completion: @escaping JSONTaskCompletionHandler) -> URLSessionDataTask {
//5)
let task = session.dataTask(with: request) { (data, response, error) in
guard let httpResponse = response as? HTTPURLResponse else {
completion(.Error(.requestFailed))
return
}
if httpResponse.statusCode == 200 {
if let data = data {
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: AnyObject] {
DispatchQueue.main.async {
completion(.Success(json))
}
}
} catch {
completion(.Error(.jsonConversionFailure))
}
} else {
completion(.Error(.invalidData))
}
} else {
completion(.Error(.responseUnsuccessful))
print("\(error)")
}
}
return task
}
}
//2)
enum Result<T> {
case Success(T)
case Error(ItunesApiError)
}
//3)
enum ItunesApiError: Error {
case requestFailed
case jsonConversionFailure
case invalidData
case responseUnsuccessful
case invalidURL
case jsonParsingFailure
}
Có vẻ quá nhiều code ở đoạn này. Tôi tạm chia đoạn code trên thành 5 phần, và chúng ta sẽ xem xét qua từng phần một.
Đầu tiên [1], tạo một property URLSession và cấu hình mặc dịnh cho object này khi mà JSONDownloader được khởi tạo. Ở dòng tiếp theo, chúng ta sử dụng typealias để improve readability. Typealias thứ nhất là JSON, kiểu dictionary [String : AnyObject] và typealias thứ hai là completion handler, như là một parameter kiểu enum, gọi là Result, tạm hiểu là typealias kiểu JSON type.
Ở phần [2], tôi khai báo một enum, đặt là Result, nhiệm vụ để handle network response. Nó là kiểu generic constraint, đặt là T, và có 2 case, một là xử lý success với associated value kiểu T, còn lại là cho xử lý error, với kiểu assiociated value, kiểu ItunesApiError.
Ở phần [3], tôi khai báo một enum, đặt là ItuneApiError, nó sẽ đáp ứng một Error protocol, bao gồm một danh sách các case của error.
Còn phần [4], tôi khai báo một hàm xử lý bất đồng bộ, với parameter bao gồm một URLRequest, và một completion handler. Hàm này trả về một URLSessionDataTask Khi tạo hàm bất đồng bộ, tại sao chúng ta cần phải chấp nhận một closure như là một completion handler cho xử lý logic mà chúng ta muốn thực thi sau khi hàm này kết thúc (hay hoàn thành)? Đơn giản là chúng ta không thể trả về value từ hàm bất đồng bộ, bởi vì là chúng ta không thể biết khi nào hàm trả về. Vì vậy, chúng ta phải đảm bảo rằng khi mã bất đồng bộ được hoàn thành, chúng ta không thể mong đợi xử lý logic theo thứ tự thông thường, đồng thời completion handler phải được đánh dấu bởi @escaping kể từ khi chúng được thực thi sau khi hàm enclosing được thực hiện. Chúng ta sẽ nói về closure và làm thế nào để tránh reference cycles.
Tiếp theo, chúng ta nói về phần [5], phần xử lý error trong Swift. Swift có thể xử lý đồng thời một cách automatic hoặc manual thông báo lỗi trong khi Objective-C không thể. Với Swift, chúng ta xử lý error một cách automatic bằng cách "ném" ra các error, nhưng đáng tiếc là không thể áp dụng cách này trong hàm bất đồng bộ, và phải kiểm tra error theo cách tương tự như trong Objective-C, tức là phải check kết quả trước, nếu nil, check error.
Đây là lý do vì sao, ở phần [5], chúng ta nên sử dụng guard để check response trước, sau đó sẽ check đến status code của response. Nếu response trả về mã 200, chúng ta check tiếp data, sau đó sử dụng catch một logic JSONSerialization, nếu nó thành công, chúng ta sử dụng completion với case .Sucess và pass JSON này như là parameter. Chú ý là, bởi vì chúng ta đang call bất đồng bộ nên là phải run ở một thread khác.
Với các error phát sinh, chúng ta sử dụng case .Error và pass error này qua case ItunesApiError như là parameter. Cuối cùng thì chúng ta trả về task. Bây giờ, chúng ta có object JSONDownloader để tạo ra API, nhưng trước hết cần phải xem dữ liệu trong iTunes API, như trong link này https://itunes.apple.com/us/rss/topmovies/limit=25/json Chúng ta cần phải parse data này, mở file Movie và thêm extension
extension Movie {
struct Key {
static let titleDict = "im:name"
static let imageURLArray = "im:image"
static let releaseDateDict = "im:releaseDate"
static let categoryDict = "category"
static let rentalPriceDict = "im:rentalPrice"
static let purchacePriceDict = "im:price"
static let itunesLinkArray = "link"
static let summaryDict = "summary"
static let label = "label"
static let attributes = "attributes"
static let amount = "amount"
static let currency = "currency"
static let href = "href"
static let term = "term"
}
//failable initializer
init?(json: [String: AnyObject]) {
guard let titleDict = json[Key.titleDict] as? [String: AnyObject],
let title = titleDict[Key.label] as? String,
let imageURLArray = json[Key.imageURLArray] as? [[String: AnyObject]],
let imageURL = imageURLArray.last?[Key.label] as? String,
let releaseDateDict = json[Key.releaseDateDict] as? [String: AnyObject],
let releaseDateAttributes = releaseDateDict[Key.attributes],
let releaseDate = releaseDateAttributes[Key.label] as? String,
let purchasePriceDict = json[Key.purchacePriceDict] as? [String: AnyObject],
let purchasePriceAttributes = purchasePriceDict[Key.attributes] as? [String: AnyObject],
let priceAmount = purchasePriceAttributes[Key.amount] as? String,
let priceCurrency = purchasePriceAttributes[Key.currency] as? String
else {
return nil
}
self.title = title
self.imageURL = imageURL
self.releaseDate = releaseDate
self.purchasePrice = Price(amount: priceAmount, currency: priceCurrency)
if let summaryDict = json[Key.summaryDict] as? [String: AnyObject], let summary = summaryDict[Key.label] as? String {
self.summary = summary
} else {
self.summary = ""
}
}
}
Extension này có struct được đặt tên là Key, nó giữ tất cả key của JSON data. Tôi cũng đã tạo sẵn một khởi tạo failable để ngăn chặn app bị crash trong trường hợp object bị nil. Cuối cùng, tôi thực hiện truy cập từng key của nested dictionary sử dụng guard và gắn value cho các property. Bạn thử chạy app xem sao, không có gì thay đổi cả? Tất nhiên, vì chúng ta chưa gọi hàm khởi tạo. Để thực hiện điều này, chúng ta tạo file mới MovieService.
//2 conforming the protocol
struct MovieService: Gettable {
//3
let endpoint: String = "https://itunes.apple.com/us/rss/topmovies/limit=25/json"
let downloader = JSONDownloader()
//the associated type is inferred by <[Movie?]>
typealias CurrentWeatherCompletionHandler = (Result<[Movie?]>) -> ()
//4 protocol required function
func get(completion: @escaping CurrentWeatherCompletionHandler) {
guard let url = URL(string: self.endpoint) else {
completion(.Error(.invalidURL))
return
}
//5 using the JSONDownloader function
let request = URLRequest(url: url)
let task = downloader.jsonTask(with: request) { (result) in
DispatchQueue.main.async {
switch result {
case .Error(let error):
completion(.Error(error))
return
case .Success(let json):
//6 parsing the Json response
guard let movieJSONFeed = json["feed"] as? [String: AnyObject], let entryArray = movieJSONFeed["entry"] as? [[String: AnyObject]] else {
completion(.Error(.jsonParsingFailure))
return
}
//7 maping the array and create Movie objects
let movieArray = entryArray.map{Movie(json: $0)}
completion(.Success(movieArray))
}
}
}
task.resume()
}
}
//1 using associatedType in protocol
protocol Gettable {
associatedtype T
func get(completion: @escaping (Result<T>) -> Void)
}
Khá là nhiều code ở phần này, chúng ta sẽ phân tích từng thứ một. Trong phần 1, có thể có chút phức tạp để hiểu nó. Đừng quá lo lắng, nếu bạn chưa hiểu về protocol với associated type, và hiểu về cách sử dụng kiểu protocol như thế này (cũng có nhiều iOS dev không hiểu cái này lắm). Như bạn đã biết, trong swift, type có thể tạo generic, nhưng nếu bạn muốn tạo một protocol kiểu generic thì sao? Chúng ta nghĩ rằng protcol hoàn toàn có thể là generic, bởi vì bất kỳ type nào cũng có thể đáp ứng được protocol. Thế còn bên trong protocol thì sao? chúng ta có thể define protocol nơi mà yêu cầu bản thân nó là một generic? Hoàn toàn có thể, nhưng chúng ta không thể làm tương tự như cách với function, đó là lý do vì sao Swift giới thiệu Associated type cho protocol để giúp chúng ta tạo ra một thực thi bên trong một protocol kiểu generic, tạm gọi là Procotol Associated Type (PAT). Gettable protocol có một associated type kiểu T, và function chứa argument là một closure chấp thuận một enum Resul, constrain với generic kiểu T và hàm này trả về kiểu void.
Trong phần [2], chúng ta taọ ra MovieService để conform một protocol. Ở phần [3], chúng ta có một cặp propertie, môt cho endpoint, một cho object JSONDownloader. Sử dụng typealias cho closure, closure này chứa argument là Result kiểu enum, constrainted với một array của Movies kiểu optional và hàm này trả về kiểu void. Trình biên dịch sẽ tự suy ra associated type là một mảng các optional movice, tức là generic tự suy ra type. Trong phần [4], chúng ta bắt buộc phải thực thi function get of Gettable protocol, trước hết phải sử dụng guard để check URL. Trong phần [5], chúng ta tạo một request kiểu constant và một task (cũng kiểu constant). Chúng ta sử dụng phương thức jsontask của instance JSONDownloader mà nó trả về một URLSessionDataTask, bên trong thực thi của nó, chúng ta check result (là enum kiểu Result), và sử dụng switch case để xử lý từng case một.
(Continue...)
All Rights Reserved