Как мы можем реализовать Rxswift с шаблоном MVVM в Swift?

Требование: основы RxSwift

В этой статье мы узнаем, как мы можем использовать RxSwift в шаблоне MVVM.

Что будем строить?

Я создам приложение, которое извлекает изображения из API, а затем показывает их в collectionView. Я загрузил ресурсы на GitHub, вы можете получить ссылку в конце страницы. Давайте начнем.

Установите RxSwift с CocoaPods

В качестве первого шага мы должны установить RxSwift с Cocoapods. Я пропущу объяснение этого, надеюсь, вы уже это знаете :)

Я создал новый проект и настроил для нас весь пользовательский интерфейс, и вы можете увидеть структуру нашего проекта ниже:

Модель установки

Я буду использовать это API, чтобы получить случайные URL-адреса фотографий. В качестве первого шага я создам для этого файл модели. Давайте откроем файл PhotoListModel и вставим этот код:

struct PhotoListModel: Codable, Identifiable {
    let id: String?
    let updated_at: String?
    let urls: PhotoListItemModel?
}
struct PhotoListItemModel: Codable {
    var id: String?
    let small: String?
    let raw: String?
    let regular: String?
}

Настройка ViewModel

Итак, наш файл модели готов. Давайте откроем файл PhotosViewModelActions. Это будет протокол и сохранит функции, которые мы будем использовать в PhotosViewModel. Хорошо, скопируйте этот код и вставьте его:

import RxSwift
protocol PhotoListViewModelActions: AnyObject {
    func fetchImages(
        currentPage: Int
    )
    var photoList       : BehaviorRelay<[PhotoListModel]> { get }
    var imageDownloaded : PublishRelay<(Int, UIImage?)> { get }
}

Теперь создайте наш файл ViewModel и назовите его PhotoListViewModel и добавьте в него этот код:

class PhotoListViewModel: PhotoListViewModelActions {
    let imageLoaderService = StringImageLoader()
// MARK: - Variables
    private var currentPage  = BehaviorRelay(value: 1)
    private let photoService = PhotoService.shared
    private let disposeBag   = DisposeBag()
var photoList       = BehaviorRelay<[PhotoListModel]>(value: [])
    var imageDownloaded = PublishRelay<(Int, UIImage?)>()
init() {
        self.fetchImages(currentPage: self.currentPage.value)
    }
}

In здесь:
imageLoaderService— представляет наш класс загрузчика изображений
currentPage — сохраняет количество текущей страницы
photoService— получает все изображения с заданного URL-адреса API (если вы загружаете файл проекта, вы можете его увидеть)
disposeBag— удаляет все наблюдаемые переменныеles

Когда PhotoListModel будет инициализирована, будет вызвана функция fetchImages (вы можете вызывать эту функцию, когда хотите). Давайте создадим ее.

extension PhotoListViewModel {
    /// Loading images from given api
    func fetchImages(currentPage: Int) {
        self.photoService.getPhotos(currentPage: "\(currentPage)") {   [weak self] response in
switch response {
           case .failure(let e):
              print("error", e.localizedDescription)
              self?.photoList.accept([])
           case .success(let data):
              self?.photoList.accept(data)
           }
        }
    }
}

Настройка сети

Создайте протокол PhotoServiceActions, чтобы он сохранял сервисные функции:

protocol PhotoServiceActions: AnyObject {
    func getPhotos(currentPage: String,
        completion: @escaping (PhotoGetPhotosResponseType) -> Void)
}

Все готово, теперь создайте класс PhotoService и подтвердите его с помощью PhotoServiceActions:

class PhotoService: PhotoServiceActions, Request {
    private let dataLoader: DataLoader
    // MARK: Request parameters
    var host: String
    var path: String
    var queryItems: [URLQueryItem]
    var headers: [String : String]
    static let shared = PhotoService()
    // MARK: - Initialization
    init() {
        dataLoader = DataLoader()
        self.host = ApiURL.baseURL
        self.path = ""
        self.queryItems = []
        self.headers = [:]
        setupHeaders()
    }
    // MARK: Request header setup
    private func setupHeaders () {
        self.headers = ["Authorization": "Client-ID \(ApiURL.apiKey)"]
    }
}
extension PhotoService {
    /// gets images from api
    /// - Parameter completion: completion handler for function
    /// - Parameter currentPage: represent current page's count
    func getPhotos(currentPage: String,
       completion: @escaping (PhotoGetPhotosResponseType) -> Void) {
        
        self.path = "/photos"
        self.queryItems = []
        let perPage     = URLQueryItem(name: "per_page", value: "30")
        let currentPage = URLQueryItem(name: "page", value: currentPage)
        self.queryItems = [perPage, currentPage]
        dataLoader.request(self, decodable: [PhotoListModel].self) {   response in
completion(response)
        }
    }
}

Я передам epxlanation класса DataLoader, вы можете увидеть это внутри проекта.

Настройка просмотра

Теперь, когда переменная photoList изменится (в модели представления), мы будем слушать ее здесь. В настоящее время открыт просмотр класса и. настроить его

Добавьте эту переменную в заголовок кода:

private var viewModel: PhotoListViewModel!
private let disposeBag = DisposeBag()
private var cachedImages: [Int: UIImage] = [:]

