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.
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
andSocketIO
?] - 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!