Mobile Development 52 min read

Binaryizing iOS CocoaPods Components to Accelerate Build Times and Streamline CI

This article describes how the iOS team reduced build times by binaryizing nearly 100 CocoaPods components, detailing macro handling, packaging with cocoapods‑packager, storage strategies, dependency switching mechanisms, and CI integration to streamline development and deployment.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Binaryizing iOS CocoaPods Components to Accelerate Build Times and Streamline CI
This article is reprinted from | iOS大全 Source | triplecc's blog

The Fire Shopkeeper iOS client has been componentized for nearly two years, now containing close to 100 components. As component count and code size grew, the main project's build time increased from a few minutes to about forty minutes. Frequent changes to upper‑level business components further lengthened release cycles, and the long compile time hurt daily development experience and CI pipeline duration, especially on resource‑constrained runners. Reducing compile time has become an urgent need for the team.

Introduction

Besides making module reuse easier and business development lighter, component binaryization offers a crucial advantage: binary components can be pre‑compiled into static or dynamic libraries and stored centrally, allowing downstream projects to link them directly and dramatically speed up compilation.

Compared with source dependencies, binary dependencies only need linking, not compilation, which can greatly improve integration efficiency. After binaryizing most components, our main project build time dropped from around forty minutes to a fastest of twelve minutes—a reduction of more than threefold. CI stages that involve compilation (lint, packaging, publishing) also saw multi‑fold time savings.

Because we could not find a mature dependency‑switching tool, we wrote a generic plugin called cocoapods‑bin for binaryization; developers can try it if needed.

Some binary solutions keep the binary package after the first compilation and reuse it for subsequent builds (e.g., app packaging, component lint, publishing). Our approach differs: we generate the binary package **before** the first compilation, typically during component publishing, which is more suitable for components that are compiled only once.

Since CocoaPods 1.3.0 introduced incremental compilation, subsequent install/update operations only re‑compile changed files. This feature mainly optimizes non‑first installs/updates and is not strictly required for our binaryization workflow.

Binaryization Requirements

Based on the daily development situation of the Shopkeeper team, the following binaryization requirements were identified:

Do not affect business teams that have not adopted binaryization.

Component‑level source/binary dependency switching.

Automatically fall back to source version when no binary version exists.

Provide an experience close to native CocoaPods usage (hence we decided to develop a custom CocoaPods plugin).

Do not add excessive extra workload.

The following sections will explain the binaryization process of the Shopkeeper iOS team according to these points.

Macro Definition Handling

Macro definitions processed during the pre‑compilation phase become invalid after a component is binaryized, especially macros like DEBUG used by debugging tools. To handle this, we split macro usage into two categories:

Inside methods

Outside methods

For macros used inside methods, we created a TDFMacro class to replace the macros and move the logic to runtime:

// TDFMacro.h
@interface TDFMacro : NSObject
+ (BOOL)enterprise;
+ (BOOL)debug;

+ (void)debugExecute:(void(^)(void))debugExecute elseExecute:(void(^)(void))elseExecute;
+ (void)enterpriseExecute:(void(^)(void))enterpriseExecute elseExecute:(void(^)(void))elseExecute;
@end

// TDFMacro.m
@implementation TDFMacro
+ (BOOL)enterprise {
#if ENTERPRISE
    return YES;
#else
    return NO;
#endif
}

+ (BOOL)debug {
#if DEBUG
    return YES;
#else
    return NO;
#endif
}

+ (void)debugExecute:(void (^)(void))debugExecute elseExecute:(void (^)(void))elseExecute {
    if ([self debug]) {
        !debugExecute ?: debugExecute();
    } else {
        !elseExecute ?: elseExecute();
    }
}

+ (void)enterpriseExecute:(void (^)(void))enterpriseExecute elseExecute:(void (^)(void))elseExecute {
    if ([self enterprise]) {
        !enterpriseExecute ?: enterpriseExecute();
    } else {
        !elseExecute ?: elseExecute();
    }
}
@end

With this approach, we only need to ensure that macros inside the TDFMacro component remain effective—no need to binaryize it.

For macros used outside methods, we try to move as much code as possible inside methods and refactor the rest to eliminate macro definitions, for example in our network layer constants:

// Before
#if DEBUG 
NSString * kTDFRootAPI = @"xxx";
#else
NSString * const kTDFRootAPI = @"xxx";
#endif
// After
NSString * kTDFRootAPI = @"xxx";

It is advisable to avoid cross‑module macro definitions; use constants or functions instead. For example, component A defines a macro TDF_THEME_BACKGROUNDCOLOR and component B uses it. If A changes the macro after both are binaryized, B will not see the change unless B is re‑binaryized.

