Reactive way of Mobile App Development using iOS Combine Framework

iOS Dec 26, 2022

Halodoc was successfully replacing ReSwift with Combine Framework. This changes is not the only that we did, but its been our culture improving our system with the latest technology and frameworks. This is not just because it's the "cool" thing to do, but because we believe that staying with the latest tech allows us to build better systems and provide better solutions for our users. This is why we constantly encourage our developers to adapt to new frameworks and tools, and why we made the decision to migrate from ReSwift to Combine.

In this blog post, we'll discuss about what is Combine, what benefits we expect to see from using it, and how our developers have been replacing ReSwift with Combine framework. We hope that by sharing our experiences, we can provide valuable insights for you that are considering implementation combine framework.

What is Combine?

Combine is a new framework introduced by Apple in WWDC 2019. It offers a declarative Swift API for handling values that change over time. This means you can easily respond to real-time updates and make dynamic changes in your code. While there are already popular open-source reactive frameworks like RXSwift and ReactiveSwift, Apple decided to create their own framework to provide a native solution for developers. With Combine, you can easily build reactive and responsive apps that deliver a seamless user experience.

There are basic key concept of combine that help us to understand.

  • Publisher
  • Subscriber

Combine with SwiftUI

Currently Halodoc using SwiftUI and the MVVM design pattern to create efficient, user-focused, and interactive applications that are a pleasure to use. We will show some examples about how we using Publisher and Subscriber to enhance its functionality and user experience, which is built using the MVVM design pattern and the latest SwiftUI.

Here is an example of using SwiftUI, MVVM, and Combine in the view layer:

 import SwiftUI
import Combine
 
class ViewModel: ObservableObject {
    @Published var data: [Data]?
 
    private var cancellables = Set<AnyCancellable>()
 
    init() {
        fetchData()
    }
 
    func fetchData() {
        // Use Combine to fetch data from an API and update the `data` property with the results
        DataService.shared.fetchData()
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                // Handle any errors that may occur
            }, receiveValue: { data in
                self.data = data
            })
            .store(in: &cancellables)
    }
}
 struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()
 
    var body: some View {
        List(viewModel.data, id: \.self) { data in
            Text(data.title)
        }
    }
}

In this example, ViewModel class is the view model that conforms to the ObservableObject protocol. This allows it to be observed by a SwiftUI view, which will automatically update whenever the data property changes.

ViewModel uses Combine to fetch data from an API and update the data property with the results. The ContentView struct is a SwiftUI view that observes the ViewModel and displays a list of data items. When the data property in the ViewModel changes, the ContentView will automatically update to reflect the changes.

Migration ReSwift to Combine Framework

One way to modernize a ReSwift to migrate its job of managing state to a publisher and subscriber model using Combine. In this approach, the store is replaced with a publisher that emits state updates, and the reducer functions are replaced with subscribers that transform the emitted state and return a new state.

In our case, we are currently working on several modules as part of different teams. One important aspect of our work is ensuring that when a user changes their location, the latitude and longitude is also updated in all relevant modules, including Pharmacy Delivery, Chat with Doctor, Commons, and etc. This is important because it ensures that the information displayed in these modules is accurate and up-to-date for the user's current location.

ReSwift Codebase

ReSwift is a library for managing state in Swift applications. It uses a central store to hold the state and dispatches actions to update the state. Reducer functions, which are pure functions that take the current state and an action as input and return a new state, are used to update the store. ReSwift follows the principles of unidirectional data flow, which means that data flows in a single direction through the app, making it easier to reason about changes to the state and to isolate side-effects.

Here we will demonstrate how our existing codebase in ReSwift will look like.

 import ReSwift

struct UserLocation: Codable {
  var latitude: Double?
  var longitude: Double?
}

struct UserLocationAppState: StateType {
  public var userLocation: UserLocation? = nil
}

enum UserLocationAppAction: Action {
  case userLocationLoaded(UserLocation)
}

func userLocationAppReducer(action: Action, state: UserLocationAppState?) -> UserLocationAppState {
  var state = state ?? UserLocationAppState()
  switch action as? UserLocationAppAction {
  case nil:
    break
  case .userLocationLoaded(let value):
    state.userLocation = value
    break
  }

  return state
}
  • The UserLocation struct is a simple data structure that represents a user's location, with latitude and longitude properties.
  • The UserLocationAppState struct is a state container for the user location application. It has a single property, userLocation, which represents the current user location.
  • The UserLocationAppAction enum defines the possible actions that can be performed within the user location application. There is only one action defined here: userLocationLoaded, which represents the event of a new user location being loaded.
  • The userLocationAppReducer function is the reducer function for the user location application. It takes an Action and an optional UserLocationAppState, and returns a new UserLocationAppState. The reducer function is responsible for updating the state based on the action that was performed. In this case, the reducer function updates the userLocation property of the state when the userLocationLoaded action is received.

ViewModel ReSwift

Here is we implement ReSwift into ViewModel to manage the state of a UserLocation object :

 import ReSwift

class ViewModel {
  var store: Store<UserLocationAppState>

  deinit {
    unsubscribe()
  }

  init() {
    self.store = Store<UserLocationAppState>(reducer: userLocationAppReducer, state: nil)
  }

  func setUserLocation(userLocation: UserLocation) {
    store.dispatch(UserLocationAppAction.userLocationLoaded(userLocation))
  }

  func startObservingStateChanges(store: Store<UserLocationAppState>) {
    self.store.subscribe(self)
  }

  func unsubscribe() {
    self.store.unsubscribe(self)
  }
}
 import ReSwift

extension ViewModel: StoreSubscriber {
  func newState(state: UserLocationAppState) {
    // Update the view or perform some other action when the location changes
  }
}

Finally ViewModel class is a view model has a store property of type Store<UserLocationAppState>, which is used to manage the application state. The view model also has a setUserLocation method, which dispatches the userLocationLoaded action to the store, and a startObservingStateChanges method, which subscribes the view model to the store to receive notifications when the state changes.

The deinit method is called when an instance of a class is about to be deallocated from memory. In this case, the deinit method calls the unsubscribe function to ensure that the ViewModel is no longer subscribed to state updates before it is deallocated. This is important because it helps to avoid potential retain cycles and memory leaks.

The ViewModel class also conforms to the StoreSubscriber protocol, which means that it can receive notifications when the state in the store changes. The newState method is called whenever the state changes, and it can be used to update the view or perform some other action.

Replacing ReSwift with Combine

Here, we will go deeper into the process of migrating to Combine. To migrate the code from using ReSwift to using Combine, we will need to make several changes to manage our application and manipulates state. Here are some steps you will see.

Here is some component that created to replace component in ReSwift.

 // MARK: Object UserLocation
struct UserLocation: Codable {
  var latitude: Double?
  var longitude: Double?
}

// MARK: AppAction and AppState replace Action and StateType ReSwift
protocol AppAction { }
protocol AppState { }

// MARK: AppStoreSubscriber replace StoreSubscriber ReSwift
protocol AppStoreSubscriber: AnyObject {
  func newState(state: Any)
}

// MARK: AppStoreProtocol helper subscribe and unsubscribe Store
protocol AppStoreProtocol {
  func subscribeForStore(sub: AppStoreSubscriber?)
  func unsubscribeForStore(sub: AppStoreSubscriber?)
}

// MARK: AppMiddleware and AppReducer replace middleware and reducer ReSwift
typealias AppMiddleware<AppState, AppAction> = (AppState, AppAction) -> AnyPublisher<AppAction, Never>
typealias AppReducer<AppState, AppAction> = (AppState, AppAction) -> AppState
  • We still using UserLocation struct that represents a user's location, with latitude and longitude properties.
  • Protocol for actions: In the ReSwift code, the Action protocol is used to define the possible actions that can be dispatched to the store. In the Combine code, we define a new protocol called AppAction that serves the same purpose.
  • Protocol for states: In the ReSwift code, the StateType protocol is used to define the shape of the state object that is managed by the store. In the Combine code, we define a new protocol called AppState that serves the same purpose.
  • Store subscriber protocol: In the ReSwift code, the StoreSubscriber protocol is used to define an object that can subscribe to and receive updates from the store. In the Combine code, we define a new protocol called AppStoreSubscriber that serves the same purpose.
  • Middleware and reducer functions: In the ReSwift code, the reducer function is used to update the state in response to actions. In the Combine code, we define a similar function called reducer, as well as additional functions called middlewares that can be used to perform additional processing or side effects in response to actions.
  • Store protocol: In the ReSwift code, the Store class is used to manage the application state and dispatch actions. In the Combine code, we define a new protocol called AppStoreProtocol that defines the methods that a store should implement, such as subscribeForStore and unsubscribeForStore.

Store Class

In the ReSwift code, the Store class is used to manage the application state and dispatch actions. In the Combine, we define a new class called AppStateStore that conforms to the AppStoreProtocol and ObservableObject protocols, and implements the methods defined in the AppStoreProtocol protocol. The AppStateStore class should also define a state property that is marked as @Published, which will allow subscribers to be notified when the state changes.

 import Combine

// MARK: AppStateStore replace Store ReSwift
class AppStateStore<AppState, AppAction>: ObservableObject {

