Earlier this year, we released a rewritten map component for our iOS app, along with a version packaged as an SDK for external customers. This release marked a major step in our journey to modularize our iOS app, unlocking benefits of code reuse, separation of concerns, and reduced overhead for maintenance and new feature development. This blog post will cover some of the design choices we made for the new map and lessons we learned along the way.
The map is at the center of the FlightAware app, serving as both a data display and an important piece of the navigation experience. It’s interactive and adaptable, changing to show the most relevant information for each screen in the app. Our map is faced with the difficult task of presenting a large amount of information in a way that’s both accessible at a glance and provides detail for users who want to dig in. Its interactivity and tight integration with many screens in our app give it an outsized impact on the way people use the app and how satisfied they are with the experience. In many ways, the map component is central to the use cases many users have for our app. It was challenging and rewarding to work on something that impacts so many users so directly.
We embarked on a project to rewrite the map component for several related reasons. Most importantly, we wanted to offer a flight mapping SDK as a product so that external customers could integrate FlightAware data and maps into their apps with just a few lines of code. We’re a small iOS team and don’t have the capacity to maintain multiple mapping solutions, so this external SDK would have to be produced from the same codebase we use for the map in our app.
We had a map component in our app already, but it had grown organically over 5-7 years and was deeply intertwined with a lot of app-specific code. It would take a lot of effort to extract it from the app, and we’d be left with something that still had significant technical debt and was tightly coupled to our internal REST API. Additionally, the old map had some architecture decisions that would be very difficult to change, such as completely removing and replacing all the aircraft icons on screen when they needed to have their positions or tracks updated. Considering all the effort that would be required to end up with a product that was still less than ideal, we determined that it made sense to start an implementation from scratch while integrating the significant knowledge we’d built designing, maintaining, and extending the previous solution.
Design Choice: Messages
Early in the development of the new map, after some significant prototyping, we decided that the flight information would be represented as a “pipeline” of different components sending “messages” to each other in sequence. A message represents the complete state of a flight at a point in time, including its position, track, and other identifying information. Each component would have a single responsibility, like applying a smoothing algorithm to the flight track or choosing the color and other style information for the aircraft icon. After going through all the processing components, the messages would arrive at the end of the pipeline and be used to update the annotations and overlays (aircraft icons, flight tracks, etc) displayed on the map.
In Swift, we expressed this with some protocols, structs, and enums as follows:
We chose to implement our own set of protocols instead of using something like Combine or AsyncSequences because we didn’t feel AsyncSequences were mature enough at the time and Combine seemed like a lot of syntactical overhead to accomplish what we needed. This implementation gives us flexibility to write simple, readable, easily testable code without adding incidental complexity.
The pipeline-based design has several important benefits:
- Its orientation around messages, each focused on a single flight, means that it can easily adapt to both “push” data sources, where updated flight positions are streamed to clients in real-time, and “pull” data sources, where clients periodically poll an API to get updated information. The old map was mostly built around “pull” datasources, so this new design added the flexibility for us to use both kinds of datasources without needing to account for the difference in the rest of the map code.
- It minimizes the number of places where state has to be maintained. Most of the components in the pipeline are stateless, using only the information present in the messages (which may have been added by previous components) to perform their task. Fewer places to store state and no need to access state across components means fewer opportunities for state to be out of sync and clearer logic in each component. This in turn dramatically improves the development and debugging experience for the whole project.
- The components are individually testable and have well-defined interfaces between them. Unit tests can be written for each component, sending messages to it and verifying the messages it generates in response. Since each component is responsible for a single task (many are less than 100 lines of code), their behavior is easy to reason about and easy to write tests for.
- The components are composable. They can be rearranged or removed entirely to change or remove behavior. For example, there’s a component whose job it is to manage what time is currently being displayed on the map, which is involved with animation of plane icons and the flight replay feature. This means there’s one place to maintain the time information, one place to change it, and all the time logic can be completely removed from the pipeline (for example, to debug something, or if animation or replay functionality is not desired) by simply removing the time component. The idea of abstracting the time handling logic into a single component was a major “a-ha moment” during the prototyping stage and one of the most compelling reasons we chose the message-based design. It significantly simplified and centralized the logic compared to the previous solution and unlocked possibilities we didn’t have before.
The pipeline-based design is an implementation detail of the map component, and is not reflected in the public API surface, so consumers don’t have to worry about configuring the specific pipeline components to produce the display they want. Instead, they specify what they want in a declarative way and our code configures a pipeline to display it.
Design Choice: Modular Data Sources
We also knew early on that we wanted our new map to be able to get flight data from multiple FlightAware data sources. For much of the development process, we weren’t sure what the solution for external customer data access would be. We also knew that we’d need to use our existing internal REST API when the map was integrated in the iOS app, but that we’d eventually be migrating away from this API to use something built on modern technologies in alignment with FlightAware’s broader modernization project. With all this in mind, we decided that the data source layer for the map would have to be modular, with well-designed abstractions of flight data that were agnostic to the specific API from which the data originated. We put a significant amount of effort into designing and evolving these abstractions. This decision paid off immensely in several ways:
- It allowed us to swap in code to read data from files instead of an API, accelerating development and enabling reproducible tests without dependencies on external systems. For example, one of the problems we ran into frequently with the old map was debugging the way a flight appeared on the map at a certain point during its flight. Issues were often entirely impossible to reproduce later, as our flight tracking system had processed different data in the interim. With our new design, it was trivial to build a data source that reads API responses out of files, which allows us to capture the API output at the exact moment the problem is happening and debug against exactly that data until the problem is resolved. We also leveraged this facility to create synthetic data representing very rare scenarios, like a flight that crosses the antimeridian in both directions. This was a significant improvement for us and saved a lot of time that would otherwise have been spent searching for real “edge-case” flights.
- It enabled the same components of the pipeline described above to be reused without regard to the specific data source. Additionally, we evolved the abstraction in line with building the components, so it’s designed for the best developer experience in Swift rather than being tied to the structure provided by any of our backend APIs. For example, we make heavy use of optionals, structs, and enums, instead of relying on primitive types like strings that might be empty or numbers that represent a particular unit of measure and may require conversion to be used correctly.
- It let us put off deciding on the solution for external customer data access until very late in the development process. This in turn allowed us to align that decision with other teams at FlightAware who were working on similar solutions for our web-based products, saving overall time and effort and avoiding redundant solutions. When we finally had a solution identified, we were able to write only the code needed to interface with that solution and map its responses into our abstraction. The rest of the processing and display logic “just worked” without additional changes required. This was a rewarding validation that our approach to abstraction had saved significant time and effort.
Even though the long-term plan is for all consumers, internal and external, of our map component to be using one standard next-generation data source, this design still has developer-experience and testing benefits. While that data source isn't in production yet, this design lets us ship our new map and get it in the hands of our users now.
Design Choice: MapKit*
From the beginning, we chose to use MapKit, the built-in framework on iOS that provides basic map views, base map tiles, and primitives for interaction and drawing annotations and overlays on the map. For those familiar with MapKit, this may seem like an odd choice—MapKit has significant limitations and our application is certainly pushing the boundaries of what’s possible. For example, MapKit doesn’t support user-provided vector map tiles; while the Apple Maps base tiles are vector-based, there’s no way for developers to provide their own vector tile set. This means we can’t directly use the beautiful vector map tiles we developed for the web (check them out on our beta site at beta.flightaware.com/live/airport/KIAH).
MapKit also has some interesting performance behavior when dealing with many annotations or overlays comprising many points, which required us to implement specific workarounds to improve performance. For example, we found a severe memory leak related to MKTileOverlayRenderer
when many flight track polylines were drawn on the map and had to implement a custom renderer to work around it. The map project as a whole provided countless opportunities to hone our skills in CPU and memory profiling using Instruments and implement evidence-backed performance improvements to mitigate specific issues.
MapKit also has limitations in terms of interactivity, animation, and design customization with the built-in primitives, so in some cases we had to reimplement significant parts of built-in functionality to achieve a desired effect. For example, our callout views and the logic to show and hide them are entirely custom because MKCalloutView
doesn’t provide room for customization. The developer-facing interface of MapKit hasn’t changed significantly since it was introduced in iOS 4 (built around raster tiles provided by Google Maps) other than some new feature additions. MapKit is almost 15 years old and definitely showing its age in some places.
Despite all these limitations, we had a few important reasons for sticking with MapKit:
- We already had expertise building complex, interactive applications with MapKit—our old map was also built on it. We were able to start simply, using our existing knowledge, and build workarounds or reimplementations as needed when things got more complex, instead of having to start from scratch with a different mapping library.
- MapKit provides base map tiles included with our Apple Developer membership. While there are other map libraries available, developers generally have to provide their own raster and/or vector map tiles. Unfortunately, paid tile hosting services, a typical solution for this, are prohibitively expensive for an app with FlightAware’s scale and monetization model. MapKit provides functionally unlimited street map and satellite map tiles at no additional charge beyond our annual Apple Developer membership.
- A third-party mapping library would increase the binary size of the SDK integration we’re providing to external customers. Using frameworks that already ship with iOS is an obvious way to reduce the binary size.
Although we built the new map around MapKit, we did put a lot of consideration into the limitations discussed above. Keeping them in mind, we designed the MapKit integration so that MapKit could be swapped out for another map library such as MapLibre Native. For example, positions and flight tracks aren’t “lowered” to MKAnnotation
or MKPolyline
until we are about to display them on the map—the rest of the map code uses data structures that are completely agnostic to the mapping library. This is a significant improvement from the old map’s codebase, which used MapKit types extensively in its business logic. In the new map component, the MapKit-related code is clustered together and separated from the message pipeline containing the core business logic, so it would be relatively straightforward to write code specific to another mapping library and connect it to the end of the pipeline. This leaves us the ability to switch to something different down the road while allowing us to ship something that works today.
What we learned
While building the new map component, we learned several important lessons and built lots of new knowledge on the team. I’ll discuss some of the insights we gained around two major areas: testing techniques and building SDKs for external consumption.
The new map component represented a major leap in our team’s testing practices and capabilities. We weren’t in the greatest starting position: the old map had no automated tests of any kind, making it hard to identify regressions. The problem is compounded by the fact that putting flights on an interactive map has many edge cases which don’t come up often in manual testing, but that we want to get right. Building the new map component was an opportunity for a fresh start with better testing practices, and we took full advantage of that opportunity.
We set a goal that as much of the new map’s feature set as possible should be covered by some sort of automated test. As discussed above, our architecture choices made unit testing easy. As a result, we have several hundred unit tests exercising the individual components. Every time we find and fix an issue that the tests missed, we add a test to make sure we don’t break it again in the future. Our unit tests have been mostly reliable and have helped catch many regressions early in the development process. In addition, we found that the act of writing the tests helps us think about the code from a different perspective, which frequently leads to bugs or edge cases being discovered much earlier than if the tests had been written at a later time or not at all.
At the same time, our team is pragmatic about code coverage. We view it as just one tool in our toolbox, not a metric for which an arbitrary threshold must be enforced. We write unit tests where they make sense, but we’re not extensively changing our code just so we can test it or writing pointless tests for the sake of increasing the coverage a few percent. This pragmatic approach is common throughout FlightAware Engineering.
Not all the code we wrote for the new map was easily unit-testable. For example, all the code dealing directly with MapKit to display flight data on the map isn’t something we could easily unit-test. To test code like this, we rely on integration-style tests. Because the ultimate goal is to draw correct results on the map, it makes sense to test what ends up being drawn on the map. This doesn’t work with traditional UI testing approaches, which use the accessibility hierarchy to find views on the screen and assert on various properties (text value, visibility, etc) of the view. Instead, we use “snapshot testing”, which involves taking a screenshot of the map after it’s rendered a particular situation and doing a pixel-for-pixel comparison with a known-good image. We built a small test app with a UI that’s easily automated by the built-in XCUITest
functionality and used the SUITCase library for screenshot comparison because many of the other libraries don’t correctly snapshot MKMapView
.
While our snapshot tests have been useful, they have also been somewhat unreliable due to bugs in MapKit and UI automation interacting with the app. We struggle with false failures that require further investigation. On the whole, the snapshot tests are a useful tool when combined with nuanced manual review of the results.
In addition to new testing techniques, we also learned a lot about building an SDK for external consumption. This is something that no one on the team had done before, so we learned as we went.
We carefully evaluated the public API surface to ensure that functionality accessible to 3rd-party developers is coherent and easy to use. With a Swift-only framework, there are no header files, but the public interface is available in a .swiftinterface
file generated by Xcode in the compiled framework bundles. We wrote comprehensive documentation on the entire public API, making sure to keep the needs of an external developer in mind.
Perhaps the most important mindset shift we adopted while building the SDK was a focus on binary size. Swift is known for large compiled binary sizes, so we had to analyze and improve ours by aggressively stripping unnecessary information from the binary, refactoring our internal dependencies to pull in only what was needed, and compressing data assets required for the SDK to function. For example, we had a Swift package with a single target containing all the code to interact with our internal REST API. The map component only needs to talk to a few endpoints, so we split the package into several targets and only imported the necessary ones into the map component. This saved several megabytes of compiled code size.
To cap it all off, we built a script that runs in our CI and monitors the change in compiled binary size with each PR to ensure we’re always considering this attribute that’s very important to our customers.
Conclusions
Overall, the project to build a new map component for our app and to deliver an external map SDK was a success. Since we shipped the new map component in our app, we’ve been able to add several map features and iterate based on customer feedback with very little additional effort thanks to the extensibility of our design. We’ve been able to dramatically clean up our codebase around the map integration, ensuring greater separation of concerns and easier maintainability for the future. We learned a lot, especially around testing techniques and building an SDK for external consumption, and we’ve already started applying some of these learnings to our other iOS development at FlightAware. And at the end of it all, we have a new native iOS map component that’s flexible and extensible, setting the stage for new map features and improvements we’re hoping to introduce in the future, as well as a drop-in SDK for customers to easily integrate our flight maps with just a few lines of code.