Improve Performance in your iOS Applications - Part 3
Although modern iOS devices are capable of handling a wide range of intensive and complex tasks, the device may seem slow if you are not paying close attention to how your application operates.
Performance improvements mentioned in this article are intended to make your code more readable and performant; however, select cautiously as per your needs. Oftentimes, altering or improving architecture and code refactoring takes more time and effort.
The first article in the series showcases performance tips that help you improve compile time of your iOS apps, build apps faster and focus on iOS performance improvements in the build system, whereas the second article in the series talks about performance tips that help you improve the UI interactions, media playback, animations and visuals, etc. delivering a smooth and seamless experience.
Enable Swift Optimizations
The first step is always to enable optimization. Swift has three optimization levels:
-One
: For regular development. It optimizes minimally and keeps all debug info.
-O
: For most production code. Its extreme optimizations may substantially alter the nature and quantity of output code. Debug data will be losty emitted.
-Osize
: The compiler favors code size above speed in this mode.
The current optimization level may be changed via the Xcode UI:
Select the Project Editor icon in the Project Navigator. To access the project settings editor, click the icon beneath the Project heading. Change the Optimization Level box under the Build Settings heading to apply an optimization setting to all targets in the project.
Select the target under the Targets in the Project Editor and override the Optimization Level box under the Build Settings header.
Understand Automatic Reference Counting
If you want to build a high-performance iOS app, you will have to analyze how your components use memory and how to optimize it. The retain cycle problem is a typical memory management issue. But first, let's examine how iOS handles its own memory.
Apple's automated memory management system is called Automatic Reference Counting (ARC). The reference count is used to determine whether or not a memory block should be deallocated. A new object's reference count starts at 1. This reference count might change over time. Finally, when the reference count approaches 0, the object is freed.
Strong and Weak References
The above idea brings to the fact that you should also understand strong and weak references when declaring variables. By default, variables hold strong references to each other.
struct Vehicle {
var strongCar = UIView()
weak var weakCar = UIView()
}
The number of references grows as the variables get more powerful. When a reference count of two is set to a new strong variable, the reference count of the object increases to three.
On the other hand, weak factors have no effect on the increase in the reference count. If a reference count of two is assigned to an object that already has a reference count of three, the object's reference count will stay at two.
Additionally, while a strong variable is active, the strong variable's referenced component is guaranteed to remain in memory at the same time. On the other hand, this certainty will not apply to weak variables.
Avoid Memory Leaks
A model in which an entity Vehicle contains many instances of another entity Car, and each instance of an entity Car is connected with an entity Vehicle is most likely something you've seen before. In a very simplified implementation, it can look like this:
class Vehicle {
private var cars = [Car]()
func add(_ car : Car) {
cars.append(car)
}
}
class Car {
private var vehicle : Vehicle
required init(vehicle : Vehicle) {
self.vehicle = vehicle
}
}
let vehicle = Vehicle()
let car = Car(vehicle: Vehicle)
vehicle.add(car)
Everything looks good in the above example, but it really isn't. Observe the similarities between the Vehicle and Cars: they both have a close connection with one another. Next, you'll have to guess. A memory leak is to blame.
When a piece of data persists in memory after its intended lifespan has expired, we have a memory leak. When two strong variables address one another, they generate a memory leak. The retain cycle problem is the technical term for this issue. Then, let's have a look at a few options.
Make Use of Protocol Oriented Programming
When it comes to storing data and modeling behavior in your applications, structures and classes are both excellent alternatives. However, because of their similarities, it may be tough to decide which is better.
Upon closer inspection of the code in the Standard Swift Library, it becomes clear that protocols are used rather often. Apple likes Protocol Oriented Programming and recommends that you use it if you are creating an inheritance connection from the beginning of your project.
Polymorphism is one of the most helpful parts of the OOP paradigm. It decides the runtime parameter or function to invoke. Dynamic Dispatch is the decision-making process. Below is a basic example of OOP. Class Car has an echo method with the override keyword since it is defined in superclass Vehicle. The echo method in class Car is called, not the echo method in class Vehicle.
import UIKit
class Vehicle {
func echo() {
print("Improving iOS App performance.")
}
class Car: Vehicle {
override fun echo() {
// your code
}
}
}
Isn't that great? No, as seen in the prior example, each runtime job slows down our execution time. So what's the fix?
import UIKit
class Vehicle {
func echo() {}
}
class Car: Vehicle {
func echo {
// your code
}
}
POP, which is an abbreviation for Protocol Oriented Programming, is now available. It took just a little tweak to significantly cut runtime computations. Isn't it funny how familiar the POP terminology is? It is the most often used delegation pattern in Apple's UIKit.
Make Use of Static Dispatch
While referring to Apple's Swift Standard Library documentation, you would witness Structs
, as opposed to classes, are of the value type, while classes are of the reference type. As a consequence, they may be used interchangeably. There seems to be a little change at first. This is much smaller than I expected!
Structs are allocated statically, as opposed to dynamically constructed classes, which are allocated dynamically. What happens, though, if a class has a struct-type parameter? So, what are you going to do? Stack allocation and struct construction are still required even if the struct argument is a structure. That is not at all the case! As a result, even if the argument is of type struct, the class that stores it allows it to be allocated in Heap and dispatched dynamically, independent of the argument's type.
For the previously mentioned example, you can continue using the class you created earlier, but with the weak references of the variables:
class Vehicle {
private var cars = [Car]()
func add(_ car : Car) {
cars.append(car)
}
}
class Car {
private weak var vehicle : Vehicle?
required init(vehicle : Vehicle) {
self.vehicle = vehicle
}
}
let vehicle = Vehicle()
let car = Car(vehicle: vehicle)
vehicle.add(Car)
Finally, instead of employing extensive class inheritance in your code, try using structs and protocols.
Restrict Variable Scopes
A declaration that contains the private
or fileprivate
keywords limits the visibility of that declaration to the file in which it is included by those keywords. This enables the compiler to determine whether or not there are any additional possibly overriding declarations in the code.
Consequently, the lack of any such declarations allows the compiler to deduce the final
keyword automatically and delete indirect calls for methods and field accesses as needed in the process. Using the following example, vehicle.doSomething()
and car.myPrivateVar
will both be able to be used directly, provided Vehicle
and Car
do not contain any overriding declarations in the same file:
private class Vehicle {
func doSomething() { ... }
}
class Car {
fileprivate var myPrivateVar: Int
}
func usingVehicle(_ vehicle: Vehicle) {
vehicle.doSomething() // The compiler will remove calls to this method
}
func usingCar(_ car: Car) -> Int {
return car.myPrivateVar
}
Make Use of Value Types
Swift has two types: value types (structs, enums, and tuples) and reference types (classes). It is vital to note that NSArrays
cannot include value types. As a result, the optimizer may avoid most of the Array cost associated with dealing with the possibility that an array is backed by an NSArray
when using value types.
// Avoid using a class here.
struct Car {
var name: String
var manufacturedYear: [Int]
}
var newCar: [Car]
Furthermore, unlike reference types, value types only need to count references if they contain a reference type recursively. To avoid unnecessary retain and release traffic in Array, value types might be used instead of reference types.
Make Use of Closures
When it comes to features, Swift's closures are among the most robust currently available. They are, on the other hand, impervious to note retention cycles. Closures have the potential to cause retain cycles for one simple reason: they maintain a strong reference to the object that uses them while not in use.
In this example, we have a retention cycle that includes closures. Note how the self
declarations are modified in the successive code blocks below:
class Car {
private var tyres = 0
private var closure : (() -> ()) = { }
init() {
closure = {
self.tyres += 1 // standard way
print(self.tyres)
}
}
func foo() {
closure()
}
}
The above example has a strong connection to the closure, which in turn has a strong connection to the object itself because self
is used in the closure block. There are two options for dealing with this:
class Car {
private var tyres = 1
private var closure : (() -> ()) = { }
init() {
closure = {
[__unowned self__] in self.tyres += 1 // unowned declaration
print(self.tyres)
}
}
func foo() {
closure()
}
}
With the improvement above, the closer
no longer has a strong reference. However, use [unowned self]
with care, if the object has already been deallocated before the closure is called, a crash will result. For the same, you can also alter the implementation as below:
class Car {
private var tyres = 1
private var closure : (() -> ()) = { }
init() {
closure = {
[weak self] in self?.tyres += 1 // weak declaration
print(self?.tyres ?? "")
}
}
func foo() {
closure()
}
}
Here, [weak self]
returns the same result as [unowned self]
but it is handled optionally.
In the context of a closure, variables and constants from the surrounding scope are captured. This establishes a tight connection between it and the value required by the closure. In our project, it's probable that we'll have thousands of closures, which means that inspecting each and every one of them for memory concerns will be quite time-consuming. It is possible to monitor for memory leaks in Xcode; all that is required is to open Instruments and seek for the option Leaks.
Navigate to Xcode and then Open Developer Tool → Instruments → Leaks.
Once opened, select the simulator and the application target and trace the leaks that need fixing.
As a best practice, when dealing with closures or delegates, it is best to utilize weak
or unowned
. Maintain a strong coding style in your project such that the presence of a weak self is immediately apparent. As soon as you're ready, go ahead and install SwiftLint, a powerful tool for enforcing coding standards. For the benefit of the whole team, compiler-time problems may be spotted, and code style can be automated.
Improve Utilization of Arrays
Arrays often store their items in memory chunks that aren't all adjacent. A new element in the array is simply added by allocating a new block and appending it to the array. This is excellent for adding, but less so for iterating. So, if you're iterating over a huge array, ContiguousArray
could be a good option.
When using ContiguousArray
, it ensures that all of the array's entries are arranged sequentially. This is quite useful in locating the following piece of information. It's a trade-off, as always, and nothing magical happens. Because of the added limits on the array management in ContiguousArray
, tasks like insertion and appending are now more difficult. As a result of our recent changes, your use case will no longer be limited.
Swift objects in general are so well-behaved that we can ignore memory problems and safety concerns while dealing with them since Swift handles everything for us. This has a negative impact on overall performance. You may use the withUnsafeBufferPointer
function to acquire the array of pointers for the array elements, which enables you to trade off safety for performance. But you need to be careful, because if for some reason those elements are deallocated, it may lead to crashes.
Apple SDK for Sentry allows you to monitor and track your app performance, user sessions, crashes that your users may face and more.
As a general rule, the issue in Sentry.io that captured this event should be available to examine. Errors resulting from other causes should not be displayed. To find them in Discover or on the Issues page, use the error search filter unhandled:true
. Because sessions are not subject to data-rate, the number of unhandled events is not anticipated to match the number of failed sessions.
Make Use of Generics
Swift's generic types provide a powerful abstraction tool. The Swift compiler constructs CustomFunc<T>
with any value of T
. Also required are a table of function pointers and a box containing T
. This is due to the fact that CustomFunc<Int>
behaves differently from CustomFunc<String>
. Here is an example of a generic function:
class CustomFunc<T> { ... }
CustomFunc<Int> X // Same function
CustomFunc<String> Y // Works for different data types
Each time such code is invoked, Swift attempts to identify the concrete (non-generic) type being used. When the optimizer sees the generic function declaration and understands the concrete type, the Swift compiler may produce a variation of the generic function customized to that type. Specialization eliminates the administrative overhead of generics. Here are some more generics:
class CustomStack<T> {
func push(_ element: T) { ... }
func pop() -> T { ... }
}
func customAlgorithm<T>(_ a: [T], length: Int) { ... }
var stackOfInt: CustomStack<Int>
for i in ... {
stack.push(...)
stack.pop(...)
}
var arrayOfInt: [Int] // Compiler emits a specialized version for [Int] type
customAlgorithm(arrayOfInt, arrayOfInt.length)
In order for the optimizer to conduct specialization, it must be possible for the generic declaration definition to be visible in the current Module. Unless the -whole-module-optimization
switch is enabled, this can only occur if the declaration and invocation of the generic are both in the same file as the invocation of the generic.
The standard library is an exception to this rule. Definitions in the standard library are accessible for use in all modules and may be customized to meet specific needs.
Optimize SpriteKit
SpriteKit is a fast 2D framework that uses Apple's Metal library to access the GPU directly. With a 120Hz display on devices like the iPad Pro, you need to work hard to maintain your frame updates inside the 8 milliseconds allotted.
Textures are expensive to load from your app package. Even if the image is modest, trying to load a full-screen background picture may cause you to go over your allotted time limit, resulting in missing frames. Make sure you preload textures in the background so that when you need them they'll be ready to go in a jiffy. As a consequence, you have a substantially lower risk of losing frames.
Texture or animation hiccups and slow-moving UI elements irritate users and detract from the overall user experience. There are two ways to assess these sorts of experiences: slow and frozen frames. Both should be avoided if you want your app to perform properly. Sentry's SDK tracks the slow frames and frozen frames encountered during rendering on the devices.
Understanding that SKTexture is similar to UIImage in that it doesn't really load data until required is critical to understanding how this works. Because of this, even for incredibly huge photos, this kind of code is almost instantaneous:
let texture = SKTexture(imageNamed: "Void")
However, once that texture is assigned to a sprite node in your game scene, it must be loaded before it can be rendered. Ideally, you want that load to occur before the scene is shown – possibly in a loading screen – to minimize frame difficulties; thus, you should preload it like follows:
texture.preload {
print("Texture is ready!")
}
Use .replace
instead of Blend
A game's rendering is one of the most time-consuming portions of development, even with all the computations required. It's complicated as most sprites have irregular forms and alpha transparency, scenes normally have numerous layers, and effects frequently provide life to the scene.
You may prevent some of this by telling SpriteKit
to render sprites without alpha transparency i.e., a solid form, or a backdrop picture as follows:
yourSprite.blendMode = .replace
In reality, this means SpriteKit doesn't need to read the old color value and mix it with the new color value.
Analyze Access Levels
During the course of the program's execution, the method call and parameter access of an object formed from a class remain unknown. This means that whenever you hit the run button in Xcode, the compiler starts up and does tasks like allocating memory and evaluating polymorphism use, among other tasks. Finally, the compiler indicates that a method or parameter is final if it cannot be accessed from beyond the program's scope.
It is necessary to add the final keyword in the class declaration when you are aware that a class is not a basis for any other class. You include the final definition of a class in all of its arguments and methods when you add it to the class.
So, let's imagine you want to override the behavior of a class. A consequence of this is that the final keyword is not allowed in this class. A new feature allows all parameters and methods that cannot be accessible by subclasses to be designated private
.
Conclusion
Choosing whatever performance improvements to employ in any particular case might take some thought, testing, and experimenting, especially if we want our code to stay efficient as we add more data to it. A mixture of various performance improvement techniques, rather than simply one, may be required to obtain the desired performance characteristics. Expanding your understanding beyond Swift is often essential to determine the best format for each case.
In this article, you gained knowledge about improving iOS application performance for optimizing the existing codebase using the best practices, modularizing the architecture, and creating and utilizing reusable components in the code.
Stay tuned for upcoming articles in this iOS Performance Series (update: part 4 is published), which will focus on UI improvements, code improvements, animations, visual experience best practices, and more.