برنامه نویسی

اجرای معماری VIP در یک برنامه iOS با Swift

در منطقه توسعه iOS ، کار با معماری های مختلف پروژه امکان پذیر است ، حتی برخی از آنها بیشتر از سایرین مورد استفاده قرار می گیرند ، تفاوت های حداقل درک می تواند به شما در درک بهتر معماری که امروز کار می کنید کمک کند.

چرا VIP ، حتی اگر به عنوان مثال از MVVM کلامی تر باشد؟

  • جدایی واضح از مسئولیت ها
  • بیشترین کد قابل آزمایش
  • ایده آل برای منطق پیچیده تجارت و پروژه های قوی
  • حتی اگر روزانه از آن استفاده نکنید ، درک کنید که VIP MVVM خود را بهبود می بخشد

بیایید برنامه ای را اجرا کنیم که صفحه ای از مقالات را نشان می دهد (تماس با API)

https%3A%2F%2Fdev to

در این پروژه ، من از View Code استفاده خواهم کرد ، اگر شما در این مورد تازه کار هستید ، در اینجا پیوندی از نحوه حذف صفحه داستانی از پروژه آورده شده است

شاید به دلیل نسخه Xcode او ، اما زمینه مرحله 4 برای من ظاهر نشده است ، در عوض ، من مجبور شدم زمینه دیگری را در Info.plist که نامگذاری آن را انجام می داد حذف کنم ، ممکن است برای شما نیز اتفاق بیفتد.

  • مدل سازی پاسخ API

با توجه به JSON برگشتی:

https%3A%2F%2Fdev to uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv6v3s9b8wkgh8petw5vb

مدل سریع ما اینگونه خواهد بود:

struct Article: Codable {
    let id: Int
    let title: String
    let description: String
    let readablePublishDate: String
    let url: String
    let coverImage: String?
    let tags: String
    let user: User

    enum CodingKeys: String, CodingKey {
        case id, title, description, url, tags, user
        case readablePublishDate = "readable_publish_date"
        case coverImage = "cover_image"
    }
}

struct User: Codable {
    let name: String
    let username: String
    let profileImage: String

    enum CodingKeys: String, CodingKey {
        case name, username
        case profileImage = "profile_image"
    }
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

  • ایجاد کارگر (سرویس)

این مسئول است:

  • بازتاب دادن
  • پاسخ ها را رمزگشایی کنید
  • ارتباط با تعامل
protocol ArticlesWorkerProtocol {
    func fetchArticles(completion: @escaping (Result<[Article], Error>) -> Void)
}

class ArticlesWorker: ArticlesWorkerProtocol {
    func fetchArticles(completion: @escaping (Result<[Article], Error>) -> Void) {
        guard let url = URL(string: "https://dev.to/api/articles") else {
            completion(.failure(NetworkError.invalidURL))
            return
        }

        URLSession.shared.dataTask(with: url) { data, response, error in

            if let error = error {
                completion(.failure(error))
                return
            }
            guard let data = data else {
                completion(.failure(NetworkError.noData))
                return
            }

            do {
                let articles = try JSONDecoder().decode([Article].self, from: data)
                completion(.success(articles))
            } catch {
                completion(.failure(error))
            }

        }.resume()
    }
}

enum NetworkError: Error {
    case invalidURL
    case noData
    case decodingError
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

  • اجرای یا تعامل

برای مدیریت قوانین تجاری استفاده می شود و همچنین می تواند یک یا چند اثر داشته باشد.
ما روشهایی را ایجاد خواهیم کرد که در معرض مشاهده کنترل کننده قرار بگیرند ، در اینجا یکی از این موارد برای بارگیری داده های مقالات و دیگری در هنگام انتخاب مقاله خواهد بود.

protocol ArticlesBusinessLogic {
    func fetchArticles()
    func didSelectArticle(id: Int)
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

علاوه بر کارگر ، مجری نیز وابستگی به تعامل دارد ، داده های حاصل از کارگر را دریافت می کند و برای انجام قالب بندی به مجری می رود ، من معمولاً این وابستگی ها را از طریق اولیه تزریق می کنم.

class ArticlesInteractor: ArticlesBusinessLogic {
    var presenter: ArticlesPresentationLogic?
    var worker: ArticlesWorkerProtocol?
    var router: ArticlesRoutingLogic?

