I am coming from Android world where we have abstract class. I am trying to improve in iOS using Swift. I create a base class as follow :
class BaseViewModel<T>: ObservableObject {
typealias GHError = APIService.GHError
@Published var result = Resource.loading
let apiService = APIService()
init() {
refresh()
}
func getSuccessResult() async throws -> T? {
return nil
}
func refresh() {
result = Resource.loading
Task { @MainActor in
do {
if let successResult = try await getSuccessResult() {
result = Resource.success(successResult)
}
} catch GHError.invalidURL {
result = Resource.error("Invalid URL")
} catch GHError.invalidResponse {
result = Resource.error("Invalid response")
} catch GHError.invalidData {
result = Resource.error("Invalid data")
} catch {
result = Resource.error("Unexpected error")
}
}
}
enum Resource {
case loading
case success(T)
case error(String)
}
}
I am trying to avoid writing refresh method in every ViewModel I create, so that is why I created the base, and as we do not have abstract in Swift, I add this method which return nil in base class :
func getSuccessResult() async throws -> T? {
return nil
}
Here is an example of sub viewModel :
class GithubViewModel : BaseViewModel<[UserWrapper]> {
override func getSuccessResult() async throws -> [UserWrapper] {
async let following = apiService.getUsers(endPoint: Constants.followingEndPoint)
async let followers = apiService.getUsers(endPoint: Constants.followersEndPoint)
return try await UserWrapper.createUsers(following: following, followers: followers)
}
private struct Constants {
private static let endPoint = "https://api.github.com/users/alirezaeiii/"
static let followingEndPoint = endPoint + "following"
static let followersEndPoint = endPoint + "followers"
}
}
It works fine but I am not sure if I am following a good approach to follow DRY (don't repeat yourself) principle.
Would you help me out with your comment if there is any improvement.
source code is here : https://github.com/alirezaeiii/Github-Users-Assignment/tree/main
I will try to propose an alternative approach, which does not use OO patterns, rather uses generics and let you set a single pure function (update(_:event)
) in the initialiser which executes the whole logic.
Please don't see the below as a direct answer to your problem. It's a change in methodology, an alternative way to solve problems.
Basically, what you need as an API for your ViewModel might look like this (pseudo code):
class ViewModel<State, Event> {
var state: State // observable
func send(_ event: Event) { ... }
init(
initialState: State,
update: @escaping (inout State, Event) -> Effect<Event>?
) { ... }
}
The interesting peaces, are
State
: defines the state of the view, where the view is a function of state. This can be a struct or an enum.Event
: Usually an enum, which represents all events which can happen in this scenario, which are user intents (a button click), and events which are result values from side-effect functions, for example the return value from an network call.update(_:event)
: a pure function which executes the logic. It takes the current state and an event and calculates a new state. it also - optionally - returns a struct Effect
which contains a function which performs side-effects (the aforementioned network call for example).The ViewModel implementation is responsible to invoke the effects and feed their results, transformed to an event, back into the system. That way, you get the "Model" of a ViewModel.
With these things in place, you can implement a universal view model. Well, it's not just a ViewModel, it's rather an actor which internally uses a "model of computation" (a Finite State Machine for example) which can take the role of a view model.
The same thing can also be used to implement a data provider, or you can use it to implement the logic which you need for a speech-to-text enabled TextField, and many other use cases.
What you need is, an implementation for it (usually in a package or library). When it comes to DRY, what is more dry than a ready to use artefact from a lib which works in all use cases? ;)
For your convenience, here is a gist containing all the code which should work, but use it at your own risk (note I copied it from a my own private package which is still in development). https://gist.github.com/couchdeveloper/bc6fd76c353756a2cb7f681a6d7aa27d
In order to give you a glimpse, how one can now implement a certain use case, for example, a "Counter" which uses asynchronous service functions to increment and decrement the counter. This is of course just for demonstration.
All you have to do, is to define State, Event and the update function. Note that State is the value a view will observe and render itself accordingly.
enum State: Sendable {
case start
case idle(value: Int)
case incrementing(value: Int)
case decrementing(value: Int)
case error(Error, value: Int)
}
enum Event: Sendable {
case start(initial: Int = 0)
// user intents:
case requestIncrement
case requestDecrement
case cancel
case clear
// service events:
case incrementResult(result: Result<Int, Error>)
case decrementResult(result: Result<Int, Error>)
}
static func update(
_ state: inout State,
event: Event
) -> Effect? {
switch (state, event) {
case (.start, .start(let initialValue)):
state = .idle(value: initialValue)
return .none
case (.idle(let current), .requestIncrement):
state = .incrementing(value: current)
return incrementEffect()
... many more cases
}
then, create the ViewModel:
let viewModel = ViewModel(
initialState: .start,
update: update(_:event)
)
Coming from a OO background, this might look unusual. However, the usage of a FSM, and especially the pure update
function, with its many case
statements, has huge benefits. Also, the swift compiler is already a perfect DSL for writing the update function: you cannot forget a case, the compiler will emit an error. Means, you have to implement all cases, including the "edge cases", which improves the chances to get it correct.