Adopting Swift: Migrating to Frameworks on iOS

By: Felipe Cypriano

Thumbtack Engineering
Thumbtack Engineering

--

Thumbtack, like any marketplace, has two sides: consumers looking for services and pros offering services. As a result, we have two iOS apps: one for consumers and one for pros. To manage dependencies in both apps we use CocoaPods, not only for 3rd party libraries but also for our internal library called TTKit.

There are two main reasons to migrate to frameworks: first and foremost, supporting Swift code in our dependencies; second, better code organization. With frameworks, each library is contained in its own module as opposed to having every static library compiled and linked into one binary. For example, with frameworks, it is possible to have a test target dependency that depends on a app target dependency without having duplicate symbols errors .

Swift is not allowed inside a static library, but it works fine in the app target even if it uses static libraries. Given this, you might wonder: why not introduce Swift code in the app target before moving all the dependencies to frameworks? Because it is very common for us to extract functionality from our two apps into our shared TTKit. If something were written in Swift and TTKit was still a static library, we would have two unacceptable options to share that functionality: reimplement it in Objective-C or duplicate the Swift code.

In the following sections I’ll detail my quest to enable Swift, starting with how it should have been a simple change, passing by the build problems, dealing with the runtime differences, and finally getting to the happy ending.

Enabling use_frameworks!

Since CocoaPods takes care of setting up all the dependencies, switching from static libraries to frameworks is easy: just add use_framework! to the Podfile, and run pod install. If you're lucky this will just work and you can go directly to the next section. But if you're like me you'll get an error like this one:

[!] target has transitive dependencies that include static binaries: (src/Thumbtack/ios/Pods/Crashlytics/iOS/Crashlytics.framework and src/Thumbtack/ios/Pods/GoogleMaps/Frameworks/GoogleMaps.framework)

CocoaPods can take care of the conversion from static library to framework if the pod is distributed with its source code; but two of our dependencies are distributed as compiled binaries: Crashlytics and Google Maps. They can’t be automatically converted. In a perfect world, the authors would update their pods to be frameworks, but unfortunately, it appears that it isn’t a issue to either of them. Since we didn’t want to stop using them, our last resort was to find a way to continue using these binary dependencies even though they couldn’t be frameworks like all the others.

If you paid attention to the error message above, it says that static binaries cannot be transitive dependencies; it doesn’t say they can’t be used at all. If we removed them as transitive dependencies from TTKit, we would have been fine.

The idea is to add a layer of indirection in between the shared code and the binary dependencies — a layer we can control and use from within frameworks. Only the app target would directly depend on any binary dependencies and provide adapters that the shared code can use to access them. The image below shows how we added this layer with our static binary dependencies: Crashlytics, and Google Maps.

The idea to create these adapters came from a pod called CrashlyticsRecorder . But because it is written is Swift it created a chicken and egg situation: we couldn’t use pods written in Swift without migrating to frameworks and we couldn’t start using frameworks without removing the transitive dependencies. Writing the adapters in Objective-C allowed us to gradually move forward, so that is what we did:

#import <Foundation/Foundation.h>/* How To Enable CrashlyticsAdapter
*
* On the App Target
*
* Initialize Crashlytics as usual and pass the real Crashlytics instance to the adapter:
*
* [Fabric with:@[[Crashlytics class]]];
* [CrashlyticsAdapter with:[Crashlytics sharedInstance]];
*
* On Libraries That Need Crashlytics
*
* Just use `[CrashlyticsAdapter sharedInstance]` instead of `CrashlyticsKit` or `[Crashlytics sharedInstance]`.
*
*/
@protocol CrashlyticsAPI- (void)setUserIdentifier:(NSString *)identifier;- (void)setObjectValue:(NSObject *)value forKey:(NSString *)key;- (void)recordError:(NSError *)error;
- (void)recordError:(NSError *)error withAdditionalUserInfo:(NSDictionary<NSString *, NSObject *> *)userInfo;
@end@interface CrashlyticsAdapter : NSObject <CrashlyticsAPI>+ (instancetype)sharedInstance;+ (void)with:(id<CrashlyticsAPI>)crashlytics;- (instancetype)init NS_UNAVAILABLE;@end#import "CrashlyticsAdapter.h"@interface CrashlyticsAdapter ()@property (nonatomic, strong) id<CrashlyticsAPI> crashlytics;@end@implementation CrashlyticsAdapterstatic CrashlyticsAdapter *sharedInstance;+ (instancetype)sharedInstance {
return sharedInstance;
}
+ (void)with:(id<CrashlyticsAPI>)crashlytics {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[CrashlyticsAdapter alloc] initWithCrashlytics:crashlytics];
});
}
- (instancetype)initWithCrashlytics:(id<CrashlyticsAPI>)crashlytics {
self = [super init];
if (self) {
self.crashlytics = crashlytics;
}
return self;
}

