When working with mobile apps, you may notice that some of them work smoothly, while others lag. The former win in the eyes of the user, while the latter are often sent to the trash because they are and make you wait. Everyone wants apps to work perfectly even on old devices. Unfortunately, many Apple tools do not provide the necessary smoothness on their own. They need to be properly configured or replaced with more convenient ones. In this lesson, we will not look for ways to speed up calculations - in most cases, such problems are individual. Let's talk about the smoothness of the user interface (UI). The focus is on UITableView
and UICollectionView
, since regular screens rarely experience performance issues, but collections - regularly.
In iOS development, UI rendering happens on the main thread, making it a performance bottleneck if overloaded. While UI tasks are inherently heavy, adding extra computations can lead to lag. Many beginner developers struggle with multi-threading, often writing code that performs network requests, caching, and database operations directly in view controllers. This results in "Massive View Controllers" that are hard to maintain and slow down performance. For example, Alamofire, by default, processes requests on the main thread asynchronously, which isn't ideal for performance. The key to smooth performance is to offload non-UI tasks to background threads while keeping the UI layer responsive.
A well-structured architecture separates the UI, business logic, and data management. Using the MVC pattern, the View
handles display, the Model
manages data and logic, and the Controller
acts as an intermediary. In iOS, UIViewController
subclasses control UI elements and user interactions, while services handle authentication, database access, and API calls. For example, an authentication screen should only collect user input, while a separate AuthService
handles validation. Simple tasks can run on the main thread, but complex UI components or data processing should use background threads. By following these principles, developers can create efficient, scalable, and high-performing applications.
Some screens, especially those with UITableView
or UICollectionView
, have hundreds of elements that need to be created, positioned, and filled with data. This can use a lot of resources, and even if the main thread is free from extra tasks, the UI may still lag. This is most noticeable when scrolling quickly—frames drop, the screen stutters, and the experience feels slow. A similar issue can happen on complex screens without tables, where animations may appear choppy. While simplifying the design can help, a better approach is to optimise performance by breaking UI updates into smaller tasks and moving some work to background threads. AutoLayout
handles positioning, but precomputing layout sizes or processing data in the background can make the app run more smoothly.
Often when creating an interface element, you need to transform data, enter auxiliary objects. A simple example from the Weather application is a collection cell displaying the date.
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
<...>
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd.MM.yyyy HH.mm"
let date = Date(timeIntervalSince1970: weather.date)
let stringDate = dateFormatter.string(from: date)
cell.time.text = stringDate
<...>
return cell
}
Here, the date and time are stored in timestamp format, but they need to be displayed on the screen in the familiar user format: "day/month/year hours:minutes." To convert the date, we need to:
DateFormatter
object, which can convert a date to a string and vice versa while changing the output format.Date
object, which represents the date in Foundation and can be used with DateFormatter
.DateFormatter
to generate a formatted string from the Date
object.
These actions are performed multiple times for each cell. Since cells are reused while scrolling, the date conversion will be repeated each time a new cell is prepared, making it a resource-intensive operation.
To optimize this process, note that steps 1 and 2 remain the same for all cells—the DateFormatter
object and its format do not change. This means that instead of creating a new DateFormatter
instance for each cell, we should create it once in advance in the controller and reuse it.
let dateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yyyy HH.mm"
return df
}()
In this case, you can make dateFormatter
a controller property, initialize it using a closure, and set the format immediately. This setup occurs once when the controller is opened, and all cells reuse this property. As a result, the table becomes more efficient, reducing the resources required for UI rendering.
To optimize further, we can cache the formatted date to avoid recalculating it every time. Let's create a property to store the cache.
var dateTextCache: [IndexPath: String] = [:]
Let's rewrite the cell formation method.
if let stringDate = dateTextCache[indexPath] {
cell.time.text = stringDate
} else {
let date = Date(timeIntervalSince1970: weather.date)
let stringDate = dateFormatter.string(from: date)
dateTextCache[indexPath] = stringDate
cell.time.text = stringDate
}
Now we check whether the date has been calculated earlier. If it has, we simply "assign" the cached value; if not, we calculate it and save it to the cache. The cell configuration method has become overloaded, so let's move the cache management to a separate method.
func getCellDateText(forIndexPath indexPath: IndexPath, andTimestamp timestamp: Double) -> String {
if let stringDate = dateTextCache[indexPath] {
return stringDate
} else {
let date = Date(timeIntervalSince1970: timestamp)
let stringDate = dateFormatter.string(from: date)
dateTextCache[indexPath] = stringDate
return stringDate
}
}
The cell method now looks much better.
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
<...>
cell.time.text = getCellDateText(forIndexPath: indexPath, andTimestamp: weather.date)
<...>
return cell
}
Always review the code for preparing elements. Move out unnecessary tasks - cache them, and perform any computational work on background threads. For example, in our case, you can also move the date conversion to a background thread to further reduce the load on the main thread.
Small adjustments, such as reusing DateFormatter
instances and caching computed values, can have a substantial impact.
Remember, every UI optimisation improves usability, especially on older devices. Always analyse your code for potential bottlenecks, offload heavy tasks to background threads, and refine your approach as needed. By following these best practices, you’ll create faster, more efficient, and user-friendly applications.