Improve Performance in Your iOS Applications - Part 1
Since inception back in 2007, Apple and the iOS ecosystem has drastically improved with a plethora of changes and new features added (or removed) over time. At the same time, the size of the applications and the data has consistently grown. This has its own impact on the powerhouse in your hand - the iOS device.
Developers strive to design the best experience, often compromising speed and performance. This can ultimately lead to application performance degrading over time and result in bad user experiences, or even worse - users deleting your iOS applications. To address this, we are introducing a 4-part performance series focused on improving your iOS applications.
In this first article, we’ll focus in on iOS performance tips that help you improve the compile time of your iOS applications, build faster, and hone in on iOS performance improvements in the build system. Let’s dive right in.
Let’s start with a one-line tweak that would slash most of your build times in half.
Did you know that all of your files get built multiple times? One important observation in Xcode has an answer to this. This relatively old Stack Overflow article still stands applicable for the latest version of Xcode.
The solution is: For all non-necessary or non-release build types In your Xcode, set Build Active Architectures Only to Yes. Because build settings generally have more than just Release and Debug settings (unlike most projects), you might see this flag enabled for all of the configurations you utilize for development by default.
To update the setting, you need to navigate to the project file in the Project navigator, then select your project on the left sidebar and navigate to the
Build Settings tab. Under
Architectures, you will find the setting of Build Active Architectures Only which you need to change to Yes.
This should ideally reduce your build time to 50% in a majority of the cases, but may vary on the basis of app architecture and other configurations. Fantastic, isn’t it? Let’s move on to the next one.
Did you know that, by default, Whole Module Optimization also impacts your build generation and build speed? By default this is only enabled for release.
Here’s how to tweak it:
For debug mode, enable the Whole Module Optimization module under
Swift Compiler - Code Generation setting and then under Other Swift Flags, specify below flags for the debug mode:
Also, set the value of Optimization Level to
Whole Module Optimization under the debug build settings.
If you use CocoaPods, adding the below code at the bottom of your Podile will help optimize all of your dependencies:
post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| if config.name == 'Debug' config.build_settings['OTHER_SWIFT_FLAGS'] = ['$(inherited)', '-Onone'] config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-Owholemodule' end end end end
This will ensure that the entire module is compiled at once without optimizing the code for debug builds for faster development and saving time.
Many developers face issues where their code is taking relatively longer to compile than it should. The more concerning fact is, sometimes it suddenly gets slower than the previous build times. This can escalate rather quickly. This technique can help you in locating sections of code that the compiler is having difficulty with, and you can try reordering them to speed up compilation.
The first step is to navigate to your Swift Compiler section and add the below flags under Other Swift Flags:
With the above flags, the compiler prints out how much time it takes for each function. The next step is to collect all of these log data points and sort them out. The output will give you filename and line numbers. In most of the cases there are only a few functions (sometimes even less than 5) being printed repeatedly - so don’t worry.
Remember, you don’t need to know a lot about compilers to understand or make sense of the log outputs. Just observe the function names and keywords at the end of each line and it will make sense.
Rewriting just a couple of lines of code may end up building your entire project 50% faster. This is because in most of the cases, the error lines showcase the improvements specific to data structures and how to write more appropriate code.
Simply rewriting the functions to be as fundamental as you can may solve the problem - ostensibly unnecessary type definitions, intermediate variables, mutable variables, etc. This is a simple yet excellent example of the same.
Above Xcode 9.2, the newly introduced architecture allows projects to run Swift build tasks in parallel. If your machine does not have enough RAM, build times can slow down drastically. You can do a few things to avoid this:
- Disable the parallel task execution for your Xcode using the below command:
defaults write com.apple.dt.Xcode BuildSystemScheduleInherentlyParallelCommandsExclusively -bool NO
- Some of your projects will run faster and better than others. Another thing to note: if your machine doesn’t have a lot of RAM, this may actually slow your builds down. If applying the above makes your builds slower, you should ideally disable concurrent build tasks using the following command:
defaults delete com.apple.dt.Xcode BuildSystemScheduleInherentlyParallelCommandsExclusively
- If you have a superfast configuration on your machine which can speed up your build time, you might want to consider allocating a few extra processes Xcode by making use of command below:
defaults write com.apple.Xcode PBXNumberOfParallelBuildSubtasks 4
In your workspace settings or project settings, enable the new build system under Shared Workspace Settings to speed up your builds. Once selected, the new build system takes control of compilation and build generation, reducing the time it takes to compile your Swift code.
Increase your RAM or upgrade your system hardware (M1 is blazing fast).
If parallel builds are enabled for your project, you will witness better build performance and the order of the build targets will not matter as much. However, if parallel build generation is turned off using the above technique, Xcode attempts to build the target dependencies in the order specified under the build action scheme.
In such cases, you can declare the input and output configuration to let the build system know which script tasks to avoid rerunning unnecessarily.
Another technique you can incorporate in your workspace is to avoid auto linking project dependencies. This particular setting lets the compiler automatically link the framework’s associated with the modules you have imported without explicitly linking them in your link library’s build phase. However, auto-link itself does not establish dependency on the specified framework at the build system level. This means, the build system or the auto-link feature does not guarantee that the target your project depends on is actually built before you try to link the dependencies.
Instead, you should add explicit dependencies to let the build system know, and you may also prefer to reference projects by drag-and-drop technique with other Xcode projects into your current project’s file navigator and capture the targets of other projects that are required.
iOS generates a dSYM file to maintain the mapping between the original code and the obfuscated stack trace to understand the crash reports. Since dSYM file generation takes a good amount of time, you can avoid generating dSYM in cases when the debugger is not attached, or during the debug build generation.
Under Debug Information Format settings, make sure you’re using plain DWARF instead of DWARF with dSYM File.
For legacy projects, you might have noticed that many of the project files start with exhaustive header declarations. Did you know that declaring extra or unnecessary header imports in the project can drastically slow down the build generation time?
Moreover, a large number of header files also contribute exponentially to the time required for dependency checking.
In such cases, the below strategies come in handy:
Reduce header imports to only the necessary ones; you can identify this easily using code compile and lint-checking tools such as SwiftLint
Make use of forward declaration instead of recursive header inclusion or header file calling another header file to drastically reduce the number of dependencies and ultimately improve the build generation time (this link should be helpful).
An opaque view is the view that does not have transparency. This means that any elements placed behind the opaque view are hidden behind the layer.
While focusing on iOS performance improvements, this is a key area which everyone needs to understand. Setting a view to opaque ensures that the system optimizations are applied before the framework draws the view and surely some improvements can be witnessed, specifically drawing performance while rendering the screen.
If any view has a transparency or alpha channel below 1.0, the iOS framework has to put extra effort to calculate the final display render by blending different views and layers together while processing the view hierarchy. On the contrary, if any view has an opaque property set, the iOS framework just super-imposes this view layer above the previous ones and avoids any extra processing to blend the view hierarchy.
To check the opaque and non-opaque view hierarchy, you can navigate to iOS Simulator → Debug → Color Blended Layers.
Once enabled, you will be able to see red and green layers being drawn on the screen. The green layers indicate that the view is opaque and no blending is applied to render it, whereas the red layers indicate the view is non-opaque and is drawn after the layers have been blended.
You can optimize iOS app performance by reducing the highlighted views with red as much as possible by applying the below code snippet:
label.backgroundColor = UIColor.clear // Original
label.backgroundColor = UIColor.white // To be applied
Also, to remove alpha channel from the images, you can make use of the Preview app. All you need to do is duplicate the image by pressing
Shift + CMD + S and unchecking the Alpha checkbox while saving the duplicate image.
When you’re writing your own extensions in Swift, by default, they are set to
public access modifiers. This increases the compilation time as it is meant to be utilized by every Swift class.
The best way to tackle this is by completely avoiding the creation of extensions. If not, then you should create the extensions and set the access modifier to
private as you would only be using them in one part of your code.
Be it pods or the code which is not serving any purpose in your iOS application any longer, removing the unused code, unused image files or assets, many pods or even the functions that are no longer used would drastically reduce the build time – as well as reduce the size of your release build.
For relatively large projects, and depending upon the requirements, one of the most effective solutions to improve your iOS app performance is to convert the mammoth iOS project into a modular structure. For such large projects where there are multiple features and functionalities, you can consider a modular architecture where each module consists of consolidated features which relate closely with each other in the same module.
The idea is to replace the project with a workspace and build modules that can be compiled and injected into the workspace for utilization. Generally, adhering to separation of concerns, you can modularize your iOS apps in a few modules such as database, network, utilities, interface, etc.
The advantage of modularizing your iOS app is not only reduction in compilation time, but you can easily reuse the modules across the project as well as in other projects just by importing them instead of re-writing the entire code every time.
With the approach to Build once, reuse everywhere - a remote build caching strategy helps reduce build times drastically since you no longer need to rebuild anything that has been built on any machine.
In this approach, artefacts generated and shared from another computer can be downloaded instead of generating a target locally as long as all input files and compilation settings are the same. Finding the proper cache level is critical to the success of remote caching. Excessively granular caching units, which cache every stage of the compilation process, may result in significant network traffic overhead, outweighing the CPU savings. Putting the entire software into a single cacheable unit, on the other hand, may reduce the caching hit rate significantly; every single local update invalidates remotely accessible cache artefacts, forcing a complete build locally.
Another way to make the best use of caching is by using the Bazel build system. For iOS, Bazel comes with a predefined set of Apple rules that help you get started and build a complete application. However, please note that setting up Bazel and successfully running your iOS build with Bazel requires advanced level understanding, but in my opinion it’s worth it, especially for large scale projects. A few key organizations including Lyft, Pinterest, and LinkedIn make use of Bazel to build their iOS apps as well.
Thanks to Bazel, build only gets performed once and rebuild is only performed when any of the target files change. Once you point it to a remote cache with
–remote_http_cache, we can share this artifact in a shared remote cache. You can learn more about setting up Bazel for your iOS apps here and here.
Apple iOS test simulator can be used to conduct app tests on a variety of software and hardware combinations. By choosing Physical Size or Pixel Accurate window size for the simulator, you may reduce the number of tests you run and the time it takes to perform them. These configuration changes use fewer resources and prevent tests from being slowed down by simulating pixels that will never be seen.
Here’s a Stack Overflow answer on how to configure your iOS simulator for both Physical Size or Pixel Accurate window size to make use of iOS performance testing tools.
Sentry’s iOS SDK reports errors automatically whenever an error(s) or exception(s) goes uncaught in your application causing the application to crash. The minimum version required to integrate Sentry on iOS is 9.0.
While the recommended way of installing the SDK using Cocoapods is shown below, the alternate installation methods are also supported for iOS apps:
platform :ios, '9.0' use_frameworks! # This is important target 'YourApp' do pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '7.11.0' end #run pod install
Once installed, you can set up the configuration for your iOS apps using the documentation guide.
With performance monitoring, Sentry tracks your software performance, measuring metrics like throughput and latency, and displaying the impact of errors across multiple systems.
iOS Performance monitoring with the help of Sentry is possible in two ways:
Automatic instrumentation allows you to capture standard tracing already specified and supported by Sentry such as capturing transactions on app load, or when the app starts including both cold start and warm start, detecting slow and frozen frames, network calls, file and I/O operations and much more. You can enable automatic instrumentation by following the steps given here.
Custom instrumentation allows you to specify your own implementation and create transactions to capture possibly every action possible. This can vary from case to case, feature to feature, such as when a user performs a checkout on the cart, or validating payment details, or signing up for the first time ever, and so on. You can enable custom instrumentation by following the steps given here.
Sentry supports a plethora of integrations to achieve your suitable target through different workflows and providers.
In this article, we walked through how to improve iOS application performance, iOS performance testing tools, iOS performance monitoring, and more. We focused on build processes and build system in general. While the effort has been to bring everything under one roof, the topic of iOS performance tips is vast and should be given proper justice.
Hence, this is the first of 4 articles (update: part 2, part 3, and part 4 are published) in the Performance Series: Improve your iOS Applications. Stay tuned for upcoming articles in the series, which will focus on UI improvements, code improvements, animations, visual experience best practices, and more.