#pragma mark - Crashlytics API Delegation
- (void)setUserIdentifier:(NSString *)identifier {
[self.crashlytics setUserIdentifier:identifier];
}
- (void)setObjectValue:(NSObject *)value forKey:(NSString *)key {
[self.crashlytics setObjectValue:value forKey:key];
}
- (void)recordError:(NSError *)error {
[self.crashlytics recordError:error];
}
- (void)recordError:(NSError *)error withAdditionalUserInfo:(NSDictionary<NSString *, NSObject *> *)userInfo {
[self.crashlytics recordError:error withAdditionalUserInfo:userInfo];
}
@end

We made a similar adapter for Google Maps and integrated both adapters into TTKit, bringing us one step closer to converting our static libraries into frameworks. We tested everything to make sure it still worked, and then we were ready for the next steps: enable frameworks again, and run pod install. With both binary dependencies removed from the shared TTKit code, the installation worked.

Build Time Problem: Imports

Finally the time came to build the app project that is now using frameworks. Of course, Xcode greeted us with an error on an import line that hadn’t changed in forever:

#import <AFNetworkActivityLogger+TTLogger/AFNetworkActivityLogger.h> // <- could not find file

The problem was that, with frameworks, the module name needed to be a valid c99ext identifier. Static libraries don’t have this naming constraint; it is common to have . To fix the imports, we needed to replace in their names + with _:

#import <AFNetworkActivityLogger_TTLogger/AFNetworkActivityLogger.h>

To avoid this, a good rule of thumb is to only create pods with names that are valid c99ext identifiers. Now that the project built, it was time to run it.

Runtime Differences: Where Are My Images?

Everything seemed to be working fine except for a few missing images. Which images? The ones that came from our shared library of course. But nothing had changed: the image was still there and its name was the same.

In TTKit we have a bundle that contains images, interface builder files, and our Core Data model. We use CocoaPods to generate this bundle, and this is what it looks like in TTKit.podspec:

s.resource_bundles = {
'TTKit' => ['Assets/**/*.{png,xcassets,xcdatamodeld,xib,storyboard}']
}

And this is how we access these resources from the app:

NSString *path = [[NSBundle mainBundle] pathForResource:@"TTKit" ofType:@"bundle"];
NSBundle *ttkitBundle = [NSBundle bundleWithPath:path];
self.logoImageView.image = [UIImage imageNamed:@"Logo" inBundle:ttkitBundle compatibleWithTraitCollection:nil];

The first problem with that approach was [NSBundle mainBundle] because TTKit.bundle was not in the main bundle anymore. It was now in the TTKit framework's own bundle. The simplest way to get the TTKit framework's bundle was to use bundleForClass: passing a class that is known to be from TTKit. So to make it even easier we made a category on NSBundle that returns the bundle we want:

@implementation NSBundle (TTKitBundle)+ (NSBundle *)ttkitBundle {
NSBundle *frameworkBundle = [NSBundle bundleForClass:[TTSomeClass class]];
NSString *path = [frameworkBundle pathForResource:@"TTKit" ofType:@"bundle"];
return [NSBundle bundleWithPath:path];
}
@end

This solved the first part of the problem: looking for the bundle in the wrong place. But, even after using the correct bundle, the following snippet still returned a nil image.

NSBundle *ttkitBundle = [NSBundle ttkitBundle];
self.logoImageView.image = [UIImage imageNamed:@"Logo" inBundle:ttkitBundle compatibleWithTraitCollection:nil];

After a lot of digging, I found explaining that it is not possible to load images from Assets Catalog (when compiled they become this post in Apple’s dev forums .car files) that are not in the main bundle. Having the images in an Assets Catalog used to work because with static libraries everything gets shipped inside the app's main bundle. The solution to this was to stop using xcassets in the TTKit.bundle.

We Are Ready for Swift

All those steps were done in small chunks. I carefully tested every change to see what they had broken, if anything. Every time I lost track of what the changes were that had caused an issue, I would reset the code back to the last good point and start over. This is not new advice but here it goes anyway: don’t try to make all the changes at once. Baby steps will help you keep your sanity during the process.

An important tip if you are using CocoaPods: when going back and forth with use_frameworks! sometimes you'll see some errors that don't make sense. For example, something related to a static library even if you are using frameworks. When this happens try this:

pod deintegrate Project.xcodeproj
pod install

It will regenerate everything and remove old references to static libraries.

I’m excited for the future and all of the new possibilities that using Swift brings. I’ve always liked Swift and I have followed its evolution during the past 2 years. We can now finally start using it as our primary language on iOS. If you’re interested in joining Thumbtack Engineering, check out more here.

Originally published at https://engineering.thumbtack.com on August 2, 2016.

--

--

Thumbtack Engineering
Thumbtack Engineering

We're the builders behind Thumbtack - a technology company helping millions of people confidently care for their homes.