Views là phần quan trọng nhất của bất kì ứng dụng IOS nào. Mỗi kiến trúc chứa một view layer. View đại diện cho ứng dụng của bạn đến với người dùng. Họ không quan tâm bạn xử lý Network Request, lưu trữ data trong database hoặc bất kì sự tính toàn khác như thế nào. Người dùng chỉ muốn ứng dụng của họ đẹp, đơn giản, và không cái bất kì lỗi nào.

Thi thoảng thì nó có thể khó để manage view updates. Đặc biệt là khi bạn có một UI phức tạp cùng nhiều subviews, và chắc chắn khi đó thì bugs có thể xuất hiện. Một số view có thể cập nhật sai cách.

Tiếp cập SwiftUI

Một vài ngày trước, tôi đã tìm thấy một bài viết hay link, viết bởi Paul Hudson. Anh ấy đã giải thích cách bạn có thể chuyển đổi view states dưới sự giúp đỡ của enum trong SwiftUI. SwiftUI là một UI Framework mạnh mẽ, nhưng nhiều ứng dụng viết bằng UIKit và phải hỗ trợ IOS 13-14. SwiftUI có một số vấn đề và lỗi trong IOS versions đó. Tôi đã quyết định ứng dụng cách này cho UIKit.

Trước khi bắt đầu

Cho bài viết, tôi đã chuẩn bị một project nhỏ. Nó đơn giản và chỉ có 1 màn hình cùng các yêu cầu giả định để mô tả các trạng thái của ứng dụng trong thực tế. Để tiết kiệm thời gian cho bạn, tôi đã chuẩn bị để một version của project chúng ta sẽ làm việc. Trong Github Repository, bạn sẽ tìm StartingVersion folder cùng một project bên trong

Bây giờ project chúng ta sẽ làm theo cấu trúc:

structure

Chúng ta tiếp tục build một app lấy data tự tạo về movies, MovieViewController có 3 state: loading data, data loadederror occurred.

Cho mỗi state, tôi đã tạo một view và để chúng bên trong Componets folder. Ngoài ra, tôi không thích trộn lẫn các trách nhiệm giữa View và ViewController vì thế tôi tạo một MovieView class để đại diện cho MovieViewController view.

MovieModel file chứa một Movie struct. Nó là một struct mà chúng ta sẽ lấy và sử dụng dọc theo tiến trình hoàn thành một view state. Nó có một thuộc tính static mock là fakedata để sử dụng.

Tạo một state

Chúng ta sử dụng một enum như một view state, vì thế bắt đầu tạo nó. Bên trong Model folder tạo MovieViewState file. Bên trong file đó, tạo một ennum cùng tên tương tự. Đây là enum cần một case cho mỗi view state và view của chúng ta gồm có 3 state.


enum MovieViewState {
   case success
   case loading
   case error
}

swift

Nhưng chúng tôi cũng cần để truyền đối tượng Movie cùng với .success state, vì thế thêm giá trị có liên quan đến nó.


enum MovieViewState {
   case success(Movie)
   case loading
   case error
}

swift

Bạn cũng có thể thêm giá trị có liên quan đến .error case nếu bạn muốn để có 1 UI khác nhau cho mỗi lỗi khác nhau. Chúng ta sẽ chỉ có 1 error state không có bất kì trường hợp đặc biệt nào.

Handle view update

Khi bắt đầu khởi động nó sẽ mất thời gian để set up view của bạn để xử lý trạng thái đó. Mở MovieView file, MovieView class có 3 subviews bên trong. Một subview đại diện cho một state. Toàn bộ các view đều được căn chỉnh ra giữa màn hình (centerede by Y axis). Nó sẽ bị ẩn nếu bạn cố gắng chạy app và bạn sẽ không thấy gì cả.

View cần một hàm để cập nhật và nhận MovieViewState là một tham số, vì thế ta sẽ tạo nó:


func updateState(_ state: MovieViewState) { }

swift

Bây giờ là lúc set up UI bên trong chức năng này. Bắt đầu cùng .loading state. Trong trường hợp này thì toàn bộ view ngoại trừ loadingView sẽ bị ẩn và chúng ta cần bắt đầu loading cùng animation bằng cách gọi đến loadingView.startAnimating(true).


switch state {
    case .loading:
     loadingView.startAnimating(true)
     loadingView.isHidden = false
     errorView.isHidden = true
     movieView.isHidden = true
    case .success(let movie):
     // TODO: - Handle success state
    case .error:
    // TODO: - Handle error state
}

swift

Tiếp tục với .success state. Trong trường hợp này chúng ta cần ẩn mọi thứ trừ movieView, tạm dừng loadingView và set Moive model trong movieView sử dụng movieView.set(movie)


