iosswiftgenerics

BaseViewModel to follow DRY principle in iOS using Swift


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


Solution

  • 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

    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? ;)

    How to use

    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.