  @Published private(set) public var state: AppState

  private let middlewares: [AppMiddleware<AppState, AppAction>]
  private let queue = DispatchQueue(label: "com.app.redux.hdredux", qos: .userInitiated)
  private var cancellableSubscriptions: Set<AnyCancellable> = []
  private let reducer: AppReducer<AppState, AppAction>
  private var subscriptions: [AppStoreSubscriber?] = []

  public init(
    initialState: AppState,
    reducer: @escaping AppReducer<AppState, AppAction>,
    middlewares: [AppMiddleware<AppState, AppAction>] = []
  ) {
    self.state = initialState
    self.reducer = reducer
    self.middlewares = middlewares
  }

  // MARK: This function will notify all subscribers to new state
  private func notifyAllSubscribers() {
    subscriptions.forEach { subscriber in
      notify(subscriber: subscriber)
    }
  }

  private func notify(subscriber: AppStoreSubscriber?) {
    guard let subscriber = subscriber else {
      return
    }
    // Notify subscriber to new state
    subscriber.newState(state: self.state)
  }
}

Here is a breakdown of the elements in the AppStateStore class:

  • state: This is a property that holds the current state of the application. It is marked as @Published, which means that any object that subscribes to it using the Publisher protocol will be notified when the state changes.
  • middlewares: This is an array of functions called middlewares that are used to perform additional processing or side effects in response to actions. Each middleware function takes a state and an action as arguments and returns a Publisher object that emits a new action.
  • queue: This is a DispatchQueue object that is used to serialize access to the store and ensure that actions are processed in a thread-safe manner.
  • cancellableSubscriptions: This is a Set of AnyCancellable objects that are used to cancel subscriptions to publishers when they are no longer needed.
  • reducer: This is a function called reducer that is used to update the state in response to actions. It takes a state and an action as arguments and returns a new state.
  • subscriptions: This is an array of AppStoreSubscriber objects that are subscribed to receive updates from the store.
  • notifyAllSubscribers(): This is a method that is called to notify all subscribers that the state has changed. It calls the newState(state:) method on each subscriber with the current state as an argument.
  • notify(subscriber:): This is a helper method that is called to send an initial state update to a specific subscriber. It calls the newState(state:) method on the subscriber with the current state as an argument.
 extension AppStateStore {
  // MARK: The dispatch function dispatches an action to the serial queue.
  public func dispatch(_ action: AppAction) {
    queue.sync {
      self.dispatch(self.state, action)
      self.notifyAllSubscribers()
    }
  }

  // MARK: The internal work for dispatching actions
  private func dispatch(_ currentState: AppState, _ action: AppAction) {
    // generate a new state using the reducer
    let newState = reducer(currentState, action)

    // pass the new state and action to all the middlewares
    // if they publish an action dispatch pass it to the dispatch function
    middlewares.forEach { middleware in
      let publisher = middleware(newState, action)
      publisher
        .receive(on: DispatchQueue.main)
        .sink(receiveValue: dispatch)
        .store(in: &cancellableSubscriptions)
    }
    // Finally set the state to the new state
    state = newState
  }
}
  • dispatch(_:): This is a method that dispatches an action to the store. It first passes the current state and the action to the middlewares, which can perform additional processing or side effects. It then generates a new state using the reducer function and updates the state property. Finally, it calls the notifyAllSubscribers method to notify all subscribers that the state has changed.
 extension AppStateStore: AppStoreProtocol {
  // MARK: Subscribe receive updates from the store
  public func subscribeForStore(sub: AppStoreSubscriber?) {
    self.subscriptions.append(sub)
    queue.sync {
      self.notify(subscriber: sub)
    }
  }

  // MARK: Unsubscribe from receiving updates from the store
  public func unsubscribeForStore(sub: AppStoreSubscriber?) {
    self.subscriptions.removeAll(where: { $0 === sub })
  }
}
  • subscribeForStore(sub:): This is a method that allows an object to subscribe to receive updates from the store. It adds the subscriber to the subscriptions array and calls the notify(subscriber:) method to send an initial state update to the subscriber.
  • unsubscribeForStore(sub:): This is a method that allows an object to unsubscribe from receiving updates from the store. It removes the subscriber from the subscriptions array.

Declare Component

We creating declare component from AppStateStore class to manage the state of a UserLocation object :

 import Combine

// MARK: UserLocationStore responsible manage the state of a UserLocation
typealias UserLocationStore = AppStateStore<UserLocationState, UserLocationStateAction>

// MARK: UserLocationState struct represents state of UserLocation
struct UserLocationState: AppState {
  public var userLocation: UserLocation? = nil
}

