Build-time optimisation with Xcode build scripts

13th February, 2015 — Aral Balkan

Yesterday, I sideways-glanced at the binary size for Heartbeat and almost had a heart attack when I saw it was a little over 220MB.

Say what? There should be nothing in there causing that sort of bloat. Let’s take a closer look:

Shell (source):find . -type f -exec du -a {} + | sort -n -r
24064	./Heartbeat/node
19480	./node.js/node_modules/indie-pulse-process/pulse/pulse-linux-amd64-1.0.0+14-gd2cd1f4/pulse
19480	./node.js/node_modules/indie-pulse-config/node_modules/indie-pulse-process/pulse/pulse-linux-amd64-1.0.0+14-gd2cd1f4/pulse
19320	./node.js/node_modules/indie-pulse-process/pulse/pulse-macosx-amd64-1.0.0+14-gd2cd1f4/pulse
19320	./node.js/node_modules/indie-pulse-config/node_modules/indie-pulse-process/pulse/pulse-macosx-amd64-1.0.0+14-gd2cd1f4/pulse
…

The output above is the top view rows from the current source folder (sans .git files), not the Resources folder of the binary, but you get the idea. The actual output from yesterday showed me that the Pulse Process module had six checked-in versions of Pulse, and was being included twice as it was being used by the Pulse Config module. Those two alone were adding ~120MB to the binary size.

Eeek!

So the problem — aside from my willingness to liberally check in different versions of Pulse — was Node.js. Or, more precisely, how Node handles dependencies.

Wait a minute… did you say Node? In a native Mac app?

Yep, Node.

I’ve separated the presentation and business logic in Heartbeat so that the former is encapsulated in a native Cocoa client and the latter is handled in Node. This keeps the content layer as hackable and portable as possible and still allows me the freedom to try and craft a beautiful, accessible experience that respects the native culture of the Mac platform.

It means that if other developers want to port Heartbeat to other platforms in the future, their jobs will be easier. But not at the expense of the experience of every person using it.

So even though the bulk of the binary size optimisation involved deleting some unused files, it was immediately apparent that there was still more I could do.

Specifically:

So it became clear that I really needed to be able to do all this during build time.

Xcode Run Script Build Phase to the rescue!

Thankfully, Xcode has a lovely Run Script Build Phase that I learned about thanks to Uli’s post from back in 2005.

To use it:

Xcode interface showing the Build Phases section of the current target.
  1. Select your Project in the Project Navigator (⌘1).
  2. Select your Target Build Phases.
  3. Press the + button for Add a new Build Phase (see figure).
  4. Select New Run Script Phase.

You can use any shell that’s installed on your system and you have access to some very useful environment variables.

The Run Script build phase showing the simple env script.

To see a full list of them, create a script that just has the following in it:

Run Script (source):env

Build (⌘B) and then look in the Report Navigator (⌘8)

Next to Run custom shell script ‘Run Script’, you should see a list of environment variables. But wait, that’s not the best way to view them. For one thing, they’re all jumbled up and, for another, they’re limited to the first 200. To see them in a manner befitting a dev of your stature, click on the Run custom shell script ‘Run Script’ line to select it and you should see a little expansion icon appear on the right-most edge.

The expand button.

Click that and you will see all the variables you can use in your script in alphabetical order. Neat, huh? (Source)

Script output showing the environment variables you can use in your scripts.

So, for example, if you want to access the Resources folder in your app’s bundle, in your script you can write:

Run Script: cd ${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources/

Weight loss through shell scripts

So, to cut a long story short, by writing a small shell script that removes the most cursory repetition of node modules in Heartbeat, I was able to bring down the size of the binary from over 220MB yesterday to under 70MB today. And that’s without going through the module structure to find the repeated modules and replacing them with symlinks. (I did do it for an especially large one as a proof-of-concept.)

I’m sure you’ll find a hundred and one uses for running shell scripts during the build process and I hope that this little post will help you get started if you’re not already using them.