Inder Deep Singh | June 6, 2024 | 15 min read
Unlocking Innovation: Zomato’s journey to seamless iOS code sharing & distribution with Swift Package Manager


History of Zomato’s iOS apps

Most of you are probably familiar with the Zomato consumer app – the one you use to order food, explore restaurants, dine out etc. But what you might not know is that we have a lot more apps which fall under Zomato’s universe of iOS apps such as Blinkit, HyperPure, Zomato Restaurant Partner, Zomato Dining Partner etc. Initially it was easy to replicate things, over time it became evident that creating every app from scratch was inefficient and hard to manage especially when fundamental layers of an app were already in place. This is why all these apps share some part of the code originally written for the Zomato consumer app.

Due to this need of code sharing, we structured our consumer code into various “Kits” for each use case. For example, ZUIKit has the code required to use our UI components. All the sister apps would take a copy of the relevant kits, customise them to their requirements and ship their app. However, this approach resulted in teams using their own modified versions of the kits without a centralised syncing system. In other words, the ZUIKit used by the Blinkit app was different from the one used in the Zomato consumer app.

Initially, this worked but whenever we needed the same behaviour across apps, we ended up manually copying and pasting the specific files then testing them in every app. This was highly inefficient and the issue compounded over time, as we found ourselves solving similar problems across multiple teams.

Original project structure

Let’s break this down into two sections – internal kits & external dependencies.

  • Internal kits

The kits we mentioned earlier, were all separate Xcode projects linked within the app. We simply shared copies of these Xcode projects with the teams that needed to integrate it into their app.

Now, the obvious question arises: why not share xcframeworks instead of sharing the whole projects? Sharing xcframework would not solve the use case of the sister apps because the code within the kits was not very customizable as most of the code was written specifically for the Zomato consumer app.

Before starting the migration, we had 21 kits. For example, we had ZUIKit for UI elements, ZPaymentsKit for our payments flow, ZChatKit for chat/support flows, ZO2Kit for food ordering flows and so on.

  • External dependencies

We were using Carthage to maintain external dependencies. Each kit had its own cartfiles, listing its dependencies. To streamline the process, we had a script in the root of the app’s folder that fetched dependencies for all kits based on the folder structure.

The problem here was that Carthage was very hard to work with. For example, whenever we upgraded to the latest version of Xcode, we had to make sure that it’s compatible with the Carthage dependencies. Wherever it was not, we had to fork the dependencies and make the necessary changes. Due to this verbose nature of using Carthage, people in the team perceived it as a black box which the platform team would take care of.

The problems of Carthage were not even just limited to dev workstations. We would face problems related to building the app on our GitHub Actions pipeline as well. We had an action which ran once per day to cache Carthage dependencies on our runner. This meant, in case you wanted to trigger a debug build with some other version of an external dependency, we would not be able to do it without rebuilding all the dependencies which would increase the build time significantly.

Deciding the approach

There were multiple approaches that we could have taken. We could have continued using Carthage for external dependencies and importing internal kits. While this would solve the problems of code sharing & syncing, it would probably worsen the existing problems we faced with Carthage. We kept this as a last resort in case no other solution worked in our favour.

Ideally, we wanted to use Apple’s Swift Package Manager (SPM) for both internal kits and external dependencies. While SPM was relatively new to us and we hadn’t explored it much before, we decided to give it a try before considering alternatives like Cocoapods. Spoiler alert, it worked.

Hence, we decided to convert our internal kits from Xcode projects to Swift Packages & alongside moved our external dependencies from Carthage to SPM as well.

Migrating to swift package manager

  • First kit migration

We started the migration from the bottom-most kit in our dependency tree, which happened to be Sushi. It is a relatively small kit written exclusively in Swift. This was an ideal case for creating a Swift Package so we simply ran the following command in Terminal within Sushi’s folder:

swift package init

This command creates the required files/directories for a Swift Package. After that, we just moved the files, and boom! Sushi was converted into a Swift Package. We were able to build it.

  • Integrating the first external dependency

From there, we moved to the next kit with no internal dependencies, which was our API Manager. Here we had to get our first external dependency, Alamofire. This was fairly straightforward: we simply provided the URL of the GitHub repository of the dependency along with the desired version.

dependencies: [
    .package(url: "https://github.com/Alamofire/Alamofire", exact: "5.4.4")
]

We could have chosen to specify the version in a range, but due to the large scale of our app, we decided to stick with the exact versions. This would prevent any unintended updates to dependency versions that could potentially cause issues for our end customers.

First roadblock: Creating interdependent mixed source swift packages

Although specifying the dependency was easy, we faced a major roadblock ahead. Our API Manager, like most of our other kits, has interdependent Swift & Objective-C code dependencies. However, SPM doesn’t allow us to create mixed-source targets. Apple designed it this way because they consider Objective-C code to be legacy. So if you have mixed sources then it implies that your Swift code is dependent on Objective-C, creating a linear dependency graph.

The way to solve this issue is to create two separate targets in your package.

  • Objective-C target which would only contain the Objective-C source files. This target would build on its own.
  • Swift target which would only contain the Swift source files and depend on the Objective-C target.
.target(
    name: "ApiManagerObjC",
    dependencies: ["Alamofire"],
    path: "ApiManager/ObjC",
    publicHeadersPath: "."
),
.target(
    name: "ApiManager",
    dependencies: ["ApiManagerObjC"],
    path: "ApiManager/Swift"
)

The package would expose a single library like below.

.library(
    name: "ApiManager",
    targets: ["ApiManager"]
)

This approach would not work for us because our Swift code depends on Objective-C and Objective-C also depends on Swift which creates a cyclic dependency.

We spent some time in identifying possible solutions for this issue, but didn’t find any example where a Swift Package contained interdependent mixed source files. The common suggestion was to create an xcframework & then create a Swift Package which uses the xcframework instead of the source code. This works for closed source packages like Google Maps but distributing xcframeworks wasn’t a solution for us.

Initially, we thought that this might not be possible with the current implementation of Swift Package Manager and that we would have to explore this solution later once Apple solves this issue. Knowing how beneficial this migration would be, we decided not to give up. Just because no solution exists currently, doesn’t mean the problem can’t be solved.

To solve this problem, we decided to dive deep into Xcode internals to understand how it compiles a project. Here’s a more technical breakdown of the process:

Xcode first compiles all Objective-C header files (.h) as they provide the necessary interface declarations required for other files.

Next, Xcode compiles the Swift files (.swift) that depend only on the Objective-C header files. This step is possible because Swift can interoperate with Objective-C through bridging headers.

After the Swift files, Xcode compiles the Objective-C implementation files (.m). These implementation files utilise both the previously compiled Objective-C header files and any public declarations from the Swift files.

This sequence ensures that dependencies are correctly resolved, allowing for successful compilation and linking of the project.

We realised that if we could separate our packages into these three targets, it might work. So, we organised our directory structure, which looked like this:

Kit/
├── ObjC
headers/
│ 	├── File1.h
│ 	├── File2.h
│ 	└── …
implementations/
│ 	├── File1.m
│ 	├── File2.m
│ 	└── ...
├── Swift/
│ ├── File1.swift
│ ├── File2.swift
│ └── …

We then updated the target description in the Package.swift file as follows:

.target(
    name: "ApiManagerObjC",
    dependencies: ["Alamofire"],
    path: "ObjC/headers",
    exclude: [“implementations”],
    publicHeadersPath: "."
),
.target(
    name: "ApiManagerSwift",
    dependencies: ["ApiManagerObjC"],
    path: "Swift"
),
.target(
    name: "ApiManager",
    dependencies: ["ApiManagerSwift"],
    path: "ObjC/implementations"
)

By doing this, we were successfully able to build the ApiManager Swift Package, but we encountered an error `“ApiManagerObjc.o” file was not found` while compiling the ApiManager Swift Package. The reason for this error is that an Objective-C target requires at least one .m file to generate a .o file. To resolve this, we created a new .m file named ApiManager.m in the headers folder and included the Foundation framework.

Lastly, we needed to add a header file in the implementation target to ensure successful compilation. We created an empty file named Header.h in the include directory within our implementation target’s directory. In this header file, we simply imported the Objective-C target.

Our updated file structure was as follows:

Kit/
├── ObjC
│	headers/
│ 	├── File1.h
│ 	├── File2.h
│ 	├── KitObjC.m
│ 	└── …
│	implementations/
│ 	├── File1.m
│ 	├── File2.m
│ 	├── include/
│		├── Header.h
│ 	└── ...
├── Swift/
│ ├── File1.swift
│ ├── File2.swift
│ └── …

