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”