viewModelпредставляет наш класс PhotoListViewModel
disposeBag— удаляет все наблюдаемые переменные
cachedImagesон сохранит загруженные изображения

Хорошо, давайте добавим источник данных CollectionView и делегируем методы с помощью RxSwift.

В качестве первого шага мы привяжем photoList к collectionView, который мы создали в файле PhotosViewModel.

private func bindCollectionView() {
    // MARK: Bind photoList to collectionView
    viewModel.photoList
       .filter({ !$0.isEmpty })
       .bind(to: collectionView.rx
          .items(cellIdentifier: PhotoCollectionViewCell.identifier,
              cellType: PhotoCollectionViewCell.self)) { _, _, _ in}
       .disposed(by: disposeBag)
}

Запустив приложение вы увидите, что мы успешно получили данные:

Как видите, мы получили URL-адреса изображений, я буду загружать изображения в методе willDisplayCell. Не забывая, мы также должны создать массив, который будет хранить наши загруженные изображения, чтобы не перезагружать их снова. Добавьте в него эту строку кодов:

collectionView.rx.willDisplayCell
    .observeOn(MainScheduler.instance)
    .map({ ($0.cell as! PhotoCollectionViewCell, $0.at.item) })
    .subscribe { [weak self] cell, indexPath in
        guard let self = self else {return}
        if let cachedImage = self.cachedImages[indexPath] {
            /// use image from cached images
            cell.imageView.image = cachedImage
        } else {
            /// start animation for download image
            cell.imageView.image = nil
            cell.activityIndicator.startAnimating()
            /// download image
            self.viewModel.loadImageFromGivenItem(with: indexPath)
        }
    }
    .disposed(by: disposeBag)

Давайте создадим функцию loadImageFromGivenItem внутри класса PhotoListViewModel:

var imageDownloaded = PublishRelay<(Int, UIImage?)>()
/// loading image from given string
func loadImageFromGivenItem(with index: Int) {
    let givenElementString = photoList.value[index].urls?.regular ?? ""
    imageLoaderService.loadRemoteImageFrom(urlString:     givenElementString) { [weak self] image in
        print("image downloaded: \(index): ", image?.description ?? "")
        self?.imageDownloaded.accept((index, image))
    }
}

Готово. Обращайтесь с ним внутри

/// bind loaded image to cell
private func bindImageLoader() {
    viewModel.imageDownloaded
    .observeOn(MainScheduler.instance)
    .filter({ $0.1 != nil })
    .map({ ($0.0, $0.1!) })
    .subscribe(onNext: { [unowned self] index, image in
        guard let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? PhotoCollectionViewCell 
        else      {
           return
        }
         cell.animateCellWithImage(cell, image)
         self.cachedImages[index] = image
    })
    .disposed(by: disposeBag)
}

Я добавил функцию animateCellWithImage внутри класса PhotoCollectionViewCell:

/// show image when image downloaded successfully
func animateCellWithImage(_ cell: PhotoCollectionViewCell, _ image: UIImage) {
    cell.activityIndicator.stopAnimating()
    cell.transform = CGAffineTransform(rotationAngle: 60)
    
    UIView.animate(withDuration: 0.5, delay: 0,  usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options:  .curveEaseInOut, animations: {
        cell.transform = .identity
    }, completion: nil)
    cell.imageView.image = image
}

Отлично сделано :) Теперь мы можем запустить наше приложение и увидеть, что результат будет таким, как показано ниже:

Хорошо, последнее, что я хочу изменить currentPage и загрузить больше фотографий, когда прокрутка закончится. Добавьте эту строку кода внутрь PhotosViewController:

// MARK: Trigger scroll view when ended
collectionView.rx.willDisplayCell
    .filter({
        let currentSection = $0.at.section
        let currentItem    = $0.at.row
        let allCellSection = self.collectionView.numberOfSections
        let numberOfItem   =         self.collectionView.numberOfItems(inSection: currentSection)
        return currentSection == allCellSection - 1
                         &&
               currentItem >= numberOfItem - 1
    })
    .map({ _ in () })
    .bind(to: viewModel.scrollEnded)
    .disposed(by: disposeBag)

с этой строкой кода сработает, когда на экране отобразится последняя ячейка:

return currentSection == allCellSection - 1 && currentItem >= numberOfItem - 1

И я активирую свою новую созданную переменную с именем scrollEnded в файле ViewModel и вызову ее функцию связывания при инициализации ViewModel. (Кроме того, я добавил эту переменную в протокол PhotoListViewModelActions)

var scrollEnded = PublishRelay<Void>()
func bindScrollEnded() {
    scrollEnded
        .subscribe { [weak self] _ in
            if let currentPage = self?.currentPage.value {
                self?.currentPage.accept(currentPage + 1)
                self?.fetchImages(currentPage: s       elf?.currentPage.value ?? 0)
            }
        }
       .disposed(by: disposeBag)
}

В качестве следующего шага вы можете остановить некоторые задачи загрузки изображений, потому что, когда мы прокручиваем вниз, верхняя часть изображений collectionView продолжает загружать изображения, но это не так.

Спасибо за прочтение. Вы также можете ознакомиться с другими моими статьями. Не забывайте улыбаться :)

Ресурсы:



Последние записи: