Published on
/
7 min read

How I Shaved 187MB Off United Airline's 439mb iOS App

Authors

Preface

In today's continuation of my saga against ridiculous app sizes, I take a look at United Airlines' iOS app.

There I was, boarding my United plane and walking past the rows and rows of seats, when I finally noticed none of them had built-in headrest screens. I swear I double-checked this before booking my ticket. As I sat down, an intercom announcement came on reminding passengers to download the United app if they wanted access to in-flight movies and entertainment. This wasn't the first time I had seen this, Hawaiian Airlines pulled the same stunt on me before. It's becoming somewhat of a popular trend in the airline industry to cut costs by having the passenger bring the hardware, and as an added bonus they get to force their way into your coveted list of installed apps. No worries then, I'll download their app and be on my way...

... wait, it's 439MB?! This can't be right.

United iOS App

Note: as mentioned in my previous article, download and install sizes are a tricky subject for iOS. Apple heavily compresses the download size (which is great!) but doesn't make this easy to find. Apple also supports incremental downloads, meaning you can download a "diff" of app binaries in some cases (also amazing!), but this is even harder to measure on an actual device. In my case I was trying to download the United app for the first time on a runway before my flight took off.

Under the hood

An app being this bloated is seriously impressive and had me intrigued – most bloated apps are within the 100-200MB range, not 400MB. Netflix itself is only 101.5MB, does United really need 4x the space to stream movies? Let's take a look.

Using the wonderful ipatool, you can download app binaries locally for inspection. After downloading the IPA file, simply rename the file extension to zip and then extract like normally.

--- UnitedCustomerFacingIPhone.app -----------
  414.8 MiB [█████████████████████] /Frameworks
    3.8 MiB [                     ] /PlugIns
    2.3 MiB [                     ] /LPMessagingSDKModels.bundle
    2.3 MiB [                     ]  UnitedCustomerFacingIPhone
    1.5 MiB [                     ]  Assets.car
    1.4 MiB [                     ] /_CodeSignature
    1.4 MiB [                     ] /Watch
  500.0 KiB [                     ] /Button.bundle
   40.0 KiB [                     ] /SC_Info
   12.0 KiB [                     ]  Info.plist
   12.0 KiB [                     ] /Base.lproj
    8.0 KiB [                     ]  AppIcon76x76@2x~ipad.png
    8.0 KiB [                     ]  AppIcon60x60@2x.png
    4.0 KiB [                     ] /UALFlightStatus.bundle
    4.0 KiB [                     ] /UALReservation.bundle
    4.0 KiB [                     ] /UALBagTracking.bundle
    4.0 KiB [                     ] /UALPayment.bundle
    4.0 KiB [                     ] /UALBooking.bundle
    4.0 KiB [                     ]  Localizable.strings
    4.0 KiB [                     ]  PkgInfo

Nearly all of the app size comes from frameworks! This isn't too surprising though, pushing code into frameworks has been a best practice for a while.

--- UnitedCustomerFacingIPhone.app/Frameworks
                                    /..
   86.0 MiB [█████████████████████] /UALAppCore.framework
   44.7 MiB [██████████▌          ] /UALBooking.framework
   40.3 MiB [█████████▌           ] /NodeMobile.framework
   36.1 MiB [████████▌            ] /UALHomeScreen.framework
   25.7 MiB [██████               ] /UALCheckIn.framework
   25.0 MiB [██████               ] /UALReservation.framework
   17.5 MiB [████                 ] /UALCommonUI.framework
   15.9 MiB [███▌                 ] /UALPayment.framework
   14.7 MiB [███▌                 ] /LocusLabsSDK.framework
    8.7 MiB [██                   ] /Mapbox.framework
    8.6 MiB [██                   ] /UALFlightStatus.framework
    8.6 MiB [██                   ] /widevine_cdm_secured_ios.framework
    7.2 MiB [█▌                   ] /LPMessagingSDK.framework
    6.8 MiB [█▌                   ] /Netverify.framework
    6.6 MiB [█▌                   ] /BlinkCard.framework
    6.4 MiB [█▌                   ] /UALMyAccount.framework
    5.7 MiB [] /WebRTC.framework
    5.3 MiB [] /UnifiedPlayerLibrary.framework
    4.4 MiB [] /UALLogin.framework
    4.1 MiB [] /UALAirportMaps.framework
    3.9 MiB [] /INPlayUtils.framework
    3.5 MiB [] /UALEntertainment.framework
    2.5 MiB [] /UALUnitedClubs.framework
    2.4 MiB [] /UALBagTracking.framework
    2.3 MiB [] /DashToHls.framework
    2.3 MiB [] /InMobileMME.framework
    1.9 MiB [                     ] /AcquireIOSDK.framework
    1.8 MiB [                     ] /Optimizely.framework
    1.8 MiB [                     ] /GoogleInteractiveMediaAds.framework
    1.7 MiB [                     ] /Qualtrics.framework
    1.7 MiB [                     ] /UALDocumentScanning.framework
    1.4 MiB [                     ] /PACStreaming.framework
    1.3 MiB [                     ] /INPLAYControlsLibrary.framework
    1.1 MiB [                     ] /JumioCore.framework
  968.0 KiB [                     ] /UALCommonSearch.framework
  892.0 KiB [                     ] /PACCoreKit.framework
  876.0 KiB [                     ] /UnifiedPlayerViewController.framework
  868.0 KiB [                     ] /SocketIO.framework
  856.0 KiB [                     ] /INPLAYiOSLibrary.framework
  632.0 KiB [                     ] /UpLiftSDK.framework
  540.0 KiB [                     ] /Starscream.framework
  424.0 KiB [                     ] /MapboxMobileEvents.framework
  384.0 KiB [                     ] /McPicker.framework
  284.0 KiB [                     ] /UALUnifiedPlayer.framework
  164.0 KiB [                     ] /iOSAdLibrary.framework
  124.0 KiB [                     ] /iOSVastAdLibrary.framework

