Swift multiple trackers architecture – part 1

Overview

Tracking is an essential part of app development. It allows us to measure the performance, behavior and preferences of our users and optimize our app accordingly. However, tracking can also be a challenge when we have to deal with multiple trackers from different providers. How do we keep our code clean, maintainable and testable when we have to integrate and update various trackers in our app?

In this article, I will show you how to use Swift Package Manager (SPM) to modularize your tracking code and make it easier to manage multiple trackers in your app. SPM is a tool that helps us create and distribute Swift packages that contain source code and resources for our projects. By using SPM, we can separate our tracking code into different packages that can be reused, tested and updated independently.

We will start by creating an abstract tracker class that will be responsible for managing all the trackers in our app. Then we will create different tracker implementations that conform to a common protocol and register them with the abstract tracker class. Finally, we will see how to use SPM to create and import these tracker packages into our main app project.

Let’s get started!

The idea

Let’s create a general Tracker class responsible for managing all trackers, without containing any specific tracker implementation. The Tracker class will allow us to register a Trackable tracker, which will then be added to the trackers array. Ideally, the trackers array should not be accessible from outside the class, but due to the need for using extensions for simpler code management in different files, we are forced to use private(set). This allows us to read the array, but we cannot write to it outside the scope of the Tracker class. If you have any suggestions on how to improve this, please let me know in the comments below. We can all benefit from the knowledge since this is one of the known drawbacks at the moment.

protocol Tracking {
    func registerTracker(tracker: Trackable)
}

// General implementation
public class Tracker: Tracking {
    // Array of our trackers
    // Is there a way to store objects faster and without duplicates?
    private(set) var trackers = [Trackable]()

    // A way to register a Trackable, without duplicates
    public func registerTracker(tracker: Trackable) {
        if !trackers.contains(where: { $0.type == tracker.type }) {
            trackers.append(tracker)
        } else {
            print("πŸ”₯πŸ”₯πŸ”₯ We don't want duplicates πŸ”₯πŸ”₯πŸ”₯")
        }
    }
}

In order to make our tracker masterplan effective, it’s essential to identify the type of tracker we’re working with. Protocols provide a solution to this problem. I’ve named our protocol Trackable, which contains the TrackerType. Having the TrackerType enables us to enable/disable specific trackers or perform other magic tricks with them. It’s like ALOHOMORA, unlocking a whole new level of potential for our trackers!

Alternatively, we could skip the TrackerType and allow specific trackers to simply do their job without worrying about whether they’re enabled or not. This approach may be more straightforward, but it may also limit our ability to customize and fine-tune the behavior of our trackers. Ultimately, the decision of whether to use TrackerType or not will depend on our specific needs and use case.

@objc public protocol Trackable {
    var type: TrackerType { get }
}

@objc public enum TrackerType: Int {
    case general, braze, sumo, adobe

    var isEnabled: Bool {
        switch self {
        case .general, .braze, .adobe:
            return true
        case .sumo:
            return false
        }
    }
}

One potential drawback of having a general TrackerType is that it may feel unnecessary in certain cases. However, this is a matter of debate, and if you have any ideas on how to improve this, let’s have a discussion. The question of whether the Tracker should have a type is an important one that deserves careful consideration. While a TrackerType may provide some benefits, such as enabling us to enable/disable specific trackers or perform other customizations, it may also add unnecessary complexity to our code. Ultimately, the decision of whether to include a TrackerType or not will depend on our specific needs and the goals of our project.

Optional func

// Need the protocol to be @objc, so we could use @objc optional
@objc public protocol TrackableHome {
    // Optional track func, not necessarily implemented by all trackers
    @objc optional func trackHomeTap(extraParam: String)
    func trackNavigate(toView: String, fromView: String)
    func trackLogin()
}

The @objc optional keyword allows us to define functions that may not be implemented by all specific trackers, but only by some of them. This can be very useful when we want to provide flexibility in our tracking system, allowing each tracker to implement only the functions that are relevant to its specific needs. Without @objc optional, we would need to define each function in every tracker, even if it wasn’t used, which could lead to bloated and inefficient code.

Event

struct Event: Eventable {
    let name: String
    let parameters: [String: String]
}

protocol Eventable {
    var name: String { get }
    var parameters: [String: String] { get }
}

Specific tracker protocols

// Need the protocol to be @objc, so we could use @objc optional
@objc public protocol TrackableHome {
    // Optional track func, not necessarily implemented by all trackers
    @objc optional func trackHomeTap(extraParam: String)
    func trackNavigate(toView: String, fromView: String)
    func trackLogin()
}

public protocol TrackablePlayback: Trackable {
    func trackPlay()
    func trackPause()
    func trackNext()
    func trackPrevious()
}