After doing this, we were able to build our ApiManager Swift Package successfully.

Second roadblock: Resolving forward declarations

Our approach worked for ApiManager but we soon hit our next roadblock while migrating ZUIKit. Some properties/functions defined in Objective-C header files were not accessible by our Swift target. The issue was we had declared properties of types defined in Swift in those header files by using forward declaration like below:

@class SwiftObject;
@property (nonatomic, strong) SwiftObject *property;
- (void)setObject:(SwiftObject*)object;

We figured that a forward declaration gets resolved by the implementation of the header file. These properties/functions were accessible as expected in the implementation target. The issue was only limited to these properties/functions being accessed in the Swift target.

Once we understood the root cause of the problem, we began brainstorming a solution. We quickly realised that we needed to remove the forward declaration entirely from the header file. To achieve this, we also had to update the data type of the property and method argument/return type. But how could we do this without changing all the callers of these properties/functions? Here’s how we updated the code:

LegacyObject.h
@property (nonatomic, strong) NSObject *propertyObj;
- (void)setObject:(NSObject*)object;

LegacyObject+.swift
import KitObjC;
@objc public extension LegacyObject {
    var property: SwiftObject? {
        get { propertyObj as? SwiftObject }
        set { propertyObj = newValue }
    }
}

LegacyObject.m
@import KitObjC;
@import KitSwift;
- (void)setObject:(NSObject*)object {
    SwiftObject* obj = (SwiftObject*)object;
    // use obj instead of object
}

We replaced the type with the first available superclass of SwiftObject in Objective-C or NSObject. We updated the property name, then defined the property with the original name in a Swift extension which was just a computed property to get and set the Objective-C property. In case of functions, we typecast the expression inside the function body as we do have access to the type in the implementation target.

Now that the solution was figured out, we repeated the migration process for all 21 of our kits. This process took a few weeks, all while feature developments were going on simultaneously as per our weekly release schedule. Consequently, we had to keep rebasing our changes on our development branch.

  • First run

A few weeks later, the entire app was in a buildable state. We hadn’t tested our migrated kits in the app yet so running it for the first time felt like a big event. And, to our surprise, the app ran smoothly. At the first glance everything seemed to work, which was a big relief and our first glimpse of success in the migration process. Upon further testing, we found there were a few common patterns of bugs.

There were issues caused by the renaming of kit bundles leading to bugs in places where we were retrieving assets/files directly from the bundle using a bundle identifier. So updating those implementations solved this issue.

There were also few crashes due to using NSKeyedArchiver & NSKeyedUnarchiver. The issue was that the global names of classes had changed. For example, the class named ZUIKit.SwiftObject was renamed to ZUIKitSwift.SwiftObject. NSKeyedArchiver & NSKeyedUnarchiver use the name string which was used while archiving to unarchive the object. Since ZUIKit.SwiftObject no longer existed, unarchiving it resulted in a crash. We fixed this by updating the class names before archiving or unarchiving them using the code below.

NSKeyedArchiver.setClassName(“Kit.SwiftObject”, for: SwiftObject.self)
NSKeyedUnarchiver.setClass(SwiftObject.self, forClassName: ”Kit.SwiftObject”)

Once these bugs were resolved, we were ready to merge the migrated code into our development branch. As a side benefit of migrating to Swift Package Manager, we no longer had to maintain multiple Xcode project files (one for each kit). We have a single project file for the app which eliminated a lot of broken file references and duplicate files stored on disk.

Third roadblock: Fixing objective-C Code highlighting & completions

Before merging, we asked some team members to try working on the migrated branch and report any issues or anything we might have missed from a development perspective. Swift projects were working as expected, but there was an unusual issue with Objective-C projects. For some kits, the Objective-C code was not getting indexed in Xcode. Features like breakpoints, code completion, highlighting, and jump to definition were not working. This was a significant issue since some kits required frequent changes to the Objective-C code. Therefore, we needed to resolve this problem before merging the code.

While researching, we learned that Xcode has a module called SourceKit for indexing code. We could access the logs generated by SourceKit in real time using the following command.

EXPORT SOURCEKIT_LOGGING=3 && /Applications/Xcode.app/Contents/MacOS/Xcode

While checking these logs, we figured that the files were being indexed correctly with no errors from Sourcekit. This indicated that it was something related to Xcode. We then found that Xcode expects the header files to be in the same directory tree as the implementation files. So we updated the file structure and package description of one of the affected kits to test this.