// MARK: UserLocationStateAction represents actions that can modify the UserLocationState
enum UserLocationStateAction: AppAction {
  case userLocationLoaded(UserLocation)
}

// MARK: userLocationReducer function that is responsible for updating state
let userLocationReducer: AppReducer<UserLocationState, UserLocationStateAction> = { state, action in
  var mutableState = state
  switch action {
  case nil:
    break
  case .userLocationLoaded(let value):
    mutableState.userLocation = value
  }
  return mutableState
}
  • UserLocationStore that is designed to manage the state of a UserLocation object.
  • UserLocationState struct that represents the state of a UserLocation object, and an UserLocationStateAction enumeration that represents the possible actions that can be taken to modify the UserLocationState.
  • userLocationReducer function that is responsible for updating the UserLocationState based on a given UserLocationStateAction.

ViewModel Combine

Here is now we can use store UserLocationStore class from ViewModel class to manage the state of a UserLocation object :

 import Combine

// MARK: ViewModel instance of the AppStateStore used to manage state of a UserLocation
class ViewModel {
  private var userLocationStore: UserLocationStore?

  // MARK: deinit method calls the unsubscribe method.
  deinit {
    unsubscribe()
  }

  // MARK: Creates a new instance of UserLocationStore class, assigns it to the userLocationStore property, then it subscribes the ViewModel to the store
  init() {
    self.userLocationStore = UserLocationStore(initialState: .init(), reducer: userLocationReducer)
    // Subscribe receive updates from the store
    self.userLocationStore?.subscribeForStore(sub: self)
  }

  // MARK: Unsubscribe from receiving updates from the store
  func unsubscribe() {
    self.userLocationStore?.unsubscribeForStore(sub: self)
  }

  // MARK: Dispatches an action to the userLocationStore to update the userLocation state
  func resetState() {
    self.userLocationStore?.dispatch(UserLocationStateAction.userLocationLoaded(UserLocation(latitude: -6.175392, longitude: 106.827153)))
  }
}
 extension ViewModel: AppStoreSubscriber {
  // MARK: Called whenever the state changes
  func newState(state: Any) {
    guard let userLocationState = state as? UserLocationState else {
      return
    }
    // finally update the view or perform some other action.
    handleUserLocationState(userLocationState: userLocationState)
  }

  private func handleUserLocationState(userLocationState: UserLocationState) {
  }
}

Finally ViewModel  class has a single property called userLocationStore of type UserLocationStore, which is an instance of the AppStateStore class that is used to manage the state of a UserLocation object. The ViewModel class also has a resetState method that dispatches an action to the userLocationStore to update the userLocation state with a new UserLocation object.

The ViewModel class also has an init method, which is called when a new instance of the class is created. In this case, the init method creates a new instance of the UserLocationStore class and assigns it to the userLocationStore property, then it subscribes the ViewModel to the store by calling the subscribeForStore method on the userLocationStore and passing in self as the subscriber.

The deinit method calls the unsubscribe method to unsubscribe the ViewModel from the userLocationStore.

And also mentioned above that we are working on several modules as part of different teams. We have added this code to the commons module so that other modules can use it and observe it whenever they need to.

Conclusion

After explaining the process of migrating from ReSwift to Combine, it should be clear how Combine can be a valuable asset for our team. Combine is a powerful framework for iOS developers, enabling us to leverage the benefits of reactive programming to create declarative code that makes more easy to read and maintain. With Combine and SwiftUI, we can build well-structured, efficient, and maintainable applications without relying on external dependencies. And with Apple's support, you know it's stable and reliable. Based on our experience, We believe that Combine can improve your workflow and the quality of your code and definitely a worth-while investment for mobile application development.

Join us

Scalability, reliability, and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels and if solving hard problems with challenging requirements is your forte, please reach out to us with your resumé at careers.india@halodoc.com.

About Halodoc

Halodoc is the number 1 all-around Healthcare application in Indonesia. Our mission is to simplify and bring quality healthcare across Indonesia, from Sabang to Merauke. We connect 20,000+ doctors with patients in need through our Tele-consultation service. We partner with 3500+ pharmacies in 100+ cities to bring medicine to your doorstep. We've also partnered with Indonesia's largest lab provider to provide lab home services, and to top it off we have recently launched a premium appointment service that partners with 500+ hospitals that allow patients to book a doctor appointment inside our application. We are extremely fortunate to be trusted by our investors, such as the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. We recently closed our Series C round and In total have raised around USD$180 million for our mission. Our team works tirelessly to make sure that we create the best healthcare solution personalised for all of our patient's needs, and are continuously on a path to simplifying healthcare for Indonesia.

Dedy Yuristiawan

Software Development Engineer iOS