switch state {
    case .loading:
     loadingView.startAnimating(true)
     loadingView.isHidden = false
     errorView.isHidden = true
     movieView.isHidden = true
    case .success(let movie):
     loadingView.startAnimating(false)
     loadingView.isHidden = true
     errorView.isHidden = true
     movieView.isHidden = false
     movieView.set(movie)
    case .error:
    // TODO: - Handle error state
}

swift

Dành cho .error state chúng ta cần tạm dừng loading animation à ẩn mọi thứ ngoại trừ errorView


switch state {
    case .loading:
     loadingView.startAnimating(true)
     loadingView.isHidden = false
     errorView.isHidden = true
     movieView.isHidden = true
    case .success(let movie):
     loadingView.startAnimating(false)
     loadingView.isHidden = true
     errorView.isHidden = true
     movieView.isHidden = false
     movieView.set(movie)
    case .error:
     loadingView.startAnimating(true)
     loadingView.isHidden = true
     errorView.isHidden = false
     movieView.isHidden = true
}

swift

Chúng ta đã hoàn thành xong. Bây giờ sẽ thiết lập một số mock request để kiểm tra nó.

Testing

Để thiết lập mock requests chúng ta cần làm một số thay đổi để MovieViewController và MoviePresenter.

Thêm fetchState() function vào MoviePresenter và MoviePresenterProtocol. Bây giờ chúng ta cần một hàm để truyền data đến view. Thêm didUpdateState(_ state: MovieViewState) trong MovieViewController và MovieViewProtocol. Bây giờ chúng ta có 2 hàm trống và cần lấp đầy chúng.

Trong fetchState function chúng ta sẽ mô phỏng yêu cầu call API. Để làm điều đó chúng ta thêm 2 giây delay sử dụng DispatchQueue.main.asyncAfter(deadline: ) bên trong ta sẽ thực hiện random state và truyền nó đến view thông qua didUpdateState(_ state: ) view function. Trước khi delay ta sẽ truyền .loading state đến view. Chúng ta sẽ thấy nó loading đầu tiên và sau 2 giây sẽ success hoặc error. Để làm điều đó ta thực hiện Int.random():

 func fetchState() {
     view?.didUpdateState(.loading)
     DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
       let state: MovieViewState = Int.random(in: 0...100) % 2 == 0 ? .error : .success(Movie.mock)
       self.view?.didUpdateState(state)
     }
 }
swift

Đã xong, bây giờ ta sẽ thêm một vài dòng code trong MovieViewController.

Thêm một Timer đến MovieViewController cùng với sự giúp đỡ của nó ta sẽ kích hoạt được hàm fetchState(). Tạo startTime() function bên trong chúng ta cần khởi tạo bộ đếm thời gian mà chúng ta tạo trước đó. Tạo thời gian là 4 giây. Thêm một selector chúng ta gọi đến presenter.fetchState() và set repeat là false. Chúng ta không muốn timer lặp đi lặp lại bởi vì chúng ta delay trong presenter. Để laoji bỏ vấn đề chúng ta sẽ bắt đầu timer trong viewDidLoad()didUpdateState(_ state: ) function.

MovieViewController code như dưới:

class MovieViewController: UIViewController {

    var presenter: MoviePresenterProtocol!
    
    private var timer: Timer!
    
    var rootView: MovieView {
      view as! MovieView
    }
    
    override func loadView() {
      super.loadView()
      view = MovieView()
    }
    
    override func viewDidLoad() {
      super.viewDidLoad()
      startTimer()
    }
    
    func startTimer() {
      timer?.invalidate()
      timer = Timer.scheduledTimer(timeInterval: 4, target: self, selector: #selector(fetchState), userInfo: nil, repeats: false)
    }
    
    @objc func fetchState() {
      presenter.fetchState()
    }
}

extension MovieViewController: MovieViewProtocol {

    func didUpdateState(_ state: MovieViewState) {
      rootView.updateState(state)
      startTimer()
    }

}

swift

Kết luận

Sự tiếp cận này giúp chúng ta loại bỏ các lỗi và tạo debugging dễ dàng. Nếu xuất hiện lỗi cùng view của bạn thì bạn chỉ cần kiểm tra 1 chức năng và bạn sẽ tìm ra vấn đề.

Cảm ơn đã đọc bài. Tôi hy vọng bài viết sẽ giúp được gì đó cho mọi người. Nếu có vấn đề gì vui lòng comment xuống bên dưới ạ.

Tham khảo khóa học Lập trình di động iOS Swift từ căn bản - TẠI ĐÂY