VISCS architecture for SwiftUI project
Why do we need one more architecture? The main reason is to have some that aggregates practices from other solutions that we already have, make it work for SwiftUI in enterprise applications and create a list of rules and conventions of how to use it that will cover most cases we could encounter.
Many examples we find on the internet just operate with a simple HelloWorld
-like application. Let's be honest, every architecture works great with a simple app. If you have just one screen in your application, you can use any form of architecture you can imagine and it will work.
However, when your application begins to grow, that’s when you will start to feel the consequences of your choice. So, let’s take a look at what we want to see from the architecture perspective in the enterprise application:
- We want to keep a maximum possible code coverage (Unit and UI tests)
- We want to split the app into some Components
- Components shouldn’t be coupled to each other
- The architecture should allow us to painlessly add Components and change it
- We want to use Dependency Injection
- We want to avoid using global objects, singletons and other similar practices as much as possible
- We want to follow
SOLID
principles
I will not explain what SwiftUI is, how it works and how it affects the architecture level; you can find this description elsewhere. The only thing that I want to mention is that SwiftUI
a declarative state-driven framework, so we have a State
and we have a View
that reacts to any State
update and redraws the UI respectively. We also need to have someplace to operate with business logic for this View
; let's have an Interactor
for it.
Now we have the VIS
part covered from our VISCS architecture. We can call it Component
. Let's take a look at our Component
and describe a list of rules it should follow.
Components
Component
is a separate instance of the functionality in our application- The simple component can have only one single view inside
For some screens that don't have business logic (Error Message View, Success View, Information View, etc.) it doesn't make any sense to make things overly complex (we are not making VIPER). This simple view will have it's own @State property wrappers and, for example, can have a Completion Block closure for letting the outside world know that something happened
3. All other components should have a View
, State
and Interactor
4. The State
and Interactor
objects should be injected into the View
from outside
5. Interactor
should have all dependencies it needs already injected
6. Interactor
and all its dependencies should be covered by protocols (we want the app to be testable). But don't take it too far. It doesn't mean that if Interactor
needs some simple Service (VISCS) that doesn't keep any state inside, we need to keep this dependency through the whole app. We can simply inject it like this:
7. Only Interactor
can update State
8. View
tells Interactor
about its events and reacts to State
updates
Here you can see the simple Component
Features
As you can see above, View
is getting all dependencies from the outside. We also need to have some object that will be responsible for cross-components communication and navigation. Let's call it Coordinator
(VISCS). It's not the same coordinator that we had in MVVM + Coordinators
architecture. It's similar, but a bit different. The main difference is we want to keep SwiftUI
native navigation (I hope Apple will improve it and fix all these bugs it has soon), so our Coordinator is not responsible for the type of navigation. it's a View
's responsibility, but only Coordinator
can provide a new View
that should be opened next.
View
in SwiftUI
is a value type, so in our case, the Coordinator
is kind of a view factory because it can't keep a reference to the View
(it's a value type). It can just create the View
, inject all dependencies View
needs and also keep some of these dependencies.
We already have a Component
and now we have a Coordinator
on top of one or several components; together let's call it Feature
.
Feature
should have only one singleCoordinator
Feature
should have one or moreComponent
Feature
can have sub-features- If
Feature
has sub-features, it means that featuresCoordinator
is a holder of sub-features coordinators and responsible for its creation and injecting all dependencies they need
You can find the Feature
example below
Coordinator
It’s time to talk more about Coordinators
.
Our application should have one RootCoordinator
that will be the entry point to our UI and business logic for the application. Basically, this RootCoordinator
(the entry point to our applications feature tree) is a part of our main RootFeature
.
RootFeature
has its own component (we follow the rules that have been described before). This component is just a container for the app.
This is how the SceneDelegate
can look like in our case:
As you can see from the example above the RootCoordinator
confirms the ViewProvidable
protocol:
NOTE: All Coordinators should confirm a ViewProvidable protocol
Also, we have all dependencies injected to the RootCoordinator
.
Here is how the Coordinators tree can look (this tree also represents the Features tree in the app)
This is an example of Coordinator
:
The only question we have here is what is coordinatorDelegate
that our FeatureView
expects as a dependency. And the answer takes us to the next topic.
Cross-component navigation
Let’s imagine that our Feature
has several components inside. We need to provide some mechanism to navigate between our components. We can add this responsibility to the Coordinator
which is covered by CoordinatorDelegate
protocol
That’s how it can look like
In the View
of the current component we just call the CoordinatorDelegate
method that provides a View
for the destination component.
We need to keep a reference to the sub-feature Coordinator
in case we have a sub-feature we want to show:
and now the extension for CoordinatorDelegate
implementation will look like this:
NOTE: We need to check to see if subFeatureCoordinator is not nil because of the way SwiftUI creates views even before we actually want to show them
You can see above that we also have an instance of our coordinator injected as NavigationDelegate
to the child Coordinator
. It takes us to the next topic.
Cross-feature navigation
We introduce the NavigationDelegate
to make a cross-feature navigation be possible. Every Coordinator
that can provide a navigation functionality should confirm it's own NavigationDelegate
protocol and be injected to the child Coordinator
Cross-component communication
For any cross-component communication, we need to inject the State
(covered by the StateShareable
protocol) of the first component to the second component Interactor
as it is shown below.
Now Interactor
from the second component can call any available and shareable method from the State
of the first component.
Services
Service
is any kind of injected functionality. It can be a DataProvider
, NeetworkService
, AnalyticsSystem
or any other object that will provide some sort of service to our Component
.
Service
is a separate instance of functionality in the appService
should be covered by the protocol- It is better to have a
Service
as a separate swift Package, Pod or Framework - This package should be covered by tests
Service
can have sub-services that should follow the same rules
Summary
Now we have our VISCS
architecture described.
Component layer
View
- just a UI representation based on theState
Interactor
- the holder of business logic and the one who change theState
State
- the current state of the component
Feature layer
- Component +
Coordinator
that responsible forDI
,View
creation and application navigation
Service layer
Service
- a functionality provider
Of course, it’s not perfect; nothing is perfect. It looks similar to many other architectures, but the goal of having it is to create a list of rules for SwiftUI
-based projects that will work for more than just simple applications. I try to find a balance between the old fashioned UIKit
approach and the current declarative state-driven approach that SwiftUI
has. Let's be honest - SwiftUI
has a lot of bugs and a lot of missing functionality; it's in beta and not yet production-ready (though it will be soon) without mixing it with UIKit
elements. I didn't cover much regarding the architecture, but I hope that this article will help someone to find his/her own architecture balance in this new iOS SwiftUI
-based world.