Creating high quality mobile apps requires engineers to implement designs on multiple platforms. While visual idioms such as tab bars, toasts, or even some icons may be platform specific, one thing that rarely changes between iOS and Android are the illustrations, which designers often leverage for visual impact, to simplify complex concepts, or to provide an enjoyable user experience.
Unfortunately, the pipeline for integrating assets is rarely as enjoyable. Since iOS has no native support for vector formats, designers are forced to create multiple image assets for iOS, then export a VectorDrawable XML file for Android.
Apps with night modes or other alternate themes are forced to break images up into separate assets that can be tinted individually or ship two separate images.
Additionally, engineers sometimes need to endure the tedious and error-prone process of writing code to draw an image manually.
At Uber, engineers experienced all of these problems while building our new driver app, and our Mobile Foundations team decided to address this pain point for designers and engineers by providing a way to use vector assets on iOS. We needed a solution that fully abstracted away the problem of creating illustrations that scaled to multiple platforms, resolutions, and localizations.
Our Android engineers were already avoiding these problems through the use of the VectorDrawable format. VectorDrawable, a sensible subset of the SVG XML schema, supports paths, gradients, RTL, semantic theming of colors, and more. VectorDrawable’s comprehensive support for the features our designers need has been proven through its use in the Android versions of our rider, driver, freight and Uber Eats apps.
Unable to find any existing solutions that met our needs, we built Cyborg, our implementation of VectorDrawable for iOS. The library consists of a VectorDrawable parser combined with a UIView subclass, which supports display of the same VectorDrawable files used in Android apps on iOS.
Why build a new library?
Existing vector graphic implementations all implement a subset of the SVG spec that isn’t well-documented. Many of these solutions choose less performant technologies, such as Core Graphics, which provide features necessary to implement parts of the spec that few illustrations actually need.
Icon fonts are extremely performant, and don’t need NSTextAttachment support to be embedded directly in text. However, they are only appropriate for simple monochrome icons.
We already had experience using Lottie for rendering animations, which supports theming out of the box, so we explored using it for static illustrations. However, using it for static images would have required us to build new export tooling, or ask our designers to export their designs from Figma to Adobe After Effects to JSON. An informal test of this workflow revealed that many vector illustrations made in Figma have unexpected appearances after undergoing this process, and the designer was forced to re-create the asset specifically for this workflow.
Rolling out Cyborg at Uber
Pre-Cyborg, Uber had a thoroughly conventional workflow for images: designers provided @2x and @3x variants of images, which were used for both icons and illustrations, and Lottie was used to render complex vector animations. In our driver app, we used vector graphics extensively for the first time with an icon font.
We experimented with the nascent Cyborg library by swapping out various icon font views for VectorDrawable views, and carefully recorded performance metrics while the view was on-screen to ensure that there would be no regressions. After ensuring that both parsing a drawable and displaying it on screen did not cause frame drops, we were confident enough to incorporate VectorDrawables into the core flow of our rider app.
With the debut of our newest redesign, we had the chance to provide all the icons in our set as VectorDrawables, with built-in support for right-to-left languages such as Arabic. We also are considering replacing our old icon font with VectorDrawables, an enhancement that is only possible because of our efforts to improve Cyborg for scalability and performance.
The first iteration of Cyborg was far too slow, taking over 4 milliseconds (ms) to deserialize a simple icon. After extensive benchmarking and tuning on our 182 driver app icons, Cyborg’s parsing performance is now comparable to UIImage: it takes about the same amount of time to parse on average. It’s also faster than the fastest SVG alternative we could find.
After making the three key changes to the implementation listed below, we managed to reduce the time it took to parse those icons from over 600ms to under 200ms.
Using a low-level parser
The first key change was to avoid Foundation’s XML parser. Foundation’s XML parser wraps CFXMLParser, which uses libxml2. We chose to use libxml2 directly. This allowed us to avoid the overhead of converting from libxml2’s internal data structures to NSDictionaries, NSArrays and NSStrings, and the additional overhead of subsequently wrapping those data structures in Swift objects.
Creating a custom string
The second and most radical optimization was to eschew Swift’s own string data structure in favor of a simpler replacement. Swift’s String gives users many features seemingly for free, but that turned out to be precisely the problem for us: we didn’t need those features and they were quite costly. For example, there is no need to ensure that a parser that only handles ASCII text correctly deals with all the complexities of Unicode, and we got significant savings from simply ignoring these complexities.
By combining this custom ASCII string implementation with our use of libxml2, we also avoid unnecessary memory allocations. We always transform these unsafe strings from the XML into data structures such as paths and colors, so this code is safe, provided we uphold the invariant that an XML string is never allowed to “escape” and outlive parsing. This also allows us to take advantage of existing, highly optimized routines provided by iOS for converting C style strings into doubles, such as strtod. At the time Cyborg was written, Swift Strings used UTF-16, so getting an UnsafePointer<UInt8> to provide to these functions caused the String to allocate a new buffer each time, greatly decreasing performance.
Minimizing unnecessary string creation
The final optimization also relates to minimizing our use of Swift strings. Cyborg’s path parser uses parser combinators, which are more readable and reusable than a regular expression. In a parser combinator, parsers are built out of multiple primitives, such as “one or more of” or “a number.” Because the SVG path spec is quite liberal in what it considers valid, parsers whose success is not required for the overall parser to succeed frequently fail. For example, the SVG path spec states that a horizontal line is represented by the letter “h”, followed by any number of doubles, provided that that number is a multiple of two. This can be represented by the parser:
oneOrMore(sequence(whiteSpaceOrCommas(), double(), whiteSpaceOrCommas() double())))
Even in a successful parse, sub-parsers of this parser will fail at least four times: each white space or comma parser will fail when it hits the beginning of a number, then when the path data ends or the next command begins, the white space parser will fail a final time. Depending on how whiteSpaceOrCommas is implemented, its own subparsers may fail dozens more times.
Originally, Cyborg produced an error message like the following when a command failed:
return “.error(“Did not find the literal (text) at (lastIndex)”)”
However, Swift Strings can be expensive to allocate and construct in a tight loop, especially when constructing them involves string interpolation or appending. By switching to an enum representation, and only constructing a String when an error was to be presented to the user, parsing time was cut by over a third.
These optimizations have made Cyborg better suited to our needs, and they should scale to all but the most illustration-heavy applications.