ABOUT
TEAM
WORK
CONTACT
|
BLOG

GirAppe blog

2019/04/15 - go to articles list

Create a (mostly) Swift framework with optional features. Approaching modularization in a closed source setup.

In this article I will show how to modularize your framework features using Swift and Objective-C, while keeping them optional, allowing to compose flexible solution from any subset of them in a closed source setup.

Imagine that you are developing an SDK. Your framework wraps up some work and exposes a public API to its users. To be a decent SDK, it should:

These points are sometimes hard to balance, and a lot of times it would require some compromises, usually between usability and "feature richness."

The obvious solution is to modularize your SDK. It is a common and extremely flexible approach, allowing to split your framework into smaller, feature-based items. It usually looks something like:

Please note that features can also depend one on another

I thought this setup solves all the possible cases. Well, most of them, but certainly not all.

The problem - optional frameworks

After publishing my last article (https://medium.com/@amichnia_31596/creating-swift-framework-with-private-objective-c-members-the-good-the-bad-and-the-ugly-4d726386644b), I did not think I would go back to closed source frameworks topic again soon. Well, I was wrong :)

I got an interesting question from someone who read my previous post and seemed to work on a closed source SDK product as well. I will try to state the requirements here:

Note: vendors frameworks are a way to distribute closed source frameworks with cocoapods. That means that the code is somewhat "sealed," not revealing the actual implementation to the user. It is very common when your product performs custom processing, and allows to protect the company's IP.

Link to the original question on StackOverflow: https://stackoverflow.com/questions/55077857/if-canimport-does-not-find-frameworks-with-cocoapods/55560102#55560102

To make it more clear how should it look, let's put up an example. Let's imagine our framework, MainSDK, provides a NumberInspector class. An end user could ask it to inspect some number and expect to get notified with a list of its features (even, odd, positive, etc.)

// MainSDK

public class NumberInspector {
    public weak var delegate: NumberInspectorDelegate?
    ...
    public func inspect(number: Int) {
        // perform  inspection and notify delegate when done
    }
}

public protocol NumberInspectorDelegate: class {
    func didFinishInspecting(number: Int, foundFeatures: [String])
}

There is also a 3rd party framework; let's call it AdditionalSDK. It provides API to detect if a given number is prime. Then it notifies its delegate.

// AdditionalSDK

@objc public class PrimeNumberChecker: NSObject {
        @objc public var delegate: PrimeNumberCheckerDelegate?
        @objc public func inspect(number: Int) {
    ...
    }
}

@objc public protocol PrimeNumberCheckerDelegate {
    func didFinishInspecting(number: Int, isPrime: Bool)
}

We want to alter the behavior of NumberInspector in a way, that if MainSDK user is also using AdditionalSDK, we will perform additional detection, and add information about being prime to detected features. If the other framework was not added though, we expect that our number inspector would still do the job, just without this particular feature.

1. #if canImport() - a solution that did not work (in this setup)

The obvious solution for Swift seemed to be a conditional compilation. In most of "open source" setups (when the SDK sources are compiled together with target app sources) it could look like this:

// MainSDK
#if canImport(AdditionalSDK)
import AdditionalSDK

public class NumberInspector: PrimeNumberCheckerDelegate {
    ...
}
#else
public class NumberInspector: {
    ...
}
#endif

Whenever the user builds his app, SDK gets compiled and canImport(..) would be evaluated, resulting in compiling a slightly different code.

There are several significant problems with this setup:

  1. It will not work with a Carthage, nor when you ship SDK as already compiled binary
  2. It can behave in an unexpected way when additional SDK is still under any framework search path (even if you don't explicitly link it)

To sum it up - canImport was designed for a different purpose and should not be used in this context.

Note 1: this setup is very vulnerable to issues with derived data. You need to clean almost every time you add/remove a feature. Because canImport(...) evaluates based on module availability, it can trigger 'false positives' when additional SDK is still in the derived data.

Note 2: by "open source" setup I mean situation, when you either ship sources directly, or via cocoapods. And then the SDK sources are compiled together with the target app.

2. Weak linking + Objective-C = a working solution

The general idea is that in Objective-C you can do something like:

weak linked framework

Weak linking (Optional) means that you can build against the module, which would not be automatically linked. You will be able to build the target app, regardless of adding or not the optional framework. Its availability is resolved in the runtime, and its symbols would evaluate to nil if it is not there.

// Below would import or not, depending on whether it is linked
@import AdditionalSDK;

...

+ (BOOL)additionalModuleAvailable {
    if ([AdditonalSDK class]) { // Or any class from additional SDK
        return YES;
    } else {
        return NO;
    }
}

Note: Please note, that above would work ONLY in Objective C. I did not find a way to achieve this with pure Swift. The good old Objective-C runtime could be a lifesaver :)

3. Final solution

The final solution would very much look like following.

We would declare "Interop" classes, written in Objective-C, to take advantage of its runtime and wrap additional feature frameworks interoperability within.

// AdditionFrameworkInterop.h

#import <Foundation/Foundation.h>
#import <MainSDK/MainSDK-Swift.h>

@interface AdditionFrameworkInterop : NSObject

@property (weak, nonatomic) id<InteropDelegate> delegate;

+ (BOOL)additionalModuleAvailable;
- (instancetype)init;
- (void)inspectWithNumber:(NSInteger)number;

@end
// AdditionFrameworkInterop.m
#import "AdditionFrameworkInterop.h"
@import AdditionalSDK;

// You can adopt protocols from additional framework even if its weak linked
@interface AdditionFrameworkInterop() <PrimeNumberCheckerDelegate>
@property (strong, nonatomic) PrimeNumberChecker *primeChecker;
@end

@implementation AdditionFrameworkInterop

+ (BOOL)additionalModuleAvailable {
    ...
}

- (instancetype)init {
    self = [super init];
    if (self && [AdditionFrameworkInterop additionalModuleAvailable]) {
        _primeChecker = [[PrimeNumberChecker alloc] init];
        [_primeChecker setDelegate:self];
    }
    return self;
}

- (void)inspectWithNumber:(NSInteger)number {
    if ([AdditionFrameworkInterop additionalModuleAvailable]) {
        [_primeChecker inspectWithNumber:number];
    }
}

- (void)didFinishInspectingWithNumber:(NSInteger)number isPrime:(BOOL)isPrime {
    [_delegate didFinishInspectingWithNumber:number isPrime:isPrime];
}

@end

Usage in MainSDK is straightforward. You use interop class as if you would use AdditionalSDK directly. Of course first check its availability, with interop.additionalSDKAvailable() call.

Summary

The whole idea is to proxy AdditionalSDK features via Objective-C class. Then we can treat it as a truly weak linked framework, and inspect its availability in runtime. Also, it allows you to declare conformance to "optional" protocols (by optional I mean they might be there or not).

It is possible to extend and change the behavior of your closed source SDK depending on whether additional frameworks were added. That gives flexibility, both to you and your SDK users, to shape the specific feature set needed.

The full project is available at my GitHub: https://github.com/amichnia/Swift-framework-with-optional-frameworks

Some final thoughts:

Thank you for reading

Andrzej Michnia
Software Developer