SNPArchitecture is an iOS app architecture created for iOS apps in Snapp!. This architecture can be used in iOS apps with a large number of Scenes and developers. It is an amalgamation of different iOS app architectures such as VIPER, Uber RIBs, MVVM and Clean Swift based on the principles of Uncle Bobs’s Clean Architecture.
SNPArchitecture provides:
-
An scalable architecture. This architecture has been proven to scale to tens of engineers working on the same codebase and apps with 50+ Scenes already in production.
-
Testability using protocol-oriented design. It enables dependency injection, which in turn enables testing each class in isolation since every dependency is automatically mocked with easy to provide stubs using SwiftyMocky.
-
Code generation using Xcode templates. It comes with Xcode templates for quick code generation. Developers only provide a name for the Scene and almost all boilerplate code is automatically generated.
An app is divided into modules based on screen/feature, which we’ll call a Scene
. A scene is comprised of these classes:
View
Interactor
Presenter
Router
Configurator
View handles view logic, Interactor handles business logic, Presenter handles presentation logic, Router handles routing logic and Configurator will act as the builder of the Scene. We’ll discuss all of them shortly.
Keep in mind that all the interaction between classes in a Scene happens through protocols and none of them are tightly coupled.
Unlike Apple’s MVC, View in SNPArchitecture is both View and View Controller objects. Since View and View Controller are tightly coupled in UIKit, it doesn’t make much sense to separate them. Because View Controller handles View’s lifecycle, it is nearly impossible to write decoupled View and View Controllers, i.e. if you change one of them, you must change the other.
View handles all the view logic, it knows how to show stuff on the screen and relays user’s interactions to Interactor. In order to better separate View and View Controller responsibilities, everything purely view-related stuff (such as setting static labels or colors) goes in UIView objects and lifecycle-related stuff goes into UIViewController objects.
As you can see in the diagram above, View directly notifies the Interactor of user interactions. Methods would be named like fooButtonTapped()
.
On the other hand, Presenter calls methods like show(viewModel:)
on the View. Notice how Presenter passes a View Model
to the view, which is a simple data structure comprised of primitive types such as Int
and String
. This is important because view cannot and must not perform any logic on the received View Model.
Interactor contains all the business logic of a Scene. It knows how to ask for data, which Scene to show next and everything related to our business.
Interactor doesn’t talk to the View directly, instead, it asks the Presenter to present some stuff on the View with methods named like present(businessModel:)
. Notice how it passes a Business Model
to Presenter, and Presenter itself translates this possibly complex data structure to a simple View Model. Remember, View Models are comprised of only primitive types that can be directly consumed by the view.
On the other hand, it listens to user interactions coming from the View, such as fooButtonTapped()
.
When a new Scene needs to be shown, it calls a method on the Router. It will be discussed more in the Router section.
Presenter is the class responsible for translating complex business models to simple View Models. A good example of this would be converting a Date
object to a human-readable String
.
We’ve already talked about input and output interfaces of Presenter in View and Interactor sections, but here’s a recap: Presenter listens to method calls from Interactor such as present(businessModel:)
. It converts the incoming Business Model to a View Model understandable by the View, then calls show(viewModel:)
on the View to complete the cycle.
Configurator acts as the builder for creating a scene. It has a static build(dependency1:dependency2:...)
method which initializes a Scene, provides each class its dependencies and returns the View, which is a subclass of UIViewController.
Router is responsible for routing logic as it knows which Scene to build by calling its Configurator. It creates the next Scene and attaches its View to the View hierarchy. Interactor calls methods such as navigateToScene(presentingViewController:)
on the Router, Router builds the next Scene using its Configurator by calling SceneConfigurator.build()
and attaching the resulting View to the View hierarchy. Remember that the Router itself doesn’t decide which Scene to build, but merely knows how to build one. Routing decisions are made by the Interactor.
In an app, there are some classes that don’t belong in Scenes: Model Managers. We will call them Manager
for simplicity.
Each Manager is responsible for providing objects of a specific Data Model. Fetching it from the network, optionally caching the result and providing it to anyone who asks for it later. Manager’s methods will take the form of getModel(parameters:completion:)
and call a completion handler when data is ready or an error has occurred. Most of the completion handlers will have only an Error?
input, since providing data models will be done by a dispatch system.
Since we are developing SNPArchitecture for an eventful and stateful app, traditional delegation and callback patterns won’t accommodate our needs. Consider the state of the Ride
model. Every change in the ride’s state results in changes all over the appl, so it’s better to use the notification pattern. Since iOS’s own NotificationCenter is a barebone framework, we’ll use SwiftNotificationCenter which provides a simple API to post and observe notifications in a protocol-friendly manner.
To better understand SNPArchitecture, let’s develop the "About Snapp!" scene in the Passenger app together.
Here is the screenshot of the About Scene:
In this Scene, we want to fetch some text from the network service and navigate to the Snapp!’s Terms and Conditions web page if the user taps on the button.
The AboutView
is as dummy as possible and knows nothing about other components of the app. Let’s dive in.
All outlets from the storyboard go here:
class AboutView: UIView {
@IBOutlet var termsAndConditionsButton: UIButton!
@IBOutlet var aboutLabel: UILabel!
// ...
}
In the awakeFromNib()
we set fonts and colors of AboutView
outlets. We also set an indicator to show and and hide the text label when data is being fetched over network:
extension AboutView {
override func awakeFromNib() {
super.awakeFromNib()
// Set fonts
// Set static texts
}
}
All ViewController
s in SNPArchitecture inherit from SNPViewController
. In SNPViewController
we autmatically call lifecycle methods on Interactor with the power of protocol extensions in Swift. This means we need to have a separate pointer that casts the reference to the Interactor to the correct type and returns it:
class AboutViewController: SNPViewController {
var aboutInteractor: AboutInteractorProtocol! {
get {
return interactor as? AboutInteractorProtocol
}
set {
interactor = newValue
}
}
private var aboutView: AboutView {
return view as! AboutView
}
}
Setup initial works in viewDidLoad()
:
extension AboutViewController {
override func viewDidLoad() {
// Other setup stuff
aboutView.termsAndConditionsButton.addTarget(self, action: #selector(termsAndConditionsButtonTapped), for: .touchUpInside)
// Could be directly sent to Interactor, but we prefer this for customizability
}
}
Here AboutViewController
tells AboutInteractor
that user has tapped the button:
extension AboutViewController {
@objc func termsAndConditionsButtonTapped() {
aboutInteractor.termsAndConditionsButtonTapped()
}
}
And whenever the corresponding data is fetched, Presenter calls the show
method on View Controller:
extension AboutViewController: AboutViewControllerProtocol {
func show(aboutText: String) {
aboutView.aboutLabel.text = aboutText
}
}
keeps a reference to AboutPresenter
and AboutRouter
:
class AboutInteractor: SNPInteractor {
var presenter: AboutPresenterProtocol!
var router: AboutRouterProtocol!
}
AboutInteractor
decides to navigate to the terms page when the user taps on the button. It tells AboutRouter
to do that:
extension AboutInteractor: AboutInteractorProtocol {
@objc func termsAndConditionsButtonTapped() {
router.navigateToTermsAndConditionsWebpage()
}
}
AboutInteractor
asks for data from AboutManager
and passes it on to AboutPresenter
.
extension AboutInteractor {
func viewDidLoad() {
// AboutInteractor has its own AboutManager object as a dependency
self.aboutManager.fetchAbout(completion: { [weak self] aboutText in
self?.presenter.present(aboutText: aboutText)
})
}
}
AboutManager
knows how to get requested data using SNPNetwork
:
private class AboutManager {
private struct URL {
static let about = ServerManager.shared.baseAPIPrefix + "about"
}
func fetchAbout(completion: @escaping (String) -> Void) {
SNPNetwork.shared.request(url: URL.about, encoding: JSONEncoding.default, responseKey: "data.text") { about, error in
// Check for errors
completion(about ?? "")
}
}
}
Keeps a reference to the AboutViewController
. As you see the reference is marked weak to avoid reference cycles:
class AboutPresenter: SNPPresenter {
weak var viewController: AboutViewControllerProtocol!
}
AboutPresenter
’s job is to format a given data model from the AboutInteractor
as a viewModel then present it to the AboutViewController
. As we don’t have any complex data model here, we just simply pass it to the AboutViewController
.
extension AboutPresenter: AboutPresenterProtocol {
func handle(error: SNPError?) {
viewController.showErrorDialog(errorViewModel: error?.viewModel)
}
func present(aboutText: String) {
// Optionally convert data to a view-comprehensible type
viewController.show(aboutText: aboutText)
}
}
Typically we need UIViewController
to navigate so we store a reference to it.
class AboutRouter: SNPRouter {
weak var viewControllerProtocol: AboutViewControllerProtocol!
}
In the About Scene we just need to create a Web View Controller when the user tapped on the Terms and Conditions button:
extension AboutRouter: AboutRouterProtocol {
func navigateToTermsAndConditions() {
if let url = URL(string: "http://snapp.ir/terms") {
let webViewContoller = SNPWebViewController(url: url)
let presentingViewController = viewControllerProtocol.viewController as? AboutViewController
presentingViewController.present(webViewContoller, animated: true, completion: nil)
}
}
}
The AboutConfigurator
’s job is to build the scene by initializing AboutViewController
from storyboard and hook up all the scene components respectively.
class AboutConfigurator: SNPConfigurator {
class func build(aboutManager: AboutManager) -> (AboutViewController, AboutInteractor) {
let viewController: AboutViewController = UIStoryboard.loadViewController()
// Dependency injection
let interactor = AboutInteractor(aboutManager: AboutManager)
let presenter = AboutPresenter()
let router = AboutRouter()
// Since we have a cyclic dependnecy graph, we have to use property dependency injection as opposed to initializer dependency injection
viewController.interactor = interactor
interactor.presenter = presenter
interactor.router = router
presenter.viewController = viewController
router.viewControllerProtocol = viewController
return (viewController, interactor)
}
}
In this article you saw the flow of SNPArchitecture in a simple Scene. The AboutViewController
passes user interactions to AboutInteractor
which asks for needed data and passes it to AboutPresenter
which formats and prepares it to be shown by AboutViewController
and thus closing the cycle.
First add SNPScene pod to your project:
pod 'SNPScene', :git => 'https://github.com/behdaad/SNPScene.git'
To install SNPArchitecture Xcode templates, first clone this repository, then cd
to its directory and run:
make install_templates
To uninstall the SNPArchitecture Xcode templates, run:
make uninstall_templates
You can also create git hooks to automatically install the new version when you commit your changes or merge others’ changes.
- Create a file named
post-merge
under.git/hooks
and fill it with:
#!/bin/sh
make install_templates
- Add execution permissions to
post-merge
using:
chmod +x post-merge
- Then add a
post-commit
hook too. It will be the same aspost-merge
. This can be simply done using:
cp post-merge post-commit
Voilà! Every time you pull this repo, templates will be automatically installed in your Xcode templates folder.