Skip to main content
Back to Blog

Developing an iOS Framework in Unison with Swift & Objective-C

It has been a long time since the Swift Programming Language was introduced back in the WWDC of 2014. Since then, the adoption of Swift by third-party developers has certainly been massive. However, Objective-C is still around.

In this post, we will provide some tips and tricks to successfully develop and ship XCFrameworks combining both Swift and Objective-C in such a way that the interoperability of the languages does not compromise or affect the public APIs of the framework.

Swift vs. Objective-C

Indeed, one could argue that the development of Objective-C in the last few years has only been pushed forwards by Swift. But it is also a reality that Objective-C is still the most used programming language in the iOS system (see the image below taken from Apple’s use of Swift and SwiftUI in iOS 16), and it looks like it will take Swift some more years to surpass Objective-C in this regard.

When talking about third-party developers for Apple platforms, even though Swift is now the default go-to programming language, there are a couple of reasons why some code bases may still contain a fair amount of Objective-C code:

If we add Swift to the equation, it most likely means that the Swift and Objective-C codes must interoperate. This interoperability is relatively straightforward when working within an App target:

Swift and Objective-C interoperability in a framework target

Things get much more nuanced when we need to have Swift and Objective-C interoperate within a framework target. The reason is that for the code in one language to be available in another, the code needs to be public (with some exceptions). But what can we do if these interoperable interfaces must not public?

Before going into details, let’s step back and answer the following question: why should you care about this?
Designing the public API of a framework is no simple task. The API must be well-documented, clearly structured, and easy to use. Therefore, having these interoperability interfaces be public would only add confusion to the public APIs of our framework. Even more so, considering that code autocompletion will suggest the integrator use these public-but-not-meant-to-be-public APIs. We should avoid confusing the integrator of our framework with classes or methods that are not meant for their code to call. In addition, there could be one more reason. Could calling these interoperability APIs to affect the behavior of our framework? Sometimes, using these APIs could mean having the framework work unpredictably or wrongly. Although a DO NOT USE comment could probably help, it cannot prevent integrators from calling these APIs by mistake (or willingly).

Hopefully, at this point, you’re probably convinced that we should not give up the cleanness of our public API only to have Swift-Objective-C interoperability in the implementation of the framework. Let us see how we can achieve this.

Importing Objective-C code into Swift internally for a framework

In this section, we propose a workaround to clean the interoperable Objective-C internal APIs off the public API of our framework, such that Swift inside the framework can see these Objective-C APIs, but integrators can’t. Before diving deeper, we should clarify that this workaround can only apply to frameworks distributed in binary form (XCFramework). The below solution can’t be applied to frameworks in source code form because (spoiler alert) it relies on a cleanup script being executed right after the XCFramework is created.

According to Apple documentation, importing Objective-C code into Swift within a framework target is achieved by importing the Objective-C headers that are to be exposed to Swift in the framework’s umbrella header. The umbrella header is a file FrameworkName.h that is meant to contain the list of all the imports for all the public headers of the framework. Note that this umbrella header is public. In fact, it should be the only header file that integrators need to import to start using a framework:

import <FrameworkName/FrameworkName.h>

Swift will automatically see all the headers imported in the umbrella header, but so will integrators of the framework because all the headers included in the umbrella header must be public.

We will explain how we can use a script to remove all the Objective-C headers that are not to be public from the XCFramework. We will illustrate this with images corresponding to a hypothetical framework called MyFramework where we want to expose the Objective-C class InternalClass to Swift.

The first step is to make public all the Objective-C headers that need to be seen by Swift. We cannot avoid this as far as we know. Otherwise, we would not even be able to build the framework. To do this, we need to:

  • Add the headers to the umbrella header.
  • Make the headers public in the target membership (this can also be done in the framework’s “Headers” section of its “Build Phases”). Do not forget this step. Otherwise, you would get a compile-time error of the type Include of a non-modular header inside the framework module ‘MyFramework’.

Now you can call the InternalClass from Swift (but remember, so are integrators at this point).