    init(presenter: ArticlesPresentationLogic?, worker: ArticlesWorkerProtocol?) {
        self.presenter = presenter
        self.worker = worker
    }
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

با اجرای این روش ، ما کارگر را برای بازسازی فرا می خوانیم و پس از اتمام ، این یک عمل خوب است که حداقل دو روش در مجری داشته باشید ، یکی برای درمان و دیگری در صورت خطا. علاوه بر این ، من همچنین یک مورد بارگیری را برای یک تجربه ارگانیک تر اجرا کردم.

    func fetchArticles() {
        presenter?.presentLoading(true)
        worker?.fetchArticles { [weak self] result in
            switch result {
            case .success(let articles):
                self?.presenter?.presentArticles(articles: articles)
                self?.presenter?.presentLoading(false)
            case .failure(let error):
                self?.presenter?.presentError(error: error)
                self?.presenter?.presentLoading(false)
            }
        }
    }
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

برای عملکرد انتخاب مقاله ، ما به روتر می نامیم که مسئولیت کنترل پیمایش این جریان را بر عهده خواهد داشت. غیر معمول نیست که یک اجرای روتر در نمایش کنترلر انجام شود ، اما در تجربه من ، فکر کردن که این رفتارها را می توان به عنوان یک قانون تجاری نیز دانست ، منطقی است که تعامل نیز مسئول آن است.

func didSelectArticle(id: Int) {
        router?.routeToArticleDetail(id: id)
    }
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

  • دریافت و قالب بندی داده ها با مجری

برای کمک به قالب بندی ، می توانیم به پرونده Article.swift دسترسی پیدا کنیم و با یک مدل نمایش یک enum ایجاد کنیم ، در آن یک شی با توجه به داده های دریافت شده از تعامل ایجاد می کند.

enum Articles {
    struct FetchArticles {
        struct ViewModel {
            struct DisplayedArticle {
                let title: String
                let description: String
                let publishDate: String
                let imageUrl: String?
                let authorName: String
                let tags: String
            }

            let displayedArticles: [DisplayedArticle]
        }
    }
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

منزل مقالات presenter.swift، ما پروتکل مورد استفاده توسط تعامل را خواهیم داشت

protocol ArticlesPresentationLogic {
    func presentArticles(articles: [Article])
    func presentError(error: Error)
    func presentLoading(_ isLoading: Bool)
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

با اجرای مجری مقالات ، ما یک ویژگی از کنترلر مشاهده داریم ، جایی که داده های فرمت شده و آماده نمایش را منتقل خواهیم کرد

منابع ارائه دهنده برای نمایش همیشه ضعیف است تا از چرخه نگهداری جلوگیری شود.
از آنجا که این یک طرفه است ، معماری VIP برای حفظ یک مرجع قوی به تعامل ، نیاز به ViewController دارد ، تعاملی یک مرجع قوی به مجری حفظ می کند ، و اگر مجری مرجع محکمی را به نمای حفظ کند ، یک چرخه نگهدارنده حافظه که مانع از عدم استفاده صحیح قوس در قوس می شود.

ViewController (forte) → تعاملی (forte) → Present (fraca) → ViewController

// MARK: - Presenter

class ArticlesPresenter: ArticlesPresentationLogic {
    weak var viewController: ArticlesDisplayLogic?

    func presentLoading(_ isLoading: Bool) {
        viewController?.displayLoading(isLoading)
    }

