Prerequisites/Background (Inspired from Functional Programming)
Data - data is inert and does not change by itself.
Example:
- immutable struct
- immutable data received from an API service, for example JSON, recorded logs etc.
Calculations (or sometimes called pure functions) - any operations on data which yield the same result no matter the time or number of invocations. They have to be idempotent i.e it does not make any difference the amount of times they were run since they do not hold any state by itself. They're side-effect free, meaning they don't affect the outside context from within the body of execution.
Example:
- math functions. Anything in the form of f(a: Int) -> Int { a * 2 }
- a .map(SomeStruct.init) where init() can return nil. on This will always result in either nil or the entity being created therefore it is deterministic.
Actions - any operations on data which can be defined as "inpure" i.e at repeated execution can yield a different result. Any Calculation mixed with an Action becomes an Action. Actions create side-effects.
Example:
- sending an email via an email service (not idempotent i.e sending an email once is not the same as sending it twice!)
- request to an API service (might be down, might return something else than last time etc.)
- print() inside a calculation makes it an Action :)
The general rule of thumb is to try to separate these concerns mentally and in code as much as possible. Functional languages such as Haskell enforce this with the compiler, in Swift, the programmer has to keep it in mind. That was the "hidden" idea behind ViewModels/ViewControllers too, in my opinion.
In the context of iOS development one of the reasons why this is a good idea is that Calculations are easy to test and understand, since they're deterministic in nature.
It is important to note that we are not doing Functional Programming in iOS. It is however also important to note that thinking in functional terms makes some stuff less complicated than it needs to be!
Applying these to MVVM-C:
ViewModel - operates and prepares the Data, talks to services, does Calculations and Actions, while trying to keep them reasonably separated. ViewController - Sends Actions to the ViewModel and binds to its output (Data). Does not do any Calculation on Data, unless they're viewController (UIKit) specific such as animating constraints. Coordinator - Entity which handles navigation, instantiating ViewModels&ViewControllers, passing Data up/down between ViewModels. Does not do Calculations.
protocol LFViewModelInput {
func viewDidLoad()
// other actions, such as input from the user, etc.
}
protocol LFViewModelOutput: AnyObject {
var onData: ((Data) -> Void)? { get set }
// other output, such as result of calculations, network requests, user input transformations to be consumed by the viewController.
var onFinish: ((Data) -> Void)? { get set }
// other output, such as navigation callbacks, passing the data up etc.
}
protocol LFViewModelType {
var input: LFViewModelInput { get }
var output: LFViewModelOutput { get }
}
final class LFViewModel: LFViewModelInput, LFViewModelOutput, LFViewModelType {
// implementation
}
Pros:
- local reasoning i.e there is no need to scroll the whole file and figure out what is this viewModel doing. Since all possible input/output is defined on top, it becomes easy to reason about what is happening in it, and why.
- easier testing. I would even call it babysitting, since, it is hard to miss an input or output while writing tests :)
- boilerplate which acts as a safety belt. One could start for example with defining the protocols in terms of the inputs/outputs a feature needs, and go from there.
Cons:
- boilerplate which has to be written and maintained. Fortunately it does not come with additional mental strain and it is quite mechanical to write.
final class LFViewController: UIViewController {
init(viewModel: LFViewModelType) {
// notice that the viewModel is of type LFViewModelType
// therefore all interactions with it can be done via LFViewModelType propreties and that only.
self.viewModel = viewModel
}
func viewDidLoad() {
// notice that the only interaction is allowed via input/output propreties.
viewModel.input.viewDidLoad()
viewModel.output.onData = { // }
}
}
Pros:
- enforces rules on what the viewController can and can not do.
- helps understand the MVVM concept in the terms of input/output
- no additional boilerplate here!
Cons:
- can't think of any!
func run() {
let viewModel = LFViewModel()
let viewController = LFViewController(viewModel: viewModel)
viewModel.output.onFinish = { [weak self] data in
self?.onDetail?(data)
}
navigationController.pushViewController(viewController, animated: true)
}
func onDetail(_ data: Data) {
// instantiate a vm with the data, its vc, push on navigation stack...
}
Pros:
- easy (?) to pass data up and down between viewModels
- viewModels do not know anything abou the coordinators!
Cons:
- injecting dependencies can become tedious when there's lots of them (?)
References: https://github.com/kickstarter/ios-oss
Example of a typical viewModel built with this approach: https://github.com/kickstarter/ios-oss/blob/main/Kickstarter-iOS/ViewModels/UpdateViewModel.swift
Example of a very large yet surprisingly readable viewModel built with this approach: https://github.com/kickstarter/ios-oss/blob/main/Kickstarter-iOS/ViewModels/AppDelegateViewModel.swift
138 - 141 https://www.pointfree.co/episodes/ep138-better-test-dependencies-exhaustivity
110 - 114 https://www.pointfree.co/episodes/ep110-designing-dependencies-the-problem