Managing Internal iOS Dependencies with Private CocoaPods

Strava's iOS application and extensions are powered by a lot of code. As with most apps, this is a combination of code written by Strava engineers and by various third parties. We've been managing these third party dependencies using CocoaPods, a dependency manager for Swift and Objective-c Cocoa projects, for a few years.

CocoaPods allowed us to easily share third party libraries such as AFNetworking or Typhoon between different targets inside the iOS application. For example, both our iOS application and our Today extension, which are separate binary targets, need access to AFNetworking for network requests. This is as easy as adding pod AFNetworking to each target's portion of our Podfile, and voila!

We liked CocoaPod's portability and ease of use for managing third party dependencies so much that we wanted to enable our engineers to do this for our internal app code as well. Luckily, CocoaPods provides a guide for doing just that. Strava's iOS application has a handful of localized formatters and model types, for example, that need to be shared between different targets. Sharing this code allows our iOS and watchOS applications to represent data, visually and programmatically, in the same way with ease. This required us to refactor this shared internal code dependency into a separate framework inside the project.

Before using CocoaPods to manage these internal target dependencies, we would have to manually link the framework to various targets inside of our Xcode project. This manual linking added bloat and meant that any changes to the framework were immediately included in all platform targets upon commit. We also wanted to write a mobile client segment matching library, which meant that we were writing Android and iOS frameworks that would ideally share the same test data for verification.

Benefits

By switching to CocoaPod's for our internal dependencies we gained the following benefits:

  • Separate Git repository on a per framework basis.
    • The nature of private CocoaPods means that the framework code exists in a separate repository.
    • Separate repositories make it less likely to have merge conflicts in framework code.
  • Decreased project/code conflicts.
    • Dependencies are managed explicitly inside the Podfile instead of in our Xcode project file. Fixing merge conflicts means fixing the Podfile and running pod install to have CocoaPods make it right.
  • Framework code can live amongst other platform code/data.
    • Related Android/iOS libraries can live in the same, logical repository with the same verification data.
  • Easy framework versioning using Strava's internal CocoaPod source repository.
  • Increased portability because of defined CocoaPod structure.
    • Smart organization allowed us to include a pod's subspec in our Apple Watch extension with little change even though the full pod was only iOS compatible.
  • Seamless multi-target framework linking handled by CocoaPods.
    • Add pod StravaKit to the target in the Podfile and you're 👌🏽
  • Fast main app-independent development, compile and test time.
    • Our main app target and tests contain 1000+ source files while our frameworks typically contain under 50 source files. Decoupling the time to test changes in these code bases from the main app enables faster development. Rather than taking 30 minutes of CI compute time it takes just seconds, making the test time more proportional to the size of the changes.

Our CocoaPod structure

This section will detail the changes and repositories involved in using and modifying our internal CocoaPods. The Podfile changes below allow CocoaPods to find our internal pods. The private framework repositories and the private spec repository allow engineers to create and contribute to our internal pods.

Podfile

According to CocoaPods, the Podfile is "a specification that describes the dependencies of the targets of one or more Xcode projects." We had to make changes to our Podfile to use private and public CocoaPods in tandem. To allow the CocoaPods app to locate our internal pods in our private spec repository we added a new source directive:

source 'https://github.com/CocoaPods/Specs.git'
source 'https://github.com/strava/specs.git'

These source directives live at the top of our Podfile. The first source is the CocoaPods main trunk where all public pods store their podspecs and the second is the repository where Strava engineers push updates to our internal pods. This addition allows engineers to use our internal pods as though they were regular public pods, and without declaring a separate source on a per pod basis.

Private Framework Repository

All individual pods are stored in a separate repository. These frameworks have their own tests and, potentially, example applications to demonstrate functionality. CocoaPods allows us to declare inter-pod dependencies with ease in the individual Git repositories. For example, our iconography pod, that contains our programmatically drawn icons, can depend on our model pod, that contains our model objects. CocoaPods does all of the heavy lifting by sorting out these dependencies when the pods are incorporated into our main application.

The workflow for contributing to these frameworks is as follows.

Development

The engineer altering the pod framework makes code changes in the separate repository. It is useful for engineers to use their framework changes alongside the main application, at times, for validation and testing. This can be done by the contributing engineer or by the reviewer. CocoaPods makes this easy with the :path Podfile directive.

  1. Clone the framework repository to the local system.

    git clone https://github.com/strava/STRVIcons.git ~/Developer/Strava/icons
    
  2. Checkout the branch in the framework that needs reviewing.

    cd ~/Developer/Strava/icons
    git checkout origin/awesome.framework.change
    
  3. Point the pod path in the main app's Podfile to the location of the cloned Git repository on their system.

    pod 'STRVIcons', :path => '~/Developer/Strava/icons'
    
  4. Run pod install in the main app directory to sync the Pods/ workspace with the new Podfile. This symbolically links the pod's source files into our main app workspace, which allows changes to the pod to be made directly in that workspace.

  5. Build and run main app like normal.