    func presentArticles(articles: [Article]) {
        let displayedArticles = articles.map { article in
            Articles.FetchArticles.ViewModel.DisplayedArticle(
                id: article.id,
                title: article.title,
                description: article.description,
                publishDate: article.readablePublishDate,
                imageUrl: article.coverImage,
                authorName: article.user.name,
                tags: article.tags
            )
        }
        let viewModel = Articles.FetchArticles.ViewModel(displayedArticles: displayedArticles)
        viewController?.displayArticles(viewModel)
    }

    func presentError(error: Error) {
        viewController?.displayError(error.localizedDescription)
    }
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

در کنترلر کنترل ، ما تعامل را به عنوان وابستگی خواهیم داشت و با ما تماس خواهیم گرفت بار مشاهده روشی که داده ها را بازسازی و باز می گرداند در سلول ها نمایش داده می شود.

import UIKit

protocol ArticlesDisplayLogic: AnyObject {
    func displayArticles(_ articles: Articles.FetchArticles.ViewModel)
    func displayError(_ error: String)
    func displayLoading(_ isLoading: Bool)
    func displayArticleDetail(_ articleDetail: ArticleDetail)
}

class ArticlesViewController: UIViewController, ArticlesDisplayLogic {
    var interactor: ArticlesBusinessLogic?
    var router: (NSObjectProtocol & ArticlesRoutingLogic & ArticlesDataStore)?

    // MARK: - UI Components
    private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        layout.minimumLineSpacing = 0
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.backgroundColor = .clear
        collectionView.isPagingEnabled = true
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(ArticleCell.self, forCellWithReuseIdentifier: ArticleCell.identifier)
        collectionView.register(EmptyArticlesCell.self, forCellWithReuseIdentifier: EmptyArticlesCell.identifier)
        return collectionView
    }()

    private lazy var activityIndicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView(style: .large)
        indicator.center = view.center
        indicator.hidesWhenStopped = true
        indicator.translatesAutoresizingMaskIntoConstraints = false
        return indicator
    }()

    private lazy var pageControl: UIPageControl = {
        let pageControl = UIPageControl()
        pageControl.currentPageIndicatorTintColor = .persianBlue
        pageControl.pageIndicatorTintColor = .lightGray
        pageControl.translatesAutoresizingMaskIntoConstraints = false
        return pageControl
    }()

    // MARK: - Properties
    private var articles: Articles.FetchArticles.ViewModel?

    // MARK: - Initialization
    init(interactor: ArticlesBusinessLogic) {
        self.interactor = interactor
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - View Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupNavigationBar()
        setupViews()
        setupConstraints()
        loadArticles()
    }

    // MARK: - Setup Methods
    private func setupViews() {
        view.backgroundColor = .systemBackground
        view.addSubview(collectionView)
        view.addSubview(pageControl)
        view.addSubview(activityIndicator)
    }

    private func setupConstraints() {
        NSLayoutConstraint.activate([
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -240),

            pageControl.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 16),
            pageControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            pageControl.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),

            activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

    private func setupNavigationBar() {
        title = HomeStrings.articlesTitle
        navigationController?.navigationBar.prefersLargeTitles = false

        let appearance = UINavigationBarAppearance()
        appearance.configureWithOpaqueBackground()
        appearance.backgroundColor = .systemBackground
        appearance.titleTextAttributes = [.foregroundColor: UIColor.persianBlue]

        let backButton = UIBarButtonItem(image: UIImage(systemName: "xmark"),
                                        style: .plain,
                                        target: self,
                                        action: #selector(backButtonTapped))
        backButton.tintColor = .persianBlue
        navigationItem.leftBarButtonItem = backButton
        navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)

        navigationController?.navigationBar.standardAppearance = appearance
        navigationController?.navigationBar.scrollEdgeAppearance = appearance
        navigationController?.navigationBar.compactAppearance = appearance
    }

    // MARK: - Data Loading
    private func loadArticles() {
        DispatchQueue.main.async { [weak self] in
            self?.activityIndicator.startAnimating()
        }
        interactor?.fetchArticles()
    }

    // MARK: - ArticlesDisplayLogic
    func displayLoading(_ isLoading: Bool) {
        DispatchQueue.main.async { [weak self] in
            if isLoading {
                self?.activityIndicator.startAnimating()
                self?.collectionView.isHidden = true
                self?.pageControl.isHidden = true
                self?.collectionView.isHidden = true
            } else {
                self?.activityIndicator.stopAnimating()
                self?.collectionView.isHidden = false
                self?.pageControl.isHidden = false
            }
        }
    }

    private func displayArticlesEmpty() {
        DispatchQueue.main.async { [weak self] in
            self?.activityIndicator.stopAnimating()
            self?.articles = nil
            self?.collectionView.reloadData()
            self?.pageControl.isHidden = true
        }
    }

    private func displayArticlesList(_ articles: Articles.FetchArticles.ViewModel) {
        DispatchQueue.main.async { [weak self] in
            self?.activityIndicator.stopAnimating()
            self?.articles = articles
            self?.collectionView.reloadData()
            self?.pageControl.numberOfPages = articles.displayedArticles.count
        }
    }

    func displayArticles(_ articles: Articles.FetchArticles.ViewModel) {
        if articles.displayedArticles.isEmpty {
            displayArticlesEmpty()
        } else {
            displayArticlesList(articles)
        }
    }

    func displayError(_ error: String) {
        DispatchQueue.main.async { [weak self] in
            self?.activityIndicator.stopAnimating()
            let alert = UIAlertController(title: HomeStrings.errorMessage, message: error, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: HomeStrings.errorButton, style: .default))
            self?.present(alert, animated: true)
        }
    }

    func displayArticleDetail(_ articleDetail: ArticleDetail) {
        router?.routeToArticleDetail(id: articleDetail.id ?? 0)
    }

    @objc private func backButtonTapped() {
        navigationController?.popViewController(animated: true)
    }
}

// MARK: - UICollectionViewDataSource
extension ArticlesViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        guard let displayedArticles = articles?.displayedArticles else { return 1 }
        return displayedArticles.isEmpty ? 1 : displayedArticles.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let articles = articles else {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: EmptyArticlesCell.identifier, for: indexPath) as! EmptyArticlesCell
            cell.configureEmptyView()
            return cell
        }

        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ArticleCell.identifier, for: indexPath) as! ArticleCell
        cell.configure(with: articles.displayedArticles[indexPath.item])
        return cell
    }
}

// MARK: - UICollectionViewDelegateFlowLayout
extension ArticlesViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let pageNumber = round(scrollView.contentOffset.x / scrollView.frame.size.width)
        pageControl.currentPage = Int(pageNumber)
    }
}

extension ArticlesViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let article = articles?.displayedArticles[indexPath.item] else { return }
        interactor?.didSelectArticle(id: article.id)
    }
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

  • ایجاد یک ناوبری روتر

فکر کردن در مورد جدایی از مسئولیت هایی که VIP موعظه می کند ، بسیار متداول است که مرور با روتر انجام شود.

در این حالت ، ما می توانیم فکر کنیم که هر صحنه روتر خواهد داشت ، بنابراین صفحه ورود به سیستم دارای یک مقاله دیگر خواهد بود. و به عنوان نمونه ، ما یک روتر ایجاد خواهیم کرد که مسئولیت نمایش صفحه جزئیات مقاله را بر عهده خواهد داشت و این روتر از تعامل قابل دسترسی است.

import Foundation

protocol ArticlesRoutingLogic {
    func routeToArticleDetail(id: Int)
}

protocol ArticlesDataStore {}

// MARK: - Router

class ArticlesRouter: NSObject, ArticlesRoutingLogic, ArticlesDataStore {
    weak var viewController: ArticlesViewController?

    func routeToArticleDetail(id: Int) {
        let articleDetailViewController = ArticleDetailFactory.build(id: id)
        viewController?.navigationController?.pushViewController(articleDetailViewController, animated: true)
    }
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

کد سلول مشاهده مجموعه ، صفحه جزئیات و ورود به سیستم را می توان در GitHub من یافت

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا