Designing scalable Compose APIs

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[MUSIC PLAYING] SIMONA MILANOVIC: Hi, I'm Simona. Welcome to this session on designing scalable Compose APIs. The basic building block in Jetpack Compose is the composable function. So when building UI, you write many functions, and functions that call other functions. And this makes you an API developer. Whether you're developing on your own in bigger teams and apps or for external libraries, it's important to establish guidelines for writing high-quality Compose code that is more scalable, making it easier to evolve with minimum friction, more consistent across Compose ecosystem, making it more intuitive and following known patterns, and that guides others towards better practices by setting the right expectations and encouraging good designs. We will cover best practices and guidelines for developing idiomatic Compose APIs based on our own experience of creating the Compose libraries. This comes in three parts-- how to think about and plan for your components, how to leverage Kotlin and naming conventions, and define a solid structure of your API, and, finally, how to verify and maintain these APIs. The guidelines covered in this talk should be considered as strict rules for Compose framework contributions. For library development, it is recommended to follow these guidelines as much as possible to meet the expectations of your API users. If you're building an app, consider these guidelines as recommendations. But also consider what's best suited for your app and team requirements. Let's go through how we would decide to create a new API using the example of a FilterChip component. If you get an idea for a new component, start by asking if this API is meant to solve a single problem. We needed a component that would represent a concise option for the user from a limited selection. So we came up with a FilterChip component to solve this problem-- one component, one problem, solved in one place. This ensures an API is use case-based, making it easy and clear to use. If you need the FilterChip to do more, like representing suggestions or quick actions, this is a signal that these additional problems should be solved in a different place, which leads to the next design question. We quickly found the need for more variations of FilterChip that have a similar UI, but also different purposes, representing generated suggestions, smart, quick actions, and concise user input. How do we approach this? Do we create more customization options on FilterChip so users can transform it completely? Or do we create variations of it as completely separate components? If your app designs have a consistent requirement for more opinionated but somewhat similar variations of a component, it's a good signal to build new components as layers on top. Layered, higher-level APIs like these are more constrained and provide specific behavior and defaults and fewer customization options. Layers are usually built on top of extracted lower-level components, like a generic chip, which define a common surface and expectations for all layers. Simpler, lower-level components like this should be more open for customizations. This means that when going from lower-level to higher-level APIs, there is an increase in opinionated behavior and reduction of open customizations. And that's how we opted for new layers of suggestion, assist, and input chips. Layering decisions are closely coupled with design requirements. So make an informed choice where to draw the line. While creating a filterable API like FilterChip might be justified, what about with ChipGroup, a layout that would rearrange chips in a selected orientation and apply some extra styling? Let's break this down. Arranging chips vertically or horizontally is easy, and you can already achieve that with existing column and row composables. The styling can be handled via applied modifiers and decorations. So there are no custom behaviors coming from the ChipGroup API. We can achieve everything using existing building blocks. This is a good signal we don't need a new API for this. A new API should justify its existence. It takes work to create, maintain, and learn how to use one. Choose between creating a new component or delegate to existing. Now that the FilterChip has been solidified as a concept, let's look at how best to structure it. Naming is a good place to start. Let's explain the different naming types in Compose. A composable function returning unit should be named using pascalcase and a noun, with or without prefixes. This is why FilterChip comes with a capital F, as it omits UI that can be added to the composition and returns unit by default. So it follows the naming rules for classes. This helps the element establish persistent identity across recompositions and reinforces the declarative model of describing UI with Compose. To differentiate between the unit returning types, for composables that return values, you should use the standard Kotlin conventions for function naming, starting with lowercase. Composable functions should either emit content into the composition or return a value, but not both. Any composable function that internally uses remember and returns a mutable object should be prefixed with remember to clearly signal its nature and potential of persisting through recompositions. Components without a prefix can represent basic components that are ready to use and somewhat decorated. Consider using a basic prefix for APIs that are bare bones with no decoration. This is a signal that can be wrapped with more decoration, as the API is not expected to be used as is. One level or layer above are the opinionated APIs, such as the FilterChip and InputChip, signaling that there are more stylistic variations. Prefer prefixes and names that are mostly derived from the component's use case or purpose instead of using generic company name, module, or feature prefixes. Composition locals provide global-like values scoped to a specific subtree of composition. To signal this, they should hold a descriptive name and use local as prefix. Your API is only as functional as the inputs it can access. Parameters are crucial to defining the API purpose and requirements. When thinking about your API structure, be explicit and descriptive. Explicit parameters make it easy to present the API, adjust it, preview, test, and use. Descriptive inputs ensure your API is readable from the start and easier for users to understand at a glance. Avoid implicit obscure inputs as much as possible via composition locals or similar mechanisms as these may get hard to track where the customization comes from. Instead, open the API up and use explicit parameters, which also make it easy for users to add more layers of customization. An essential Compose concept are modifiers and how they define composables they're paired with. Components are responsible for their own internal behavior and appearance, while modifiers are in charge of well modifying the external ones. Modifiers should be at the top of your mind when defining API parameters. Compose users already have expectations around where and how to use modifiers. So make sure you leverage that in your APIs. This is why every component that emits UI should have a modifier parameter that is-- of modifier type, so that any modifier can be passed; is the first optional parameter, so it can be set without a name parameter and has consistent positioning in the contract; has a default no-op value, so no functionality is lost when users provide their own modifiers appended to the existing chain; is the only parameter of type modifier, as one should be enough for a single component. The need for more could signal you should rethink other parameters or break your API up, which is also why this modifier should only be applied once to the root outermost layout in the component. If modifiers describe the composable behavior and appearance, you might ask, why do we need explicit parameters? How do we tell the difference? Every API has crucial core parts that represent its behavior and UI. These should be explicit parameters as an indication of the API main purpose. Parameters should also add behavior or decoration that cannot otherwise be added via modifier. If, however, there's a noncore modifier-supported customization, then you can use a modifier. When defining the order of parameters, think about the API readability and ease of use. Its signature is an implicit user instruction for its purpose and customization options. Consider listing the API parameters in the following order-- required parameter first, indicating the main purpose of your API, set without default values so they can be used without named parameters. Then comes the modifier, making the API customizable and aligning it with Compose expectations, then the optional parameters, with default values that can be overridden by the user, or not, requiring the usage of named parameters. Additionally, you should group parameters that are semantically similar, like putting textual inputs together, click events, styling, et cetera. A trailing composable lambda named content, representing the nested slot content of the API if it requires one-- this is a common concept we'll cover later. Using default parameter values and nullability to signal absence of value can be a meaningful API description. So choose with care. There's a difference between a default value, an empty value, and an absent one. Nullable parameters hint that this API feature is available but might not be required at all for your use case. The choice is yours. But a null value is not the same as an empty one. Empty defaults signal. This feature is required and can be used with an empty value, or it can be overridden with a more concrete one, based on the use case. All other default values should be nonnull, meaningful, and clear to the user. They need to be publicly available so the component gives the same consistent result in any place. A subset of your parameters will most likely target styling and customization options. A good practice is to provide default values for these parameters so the API is independent and can be used as is. If the default styling values are short and predictable, it might be enough to keep them as simple inline constants. However, some APIs, like the chip, can have a long list of customizations, different shapes, colors and borders between enable, disabled, selected, et cetera. If you find that your API requires a lot of default values of similar type, you could use component defaults to map them externally in a single place. Ensure these objects are publicly available so the component can be used anywhere, like adding them to your theming. Styling can be conditionally set based on different states, like how the chip sets a different background color based on the selected state. Handling conditions like this can also be delegated to the defaults object. Another Compose concept which you've likely used a lot are slot parameters, or slot content. These are composable lambda parameters that specify an open space inside the parent component to fill with child ones. For chips, we want the user to be able to insert a text, image, icon, or any other content. For a single slot, position it as the last content parameter of a composable so it ensures consistency across Compose and that it could be used as a trailing lambda. If your API requires multiple slots, like the chip icon and label, you can provide multiple parameters of such type. By using slots, the chip doesn't care what's being passed as its nested content. It focuses only on handling its main responsibilities, like the choice selection, click handling, et cetera, giving more flexibility to the user. This also simplifies the main API contract by taking only the information needed for it and delegating everything else to the slots. The chip API now has a relevant name, carefully defined inputs and styling, and nested content. But we also need a mechanism to manage the changes happening inside and outside the component. This is where state comes in. We need the chip composable to have an enabled and disabled state, a property that could change frequently and impact the UI. Changes in composables can be handled in two ways-- statefully, with the composable owning and handling its own state, via APIs like remember or mutableStateOf, or statelessly, where you rely on the composable being called and recomposed with different inputs and rendered in different state. Wherever possible, prefer the stateless composables that hold no state of their own but instead accept it as input and are controlled by the caller. This makes your API more reusable and testable, as the caller is dictating the changes, and your component just follows. Extracting the state control like this is called state hoisting, a common Compose practice. The enabled state is now hoisted and outside of the API control, but the chip still needs a way of handling clicks and signaling it to the caller. Events such as onClick should be accepted as lambda parameters and passed accordingly. Extracting events like this ensures your API is more reusable, testable, and previewable. But avoid passing state mutable or immutable as a direct parameter of an API. Passing state like this means there could be multiple owners of it, both the caller and the composable, which complicates control, creates multiple sources of truth. Instead, prefer explicit inputs. Structuring your API is a big part of the design process. But we still have to ensure it satisfies other important requirements, like accessibility and testing, that it has the right documentation, and backward compatibility. We want the API to be set up to be solid and reliable long-term. To make your API supportive of different users with different needs, ensure it is able to reliably and logically transform what's shown on the screen to a more fitting format. Compose uses semantic properties to pass information to accessibility services. Lower-level APIs like clickable modifier, or composables like image and text, have a predefined way of handling this. They either provide good defaults or explicitly ask for information. When creating higher-level APIs, you might need to specify and request more information to understand how to describe UI to the user. If your API is required to have an explicit semantic set, you should request a nonoptional accessibility parameter. A good example is the image composable that has a required parameter, contentDescription, to signal what is necessary. To apply this to the chip, you could specify a contentDescription parameter and apply it via the modifier semantics to the root component. When designing APIs, it's important to make an informed decision on what to build into the implementation versus what to ask from the user. Keep in mind that users can also provide their own modifier semantics and try to override the default setup. Aside from the explicit parameters, you could also rely on slotted content semantics as more logical choice. APIs with slots don't necessarily need to set the text for accessibility services to read. Where it is contextually appropriate, the slotted content semantics, like icons, content description, or text from a text composable, can be merged into the parent semantics. This is called semantics merging for accessibility and is often used in Compose. To have the chip merge all of its children semantics into one, you can set a modifier semantics with mergeDescendants as true. This will force children descendants to collect and pass the data to your component to be treated as a single entity. A rule of thumb here is that simple components typically require one to three semantics, whereas more complex components require a richer set of semantics to function correctly with screen readers. When developing a new component, you can compare your API to an existing composable. Find the one that is most alike, and copy its semantics implementation to start with. And then continue fine-tuning it according to your own use case. When creating a new API, you should think about how it will be used for building UI as well as for testing UI. Checking how testable it is in isolation is a good indicator of a well-crafted API, which also makes it more testable as part of a larger composite component. Let's look at ways to maximize API testability. Does the component require platform-specific resources to work? For example, does it need a specific activity or context? If so, this could limit its testability because in tests, and also in previews, you might not have access to these resources. Here we require access to context to launch an activity as a click event. A better alternative would be to provide a click lambda as a parameter and delegate this responsibility to the caller. Implicit resources like context or composition locals can have impact on testability. So you should always aim to use alternatives through explicit parameters. However, if you need composition locals for styling purposes, make sure to put good defaults in place. When testing your API, you should be able to easily verify all possible states. For example, we should test all chip variations for selected and enabled states. Exposing these as explicit state parameters gives us access to change and verify in tests as we like. While selected and enabled states are simple to invoke, there are some more complex system-specific ones, like the pressed state. To make testing easier for these states, you can hoist and use interactionSource. This lets you emit and verify interactions at tests, such as the PressInteraction. Having the modifier parameter allows users to pass the testTag, which also helps identify the element in tests when other matchers are not specific enough to find it. For long-term stability and maintenance of your API, consider writing appropriate documentation. And if you're developing for external usage, ensure backward compatibility is supported. Documentation should follow [? KTDoc ?] guidelines, should clearly communicate the APIs capabilities and purpose, describe its parameters and expectations, as well as usage examples. For in-depth backward compatibility guidelines, refer to the official documentation. This brings us to one good-looking and well-structured chip API that is planned around solving a single problem, has different variations as opinionated components, chose its name carefully and descriptively, has the right explicit, mandatory, and optional parameters, well ordered with the right defaults, accepts slot as subcontent, is adept at handling state, supports accessibility and testing requirements, and is user-friendly with good documentation. Since in Compose, everyone is an API developer, we hope this equips you to create components that are more maintainable, scalable, consistent across Compose ecosystem, and that guide users towards better practices. Thanks so much for watching. [MUSIC PLAYING]
Info
Channel: Android Developers
Views: 13,738
Rating: undefined out of 5
Keywords: Android, pr_pr: Google I/O;, ct:Event - Technical Session;, ct:Stack - Mobile;
Id: JvbyGcqdWBA
Channel Id: undefined
Length: 19min 53sec (1193 seconds)
Published: Thu May 16 2024
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.