We have a couple of different protocols above. One is for tracking homepage events, other is for tracking playback events. But we could have as many different protocols conforming to Trackable as we want. For each ViewController, feature, service…

Our goal is for our Tracker to support them, so that specific Trackable trackers could implement their specific implementations as well. We extend our abstract Tracker with TrackableHome. Next, we go through each registered tracker, check if it is supported(enabled) and call the same tracking function. The abstract tracker knows nothing about the specific implementations required by each tracker.

// Extension of Tracker, where we see if tracker is enabled and call all the enabled trackers
extension Tracker: TrackableHome {
    public func trackNavigate(toView: String, fromView: String) {
        trackers.forEach { tracker in
            guard tracker.type.isEnabled,
                  let tracker = tracker as? TrackableHome
            else {
                return
            }

            tracker.trackNavigate(toView: toView, fromView: fromView)
        }
    }

    public func trackLogin() {
        trackers.forEach { tracker in
            guard tracker.type.isEnabled,
                  let tracker = tracker as? TrackableHome
            else {
                return
            }
            
            tracker.trackLogin()
        }
    }
}

Specific tracker implementations

Let’s take a closer look at the implementation of a specific tracker. In the BrazeTracker class, our goal is to set up everything we need to run the tracker. By extending the class with Trackable, we gain support for TrackerType, which allows us to easily enable or disable trackers as needed.

To use BrazeTracker with a specific feature or service, we extend it with TrackableHome (or another relevant protocol). The Tracker class does not need to know the specific implementation details required by AppBoy – we can provide all of the necessary extras in the BrazeTracker class. This allows us to customize the specific tracker for our specific needs while still leveraging the benefits of the single Tracker class approach.

class BrazeTracker: Trackable {
    private let apikey = "YOUR-API-KEY"

    let type: TrackerType = .braze
//    let appboy = Appboy.sharedInstance()
}

extension BrazeTracker: TrackableHome {
    func trackNavigate(toView: String, fromView: String) {
        // Add specific event params or whatever is needed
        // Can be a specific event as well.
//        let event = Event(
//            name: "navigate",
//            parameters: [
//                "from": fromView,
//                "to": toView
//            ]
//        )
        // Call specific implementation
        // Sumo.track()
    }

    func trackLogin() {}

    func trackHomeTap(extraParam: String) {
        // Implements optional function
    }
}
class AdobeTracker: Trackable {
    var type = TrackerType.adobe
}

extension AdobeTracker: TrackableHome {
    func trackNavigate(toView: String, fromView: String) {
        // Add specific event params or whatever is needed
        // Can be a specific event as well.
//        let event = Event(
//            name: "navigate",
//            parameters: [
//                "from": fromView,
//                "to": toView
//            ]
//        )
        // Call specific implementation
        // Sumo.track()
    }

    func trackLogin() {}

    // Does not implement optional func
    // trackHomeTap(extraParam: String)
}
class SumoTracker: Trackable {
    let type: TrackerType = .sumo
}

extension SumoTracker: TrackableHome {
    func trackNavigate(toView: String, fromView: String) {
        // Add specific event params or whatever is needed
        // Can be a specific event as well.
        let event = Event(
            name: "navigate",
            parameters: [
                "from": fromView,
                "to": toView
            ]
        )
        // Call specific implementation
        // Sumo.track()
    }

    func trackLogin() {}

// Does not implement optional func
// trackHomeTap(extraParam: String)
}

We also have implementations for other specific trackers using this setup, which makes it easy to add, disable, and maintain different trackers as needed. By abstracting away the implementation details and leveraging protocols like Trackable, we can create a flexible and modular tracking system that can be customized to suit the needs of any project.

Protocol naming tip for teams

When it comes to protocol naming in Swift, it’s important to follow some guidelines. The Swift.org documentation suggests using suffixes like -able, -ible, or -ing. However, it’s not always clear which suffix to use for a given protocol.

In some cases, a protocol may have multiple suffixes that sound good, like Eventable, Eventing, or Eventible. This can lead to confusion, especially for non-native English speakers.

One approach to simplify protocol naming is to simply add the word Protocol at the end of the protocol name, like EventProtocol. This approach can be helpful for ensuring consistency across a team and avoiding confusion during code reviews.

However, it’s important to make sure that all team members are on board with this approach and that it fits within the larger project’s naming conventions. Ultimately, the most important thing is to choose a naming convention that is clear, consistent, and easy to understand for everyone working on the project.

SOLID discussion and improvements

Does this code conform to SOLID? How could we improve it? What rules are followed partially? What are adhered?

If you have any suggestions on how to improve this implementation, please share them in the comments below. We can work together to refine the Tracker class and make it even more effective.

Don’t forget to read the follow-up, where we’ll be discussing how we’ve refactored the code according to SOLID principles in part 2.

1 thought on “Swift multiple trackers architecture – part 1”

Leave a Comment