Integrating Extensions into Large-Scale iOS apps

Written by Artur Stepaniuk and Max Husar

Today, when you open Apple Maps and choose a destination, you are able to see a list of available Lyft offers, seamlessly routing you to the Lyft app to book your next ride. To create this fluid and user-friendly experience across the iOS ecosystem, however, engineers must tackle a range of technical challenges, from managing dependencies in a highly modular application to optimizing performance while maintaining a high quality user experience.

Disclaimer: For the purpose of this deep dive, we assume that you have a general understanding of what a build system is, including concepts like modules, static/dynamic frameworks, dependency graphs and build settings customization via flags.

Demo of Lyft’s integration to Apple Maps

From an implementation perspective, this involves creating a separate application extension. Detailed descriptions of API for booking rides can be found in the relevant Apple documentation, which includes multiple code examples.

Let’s explore the architectural nuances, challenges faced during development, and solutions implemented to overcome these obstacles:

  • The Dependency Jungle and how to manage constraints in a highly modular application, complying with RAM and binary size limitations;
  • Development process caveats & SiriKit integration tips to maintain a consistent development user experience.

Most of the faced complications in this specific use case can be generalized to integrations with different parts of the iOS system or other applications.

Dependency Jungle

Lyft applications are built using the Bazel build system (check out our Lyft Mobile podcast with Keith Smiley). Our codebase is highly modular, with each business feature consisting of several separate blocks/modules. This modularity forces the use of static linking for dependencies to avoid long app start times, among other benefits.

However, the downside of static linking is that each linked dependency is copied to the extension, which is a separate target. With many small modules, this leads to numerous connections between them, potentially causing issues and inevitably increasing the dependency graph complexity.

The advantage of a highly modular codebase is that it simplifies adding corresponding modules and avoids code duplication or significant refactoring. However, after the initial setup, the dependency tree of the extension looks as follows:

Initial dependency tree of the extension module. Powered by Gephi, a visualization tool.

There are dozens of modules linked to the extension module and hundreds of dependencies between them, making the extension’s dependency tree immensely complex.

While a large dependency graph isn’t inherently problematic, it does contribute significantly to the Extension’s memory footprint.

As mentioned above, with static linking each dependency is copied. It implies that every module from the image above will be added to the application package twice, increasing its binary size.

At the same time, loading these modules during extension’s work increases its overall runtime memory consumption.

Tooling tea break #1

At Lyft, we utilize Bazel to analyze module dependency graphs together with open-source Graphviz visualization software. It enables us to determine which application modules depend on others, such as checking if module A depends on module B.

Additionally, Gephi visualization software is used for demonstration purposes & as a more enhanced graph analysis tool.

An example query might look like this and can be executed against any module or the root of the app:

bazel query 'kind(swift_library, deps(MODULE_PATH:MODULE_NAME))'

The output is a list of MODULE_NAME dependencies. Different parameters allow you to create simple files with dependencies lists or build a graph representation of ones. This tool is essential for our instrumentation, particularly in addressing the extension memory footprint issue.

The next section describes the limitations related to the extension’s available memory and our way of handling them.

Memory footprint

App extensions are designed to extend existing applications’ functionality, so their resource needs are limited to avoid impacting the overall user experience within the main application or the iOS system.

For an extension’s runtime memory, there is no fixed limit on the RAM extension, as it depends on the iOS version, device model, OS environment, and other factors. Our explorations indicate that this limit can vary roughly between 20 to 50 MB.

Regarding binary size, it’s generally understood that smaller is better. A larger binary size can lead to longer download and install times, potentially reducing the number of installs. The worst-case scenario is hitting the 200 MB download size limit, which triggers an additional confirmation dialog during app download when using cellular data.

In our case, the initially created extension’s RAM footprint is around 21 MB which is considered safe within the explored boundaries. However, the initial binary size increase of 45 MB is a critical issue, as the extension itself would take almost ¼ of the 200MB size limit.

To address this, the following steps can be taken:

  1. Analyze Business Logic Blocks: Break down the three main business logic blocks — authorization, available offers, and additional offers’ data. Identify the components that contribute most to the binary and runtime memory footprint.
  2. Eliminate Major Contributors: Investigate and implement strategies to reduce or eliminate these major contributors.

To facilitate this analysis, a graph visualization tool is used. For example:

bazel query --output=graph [other omitted parameters] module=ExploredModule

The command above generates a detailed dependency graph file, which can be then visualized using Graphviz, Gephi or other software:

ExploredModule’s graph tree with most significant dependencies highlighted

The next step is to analyze the graph to identify any suspicious dependencies that might include unnecessary resources beyond the source code.

In-depth modules analysis

To measure the binary size impact in detail, each module can be added as the only dependency to the Apple Maps extension and analyzed using the `binary-size-diff` tool (explained in section below).

By repeating this process for each necessary business logic feature and its dependencies, we can identify the main contributors to the binary size:

  • UI module: ~15MB
  • Networking layer: ~7MB
  • Interface Description Language (IDL)* imports: ~6MB
  • Maps/ML stack: ~8MB

*Note: IDL modules in the context of the Lyft iOS applications are code-generated modules with DTO models and simple API clients to communicate with backend based on the contracts predefined using protocol buffers. The usage of this concept is explained in detail in our other article.

To understand how to remove these elements or limit their impact, we can use Bazel again to show the transitive dependencies (the path) between two modules. For example:

bazel query 'allpaths(INITIAL_MODULE_PATH:INITIAL_MODULE_NAME, TARGET_MODULE_PATH:TARGET_MODULE_NAME)' --output=graph | grep -v ' node \[shape=box\];' > relations.dot

As we can see on the graph shown below, the result is a much more readable graph compared to other methods.

Graph showcasing the dependencies that create Initial-Target modules connection

It allows us to identify what modules are linking our Initial module to the Target one and understand how to remove it from our dependency graph to unlink the unnecessary source code.

In our case there are 6 dependencies of the Initial module that need to be addressed to remove the biggest binary size contributor: the Target (CoreUI)* dependency.

*Note: CoreUI is our main module containing all UI components and related resources. While it’s widely used in the main app, it is not needed for the extension and only adds a significant increase in binary size.

The next step for this Initial module would be to either remove all 6 dependencies that lead to the Target module or to eliminate their connection to the Target module.

Separately, while some adjustments can be made to create a lighter version of the networking layer and to strip some of the IDL imports, the main issue is a singular module which fetches available ride offers (part of its dependencies is shown on the graph above) and its broad dependency list.

Two approaches are considered:

1. Extracting a core submodule to be used in both the main app’s Offers service and directly in the Apple Maps extension.

2. Creating a small, separate module containing only the functionality required for the extension.

In this case we are choosing the second approach as it allows us to have full control over added dependencies, thereby minimizing any unnecessary imports. The downside is the need to keep both the original and the new services in sync if any relevant parts in the extension are changed.

Tooling tea break #2

One of the essential tools for managing the dependency tree is the binary-size-diff script. This CI bash script allows you to compare the binary size differences between the base branch and the created Pull Request. Essentially, it compares the .ipa file sizes in both compressed and uncompressed states, enabling you to see changes in the app’s install and download sizes.

The workflow looks like:

  1. Create a draft Pull Request (PR).
  2. Modify the extension’s BUILD file* to include only the dependencies you want to measure.
  3. Commit and push the changes.
  4. Invoke the CI with the command `/test diff-app-sizes` to run the bash script described above.
  5. Iterate this process for each individual dependency you want to measure.

*Note: BUILD file is the main configuration file that tells Bazel what software outputs to build, what their dependencies are, and how to build them.

Optimization results

The culmination of our efforts is resulting in a significantly streamlined dependency tree for the extension, leading to a substantial reduction in its binary size — from 45MB to 15MB.

Below is a visualization of the extension’s resulting dependency graph. Although it may still appear chaotic, most of the remaining modules are IDL imports. These imports are highly atomic and are all transitively linked to a networking base layer, creating some “dependency noise.”

Extension module dependency tree after performed optimizations

Development process caveats & SiriKit integration tips

While the optimization of the dependency tree marks a significant milestone in enhancing the extension’s efficiency, the journey towards a successful release doesn’t end here. Attention must now shift to the finer details of the development process. Successfully releasing the Apple Maps extension requires addressing the following small but crucial details.

Region Availability

Ensure your extension is available in the regions where your platform provides rides. This is done by creating a GeoJSON file listing the supported regions and uploading it as your app’s Routing App Coverage File (documentation). The extension card will only be displayed to users trying to book a ride within the specified regions.

Caveats with GeoJSON:

- While you can debug the GeoJSON file itself to check its correctness (developer docs), there is no direct way to test its integration with the Maps extension. Therefore, testing must be done in production after release.

- The GeoJSON file adds maintenance overhead. Whenever your service area changes, the configuration must be manually updated and uploaded.

Third-Party dependencies and the APPLICATION_EXTENSION_API_ONLY

According to the documentation, the build setting flag APPLICATION_EXTENSION_API_ONLY “when enabled, causes the compiler and linker to disallow use of APIs that are not available to app extensions and to disallow linking to frameworks that have not been built with this setting enabled.

Implications:

  • If any of your application’s modules or any of its dependencies (direct or indirect) are built with this flag set to TRUE, they cannot be linked to the extension.
  • While you can control this flag in your own modules, dealing with third-party dependencies may require stripping them out (in cases when it is impossible to rebuild them from the source code). This issue is closely related to efforts to reduce the extension’s memory footprint (discussed in the previous section). Fortunately in our case no critical functionality depended on these third-party frameworks.

This highlights the risks associated with relying on third-party dependencies — adding one can lead to unexpected limitations in the future.

For more on this topic, check out our article about the risks and evaluation of adding third-party dependencies.

Encountered Developer experience issues

In the process of developing extension for Apple Maps, you may face several challenges that can impact workflow and efficiency.

The first one arises during the installation of your extension on a device or simulator for the first time, SiriKit may not immediately recognize it. This results in the Apple Maps application not displaying the added extension. You may need to wait several minutes before issuing any relevant commands to the Apple Maps (or simply try to reinstall & rerun the extension).

Similarly, when updating your extension’s Info.plist file or making any code updates to the extension’s business logic, it may take several minutes for SiriKit to recognize the changes. This is especially important during the develop-run-debug cycle as some unexpected behaviors might get unnoticed.

Conclusions

Integrating Lyft’s ride booking functionality with Apple Maps is a rewarding effort — this journey has underscored the critical importance of precise dependency management and efficient memory usage, even with the substantial computational resources available on modern mobile devices.

Key Takeaways:

  • Dependency Management: Effective management of a large dependency tree is essential to minimize the memory footprint and binary size of the extension. A highly modular codebase is key for success.
  • Tooling: Leveraging tools such as dependency graph visualization and binary size comparison can significantly aid in identifying and resolving issues related to memory and binary size.
  • Third-Party Dependencies: Relying on third-party dependencies can introduce unexpected limitations, highlighting the need for careful consideration and potential alternatives.

As we continue to refine our integration with Apple Maps, the lessons learned from this experience will guide us in overcoming new challenges and can be extrapolated to other initiatives related to utilizing various Apple’s App Extensions.

And one more thing: Lyft is hiring! If you’re passionate about developing complex systems using state-of-the-art technologies or building the infrastructure that powers them, consider joining our team.

ホーム - Wiki
Copyright © 2011-2024 iteam. Current version is 2.139.0. UTC+08:00, 2024-12-26 01:29
浙ICP备14020137号-1 $お客様$