EventBus | Facilitate seamless event notifications within the application.

Pravin Tate
8 min read4 days ago

--

In the majority of larger applications, it is essential to communicate certain events between screens. To facilitate this, many applications utilize methods such as singletons, notifications, passing the same object between screens, or employing a publisher-subscriber model. Each of these approaches has its own advantages and disadvantages. If you are familiar with Redux, you will know that it employs a store that serves as the sole source of truth. Based on this concept, I considered implementing a similar solution. However, I prefer not to retain all objects in memory at all times (as with a singleton), since there are specific internal screens and certain objects that should only exist within those contexts, rather than throughout the entire application.

Upon reviewing all available options, I believe that utilizing Combine represents one of the most effective methods to achieve our objectives. This is primarily due to the inherent strength of the publisher’s one-to-many relationship. However, I wish to avoid burdening consumers with the necessity of writing conventional subscription code, such as sink or store. My aim is to maintain simplicity; consumers should be able to publish or subscribe to events as needed with minimal code.

There exists a comparable concept to EventBus that primarily performs the same functions as previously described. Therefore, let us explore how we can implement this without further discussion.Note: Our EventBus should not directly depend on any concreate implementation, it should flexible and easily adopt with any types of events.

Initially, it is essential to establish the event protocol, as the event bus must facilitate the publishing and subscribing of events within the application.

// Events which we need to get on the component - loading, unload
protocol BusEvent: Hashable, Equatable {
}
// Component on which we need to listen events - screen
protocol BusComponent: Hashable, Equatable {
}

Let’s write EventBus protocol now.

protocol EventBusInterface {
// Generic data type declaration
associatedtype Event
associatedtype Component
associatedtype Failure: Error
// functionality
func publish(event: Event, for component: Component)
func subscribe(for component: Component,
_ event: @escaping (Event) -> Void) -> AnyPublisher<Event, Failure>
func cancelAllSubscription()
}

I trust you comprehend the aforementioned code; however, I encourage you to review it once more. We have three associated types in the protocol: Event, Component, and Failure. As previously indicated, we possess the Event and Component protocols, which are essentially those protocol types. Rather than declaring them here, I will incorporate them in the implementation using Generics. The last associated type, Failure, represents the error type. Although we are not addressing it in this example, it can be easily integrated. Our EventBus concept and protocol are now established with all necessary dependencies. It is time to adopt these ideas and develop a concrete implementation. To begin, we will create enums for the Event and Component.

// Components
enum SectionType: String, BusComponent {
case order, result, allergy
func uniqueKey() -> String {
rawValue
}
}

// Events
enum EventBusEventImp: String, BusEvent {
case loading, success, failed

func eventName() -> String {
rawValue
}
}

EventBus implementation:

import Combine
// 1
final class EventBus<ComponentType: BusComponent, EventType: BusEvent>: EventBusInterface {
// 2
typealias Event = EventType
typealias Component = ComponentType
typealias Failure = Never
// 3
private var store: [ComponentType: PassthroughSubject<EventType, Failure>] = [:]
private var disposeBag = Set<AnyCancellable>()
// 4
func cancelAllSubscription() {
print("Event bus canceled for \(store.keys.map({ $0 }))")
disposeBag.removeAll()
store.removeAll()
}
// 5
@discardableResult
func subscribe(for component: ComponentType,
_ event: @escaping (EventType) -> Void) -> AnyPublisher<EventType, Never> {
var publisher = PassthroughSubject<EventType, Failure>()

if let currentPublisher = store[component] {
publisher = currentPublisher
} else {
let newPublisher = PassthroughSubject<EventType, Failure>()
store[component] = newPublisher
publisher = newPublisher
}
publisher
.receive(on: RunLoop.main)
.sink(receiveValue: event)
.store(in: &disposeBag)
return publisher.eraseToAnyPublisher()
}
// 6
func publish(event: EventType, for component: ComponentType) {
if let publisher = store[component] {
publisher.send(event)
} else {
debugPrint("publisher not created for \(component)")
}
}
}

Initially, the extensive amount of code may seem overwhelming; however, there is no need for concern, as it consists of merely six distinct components. We will examine each of these components individually at this moment.

  1. We are implementing the EventBus protocol within our class. This class incorporates two generic data types at the class level, which means that whenever an object of this class is instantiated, the user must specify these two generic object types. The required types are simply the component type and the event type, as defined earlier in this article. This concept should be quite straightforward to grasp, correct?
  2. We have defined three type aliases; two of these are derived from the class declaration, while the third, which represents our failure, is designated as the Error type.
  3. As previously mentioned, I indicated that our bus should be of a generic type to facilitate easy adoption by anyone. How do we achieve this? By utilizing protocols instead of relying on direct dependencies tied to specific implementations. At this stage, we maintain a store dictionary that contains component objects, where the key represents the component and the value is a publisher (I have employed PassthroughSubject here, but it can be substituted with a different publisher as required). Furthermore, we aim to ensure that the consumer is not burdened with managing the publisher store and related elements; therefore, we are handling that logic at the bus level.
  4. The function cancelAllSubscription is designed to release the publisher of a specific component. It should be invoked by the consumer when they no longer wish to have that publisher active within the application.
  5. The subscription process requires the consumer to invoke this method, specifying the component event they wish to subscribe to. This method will then register the subscription and return a publisher object. One might wonder why a publisher is returned, given that this method also utilizes a closure. The return type is designed to be discardable, allowing the consumer to choose whether to retain it; however, there is no need for concern, as the latest event value will always be accessible through the closure. By simply calling this method, the consumer becomes eligible to receive events from the specified component. In the implementation, we first verify whether a publisher already exists in the store; if it does not, a new one will be created; otherwise, the existing publisher will be utilized. When data is received from the publisher, it is passed to the closure, and the method ultimately returns an AnyPublisher, with no additional functionality provided.
  6. The publish function allows consumers to disseminate events related to the component. To utilize this function, it is necessary to invoke the method, which requires specifying both the component type and the event type. The implementation includes a check to determine whether a publisher is already registered. If a publisher exists, the event is published; if not, a log message is generated. Essentially, the expectation is that at least one subscriber must be present before the event can be triggered; otherwise, it will be disregarded. While modifications can be made if necessary, the current demonstration maintains a straightforward approach.