// A
#define TDF_THEME_BACKGROUNDCOLOR [[UIColor whiteColor] colorWithAlphaComponent:0.7]

// B (uses the macro)
// ...
// Later A changes the macro:
#define TDF_THEME_BACKGROUNDCOLOR [[UIColor whiteColor] colorWithAlphaComponent:0.4]

Creating Binary Packages

The first step of binaryization is to produce the binary package. We commonly use cocoapods‑packager and Carthage; in our case we use cocoapods‑packager to build a static framework.

cocoapods‑packager works similarly to pod spec/lib lint : it generates a temporary Podfile from the podspec, installs the target, and finally runs xcodebuild to produce the binary. As long as the component passes lint, a binary can be built without tying to an example project. However, the plugin is largely unmaintained, and many older issues remain unresolved.

Command we used to build a static framework:

pod package TDFNavigationBarKit.podspec --exclude-deps --force --no-mangle --spec-sources=http://git.xxx.net/ios/cocoapods-spec.git

During usage we encountered two resource‑related issues:

Using --exclude-deps prevents dependency symbols from being linked, but still copies the dependency bundle (see builder.rb line 229).

Resources declared in a subspec are not copied into the framework.

Since cocoapods‑packager has no near‑term release plan, we forked it, fixed the two problems, and released cocoapods‑packager‑pro . The command becomes:

pod package‑pro TDFNavigationBarKit.podspec --exclude-deps --force --no-mangle --spec-sources=http://git.xxx.net/ios/cocoapods-spec.git

The package‑pro sub‑command simply replaces package .

cocoapods‑packager creates a module.modulemap by checking the podspec's module_map field; if absent, it looks for a header file with the same name as the component. For components without a matching header (e.g., SDWebImage ), no modulemap is generated, which breaks Swift imports. In such cases we must add a modulemap manually.

CocoaPods 1.6.0 beta introduced API changes that broke cocoapods‑packager . Therefore the packager currently works only with CocoaPods versions below 1.6.0. If the official plugin remains unupdated, we will adapt cocoapods‑packager‑pro to newer versions.

The cocoapods‑generate plugin can generate a target project from a podspec, serving as an enhanced front‑half of cocoapods‑packager . It supports CocoaPods 1.6.0 beta. Developers can generate the project with cocoapods‑generate and then handle the binary packaging themselves, using either custom scripts or Carthage.

After building the .framework , we compress it into a zip file:

zip --symlinks -r TDFNavigationBarKit.framework.zip TDFNavigationBarKit.framework

The resulting zip is the binary package.

The directory layout of a framework produced by cocoapods‑packager contains symlinks for Headers , Resources , and Versions/Current . Fields like source_files , public_header_files , and resources in the podspec must point to the actual file locations, not the symlinks. Example adjustments:

s.source_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h
s.public_header_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h
# or more comprehensively
s.source_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h, TDFNavigationBarKit.framework/Headers/*.h
s.public_header_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h, TDFNavigationBarKit.framework/Headers/*.h

We added a convenient command for the team:

# Package source into binary and zip it
pod binary package

Storing Binary Packages

There are two common storage locations; we currently use the second (static file server):

Component's Git repository.

Static file server.

Advantages of a static file server over a Git repo include:

API‑based access, easy to extend and automate.

Separates source and binary, making binary download faster than cloning the repo.

Does not bloat the Git repository size, improving source dependency download speed.

CocoaPods caches downloaded components. The first install caches the component; subsequent installs reuse the cache unless the CocoaPods version changes, which clears the cache. Our CI environment runs multiple CocoaPods versions across different pipelines, causing frequent cache invalidation and longer download times.

Switching Dependency Modes

After binaryization, build speed improves, but developers lose the ability to debug source code, so a switch between binary and source dependencies is required. This includes switching for the whole project, individual components, and handling binary versions—an effort‑intensive part of binaryization.

We tried three schemes: single private source single version, single private source double version, and finally double private source single version.

Single Private Source Single Version

Switch dependencies by dynamically modifying the podspec without changing the private source or component version.

This was our first attempt, and we created the plugin cocoapods‑tdfire‑binary . The idea mirrors the "iOS CocoaPods component smooth binaryization" article: use environment variables and conditional statements in the podspec to change its content. Although podspecs support Ruby, we recommend publishing them as JSON for idempotent caching.

The main difficulty is handling CocoaPods cache when switching. Two approaches we tried:

Keep both source and binary resources in cache (set preserve_paths ).

Delete the component's cache and the local Pods directory before switching.

The second approach hooks Pod::Installer#resolve_dependencies to clear caches and mark the sandbox as changed, forcing a fresh download.

def cache_descriptors
  @cache_descriptors ||= begin
    cache = Downloader::Cache.new(Config.instance.cache_root + 'Pods')
    cache_descriptors = cache.cache_descriptors_per_pod
  end
end

def clean_local_cache(spec)
  pod_dir = Config.instance.sandbox.pod_dir(spec.root.name)
  framework_file = pod_dir + "#{spec.root.name}.framework"
  if pod_dir.exist? && !framework_file.exist?
    @analysis_result.sandbox_state.add_name(spec.name, :changed)
    begin
      FileUtils.rm_rf(pod_dir)
    rescue => err
      puts err
    end
  end
end

def clean_pod_cache(spec)
  descriptors = cache_descriptors[spec.root.name]
  return if descriptors.nil?
  descriptors = descriptors.select { |d| d[:version] == spec.version }
  descriptors.each do |d|
    slug = d[:slug].dirname + "#{spec.version}-#{spec.checksum[0,5]}"
    framework_file = slug + "#{spec.root.name}.framework"
    unless framework_file.exist?
      begin
        FileUtils.rm(d[:spec_file])
        FileUtils.rm_rf(slug)
      rescue => err
        puts err
      end
    end
  end
end

When using the binary server, the first approach (preserve paths) requires downloading both source and binary resources, which we found cumbersome, leading us to abandon cocoapods‑tdfire‑binary .

The second approach modifies the installer to clear caches and then re‑download the component.

# In podspec, add:
....
tdfire_source_configurator = lambda do |s|
  # source dependency configuration
  s.source_files = '${POD_NAME}/Classes/**/*'
  s.public_header_files = '${POD_NAME}/Classes/**/*.{h}'
