Reactive way of Mobile App Development using iOS Combine Framework
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
UserLocationstruct is a simple data structure that represents a user's location, with latitude and longitude properties. - The
UserLocationAppStatestruct is a state container for the user location application. It has a single property,userLocation, which represents the current user location. - The
UserLocationAppActionenum 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
userLocationAppReducerfunction is the reducer function for the user location application. It takes anActionand an optionalUserLocationAppState, and returns a newUserLocationAppState. The reducer function is responsible for updating the state based on the action that was performed. In this case, the reducer function updates theuserLocationproperty of the state when theuserLocationLoadedaction 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
UserLocationstruct that represents a user's location, with latitude and longitude properties. - Protocol for actions: In the ReSwift code, the
Actionprotocol is used to define the possible actions that can be dispatched to the store. In the Combine code, we define a new protocol calledAppActionthat serves the same purpose. - Protocol for states: In the ReSwift code, the
StateTypeprotocol 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 calledAppStatethat serves the same purpose. - Store subscriber protocol: In the ReSwift code, the
StoreSubscriberprotocol 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 calledAppStoreSubscriberthat serves the same purpose. - Middleware and reducer functions: In the ReSwift code, the
reducerfunction is used to update the state in response to actions. In the Combine code, we define a similar function calledreducer, as well as additional functions calledmiddlewaresthat can be used to perform additional processing or side effects in response to actions. - Store protocol: In the ReSwift code, the
Storeclass is used to manage the application state and dispatch actions. In the Combine code, we define a new protocol calledAppStoreProtocolthat defines the methods that a store should implement, such assubscribeForStoreandunsubscribeForStore.
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 thePublisherprotocol will be notified when the state changes.middlewares: This is an array of functions calledmiddlewaresthat 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 aPublisherobject that emits a new action.queue: This is aDispatchQueueobject that is used to serialize access to the store and ensure that actions are processed in a thread-safe manner.cancellableSubscriptions: This is aSetofAnyCancellableobjects that are used to cancel subscriptions to publishers when they are no longer needed.reducer: This is a function calledreducerthat 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 ofAppStoreSubscriberobjects 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 thenewState(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 thenewState(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 thestateproperty. Finally, it calls thenotifyAllSubscribersmethod 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 thesubscriptionsarray and calls thenotify(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 thesubscriptionsarray.
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
}UserLocationStorethat is designed to manage the state of aUserLocationobject.UserLocationStatestruct that represents the state of aUserLocationobject, and anUserLocationStateActionenumeration that represents the possible actions that can be taken to modify theUserLocationState.userLocationReducerfunction that is responsible for updating theUserLocationStatebased on a givenUserLocationStateAction.
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.