Our component is now fully prepared for use; let us integrate it into the application.

class EventBusFeatureTester {
// 1
typealias EventType = EventBusEventImp
weak var eventBus: EventBus<SectionType, EventBusEventImp>?
// 2
init(eventBus: EventBus<SectionType, EventBusEventImp>) {
self.eventBus = eventBus
}

// 3
func registerEventBusEvents() {
guard let eventBus = eventBus else { return }
eventBus.subscribe(for: .order) { value in
print("order \(value)")
}
eventBus.subscribe(for: .result) { value in
print("result \(value)")
}
eventBus.subscribe(for: .allergy) { value in
print("allergy \(value)")
}
}
// 4
func pubishEventBus(_ event: EventType = .loading) {
guard let eventBus = eventBus else { return }
eventBus.publish(event: event, for: .allergy)
eventBus.publish(event: event, for: .order)
eventBus.publish(event: event, for: .result)
}
// 5
func cancelEventBusListListner() {
guard let eventBus = eventBus else { return }
eventBus.cancelAllSubscription()
}
}
  1. Create eventBus type — we declared component/sectionType and event/EventBusEvent
  2. init- we will inject eventBus from outside instead of creating it internally, because we need same event bus object where we need to publish or subscribe it.
  3. register all components as subscribe so we will get callback when events will fire.
  4. publishEvents- here we publish 3 components
  5. cancelEvent- cancelling all listeners

Use of this class:

    var eventBusViewModel: EventBusFeatureTester?
let eventBus = EventBus<SectionType, EventBusEventImp>()
func checkEventBusFunctionality() {
eventBusViewModel = EventBusFeatureTester(eventBus: eventBus)
guard let eventBusViewModel = eventBusViewModel else {
print("view model not created")
return
}
eventBusViewModel.registerEventBusEvents()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.eventBusViewModel?.pubishEventBus(.success)

DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.eventBusViewModel?.cancelEventBusListListner()

DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.eventBusViewModel?.pubishEventBus(.failed)
}
}
}
}

The checkEventBusFunctionality involves the creation of a ViewModel object utilizing a class-level event bus alongside the ViewModel instance. Initially, we register for events and subsequently publish these events after a brief delay. We anticipate the output of printed data, and after another delay, we proceed to cancel the event registration. To ensure that the cancellation has been executed successfully, we attempt to trigger an event once more. I apologize for the brevity of this function, as my intention is to maintain a concise and straightforward explanation of the process.

output:

allergy success
order success
result success
Event bus canceled for [EventBusPOC.SectionType.order, EventBusPOC.SectionType.result, EventBusPOC.SectionType.allergy]
"publisher not created for allergy"
"publisher not created for order"
"publisher not created for result"

Is Everything done?

No, the reason is that the eventBus object is instantiated at the class level, making it inaccessible to other classes that do not possess a reference to it. If this is your main screen and you have other child controllers or views within this class, you can achieve the desired output as previously described. However, if you require the use of this object throughout the entire application, it is necessary to create an EventBusContainer or a similar structure to hold all eventBus instances. This container would allow us to register our eventBus, retrieve it when needed, and release it from memory when it is no longer required. I apologize for the lengthy explanation, but it is crucial to understand this aspect; otherwise, you may find it ineffective. Please bear with me for a moment, and we will resolve this issue.

EventBus container/ Stand

final class EventBusContainer {
static let shared = EventBusContainer()
private init() {}

var container: [String: any EventBusInterface] = [:]

func register(bus: any EventBusInterface, identifier: String) {
container[identifier] = bus
}

func getBus(for identifier: String) -> (any EventBusInterface)? {
container[identifier]
}

func cleanBus(for identifier: String) {
container[identifier] = nil
}
}

This concept is quite similar to an eventBus; however, the primary distinction lies in its singleton nature. It maintains the eventBus within a container, allowing you to utilize the getBus method instead of passing the eventBus across multiple classes. Prior to this, it is essential to instantiate the eventBus object to ensure it is registered. Once your usage is complete, you should invoke cleanBus to prevent the singleton class from retaining the eventBus object unnecessarily.

If you are currently reading this sentence, I assume you have gone through the entire article. Thank you for your patience in engaging with my lengthy discussion. I hope you found it somewhat helpful; it serves as a foundational concept upon which you can develop your own event management application.

--

--