Skip to content
  • Home
  • Code
  • iOS & Swift
  • Combine
  • RxSwift
  • SwiftUI
  • Flutter & Dart
  • Tutorials
  • Art
  • Blog
Fx Studio
  • Home
  • Code
  • iOS & Swift
  • Combine
  • RxSwift
  • SwiftUI
  • Flutter & Dart
  • Tutorials
  • Art
  • Blog
Written by chuotfx on March 20, 2020

Combine vs. MVVM – Request

Combine

Contents

  • Chuẩn bị
  • 1. Working with Model
    • 1.1. Request từ người dùng
    • 1.2. Gọi function của Model
    • 1.3. Phản hồi
  • 2. Core API
    • 2.1. Cấu trúc
    • 2.2. Struct API
    • 2.3. Define Error
    • 2.4. Connection
    • 2.5. Business Model
  • 3. Request
    • 3.1. Setup View & ViewModel
    • 3.2. Config Endpoint
    • 3.3. Completion request function
    • 3.4. Implement
  • 4. Parse Data
    • 4.1. Cấu hình dữ liệu
    • 4.2. Cấu hình Response
  • 5. Update UI
    • 5.1. Binding to View
    • 5.2. Cell for row
    • 5.3. Download image for cell
      • 5.3.1. Setup Cell & View
      • 5.3.2. Setup Downloader Model
      • 5.3.3. Update Cell
  • Tạm kết

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:

    • Combine vs. UIKit – Networking
    • Combine vs. UIKit – Fetching Data from API

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 Model
  • API.Request.swift xử lý request với việc sử dụng thư viện để connection
  • API.Downloader.swift phục vụ việc download, trong demo thì là download ảnh
  • API.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ình
    • tryMap để biến đổi response nhận được thành Data
    • eraseToAnyPublisher xoá dấu viết để lại
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
  • 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 ViewModel
  • musicsCancellable 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:

    • https://rss.itunes.apple.com/api/v1/us/apple-music/coming-soon/all/100/aexplicit.json

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ới error
  • Tiến hành sử dụng struct API để lấy được Publisher với url trên
  • Sử dụng các toán tử
    • decode để biến đổi data 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ểu
    • eraseToAnyPublisher 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 TableView reloadData & gọi reset
self.cellsSubscription.removeAll()
  • HomeViewModel
    • Xoá hết musicsCancellable trước khi gọi lại API để fetchData
 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:

    • https://github.com/fx-studio/combine_mvvm

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!

FacebookTweetPinYummlyLinkedInPrintEmailShares14
Tags: combine
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

Your email address will not be published. Required fields are marked *

Donate – Buy me a coffee!

Fan page

Fx Studio

Tags

Actor Advanced Swift AI api AppDistribution autolayout basic ios tutorial blog ci/cd closure collectionview combine concurrency crashlytics dart dart basic dart tour Declarative delegate deploy design pattern fabric fastlane firebase flavor flutter GCD gradients iOS MVVM optional Prompt engineering protocol Python rxswift safearea Swift Swift 5.5 SwiftData SwiftUI SwiftUI Notes tableview testing TravisCI unittest

Recent Posts

  • Role-playing vs. Persona-based Prompting
  • [Swift 6.2] Raw Identifiers – Đặt tên hàm có dấu cách, tại sao không?
  • Vibe Coding là gì?
  • Cách Đọc Sách Lập Trình Nhanh và Hiệu Quả Bằng GEN AI
  • Nỗ Lực – Hành Trình Kiến Tạo Ý Nghĩa Cuộc Sống
  • Ai Sẽ Là Người Fix Bug Khi AI Thống Trị Lập Trình?
  • Thời Đại Của “Dev Tay To” Đã Qua Chưa?
  • Prompt Engineering – Con Đường Để Trở Thành Một Nghề Nghiệp
  • Vấn đề Ảo Giác (hallucination) khi tương tác với Gen AI và cách khắc phục nó qua Prompt
  • Điều Gì Xảy Ra Nếu… Những Người Dệt Mã Trở Thành Những Người Bảo Vệ Cuối Cùng Của Sự Sáng Tạo?

Archives

  • 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)

About me

Education, Mini Game, Digital Art & Life of coders
Contacts:
contacts@fxstudio.dev

Fx Studio

  • Home
  • About me
  • Contact us
  • Mail
  • Privacy Policy
  • Donate
  • Sitemap

Categories

  • Art (1)
  • Blog (44)
  • Code (11)
  • Combine (22)
  • Flutter & Dart (24)
  • iOS & Swift (102)
  • No Category (1)
  • RxSwift (37)
  • SwiftUI (80)
  • Tutorials (87)

Newsletter

Stay up to date with our latest news and posts.
Loading

    Copyright © 2025 Fx Studio - All rights reserved.