class MySwiftClass {
    let objcClass = InternalClass()
}

The second step is to prepare the umbrella header to allow the future script to quickly determine the public headers to be removed from the resulting XCFramework. To make it simpler for the script, we can add a comment mark to the umbrella header. The script will look for this mark in the next step and remove all the header files imported after the mark.

We have used the mark __INTERNAL__, but any mark would work. Ensure that this text mark cannot appear anywhere else in the umbrella header. Otherwise, the script would not work as expected.

The third and final step is to write a script that will be executed once the XCFramework is generated and does the following:

  1. Reads the umbrella header and searches for the __INTERNAL__ mark.
  2. Removes all the header files imported in the umbrella header after the __INTERNAL__ mark.
  3. Remove the line containing the __INTERNAL__ mark and all the lines afterward.

We will call it removeInternalHeaders.sh, and from now on, we will assume that we already have the MyFramework.xcframework (here, you can check how to generate XCFrameworks). Our script will receive one parameter containing the path to the XCFramework to remove the public Objective-C headers that are meant to be internal.

#! /bin/sh -e
#
# removeInternalHeaders.sh
#

## 1
XCFRAMEWORK_DIR=$1
INTERNAL_MARK="__INTERNAL__"

## 2
function removeInternalHeadersInUmbrellaHeader {
  local framework_name="$(basename $1 .framework)"
  local headers_dir="$1/Headers"
  local umbrella_header_file="$headers_dir/$framework_name.h"
  local internal_mark_found=false
  local internal_headers=()
  ## 2.1
  while read -r line; do
    if $internal_mark_found; then
      if [[ $line == "#import"* ]]; then
        local filename=$(sed 's/.*\"\(.*\)\".*/\1/' <<< $line)
        internal_headers[${#internal_headers[@]}]=$filename
      fi
    elif [[ $line == *$INTERNAL_MARK* ]]; then
        internal_mark_found=true
    fi
  done < $umbrella_header_file

  ## 2.2
  echo "${#internal_headers[@]} files will be removed"
  for filename in ${internal_headers[@]}; do
    local file="$headers_dir/$filename"
    if [ -f "$file" ]; then
      rm $file
      echo "Removed file: $file"
    else
      echo "Tried to remove file but it does not exist: $file"
    fi    
  done

  ## 2.3
  sed -i "" '/'$INTERNAL_MARK'/,$d' $umbrella_header_file 
}

## 3
for directory in ${XCFRAMEWORK_DIR}/**/*.framework; do
  [ -d "$directory" ] || continue
  removeInternalHeadersInUmbrellaHeader $directory
done

Let us go into details. Note that we have added some tags of the type ## X to the script code. We will use these for the explanations:

  1. The script receives the path to the XCFramework ($1) and declares the mark that identifies the public-but-internal headers. This mark must match exactly the one added to the umbrella header (“__INTERNAL__” in our case).
  2. Because an XCFramework is a mere bundle of frameworks and libraries, we will need to repeat the cleanup process for each element inside the XCFramework. That is why we define the removeInternalHeadersInUmbrellaHeader function: to avoid repeating the same code for each element.
    1. This function reads the umbrella header, line by line until it finds the first line that contains the __INTERNAL__ mark. For all the subsequent lines of the umbrella header, it adds every file name imported file into the internal_headers array to keep track of the header file that will later be removed. Each file name is obtained by extracting the text between quotes in the line.
    2. Then the function removes the files collected in the internal_headers array.
    3. Finally, the function edits the umbrella header to remove the line containing the __INTERNAL__ mark and all the lines after because the header files specified in these lines have been deleted from the framework.
  3. In this case, the XCFramework bundles one or more frameworks. So the function removeInternalHeadersInUmbrellaHeader must be executed for all of them.

Note that the script only considers the file imports with double quotes. It can easily be modified to also include the imports with angle brackets: #import <Classes/InternalClass.h>

After executing the removeInternalHeaders.sh and passing it the path to the XCFramework, we can check that the InternalClass.h file has been removed and is not imported in the umbrella header anymore.

The folder structure of the framework after removing public headers that were to be internal:

The umbrella header after running the script to remove public headers after the __INTERNAL__ mark:

Integrators that use the XCFramework will not be able to access InternalClass.

Importing Swift code into Objective-C internally for a framework

In this section, we intend to use interoperability in the opposite direction. We want to use Swift symbols in Objective-C code within our framework.

Again, we refer to Apple documentation, highlighting that to use Swift code in Objective-C .m files, we need to import the Xcode-generated header for Swift code. In our example above, it would be the file MyFramework-Swift.h. However, this file is public, and, in fact, it only includes Swift declarations marked with the public or the open modifier. This means that if we have an internal Swift class compatible with Objective-C (either using the internal modifier or no modifier), this class will not be included in the Xcode-generated header. It certainly looks like we are facing a similar problem as before.

There is one possible workaround, though. In the Apple documentation page, we can read the following:

[…]. Methods and properties marked with the internal modifier and declared within a class that inherits from an Objective-C class are accessible to the Objective-C runtime. However, they’re inaccessible at compile time and don’t appear in the generated header for a framework target.

This gives us a clue. Since Objective-C can access these internal Swift symbols at runtime, maybe we need to help Objective-C access them at compile time. Can we achieve this somehow? Well, good news. Yes, this is possible. Let us see how.

The key is to have our internal header declare the internal Swift code symbols for Objective-C. This internal header would need to include the same interfaces that would otherwise be added in the public, Xcode-generated -Swift.h header.

Full disclosure: The solution we propose next has its issues and risks:

  • It is not as ideal or convenient as having the Objective-C bridging interfaces automatically generated by Xcode for us.
  • It is dangerous because there is some manual maintenance work: the Objective-C declarations in the internal header file need to be updated if we change the Swift symbols they bridge.

But it works and keeps our public APIs clean and limited to the desired public interface of the framework.

Let us illustrate how to do this with an example. Let us assume that we need to access the following InternalSwiftClass from Objective-C within our framework without making it public or open.

As pointed out above, since this class does not have either the public or open modifier, the file MyFramework-Swift.h does not include the Objective-C interfaces for this Swift class.

Small tip: You can access the contents of the Xcode-generated -Swift.h file with Xcode. To do that, import the file in a .m file (in our example, with #import <MyFramework/MyFramework-Swift.h>), do cmd + click on the import, and then click “Jump to Definition”.

Let us create our internal header that will contain the internal Swift interfaces. In our example, we will call this file MyFramework-Swift-Internal.h. Ensure that the file is included in the framework target with the Project access level and that it imports the actual Xcode-generated -Swift.h header.

We now need to declare the Objective-C interfaces of InternalSwiftClass in this file. This is a delicate step because of the way Swift interfaces are converted into Objective-C. We recommend using a very simple trick to let Xcode do the conversion for us, removing the risk of making any mistakes or misspellings. The trick consists of temporarily adding the public modifier to the declarations to let the interfaces be added to the Xcode-generated header file.

Build the project (cmd + b) and open the Xcode-generated header file. There you will see the Objective-C interface for our Swift class:

Copy the @interface declarations (including the SWIFT_CLASS(…) part) and paste them into the internal header file we created. Do not forget to remove the temporary public modifier for our Swift interface (in the example in the file InternalSwiftClass.swift).

And that’s all. You can now call the internal Swift interfaces from Objective-C from .m files by importing the internal header file we just created.

An important note: remember to update the internal header whenever you make any changes to the internal Swift interfaces to be used from Objective-C. Otherwise, your project will build but crash at run time!

To avoid this, you can have some unit tests written in Objective-C that use these bridged APIs from Swift. With these unit tests, you can catch these kinds of oversights before releasing any update to your framework.

Developing an iOS framework with multiple languages is by no means easy. At Fleksy, we have been working with both Objective-C and Swift together. If you want to discuss this article or need help with your project, don’t hesitate to contact us directly or on our Discord server. Our team will be happy to help you.

Did you like it? Spread the word: