In one of my previous companies, it happened from time to time I had the opportunity to do some R&D of experimental ideas.
What came out once, was, in my opinion, pretty neat. It never saw the light in production and I don't want this amount of work to be forgotten, so here is, after years, a still valid outline of a powerful impression tracking engine on iOS.
Before further reading, you should be familiar with AOP and you should read my previous article on Analytics on iOS.
- You have an app with a feed
- You want to track the impressions of the items
- You don't want to track items displayed on screen during a fast scroll
- You only want to track impressions that stay on screen for more than n seconds
Reasons for this are, for example, you want to collect data for the impressions to better sell ads. Prepare to read a lot of code to understand the overall design, not the implementation (for that you need quite some time).
I'll use an approach similar to the one used in git: working directory, staging area & index have been used for tracking and discarding impressions, I'll use these terms in this article to leverage the analogy.
These are the main components:
- ADBImpressionManager: holds a store with the impressions (ADBImpressionData objects). When asked for the impressions, it flushes the store and returns the retrieved objects. Could be a stack with push and pop operations. Using an analogy with Git, it's a way for clients to simulate moving stuff away from the "working directory" into something else (the "staging area").
@interface ADBImpressionData : NSObject // things you want to track ... // mandatory fields to populate on appear and disappear of the element we want to track the impression of @property (nonatomic, strong) NSDate *startDate; @property (nonatomic, strong) NSDate *endDate; @end @interface ADBImpressionManager : NSObject - (void)addImpression:(ADBImpressionData *)impression; - (NSArray *)impressions; @end
- ADBThreadSafeStore: a thread-safe store user by the ImpressionManager. Accesses must be serial as we are putting objects here according to UI events (when cells are displayed). UI is intrinsically non thread-safe so, better be cautious.
@protocol ADBStoreProtocol <NSObject> @property (nonatomic, strong, readonly) NSArray *objects; @property (nonatomic, readonly) NSUInteger count; - (id)objectAtIndex:(NSUInteger)index; - (NSUInteger)indexOfObject:(id)object; - (BOOL)containsObject:(id)object; - (void)addObject:(id)object; // ... etc @end @interface ADBThreadSafeStore : NSObject <ADBStoreProtocol> // this class can hold only objects of a specific class // in our case, ADBImpressionData - (instancetype)initWithClass:(Class)clazz; @end
- ADBImpressionTracker: responsible for actually sending the tracking events over to the service (e.g. Google Analytics).
Impressions are batched by the caller before being sent with
@protocol ADBImpressionTrackingServiceProtocol <NSObject> - (void)trackImpressions:(NSArray *)impressions; @end @interface ADBGoogleAnalyticsImpressionTracker : NSObject <ADBImpressionTrackingServiceProtocol> @end
- ADBImpressionDataEncoder: used to convert ADBImpressionData objects to a format that can be sent over to the service and in a form respecting a given specification.
@protocol ADBObjectEncoderProtocol <NSObject> - (NSDictionary *)encodeObject:(id)object; @end @protocol ADBImpressionDataEncoderProtocol <NSObject> - (NSArray *)encodedImpressions:(NSArray *)impressions; @end @interface ADBImpressionDataEncoder : NSObject <ADBObjectEncoderProtocol, ADBImpressionDataEncoderProtocol>
- ADBImpressionTrackingManager: this component (ITM) put all of the above (sub)components together. A root level object is the right place for creating it (if not the AppDelegate 😷, something near). An impression tracker, an impression manager and an impression data converter are passed-in as dependencies. A configuration (something similar to what proposed here, including selectors for start and end tracking) is used to perform the necessary AOP. The number of seconds necessary to consider an impression to be a valid is also provided. In git terms, what the commitAndPush does is 1. commit the impressions to the impression manager and 2. push them remotely.
When committing them to the impression manager we check for the startDate and endDate of the ADBImpressionData object to verify that the impression was on screen for at least
impressionTimeseconds, otherwise the impression is trashed away (simply ignored).
When pushing the impressions remotely we grab the impressions from the manager (that flushes the store afterwards) and send them via the tracking service (impressions are batched before being sent).
@interface ADBImpressionTrackingManager : NSObject - (instancetype)initWithTrackingService:(id<ADBImpressionTrackingServiceProtocol>)trackingService impressionManager:(ADBImpressionManager *)impressionManager impressionDataConverter:(id<ADBImpressionDataEncoderProtocol>)impressionDataConverter configuration:(NSArray *)configuration impressionTime:(NSTimeInterval)impressionTime; - (void)commitAndPush; @end @interface ImpressionTrackingManager () - (void)_commitImpressionsToManager; - (void)_pushRemotely; @end
- RepeatedTimer: a simple component encapsulating the logic to trigger calls to selector on an object every n seconds (it's nothing more than a wrapper on top of NSTimer, but much nicer to use).
@interface ADBRepeatedTimer : NSObject - (instancetype)initWithTarget:(id)target selector:(SEL)selector interval:(NSTimeInterval)timeInterval; - (void)start; - (void)stop; @end
All of the above put together:
ADBImpressionManager *im = [[ADBImpressionManager alloc] init]; ADBGoogleAnalyticsImpressionTracker *it = [ADBGoogleAnalyticsImpressionTracker new]; ADBImpressionDataEncoder *idc = [[ADBImpressionDataEncoder alloc] init]; self.impressionTrackingManager = [[ADBImpressionTrackingManager alloc] initWithTrackingService:it impressionManager:im impressionDataConverter:idc configuration:configuration impressionTime:1.0f]; self.timer = [[ADBRepeatedTimer alloc] initWithTarget:self.impressionTrackingManager selector:@selector(commitAndPush) interval:10.0f]; [self.timer start];
Let's try to explain
Using the AOP approach we can (again, I'm not showing the implementation, let's just make the assumption it's possible) do things after some methods are called. If we consider the UITableView for our example, we want to AOP the
tableView:cellForRowAtIndexPath: to set the startDate property of an ADBImpressionData object and the
tableView:didEndDisplayingCell:forRowAtIndexPath: for the endDate property. Given the configuration provided (names of the classes and the selectors to bind to the start and end date), the ImpressionTrackingManager can do the hard AOP/swizzling and business logic. An internal data structure is used to keep the 'working directory' of the impressions where keys are the tracked classes, values are mutable dictionaries. The keys of those dictionaries are index paths, the values are ImpressionData objects (but that's way far from the scope).
Commit actions must happen everytime an impression ends to avoid losing duplicate impressions of the same item.
Impressions that (for some reason) don't hit the end via end selectors configured in the configuration (for example, they are still on the screen) are still evaluated on the next commit & push cycle to check if the minimum time of an impression to be valid (MT) has been reached.
Commit & push cycles are triggered periodically and the interval for such period must strictly be greater than the MT.
The following diagram should explain how impressions are committed to the ImpressionManager and when they are pushed remotely via the ImpressionTracker.
There are way many aspects not discussed in this article that make the outlined design work but they would need a deeper analysis that would be too much to take in for a single reading. For instance, Extended Analytics Info (EAI) (via associated objects on NSObject+ADBAnalytics) can also be used.
@interface NSObject (ADBAnalytics) - (NSDictionary *)analyticsEntryForKey:(NSString *)key; - (void)setAnalyticsEntry:(id)object forKey:(NSString *)key; @end #define ADBSetAnalyticsEntry(__obj, __key, __value) [__obj setAnalyticsEntry:__value forKey:__key];
The above macro could be used in your
collectionView:cellForItemAtIndexPath: like so:
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; id myObj = self.myDataSource[indexPath.row]; ADBSetAnalyticsEntry(cell, @"someKey1", someProperty1); ADBSetAnalyticsEntry(myObj, @"someKey2", someProperty2);
With the necessary implementation in the ITM, it'd be possible to tie together tracking information for impression events. In order for the ITM to reach the objects enriched with extra information, the view controllers (classes provided in the AOP configuration) could implement the ADBImpressionsProtocol and return some transformation of the datasource objects displayed in the table/collection view.
@protocol ADBImpressionsProtocol <NSObject> - (id)impressionItemAtIndexPath:(NSIndexPath *)indexPath; @end
It's quite a lot of code to digest, I know, but it should help to understand the overall design and how a solution to the original problem (something definitely not trivial) could be developed. With some crazy gymnastic and black magic, of course.