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

در منطقه توسعه iOS ، کار با معماری های مختلف پروژه امکان پذیر است ، حتی برخی از آنها بیشتر از سایرین مورد استفاده قرار می گیرند ، تفاوت های حداقل درک می تواند به شما در درک بهتر معماری که امروز کار می کنید کمک کند.
چرا VIP ، حتی اگر به عنوان مثال از MVVM کلامی تر باشد؟
- جدایی واضح از مسئولیت ها
- بیشترین کد قابل آزمایش
- ایده آل برای منطق پیچیده تجارت و پروژه های قوی
- حتی اگر روزانه از آن استفاده نکنید ، درک کنید که VIP MVVM خود را بهبود می بخشد
بیایید برنامه ای را اجرا کنیم که صفحه ای از مقالات را نشان می دهد (تماس با API)
در این پروژه ، من از View Code استفاده خواهم کرد ، اگر شما در این مورد تازه کار هستید ، در اینجا پیوندی از نحوه حذف صفحه داستانی از پروژه آورده شده است
شاید به دلیل نسخه Xcode او ، اما زمینه مرحله 4 برای من ظاهر نشده است ، در عوض ، من مجبور شدم زمینه دیگری را در Info.plist که نامگذاری آن را انجام می داد حذف کنم ، ممکن است برای شما نیز اتفاق بیفتد.
- مدل سازی پاسخ API
با توجه به JSON برگشتی:
مدل سریع ما اینگونه خواهد بود:
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 من یافت