Using Channels for Data Flow in Swift

Using Channels for Data Flow in Swift

Apple frameworks use delegation and observer pattern (NotificationCenter) heavily to pass information around. Although there is nothing wrong about these patterns, the actual implementation always looked a bit inconsistent to me.

Let’s look at the basic traits of these patterns first:

  • Delegation: Supports 1-to-1, two-way communication.
  • Observer: Supports 1-to-many, one-way communication.

Let’s look at some UITableViewDelegate methods.

optional func tableView(_ tableView: UITableView,
                        heightForRowAt indexPath: IndexPath) -> CGFloat
  • UITableView: Sender
  • UITableViewDelegate (Mostly UIViewController): Receiver

This method is a good example of 1-to-1, two-way communication.

Since the table view requires height to be returned by the controller, communication is two-way and it can’t be 1-to-many. (Otherwise, we wouldn’t be able to decide which returned height value should be used.)

However, the following method does not return anything.

optional func tableView(_ tableView: UITableView,
                        didSelectRowAt indexPath: IndexPath)

Here, sender only notifies the receiver. Therefore, the communication is one-way and can either be 1-to-1 or 1-to-many.

In this case, is delegation a good choice?
Maybe…
What happens if we need to notify more than one component?
We can’t.

What if we don’t care about row selection but want to provide row height ourselves? Do we still need to provide empty implementations?
If this was a pure Swift protocol, yes, we would need empty implementations in place for each delegate method we don’t care about, which is pretty annoying. Since UITableViewDelegate is inherited from Objective-C, we can mark methods as optional for the sake of backwards compatibility. Swift disables this "feature" for a reason. Having huge protocols violates interface segregation principle and makes them unreadable. Table views are a huge part of my life as a developer but I still can’t remember what’s defined in UITableViewDelegate and UITableViewDataSource protocols because the list is way too long.

So… Why don't we use observer pattern for every one-way action? We could provide the same functionality with more flexibility and it would also clear up most methods in huge delegate protocols.

An Imaginary Problem

We have a settings page which features theme selection. We want to update our home screen whenever the theme changes.

Solution #1: Using Delegation

enum Theme: String {
    case light, dark
}

protocol UserSettingsDelegate {
    func themeDidChange(_ theme: Theme)
}

class UserSettings {
    var delegate: UserSettingsDelegate?

    var theme: Theme = .light {
        didSet {
            delegate?.themeDidChange(theme)
        }
    }

    init() { }
}
class HomeViewController: UIViewController {
    private lazy var userSettings: UserSettings = {
        let settings = UserSettings()
        settings.delegate = self
        return settings
    }()

    func didTapOnSettings() {
        let settingsVC = SettingsViewController(userSettings: userSettings)
        present(settingsVC, animated: true)
    }
}

extension HomeViewController: UserSettingsDelegate {
    func themeDidChange(_ theme: Theme) {
        // Apply theme here.
    }
}

This solution works but is it flexible enough? What happens if we have 5 tabs to be updated with this theme change?

Solution #2: Using NotificationCenter

enum Theme: String {
    case light, dark
}

class UserSettings {
    static let shared = UserSettings(notificationCenter: .default)
    let notificationCenter: NotificationCenter

    var theme: Theme = .light {
        didSet {
            let name = ThemeDidChangeNotification.name
            let userInfo = ThemeDidChangeNotification.userInfo(with: theme)
            notificationCenter.post(name: name, object: self, userInfo: userInfo)
        }
    }

    init(notificationCenter: NotificationCenter) {
        self.notificationCenter = notificationCenter
    }
}

struct ThemeDidChangeNotification {
    static let name = Notification.Name(rawValue: "ThemeDidChangeNotification")
    let theme: Theme

    init?(userInfo: [AnyHashable: Any]?) {
        if let rawTheme = userInfo?["theme"] as? String,
           let theme = Theme(rawValue: rawTheme) {
            self.theme = theme
        } else {
            return nil
        }
    }

    static func userInfo(with theme: Theme) -> [AnyHashable: Any] {
        return ["theme": theme.rawValue]
    }
}
class HomeViewController: UIViewController {
    private let userSettings = UserSettings.shared

    override func viewDidLoad() {
        super.viewDidLoad()
        subscribe()
    }

    func subscribe() {
        let notificationCenter = userSettings.notificationCenter

        notificationCenter.addObserver(forName: ThemeDidChangeNotification.name, object: userSettings, queue: .main) { (notification) in
            guard let theme = ThemeDidChangeNotification(userInfo: notification.userInfo)?.theme else { return }
            // Apply theme here.
        }
    }
}

Now we support 1-to-many communication, but we doubled the number of lines, right? This is the main problem with NotificationCenter. It is powerful but not very handy.

Solution #3: Using Channels

What is a channel?

It’s nothing new. Channel is an observer pattern implementation which provides a much better API than NotificationCenter.

  • Create a channel:
enum Message {
  case themeDidChange(Theme)
}

let channel = Channel<Message>()
  • Subscribe to it:
channel.subscribe(self) { message in
  // Handle message here.
}
  • Broadcast a message:
channel.broadcast(.themeDidChange(.light))

So using a channel, our solution would look like the following:

enum Theme: String {
    case light, dark
}

class UserSettings {
    enum Message {
        case didUpdateTheme(Theme)
    }

    static let shared = UserSettings()
    let channel = Channel<Message>()

    var theme: Theme = .light {
        didSet {
            channel.broadcast(.didUpdateTheme(theme))
        }
    }

    init() { }
}
class HomeViewController: UIViewController {
    let userSettings = UserSettings.shared

    override func viewDidLoad() {
        super.viewDidLoad()
        subscribe()
    }

    func subscribe() {
        userSettings.channel.subscribe(self) { (message) in
            switch message {
            case .didUpdateTheme(let theme):
                // Apply theme here.
            }
        }
    }
}

Final Words

  • We can standardize data flow in our code by adapting channels for one-way communication and delegation for two-way communication.
  • We can finally stop using NotificationCenter.
  • Channel implementation is less than 100 lines of code and now is a part of Lightning framework.

Thanks for scrolling all the way!

Your opinion matters! Please let me know what you think and help spread the word. ❤️


Image Credit: Photo by Milivoj Kuhar on Unsplash