Monkeypatching Compatibility to Older iOS Versions

The Inevitability of Progress

Apple wants all of its users to be on the latest iOS version, and it wants all third-party developers to be quick to adopt new APIs, quick to remove deprecated APIs, and quick to drop support for older iOS versions.

While, historically, iOS adoption rates have been rather quick, lately this has not been the case.

For the independent developers who rely on iOS app sales, this isn’t much of a problem. People who buy apps have spare money. People with spare money are on the latest iPhones. The latest iPhones typically have the latest OS. Small, independent software developers (the kind that Apple caters to – I may complain about this in another article) do not have to worry so much about old OS compatibility.

However, enterprise software does not always have the luxury of dropping support for older operating systems and devices.

 

The Snag

Mobile enterprise software typically comes free with an existing suite of software. Enterprise software may have peripherals that only work on older devices. Enterprise software may target a group of users who do not typically update frequently.  And, to add to all of this, the company may still be ramping up mobile development and only have a few experts, because mobile is not the core business.

For example, let’s say you make inventory software and sell it to various organizations. Your inventory software uses barcoding. The barcoding landscape for Apple mobile devices is complex. Camera barcode recognition is future-safest, but slow. If you’ve walked into an Apple store, you may have noticed they have ‘sleds’ that provide barcoding capabilities to an i-device. The sleds are form-fitting. When the next iPhone or iPod Touch comes out, the sleds must be replaced. Not much of a problem for Apple – but a huge pain for you and your customers.

If you sell an integrated healthcare record database to a large healthcare provider – the doctors will typically have the latest and greatest, but many patients might still be on iOS 6 or earlier due to having an older device. Because your customer is the organization, you are accountable to them, which makes you twice as accountable to the actual users.

 

Potential Solutions

1. Multiple App Versions

Apple’s AppStore supports only one version of your application* per application name.  You could place multiple versions of your app on the store with different names supporting backwards compatibility to different versions.  Except forking code cascades into tons of extra work for your developers and testers, and strange issues for end-users, and when they report them will they report the right version?

*The AppStore does support multiple versions insofar as you have the option of offering an older version that supported the older iOS version a user might have.  You can’t fix any bugs with these older versions or update them at all.

2. Screw It!

Drop iOS versions as soon as possible.   As noted above, this isn’t a route that many companies in this position can take.

3. Code For The Oldest

The old code doesn’t stop working on new iOS versions, so you can continue to use UIAlertView in iOS 8 or fixed font sizes in iOS 7.

A problem here is that the moment you update your target, you have a million deprecation warnings cluttering your build and causing serious compiler warnings to be ignored.  A rough check shows that I am curating over 200 view controller subclasses in one application.  We do not have enough developers and testers to prioritize a wide-scale fix.  The testers will ask why we’re trying to break everything, the managers will ask why we’re wasting everyone’s time on non-issues, and the developers aren’t all iOS experts who see this as important or can do this easily, either.

Another problem is that it prevents developers from learning the new APIs sooner, and not everyone will simultaneously, magically get the memo to use the new method when they can.

4. Code For Both

a. Riddle your code with branches…

Most answers on Stack Overflow suggest the following. It’s more explicit, but you end up with duplicate code paths and sometimes ugly logic. If you have 1,000 places, it’s simply not feasible.

if([UIAlertController class]) {
    // use UIAlertController
} else {
    // use UIAlertView
}

b. Riddle your code with nonstandard wrappers…

You can write your own infrastructure that calls the appropriate API on each platform. In this case, you run the risk of other developers not using the wrappers, or not understanding the code.

MySpecialAlertController *alert = ...

 

The Technical Issue

For those who aren’t familiar with iOS development, Apple adds new APIs with new SDKs.  When you update your project to the new SDK, you can use the new APIs and everything compiles fine.  However, when you attempt to run on an older iOS version, at best the code might not work and at worst it will fail.

New SDK on Old iOS Rules

  • Enums – enum values can be used, but may not be supported by the APIs they’re being inserted into
  • Methods – when called, an exception will be raised
  • Classes – references to classes will return nil, and consequentially allocations will be nil
  • Global variables – references to strings will cause a runtime error due to dereferencing a null variable

Again, everything compiles fine, but when it runs, you’re screwed.

 

Compatibility Layers – a better way?

Could we monkeypatch a compatibility layer so that we can write against the latest API while still using the old API on the old platform?  Let’s explore the types of things that get added to the SDK.

Enums and other header-defined things

These are fine, by definition.  They exist solely in a header file and do not get compiled into anything different.

Inline functions, enums, defines, etc, are all good to go.

Methods

As noted, if you call a method that does not exist on an older iOS version, it will throw an exception.  A simple, elegant solution to this is to swizzle.  The Objective-C runtime is runtime-modifiable – you can add methods if they do not exist.  For example:

@implementation UITableViewCell (SDK8Compatibility)
+ (void)load {
    if(![self instancesRespondToSelector:@selector(setSeparatorInset:)]) {
        Method impMethod = class_getInstanceMethod(cls, @selector(sdk7_setSeparatorInset:));
        IMP imp = method_getImplementation(impMethod);
        const char *impType = method_getTypeEncoding(impMethod);
        return class_addMethod(cls, @selector(setSeparatorInset:), imp, impType);
    }
}

- (void)sdk7_setSeparatorInset:(UIEdgeInsets)insets {
    if(!UIEdgeInsetsEqualToEdgeInsets(insets, UIEdgeInsetsZero)) {
        NSLog(@"warning: iOS6 cannot have separator insets");
    }
}
@end

Pretty cool! Sometimes it’s not that easy, but it’s generally do-able.

Class methods require a different set of code, but keep in mind that Classes are Objects, and anything you do to an object you can do to a class.

Global Variables

Now, you may have added +[UIFont preferredFontForTextStyle:], but you also need UIFontTextStyleHeadline.  This is defined in a header as:

UIKIT_EXTERN NSString *const UIFontTextStyleHeadline NS_AVAILABLE_IOS(7_0);

I’ll show the hard fix later, but the easy, safe fix is to abuse the pre-processor:

#define UIFontTextStyleHeadline (&UIFontTextStyleHeadline != NULL ? UIFontTextStyleHeadline : @"UICTFontTextStyleHeadline")

It’s not the prettiest… but it accomplishes its goal. Note that the address of the variable is null in the case of iOS 6.

Classes

This is where things get complicated.  You can add an arbitrary class to the Objective-C runtime at runtime, but it won’t be compiler-visible.  The reason for this is that the following code is not equivalent.

Class clsA = [UIAlertController class]; // compiler reference
Class clsB = NSClassFromString(@"UIAlertController"); // from ObjC runtime

Let’s handle the simple case first.

Adding a Class at Runtime

The first step is to create the class you want to pose as the class you want.  Because Objective-C supports duck typing, code shouldn’t care as long as it works properly.  If it looks like a duck and quacks like a duck, it’s a duck.

The second step is to add it to the runtime:

if(NSClassFromString("UIAlertController") == Nil) {
    objc_allocateClassPair([KZUIAlertController class], "UIAlertController", 0);
    //NSClassFromString now returns the class we registered
}

Cool!  Now let’s get back to the hard part.

Compile-time references

Question: What does the compiler actually do when you have the following?

Class clsA = [UIAlertController class];

Answer: it is passing a message – “class” – to an object.  UIAlertController, in this context, is not a type (as in a declaration) but a special variable.  It is a magic compiler variable that you can’t manipulate in code, and it contains a reference to a Class object.  Similarly, for global variables such as UIFontTextStyleHeadline, the variable itself holds the reference to the NSString*, but the reference to the variable exists elsewhere.  Like the variable, a smart developer might have tried to solve the class problem with the preprocessor, but he would have soon realized he could no longer declare UIAlertController variables of that type.

