This post describes how to cache compiled pods frameworks to allow reusing them across multiple CI builds — saving about 5–10 minutes of CI run time depending on how many dependencies you have. We’ll use fastlane to help build the app and Amazon S3 to save and restore the Pods cache.

CocoaPods & the Pods project

Many — if not most — iOS apps now use CocoaPods to manage dependencies. Swift and ObjC libraries like Alamofire or RxSwift can be incorporated into an app just by adding a single line to a Podfile.

target 'App'
use_frameworks!
pod 'Alamofire'

CocoaPods analyzes the Podfile, generates an Xcode project called Pods, and adds it as part of a Xcode workspace that encompasses both your app and the Pods project:

App workspace  App project    App source code  Pods project    Alamofire

For the App project to build properly, Pods needs to build as well. Depending on how many dependencies you have and what languages they’re written in, this can take a long time.

Many companies & projects use Continuous Integration (CI) systems to lint, test, and build their apps. Often those CI runs happen on each commit pushed to version control and are required for pull requests to be merged. As developers wait on builds to finish to merge their code, each minute that we can shave off is a big win.

Where can we save time?

Let’s look at how Xcode archives a workspace:

  1. Xcode builds the Pods target and generates the proper frameworks (Pods.frameworkAlamofire.framework, etc) in the $BUILT_PRODUCTS_DIRdirectory. By default this happens in the build/directory at the root of your project.
  2. Xcode builds the App target, links it against pods frameworks in $BUILT_PRODUCTS_DIR, then runs the Embed Pods Frameworks script that CocoaPods automatically adds to the project.
  3. Xcode generates an archive containing the app, the pod frameworks (eg: Alamofire.framework), Pods.framework, the Swift standard libraries, and code signs the whole thing.

Step 2 & 3 can’t be optimized much. Your App code changes on each commit and unless you find a way to cache incremental builds of the App target, there’s not very much you can do about speeding it up.

Step 1, on the other hand, seems interesting. If we manage to retrieve compiled frameworks from an earlier build, then Xcode could detect them and skip the Pods build altogether.

In theory, this means we’d only ever have to build the Pods target once, store the built frameworks to a remote server, then all future CI builds could download those frameworks and link against them. Not only does this save time for release builds, but this principle can also be applied when building apps for UI & unit tests in debug mode.

To do this, we need to:

  • Restore the cache from S3 if it exists
  • Build the Pods + App projects separately
  • Embed pods frameworks in the App archive
  • Save the cache to S3

Building projects separately

In fastlane terms, this is how you’d archive a whole workspace (Pods + App):

gym(
  workspace: 'App.workspace',
  configuration: 'Release'
)

Since we want to save generated pod frameworks to retrieve them during another build, we need to build the Pods + App projects separately.

lane :build_pods do
  xcodebuild(
    :project => File.expand_path('../Pods/Pods.xcodeproj'),
    :scheme => 'Pods',
    :configuration => 'Release',
    :destination => 'generic/platform=iOS'
  )
end

lane :build_app do
  cache_folder = File.expand_path('../build/Release-iphoneos')
  gym(
    project: 'App.xcodeproj',
    scheme: 'App',
    configuration: 'Release',
    destination: 'generic/platform=iOS',
    xcargs: [
      "PODS_CONFIGURATION_BUILD_DIR=#{cache_folder}",
      "FRAMEWORK_SEARCH_PATHS='$(inherited) #{cache_folder}'"
    ].join(" ")
  )
end

Let’s look at what happens here. First we build the Pods project in release mode. This generates the following files:

build/  Release-iphoneos/    Alamofire/Alamofire.framework    # ...    # ... other pod frameworks    # ...    Pods.framework

Then we build the App project in release mode, by providing two crucial xcodebuild arguments:

  • $PODS_CONFIGURATION_BUILD_DIR is the base path where pod frameworks will be searched at linking time. Xcodebuild looks for $PODS_CONFIGURATION_BUILD_DIR/Alamofire/Alamofire.framework so we need to set $PODS_CONFIGURATION_BUILD_DIR to build/Release-iphoneos.
  • $FRAMEWORK_SEARCH_PATHS is a list of paths where the linker looks for frameworks. By default it contains paths to each pod framework (eg: Alamofire.framework). Since we also need to link against Pods.framework, we need to add build/Release-iphoneos there.

At this point the Pods and App project will get built separately and the App project will be aware of the frameworks generated in the Pods project.

Embedding Pods Frameworks

