Xcode & Instruments: Measuring Launch time, CPU Usage, Memory Leaks, Energy Impact and Frame Rate

When you’re developing applications for modern mobile devices, it’s vital that you consider the performance footprint that it has on older devices and in less than ideal network conditions. Fortunately Apple provides several powerful tools that enable Engineers to measure, investigate and understand the different performance characteristics of an application running on an iOS device.

Recently I spent some time with these tools working to better understand the performance characteristics of an eCommerce application and finding ways that we can optimize the experience for our users. We realized that applications that are increasingly performance intensive, consume excessive amounts of memory, drain battery life and feel uncomfortably slow are less likely to retain users.

With the release of iOS 12.0 it’s easier than ever for users to find applications that are consuming the most of their device’s finite amount of resources. Users can now make informed decisions about which applications they want to keep around, and which they don’t.

As part of measuring the performance of this application, I decided to compile my findings into a thorough, comprehensive Performance Measurement Report that I can use as a template for measuring performance in the future. And to allow my team to re-evaluate the performance at regular intervals in the development cycle so that changes can be recorded over time, and changes that either positively or negatively affect performance can be identified early on and resolved as quickly as possible.


App Name iOS — Performance Measurement Report (DD/MM/YYYY)

Device Environment

Device: iPhone 6
OS: iOS 11.2.6 (15D100)
Network Connectivity: Wifi/Cellular (1.0 Gbit/s)
App Version: 4.16.0
Commit Hash: 026c834cb7ac6b7212b4e6b84b69a8a4eecd80e9

CPU Usage

App Startup — 3.66 seconds

Total time of App Launch = pre-main() time + post-main() time

Pre-main() — 1.12 seconds
Time it takes for the operating system to launch the process including — dylib loading, rebase binding, Obj-C setup, initializers. Collected using DYLD_PRINT_STATISTICS environment variable during runtime.

Total pre-main time: 1.1 seconds (100.0%)
dylib loading time: 866.21 milliseconds (76.9%)
rebase/binding time: 72.07 milliseconds (6.4%)
ObjC setup time: 52.54 milliseconds (4.6%)
initializer time: 134.81 milliseconds (11.9%)
slowest intializers :
libSystem.B.dylib : 8.99 milliseconds (0.7%)
AFNetworking : 32.60 milliseconds (2.8%)
Apptentive : 51.09 milliseconds (4.5%)
App Name : 50.80 milliseconds (4.5%)

Post-main() — 2.54 seconds

Time it takes for the launched process to setup the application, and reach application(:didFinishLaunchingWithOptions:) when the user can see the initial viewController and begin interacting with your app.

2018–10–10 15:42:42.784235–0700 App Name[643:186751] *** App Launched in: 2.541393 sec ***

App Startup has a Very High CPU Usage. We’re calling CommandMapper.mapAllCommands() which triggers 75 different command events to initialize third party frameworks, analytics/logging, or application specific functionality. CPU Usage will spike during startup, but immediately return to normal once these commands have finished (0–5 seconds).

Most CPU intensive initialization commands include:

- StartupCommand
- InitMonitoring
- InitAPIEnvironmentCommand
- InitFeedbackCommand
- InitFirstViewControllerCommand
- InitAnalytics
- InitCache

See More Information about App launch time analysis

General Usage

Note: Image above represents running the app, waiting for startup to complete, then navigating around Shop, My Bag, Wallet, Account, and Product screens without deliberately overworking the User Interface in a way that a typical user would not.

Generally CPU usage is between 0% — 100% while navigating around the app in a manner that you would expect from most users (not scrolling unnecessarily fast, or deliberately trying to overwork the UI).

Feature Specific Usage

Product Categories

Note: Image above represents running the app, waiting for startup to complete, then opening up the first category on the featured screen and scrolling extremely fast through the list of products. It does not represent the usage behavior of a typical user, but is intended to stress test the performance of the components that render the screen.

Product Categories is the most CPU intensive feature/screen of the application. It displays a UICollectionView with long lists of Products. It contains a high volume of network requests for products as the user scrolls, fetching product images, rendering images on screen, animating Shimmer views while loading images and sending various analytics events.

High CPU Usage (200% — 300%)

When scrolling extremely quickly throughout the Product Categories collection view of products, the CPU usage grows very rapidly up to 200% — 300%. It can cause the scroll view to lose its 60 fps frame rate and start to feel sluggish and slow. I’ve profiled this scenario in Xcode’s Instruments tooling in order to better understand why this would be happening. Identified issues and explanations are below:

Identified Issues

  1. Shimmer Framework

We’re using a third party framework called Shimmer to render shimmering loading states while Product images are loading. Our usage of this framework involves creating FBShimmerView’s each time that a UICollectionViewCell is re-used. This means we’re adding and removing Views as subviews of cells repetitively, when we could instead we instantiating one FBShimmerView per cell once it is dequeued (or in the nib), and then showing/hiding the view as needed. There’s also potentially performance issues in the framework that we aren’t aware of. We could replace this library with our own simpler, more tailored implementation of a shimmer that is more performance friendly.

2. ONUITestingConstants

We have a type that defines some variables to identify if the process is in UITesting Mode. These properties isTestingModeEnabled and shouldUseMockServer have getters that are computed properties. This means the getter gets run every single time we call isTestingModeEnabled which happens to be called intensively as the user scrolls the collection view, instead of once when ONUITestingConstants is initialized. These values are static and never change so they could be immutable properties, or static/class properties so that ProcessInfo is only called once on initialization.

3. ProductImages array

Product Images are now an array, so each time productImage(for resourceID:) is called, the array is filtered. Instead Product Images could be a hash table with resourceID as a key, and the images as values, so that this lookup can happen in constant time O(1). Especially for being called in cellForRowAtIndexPath(:). This work could be done as part of the Product Images changes with the API.

4. Service.swift JSON Decoding

Service.swift is called by many different places to execute network requests. Network requests themselves are dispatched out to the Networking Manager which uses Alamofire to make the requests, and will execute them on a background thread.

Once a request comes back to Service.swift, execution continues on the main thread. Service.swift then calls the Gloss JSON initializer (see line 69) for a generic type T, the type of whatever object model is required — this could be a CDPProductSearch model which contains many nested CDPProduct models.

When the CategoryDisplayViewController is fetching products while the user is scrolling, it’s calling this JSON initializer repeatedly on the main thread. These Service.swift calls are asynchronous anyway — so we could spend a lot less CPU cycles performing this work on the main thread, and instead move it to the background.

5. preFetchFutureProductImage

While the user is scrolling down the UICollectionView, this method is being called excessively. This is not only contributing to high CPU load, but is also triggering a high amount of Network Requests that cause the application’s overhead (need to turn on, and turn off the device’s radio antennas) to be unnecessarily high. There’s potentially some room to be smarter and more efficient about these calls, throttling them so that they don’t get called repeatedly within a very short period of time. I’d suggest investigating use of a debouncer.

6. viewFactory.createFromNib(ProductGridCellShimmerView.self)

Within cell(:forRowAtIndexPath:) we’re calling this method to instantiate a view from a UINib that we add as a subview to the cell, during the shimmer loading state, and then we remove it once the shimmer is removed. This means we’re calling this method each time a UICollectionViewCell is re-used, and means that we’re reading the same nib file unnecessarily repetitively, when we could be either creating this view in code, or only instantiating this view when the cell is first dequeued, and reusing the view each time by hiding/showing it.

This method call is one of the main offenders of high CPU usage for the entire CategoryDisplayViewController.

7. ProductGridCollectionViewCell.prepareForReuse

This method is also called many times as the user scrolls through the UICollectionView. We’re updating NSLayoutConstraints, as well as removing views from their superview, which is causing some not-insignificant impact on CPU performance. If needed we could potentially refactor parts of this cell to ensure we don’t need to perform such drastic changes to a view each time it needs to be re-used.

8. handleTabBarHiding(_ scrollView: UIScrollView)

As the UIScrollView y offset changes on this screen, we’re calling this method to update whether the shy tab bar needs to animate off screen or on screen. This function calls the ONUITestingConstants getters to check if the process is in UITesting mode, and is therefore causing a significant performance hit on the CPU.

Product Detail

CPU usage while using the Product Detail screen is between 0% — 50%. There are no immediate issues visible. While testing scrolling through Product Images, adjusting Pickup In Store values, viewing the Reviews, Size Guide and Details screens. No CPU spikes were immediately visible. This screen is only displaying a constant size of views on the screen, and does not access any unique functionality such as the GPU, or Location Services and therefore does not have a significant impact on the applications performance or overhead.