end
unless %w[tdfire_set_binary_download_configurations tdfire_source tdfire_binary].reduce(true) { |r, m| s.respond_to?(m) && r }
  tdfire_source_configurator.call s
else
  # generate source configuration internally
  s.tdfire_source tdfire_source_configurator
  # generate binary configuration internally
  s.tdfire_binary tdfire_source_configurator
  # set download script, preserve_paths
  s.tdfire_set_binary_download_configurations
end

In the Podfile we enable the plugin and switch:

... 
plugin 'cocoapods-tdfire-binary'

tdfire_use_binary!
# tdfire_third_party_use_binary!
tdfire_use_source_pods ['AFNetworking']
...

Because we were not familiar with CocoaPods internals, the plugin kept most podspec logic outside. A better design would place a binary flag inside the podspec and let the hook replace source and dependency fields accordingly.

Single Private Source Double Version

Switch by adding a -binary suffix to the version number without changing the private source.

This approach required us to manipulate the resolver to replace specifications after dependency analysis. We attempted to modify Pod::Resolver#specifications_for_dependency to adjust version requirements, but indirect dependencies caused conflicts because the version constraint did not explicitly request a pre‑release binary version.

Due to the previous naïve CocoaPods resolver, you were using a pre‑release version of `YYModel` without explicitly asking for a pre‑release version, which now leads to a conflict. Please decide to either use that pre‑release version by adding the version requirement to your Podfile (e.g. `pod 'YYModel', '= 1.0.1-binary, ~> 1.0'`) or revert to a stable version by running `pod update YYModel`.

Ultimately we abandoned this scheme and moved to a double private source single version approach, which shares the same implementation entry points.

Double Private Source Single Version

By keeping the same component version but using two private sources—one for source pods and one for binary pods—we can switch dependencies simply by changing the source URL. Both sources contain identical version numbers, but the podspec fields differ (e.g., source , vendored_frameworks ).

Example with YYModel :

# cocoapods-spec (source)
{
  "name": "YYModel",
  "version": "1.0.4.2",
  "source": {"git": "[email protected]:cocoapods-repos/YYModel.git", "tag": "1.0.4.2"},
  "source_files": "YYModel/*.{h,m}",
  ...
}

# cocoapods-spec-binary (binary)
{
  "name": "YYModel",
  "version": "1.0.4.2",
  "source": {"http": "http://iosframeworkserver-shopkeeperclient.app.2dfire.com/download/YYModel/1.0.4.2.zip"},
  "vendored_frameworks": "YYModel.framework",
  "source_files": ["YYModel.framework/Headers/*", "YYModel.framework/Versions/A/Headers/*"],
  ...
}

Switching is performed by the cocoapods‑bin plugin, which overrides Pod::Resolver#resolver_specs_by_target . The plugin decides whether to use the binary source or the code source based on the use_binaries flag and a selector. If a binary spec cannot be found, the component falls back to the source version.