There’s one more thing we need to do before we can get a successful build. Remember the Embed Pods Frameworks run script that CocoaPods automatically adds to your project? It takes care of copying pod frameworks to the proper directory so that they’ll be part of the final archive.

A dumbed down version of the script looks like this:

cp "$BUILT_PRODUCTS_DIR/Alamofire/Alamofire.framework" \
"$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH/"

Since the script expects the whole workspace to get built at once, it looks for frameworks in $BUILT_PRODUCTS_DIR but they are nowhere to be found. Due to a lack of sanity checks, the script then copies all files in the repo to $TARGET_BUILD_DIR and tries to codesign them, which of course fails badly.

Unfortunately I couldn’t find way to set $BUILT_PRODUCTS_DIR to our cache directory by adjusting arguments passed to xcodebuild. The only option we have is to modify the run script so that we can pass another value for $BUILT_PRODUCTS_DIR.

# Before
"${SRCROOT}/Pods/Target Support Files/Pods/Pods-frameworks.sh"

# After
if [ -z "${FAKE_BUILT_PRODUCTS_DIR}" ]; then
    built_products_dir=$BUILT_PRODUCTS_DIR
else
    built_products_dir=$FAKE_BUILT_PRODUCTS_DIR
fi
BUILT_PRODUCTS_DIR=$built_products_dir "${SRCROOT}/Pods/Target Support Files/Pods/Pods-frameworks.sh"

Since the Embed Pods Frameworks run script gets overridden on every pod install, modifying it in Xcode would be pointless. Instead, let’s add a new lane that patches the run script for us. We’ll run this lane before building the App project in CI builds.

lane :patch_app_project_for_pods_cache do
      
    fastlane_require 'xcodeproj'
    project = Xcodeproj::Project.open("../Scoop.xcodeproj")
    target = project.targets.select { |target| target.name == 'App' }.first
    phase = target.shell_script_build_phases.select { |phase| phase.name.include?('Embed Pods Frameworks') }.first
    
    prefix = [
      'if [ -z "${FAKE_BUILT_PRODUCTS_DIR}" ]; then',
      '  built_products_dir=$BUILT_PRODUCTS_DIR',
      'else',
      '  built_products_dir=$FAKE_BUILT_PRODUCTS_DIR',
      'fi'
    ].join("\n")
    
    phase.shell_script = [
      prefix,
      "BUILT_PRODUCTS_DIR=$built_products_dir #{phase.shell_script}"
    ].join("\n")
    
    project.save()
end

To patch the project, run:

$ fastlane patch_app_project_for_pods_cache

Now we can build the Pods + App projects separately using the commands above and everything will build, link, copy, and codesign properly.

Saving, checking, and downloading the Pods cache

Now that we can build our pod frameworks separately, all we need to do it save them for a later reuse. Pod frameworks are stored in the build/Release-iphoneos directory, so let’s zip it up and upload that to S3. But first, we need a way to juggle between different caches in case that the Podfile changes between builds.

Cache key

At Scoop we use S3 to store our Pods cache but you can use any other remote storage mechanism. While a cache is just a zip of the build/Release-iphoneosfolder, it needs to be specific to the set of pod versions that the project is using.

If any pod version changes (for instance Alamofire 5.0.0 to 5.0.1) a new cache needs to get generated. So we can’t really store our cache to a generic cache.zip file, otherwise we run into the risk of using a cache that contains the wrong version of a Pod. This is why we need to generate a cache key that stays the same as long as pod versions stay the same.

A good way to generate a cache key is to combine:

  • The SHA1 of Podfile so that if Podfile changes then the key changes as well
  • The version of Xcode used to build the cache, so that upgrading to another version of Xcode changes the cache key as well
  • The configuration (Debug or Release) used when building the cache

In fastlane terms:

private_lane :pods_cache_s3_key do
  podfile_path = File.expand_path('../Podfile')
  podfile_sha1 = sh("shasum '#{podfile_path}'").split("  ")[0].strip
  configuration = 'release' # Adjust depending on what you build the cache for
  xcode_version = sh("xcodebuild -version | grep 'Build version' | awk '{ print $3 }'").strip
  "s3://S3_BUCKET/cache/pods_#{configuration}_#{podfile_sha1}_xcode-#{xcode_version}.zip"
end

Which generates the following key:

s3://S3_BUCKET/cache/pods_debug_ea0b39fcbcca4ee58eea4fa2178eb420fc0f2862_xcode-9C40b.zip

Saving the cache

Saving the cache consists of zipping the ./build/Release-iphoneos folder and uploading it to S3 with the cache key.

