When I joined the Android team at PSPDFKit, one of the most exciting prospects was shifting away from building end user mobile applications and instead focusing on building a library used by developers. While my existing experience of building end user-based apps on the Android platform is relevant, there’s a big difference between building apps and building libraries, and this is the kind of challenge that was particularly interesting for me.
Recently, I was tasked with designing the public-facing APIs for our Jetpack Compose integration. While the current API consists of only a handful of functions and classes, a lot of thought was put into it to ensure the API surface can grow without feeling cumbersome or out of place. In this post, I’ll share some insight on how to design public APIs in general, with a focus on Jetpack Compose.
This post is written from the perspective of a library developer, but after working at PSPDFKit for a while, one thing I learned is that we’re always creating libraries — be it for public use or just for our own teams. So learning how to design APIs will allow you to create more pleasant code and avoid leaky abstractions, even if your target user is just a few people or yourself!
Now, without further ado, here are some of the things we learned.
Blend in with the Existing Landscape
This one is particularly interesting because it’s carried over from my time developing applications and not libraries. When building interfaces for any platform, using the OS-provided design language will make your app blend in well with the operating system, thus making your user feel at home, since they’ll already be familiar with the look and feel of every component. For example, use Material Design on Android, Human Interface Guidelines on Apple platforms, and Fluent Design on Windows.
This translates extremely well to designing library APIs! The idea is to make your library blend in with the rest of the system-provided frameworks, allowing the developer to intuitively figure out how to perform certain tasks because they’re already familiar with that ecosystem.
A good example of this is the
DocumentState class. This class is used to observe and control properties in our
DocumentView composable. We could’ve exposed many ways to create this class (a builder, a public constructor, a factory pattern). However, when you consider that this class is used in the UI and thus will likely need to be memoized every time it’s used, it becomes easy to spot that the best approach is to mimic what Google does with its own APIs and offer our users a
rememberDocumentState function that automatically takes care of memoizing and state restoration.
By following the pattern already in place, I know consumers of my API can make assumptions about how to wield it and use it effectively. This prevents them from having to read the documentation to figure out how something works, and the final result is a seamless experience.
Provide Smart Defaults and Leave Room for Expanding
Trying to predict how consumers of your library will use it is a great way of figuring out what needs to be exposed. As such, user stories are good for helping determine what your public API should expose. We can’t, however, assume that we know everything, so we should be smart about the use cases we can think of while still leaving room for customization and edge-case scenarios.
Again, I’ll refer to the
DocumentState class and its relationship with the
DocumentView composable. We expose two different
DocumentView composable functions: one that takes a
Uri that points to the document, and one that takes the full-blown
The former is for a simple use case: The user doesn’t care about configuring the details of the composable; they just want to display a document and call it a day. This is useful for scenarios like quickly prototyping or programming by wishful thinking, and even cases where the defaults are good enough for what you’re building. The latter option gives users with more specific needs all the power they need to customize the displayed document accordingly.
The trick here is to calibrate the defaults according to the needs of who’ll be consuming your library. First, never assume you’ve covered every single use case. And then, provide extension points in your API without compromising on its overall philosophy. This isn’t an easy task, but it’s a rewarding one!
Expose the Modifier
Chris Banes already explained this one much better than I possibly could, but since this blog post is about Jetpack Compose, it’s something I must mention. Exposing the modifier on every composable isn’t something that’s restricted to library developers, though. Everyone should do it, as it makes it easier to extend and consume composable functions.
Embrace the Volatility of the Ecosystem
While Jetpack Compose has been used in production for a while now, not all of its APIs are final. Things are much more stable now than they were during the earlier dev builds (where every update would break your app because half the composable names changed), but some APIs are still experimental and subject to change. Google makes it very explicit by marking such APIs with an annotation that forces consumers of the code to opt in, which in turn makes the user aware that such APIs should maybe be contained (and can potentially break in the future).
We went with a similar approach. We’ll improve our Jetpack Compose APIs over time, but since our APIs are still new and evolving, we decided to mark them with
@ExperimentalPSPDFKitApi. Again, this ties in with my first point: When you follow existing patterns already known by your user, it makes understanding your APIs much easier.
We’re currently researching new ways to make our Jetpack Compose support even better! This means exploring what to expose (and more importantly: how to expose it) to make the experience of displaying documents using Jetpack Compose so seamless that, if it weren’t for the package names, you wouldn’t even realize they’re not part of the Android SDK!