Architecting iOS Apps with “Core”
Understanding architectures to the core…
In the last two years, I’ve had chances to experiment with architectures like MVC, MVVM and VIPER. What’s common among those is the V component, which represents the views in our application. In a perfect world, view component should only do the following:
- Delivers any user action (touches) to business layer.
- Listens for state changes and updates itself.
Nothing else. Unlike their differences, view component is actually the same in all architectures. It is dumb, isolated and therefore easily replaceable.
But honestly, are they? Look at any of your implementations and tell me… In most iOS applications, they are actually the strongest component. We inject every smart component (like view model, presenter and interactor) into view controllers. That means if a view controller dies, those smart components die too. View is actually the dumb muscular guy who dictates the whole flow with its lifecycle. Shouldn’t it be the other way around?
I don’t think there is a problem with architecture definitions, but I can’t say the same for how they are advertised and implemented.
What are we really trying to achieve?
If we abstract it further, an ideal architecture should look like this:
Assuming the UI
component is designed by the book and Core
includes all business logic, we can actually remove UI
and replace it with anything… Like a test suite.
If we achieve this, we can simulate every use-case with a test suite without needing any UI at all. Let’s have a look at login use-case:
Login (from user perspective):
- Enter username and password.
- Tap on login.
Login (in test suite):
core.dispatch(LoginCommand(username: "goksel", password: "123"))
This would be cool, right?
Enter “Core”
Our goal is to design a business layer that represents our app’s state without needing any UI component at all. Obviously, this is not possible unless we stop relying on view lifecycle. So we need another layer, which would simply be the source of life for our app. I’ll call this layer “Core”.
In our products, we generally have features like login, sign-up, movie-list, movie-detail etc. All of these features should ideally be self-functioning components which can be represented with a class. This class will contain that feature’s current state and will be able to process incoming actions to produce a new state. By doing this, we’ll have an isolated component for each feature, and all of those components and transitions will be managed by core.
The final picture would look like this:
As shown in the diagram;
- Core is simply a box that represents our app.
- Actions are the changes that happen on the system. They travel through Core and trigger state changes on components.
- Components contain business logic for features and are able to process actions.
- Subscribers can be anything. It can be a console app. It can be an iOS app. It can be our test suite.
Simple, right?
Talk is cheap. Show me the code.
It sounds good in theory (as always) but would this be easy enough to implement as well? Let’s implement a simple login screen with 2-factor authentication and see.
Requirements:
- Allow user 60 seconds to enter the security code. Pop back on timeout.
- Verify security code with a web service once user taps on login button.
- If verification is successful, push home component.
- If verification is not successful, show an alert.
- Show loading indicator when a network request is in progress.
Easy peasy. Let’s dive in.
1- Define the State
What makes the state of this component? What data do we need to render it on the screen?
- We need a flag to show/hide loading indicator.
- We need to keep track of timer status.
- We need to keep track of the verification process.
struct LoginState: State {
var isLoading = false
var timerStatus: TimerStatus = .idle
var verificationResult: Result<Void>?
}
2- Define Actions
What changes can occur on this screen?
- Timer tick
- Tap on verify button
enum LoginAction: Action {
case tick
case verifyOTP(String)
}
3- Define the Component
Now we can define the component itself. All components will have a state
and a process
function. When an action is dispatched to core, all components will receive and react to it if necessary. If the state changes as a result of the dispatched action, component subscribers will be notified with the new state.
Note: State is a readonly property on Component class, however, it can be updated using commit function. This is by design and to avoid too many state updates. So to change state; create a copy, make all the changes and call commit. This will set a new state, and notify all subscribers.
class LoginComponent: Component<LoginState> { override func process(_ action: Action) {
guard let action = action as? LoginAction else { return }
switch action {
case .tick:
self.tick()
case .verifyOTP(let code):
self.verifyOTP(code)
}
} // ...}
So this simply is our component. By overriding process function, we can react to incoming actions when necessary. As you may have guessed, the real magic happens in tick
and verifyOTP
functions.
Let’s take a closer look: 🔍
func verifyOTP() {
// Get a copy of the current state:
var state = self.state // Start loading before network call:
state.isLoading = true // Publish state:
commit(state) // Start network call:
service.verifyOTP { result in // Update state with response:
state.isLoading = false
state.timerStatus = .finished
state.result = result // Publish new state (and navigation):
switch result {
case .success():
// Navigate to home if success:
let nav = BasicNavigation.push(HomeComponent(), from: self)
self.commit(state, nav)
case .failure(_):
self.commit(state)
}
}
Although this function does async work internally, it doesn’t have a completion block because state propagation is done by committing a new state to the component.
4- Subscribe to the State
So from a UI point of view, nothing is async. View just gets a new state whenever it’s available and update itself. For example, LoginViewController
would look like this:
class LoginViewController: Subscriber { var component: LoginComponent! // Injected by previous component. func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
component.subscribe(self)
} func viewDidDisappear(animated: Bool) {
super.viewDidDisappear(animated)
component.unsubscribe(self)
} // MARK: - Actions func verifyTapped(field: UITextField) {
core.dispatch(LoginAction.verifyOTP(field.text))
} // MARK: - Subscriber func update(with state: State) {
guard let state = state as? LoginState else { return }
// Update UI here.
} func perform(_ navigation: Navigation) {
// Perform navigation here.
}}
5- Define “Core”
Core can be defined in global scope as below.
let core = Core(rootComponent: LoginComponent())
That’s it. After this point on, we are free to dispatch any action. 🎉
Bottom Line
Core can be seen as a Redux, Flux and MVVM hybrid. If we structure an app with Core, we’ll have Redux-like communication and state propagation. However, the component itself is very similar to MVVM’s view model which is very straightforward and easy to adapt.
Main differences between Core and Redux
- Redux is static. It expects you to define a big fat structure that expresses the app state in compile time. This can be challenging if you have reusable controllers that you present here and there a number of times. Especially in cases where your application flow is altered by server responses, you cannot easily define your app state in compile time without hacking the architecture. Core is dynamic. You only need to define the state and actions for the component you are working on. Component transitions can be handled dynamically.
- In Redux, there is no standard way to implement navigation between components. With Core, you get native navigation support.
- Redux focuses on application state, whereas Core focuses on isolated component state. In this regard, I find it easier to work in isolation on one component rather than getting lost in huge application state.
- In Redux, since the state is global, it’s easy to forget to do state clean-up when a screen is popped from the navigation stack. In Core, since every component stores its own state, when you remove a component from the tree, state gets disposed along with it. This is handled internally by navigation mechanism.
Why should I use it?
Because you get many things for free by doing roughly the same amount of work compared to other architectures.
- Separation of concerns: Each and every box in Core diagram has a purpose and a clear definition.
- Easy to reason about: It forces you to model your components in a way that they become easy to reason about. By defining a state for each component, you are also documenting them.
- High testability: Since UI is just a reflection of the state stored in the core, you can test your components easily by replacing UI with a test class. This test class would simulate the user and dispatch actions into core then validate the resulting state.
- Reusability: You can develop business logic with no UIKit dependency at all. Therefore it becomes highly reusable between different platforms (iOS, macOS, watchOS, tvOS).
- Detailed bug reports: You can record user actions with a simple middleware implementation and attach action stack to your bug reports. This would help you reproduce the same exact state that crashed the app and save a lot of time. I mean… A LOT.
However, this is not to say that Core is superior to every other architecture. There are no “right” way to solve problems in software development; it’s always about requirements and trade-offs.
So to choose the “right” architecture:
- Always keep yourself up to date but avoid Hype Driven Development.
- Define your problem well and choose the right trade-offs to solve them.
- And lastly… Avoid Massive-View-Controller — no matter what.