I'd categorize most of these as:

  • Core architecture split into various UAL frameworks
  • NodeMobile for some NodeJS functionality, curious what this is for
  • Map providers [LocusLabsSDK, Mapbox]
  • App services like identity verification [Netverify, JumioCore], customer support [LPMessagingSDK], customer feedback [Qualtrics]
  • Video playback [WebRTC, DashToHls, widevine_cdm_secured_ios, UnifiedPlayerViewController, PACStreaming, PACCoreKit, INPLAYControlsLibrary]
  • Websockets [both Starscream and SocketIO?]
  • Ad tech [AcquireIOSDK, iOSAdLibrary, iOSVastAdLibrary, GoogleInteractiveMediaAds]
  • User Acquisition [AcquireIOSDK, Optimizely]

Let's inspect the biggest framework, UALAppCore.

--- Frameworks/UALAppCore.framework
                              /..
   76.8 MiB [███████████████]  UALAppCore
    6.9 MiB [] /UnitediPhoneCoreDataModel.momd
  760.0 KiB [               ]  DefaultAirports.json
  708.0 KiB [               ]  UA_AppCore_database.sqlite
  412.0 KiB [               ] /SC_Info
  116.0 KiB [               ] /UAAppCoreDataModel.momd
  108.0 KiB [               ] /HomeScreenDataModel.momd
   44.0 KiB [               ]  UAAppCoreDataModelv3v4.cdm
   36.0 KiB [               ] /_CodeSignature
   20.0 KiB [               ] /ReservationDataModel.momd
   16.0 KiB [               ]  LoginDataModelv3_v4.cdm

OK, so the vast majority of the size is within UALAppCore.framework itself. 77MB is very large for a framework. We can dig in further with our trusty nm tool to inspect the framework's symbols.

➜  UnitedCustomerFacingIPhone.app nm -m Frameworks/UALAppCore.framework/UALAppCore
000000000153c36c (__TEXT,__text) non-external +[CYFBackgroundEventFormatter formatEvent:]
0000000001542bec (__TEXT,__text) non-external +[CYFBackgroundEventManager sharedBackgroundEventManager]
0000000001538764 (__TEXT,__text) non-external +[CYFChallengeActionView showDialog:title:message:cancelButtonTitle:delegate:]
0000000001533e78 (__TEXT,__text) non-external +[CYFCircularProgressTheme standardTheme]
0000000001533e50 (__TEXT,__text) non-external +[CYFCircularProgressTheme themeWithName:]
000000000152f3d4 (__TEXT,__text) non-external +[CYFCryptoChallengeAction sharedInstance]
000000000152a99c (__TEXT,__text) non-external +[CYFDCTEncoder dctEncode:withLength:andCompression:]
000000000152aff4 (__TEXT,__text) non-external +[CYFDCTEncoder encode:withLength:]
000000000152b010 (__TEXT,__text) non-external +[CYFDCTEncoder encode:withLength:andCompression:]
000000000152ad54 (__TEXT,__text) non-external +[CYFDCTEncoder simpleEncode:withLength:]
...

Now we're getting somewhere. I'm not an expert at how symbols are packed into an executable, but I do remember Swift symbols need to be stripped, unlike ObjC (fact check pls?). Let's see if we can find some.

0000000001a4c760 (__DATA,__llvm_prf_cnts) non-external (was a private external) ___profc_/Users/uasvcagent/Go_Pipeline/pipelines/UA-AppCore-Release-iOS/UALAppCore/Classes/CoreData/Handlers/HomeScreen/UASudokuPuzzle.swift:$s10UALAppCore14UASudokuPuzzleV06createB10DataObject33_4C5DED22C8FD26C81E26FA4955E2A94ALL10difficulty3num6puzzle8solutionys5Int16V_s5Int32VS2StF
0000000001a4c7a0 (__DATA,__llvm_prf_cnts) non-external (was a private external) ___profc_/Users/uasvcagent/Go_Pipeline/pipelines/UA-AppCore-Release-iOS/UALAppCore/Classes/CoreData/Handlers/HomeScreen/UASudokuPuzzle.swift:$s10UALAppCore14UASudokuPuzzleV19fixUnresponsiveGame33_4C5DED22C8FD26C81E26FA4955E2A94ALLyyF
0000000001a4c808 (__DATA,__llvm_prf_cnts) non-external (was a private external) ___profc_/Users/uasvcagent/Go_Pipeline/pipelines/UA-AppCore-Release-iOS/UALAppCore/Classes/CoreData/Handlers/HomeScreen/UASudokuPuzzle.swift:$s10UALAppCore14UASudokuPuzzleV19fixUnresponsiveGame33_4C5DED22C8FD26C81E26FA4955E2A94ALLyyFSSyKXEfu0_
0000000001a4c800 (__DATA,__llvm_prf_cnts) non-external (was a private external) ___profc_/Users/uasvcagent/Go_Pipeline/pipelines/UA-AppCore-Release-iOS/UALAppCore/Classes/CoreData/Handlers/HomeScreen/UASudokuPuzzle.swift:$s10UALAppCore14UASudokuPuzzleV19fixUnresponsiveGame33_4C5DED22C8FD26C81E26FA4955E2A94ALLyyFSSyKXEfu_
0000000001a4c7c8 (__DATA,__llvm_prf_cnts) non-external (was a private external) ___profc_/Users/uasvcagent/Go_Pipeline/pipelines/UA-AppCore-Release-iOS/UALAppCore/Classes/CoreData/Handlers/HomeScreen/UASudokuPuzzle.swift:$s10UALAppCore14UASudokuPuzzleV21prepopulatedCDManager33_4C5DED22C8FD26C81E26FA4955E2A94ALLAA0B11DataManagerCSgyF
0000000001a4c7d8 (__DATA,__llvm_prf_cnts) non-external (was a private external) ___profc_/Users/uasvcagent/Go_Pipeline/pipelines/UA-AppCore-Release-iOS/UALAppCore/Classes/CoreData/Handlers/HomeScreen/UASudokuPuzzle.swift:$s10UALAppCore14UASudokuPuzzleV21prepopulatedCDManager33_4C5DED22C8FD26C81E26FA4955E2A94ALLAA0B11DataManagerCSgyFyycfU_
0000000001a4c738 (__DATA,__llvm_prf_cnts) non-external (was a private external) ___profc_/Users/uasvcagent/Go_Pipeline/pipelines/UA-AppCore-Release-iOS/UALAppCore/Classes/CoreData/Handlers/HomeScreen/UASudokuPuzzle.swift:$s10UALAppCore14UASudokuPuzzleV24fetchRequestFromTemplate33_4C5DED22C8FD26C81E26FA4955E2A94ALL8withName21substitutionVariablesSo07NSFetchF0CySo0vF6Result_pGSgSS_SDySSypGtF

Interesting! It looks like none of the Swift framework symbols have been stripped, severely bloating our binary size. We can actually see United's CI pipeline as part of the symbol name. 🤣

Like I said before, none of these symbols are actually needed. Let's whip up a Bash script to run the frameworks through strip:

#!/bin/bash

function strip_framework() {
    FRAMEWORK_NAME=$(basename "$1")
    strip -rSTx -no_code_signature_warning "$1" -o "StrippedFrameworks/$FRAMEWORK_NAME"
}

function copy_framework() {
    FRAMEWORK_NAME=$(basename "$1")
    cp -- "$1" "OriginalFrameworks/$FRAMEWORK_NAME"
}

export -f strip_framework
export -f copy_framework

# I couldn't figure out how to 'find' for only binary files =[
find "$1" -perm +111 -type f -not -name "*.js" -not -name "*.png" \
-not -name "*.json" -not -name "*.svg" -not -name "*.plist" -not -name "*.md" -not -name "*.sh" \
-exec bash -c "strip_framework \"{}\"; copy_framework \"{}\"" \;

and see how we did:

➜  United Analysis du -s -h OriginalFrameworks
350M	OriginalFrameworks
➜  United Analysis du -s -h StrippedFrameworks
163M	StrippedFrameworks

Wow! I honestly wasn't expecting that much of an improvement. We shaved 187MB off the app size in five minutes by stripping framework symbols. I bet there are some additional low-hanging fruit but I'll stop short here.

Learnings

While the United dev team should have noticed this much earlier – I originally started writing this in August 2021 but life happened – it is fair to say this area of app development is complicated to understand.

My biggest takeaway is that the developer tooling remains extremely poor for monitoring app sizes. The App Store should be sending all sorts of red flags to developers that have such easy fixes to make. It begs the question of whether Apple is even incentivized to make the tooling better. I'll give a shoutout to Emerge Tools as the only startup I'm aware of trying to tackle this problem.

Anyway, I hope you had fun reading this! And I really hope this gets fixed and saves people real bandwidth and stress on the runway!