iOS-factor: Best Practices for Building High-Quality iOS Apps
Each topic is covered by a factor, describing the ideal state of iOS app development
That feeling, when you pull the old project from Git and it does not compile…
Even though this project is in the App Store, “production-ready”, the previous developer did not maintain it properly. There are many possible reasons — deprecated Swift version, external dependencies, missing certificates…
iOS-factor was inspired by the famous Twelve-Factor App framework,
a methodology to write high-quality web services. iOS-factor uses the same structure and similar principles, re-written and applied to the iOS app development processes.
It aims to provide a collection of best practices for building high-end iOS applications.
Each topic is covered by a factor, which describes the ideal state of what a certain category of the iOS app development process could look like.
- Dependencies: Explicitly declare and isolate dependencies.
- Config: No configuration in code, ships with default config and allows OTA updates.
- Dev/prod parity: Keep development, testing, and production as similar as possible.
- Deployment: Automate your deployment so you can release it from any machine.
- Prefer local over remote: Keep the iOS app smart enough to operate without a back end where possible.
- Backward-compatible APIs.
- App versioning: Automate your app’s build and version numbers for consistency.
- Rollbacks: Reverting a build that causes issues.
- Persistence of data: Follow the Apple guidelines when storing data.
Ideally, your build tools never rely on the implicit existence of system-wide packages.
The benefit of explicit dependency declaration is that it simplifies setup for developers new to the app, as well as having a reliable build system that is also able to run past-builds again in a reproducible fashion.
As iOS development cannot be containerized, as is already the case for web development, we’re limited to third-party tools for fulfilling this requirement, until Apple provides an official solution.
Apps sometimes store configs as constants in the code. This is a violation of iOS-factor, which requires strict separation of config from code. Config varies substantially across deploys, code does not.
There are many ways to inject config values during build time:
- Configuration files (e.g. JSON or YAML files).
- CocoaPods-keys to properly hide keys and apply them to your iOS app during build time.
- Custom-built solution (e.g. using a build phase).
As deployments on the iOS platform are significantly slower than server deployments, you might want a way to quickly update config over the air (OTA) to react to issues quickly.
OTA config updates are powerful and allow you to instantly:
- Run A/B tests to enable certain features or UI changes for only a subset of your active users.
- Rotate API keys.
- Update web hosts or other URLs that have changed.
- Remotely disable features or hide buttons.
Potential approaches to implementing OTA updates of config:
3. Dev/Prod Parity
Historically, there have been substantial gaps between development (a developer making live edits to the app on their local machine) and production (an app deployed on the App Store, accessed by end-users).
- The time gap: A developer may work on code that takes days, weeks, or even months to go into production.
- The personnel gap: All iOS developers write code, only one person knows how to deploy the app.
- The tools gap: Developers may be using a staging server that runs a different version than the production one. Developers might be using a different Xcode release than the one that was used for deployment.
An iOS-factor app is designed for continuous deployment by keeping the gap between development and production small.
Looking at the three gaps described above:
- Make the time gap small. A developer may write code and have it deployed just a few days later.
- Make the personnel gap small. Developers who wrote the code are closely involved in deploying it and watching its behavior in production. This is best made possible by completely automating the release process of your iOS app and putting the know-how in the code declarations instead of the documentation.
- Make the tools gap small. Keep development and production as similar as possible. Follow the principles of the dependencies factor of iOS-factor and make use of a
.xcode-versionfile, as well as defining all other dependencies explicitly.
As described in the dependencies factor, the code repository should include all dependencies needed to build, test, and release the iOS app.
Unfortunately, because Xcode has to run on macOS, we can not use Docker or containers.
Right now, the best approach we can take, as iOS developers, is:
- Automate the installation of Xcode using Xcode::Install.
- Make use of an .xcode-version file to specify the exact Xcode release.
- Define all dependencies in configuration files (see the dependencies factor).
- Automate the complete deployment process using a deployment tool like fastlane.
- Automate code signing (e.g. codesigning.guide).
- Deploy often, ideally on a weekly schedule.
5. Prefer Local Over Remote
In recent years, some development teams have started to use approaches that require less development work, at the expense of the user’s app experience. They’re moving more logic to a remote back end and have the iOS app be a thin client showing the server results.
This approach results in user frustration when the app is used in a situation with a less-than-perfect internet connection.
An app should do as much of the business logic and calculations on-device as possible, for a variety of reasons:
- Privacy: Avoid sending data to a remote server.
- Speed: Sending data to a server and waiting for a response requires time and might fail (e.g. spotty WiFi).
- Data usage: Users often have monthly data limits.
- Scaling: If your app goes viral, you are responsible to scale the back-end services up.
- Battery life: Using mobile data is costly for battery life.
- Reliability: Some countries still have unreliable LTE/3G connections.
If your app requires an internet connection for everything (e.g. social networking app or ride-sharing app), your app should still work (in read-only mode) without an internet connection to access historic data (e.g. recent rides, recent direct messages).
6. Backwards-Compatible APIs
While the majority of your end-users will update to the most recent update within a few weeks, there will always be a subset of users who won’t.
The basic concept is that you don’t update an existing API but add a new one instead and let them run in parallel.
7. App Versioning
Version and build numbers work together to uniquely identify a particular App Store submission for an app:
- Version number (
CFBundleShortVersionString) — shown as
Versionin Xcode. It is also called the marketing version: the version that’s visible to the end-user. It has to be incremented on each public App Store release.
- Build number (
CFBundleVersion) — shown as
Buildin Xcode: an incrementing number.
In today’s iOS development process there is no reason to manually change those numbers.
There is no need to use third-party tools, Xcode has a tool called
agvtool built-in. (more details).
The App Store doesn’t natively allow rollbacks, so this section describes how to achieve similar results, using the technology available.
Using phased releases, you can slowly roll out a build to production, starting with a subset of the active users.
The main benefit of this approach is the built-in way to pause a rollout, before it reaches a high amount of users, allowing you to replace the binary.
However, even with phased releases, there is no way to completely revoke a build.
As with phased releases, App Store and TestFlight builds can only be updated by doing this:
- Go back in your version control system to a state you want to rollback to.
- Increment the version or build number of your project.
- Build and co-design your application.
- Distribute your app through a beta-testing service or App Store.
- User has to update the app on their phone.
The above steps can be done manually, however, it is recommended to fully automate the process so you can react quickly.
Alternative: Resign an old build:
- Access an old build (
.ipafile) from before the regression was introduced.
- Update the version/build number in the
- Resign the build.
- Distribute it as a new build.
However, resigning iOS apps often introduces more problems, especially because the Xcode command line tools offer no good way of doing so.
9. Persistence of Data
Storing data and configurations according to Apple’s guidelines is crucial for your app’s lifecycle, in particular when it comes to iCloud sync, upgrading to a new phone, and restoring a phone from a backup.
Make sure you follow Apple’s official iOS Data Storage Guidelines:
Documents: Use this directory for user-generated content, it will be backed up.
Caches: Use this directory for data that can be regenerated.
tmp: Use this directory for temporary files.
- Make use of the
do not back upattribute for files.
Never store sensitive user information (like passwords or sessions) in those directories. Instead, use the keychain API.
This is a living project, maintained by the iOS development community.
You can find the full source on GitHub, update existing pages, or add new factors.