paint-brush
Developers Keep Ruining Apps with This Simple UI Blunderby@threadmaster
102 reads

Developers Keep Ruining Apps with This Simple UI Blunder

by Boris DobretsovFebruary 10th, 2025
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Some apps work smoothly, while others lag. In iOS development, UI rendering happens on the main thread, making it a performance bottleneck if overloaded. The key to smooth performance is to offload non-UI tasks to background threads.
featured image - Developers Keep Ruining Apps with This Simple UI Blunder
Boris Dobretsov HackerNoon profile picture
0-item
1-item
2-item

UI Performance Issues

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.

Let's list the main factors that slow down the UI:

  1. Executing non-UI related actions in the main thread
  2. A large number of elements on the screen
  3. Extra actions when preparing elements
  4. Complex rules for positioning elements relative to each other
  5. Complex elements: text with hyphens and attributes (Attributed string), rounding, translucency, gradient; images that have to be compressed or enlarged; images of inappropriate density (1x on 2x, 3x screens). Some of them can be prevented, others are easy to deal with, and some are a complex problem. Let's consider everything in order.


Keeping the Main Thread Light

image reference: https://blog.devgenius.io/an-intro-into-uikit-swiftui-building-a-simple-login-screen-with-both-frameworks-9ed3d5e23622

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.

Complex Screens

image reference: https://about.fb.com/news/2022/07/home-and-feeds-on-facebook/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.




Extra steps when creating objects

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:


  1. Create a DateFormatter object, which can convert a date to a string and vice versa while changing the output format.
  2. Set the desired date format.
  3. Convert the timestamp into a Date object, which represents the date in Foundation and can be used with DateFormatter.
  4. Use DateFormatter to generate a formatted string from the Date object.
  5. Assign this string to the label in the cell.


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
}


Conclusion

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.