Striving for iOS App Performance
by Experience Team at Strava
Getting Started
This year, Strava achieved a milestone of 1 billion new activities in 18 months*. The first billion activities were recorded over 8 years. Our rapid growth has created new engineering opportunities. One of the focus areas in the past year was our app’s performance. As the size of our app expanded, more features created increased loading times for content. It became clear that there were a lot of improvements to be made for app launch, feed loading, and dependency management.
Time to Something Useful (TTSU)
For our mobile clients, we wanted to focus on a single performance metric that could have the most benefit to our athletes. Our product and engineering teams mutually targeted a “time to something useful” metric (abbreviated as “TTSU”) as a clear goal. This new metric would focus on the time it takes from when the athlete taps our app icon, to render a feed, and then to display content.
From an engineering perspective, our team had two ways to improve TTSU.
- The application could do less work overall.
- The application could defer nonessential work until after content is displayed.
Seems simple, right? Importantly, doing less work at startup time would take the identification of components that were making the experience slow. From a creative perspective, we had to determine which work could be done early or deferred.
Our team determined three targets for improvement:
- Improve dependency injection and the dependency graph
- Optimize and defer API requests
- Audit external dependencies
Instrumentation and Measurement of Performance
In order to understand what performance improvements to focus on, we needed to first understand which parts of our application’s code were actually slow. This is where powerful instrumentation comes in handy.
Xcode Instruments — Time Profiler
Out of the box, Xcode provides valuable tools for understanding your app’s performance. We used Xcode’s Instruments Time Profiler in this work. When running the app in a profiling configuration, we used Time Profiler to breakdown the speed of our app’s methods, function calls, and class instantiations during the app startup, feed container, and feed content phases.
As shown above, each row in this table is part of the call tree of the selected thread. Double clicking on a row will show the place in the app where this code is being executed. From within this view, we can now easily see which optimizations to make.
OSLog and OSSignpost
Two lesser known techniques to help us identify the timing of calls within our app are OSLog and OSSignpost. These classes provide the ability to directly send messages to the system logs.
OSSignpost helps us identify the timing of tasks when running Xcode’s Instruments Time Profile as mentioned above. For our performance tracing calls, we can use a pair of os_signpost declarations to debug problems. The example below demonstrates how a signpost is declared.
OSLog provides custom group-based logging using identifiers and types. OSLog is able to appropriately define levels for each of our logs (default, info, debug, error, fault). Each of these types can be passed as a parameter to our os_log declaration. Each os_log declaration is placed within a method body of the location we are interested in logging.
Apple’s WWDC 2018 talk provides a useful explanation of the ways in which OSSignpost and OSLog can be used for instrumenting performance. This technique however does have limitations. Most notably, it can only be used in development builds with a connected device.
Analytics tracing
Across the Strava organization, we use analytics to measure common user interactions for the business. We leaned heavily on tracing to instrument spans of time between the start and end of various runtime operations. For example, we added traces for the three metrics — app startup, feed container, feed content — where we are interested in having millisecond precision data. For production builds, analytics tracing is one of the more powerful techniques we have to measure Strava in a real-world setting.
On all devices in production, these traces can be passed back to a central dashboard. Using this dashboard, we can visualize increases in performance between different app versions. This helps us also identify regressions in production. This allows us to see which version regressed the metric, which provides a starting point for investigating the cause.
In our code-base, it is simple to instrument method calls. The most difficult part of the process however is ensuring what you measure is equal to what you expect to measure. As an example, it took us multiple tries to instrument our start-end calls correctly for the feed trace. Even if you identify these start and end locations, you may have special cases like user onboarding where an end call may never be executed. In these cases, all of your performances traces will now be incorrect due to an incredibly high trace time.
Improving Dependency Injection
For all objects in the application, we use a very common software pattern called dependency injection. This is a technique that ensures each class is initialized with everything it needs to properly function. Dependency injection’s main goal is to create loosely coupled systems that provide generic interfaces to concrete class instances. The loosely coupled systems are split into the factory and the assembly. The assembly contains the concrete implementations while the factory maintains an abstract interface. This separation ensures the application’s classes implement their dependencies without knowledge of concrete classes.
In the long-run, dependency injection not only creates clean code; it also increases the testability of the application. By decoupling classes and their dependencies, you can inject new parameters or mock objects during testing. Dependency injection also ensures the open-closed principle[2]. Classes that abstract their dependencies allow for extension but closed to modification. When dependencies are loosely coupled, you can easily modify the behavior of your class without impacting the underlying implementation.
In Strava’s early history, performance requirements with a dependency injection architecture were overlooked. As new teams added features, our dependency graph grew exponentially. Classes were rarely audited for unused dependencies, injection was slow, and app startup times were greatly increased. As part of improving app startup times, dependency injection was a clear target for improvement. We had three main ways to change how dependency injection impacted app startup:
- Reduce assembly size by auditing the dependency graph.
- Migrate the assembly to use a faster form of dependency resolution.
- Refactor the application to take advantage of this faster form of dependency injection.
Reduce assembly size by auditing the dependency graph
As mentioned, the size of our dependency graph grew exponentially over time. Since our focus was on app startup, we could isolate all of the dependencies used, audit unused dependencies, and simplify the assembly. Below, you can see a small snippet of the complexity of this dependency graph. By auditing each class, we slowly figured out which dependencies were not needed. While this has a marginal impact on performance, a unidirectional assembly reduces the amount of classes the application needs to initialize.
Migrate the assembly to use a faster form of dependency resolution
One of the main ways we increased performance was to migrate our existing dependency injection to use a faster form of dependency resolution. For the past four years, we depended on a third-party dependency injection framework written in Objective-C. By moving to a Swift-only third-party dependency injection framework, we were able to maximize the modern benefits of Swift.
This faster form of dependency resolution defers the initialization of the dependency graph until objects are needed. With our old third-party library, the entire dependency graph needed to be built at app startup. By deferring the construction of the dependency graph to only when resources are used, we were able to see large performance benefits.
Refactor application to take advantage of faster dependency injection
Once we migrated our dependencies to this modern form, we were really able to optimize for our metrics. For instance, if we are trying to optimize for feed content, we can move our assemblies so that only necessary objects are initialized. If a dependency is not on the startup path it can be removed from the app start assembly to reduce the total registrations. This pattern of optimization led to successive assemblies: app delegate, launch, core, etc. Each of these assemblies contain classes that would only be needed at a specific point in the application. This technique reduces the size of the object graph during startup time.
Anti-Patterns
Over time, many anti-patterns found their way into the codebase. During our audit of performance, we found a few cases where dependency management was improperly designed. In one case, we found the use of an “options” dictionary — where keys in the dictionary pointed towards dependencies. Since this dictionary would unnecessarily instantiate a group of dependencies, it would have consequential performance issues. The dictionary would be prepared with a host of dictionaries only accessible by a key. For instance, an API client in a class may be injected via “options[kAPIClient]”. A technique like this would be very prone to errors. Dictionaries have no protection on null values for a given key. In order to remove this anti-pattern, all cases of this options dictionary were refactored to use our improved dependency injection framework.
Deep Linking
One of the high-use areas of our app is the deep link system. These types of links are used across the company to trigger special functionality of the app. For instance, a marketing email can trigger a special prompt in the app. Deep links enable us to route the user to a page in our application based on the deep link’s path. Deep links are triggered both internally and externally in the application.
Generally, all URLs point to a URLDestination class. This URLDestination class can handle showing a screen or mutating data. The URL to URLDestination map exists as a 1:1 dictionary-based map. For instance, “strava://record” can map to a RecordURLDestination.
One of the issues we found was that URL destinations often used the initialization anti-pattern discussed in the previous section. In order for each URL destination to properly have its dependencies injected, we created a provider class that could give our deep link mapper a reference to the object it needed. Removing this anti-pattern enabled us to streamline the dependency injection architecture used by all of our deep links.
Optimizing API Requests
One of the most time consuming aspects of TTSU is requesting content from the network. Kicking off this request as early as possible in the application would ensure we could have the content ready as soon as possible. During the dependency injection improvement, we identified all of the dependencies necessary to set up an APIClient. This optimization allows us to directly instantiate an APIClient, make a request for data, then load the rest of our dependencies while the client is waiting on the server’s response.
Auditing External Dependencies
As part of a long-term project, it was also our goal to reduce the number of external dependencies needed by our application. External dependencies would only affect pre-main time (as opposed to our post-main time). Since we are unable to reliably measure pre-main time in production, determining the impact of our external dependencies could not be gathered. Regardless, it was recognized that this practice would benefit us architecturally in the long run. By reducing the footprint of our external dependencies, the size of the application could be smaller allowing the operating system to more easily launch and reduce initial app download times.
Conclusion
By the end of the project, our team was happily surprised by the amount of ground we were able to cover. In only a few months, all of our dependencies were migrated to the new dependency injection framework, API request timing was optimized, and many external dependencies were removed. Our team not only hit our target performance goal, but we greatly exceeded it.
Making performance a priority has become an important goal at Strava. With the number of athletes we serve, it is very important for athletes to count on Strava in order for us to be the record of the world’s activities.
Lessons Learned
- Profiling our app early and often in a normal development workflow can reduce and catch performance regressions.
- Adding robust performance tracing can help us identify performance regressions in production.
- Increasing performance should not only be a quarterly goal but a consistent goal.
- Setting up accurate instrumentation in both development and production is difficult.
- It is important to know the size and performance cost of our 3rd-party code.
- Improving our dependency injection and dependency management for new features and roadmaps is important.
Special thanks to Rodrigo Gutierrez, Tom Drummond, Matthew Robinson, Jason Cheng, Merty McGraw, Harini Iyer, Graham Keggi, and all members of the Strava Experience team for all their performance efforts.
If you enjoyed this article, please feel free to reach out and look at our open positions.