Chapter 1. Performance in Mobile Apps
This book assumes that you are an iOS developer and have been writing native iOS apps for a substantial amount of time—and that you now want to take the leap from being yet another iOS developer to the top of the league.
Consider the following statistics:1
-
79% of users retry an app only once or twice if it failed to work the first time.
-
25% of users abandon an app if it does not load in 3 seconds.
-
31% of users will tell others about their bad experience.
These numbers reemphasize the importance of optimizing your app for performance. Getting your app recognized in the users’ view is not just about the functionality. It is about providing a smooth experience all throughout the interaction with the app.
For any particular task, there might be several apps available in the App Store to accomplish it. But users will stick to the one that is either indispensable or has no glitches and stands out from others in terms of performance.
Performance is impacted by many factors, including memory consumption, network bandwidth efficiency, and user interface responsiveness. We will first outline different types of performance characteristics, before moving on to ways of measuring them.
Defining Performance
From a technical standpoint, performance is, strictly speaking, a very vague term. When someone identifies an app as being a high-performing one, we don’t necessarily know what that means. Does the app use less memory? Does it save money on network usage? Or does it allow you to work fluidly? The meaning can be multifaceted, and the implications abundant.
Performance can be related to one or more of the considerations that we discuss next. One part of these considerations is performance metrics (what we want to measure and monitor) while the other is about measurement (actually collecting the data).
We explore the measurement process in great depth in Chapter 11. Improving the usage of the engineering parameters is the crux of Part II and Part III of the book.
Performance Metrics
Performance metrics are the user-facing attributes. Each attribute may be a factor of one or more engineering parameters that can be measured.
Memory
Memory refers to the minimum RAM that the app requires to run, and the average and maximum memory that it consumes. Minimum memory puts a strong constraint on the hardware, whereas higher average or peak memory means more background apps are likely be killed.
Also, you must ensure that you do not leak memory. A gradual increase in memory consumption over time results in a higher likelihood of app crashes due to out-of-memory exceptions.
Memory is covered in depth in Chapter 2.
Power Consumption
This is an extremely important factor to tackle when writing performant code. Your data structures and algorithms must be efficient in terms of execution time and CPU resources, but you also need to take into account various other factors. If your app drains battery, rest assured that no one will appreciate it.
Power consumption is not just about calculating CPU cycles—it also involves using the hardware effectively. It is therefore important to not only minimize power consumption but also ensure that the user experience is not degraded.
We cover this topic in Chapter 3.
Initialization Time
An app should perform just enough tasks at the launch to initialize itself so that the user can work with it. Time taken to perform these tasks is the initialization time of the app. Just enough is an open-ended term—finding the right balance is dependent on your app’s needs.
One option is to defer object creation and initialization until the app’s first usage (i.e., until the object is needed). This is known as lazy initialization. This is a good strategy, but the user should not be kept waiting each time any subsequent task is performed.
The following list outlines some of the actions you may want to execute during your app’s initialization, in no particular order:
-
Check if the app is being launched for the first time.
-
Check if the user is logged in.
-
If the user is logged in, load previous state, if applicable.
-
Connect to the server for the latest changes.
-
Check if the app was launched with a deep link. If so, load the UI and state for the deep link.
-
Check if there are pending tasks from the last time the app was launched. Resume them if need be.
-
Initialize object and thread pools that you want to use later.
-
Initialize dependencies (e.g., object-relational mapping, crash reporting system, and cache).
The list can grow pretty quickly, and it can be difficult to decide what to keep at launch time and what to defer to the next few milliseconds.
We cover this topic in Chapter 5.
Execution Speed
Once the user opens an app, the expectation is for it to work as quickly as possible. Any necessary processing should be handled in as little time as possible.
Consider a photo app, for example. A live preview is ideal for simple effects like changing brightness or contrast where the processing needs to happen within milliseconds.
This may require parallel processing for local computation or the ability to offload to the server for complex tasks. We will touch on this topic in Chapter 4, Chapter 6, and Chapter 7. Chapter 11 covers various related tools.
Responsiveness
Your app should be fast to respond to user interaction. Responsiveness is the result of all the optimizations and trade-offs that you have made in your app.
There may be multiple apps in the App Store to accomplish similar or related tasks. Given an array of options, the user will ultimately choose the app that is most responsive.
Parallel processing for optimal local execution is covered in Chapter 4. Best practices for implementing fluid interactions in your app are covered in Chapter 5 and Chapter 6. We explore testing your app in Chapter 10.
Local Storage
Any app that stores data on a server and/or has to refresh its data from an external source must plan for local storage for offline viewing capabilities.
For example, a mail app will be expected to at least show previously downloaded messages if the network is not present or the device is in offline mode.
Similarly, a news app should be able to show recently updated news for offline mode as well as an indicator showing which articles are new and unread.
However, loading from local storage and syncing the data should be painless and fast. This may require selecting not only the data to be cached locally but also the structure of the data, choosing from a host of options, as well as the frequency of sync.
If your app uses local storage, you should provide an option to clean it. Unfortunately, most of the apps in the market do not do so. What is more worrisome is that some of these apps consume storage in the hundreds of megabytes. Users frequently uninstall these apps to reclaim local storage. This results in a bad user experience, thereby threatening the app’s success.
Looking at Figure 1-1, you will see that over 12 GB of space has been used and the user is left with only 950 MB. A large part of the data can be safely deleted from local storage. The app should provide an option for cache cleanup.
Tip
Always give the end user an option to clean up the local cache.
If the user has iCloud backup enabled, the app data will consume the user’s storage quota. Use it prudently.
The topics that impact local storage are covered in Chapters 7, 8, and 9.
Interoperability
Users may use multiple apps to accomplish a task, which requires interoperability across them. For example, a photo album may be best viewed in a slideshow app but might require another app for editing it. The viewer app should be able to send a photo to the editor and receive the edited photo.
iOS provides multiple options for interoperability and sharing data across apps. UIActivityViewController
, deep linking, and the MultipeerConnectivity
framework are some of the options available on iOS.
Defining good URL structure for deep linking is as important as writing good code to parse it. Similarly, for sharing data using the share sheet, it is important to identify the exact content to be shared as well as to take care of security concerns that arise from processing content from an untrusted source.
It would be a really bad user experience if your app took a long time just to prepare data to be shared with a nearby device.
We discuss this in Chapter 8.
Network Condition
Mobile devices are used in varying network conditions. To ensure the best user experience, your app must work in all of the following scenarios:
-
High bandwidth and persistent network
-
Low bandwidth but persistent network
-
High bandwidth but sporadic network
-
Low bandwidth and sporadic network
-
No network
It is acceptable to present the user with a progress indicator or an error message, but it is not acceptable to block indefinitely or let the app crash.
The screenshots in Figure 1-2 show different ways in which you can convey the message to the end user. The TuneIn app shows how much of the streaming content it has been able to buffer. This conveys to the user the expected wait time before the music can start. Other apps, such as the MoneyControl and Bank of America apps, just provide an indefinite progress bar, a more common style for non-streaming apps.
We cover this topic in Chapter 7.
Bandwidth
People use their mobile devices on various network types with speeds ranging from hundreds of kilobits per second to tens of megabits per second.
As such, optimal use of bandwidth is another key parameter that defines your product’s quality. In addition, if you have been developing your app using low-bandwidth conditions, running it in high-bandwidth conditions can produce different results.
In around 2010, my team and I were developing an app in India. In low-bandwidth conditions, the app’s local initialization would happen long before initial responses from the server were available, and we tuned the app for those conditions.
However, the app was focused on the South Korean market, and when we tested it there, the results were extremely different. None of our optimizations worked, and we had to rewrite a large chunk of code that could have resulted in resource and data contention.
Planning for high performance does not always result in optimizations, but can result in trade-offs as well.
Chapter 7 covers best practices for optimally using bandwidth.
Data Refresh
Even if you do not have any offline viewing capabilities, you may still refresh periodically with data from the server. The rate at which you refresh and the amount of data transferred will affect overall data consumption. If the number of total bytes transferred is large, the user is bound to exhaust his data plan quickly. And if that value is large enough, you may have just lost a user.
In iOS 6.x and below, if your app is in the background, the app cannot refresh data. In iOS 7 onward, the app can use background app refresh for periodic refreshes. For live chat apps, a persistent HTTP or raw TCP connection may be more useful.
Multiuser Support
A family might share a mobile device, or a user may have multiple accounts for the same application. For example, two siblings might share the same iPad for games. As another example, a family may want to configure one device to check each person’s emails during vacation to minimize roaming costs, particularly during international travel. Similarly, one person may have multiple email accounts to be configured.
Whether you want to support multiple simultaneous users will be dependent on your product. But if you do decide to offer this feature, make sure to follow these guidelines:
-
Adding a new user should be efficient.
-
Updates across the users should be efficient.
-
Switching between users should be efficient.
-
User-data boundaries should be neat and without any bugs.
Figure 1-3 shows examples of two apps with multiuser support. The left shows the account selector for Google apps while the right shows the one for Yahoo apps.
You will learn how to make your application secure for multiuser support and more in Chapter 9.
Single Sign-on
If you have created multiple apps that allow or require sign-in, it is always a good idea to support single sign-on (SSO). If a user logs in to one of your apps, it should be one-click sign-in to your other apps.
This process requires more than just sharing data across apps—you’ll also need to share state, synchronize across your apps, and more. For example, if the user signs out using one of the apps, signout should also occur in all other apps where the user signed in using SSO.
In addition, the synchronization across the apps must be secure.
This is covered in Chapter 9.
Security
Security is paramount in a mobile app, particularly because sensitive information might be shared across apps. It is important to secure all communications, as well as both local and shared data.
Implementing security requires additional computation, memory, or storage, which is at odds with your end goal of striving for maximum speed and minimum memory and storage requirements.
As a result, you’ll need to trade off between security and other factors.
Adding multiple layers of security degrades performance and may have a perceivable negative impact on user experience. Where you draw the line with security is app- and user demographics–determined. In addition, the hardware plays an important role: the options chosen will vary based on the computing capabilities of the device.
Security is covered in depth in Chapter 9.
Crashes
Apps can and do crash. Extreme optimizations can lead to crashes. Likewise, using native C code can lead to crashes.
A high-performing app will try to not only secure itself from crashes but also recover gracefully if a crash actually happens, particularly if it was in middle of an operation when the crash occurred.
Crash reporting, instrumentation, and analytics are covered in depth in Chapter 12.
App Profiling
There are two ways to profile your app to measure the parameters that we have discussed: sampling and instrumentation. Let’s take a look at each.
Sampling
Sampling (or probe-based profiling), as the name implies, requires sampling the state at periodic intervals, generally with the help of tools. We explore these tools in “Instruments”. Sampling provides a great overall picture of the app, as it does not interfere with its execution. The downside of sampling is that it does not return 100% accurate details. If the sampling frequency is 10 ms, you will not know what happens for the 9.999 ms between the probes.
Tip
Use sampling for initial performance explorations and to track CPU and memory utilization.
Instrumentation
Instrumentation—that is, modifying the code to log detailed information—provides more accurate results than sampling. This can be done proactively for critical sections, but can also be done reactively to troubleshoot problems found during profiling or through user feedback. We will discuss this process in more depth in “Instrumenting Your App”.
Caution
Because instrumentation involves injecting extra code, it does impact app performance—it can take a toll on memory or speed (or both).
Measurement
Now that we have established the parameters we would like to measure and explored the types of profiling for measurement, let’s run through the steps to implement it.
By measuring performance and identifying where you truly have problems, you can avoid the pitfall of premature optimization described by Donald Knuth:
The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.2
Project and Code Setup
In the following sections, we will set up a project to be able to measure the parameters we’ve identified during development as well as production. There are three sets of tasks for project configuration, setup, and code implementation:
- Build and release
- Testability
-
Ensure that your code works with both mock and real data, including isolated replication of real-world scenarios.
- Traceability
-
Ensure that you can resolve errors by identifying where the problem happened and what the code was trying to do at that stage.
The following subsections take a look at each of these options.
Build and release
Until recently, build and release was an afterthought. But thankfully, with the urge to go nimble and agile, systems and tools have evolved. They are now sped up to pull in dependencies, to build and release the product for testing or for enterprise distribution, and/or to upload to iTunes Connect for public release.
In a blog post published by Joel Spolsky in 2000, he asks the question, “Can you build your app in one click (from the source)?” The question still stands today. And the answer may define how quickly you can respond to improving quality and performance after defects or bottlenecks have been identified.
CocoaPods, written in Ruby, is the de facto dependency manager for Objective-C and Swift projects.3 It integrates with Xcode command-line utilities for build and release.
Testability
All apps have multiple components that work together. A well-designed system supports loose coupling and tight cohesion, allowing you to replace any or all of a component’s dependencies.
You should test each component in isolation by mocking out the dependencies. In general, there are two types of tests:
- Unit tests
-
Validate the operation of an individual unit of code in isolation. This is typically done in an environment that repeatedly calls methods with a variety of input data to assess how the code performs.
- Functional tests
-
Validate the operation of a component in the final integrated setup, either in the final shippable version of the software or in a reference app built specifically for test purposes.
We explore testing in detail in Chapter 10.
Crash Reporting Setup
Crash reporting systems collect debug logs for analysis. There are dozens of crash reporters available on the market. With no particular bias, Flurry has been used in this book. The primary reason I chose Flurry is that crash reporting and instrumentation can be set up using one SDK. We discuss instrumentation in depth in Chapter 12.
To use Flurry, you’ll need to set up an account at www.flurry.com, get an API key, then download and set up the Flurry SDK. Example 1-1 shows the code for the initialization.
Example 1-1. Configuring crash reporting in the app delegate
#
import "Flurry.h"
-
(
BOOL
)
application:
(
UIApplication
*
)
application
didFinishLaunchingWithOptions:
(
NSDictionary
*
)
launchOptions
{
[
Flurry
setCrashReportingEnabled
:
YES
]
;
[
Flurry
startSession
:
@"
API_KEY
"
]
;
}
Caution
The crash reporting systems (CRS) will set the global exception handler using the NSSetUncaughtExceptionHandler
method. If you have been using a custom handler, it will be lost.
If you want to keep your exception handler, set it after initializing the CRS. You can get the handler set by the CRS using the NSGetUncaughtExceptionHandler
method.
Instrumenting Your App
Instrumenting your app is a very important step in understanding user behavior, but also—and more importantly for our purpose here—in identifying critical paths of the app. Injecting deliberate code to record key metrics is a good step toward improving app performance.
Tip
It is a good idea to abstract and encapsulate any dependencies. This allows you to do a last-minute switch or even work with multiple systems simultaneously before making a final decision. It is especially useful in scenarios where there are multiple options available and you are in the evaluation phase.
As shown in Example 1-2, we will add a class called HPInstrumentation
to encapsulate instrumentation. For now, we log in to the console using NSLog
and send out the details to the server as well.
Example 1-2. Class HPInstrumentation wrapper for underlying instrumentation SDK
//HPInstrumentation.h
@interface
HPInstrumentation
:NSObject
+(
void
)
logEvent:
(
NSString
*
)
name
;
+(
void
)
logEvent:
(
NSString
*
)
name
withParameters:
(
NSDictionary
*
)
parameters
;
@end
//HPInstrumentation.m
@implementation
HPInstrumentation
+(
void
)
logEvent:
(
NSString
*
)
name
{
NSLog
(
@"%@"
,
name
);
[
Flurry
logEvent
:
name
];
}
+(
void
)
logEvent:
(
NSString
*
)
name
withParameters:
(
NSDictionary
*
)
parameters
{
NSLog
(
@"%@ -> %@"
,
name
,
params
);
[
Flurry
logEvent
:
name
withParameters
:
parameters
];
}
@end
We start with instrumenting three critical stages of our app lifecycle (see Example 1-3):
-
Whenever the app comes to the foreground, indicated by a call to
applicationDidBecomeActive:
-
Whenever the app goes into the background, indicated by a call to
applicationDidEnterBackground:
-
If and when the app receives a low-memory warning, indicated by a call to
applicationDidReceiveMemoryWarning:
And just for fun, we add a button in the HPFirstViewController
that will cause the app to crash when clicked.
Example 1-3. Basic instrumentation in the app delegate
-
(
void
)
applicationDidBecomeActive:
(
UIApplication
*
)
application
{
[
HPInstrumentation
logEvent
:
@"App Activated"
];
}
-
(
void
)
applicationDidEnterBackground:
(
UIApplication
*
)
application
{
[
HPInstrumentation
logEvent
:
@"App Backgrounded"
];
}
-
(
void
)
applicationDidReceiveMemoryWarning:
(
UIApplication
*
)
application
{
[
HPInstrumentation
logEvent
:
@"App Memory Warning"
];
}
App Activated
, App Backgrounded
, and App Memory Warning
are unique names that we have given to these events. You can choose any names that you are comfortable with. Numeric values are also fine.
Caution
Instrumentation should not be used as a logging alternative. Logging can be very verbose. Because it consumes the network’s resources when reporting to the server, you should instrument only the bare minimum.
It is important that you instrument only the events that you and other members of the engineering or product teams are interested in (with enough data to support important reports).
The line between instrumentation and overinstrumentation is thin. Start with instrumenting for a few reports and increase the coverage over time.
Next, let’s add a UI control so that we can generate a crash and then look at the crash report.
Figure 1-4 shows the UI for the crash button. Example 1-4 shows the code to link the Touch Up Inside
event to the method crashButtonWasClicked
:
Example 1-4. Raising exception to generate a crash
-
(
IBAction
)
crashButtonWasClicked:
(
id
)
sender
{
[
NSException
raise
:
@"Crash Button Was Clicked"
format
:
@""
];
}
Let’s interact with the app to generate some events:
-
Install and launch the app.
-
Background the app.
-
Foreground it.
-
Repeat steps 2 and 3 a few times.
-
Tap the Generate Crash button. This will cause the app to crash.
-
Launch the app again. It is only now that the crash report will actually be sent to the server.
The first set of instrumentation events and crash reports can take a little while to be sent to the server and processed. You may have to wait for some time for the reports to appear on the Flurry dashboard. Then, go to the dashboard and take a look at these events and the crash report. You should see reports similar to the ones shown in the following screenshots, which were taken from the dashboard for my app.
Figure 1-5 shows the user sessions—that is, how many users opened the app at least once per day. Multiple launches may or may not be considered as part of the same session depending on the time elapsed between those launches.
Figure 1-6 shows a detailed breakdown of each event instrumented. This report is more useful because it provides insights into the app usage (i.e., it pinpoints which parts of the app were more frequently used as compared to the others).
If you look at the crash report in Figure 1-7, you will notice a “download” link for downloading the crash log. Go ahead and click that to download the log. Looks familiar, right?
Logging
Logging is an invaluable tool to know what is going on with an app.
There may only be subtle differences between logging and instrumentation. Instrumentation can be considered a subset of logging. Anything that is instrumented must also be logged.
Whereas instrumentation entails publishing key performance data for aggregated analysis, logging provides detailed information for tracing app behavior at various levels, such as debug
, verbose
, info
, warning
, and error
. While logging typically runs throughout an app’s execution lifecycle, instrumentation is added to particular sections of development interest.
Instrumentation data is sent to the server, whereas logging is local to the device.
For logging, we will use CocoaLumberjack
, which is available via CocoaPods.
Example 1-5 shows the line to add to your Podfile to include the library. After making the change, run pod update
to update the Xcode workspace.
Example 1-5. Podfile configuration for CocoaLumberjack
pod 'CocoaLumberjack', '~> 2.0'
CocoaLumberjack
is an extensible framework that comes bundled with built-in loggers that can emit messages to various destinations. For example, use DDASLLogger
to log to the Apple System Log (ASL)—the default location used by the NSLog
method. Similarly, use DDFileLogger
to log to a file. The loggers can be configured during app launch.
The macros DDLog<Level>
can be used to log at a specific level. The higher the level, the more severe the message. The highest level is Error
, while the lowest is Verbose
. The minimum level for which the messages should actually be logged can be configured at a per-file level, per-Xcode-configuration level, per-logger level, or global level.
The following macros are available:
DDLogError
-
Indicates an unrecoverable error
DDLogWarn
-
Indicates a recoverable error.
DDLogInfo
-
Indicates non-error information.
DDLogDebug
-
Indicates data mostly useful for debugging.
DDLogVerbose
-
Provides absolutely all details, predominantly to trace control flow during execution
The macros have the same signature as that of NSLog
. This means that you can just replace NSLog
with the appropriate DDLog<Level>
call.
Example 1-6 shows representative code that configures and uses the library.
Example 1-6. Configuring and using CocoaLumberjack
//Setup
-
(
void
)
setupLogger
{
#
if _DEBUG
[
DDLog
addLogger
:
[
DDASLLogger
sharedInstance
]
]
;
#
endif
DDFileLogger
fileLogger
=
[
[
DDFileLogger
alloc
]
init
]
;
fileLogger
.
rollingFrequency
=
60
*
60
*
24
;
fileLogger
.
logFileManager
.
maximumNumberOfLogFiles
=
7
;
[
DDLog
addLogger
:
fileLogger
]
;
}
//Using logger in some file
#
if _DEBUG
static
const
DDLogLevel
ddLogLevel
=
DDLogLevelVerbose
;
#
elsif MY_INTERNAL_RELEASE
static
const
DDLogLevel
ddLogLevel
=
DDLogLevelDebug
;
#
else
static
const
DDLogLevel
ddLogLevel
=
DDLogLevelWarn
;
#
end
-
(
void
)
someMethod
{
DDVerbose
(
@"
someMethod has started execution
"
)
;
//...
DDError
(
@"
Ouch! Error state. Don't know what to do
"
)
;
//...
DDVerbose
(
@"
someMethod has reached its end state
"
)
;
}
The most likely place to call this method is
application:didFinishLaunchingWithOptions:
.Log to ASL only in debug mode, when connected to Xcode. You do not want these logs to be available on the device in production.
The file logger, configured to create a new file every 24 hours (
rollingFrequency
) with a maximum of 7 files (maximumNumberOfLogFiles
).Register the logger.
Configure the log level (
ddLogLevel
) to an appropriate value. Here, we set up for maximum verbosity during development, less verbose (debug
level) logging for internal releases (MY_INTERNAL_RELEASE
is a custom flag), and only error logging for distribution builds.Log some messages. For the level
DDLogLevelVerbose
, all messages will be logged, whereas forDDLogLevelWarn
, only the error messages will be logged.
Tip
The app delegate’s application:didFinishLaunchingWithOptions:
callback is the recommended method to set up the logger.
Summary
In this chapter, we established the factors that contribute to app performance. One part of performance concerns user perception, while a larger chunk is actually making an app highly performant.
We looked at some of the key attributes that constitute and affect app performance. In the metrics involving measurement and tracking, these attributes are referred to as key performance indicators.
We looked at the concept of profiling and explored two broad categories of profiling techniques: sampling and instrumentation. We also looked at some code changes required to instrument our app. We then played around with the instrumented app, causing the events to be generated.
Finally, we added some boilerplate code for classes that will help in instrumentation and logging.
The chapters in the next part are focused on individual attributes that define performance. Each chapter begins by defining and reviewing the attribute, and then moves on to discuss potential problems and how to get them solved with actual code.
1 Hewlett Packard Enterprise Software Solutions, “3 keys to a 5-star mobile experience”.
2 Donald Knuth, “Computer Programming as an Art”.
3 At the time of writing, most of the objects released as CocoaPods are written in Objective-C. After all, Swift is relatively new compared to Objective-C.
Get High Performance iOS Apps now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.