The file structure was now as follows:

Kit/
├── ObjC
│ 	├── File1.m
│ 	├── File2.m
│ 	├── include/
│		├── Header.h
│	headers/
│ 	├── File1.h
│ 	├── File2.h
│ 	├── KitObjC.m
│ 	└── …

The target description in our Package.swift file would look something like this:

.target(
    name: "ApiManagerObjC",
    dependencies: ["Alamofire"],
    path: "ObjC/headers",
    publicHeadersPath: "."
),
.target(
    name: "ApiManagerSwift",
    dependencies: ["ApiManagerObjC"],
    path: "Swift"
),
.target(
    name: "ApiManager",
    dependencies: ["ApiManagerSwift"],
    path: "ObjC",
    exclude: ["headers"]
)

Quitting & relaunching Xcode after this change solved our issue. All the Objective-C files were now being indexed correctly and we were able to use all the features which were not working previously.

Fourth roadblock: App size increase

Now that the app was working as expected and there were no development related issues, we were almost at the finish line. However, we wanted to see if the app binary size has changed due to our migration. That’s when we faced another issue. Our app size had increased from around 60 MB to 400 MB.

Upon analysing the app binary, we realised that most of our kits were getting linked statically in the executable rather than dynamically. Before migration, all of our kits were linked dynamically. The static linking caused the size increase, as many of our kits cross-reference the same underlying kits. This resulted in multiple copies of the same kits in the final executable binary.

A Swift Package library can state its type as static or dynamic. If a library doesn’t specify its type, the type is considered as automatic. In this case, Xcode decides the linking type at compile time based on the dependency graph of your project. Ideally, Xcode should detect the same kit used as a dependency in multiple kits and link it dynamically, but this didn’t happen. The documentation states that Xcode prefers static linking but we couldn’t get Xcode to link our dependencies dynamically. So, we specified the type as dynamic in all of our packages.

.library(
    name: "ApiManager",
    type: .dynamic,
    targets: ["ApiManager"]
)

This reduced our app size back to its pre migration state. Another small issue we faced while testing our app in the Release configuration was that NSAssert added in Objective-C was still causing the app to crash. To fix it, we added a flag to our Objective-C implementation target. Our final target description looks like this:

.target(
    name: "ApiManagerObjC",
    dependencies: ["Alamofire"],
    path: "ObjC/headers",
    publicHeadersPath: "."
),
.target(
    name: "ApiManagerSwift",
    dependencies: ["ApiManagerObjC"],
    path: "Swift"
),
.target(
    name: "ApiManager",
    dependencies: ["ApiManagerSwift"],
    path: "ObjC",
    exclude: ["headers"],
    cSettings: [
        .define("NS_BLOCK_ASSERTIONS", to: "1", .when(configuration: .release))
    ]
)

After doing all of this, we shipped our first build using our migrated code in our app version 17.50.0. Our usage metrics remained unaffected, with crash free sessions stable at 99.99%. So outside of the iOS team, no one was aware that such a major architectural change had taken place in this release. This was a major win for us as we were able to entirely restructure the codebase of an app that is used by millions of people every single day without causing any negative impact.

Migrating to a mono repo

Once we were confident in the success of our Swift package manager migration, we shifted our focus to moving all of our newly created Swift packages to a new central repository. We would call it iOS Mono Repo and it would host readily available kits which could be plugged into any of our existing or upcoming sister apps. This would have been a straightforward thing to do but all 21 of our kits were not previously hosted on a single repository and we wanted to preserve git history for all files so as to not affect the day to day dev process.

To achieve this, we had to merge the histories of four repositories where our kits were previously hosted. There was a lot of Git magic involved, but nothing a few internet searches and a couple of cups of coffee couldn’t fix. In the end, we got all 21 of our Swift Packages to the iOS Mono Repo with all their history intact.

We wanted the iOS Mono Repo to have a single Package.swift file at its root which would define all 21 libraries in one file. This would allow us to reference this repository from Xcode, specify a version and get access to all of our kits. However, we encountered a hurdle: Swift Package Manager doesn’t allow remote packages to have local dependencies. Due to this, we had to scrap the idea of importing the iOS Mono Repo using Swift Package Manager in our app.