Submitting Changes

  1. Push code to these repositories using a normal GitHub Pull Request flow (open Pull Request, solicit code review, CI checks, etc.).

  2. Validate the current, local state of the pod using pod lib lint and a reference to any source dependencies.

    
      pod lib lint STRVIcons.podspec --private --sources=https://github.com/strava/specs.git,master
    
    

    • --sources keyword is required when the pod has dependencies on another internal CocoaPod.
    • --private allows us to avoid warnings that don't matter for private pods like a non-reachable homepage value.
  3. Version the pod by altering the version value in the podspec...

    ~/D/S/icons> git diff
    diff --git a/STRVIcons.podspec b/STRVIcons.podspec
    index 51f59f9..1d8e4bd 100644
    --- a/STRVIcons.podspec
    +++ b/STRVIcons.podspec
    @@ -8,7 +8,7 @@
    
     Pod::Spec.new do |s|
       s.name             = 'STRVIcons'
    -  s.version          = '0.2.0'
    +  s.version          = '1.0.0'
    
  4. ...and push a Git tag that matches the version.

    git tag 1.0.0
    git push origin 1.0.0
    
  5. The new podspec must be pushed to the private spec repository. The pod repo push command is detailed in the next section. We have created a Makefile in many of our pods to ease this process.

Private Spec Repository

As mentioned above, Strava has an internal CocoaPod spec repository where we store our pod versions. This repository contains all podspec files with an associated version and a similar public example is here. These podspec files have information about a given pod and point CocoaPods to a specific tag in one of our framework repositories. The structure of this spec repository is maintained by the pod repo push command in CocoaPods. We don't manipulate it manually.

The following will detail what's required to push new versions of our pods. These actions are only required when mutating internal pods since the source directive in the Podfile allows CocoaPods to interface with our internal spec repository when running pod install (to sync the project with the Podfile.lock/Podfile file) or pod update (to update a given pod included in our Podfile).

  1. The reference to this repository must be added to the CocoaPods Ruby gem on your system. strava is typically the name we give the local copy of our internal spec repository.

    pod repo add strava https://github.com/strava/specs.git
    
  2. The local copy of the spec repository can become out of date. We run this to sync it with the remote repository.

    pod repo update strava
    
  3. The new version of the internal CocoaPod must be pushed so we can include it in our main app code. We run this to push the latest framework repository's podspec to our internal spec repository.

    pod repo push strava ~/Developer/Strava/icons/STRVIcons.podspec
    

Caveats

  • We currently version and update each pod manually. We are looking to automate this in the future but our pods tend to rarely change.
  • We don't manually diff Pods/ changes in our main app repository. We re-run the desired pod command (pod install or pod update) after sorting out Podfile and Podfile.lock collisions.
  • Keeping everyone on the same CocoaPods gem helps avoid collisions.
  • We check in our Pods/ folder to our main application repository. This allows initial setup and build without any CocoaPods knowledge.

Difficulties (it's not all 🌈 and 🦄)

The following are some difficulties we've experienced with our private CocoaPod structure.

  1. Internal CocoaPod name that matches a public CocoaPod. See StackOverflow here.
    • CocoaPods defers to the master trunk when doing pod operations. This results in our internal CocoaPod getting skipped for the public pod with the same name.
    • We ended up changing our internal pod name to easily get around this issue. And we won't tell you what it is. 😀
  2. Sparse information on maintaining CocoaPods.
    • We started using this structure more than 2 years ago and the documentation/CocoaPods community has come a long way.
  3. Localized string/resource bundling, in general.
    • We manually include a string bundle from our localized formatter pod in our main app, localize it separately from the pod, and ensure the pod files load the bundle by name.
  4. External Pull Request review and multiple levels of review take time.
    • The external nature of the code is great for many things. It ends up being harder to solicit reviews when it's separate from our typical day-to-day codebase.
  5. Lack of understanding through team about how CocoaPods work.
    • Like any new technology, this requires an engineer to learn how to use it and creates a natural barrier to entry, despite it making things easier once he/she is comfortable.

Finally

In closing, using CocoaPods to manage both internal and external dependencies has made development easier and faster over the past two years. Our infrastructure for dealing with the various repositories continues to evolve and become more automated over time.

Also, Strava is hiring! If you're interested in working on Strava's engineering team, you can also reach out to me directly @m4ttrob.