module Pod
  class Resolver
    if Pod.match_version?('~> 1.4')
      old_resolver_specs_by_target = instance_method(:resolver_specs_by_target)
      define_method(:resolver_specs_by_target) do
        specs_by_target = old_resolver_specs_by_target.bind(self).call
        sources_manager = Config.instance.sources_manager
        use_source_pods = podfile.use_source_pods
        missing_binary_specs = []
        specs_by_target.each do |target, rspecs|
          use_binary_rspecs = if podfile.use_binaries? || podfile.use_binaries_selector
                                 rspecs.select do |rspec|
                                   ([rspec.name, rspec.root.name] & use_source_pods).empty? &&
                                   (podfile.use_binaries_selector.nil? || podfile.use_binaries_selector.call(rspec.spec))
                                 end
                               else
                                 []
                               end
          specs_by_target[target] = rspecs.map do |rspec|
            next rspec unless rspec.spec.respond_to?(:spec_source) && rspec.spec.spec_source
            use_binary = use_binary_rspecs.include?(rspec)
            source = use_binary ? sources_manager.binary_source : sources_manager.code_source
            spec_version = rspec.spec.version
            begin
              specification = source.specification(rspec.root.name, spec_version)
              specification = specification.subspec_by_name(rspec.name, false, true) if rspec.spec.subspec?
              next rspec unless specification
              rspec = if Pod.match_version?('~> 1.4.0')
                         ResolverSpecification.new(specification, rspec.used_by_tests_only)
                       else
                         ResolverSpecification.new(specification, rspec.used_by_tests_only, source)
                       end
              rspec
            rescue Pod::StandardError => error
              missing_binary_specs << rspec.spec if use_binary
              rspec
            end
          end.compact
        end
        missing_binary_specs.uniq.each do |spec|
          UI.message "【#{spec.name} | #{spec.version}】component has no binary version, falling back to source."
        end
        specs_by_target
      end
    end
  end
end

The plugin also patches Specification::Set::LazySpecification (available from CocoaPods 1.4.0) to expose the spec_source attribute, which is required for the resolver override.

Integrating CI

Binaryization adds extra steps (package creation, publishing). To keep the workflow smooth, we extended our GitLab CI configuration:

variables:
  BINARY_FIRST: 1
  DISABLE_NOTIFY: 0

before_script:
  - export LANG=en_US.UTF-8
  - export LANGUAGE=en_US:en
  - export LC_ALL=en_US.UTF-8
  - pwd
  - git clone [email protected]:ios/ci-yaml-shell.git
  - ci-yaml-shell/before_shell_executor.sh

after_script:
  - rm -fr ci-yaml-shell

stages:
  - check
  - lint
  - test
  - package
  - publish
  - report
  - cleanup

component_check:
  stage: check
  script:
    - ci-yaml-shell/component_check_executor.rb
  only:
    - master
    - /^release.*$/
    - /^hotfix.*$/
    - tags
    - CI
  tags:
    - iOSCI
  environment:
    name: qa

package_framework:
  stage: package
  only:
    - tags
  script:
    - ci-yaml-shell/framework_pack_executor.sh
  tags:
    - iOSCD
  environment:
    name: production

publish_code_pod:
  stage: publish
  only:
    - tags
  script:
    - ci-yaml-shell/publish_code_pod.sh
  tags:
    - iOSCD
  environment:
    name: production

publish_binary_pod:
  stage: publish
  only:
    - tags
  script:
    - ci-yaml-shell/publish_binary_pod.sh
  tags:
    - iOSCD
  environment:
    name: production

report_to_director:
  stage: report
  script:
    - ci-yaml-shell/report_executor.sh
  only:
    - master
    - tags
  when: on_failure
  tags:
    - iOSCD

After pushing a tag, the pipeline runs the package and publish stages, which handle binary package creation and publishing. Developers can continue to work with source versions as before; the binary workflow is fully automated.

Conclusion

The binaryization effort consumed half a year of work and required maintaining an additional binary server. For small component sets without CI, the cost outweighs the benefit, so we do not recommend teams that have not yet componentized their code to adopt this approach.

For our team, however, the reduction in compile time and faster component publishing justified the investment, and the binaryization pipeline now provides clear performance gains.

References

[1] iOS CocoaPods component smooth binaryization solution https://www.jianshu.com/p/5338bc626eaf [2] iOS CocoaPods component smooth binaryization tutorial – subspecs part https://www.jianshu.com/p/85c97dc9ab83 [3] Componentization – binary solution https://www.valiantcat.cn/index.php/2017/05/16/48.html

- EOF -

iOSbuild optimizationCocoaPodsdependency managementCIbinary components
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.