#define UIAlertController NSClassFromString(@"KZUIAlertController")
UIAlertController *variable; // compiler error on preprocessor expansion
variable = [UIAlertController new]; // no compiler error here

To solve this problem, we must pinpoint the difference between running on the older iOS and the latest. Your compiled app binary is identical between the two, so there’s something else going on.

 

Linking and Dynamic Loading

Obviously, these hidden variables exist.  You just can’t reference them directly in your compiled code.

At compile time, the static SDK8 library is added to your program – it’s basically a shell.  The actual functional pieces then exist on whatever device (or simulator) you are deploying to as dynamically-linked library.  The dynamic library is loaded, your variables are populated, and everything is happy.

In the case of an older iOS version, the slots are there, but they aren’t populated because the dynamic library that exists on the older device does not have those symbols.

Somewhere in your running application there is a table that says “UIAlertController” = null.

Mach-O, where art thou?

If you are interested in this type of thing, you might already know that compiled binaries have very particular formats with different bits to them.  The format for iOS and OSX applications is Mach-O (Mach being the kernel and O standing for object).

A Mach-O file reads like a series of commands, and each command describes something about the file.  The file is broken into segments, and each segment can have sections.

We find our UIAlertController class variable somewhere in the __DATA segment under the __objc_classrefs section.  The section is just an array of pointers to classes.  You can test this by using the following code to replace blank classes and see what happens in your own code!

int printClassPointers() {
    Dl_info dlinfo;

    if (dladdr(printClassPointers, &dlinfo) == 0 || dlinfo.dli_fbase == NULL) {
        return 0;
    }
    uint32_t magic = *((uint32_t*)dlinfo.dli_fbase);

    unsigned long size;
    void **classes;

    if(magic == MH_MAGIC_64) {
        const struct mach_header_64 * header = dlinfo.dli_fbase;
        classes = (void**)getsectiondata(header, SEG_DATA, "__objc_classrefs", &size);
        //size = sect->size;
    } else if(magic == MH_MAGIC) {
        const struct mach_header * header = dlinfo.dli_fbase;
        classes = (void**)getsectiondata(header, SEG_DATA, "__objc_classrefs", &size);
    } else {
        return 0;
    }


    long count = size / sizeof(Class);
    for(int i = 0; i < count; i++) {
        Class cls = (__bridge Class)classes[i];
        NSLog(@"%@ - %@ - %p", @(i), [cls description], &classes[i]);
        if(!cls) {
            // this is our problem! set classes[i] = whatever you want here to play around
        }
    }
    return 0;
}

We have the variable, but we don’t have its name!  If two classes were nil, we wouldn’t be able to tell their entries apart.

 

It’s all in the loader

The Mach-O format, aside from having segment commands, has a dynamic load command.  The dynamic load command itself points to a particular segment that contains loading bytecode.  The bytecode, through much intense staring, eventually gives the location of the class variable.  If you’re interested in more Macho-O details, check out MachOView.

It turns out that the dynamic load area containing “binding info” is where we find our mapping of symbol names to hidden variables.  We may then arbitrarily populate these values such that [UIAlertController class] actually does work on an iOS 7 device.

This is a bit involved, so please refer to my code linked at the end.

Make a new library instead?

Since the problem is in the loader, can we use the standard route to add symbols? Can we create a new library to impersonate UIKit?
This was my colleague’s first instinct. Unfortunately, this causes linker errors with symbol conflicts.

 

Parting Words and Example Code

I recommend using swizzling and defines in production to future-proof code – it’s something I’ve done and has worked well.  I have a large team of developers who cannot focus all of their time and energy on keeping up with the i-joneses.

I am not quite comfortable recommending modifying the loader, at this point, as it seems like a much more fragile mechanism. However, it’s a fascinating concept, and I think that if some brave soul proves its reliability in production, it would be great for there to be a compatibility pack project for iOS.

My code may be found on GitHub here.

Thanks for reading.