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
singletoncủ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.swiftnơi tập trung các khai báo, define, error và API ModelAPI.Request.swiftxử lý request với việc sử dụng thư viện để connectionAPI.Downloader.swiftphục vụ việc download, trong demo thì là download ảnhAPI.Music.swiftphầ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
extensioncủa API - Giải thích như sau:
- Sử dụng
dataTaskPublishercủa URLSession để tạo Publisher receiveởmaintránh block UI hay crash chương trìnhtryMapđể biến đổi response nhận được thànhDataeraseToAnyPublisherxoá 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
stringtươ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:
subscriptionsdành cho các subscription phát sinh trong ViewModelmusicsCancellablechỉ 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ớiurltrên - Sử dụng các toán tử
decodeđể biến đổidatanhận được thành kiểu dữ liệu làMusicResponse(sẽ trình bày ở dưới)mapErrornhắm đưa tất cả error phát sinh về cùng một kiểueraseToAnyPublisherxoá 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
}
}
resultschí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
cellSubscriptiontrước khi TableViewreloadData& gọireset
- Xoá hết
self.cellsSubscription.removeAll()
- HomeViewModel
- Xoá hết
musicsCancellabletrướ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
- Multi-Layer Prompt Architecture – Chìa khóa Xây dựng Hệ thống AI Phức tạp
- Khi “Prompt Template” Trở Thành Chiếc Hộp Pandora
- Vòng Lặp Ảo Giác
- Giàn Giáo Nhận Thức (Cognitive Scaffold) trong Prompt Engineering
- Bản Thể Học (Ontology) trong Prompt Engineering
- Hướng Dẫn Vibe Coding với Gemini CLI
- Prompt Bản Thể Học (Ontological Prompt) và Kiến Trúc Nhận Thức (Cognitive Architecture Prompt) trong AI
- Prompt for Coding – Code Translation Nâng Cao & Đối Phó Rủi Ro và Đảm Bảo Chất Lượng
- Tại sao cần các Chiến Lược Quản Lý Ngữ Cảnh khi tương tác với LLMs thông qua góc nhìn AI API
- Prompt for Coding – Code Translation với Kỹ thuật Exemplar Selection (k-NN)
Archives
- October 2025 (1)
- September 2025 (4)
- August 2025 (5)
- July 2025 (10)
- June 2025 (1)
- May 2025 (2)
- April 2025 (1)
- March 2025 (8)
- January 2025 (7)
- December 2024 (4)
- 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)