lane :save_pods_cache do |options|
  output_build_folder = 'Release-iphoneos'  
  s3_key = pods_cache_s3_key()
  build_directory = File.expand_path('../build')
  zip_output_file = "/tmp/#{SecureRandom.hex}.zip"
  sh("cd '#{build_directory}' && zip -r '#{zip_output_file}' ./#{output_build_folder}")    
  sh("aws s3 cp '#{zip_output_file}' '#{s3_key}'")
  FileUtils.rm(zip_output_file)
end

You’ll need to have the AWS cli installed on your CI, which on CircleCI is a matter of adding the following line to dependencies:override:

HOMEBREW_NO_AUTO_UPDATE=1 brew install awscli

Checking whether the cache exists

We can check whether the cache already exists by querying S3:

private_lane :s3_key_exists do |options|
  s3_key = options[:s3_key]
  sh("aws s3 ls '#{s3_key}' &> /dev/null; echo $?").strip.to_i == 0
end

Restoring the cache

Restoring the cache in later builds is a matter of fetching a zip file by its S3 key, then unzipping it in the build/ folder:

lane :restore_pods_cache do |options|
  s3_key = pods_cache_s3_key()
  zip_output_file = "/tmp/#{SecureRandom.hex}.zip"
  cache_output_dir = File.expand_path('../build')
  FileUtils.mkdir_p(cache_output_dir)
  sh("aws s3 cp '#{s3_key}' '#{zip_output_file}'")    
  sh("cd '#{cache_output_dir}' && unzip '#{zip_output_file}'")
  FileUtils.rm(zip_output_file)
end

Automatically download or build the cache

To glue it together, let’s add a lane that downloads the cache if it exists, or builds it if it doesn’t.

lane :restore_or_build_pods_cache do
  s3_key = pods_cache_s3_key()
  does_cache_exist = s3_key_exists(:s3_key => s3_key)
  
  if does_cache_exist
    restore_pods_cache()
  else
    build_pods()
    save_pods_cache()
  end
end

Putting it together

Now that we have all our little building blocks — saving / checking / restoring / building the Pods cache and patching / building the App project — we can run all our lanes like this:

$ fastlane restore_or_build_pods_cache
$ fastlane patch_project_for_pods_cache
$ fastlane build_app

You can find Scoop’s Fastfile here: https://gist.github.com/ldiqual/b060cff88f8ad2147fefa7f5af210e02

It contains a lot of Scoop specific things to support different targets & services but this will give you an idea of how we apply this in production.

Results & Limitations

For each commit pushed to Scoop’s iOS repo, CircleCI runs the following steps:

  1. Checkout source code
  2. Run pod install
  3. Lint the project
  4. Build app for tests: ~5′
  5. Run unit and integration tests: ~7′
  6. Build app for QA: ~10′
  7. Upload IPA to S3

This would usually take about 25 minutes total. By implementing the Pods caching strategy described above we saved about 5 minutes for both steps 4) and 6), which decreases overall build time to 15–20 minutes.

Here’s what CircleCI build times look like for the past year, excluding outliers:

We have been using this system for about 4 months and it has worked pretty consistently.

There are significant limitations though:

  1. It does make it harder to maintain the codebase because for each new app target (eg: Scoop Alpha, Scoop Beta) or extensions (eg: notification services) you need to update the Fastfile so that it also compiles a pods cache for those targets. This doesn’t happen often but we need to be aware of it.
  2. There is a chance that this could break in future versions of CocoaPods if the Embed Pods Frameworks script changes, or even in future versions of Xcode if the build folder were to change (ie: build/Release-iphoneos).
  3. A couple things in this technique — namely that it uses a dependency cache without being 100% sure that it is generated under the same conditions (env vars, pod versions, build settings, …) than the current build, and the fact that it patches an Xcode project to hack around a CocoaPods script — makes it unsuitable for production builds. It might work, but we recommend against it.

Given that we’re limiting this to test and internal builds, we think those limitations are acceptable. We recommend giving pods caching a shot if you care about build times!


Interested in tackling exciting iOS challenges and putting living, breathing human beings in cars together? Hit us up at jobs@takescoop.com or visit takescoop.com/careers.


Jon Sadow

Jon Sadow

Jonathan Sadow is the Co-Founder and Chief Product Officer of Scoop, overseeing all product, engineering, UX, research, and design functions. When he isn’t immersed in making Scoop carpools the best experience possible, he can be found spending time with his family and Scoop’s unofficial mascot, Kugel.

Leave a Reply

Your email address will not be published. Required fields are marked *