Search
  • Nick Daigler

iOS @ Pludo: Reactive Components

Reactive programming is a trendy topic these days, and this has proved especially true in the iOS community over the past few years. Combine was introduced at WWDC 2019 and only added fuel to the fire.


Combine is great; we use it at Pludo for a number of different components and domain-related concepts. But, there are other cases where it's been easier to use our own notions of triggers and observers, rather than Combine, or some other third-party reactive framework.



Triggers and Observers


There are a number of components that use the notion of triggers and observers to react to data flow. Triggers and observers are coordinating components that control the interaction on domain-related models. Other names for these kinds of components include "controller", "interactor", "model controller", and "control boundary". All of these names refer to a similar notion: an object that encapsulates the interaction of domain models and services.


As usual, I'll use a concrete example. Consider scrolling inside a Pludo space. There's going to have to be some pagination at some point. There are a number of components interacting to make the magic happen. The semantics behind the pagination system encapsulate

  1. determining the list's content's offset

  2. determining if we need to paginate

  3. fetching the messages

  4. loading the messages appropriately into memory



The Big Picture


We would like to

  1. trigger message fetching when we need to paginate

  2. observe any new messages that have been fetched


Consider the pseudo-code below.

struct Message {}

protocol FetchMessagesTrigger {
  func fetchMessages()
}

protocol NewMessagesObserver {
  func bind(completion: @escaping ([Message]) -> Void)
}


Putting It Into Practice


Imagine we had two distinct domain services that each conform to the FetchMessagesTrigger and NewMessagesObserver, respectively. Now, we could have our UI presentation logic depend on a FetchMessagesTrigger, and any underlying infra responsible for appropriately loading messages into memory depend on a NewMessagesObserver.


It might sound scary, but consider the example code below; this is how these components could be wired together via initializer injection.

class MessageFetcher: FetchMessagesTrigger { ... }
class MessageObserver: NewMessagesObserver { ... }

class PresentationLogic {
    init(trigger: FetchMessagesTrigger { ... }
    ...
}

class MessageLoadingInfra {
    init(observer: NewMessagesObserver) { ... }
    ...
}

let trigger = MessageFetcher()
let presentationLogic = PresentationLogic(trigger: trigger)

let observer = MessageObserver()
let loadingInfra = MessageLoadingInfra(observer: observer)


So, in this example, both the MessageFetcher and MessageObserver represent domain services that enforce application-specific business logic. The benefits of separating these domain services can become more evident by introducing a coordinating component. Right now, the MessageFetcher and MessageObserver are not logically coupled in any meaningful way. In other words, invoking fetchMessages on the MessageFetcher will not have any effect on the MessageObserver.


We'll need to have PresentationLogic and MessageLoadingInfra rely on a component that implements both the trigger and observer.



Putting It Into Practice: Take Two


Let's introduce a component to federate the interactions between these domain services: we need some application-level glue to achieve the desired behavior.

class PaginationBinding: 
        FetchMessagesTrigger, NewMessagesObserver {
    private var completion: (([Message]) -> Void)?
    
    init() { ... }
    ...
    
    func loadMessages() {
        service.fetch { [weak self] messages in
            guard let self = self else { return }
            self.completion?(messages)
        }
    }
        
    func bind(completion: @escaping ([Message]) -> Void)) {
        self.completion = completion
    }
}

Now, we can have two sub-components, each relying on the FetchMessagesTrigger and NewMessagesObserver, respectively, and still achieve coordinated behavior between the components.


Let's revise how these components will now be composed.


class PaginationBinding: 
        FetchMessagesTrigger, NewMessagesObserver {}

class PresentationLogic {
    init(trigger: FetchMessagesTrigger { ... }
    ...
}

class MessageLoadingInfra {
    init(observer: NewMessagesObserver) { ... }
    ...
}

let binding = PaginationBinding()
let presentationLogic = PresentationLogic(trigger: binding)
let loadingInfra = MessageLoadingInfra(observer: binding)

Now, binding to the PaginationBinding will result in updates whenever loadMessages is called. By separating triggering an action and binding to an action into separate protocols, we allow lower-level modules to depend on behavior, rather than concern themselves with the coordination of behavior.



That's all for now; be well.


Nick

15 views0 comments

Recent Posts

See All

Parsing XML with Swift

If you're just here for the code, go here. I recently found myself needing to parse an RSS feed and display a list of podcast episodes in a list. I typed, "xml parser apple docs" into Google and found

iOS Lead Essentials Course Learnings - Part 2

I've been chugging through the iOS Lead Essentials Course for about one month, at this point. I'm enjoying how the course doesn't lean heavily into iOS-specific technologies and concepts. Rather, the

Why I'm not a fan of pursuing balance

I had zero balance a bit over three years ago, as a freshman in college. I spent most of my time studying. Most of the time I didn't spend studying was spent worrying that I should be studying. Those