Contents
Chào bạn đến với Fx Studio!
Chúng ta lại tiếp tục series Combine với phần 3 : Mô hình MVVM. Ở 3 bài trước thì giải quyết xong các mối quan hệ giữa View & ViewModel. Và bài viết này sẽ nói tới mối quan hệ của ViewModel & Model.
Dành cho các bạn muốn ôn lại kiến thức hoặc chưa đọc qua 3 bài viết trước đó:
-
- Tổng quát : Combine vs. MVVM – Overview
- Quản lý & hiển thị dữ liệu : Combine vs. MVVM – Binding
- Xử lý sự kiện người dùng : Combine vs. MVVM – Actions
Nếu mọi việc đã ổn định thì …
Bắt đầu thôi!
Chuẩn bị
- Xcode 11.0
- Swift 5.1
- iOS 13.0
Chúng ta lại sử dụng project demo của bài trước (hay của cả 3 bài trước). Tuy nhiên, lần này chúng ta sẽ triển khai cho HomeViewController. Yêu cầu của màn hình Home như thế này:
- Có 1 UITableView
- Lấy thông tin từ API và phân tích dữ liệu nhận được
- Cập nhật dữ liệu nhận được lên UITableView
Giao diện của HomeViewController thì bạn thoải mái sáng tạo, mình chọn giao diện đơn giản thôi. Bạn tham khảo hình sau:
Còn đây là đoạn code HomeViewController ban đầu, với:
- Kế thừa lại BaseViewController
- Implement các function cho Combine
- Delegate & DataSource UITableView
import UIKit import Combine class HomeViewController: BaseViewController { // Outlets @IBOutlet weak var tableView: UITableView! //MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() } //MARK: - Config View //MARK: Setup override func setupData() { super.setupData() } override func setupUI() { super.setupUI() title = "Home" //tableview tableView.delegate = self tableView.dataSource = self let musicCellNib = UINib(nibName: "MusicCell", bundle: .main) tableView.register(musicCellNib, forCellReuseIdentifier: "MusicCell") // Navigation Bar let clearBarButton = UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(reset)) self.navigationItem.rightBarButtonItem = clearBarButton } //MARK: Binding override func bindingToView() { } override func bindingToViewModel() { } //MARK: Router override func router() { } //MARK: - Private functions @objc func reset() { } } //MARK: - UITableView Delegate extension HomeViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 10 } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 80 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "MusicCell", for: indexPath) as! MusicCell return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) } }
1. Working with Model
Chúng ta xem qua hình ảnh sau, để mô tả cho toàn mô hình MVVM với Combine. Hình này có bổ sung thêm phần tương tác với Model.
Đối với việc tương tác với Model nó bao gồm nhiều loại, tuỳ thuộc vào các loại Model sử dụng. Nhưng điển hình nhất đó chính là việc tương tác với API để lấy dữ liệu về và update lên giao diện.
Việc tương tác này được giới giang hồ gọi với cái tên là
request
.
Ta sẽ đi qua các bước của 1 request
hoạt động như thế nào trong mô hình MVVM với Combine.
1.1. Request từ người dùng
Tất nhiên, mọi request đều phải bắt nguồn từ người dùng, còn bản thân ứng dụng sẽ không tự động thực hiện được. Nó sẽ khác với các sự kiện người dùng đã được trình bày trong phần Actions. Trong trường hợp này:
Đó chính là
use case
.
Một chút định nghĩa nhau:
Use case là một kỹ thuật được dùng trong kỹ thuật phần mềm và hệ thống để nắm bắt yêu cầu chức năng của hệ thống. Use case mô tả sự tương tác đặc trưng giữa người dùng bên ngoài và hệ thống. Nó thể hiện ứng xử của hệ thống đối với bên ngoài, trong một hoàn cảnh nhất định, xét từ quan điểm của người sử dụng
Theo wikipedia
Chốt lại chúng ta sẽ tốn một loạt các function mới xử lý xong 1 pha gọi request. Và với 1 request bạn có thể dùng lại ở nhiều nơi bắt sự kiện người dùng hoặc sẽ là câu chuyện tiếp nối cho một request khác nào đó.
1.2. Gọi function của Model
Vẫn như bài trước, các sự kiện sẽ được quản lý tập trung ở action
, nên việc triển khai cũng sẽ bắt đầu từ ViewModel. Tất nhiên, lần này ViewModel sẽ không đích thân chinh chiến giải quyết ân oán nhau nữa. ViewModel sẽ triệu hồi Model để thay mình giải quyết vấn đề.
Bạn để ý trong hình mô tả ở trên, thì
ViewModel liên lạc trực tiếp với Model, còn Model lại không liên lạc trực tiếp với ViewModel.
Nghe qua hơi vô lý, nhưng đó là sự thật mà hiếm người ngộ ra được. Model sẽ phục vụ nhiều Controller hay View hay ViewModel nào đó. Chúng hầu như là các Data Manager, là nơi sẽ quản lý dữ liệu tập trung. Model sẽ nhận các request
tới mình để xử lý dữ liệu.
Đó là lý do mà nhiều Model có 1 đối tượng
singleton
của riêng mình.
Và khi xử lý xong thì … sang bước 3
1.3. Phản hồi
Model sẽ không phản hồi trực tiếp tới một ViewModel nào đó. Thay vì đó chúng sẽ thông báo tới 1 loạt các đối tượng đang cần dữ liệu của Model.
Nên nhiệm vụ của bạn sẽ viết các giao thức (interface) để xử lý các thông báo nhận được từ Model.
Đó là mô hình Key–value observing.
Tới đây, nhiều bạn sẽ cười vì như vậy đích thị là Combine rồi. Coi như sau bao bài luyện tập về Combine thì việc giải quyết phần khó nhất (là phản hồi) lúc này lại khá là đơn giản.
OKAY! Chúng ta sẽ đi vào bài thôi.
2. Core API
Như đã nói ở trên, điển hình nhất cho việc request
đó chính thực hiện việc tương tác với API để lấy dữ liệu về. Bạn có thể xem lại hai bài viết sau:
Phần này mình sẽ không giải thích về việc tương tác Networking với Combine nữa. Thay vì đó mình sẽ giới thiệu một Core API mới, dành cho việc tương tác với Networking bằng Combine trong mô hình MVVM mới.
2.1. Cấu trúc
Đầu tiên bạn xem qua cấu trúc file như sau:
Chức năng & nhiệm vụ của từng file như sau:
API.swift
nơi tập trung các khai báo, define, error và API ModelAPI.Request.swift
xử lý request với việc sử dụng thư viện để connectionAPI.Downloader.swift
phục vụ việc download, trong demo thì là download ảnhAPI.Music.swift
phần này là Business Model, nó sẽ thực hiện việc tương tác với các API liên quan tới Music. Tuỳ thuộc vào yêu cầu dự án mà bạn có thể có nhiều file thêm nữa.
2.2. Struct API
import Foundation import Combine struct API { //MARK: - Config struct Config { static let baseURL = "https://rss.itunes.apple.com/" } //MARK: - Logic API struct Downloader { } //MARK: - Business API struct Music { } }
Bao gồm các struct con, mỗi struct có nhiệm vụ riêng. Bạn sẽ thấy struct Music
nó sẽ tương ứng với file API.Music.swift
Muốn thêm gì thì bạn hãy tạo thêm nha.
2.3. Define Error
Thêm vào struct API
đoạn code sau:
//MARK: - Error enum APIError: Error { case error(String) case errorURL case invalidResponse case errorParsing case unknown var localizedDescription: String { switch self { case .error(let string): return string case .errorURL: return "URL String is error." case .invalidResponse: return "Invalid response" case .errorParsing: return "Failed parsing response from server" case .unknown: return "An unknown error occurred" } } }
Đó chính là Handling Error
đã được trình bày ở phần 2 với Networking. Và cũng như trên, bạn muốn gì thêm thì hãy tự do sáng tạo.
2.4. Connection
Đây là trái tim của toàn bộ phần Core API này. Bạn mở file API.Request.swift
và xem đoạn code sau:
- Đây là phần
extension
của API - Giải thích như sau:
- Sử dụng
dataTaskPublisher
của URLSession để tạo Publisher receive
ởmain
tránh block UI hay crash chương trìnhtryMap
để biến đổi response nhận được thànhData
eraseToAnyPublisher
xoá dấu viết để lại
- Sử dụng
import Foundation import Combine extension API { static func request(url: URL) -> AnyPublisher<Data, Error> { return URLSession.shared .dataTaskPublisher(for: url) .receive(on: DispatchQueue.main) .tryMap { data, response -> Data in guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw API.APIError.invalidResponse } return data }.eraseToAnyPublisher() } }
Vẫn là các class quen thuộc (URLSession
) và các đối tượng quen thuộc (dataTaskPublisher
) và các operators quen thuộc. Và nếu bạn quyết định sử dụng thêm 1 core khác cho việc connection thì bạn có thể thêm các function mới vào đây. Hoặc bạn muốn request với 1 urlString
hay request
với các tham số & cấu hình Header khác nhau … thì bạn cũng có thêm các function với vào đây.
2.5. Business Model
Ta lấy file API.Music.swift
và đưa ra một cấu trúc tiêu chuẩn cho các file Model liên quan tới yêu cầu của dự án. Bạn thêm đoạn code sau đây vào:
import Foundation import Combine extension API.Music { //MARK: - Endpoint enum EndPoint { //case case newMusisc(limit: Int) var url: URL? { // config here } } struct MusicResponse: Decodable { // ... json data } //MARK: - Domains static func getNewMusic(limit: Int = 10) -> AnyPublisher<MusicResponse, API.APIError> { } }
Trong đó:
- Endpoint
- Phần cấu hình có việc sinh ra các
string
tương ứng với mỗi link API - Tối ưu cho việc tái sử dụng lại nhiều lần
- Phần cấu hình có việc sinh ra các
- MusicResponse
- Struct này sẽ define dữ cấu trúc dữ liệu và tương ứng với cấu trúc JSON nhận được
static func getNewMusic -> AnyPublisher<MusicResponse, API.APIError> { ... }
- Bạn sẽ có nhiều function tương tự này
- Mỗi function sẽ tương ứng với việc tương tác với 1 link API
- Bạn phải chú ý tới giá trị trả về của function đó là Publisher. Vì chúng ta đang làm việc với Combine mà.
- Output là struct Response ở trên
- Failure là enum error được khai báo ở trên
3. Request
3.1. Setup View & ViewModel
Bắt đầu bằng việc setup cho ViewModel của HomeViewController. Bạn tạo 1 file với tên là HomeViewModel, tham khảo code & cấu trúc như ở 3 bài trước.
import Foundation import Combine final class HomeViewModel { //MARK: - Define // State enum State { case initial case fetched case error(message: String) case reloadCell(indexPath: IndexPath) } // Action enum Action { case fetchData case reset case downloadImage(indexPath: IndexPath) } //MARK: - Properties // Publisher & store @Published var musics: [Music] = [] @Published var isLoading: Bool = false // Actions let action = PassthroughSubject<Action, Never>() // State let state = CurrentValueSubject<State, Never>(.initial) // Subscriptions var subscriptions = [AnyCancellable]() var musicsCancellable = [AnyCancellable]() //MARK: - Init init() { // state state .sink { [weak self] state in self?.processState(state) }.store(in: &subscriptions) // action action .sink { [weak self] action in self?.processAction(action) }.store(in: &subscriptions) } //MARK: - Private functions // process Action private func processAction(_ action: Action) { switch action { case .fetchData: print("ViewModel -> Action: FetchData") fetchData() case .reset: print("ViewModel -> Action: Reset") musics = [] fetchData() case .downloadImage(let indexPath): print("ViewModel -> Action: Download at \(indexPath.row)") downloadImage(indexPath: indexPath) } } // process State private func processState(_ state: State) { switch state { case .initial: print("ViewModel -> State: initial") isLoading = false case .fetched: print("ViewModel -> State: fetched") isLoading = true case .error(let message): print("ViewModel -> State: error : \(message)") case .reloadCell(let indexPath): print("ViewModel -> State: reload cell : \(indexPath.row)") } } } //MARK: - TableView extension HomeViewModel { func numberOfRows(in section: Int) -> Int { musics.count } func musicCellViewModel(at indexPath: IndexPath) -> MusicCellViewModel { MusicCellViewModel(music: musics[indexPath.row]) } }
Vẫn là các phần quen thuộc của ViewModel trong mô hình MVVM mới. Bạn triển khai các phần State & Actions, các function xử lý chung. Trong đó:
- Action với các request
- fetchData
- reset
- downloader
- State với các trạng thái
- initial
- fetched
- error
- reloadCell
Lần này bạn sẽ phải dùng tới 2 subscriptions để lưu trữ 2 loại subscriber:
subscriptions
dành cho các subscription phát sinh trong ViewModelmusicsCancellable
chỉ dành riêng cho việc tương tác với API Music (có thể bạn thực hiện rất nhiều lần). Tách biệt vẫn là hay nhất.
var subscriptions = [AnyCancellable]() var musicsCancellable = [AnyCancellable]()
OKAY! tới đây bạn đã setup ổn cho ViewModel rồi. Chuyển sang HomeViewController và tạo đối tượng viewmodel
cho View.
var viewModel = HomeViewModel()
Tiếp tục với việc gọi fetchData
khi bắt đầu hiển thị HomeViewController, tại function setupData
bạn thêm đoạn code sau:
override func setupData() { super.setupData() //fetchData self.viewModel.action.send(.fetchData) }
Ở đây, chúng ta chỉ cần dùng action
của ViewModel phát đi giá trị tương ứng và ViewModel sẽ tự động thực hiện.
3.2. Config Endpoint
Bạn mở fle API.Music.swift
và tiến hành config phần Endpoint này.
extension API.Music { //MARK: - Endpoint enum EndPoint { //case case newMusisc(limit: Int) var url: URL? { switch self { case .newMusisc(let limit): let urlString = API.Config.baseURL + "api/v1/us/apple-music/coming-soon/all/\(limit)/explicit.json" return URL(string: urlString) } } } //.... }
Mình lại sử dụng link API này:
Nếu bạn muốn thêm 1 link API nữa thì chỉ cần thêm 1 case
mới và cấu hình tương tự thôi.
3.3. Completion request function
static func getNewMusic(limit: Int = 10) -> AnyPublisher<MusicResponse, API.APIError> { guard let url = EndPoint.newMusisc(limit: limit).url else { return Fail(error: API.APIError.errorURL).eraseToAnyPublisher() } return API.request(url: url) .decode(type: MusicResponse.self, decoder: JSONDecoder()) .mapError { error -> API.APIError in switch error { case is URLError: return .errorURL case is DecodingError: return .errorParsing default: return error as? API.APIError ?? .unknown } } .eraseToAnyPublisher() }
Tiếp tục hoàn thiện function dành cho request API (đã được đề cập ở trên). Phần này mình trình bày ở bài Fetching Data , bạn có thể xem lại nếu vẫn còn chưa hiểu. Còn giải thích đơn giản như sau:
- Kiểm tra việc tạo đối tượng
url
. Nếu có lỗi thì return vớierror
- Tiến hành sử dụng
struct API
để lấy được Publisher vớiurl
trên - Sử dụng các toán tử
decode
để biến đổidata
nhận được thành kiểu dữ liệu làMusicResponse
(sẽ trình bày ở dưới)mapError
nhắm đưa tất cả error phát sinh về cùng một kiểueraseToAnyPublisher
xoá hết dấu vết
3.4. Implement
Mở file HomeViewModel, tiến hành gọi Model thực hiện tương tác. Bạn trỏ tới function fetchData
và thêm đoạn code sau vào:
func fetchData() { musicsCancellable = [] API.Music.getNewMusic(limit: 100) .map(\.feed.results) .replaceError(with: []) .assign(to: \.musics, on: self) .store(in: &musicsCancellable) }
Vẫn là các hình ảnh quen thuộc với việc subscribe
một Publisher và sử dụng map
để lấy phần dữ liệu array cho musics
của ViewModel. Sau đó, sử dụng assign
để đưa dữ liệu trực tiếp tới thuộc tính music
.
4. Parse Data
Bạn xem hình sau mô tả về cấu trúc JSON nhận được từ link API kia.
4.1. Cấu hình dữ liệu
Tạo một file với tên là Music.swift
. Tiến hành define các class sau:
import Foundation import Combine import UIKit class Music: Decodable { var name: String var id: String var artistName: String var artworkUrl100: String var thumbnailImage: UIImage? private enum CodingKeys: String, CodingKey { case name case id case artistName case artworkUrl100 } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) self.id = try container.decode(String.self, forKey: .id) self.artworkUrl100 = try container.decode(String.self, forKey: .artworkUrl100) self.artistName = try container.decode(String.self, forKey: .artistName) } }
Lần này không sử dụng Codable
mà sử dụng Decoder
. Vì bạn chỉ cần decode 1 chiều thôi. Chú ý với khai báo là class
để dành cho việc lưu trữ các properties . Và có sự tham chiếu dữ liệu lẫn nhau ở nhiều đối tượng.
Bạn xem phần CodingKeys
, chính là các key trong cấu trúc JSON nhận được. Công việc còn lại là map
chúng tương ứng với các property của bạn mà thôi.
4.2. Cấu hình Response
Bạn xem hình trên, phần được đánh dấu màu đỏ. Và bạn sẽ tạo các struct tương ứng như vậy. Bạn mở file API.Musict.swift
, edit lại phần MusicResponse như sau:
struct MusicResponse: Decodable { var feed: MusicResults struct MusicResults: Decodable { var results: [Music] var updated: String } }
results
chính là Array dữ liệu cần có TableView- Các thuộc tính khác muốn lấy thì bạn sẽ phải khai báo thêm
Bạn có thể build project và xem chúng hoạt động chưa, bằng việc print
tại subscription các Publisher. Nếu mọi việc đã ổn định thì bạn phải đưa dữ liệu lên View. Phần này Combine sẽ đảm nhiệm.
5. Update UI
Khi mọi dữ liệu đã sẵn sàng thì công việc còn lại sẽ đơn giản thôi.
5.1. Binding to View
Với mô hình MVVM thì ViewModel sẽ ánh xạ những gì thuộc về View. Với 1 UITableView thì sẽ có 1 array tương ứng cho nó. Tại file HomeViewController, tới function bindingToView
, tiến hành subscribe
thuộc tính musics
của ViewModel.
override func bindingToView() { // musics viewModel.$musics .debounce(for: .seconds(0.1), scheduler: DispatchQueue.main) .sink(receiveValue: { _ in print("binding table : \(self.viewModel.numberOfRows(in: 1))") self.tableView.reloadData() }) .store(in: &subscriptions) }
Lưu ý ở đây, chúng ta sử dụng toán tử debounce
để delay việc nhận dữ liệu lại. Do sử dụng @Published
thay cho Subject
nên dữ liệu sẽ được assign
lên thuộc tính ở didChangedObject
. Mà sink
ở HomeViewModel sẽ thực thi ở willChangeObject
. (cái này là lỗi của Apple)
5.2. Cell for row
Giờ sang phần binding dữ liệu lên Cell. Bạn mở file HomeCell và tiến hành thêm đoạn code sau vào:
import UIKit import Combine class MusicCell: UITableViewCell { //MARK: - Properties // Outlets @IBOutlet weak var thumbnailImageView: UIImageView! @IBOutlet weak var nameLabel: UILabel! @IBOutlet weak var artistNameLabel: UILabel! // ViewModel var viewModel: MusicCellViewModel? { didSet { self.bindingToView() } } //MARK: - Lifecycle override func awakeFromNib() { super.awakeFromNib() } func bindingToView() { self.nameLabel.text = viewModel?.music.name self.artistNameLabel.text = viewModel?.music.artistName if let image = viewModel?.music.thumbnailImage { self.thumbnailImageView.image = image } else { self.thumbnailImageView.image = nil // download image for cell } } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) } }
Dữ liệu sẽ được update cho cell tại didSet
của đối tượng cell viewmodel
. Và với HomeCellViewModel như sau:
- Class này chúng ta chưa cần phải Combine hoá làm gì
import Foundation import Combine final class MusicCellViewModel { var music: Music init(music: Music) { self.music = music } }
Cuối cùng, đổ dữ liệu lên cell tại delegate
của UITableView. Bạn update lại các function delegate đó như sau:
extension HomeViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { viewModel.numberOfRows(in: section) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 80 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "MusicCell", for: indexPath) as! MusicCell let vm = viewModel.musicCellViewModel(at: indexPath) cell.viewModel = vm return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) } }
Tiến hành build project và xem kết quả.
Dữ liệu của bạn đã hiển thị lên TableView rồi. Còn một công việc cuối cùng đó là hiển thị ảnh. Việc này lại rắc rối hơn bạn suy nghĩ nhiều đó.
5.3. Download image for cell
Lưu ý như sau:
Phần này hiện tại mình không khuyến khích bạn sử dụng. Nó được dùng để mô tả cho 1 action được Combine hoá trong 1 Custom View nào đó. Mình chưa hoàn thiện chúng.
Sau khi đã đọc lưu ý, nếu bạn cảm thấy không thích sử dụng Combine thì có thể sử dụng các cách trước đây hoặc sử dụng thư viện cho việc download ảnh.
5.3.1. Setup Cell & View
Còn nếu muốn thử thì bắt đầu tại HomeCell, bạn thêm 1 Publisher sau:
var downloadPublisher = PassthroughSubject<Void, Never>()
Publisher này phụ trách việc phát đi 1 tín hiệu, để ViewController biết bạn đang muốn download một ảnh cho cell đó. Tiếp tục, edit lại function bindingToView
của HomeCell.
func bindingToView() { self.nameLabel.text = viewModel?.music.name self.artistNameLabel.text = viewModel?.music.artistName if let image = viewModel?.music.thumbnailImage { self.thumbnailImageView.image = image } else { self.thumbnailImageView.image = nil // publisher DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { self.downloadPublisher.send() }) } }
Mình sử dụng DispatchQueue và delay để thực hiện việc phát đi tín hiệu khi dữ liệu của CellViewModel không có ảnh.
Chuyển sang file HomeViewController, nới sẽ tiến hành tiếp nhận các tín hiện từ Cell. Trước tiên bạn cần phải setup thêm một số thứ:
- Tạo nơi lưu trữ cho các subscription tới cell
- Nó là kiểu Dictionary
- Vì ứng với 1 index thì sẽ có 1 subscription tương ứng
var cellsSubscription: [IndexPath : AnyCancellable] = [:]
- Cài đặt function lưu trữ các cell subscription này
- Tiến hành kiểm tra các key
- Subscribe với
sink
- Lưu trữ chúng
func storeCellsCancellable(indexPath: IndexPath, cell: MusicCell) { if !cellsSubscription.keys.contains(indexPath) { print("Cell subcriber total: \(cellsSubscription.count)") let cancellable = cell.downloadPublisher .debounce(for: 0.1, scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.viewModel.action.send(.downloadImage(indexPath: indexPath)) } cellsSubscription[indexPath] = cancellable } }
5.3.2. Setup Downloader Model
Khi nhận được tín hiệu để download ảnh thì sẽ gọi ViewModel thực hiện download. Bạn mở file HomeViewModel và tiến hành cài đặt function cho việc download ảnh.
func downloadImage(indexPath: IndexPath) { if indexPath.row < musics.count { let item = musics[indexPath.row] API.Downloader.image(urlString: item.artworkUrl100) .replaceError(with: nil) .sink(receiveValue: { image in item.thumbnailImage = image self.state.send(.reloadCell(indexPath: indexPath)) }) .store(in: &musicsCancellable) }
Với API.Downloader
thì bạn biến tấu lại API.Music
với kiểu dữ liệu trả về là UIImage
. Bạn tham khảo code sau cho nó:
import Foundation import Combine import UIKit extension API.Downloader { static func image(urlString: String) -> AnyPublisher<UIImage?, API.APIError> { guard let url = URL(string: urlString) else { return Fail(error: API.APIError.errorURL).eraseToAnyPublisher() } return API.request(url: url) .map { UIImage(data: $0) } .mapError { $0 as? API.APIError ?? .unknown } .eraseToAnyPublisher() } }
Khi ViewModel gọi download ảnh và lúc nhận được dữ liệu trả về thì tiến hành thay đổi state
. Để báo cho View cập nhật lại giao diện cho Cell.
5.3.3. Update Cell
Tại function bindingToView
của HomeViewController, bạn tiến hành subscribe
thêm cho trạng thái reloadCell
của ViewModel.
override func bindingToView() { // musics viewModel.$musics .debounce(for: .seconds(0.1), scheduler: DispatchQueue.main) .sink(receiveValue: { _ in print("binding table : \(self.viewModel.numberOfRows(in: 1))") self.tableView.reloadData() }) .store(in: &subscriptions) // cell viewModel.state .sink { [weak self] state in if case .reloadCell(let indexPath) = state { self?.tableView.reloadRows(at: [indexPath], with: .fade) } } .store(in: &subscriptions) }
Cuối cùng tại cellForRow
của delegate TableView, bạn thêm việc gọi lưu trữ các cell & cell subscription cho việc download image.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "MusicCell", for: indexPath) as! MusicCell let vm = viewModel.musicCellViewModel(at: indexPath) cell.viewModel = vm self.storeCellsCancellable(indexPath: indexPath, cell: cell) return cell }
Build project và test thử có download được ảnh không?
Mọi thứ đã hoàn hảo rồi, nhưng bạn cần nên kiểm tra các biến lưu trữ subscripton ở cả View & ViewModel đã được giải phóng chưa. Vì đây là nguyên nhân sẽ gây bugs cũng như tốn bộ nhớ cho code của bạn
- HomeViewController
- Xoá hết
cellSubscription
trước khi TableViewreloadData
& gọireset
- Xoá hết
self.cellsSubscription.removeAll()
- HomeViewModel
- Xoá hết
musicsCancellable
trước khi gọi lại API đểfetchData
- Xoá hết
musicsCancellable = []
OKAY! Mọi thứ đã ổn và mình xin hết thúc bài viết này ở đây. Với câu chuyện tương tác với Model thông qua việc giải quyết vấn đề request
thì cũng là bài cuối trong Phần 3 này. Bạn hãy đón chờ xem các phần tiếp theo của series cũng như những series mới từ Fx Studio.
Bạn có thể checkout project demo tại đây:
Tạm kết
- Quá trình hoạt động và tương tác của một request
- Cách làm việc với Model trong mô hình MVVM sử dụng Combine
- Tạo Core API (đơn giản) để tương tác với API
- Parse & update dữ liệu lên giao diện
- Xử lý download ảnh ở Cell
Nếu bạn thấy bài viết này hay và hưu ích, thì hãy share cho nhiều người cùng đọc. Nếu bạn muốn đóng góp hoặc góp ý cho mình, thì hãy để lại comment hoặc email hoặc theo contact của website.
Cảm ơn bạn đã đọc bài viết này!
Written by chuotfx
Hãy ngồi xuống, uống miếng bánh và ăn miếng trà. Chúng ta cùng nhau đàm đạo về đời, về code nhóe!
Leave a Reply Cancel reply
Fan page
Tags
Recent Posts
- Prompt Engineering trong 10 phút
- Một số ví dụ sử dụng Prompt cơ bản khi làm việc với AI
- Prompt trong 10 phút
- Charles Proxy – Phần 1 : Giới thiệu, cài đặt và cấu hình
- Complete Concurrency với Swift 6
- 300 Bài code thiếu nhi bằng Python – Ebook
- Builder Pattern trong 10 phút
- Observer Pattern trong 10 phút
- Memento Pattern trong 10 phút
- Strategy Pattern trong 10 phút
Archives
- December 2024 (3)
- September 2024 (1)
- July 2024 (1)
- June 2024 (1)
- May 2024 (4)
- April 2024 (2)
- March 2024 (5)
- January 2024 (4)
- February 2023 (1)
- January 2023 (2)
- November 2022 (2)
- October 2022 (1)
- September 2022 (5)
- August 2022 (6)
- July 2022 (7)
- June 2022 (8)
- May 2022 (5)
- April 2022 (1)
- March 2022 (3)
- February 2022 (5)
- January 2022 (4)
- December 2021 (6)
- November 2021 (8)
- October 2021 (8)
- September 2021 (8)
- August 2021 (8)
- July 2021 (9)
- June 2021 (8)
- May 2021 (7)
- April 2021 (11)
- March 2021 (12)
- February 2021 (3)
- January 2021 (3)
- December 2020 (3)
- November 2020 (9)
- October 2020 (7)
- September 2020 (17)
- August 2020 (1)
- July 2020 (3)
- June 2020 (1)
- May 2020 (2)
- April 2020 (3)
- March 2020 (20)
- February 2020 (5)
- January 2020 (2)
- December 2019 (12)
- November 2019 (12)
- October 2019 (19)
- September 2019 (17)
- August 2019 (10)