Our latest project involved working on an application for both iOS and tvOS platforms. The iOS component had already been started but the tvOS component that we were tasked with was to be created from scratch. Our goal was to share as much code as possible between the platforms, maintaining the same architecture but differing slightly in terms of their layout files and custom behaviours. In this post, I’m going to explore how we used Carthage to configure our iOS and tvOS projects and why.
Carthage is a dependency manager written in Swift.
It doesn’t automatically modify your project files or build settings. It instead builds your dependencies and provides you with binary frameworks independently of your project, which you then add before including a script to copy them in properly.
You also have to add the paths to the frameworks you want to use to your input.xcfilelist as well as the paths to the copied frameworks to the output.xcfilelist.
When using Carthage, once you’ve built your dependencies you’re good until you need to update one of them. If you think you won’t have to modify your dependencies all that often during development, Carthage is recommended in terms of its performance.
We also used embedded frameworks alongside Carthage. The shared approach aimed to modularize the app to create single testable and reusable modules between iOS and tvOS using only one target.
Why use embedded frameworks?
XCode supports the concept of embedding frameworks into the app bundle. They’re frameworks that are only available inside that app.
This can help to reduce the size of bundles by removing all the information that isn’t necessary to work at runtime (e.g. the headers and the module definitions).
What do we mean when we talk about frameworks?
‘A framework is a hierarchical directory that encapsulates shared resources, such as a dynamic shared library, nib files, image files, localized strings, header files, and reference documentation in a single package. Multiple applications can use all of these resources simultaneously. The system loads them into memory as needed and shares the one copy of the resource among all applications whenever possible’ – Apple’s official documentation.
A framework defines all the classes with methods and functions that you want to access in your code. Apple gives developers a lot of frameworks to play with, from MapKit to CloudKit, ARKit to SpriteKit, and we can use them to implement different features in our iOS apps.
When you write an iOS application, you typically import the default Foundation or UIKit framework. If you want to work with floats, dates or strings, you might import Foundation. If you want to use UI components, like UITableViewController or UICollectionViewController, you might import UIKit.
When working with MVVM architecture, your ViewModels should only import Foundation because you don’t want to include any view references. As a specification, the UIKit import also contains the Foundation so you don’t need both imports.
Why use frameworks?
Frameworks offer a modular and reusable set of code that can be built once but be reused an infinite number of times.
By using frameworks as different modules (as many you want or need for the different layers of your app) we can create apps that are both easy to test and maintain. Every framework can have its own UnitTests and UITests target and it’s, of course, important to validate them before using them.
We can create our framework for modules that represent the technical parts of our app. Here are some examples:
- The Network module which handles the networking layer. This framework will have your ApiClient class that you use in your code to manage network queries.
- The YourAppNameUI module which represents the UI elements.
- The Storage module which handles all of your database requests to store and retrieve data.
- The InAppPurchaseKit module which handles all of the stuff related to managing your plan’s prices.
You can also add modules for features but the idea behind this approach is that, as we said before, modularizing your app gives you a cleaner codebase that is easier to understand and modify. How? It enforces separation of concerns between features because by default everything has internal access level and you have to explicitly add modules as dependencies.
Every module can be written using different architectures. For example, you might follow MVVM in one module but VIPER in another (but remember to be consistent). This gives you the chance to try out new architectural approaches. Modules can even be written in different languages.
If your project is written in Objective-C codebase, you might consider migrating individual modules to Swift. You can migrate modules to the next version of Swift on an individual basis. You don’t have to migrate the entire codebase all at once.
How do we use a single target when we create a new framework to support different platforms?
With many projects and even more modules, managing build settings can become challenging. An easy way to make your life easier is to extract the settings configuration to .xcconfig files and add them in a /Config directory at the root of your workspace.
These files contain all the default settings that are the same no matter what target or configuration is set. For example, you can maintain the deployment targets as the Swift version is identical across all targets and projects. You can start with the two basic configurations, Debug and Release, each one with its own config file – Debug.xcconfig and Release.xcconfig respectively.
They all inherit the settings from the Config.xcconfig file and add their own configuration-specific settings. For example, the Debug config removes any compile-time optimisations and the Release config turn these optimisations on.
You can then add your FrameworkConfig.xcconfig file to add all the build settings for your frameworks and, as mentioned above, you can create your two variations for Debug and Release.
Lastly, you need to create the UnitTest.xcconfig file that provides all the default settings for your unit test targets. This only has one version because unit tests should always be run using the Debug configuration.
Let’s walk through the steps…
- Create a new project and add it to the workspace as you would normally. Once that’s done, add a Config folder into your project with all your xcconfig files then delete all of the default build settings from the project.
Select the Project Build Settings tab and make sure Levels is selected:
2. Select all (cmd+A) and delete all of the project settings (fn+⌫ or ⌦):
3. Add the Config group folder with all of the xcconfig files then go to the Project Info tab and add the correct xcconfig file for each configuration at the project level:
You can follow these instructions for any frameworks you may want to add to your project.
How to make an iOS and tvOS target
To create a new universal target make sure the following build settings are present:
SUPPORTED_PLATFORMS = iphonesimulator, iphoneos, appletvsimulator and appletvos.
TARGETED_DEVICE_FAMILY = 1, 2, 3 (iPhone, iPad, TV).
How to use an iOS and tvOS target
To build or run, select the device/simulator you wish to use and XCode will build against the correct SDK.
To depend on a universal target, add it to your linked libraries as usual and Xcode will use $BUILT_PRODUCTS_DIR to link the correct item.
Creating files specifically for iOS or tvOS
If you need a specific file for iOS or tvOS, create subfolders and place files inside: e.g. MyFile.nib -> iOS/MyFile.nib or tvOS/MyFile.nib.
Note: These subfolders are case sensitive and they must be a physical directory on the file system and not just a group.
Check out Apple’s file configuration settings documentation for more information.
Managing iOS and tvOS frameworks with Carthage
After installing and configuring Carthage (see the Github documentation for the required steps) you will have different folders – one for every platform. This is useful because you can decide to update only the frameworks for the platform you need.
If you want to link your embedded frameworks with the Carthage ones, you can add your User-Defined settings and add your frameworks directory to point to the correct build folder. In the following image, the Release config was renamed ‘AppStore’ and we added an Enterprise config too as an example:
Carthage makes the process easier and working with different modules that are compatible with both platforms is an excellent idea because you always have the chance to create specific files to maintain the platform’s native behaviour.
During this project, we found that it was extremely important to stay synchronized. It’s also important to ensure that the iOS codebase is stable before developing for tvOS, in order to reduce the possibility of conflicts.
In terms of sharing advice, we would recommend that you create wrappers around the frameworks so that you have the chance to exchange them with new ones without too much effort. The pattern we used followed solid principles, which is hugely important if you’re looking to maintain a clean codebase.
Have you used Carthage before? What’s your opinion? Tweet us and we’ll be sure to retweet the responses!
We Are Mobile First is a digital product agency based in Barcelona helping to transform businesses in a mobile-first world. Follow us on Twitter, LinkedIn and Medium to be notified of our future posts and stay up-to-date with our company news. We share weekly content on everything from Apple Combine to Apple Books.