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
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 anAction
and 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 theuserLocation
property of the state when theuserLocationLoaded
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 calledAppAction
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 calledAppState
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 calledAppStoreSubscriber
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 calledreducer
, as well as additional functions calledmiddlewares
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 calledAppStoreProtocol
that defines the methods that a store should implement, such assubscribeForStore
andunsubscribeForStore
.
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 thePublisher
protocol will be notified when the state changes.middlewares
: This is an array of functions calledmiddlewares
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 aPublisher
object that emits a new action.queue
: This is aDispatchQueue
object that is used to serialize access to the store and ensure that actions are processed in a thread-safe manner.cancellableSubscriptions
: This is aSet
ofAnyCancellable
objects that are used to cancel subscriptions to publishers when they are no longer needed.reducer
: This is a function calledreducer
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 ofAppStoreSubscriber
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 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 thestate
property. Finally, it calls thenotifyAllSubscribers
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 thesubscriptions
array 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 thesubscriptions
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 aUserLocation
object.UserLocationState
struct that represents the state of aUserLocation
object, and anUserLocationStateAction
enumeration that represents the possible actions that can be taken to modify theUserLocationState
.userLocationReducer
function that is responsible for updating theUserLocationState
based 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.