Bag / Save For Later

CPU usage while using the Bag and Save For Later screen is between 0% — 50%. No CPU spikes were immediately visible. This screen is only displaying a constant size of views on the screen, and does not access any unique functionality such as the GPU, or Location Services and therefore does not have a significant impact on the applications performance or overhead.

Wallet

CPU usage while using the Wallet screen is between 0% — 50%. No CPU spikes were immediately visible. This screen is only displaying a constant size of views on the screen, and does not access any unique functionality such as the GPU, or Location Services and therefore does not have a significant impact on the applications performance or overhead.

Account

CPU usage while using the Account screen is between 0% — 50%. No CPU spikes were immediately visible. This screen is only displaying a constant size of views on the screen, and does not access any unique functionality such as the GPU, or Location Services and therefore does not have a significant impact on the applications performance or overhead.

Search

CPU usage of this screen mimics the usage measured on the Product Categories (200% — 300%) screen as this screen is very similar and shares a lot of the same functionality. It displays a long list of products in a UICollectionView, and therefore suffers from a lot of the same symptoms discovered in the CategoryDisplayViewController. See Product Categories for ways to improve the CPU usage of this screen.

Memory Usage

High: 200MB — 300MB
Average: 70MB — 200MB

With typical usage of the app, the process tends to use between 200MB — 300MB of memory, which is primarily caused by Product Images stored in the in-memory cache. As the user navigates throughout the Product Category screens and Product Detail screens, the in-memory cache fills up and/or purges and continues to fill up. This does not have a significant effect on the user experience, and does not cause the device to receive any memory warnings but does cause the device overhead to grow and has a negative effect on the application’s Energy Impact.

Memory Leaks / Retain Cycles

Measuring usage of the app using Instrument’s Memory Leak profiler revealed that there are no major memory leaks caused by the application itself. While it did reveal the existence of memory leaks in a small number of third party dependencies such as analytics and network logging frameworks these appear to be insignificant and don’t cause a significant spike in the memory footprint of the application.

Two false positive retain cycles were detected in the MainViewController, and BagItemCellListHelper — based on thorough investigation into the source code it was deemed that these objects are not accumulating over time on the heap and not causing an increase in the memory footprint.

Energy Impact

During average usage of the app, without deliberately trying to stress test the interface the app has a high energy impact and overhead. Xcode’s runtime analyzer shows that a high CPU usage contributes most to the energy footprint and overhead the process. Second to CPU usage, a large amount of network activity requires the device to need to repetitively turn on and turn off the device antenna’s in order to perform network requests.

This means that while using the app, the user might see it contribute significantly to decreasing their device’s battery life. In order to improve Energy efficiency, average CPU usage needs to be decreased such as in the Product Categories screen. Memory usage needs to be reduced, such as the size of the images in the in-memory cache. And network activity needs to be streamlined, or batched in a more strategic way that minimizes the amount of individual network requests being made — one such contributor to this issue is the high volume of analytics events that are being sent in the background as the user scrolls through products and performs certain actions.

Frame Rate

Apple recommends that apps target a frame rate of 60 FPS, equivalent to 16.67 ms per frame.

Average: 46 fps

Scroll performance on screens that are content heavy and involve a user scrolling through a long list of content such as the Product Categories screen (CategoryDisplayViewController) is less than optimal. Average frame rate while scrolling through product cells is approximately 46 fps. This means that scrolling feels slow and jittery making the app feel frustrating and hard to use.

Based on investigations above this scroll performance benchmark is caused by high CPU usage, on the main thread, during UICollectionViewCell layout during scroll events. Cells are image and content heavy, performing too many operations that are too slow on the main thread. It is recommended that this be addressed and that tasks that require CPU intensive work be minimized, or moved to a background thread to prevent blocking the UI from updating and scroll views from scrolling smoothly.


Constructing this report in my spare time has afforded me the opportunity to identify and craft my own unique and systematic approach to handling performance management as a Software Engineer and to better understand more generally the characteristics of high performance software. Building a broad, deep and thorough understanding of performance will allow me to apply an informed mindset and knowledgeable expertise to the way that I architect and build software in the future. I intend to amend, improve and edit this report as I continue to work with Apple’s performance tooling and more generally with the tools that Software Engineers use to ship high performance mobile products.


Thanks for reading, view more here: Twitter // Instagram // Blog