the good, alright dev

CocoaPods Setup ft. Apple Silicon

The article describes CocoaPods setup for Apple Silicon (arch64) machines. We're going to skim over possible Ruby Environment setups, discuss why and how pin gems versions, and how to resolve common issues.

Ruby Management

tl;dr:

  • If you can roll with old Ruby (~2.6.3) - checkout System Ruby solution

  • Otherwise, I recommend to use asdf or rbenv

CocoaPods is a Ruby gem or package. Therefore we can't get much done without a proper Ruby setup. We'll kick off the tutorial by discussing our Ruby environment options.

System Ruby

tl;dr:

Fortunately for us, macOS has Ruby on board. It's a 2.6.3 version on Big Sur 11.2.3 at the time of writing. It's an outdated version and all, but let's be honest here, for most iOS projects, it might be enough ๐Ÿ˜ฌ

Just imagine for a second, no Ruby environment juggling, and you are ready to roll from the start with a minimal setup. I assure you, it's nothing wrong with using the system Ruby. Especially so with arm-based machines:

$ file /usr/bin/ruby

/usr/bin/ruby: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64e:Mach-O 64-bit executable arm64e]
/usr/bin/ruby (for architecture x86_64): Mach-O 64-bit executable x86_64
/usr/bin/ruby (for architecture arm64e): Mach-O 64-bit executable arm64e

See that file output with 2 architectures? It means that the binary can run either natively or under Rosetta (arch -x86_64 ruby) emulation.

Does it matter? Well, sometimes Rosetta fails. For example, launching arch -x86_64 scripts under arch -x86_64 mode will fail with arch: posix_spawnp: ruby: Bad CPU type in executable. You might think that the example is a bit contrived. Alas, libraries' support for M1 is a mess at the time of writing. So you happen to find a strange workaround here and there.

Universal binaries don't matter much in terms of local Ruby setup, but I guess it's fewer things to worry about.

System Ruby: GEM_HOME

Probably the main issue you'll encounter with the system Ruby is that gem install requires sudo by default.

But we can easily fix this by providing GEM_HOME environment variable:

# ~/.bash_profile or ~/.zshrc
export GEM_HOME="~/.gem/ruby/2.6.3/"

# fish shell
set -x GEM_HOME "~/.gem/ruby/2.6.3/"

After sourcing the config (or opening a new terminal tab), gem install no longer needs sudo. Try it by installing bundler (gem install bundler). You'll probably need it later :)

System Ruby: PATH

By the way, you'll also need to add ~/.gem/ruby/2.6.3/bin to your $PATH. It's the home of gem executables (bundler, pods, fastlane):

# ~/.bash_profile or ~/.zshrc
export PATH="~/.gem/ruby/2.6.3/bin${PATH:+:${PATH}}"

# fish shell
set -U fish_user_paths "~/.gem/ruby/2.6.3/bin" $fish_user_paths

Fishshell users, please checkout path.fish function to simplify $PATH handling.


Homebrew

tl;dr:

  • Install Homebrew

  • brew install ruby@3 and follow the provided instructions

If you don't swap Ruby versions, but need something other than the system Ruby version, then Homebrew is here for you.

Homebrew has a collection of precompiled Ruby versions. The group included 2.4, 2.5, 2.6, 2.7, 3.0 versions at the time of writing. You can specify version via:

$ brew install ruby@3.0

If you need to have ruby first in your PATH, run:
  echo 'export PATH="/opt/homebrew/opt/ruby/bin:$PATH"' >> ~/.zshrc
...

I'll leave you in the good hands of Homebrew for the subsequent setup. All you need to do is to follow the provided instructions.

Mind that you can install as many versions as you need. Though the swapping between them is passable at best. Another downside of Homebrew is its precompiled versions range. If your version is not on the list, well, brew won't help you here.

Also, specific Ruby versions are drift with time. For example, ruby@3 installs 3.0.1 today, but tomorrow's ruby@3 will install 3.0.2 or something entirely different.

Despite all the downsides and inconveniences, it's a sensible path to take to avert Ruby compilation (some RVM hacks use this Homebrew feature).

RVM

tl;dr:

  • Follow the official RVM instructions

  • Also, mind I interest you in switching to something else ๐Ÿค”

RVM stands for Ruby Version Manager and is considered a classical approach to managing the Ruby environment.

There're already tons of guides on installing RVM. Therefore I leave you here.

As far as Apple Silicon is concerned, you might find the RVM ride to be a bit bumpy: Unable to install any version of ruby on macOS Big Sur ยท Issue #5047 ยท rvm/rvm. Some workarounds, including installing Homebrew versions, are specified in the discussion.

RVM is more than capable of providing a decent Ruby environment. Nevertheless, my take is RVM feels too hacky. It loads in the shell, overrides cd, or requires additional prompt mockery. I often find the rbenv or asdf to be a better choice.

rbenv

tl;dr:

rbenv is yet Ruby environment manager. Alas, a less popular one. It works via PATH directories prioritization trick (How rbenv works it details). So, no side scripts and other shell override shenanigans in the background (RVM ๐Ÿ‘€)

It also has a dedicated page for rbenv vs RVM comparison if you are into it.

The main rbenv's' drawback for arm64 architecture is that some Ruby versions require unconventional installation approach. Nevertheless, rbenv is genuinely good. It was my Ruby environment manager before asdf.

asdf

tl;dr:

  • follow the official install instructions

  • install Ruby plugin asdf plugin-add ruby https://github.com/asdf-vm/asdf-ruby.git

  • install required version asdf install ruby 3.0.0 && asdf ruby global 3.0.0

asdf is an extendable version manager with support for Ruby, Node.js, Elixir, Erlang, and more. The principle behind asdf is similar to rbenv, both use PATH directories prioritization.

Official documentation is genuinely good. Alas, an installation process might turn a bit cryptic, especially considering Common Homebrew issues ๐Ÿป ยท Issue #785. I'm personally using the plain git clone method here (don't forget to subscribe to asdf releases on GitHub)

asdf Ruby plugin aside from managing Ruby environments, also can:

  • Install default gems right after installing a Ruby versions. Presumably, you want bundler, pry, or gems of your choice to be available on each and every installed Ruby version.
  • Help with migrating from other Ruby version managers. Meaning it supports .ruby-version configuration file

asdf seems to go well along M1 and builds most Ruby versions just fine. Alas, there are nuances

If you got tired of a never-ending stream of language managers, check out asdf. Despite a bit messy setup, it's dope!

Gems Management

tl;dr: use bundler


At this point, I presume you have a working Ruby setup. Check out the Ruby management chapter if it's not the case.

It's a great temptation to globally install CocoaPods(gem install cocoapods) and jump straight into the project. But hear me out, knowing (version control) the exact gem version we work with is always a good idea.

I'm sure you want to get the same result from running pod install on your machine and on a college's machine or a build server. Even if you an indie developer, there's a notion of time. Your future self will have a different setup. Imagine how happy you'll be after enumerating CocoaPods and Ruby versions for the whole day just to reproduce a particular build. The pinned or at least known version of tooling never harms.

With that out of the way, let's discuss how we can pin gem versions in the Ruby environment.

Bundler

bundler is sort of CocoaPods but for Ruby gems. From the side, bundler looks like a complete Xzibit thing: installing package manage to manage package manager while managing packages.

Alas, while sounding like insanity, it's a surprisingly reoccurring theme. For example, Python with easy_install, pip, and pipenv or Haskell and its slack + cabal pairing.

CocoaPods is heavily inspired by bundler, and indeed we can draw a lot of parallels between them:

  • Gemfile is analog to Podfile
  • Gemfile.lock is analog to Podfile.lock

By the way, CocoaPods themselves use bundler. And as you can see, bundler doesn't take much to setup:

  1. Create a Gemfile (or use bundle init) and specify required dependencies. A typical iOS project Gemfile:
# frozen_string_literal: true

source 'https://rubygems.org'

gem 'cocoapods'
gem 'fastlane'
  1. Install specified dependencies with bundle install. At this stage bundler generates dependency tree in Gemfile.lock file:
GEM
  remote: https://rubygems.org/
  specs:
    CFPropertyList (3.0.3)
    activesupport (5.2.4.5)
      concurrent-ruby (~> 1.0, >= 1.0.2)
      i18n (>= 0.7, < 2)
      minitest (~> 5.1)
...

Don't forget to check in Gemfile.lock to the source control or pin the exact gem versions in the Gemfile.

  1. Run commands with bundle exec prefix:
$ bundle exec pod install

You might add an alias alias be="bundle exec" to avoid typing bundle exec over and over again:

$ be pod install

Also, most of the shells have a dedicated bundler plugin with completions:

  1. (Optional) bundler can pin Ruby version as well:
ruby '~> 2.6.0'
# or
ruby '3.0.1'

You might consider using this method, but it depends on your Ruby environment manager of choice. Some managers use .ruby-version or .tool-versions file mechanisms.

Gemset

A lesser-known option of handling gem versions is a gemset. Gemset is a snapshot of globally installed gems. Both RVM and rbenv support a gemset-like notion. Alas, asdf Ruby plugin don't and won't have it

From the first take, gemsets won't work well in "long-term" projects. Yet, it can be helpful in one-shot scripts or library tryouts. Nevertheless, I wholeheartedly recommend sticking to bundler. But if you have a bundler reckoning, I guess gemsets are better than nothing :)

Pods Management

Hey, we're getting closer! At this point, I presume you have a working Ruby setup and installed CocoaPods (either via bundler or globally). It it's not the case, consider skimming through Ruby environment management and gems management parts.

Aside from few quirks, there's nothing new to running CocoaPods under Apple Silicon. Alas, CocoaPods doesn't officially support Apple Silicon at the moment of writing. With that said, CocoaPods run perfectly fine under Rosetta and times even natively.

Our end goal is a project setup that runs on both arm64 and x86 simulators. Such configuration allows a graceful migration without sacrificing simulator performance.

Firstly, we'll see that pod install works as expected. Secondly, we'll build a project and discuss possible build issues.

Pod Install Quirks

If you are lucky enough, pod install or bundle exec pod install won't cause any issues on your machine. If that's the case, move along to the project setup chapter.

If not, well, here's the most common issue with CocoaPods out there:

ffi error

pod install exists with a ffi-related exception:

$ bundle exec pod install
...
LoadError - dlsym(0x7fc182ca67b0, Init_ffi_c): symbol not found - /Library/Ruby/Gems/2.6.0/gems/ffi-1.14.2/lib/ffi_c.bundle
...

Turns out, ffi is also waiting for M1 adoption and probably will get one in 3.4 version.

Depending on your setup, try the following steps if you're using bundler:

  • Pin ffi in Gemfile: gem 'ffi', '1.14.2'
  • Run bundler install (You'll probably need to delete Gemfile.lock beforehand)
  • Run pod install under Rosetta: arch -x86_64 bundle exec pod install

If you have a global CocoaPods pod installed, the steps are mostly the same, but instead, you'll need to pin ffi globally: gem install ffi -v 1.14.2

Project Setup

At last, it's time to build and run the project! Select iOS simulator target and launch the build process. Take my congratulations ๐ŸŽ‰ if everything works fine. It was a long way. I wasn't that lucky and faced another bunch of issues.

First of all, if the project was create in Xcode 11 or older, make sure to get rid of VALID_ARCHS build setting:

  • VALID_ARCHS is no longer a thing in Xcode 12
    • Remove it from build settings (in user-defined variables) and config files
  • VALID_ARCHS setting was replaced by EXCLUDED_ARCHS. Update it accordingly or leave empty
  • Optionally (but highly recommended) to have ONLY_ACTIVE_ARCH = YES for Debug builds
    • Make sure that it's set to ONLY_ACTIVE_ARCH = NO for Release. Xcode doesn't care about ONLY_ACTIVE_ARCH when assembling the Release for a general device. Yet, let's keep things explicit, shall we?

EXCLUDED_ARCHS shenanigans

You've built the project only to face the following error:

Showing Recent Errors Only
~/Work/quotes/Quotes/Sources/Scenes/Premium/PremiumScene.swift:5:8: Could not find module 'Analytics' for target 'arm64-apple-ios-simulator'; found: x86_64-apple-ios-simulator, x86_64

Some CocoaPods vendors add a nasty quick-fix to work around Xcode 12 VALID_ARCHS deprecation:

Pod::Spec.new do |s|
  # ...
  s.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' }
  s.pod_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' }
  # ...
end

These two lines force Xcode to skip assembling the arm64 simulator slice and failing the build.

Tweaking user_target_xcconfig in a Podspec is a big NO-NO. Please, consider other methods.


To fix the issues we're going to patch the project and libraries .xcconfig files:

post_install do |pi|
  pi.target_installation_results.each do |result|
    result.each do |name, installation_result|
      target = installation_result.target
      installation_result.native_target.build_configurations.each do |config|
        config_path = target.xcconfig_path(config.name)
        next unless config_path.exist?

        config_data = config_path.read
        config_data.gsub!("EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64", "")
        File.write(config_path, config_data)
      end
    end
  end
end

I find it as a rather dirty solution, but that's the reality we live in. Also, keep in mind that tweaking build_settings alone won't cut here.

Missing arm64 Simulator Slice

pod install runs just fine, but the project won't build with the following error:

ld: in ../../SpotifyiOS.framework/SpotifyiOS(MPMessagePackReader.o), building for iOS Simulator, but linking in object file built for iOS, file '../../SpotifyiOS.framework/SpotifyiOS' for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

We've encountered a close-sourced (framework or library) dependency that doesn't have an arm64 Simulator slice. Alas, we can't do much here unless you're brave enough ๐Ÿ™ˆ

The thing is, classic fat Mach-O libraries can't have two slices of arm64 architecture. The trick works only with the new [XCFramework](Distributing Binary Frameworks as Swift Packages | Apple Developer Documentation). You can find more about XCFrameworks here:

The quick fix is to launch Xcode under Rosetta (arch -x86_64 xed .) and notify a third-party vendor.

Conclusion (or Rant)

iOS package management is living through its wild west. The advent of new architecture surely doesn't make things easier either. For now, I hope that you found the guide helpful.

Despite all the rough edges, I can't thank the iOS community enough. CocoaPods team delivers the best experience possible and even more. I only wish for Apple to stop pretending CocoaPods doesn't exist in the first place.

It's hard to make any calls but just imagine a graceful Swift Package Manager migration or proactively making CocoaPods support a new Xcode change. How cool would that be, huh? ๐Ÿค”

You can make anything, till next time :)

Subscribe with RSS

ยฉ Egor, All Rights Reserved

thegoodalright.dev is released under a Creative Commons BY-NC License