Instead, we adopted an alternative approach by adding the iOS Mono Repo as a git submodule of the consumer app’s repository. Now, every commit on the consumer app’s repository would point to a commit of iOS Mono Repo with which it would build. Although this wasn’t the ideal solution, it allowed us to proceed until Swift Package Manager supports a method for handling this use case. For the consumer app, this change mainly involved moving files around so we shipped it in the very next release, version 17.51.0.

This is where our bigger battle started. We now had to get all our sister apps to adopt this iOS Mono Repo. For newer apps or those with minimal changes to our original kits, This transition was straightforward, allowing us to migrate the Dining Merchant iOS app and the HyperPure iOS app without much hassle. The major challenge, however, was the Blinkit iOS app. The Blinkit iOS app was using versions of our kits which were about 1.5 years old and over time all the referenced kits had undergone numerous changes. Migrating the Blinkit app took us about 2-3 weeks, but it has now been successfully shipped to customers.

On to GitHub actions

No code migration is complete without ensuring everything runs smoothly. Since we were now merging code across two repositories on a day to day basis, we needed to add checks before a pull request was merged. 

On the consumer app’s repository, we added a check to attempt building the code from the changes to be merged with the development branch of iOS mono repo. This would ensure no breaking change is merged to the Zomato consumer app’s repository and that both the development branches always build together.

On the iOS Mono Repo, we wanted to ensure that all the Swift Packages inside the repository could be built with the proposed changes. This would have been super easy if we had a single Package.swift file at the root of the iOS Mono Repo to build all the kits which we, unfortunately, didn’t have as we were unable to use Swift Package Manager to import iOS Mono Repo into our app. One way was to have that file anyway and someone would have to update it every time some package is added or removed from the iOS Mono Repo. Since the file would not be used by anyone in day to day development, ensuring the changes would not be easy. To combat this, we wrote a Swift script to generate the Package.swift file based on the contents of iOS Mono Repo. We kept that Swift file at the root of the iOS Mono Repo. Before merging every pull-request, a check runs this script and then tries to build the iOS Mono Repo Package. The pull-request can only be merged if the checks are succeeded. This ensures that the development branch of iOS Mono Repo always builds.

Road ahead

Now that our project structure is up to date, we have a streamlined way to ship kits to multiple apps. All this migration made it super easy to update to Xcode 15 as well. All we had to do was to just open the project and that’s it. We have already merged our first Swift Macro to the iOS Mono Repo which will improve the development cycle for all iOS developers at Zomato.

Next up, we have to break our kits down into smaller pieces and write new kits designed for use across multiple apps. With all this code flowing across apps, testing also becomes crucial. We now need to make our kits and apps more testable to ensure no business logic breaks when updating the kits. You will hear more about the developments in these areas in our upcoming blog posts.

Overall, this migration significantly elevates how we develop for Apple platforms as a whole. As we embrace these advancements, we’re excited to unlock new possibilities and set a higher standard for innovation. Exciting times ahead!

If solving these kinds of problems is something that excites you, and you feel like you have the required skills, write to us at ios@zomato.com

facebooklinkedintwitter

More for you to read

Technology

apache-flink-journey-zomato-from-inception-to-innovation
Data Platform Team | November 18, 2024 | 10 min read
Apache Flink Journey @Zomato: From Inception to Innovation

How we built a self-serve stream processing platform to empower real-time analytics

Technology

introducing-pos-developer-platform-simplifying-integration-with-easy-to-use-tools
Sumit Taneja | September 10, 2024 | 2 min read
Introducing POS Developer Platform: Simplifying integration with easy-to-use tools

Read more about how Zomato is enabling restaurants to deliver best-in-class customer experience by working with POS partners

Technology

migrating-to-victoriametrics-a-complete-overhaul-for-enhanced-observability
SRE Team | August 12, 2024 | 11 min read
Migrating to VictoriaMetrics: A Complete Overhaul for Enhanced Observability

Discover how we migrated our observability metrics platform from Thanos and Prometheus to VictoriaMetrics for cost reduction, enhanced reliability and scalability.

Technology

go-beyond-building-performant-and-reliable-golang-applications
Sakib Malik | July 25, 2024 | 6 min read
Go Beyond: Building Performant and Reliable Golang Applications

Read more about how we used GOMEMLIMIT in 250+ microservices to tackle OOM issues and high CPU usage in Go applications, significantly enhancing performance and reliability.