Skip to Content
Course content

238: Reducing Binary Size for iOS Apps

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember a project a few years ago where we almost missed our release window because the binary size spiked by 40MB overnight. We hadn't added any major new features, just a few more data models and a generic wrapper to handle API responses. We were hitting the cellular download limit, and the team was scrambling to find the "leak."

The culprit wasn't a memory leak, but generic specialization bloat. Here is a simplified version of the pattern we were using:

struct APIResponse<T> {
    let data: T
    let timestamp: Date
    let requestId: String

    func logResponse() {
        // A complex logging function with lots of logic
        print("Response received at \(timestamp) for request \(requestId)")
        print("Type being handled: \(T.self)")
        // Imagine 50 more lines of formatting and validation logic here
    }
}

// We used this for every single model in the app
let userResponse = APIResponse<User>(data: user, timestamp: Date(), requestId: "1")
let productResponse = APIResponse<Product>(data: product, timestamp: Date(), requestId: "2")
let orderResponse = APIResponse<Order>(data: order, timestamp: Date(), requestId: "3")
// ... and 40 other models

On the surface, this looks like clean, DRY (Don't Repeat Yourself) code. But the Swift compiler tries to be helpful. To make the code fast, it often performs "specialization," meaning it generates a concrete version of logResponse() for every single type T you use. If you have 50 models, the compiler might be baking 50 nearly identical copies of that complex logic into your binary. When you multiply this across a whole project, your binary size balloons.

Hunting the Bloat with the App Size Report

You can't find this by looking at your .swift files; you have to look at the compiled result. I always start by archiving the app (Product > Archive), then selecting the archive in the Organizer and clicking "Download Debug Symbols." From there, you can generate an App Size Report.

When I ran this on our project, the "Symbols" section was massive. I saw dozens of entries for APIResponse<User>.logResponse, APIResponse<Product>.logResponse, and so on. It was a smoking gun. We weren't writing a lot of code, but the compiler was generating a lot of it for us.

Using Type Erasure to Collapse Specializations

To fix this, we needed to move the heavy logic out of the generic context. The trick is to move the logic that doesn't actually depend on the specific type T into a non-generic helper or a protocol extension that doesn't trigger specialization.

// 1. Define a protocol for the shared behavior
protocol ResponseLoggable {
    var timestamp: Date { get }
    var requestId: String { get }
}

// 2. Make the generic wrapper conform to it
struct APIResponse<T>: ResponseLoggable {
    let data: T
    let timestamp: Date
    let requestId: String

    func logResponse() {
        // Instead of doing the work here, delegate to a non-generic function
        ResponseLogger.shared.log(self)
    }
}

// 3. Create a single, non-generic implementation
struct ResponseLogger {
    static let shared = ResponseLogger()
    
    func log(_ response: ResponseLoggable) {
        // This code is compiled EXACTLY ONCE, regardless of how many T's exist
        print("Response received at \(response.timestamp) for request \(response.requestId)")
        // ... all the complex logic goes here
    }
}

By introducing ResponseLoggable, we forced the compiler to use dynamic dispatch (via the protocol) rather than static specialization. The binary size dropped immediately because the complex logging logic now existed in only one place in the executable, not fifty.

Optimizing the Asset Catalog and Binary Stripping

While code specialization is a silent killer, the most common "loud" bloat comes from assets and build settings. I've seen too many developers leave "Debug" symbols in a release build or include 4K PNGs in the main bundle.

  • Dead Code Stripping: Ensure Dead Code Stripping is set to Yes in your Build Settings. This tells the linker to remove functions and variables that are never actually called.
  • Optimization Level: Make sure your Release configuration is set to -O (Optimize for Speed). This doesn't just make the app faster; it allows the compiler to inline small functions and remove redundant code paths.
  • Asset Compression: If you have large images, stop using PNGs for everything. Use HEIC or, better yet, move large assets to On-Demand Resources (ODR). This keeps them out of the initial binary download and fetches them from Apple's servers only when the user actually needs that specific feature.

Reducing binary size is often a game of trade-offs. Specialization makes code faster (static dispatch), while type erasure makes the binary smaller (dynamic dispatch). In most iOS apps, the millisecond difference in execution speed is negligible, but a 200MB download is a deal-breaker for a user on a limited data plan.




📋 Practical Task

Audit and Shrink: The Asset-to-ODR Migration

You are working on an app that has a "Tutorial" section containing five high-resolution image assets (each 5MB) that are only seen once during the first launch. Currently, these are stored in the main Assets.xcassets folder, bloating the initial app download.

Your task:

  1. Describe the steps you would take in Xcode to move these five images to On-Demand Resources (ODR).
  2. Write a Swift code snippet using NSBundle.load() or UIImage(named:in:with:) that demonstrates how to asynchronously load one of these images from the remote server so the app doesn't freeze while waiting for the download.
  3. Explain how you would verify that these images are no longer part of the "Install" size of the app using the Archive Organizer.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.