In this episode, we focus on the analytics API itself. We define a single entry point for analytics and remove some of the string literals at the call site. The resulting API is surprisingly small, intuitive to use, and easy to extend.
Defining an Enum
I usually choose a distinctive name for the libraries and components I create to facilitate searching and debugging. Let me show you what I mean. Add a group and name it Journey. Journey is the name of the analytics library we create in this series. When a user uses an application, they make a journey through the application hence the name. You can use whichever name you prefer.
Add a Swift file to the Journey group and name it Journey.swift. We declare an internal enum with name Journey. The Journey enum is the entry point of the API. The Journey enum also acts as a namespace. When we need to send an analytics event, we use the Journey enum. How that works becomes clear later in this episode.
import Foundation
internal enum Journey {
}
Every analytics event the application can trigger is defined as a case of the Journey enum. We define a case for creating a note, updating a note, and deleting a note. This simple change is a significant improvement over the existing analytics implementation. A developer using Journey can choose from a predefined list of analytics events. There is no longer a need to browse an external document for the name of a particular analytics event.
import Foundation
internal enum Journey {
// MARK: - Cases
case createNote
case updateNote
case deleteNote
}
The Journey enum is convenient for defining analytics events, but, as you know, an enum cannot have stored properties. We need to define a type that encapsulates an analytics event, including its properties.
Create an extension for the Journey enum. In the extension, we declare a struct with name Event. The Event struct encapsulates the details of an analytics event. It declares two properties, name of type String and a dictionary of properties. The properties dictionary has keys of type String and values of type Any.
extension Journey {
struct Event {
// MARK: - Properties
let name: String
let properties: [String: Any]
}
}
There are a few details I want to point out. Notice that the Event struct is nested within the Journey enum. The Event struct lives in the Journey namespace and that implicitly communicates to the consumer of the API that it is tied to Journey, the analytics library. The name property is of type String. Don't worry about this for now. While we won't use string literals at the call site, the analytics service we send events to expects events to have a name of type String.
Let's put the Event struct to use. We revisit the Journey enum. Declare a method with name properties(_:). It accepts a single argument, properties, a dictionary with keys of type String and values of type Any. Its return value is an Event object.
import Foundation
internal enum Journey {
// MARK: - Cases
case createNote
case updateNote
case deleteNote
// MARK: - Public API
func properties(_ properties: [String: Any]) -> Event {
}
}
The implementation is as simple as it gets. We create and return an Event object. There is one problem, though. The first argument of the initializer of the Event struct is the name of the event and it is of type String.
The solution is surprisingly straightforward. We define a private, computed property with name event and it is of type String. The computed event property defines the mapping between the cases of the Journey enum and the event names that are sent to the analytics service.
import Foundation
internal enum Journey {
// MARK: - Cases
case createNote
case updateNote
case deleteNote
// MARK: - Properties
private var event: String {
switch self {
case .createNote:
return "create-note"
case .updateNote:
return "update-note"
case .deleteNote:
return "delete-note"
}
}
// MARK: - Public API
func properties(_ properties: [String: Any]) -> Event {
}
}
This approach has a number of advantages. Event and property names can sometimes have odd names and they usually don't match the naming conventions you use in your project. What's nice about this approach is that the API doesn't explicitly expose the event names. You can access the event name through the name property of the Event struct, but you don't need to use them at the call site. At the call site, you use the cases the Journey enum defines and the names of the cases is something you define and control.
With the computed event property defined, we can create the Event object in the properties(_:) method and return it.
func properties(_ properties: [String: Any]) -> Event {
Event(name: event, properties: properties)
}
Because the properties(_:) method returns an Event object, we can make use of method chaining, making the API even more intuitive to use. This may seem like a subtle detail, but it isn't. Xcode's autocompletion helps the consumer of the API correctly use the API we are building. This becomes clear in a few moments when we use the API.
Let's revisit the Event struct. Declare a method with name send(to:). The send(to:) method defines a single parameter of type GoogleAnalyticsClient.
extension Journey {
struct Event {
// MARK: - Properties
let name: String
let properties: [String: Any]
// MARK: - Public API
func send(to analyticsService: GoogleAnalyticsClient) {
}
}
}
In the body of the send(to:) method, we invoke the trackEvent(with:properties:) method of the GoogleAnalyticsClient class, passing in the name and properties of the event.
extension Journey {
struct Event {
// MARK: - Properties
let name: String
let properties: [String: Any]
// MARK: - Public API
func send(to analyticsService: GoogleAnalyticsClient) {
analyticsService.trackEvent(with: name, properties: properties)
}
}
}
The Journey enum is ready to be used in the notes view model. Open NotesViewModel.swift and navigate to the createNote(_:) method. We create a Journey object for the event we want to send to the analytics service. We define the properties of the event by invoking the properties(_:) method of the Journey object. We continue to use string literals for the properties dictionary for the time being. Because the properties(_:) method returns an Event object, we can use method chaining and invoke the send(to:) method to send the event to the analytics service. We pass a reference to the GoogleAnalyticsClient singleton to the send(to:) method.
func createNote(_ note: Note) {
Journey.createNote
.properties([
"source": "home",
"kind": "template"
]).send(to: .shared)
}
We can make one subtle improvement. Revisit Journey.swift and navigate to the send(to:) method of the Event struct. The send(to:) method accepts a GoogleAnalyticsClient instance as its only argument. We can define a default value for the analyticsService parameter, the GoogleAnalyticsClient singleton.
func send(to analyticsService: GoogleAnalyticsClient = .shared) {
analyticsService.trackEvent(with: name, properties: properties)
}
Revisit NotesViewModel.swift. Because we define a default value for the analyticsService parameter, we can omit the parameter at the call site. I hope you agree that the API is starting to look quite nice.
func createNote(_ note: Note) {
Journey.createNote
.properties([
"source": "home",
"kind": "template"
]).send()
}
Let's refactor the updateNote(_:) method next. We create a Journey object for the event we want to send to the analytics service. We no longer need to fiddle with string literals. Xcode displays a list of options we can choose from. If we need to add support for a new event, then we add a case for the new event to the Journey enum. It's that simple.
Attaching properties to an event is intuitive. We simply invoke the properties(_:) method on the Journey object. Because the properties(_:) method returns an Event object, we can use method chaining and invoke the send(to:) method. Xcode's autocompletion kicks in to help you at every step, making it difficult to misuse the API we built.
func updateNote(_ note: Note) {
Journey.updateNote
.properties([
"source": "home",
"kind": "template",
"word_count": note.wordCount
]).send()
}
Refactoring the deleteNote(_:) method is very similar.
func deleteNote(_ note: Note) {
Journey.deleteNote
.properties([
"kind": "template",
"word_count": note.wordCount
]).send()
}
What's Next?
In the next episode, we focus on the properties of the event. We currently pass the properties(_:) method of the Journey object as a dictionary that contains string literals. That isn't ideal for a number of reasons we discussed in the previous episode. We improve that aspect of the API next.