<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Pavlo Stavytskyi on Medium]]></title>
        <description><![CDATA[Stories by Pavlo Stavytskyi on Medium]]></description>
        <link>https://medium.com/@morfly?source=rss-2da4afadaa0c------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*rXoa4QDENtRT_2lWFWqQYw.jpeg</url>
            <title>Stories by Pavlo Stavytskyi on Medium</title>
            <link>https://medium.com/@morfly?source=rss-2da4afadaa0c------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 03:32:17 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@morfly/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Dynamic destinations with Jetpack Navigation 3]]></title>
            <link>https://morfly.medium.com/dynamic-destinations-with-jetpack-navigation-3-41b851eef933?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/41b851eef933</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[jetpack-compose]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Mon, 09 Jun 2025 07:02:49 GMT</pubDate>
            <atom:updated>2025-06-09T23:03:57.967Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CWAV4TYn9eV3mkqzo88mVg.png" /><figcaption>Image generated with Gemini 2.5 Flash</figcaption></figure><p><a href="https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html">Navigation 3</a> is the next major release of the navigation library for Jetpack Compose, providing an API that has been redesigned from the ground up to be more flexible and open.</p><p>Typically, in both Navigation 3 and its older version (which I’ll refer to as Navigation 2), destinations are declared statically. This means all possible destinations must be specified upfront, at the moment their respective navigation container — NavDisplay in Navigation 3 or NavHost in Navigation 2—is used in code.</p><p>But what if you don’t know in advance what navigation destinations your app will have? While this is a less common use case, sometimes destinations will be known only at the moment the actual navigation happens. In this case, we need the ability to register destinations dynamically, outside of the navigation graph declaration.</p><p>In Navigation 2, there was no easy way to accomplish this. Its relatively closed and inflexible API required to make complex workarounds (e.g. <a href="https://github.com/open-turo/nibel/blob/e66f29f9030fd6818d9cd2c15a78fc62c0885a23/nibel-runtime/src/main/kotlin/nibel/runtime/ComposeNavigationSpec.kt#L29-L58">1</a>, <a href="https://github.com/open-turo/nibel/blob/e66f29f9030fd6818d9cd2c15a78fc62c0885a23/nibel-runtime/src/main/kotlin/nibel/runtime/ExploredEntriesRegistry.kt#L5-L15">2</a>) to implement saveable dynamic destinations. In contrast, Navigation 3 offers a more open and adaptable API, making it well-suited for custom use cases that were not necessarily foreseen by its creators.</p><p>All the source code in this blog post could be found in <a href="https://github.com/Morfly/compose-nav3-dynamic-sample">compose-nav3-dynamic-sample</a> GitHub repository.</p><h3>Static destinations</h3><p>First, let’s look at a basic example of declaring navigation destinations statically within the NavDisplay composable, as intended by the creators of Navigation 3.</p><p>Let’s start by declaring a data class for each destination in the navigation graph (e.g. an app screen). They will also serve as containers for the arguments of each destination.</p><pre>@Serializable<br>data object DestinationA : NavKey<br><br>@Serializable<br>data class DestinationB(val number: Int) : NavKey<br><br>@Serializable<br>data class DestinationC(val number: Int) : NavKey</pre><p>Next, build a navigation graph using Navigation 3 API.</p><pre>// --(1)--<br>val backStack = rememberNavBackStack(DestinationA)<br><br>// --(2)--<br>NavDisplay(<br>  backStack = backStack,<br>  onBack = { backStack.removeLastOrNull() },<br>  // --(3)--<br>  entryProvider = { key -&gt;<br>    when (key) {<br>      is DestinationA -&gt; NavEntry(key) {<br>        ScreenA(onNextClick = { backStack.add(DestinationB(...)) })<br>      }<br>      is DestinationB -&gt; NavEntry(key) {<br>        ScreenB(...)<br>      }<br>      is DestinationC -&gt; NavEntry(key) {<br>        ScreenC(...)<br>      }<br>      else -&gt; error(&quot;Unknown route: $key&quot;)<br>    }<br>  }<br>)</pre><ol><li>Create an instance of a back stack.</li><li>Use NavDisplay composable to build a navigation graph.</li><li>Inside the entryProvider, declare all the possible destinations the user might navigate to.</li></ol><h3>Dynamic destinations</h3><p>Now, back to the topic of this blog post. Below is the example of how to use dynamic destinations with Navigation 3.</p><pre>// --(1)--<br>val backStack = rememberDynamicNavBackStack(DestinationA)<br><br>NavDisplay(<br>  backStack = backStack,<br>  modifier = modifier,<br>  onBack = { backStack.removeLastOrNull() },<br>  entryProvider = { key -&gt;<br>    when (key) {<br>      // --(2)--<br>      DestinationA -&gt; ScreenAEntry().value(backStack)<br>      else -&gt; {<br>        // --(3)--<br>        val entry = backStack.dynamicEntries[key]<br>        entry?.value(backStack) ?: error(&quot;Unknown route: $key&quot;)<br>      }<br>    }<br>  }<br>)</pre><ol><li>Create an instance of a DynamicNavBackStack that can handle destinations added dynamically. <em>It is a custom component and we will look into its implementation later.</em></li><li>We still need to declare one destination statically, so that NavDisplay knows what screen to display first. You may notice that here instead of a generic NavEntry we’re using a specific ScreenAEntry. <em>We will cover it later.</em></li><li>The rest of the destinations are extracted dynamically from the back stack instance.</li></ol><p>A key difference with dynamic destinations here is that they need to be wrapped with a custom entry implementation rather than a generic NavEntry. This is needed to make sure the destinations added dynamically can survive configuration changes. Because these destinations are not declared inside NavDisplay, when the UI is being recreated, it would not know where to find all the composables added dynamically.</p><pre>// --(1)--<br>@Serializable<br>class ScreenBEntry(<br>  // --(2)--<br>  override val key: DestinationB<br>) : DynamicNavEntry {<br><br>  // --(3)--<br>  override fun value(backStack: DynamicNavBackStack) = NavEntry&lt;NavKey&gt;(<br>    key = key,<br>    content = {<br>      ScreenB(<br>        number = key.number,<br>        onNext = {<br>          val screenCKey = DestinationC(number = key.number + 1)<br>          // --(4)--<br>          if (screenCKey !in backStack.dynamicEntries) {<br>            backStack.newEntry(ScreenCEntry(screenCKey))<br>          }<br>          // --(5)--<br>          backStack += screenCKey<br>        }<br>      )<br>    }<br>  )<br>}</pre><ol><li>As mentioned above, we’re creating an entry class the main purpose of which is to serve as an entry point to the composable UI of the corresponding navigation destination.</li><li>We map this entry class to the NavKey type that represents the destination and holds its arguments.</li><li>Each entry implements a function that contains the composable UI code of the destination and wraps it in a NavEntry instance to be supplied to a NavDisplay.</li><li>If this destination is not registered yet — we do that using a newEntry call.</li><li>Navigate to the next screen by appending its navigation key to the back stack.</li></ol><p>You may have noticed the ScreenBEntry implements DynamicNavEntry interface. Here is how its declaration looks like.</p><pre>interface DynamicNavEntry {<br>  val key: NavKey<br>  fun value(backStack: DynamicNavBackStack): NavEntry&lt;NavKey&gt;<br>}</pre><p>Finally, let’s have a closer look at the DynamicNavBackStack implementation.</p><pre>// --(1)--<br>class DynamicNavBackStack(<br>  private val internalBackStack: NavBackStack,<br>  private val entries: MutableMap&lt;NavKey, DynamicNavEntry&gt; = mutableMapOf()<br>) : MutableList&lt;NavKey&gt; by internalBackStack {<br><br>  // --(2)--<br>  val dynamicEntries: Map&lt;NavKey, DynamicNavEntry&gt;<br>    get() = entries<br><br>  // --(3)--<br>  fun newEntry(entry: DynamicNavEntry) {<br>    entries[entry.key] = entry<br>  }<br>}</pre><ol><li>The DynamicNavBackStack type actually implements MutableList interface and we’re delegating the implementation to the original NavBackStack provided by Navigation 3 as we don’t need much customization here.</li><li>We expose all the dynamically added destinations (or navigation entries which is the same in this context).</li><li>The newEntry call is what allows us to dynamically register navigation destinations.</li></ol><p>All that’s left is to make sure the DynamicNavBackStack is saveable, so it can survive configuration changes.</p><pre>// --(1)--<br>@Composable<br>fun &lt;T : NavKey&gt; rememberDynamicNavBackStack(<br>  vararg elements: T<br>): DynamicNavBackStack {<br>  val backStack = rememberNavBackStack(*elements)<br>  return rememberSaveable(saver = DynamicNavBackStackSaver(backStack)) {<br>    DynamicNavBackStack(backStack)<br>  }<br>}<br><br>// --(2)--<br>fun DynamicNavBackStackSaver(<br>  internalBackStack: NavBackStack<br>): Saver&lt;DynamicNavBackStack, *&gt; = Saver(<br>  save = { it.dynamicEntries.toMutableMap() },<br>  restore = { DynamicNavBackStack(internalBackStack, it) },<br>)</pre><ol><li>We create a simple remember function that helps to instantiate a DynamicNavBackStack.</li><li>Implement a Saver to make sure all destinations we added dynamically could survive configuration changes. That is why all the custom entry classes must be @Serializable as we’ve seen earlier.</li></ol><h3>Why dynamic destinations?</h3><p>The official documentation and samples for Navigation 2 and Navigation 3 demonstrate only statically declared destinations. While in most cases this would work just fine, there are scenarios where this won’t be sufficient.</p><p>To make this more specific, I’ll use an example from a project I worked on some time ago. The goal was to adopt Jetpack Compose in an application that relied heavily on Fragments, which required a seamless migration path. You can learn more about it in <a href="https://medium.com/turo-engineering/designing-jetpack-compose-architecture-for-a-gradual-transition-from-fragments-on-android-b11ee5f19ba8">this</a> and <a href="https://medium.com/turo-engineering/introducing-nibel-a-navigation-library-for-adopting-jetpack-compose-in-fragment-based-apps-541c7b2f3f84">this</a> blog posts.</p><p>For that project, a navigation framework was built to serve as an abstraction layer. Under the hood, it automatically decided whether to use Fragment Transactions or Compose Navigation, depending on the destination screen type. The key was that the Compose Navigation API was never exposed to developers. It was treated as an implementation detail so that it could always be replaced with another solution for navigating between composables in the future.</p><p>In more general terms, dynamic destinations are ideal for situations where you want to avoid or limit a direct dependency on the Navigation library’s API. This approach allows you to build an abstraction layer, leveraging the library’s power “under the hood” while controlling how its API is exposed — either by hiding it completely as an implementation detail or by carefully limiting its direct use.</p><h3>Sample app</h3><p>The complete source code from this blog post is available in a sample Android app project on GitHub.</p><p><a href="https://github.com/Morfly/compose-nav3-dynamic-sample">GitHub - Morfly/compose-nav3-dynamic-sample</a></p><h4>References</h4><ul><li><a href="https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html">Announcing Jetpack Navigation 3 — android-developers.googleblog.com</a></li><li><a href="https://github.com/android/nav3-recipes">android/nav3-recipes — github.com</a></li><li><a href="https://medium.com/turo-engineering/designing-jetpack-compose-architecture-for-a-gradual-transition-from-fragments-on-android-b11ee5f19ba8">Designing Jetpack Compose architecture for a gradual transition from fragments on Android — medium.com</a></li><li><a href="https://medium.com/turo-engineering/introducing-nibel-a-navigation-library-for-adopting-jetpack-compose-in-fragment-based-apps-541c7b2f3f84">Introducing Nibel: A Navigation Library for Adopting Jetpack Compose in Fragment-Based Apps — medium.com</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=41b851eef933" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Adjusting Compose Google Map While Bottom Sheet Moves]]></title>
            <link>https://medium.com/turo-engineering/adjusting-compose-google-map-while-bottom-sheet-moves-4a7465305137?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/4a7465305137</guid>
            <category><![CDATA[maps]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[bottomsheet]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Wed, 05 Jun 2024 21:43:48 GMT</pubDate>
            <atom:updated>2024-06-26T01:27:10.008Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IfpKhWHSio2zcIz5TtkEow.png" /><figcaption>Illustrations by <a href="https://www.linkedin.com/in/pavlo-stavytskyi-184313129/">Pavlo Stavytskyi</a></figcaption></figure><h4>To resize or not to resize…</h4><p>Maps and bottom sheets are a popular combination in Android apps because they work well together. Maps show you where things are, and bottom sheets give you extra details about those places. This saves screen space while keeping the map easy to see. Users can quickly switch between seeing the big picture on the map and getting more information from the bottom sheet.</p><p>When implementing a map and bottom sheet combination, there are a few considerations to keep in mind.</p><ul><li><strong>Map controls position. </strong>Ensure map UI controls (e.g. zoom buttons, Google logo, etc) move along with the bottom sheet to prevent them from being covered.</li><li><strong>Map camera position</strong>. Adjust the camera focus when the map’s visible center changes due to bottom sheet movement. This ensures the previous center point remains centered after the bottom sheet’s state changes.</li></ul><p>You might be wondering how to implement this functionality. Should the map be resized alongside the bottom sheet, as the title suggests, or is there a more efficient approach?</p><p>In a <a href="https://medium.com/@morfly/customizable-bottom-sheet-with-flexible-configuration-for-compose-36953b10b758">previous blog post</a>, I detailed a custom bottom sheet implementation and how it overcomes limitations found in the official Material 3 version. At Turo, we utilize this enhanced bottom sheet to streamline the parking location experience for both hosts and guests at airport locations.</p><p>In this story, I’ll focus on the specifics of implementing interactions between maps and bottom sheets, particularly focusing on Google Maps.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/340/1*KouakjXCPZSEA8pdz8YGuA.gif" /><figcaption>UI design by <a href="https://www.linkedin.com/in/irisyfzhao/">Yifan Zhao</a></figcaption></figure><p>You can find the sample source code in the <a href="https://github.com/Morfly/advanced-bottomsheet-compose/blob/76b87460bd29f1ede2181a94410ad51742330743/androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/CustomFinalizedDemoScreen.kt">advanced-bottomsheet-compose</a> repository on GitHub.</p><h3>Map controls position</h3><p>When the bottom sheet moves and changes state, it’s crucial to adjust the Google Map’s UI overlay components (e.g., zoom buttons, Google logo) to prevent them from being obscured. This ensures compliance with Google Maps’ terms of service, which require the logo to remain visible. In the demo above, the Google logo is part of the map, so it moves along with the rest of the map content.</p><p>Let’s explore how to implement this behavior in practice. First, I’ll demonstrate how to achieve this using the standard Material 3 bottom sheet. Then, you’ll see a simpler approach using the advanced bottom sheet I described in the <a href="https://medium.com/@morfly/customizable-bottom-sheet-with-flexible-configuration-for-compose-36953b10b758">previous blog post</a>.</p><h4>Original Material 3 bottom sheet</h4><p>Let’s start with the Material 3 bottom sheet. First, initialize SheetState and BottomSheetScaffoldState.</p><pre>val sheetState = rememberStandardBottomSheetState(<br>  initialValue = SheetValue.PartiallyExpanded<br>)<br>val scaffoldState = rememberBottomSheetScaffoldState(sheetState)</pre><p>Then create a composable hierarchy including BottomSheetScaffold and GoogleMap.</p><pre>val density = LocalDensity.current<br><br>// --(1)--<br>BoxWithConstraints {<br>  val layoutHeightPx = constraints.maxHeight<br><br>  // --(2)--<br>  val sheetHeight by remember(layoutHeightPx) {<br>    derivedStateOf {<br>      val sheetVisibleHeightPx = layoutHeightPx - sheetState.requireOffset()<br>      with(density) { sheetVisibleHeightPx.roundToInt().toDp() }<br>    }<br>  }<br><br>  // --(3)--<br>  val paddingValues by remember {<br>    derivedStateOf { PaddingValues(bottom = sheetHeight) }<br>  }<br><br>  BottomSheetScaffold(<br>    scaffoldState = scaffoldState,<br>    sheetContent = {<br>      BottomSheetContent()<br>    },<br>    content = {<br>      val camera = rememberCameraPositionState {<br>        position = CameraPosition.fromLatLngZoom(LatLng(...), 13f)<br>      }<br>      // --(4)--<br>      GoogleMap(<br>        modifier = modifier.fillMaxSize(),<br>        cameraPositionState = camera,<br>        contentPadding = paddingValues<br>      )<br>    }<br>  )<br>}</pre><ol><li>We wrap everything with BoxWithConstraints so that we can measure the layout height of a screen.</li><li>Now for the crucial part — calculating the visible height of the bottom sheet. By wrapping this calculation in derivedStateOf, the value will update in real-time whenever the bottom sheet&#39;s offset changes.</li><li>To prevent the bottom sheet from covering the map UI controls, we need to add padding to the bottom of the map equal to the visible height of the bottom sheet. We achieve this by wrapping the calculated height value within PaddingValues, which is the required format for the GoogleMap composable.</li><li>Lastly, apply the calculated bottom padding to the GoogleMap composable using its contentPadding parameter.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KHCSp5kxw5LkoFT7eV0WhA.png" /><figcaption>UI design by <a href="https://www.linkedin.com/in/irisyfzhao/">Yifan Zhao</a></figcaption></figure><p>In the image on the right, the bottom sheet is invisible for better demonstration purposes. Notice that when padding is applied to a GoogleMap, it affects only the UI overlay, not the entire map view. This is beneficial for performance, as the padding frequently changes during bottom sheet dragging. By repositioning only the UI overlay, we avoid the costly operation of resizing the entire map.</p><h4>Advanced bottom sheet</h4><p>In the <a href="https://medium.com/@morfly/customizable-bottom-sheet-with-flexible-configuration-for-compose-36953b10b758">previous blog post</a>, you’ll find an advanced bottom sheet implementation that offers a more flexible API and overcomes the limitations of the original Material 3 bottom sheet. It also simplifies the retrieval of the visible height.</p><p>To begin, initialize both a BottomSheetState and a BottomSheetScaffoldState.</p><pre>enum class SheetValue { PartiallyExpanded, Expanded }<br><br>val sheetState = rememberBottomSheetState(<br>  initialValue = SheetValue.PartiallyExpanded,<br>  defineValues = {<br>    SheetValue.Collapsed at height(100.dp)<br>    SheetValue.PartiallyExpanded at offset(percent = 60)<br>  }<br>)<br>val scaffoldState = rememberBottomSheetScaffoldState(sheetState)</pre><p>In this case, you can directly retrieve requireSheetVisibleHeightDp from the BottomSheetState instead of using BoxWithConstraints.</p><pre>// --(1)--<br>val sheetHeight by remember {<br>  derivedStateOf { sheetState.requireSheetVisibleHeightDp() }<br>}<br><br>// --(2)--<br>val paddingValues by remember {<br>  derivedStateOf { PaddingValues(bottom = sheetHeight) }<br>}<br><br>BottomSheetScaffold(<br>  scaffoldState = scaffoldState,<br>  sheetContent = {<br>    BottomSheetContent()<br>  },<br>  content = {<br>    val camera = rememberCameraPositionState {<br>      position = CameraPosition.fromLatLngZoom(LatLng(...), 13f)<br>    }<br>    // --(3)--<br>    GoogleMap(<br>      modifier = modifier.fillMaxSize(),<br>      cameraPositionState = camera,<br>      contentPadding = paddingValues<br>    )<br>  }<br>)</pre><ol><li>Unlike the original Material 3 bottom sheet, you can directly obtain the visible height from the BottomSheetState. Wrapping this value in derivedStateOf ensures that sheetHeight stays updated as the bottom sheet is dragged.</li><li>Next, we encapsulate the calculated bottom padding value within PaddingValues, which is the required format for the GoogleMap composable.</li><li>Lastly, we apply the calculated bottom padding to the GoogleMap composable.</li></ol><h3>Map camera position</h3><p>Another feature you might want to implement is automatically refocusing the map’s camera to the center after the bottom sheet is dragged. Here is how it looks.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/340/1*KouakjXCPZSEA8pdz8YGuA.gif" /><figcaption>UI design by <a href="https://www.linkedin.com/in/irisyfzhao/">Yifan Zhao</a></figcaption></figure><p>We’ll build upon the code from the previous section, omitting a few details for brevity. Assume these omitted parts are implemented exactly as described earlier.</p><pre>val sheetState = ...<br>val scaffoldState = ...<br>val paddingValues = ...<br><br>// --(1)--<br>val isBottomSheetMoving by remember {<br>  derivedStateOf { sheetState.currentValue != sheetState.targetValue }<br>}<br><br>BottomSheetScaffold(<br>  scaffoldState = scaffoldState,<br>  sheetContent = {<br>    BottomSheetContent()<br>  },<br>  content = {<br>    // --(2)--<br>    val camera = rememberCameraPositionState {<br>      position = CameraPosition.fromLatLngZoom(LatLng(...), 13f)<br>    }<br><br>    // --(3)--<br>    AdjustedCameraPositionEffect(<br>        camera = camera,<br>        isBottomSheetMoving = isBottomSheetMoving,<br>        bottomPadding = paddingValues.calculateBottomPadding(),<br>    )<br><br>    GoogleMap(<br>      modifier = modifier.fillMaxSize(),<br>      cameraPositionState = camera,<br>      contentPadding = paddingValues<br>    )<br>  }<br>)</pre><ol><li>To determine if the bottom sheet is currently being dragged, we can compare its target state with its current state. If they differ, it indicates that the bottom sheet is in motion. Utilizing derivedStateOf guarantees real-time updates of this value, allowing us to track the bottom sheet&#39;s movement accurately</li><li>Next, initialize a CameraPositionState instance to adjust the camera focus later.</li><li>Finally, let’s dive into the core logic. The AdjustedCameraPositionEffect is a custom composable created for demonstration purposes, and its code will be explained further in this post.</li></ol><p>The CameraPositionState instance relies on a target location that is at the center of the camera view. When you update the bottom padding of the map, the CameraPositionState will adjust to the new location as if the bottom part of the map does not exist. Because this new location is shifted due to the bottom sheet movement, we need to revert to the previous target location.</p><p>Therefore, the core idea behind the camera updates is simple. Before the bottom sheet starts moving, we check the camera’s target location. Once the bottom sheet has finished moving, we update the camera back to the previous location, restoring the original center.</p><pre>@Composable<br>fun AdjustedCameraPositionEffect(<br>  camera: CameraPositionState,<br>  isBottomSheetMoving: Boolean,<br>  bottomPadding: Dp,<br>) {<br>  val density = LocalDensity.current<br><br>  // --(1)--<br>  var cameraLocation by remember { mutableStateOf(camera.position.target) }<br>  LaunchedEffect(camera.isMoving, camera.cameraMoveStartedReason) {<br>    if (!camera.isMoving &amp;&amp; camera.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {<br>      cameraLocation = camera.position.target<br>    }<br>  }<br><br>  // --(2)--<br>  var isCameraInitialized by remember { mutableStateOf(false) }<br>  LaunchedEffect(isBottomSheetMoving) {<br>    if (isBottomSheetMoving) return@LaunchedEffect<br><br>    if (!isCameraInitialized) {<br>      // --(3)--<br>      isCameraInitialized = true<br>      val verticalShiftPx = with(density) { bottomPadding.toPx() / 2 }<br>      val update = CameraUpdateFactory.scrollBy(0f, verticalShiftPx)<br>      camera.animate(update)<br>    } else {<br>      // --(4)--<br>      val update = CameraUpdateFactory.newLatLng(cameraLocation)<br>      camera.animate(update)<br>    }<br>  }<br>}</pre><ol><li>This precondition ensures we track the latest location on the map, which may have been changed by the user’s manual gestures. This is independent of any bottom sheet movement and simply helps the camera stay updated with the user’s chosen focus point.</li><li>This is the main block containing the logic. Every time the bottom sheet stops moving, we execute the camera adjustment logic detailed below.</li><li>First, we need to consider a workaround for a specific case when the GoogleMap composable is being initialized. At this stage, the camera focus will be set as if there is no bottom padding, even if you set it. To address this, we need to manually adjust the camera to account for the padding and ensure the target location is correctly positioned.</li><li>Finally, for the general case. After the bottom sheet moves, we animate the camera back to the last target location from before the bottom sheet started moving.</li></ol><p>You can refer to the illustration for a visual representation of what was described above.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XFhHa-2h5Mi5dvxGhd7eNg.png" /></figure><p>On a side note, if you know the height of the smallest bottom sheet state, you can set it as a static minimum bottom padding for the entire GoogleMap composable. This is represented with a blue dashed line in the diagram and helps to avoid stretching the map into areas of the screen that will never be visible to the user.</p><p>You can check the source code with full samples in the <a href="https://github.com/Morfly/advanced-bottomsheet-compose/blob/76b87460bd29f1ede2181a94410ad51742330743/androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/CustomFinalizedDemoScreen.kt">advanced-bottomsheet-compose</a> repository on GitHub.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4a7465305137" width="1" height="1" alt=""><hr><p><a href="https://medium.com/turo-engineering/adjusting-compose-google-map-while-bottom-sheet-moves-4a7465305137">Adjusting Compose Google Map While Bottom Sheet Moves</a> was originally published in <a href="https://medium.com/turo-engineering">Turo Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Advanced Bottom Sheet With Flexible Configuration for Compose]]></title>
            <link>https://medium.com/turo-engineering/customizable-bottom-sheet-with-flexible-configuration-for-compose-36953b10b758?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/36953b10b758</guid>
            <category><![CDATA[bottomsheet]]></category>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[android]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Wed, 05 Jun 2024 21:32:43 GMT</pubDate>
            <atom:updated>2024-06-06T03:51:04.384Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MuAFymAQF70MwxQ70aNsqw.png" /><figcaption>Illustrations by <a href="https://www.linkedin.com/in/pavlo-stavytskyi-184313129/">Pavlo Stavytskyi</a></figcaption></figure><h4>Using AnchoredDraggable and SubcomposeLayout</h4><p>Turo empowers users to rent cars for a wide variety of trips, from quick weekend getaways to epic cross-country adventures. Among such diverse journeys, those that originate at airport locations are particularly significant, representing approximately 36% of Turo’s total trip revenue.</p><p>Given the significant impact of airport trips, Turo has launched a major initiative to streamline airport pickups and returns, with a focus on enhancing the experience to make every step of the process seamless and stress-free. The feature is coming soon, so stay tuned for updates!</p><p>In the redesigned airport trip experience, bottom sheets play a crucial role in guiding Turo guests and hosts through the parking process. Although Google offers a <a href="https://m3.material.io/components/bottom-sheets/overview">Material 3 bottom sheet</a> implementation for Jetpack Compose on Android, we encountered limitations that prevented us from fully realizing our design vision.</p><ul><li><strong>More than 2 expanded states</strong>. We needed 3 expanded states which is not supported by the original bottom sheet implementation as it offers a maximum of 2.</li><li><strong>Dynamic state changes</strong>. We need to be able to dynamically change the number of bottom sheet states and their height while it is being used.</li></ul><p>In this blog post, I’ll demonstrate a custom bottom sheet built for Compose. While leveraging the foundation of the original Material 3 implementation, it enhances functionality and flexibility, addressing the constraints above. We’ll explore its versatile API and the inner workings, which rely on low-level Compose components like <a href="https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/AnchoredDraggableState">AnchoredDraggable</a> and <a href="https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2)">SubcomposeLayout</a>.</p><p>If you’d like to see the full source code from this post and use it in your project, check out the <a href="https://github.com/Morfly/advanced-bottomsheet-compose">advanced-bottomsheet-compose</a> repository on GitHub.</p><h3>Overview</h3><h4>More than 2 expanded states</h4><p>Below is an example of a bottom sheet with 3 states. We use it to display parking location details to Turo guests.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/340/1*4M1uEfw-4sLM-16BB4uBvw.gif" /><figcaption>UI design by <a href="https://www.linkedin.com/in/irisyfzhao/">Yifan Zhao</a></figcaption></figure><p>The API is very similar to the original <a href="https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#BottomSheetScaffold(kotlin.Function1,androidx.compose.ui.Modifier,androidx.compose.material3.BottomSheetScaffoldState,androidx.compose.ui.unit.Dp,androidx.compose.ui.unit.Dp,androidx.compose.ui.graphics.Shape,androidx.compose.ui.graphics.Color,androidx.compose.ui.graphics.Color,androidx.compose.ui.unit.Dp,androidx.compose.ui.unit.Dp,kotlin.Function0,kotlin.Boolean,kotlin.Function0,kotlin.Function1,androidx.compose.ui.graphics.Color,androidx.compose.ui.graphics.Color,kotlin.Function1)">BottomSheetScaffold</a> but adds more flexibility during the configuration. Notably, it lifts the restriction of a maximum of 2 expanded states.</p><p>First, we declare an enum that represents each state of a bottom sheet.</p><pre>enum class SheetValue { Collapsed, PartiallyExpanded, Expanded }</pre><blockquote>Bottom sheet <strong>state</strong> and <strong>value</strong> are used interchangeably in the context of this blog post.</blockquote><p>Then we use a rememberBottomSheetState function to map bottom sheet states to their positions on the screen.</p><pre>val sheetState = rememberBottomSheetState(<br>  initialValue = SheetValue.PartiallyExpanded,<br>  defineValues = {<br>    // Bottom sheet height is 100 dp.<br>    SheetValue.Collapsed at height(100.dp)<br>    // Bottom sheet offset is 60% meaning it takes 40% of the screen height.<br>    SheetValue.PartiallyExpanded at offset(percent = 60)<br>    // Bottom sheet wraps its content when expanded.<br>    SheetValue.Expanded at contentHeight<br>  }<br>)</pre><p>Each bottom sheet state can be configured either by declaring its <strong>height</strong> or setting an <strong>offset</strong> from the top of the screen. It is allowed to use <strong>pixels</strong>, <strong>dp</strong>, or <strong>percentages</strong> during the configuration.</p><pre>height(dp = 200.dp)<br>height(px = 550f)<br>height(percent = 45)<br><br>offset(dp = 200.dp)<br>offset(px = 550f)<br>offset(percent = 45)<br><br>contentHeight</pre><p>In addition, there is a special <strong>contentHeight</strong> value that allows the bottom sheet to wrap its content.</p><p>The final step is identical to the one used with the original BottomSheetScaffold.</p><pre>val scaffoldState = rememberBottomSheetScaffoldState(sheetState)<br><br>BottomSheetScaffold(<br>  scaffoldState = scaffoldState,<br>  sheetContent = {<br>    // Composable bottom sheet content.<br>  },<br>  content = {<br>    // Composable screen content.<br>  },<br>)</pre><h4>Adding or removing the bottom sheet states</h4><p>A more advanced use case involves dynamically reconfiguring the bottom sheet while the user interacts with it, such as adding or removing states while it’s being dragged. Consider the example below.</p><pre>// --(1)--<br>var isInitialState by remember { mutableStateOf(true) }<br><br>val state = rememberBottomSheetState(<br>  initialValue = SheetValue.PartiallyExpanded,<br>  defineValues = {<br>    SheetValue.Collapsed at height(200.dp)<br>    // --(2)--<br>    if (isInitialState) {<br>      SheetValue.PartiallyExpanded at height(percent = 50)<br>    }<br>    SheetValue.Expanded at contentHeight<br>  },<br>  confirmValueChange {<br>    if (isInitialState) {<br>      isInitialState = false<br>      // --(3)--<br>      refreshValues()<br>    }<br>    true<br>  }<br>)</pre><ol><li>We declare a mutable variable to determine when to include or exclude a specific bottom sheet state.</li><li>Then, add or skip a particular bottom sheet state based on the variable.</li><li>Finally, we call refreshValues to invoke defineValues lambda again and reconfigure the bottom sheet when needed. The refreshValues is a member function of a BottomSheetState.</li></ol><p>In the code snippet above, the bottom sheet initially has 3 states and is set to PartiallyExpanded, but only the Collapsed or Expanded states are available once dragged by the user.</p><h4><strong>Changing the height of existing states.</strong></h4><p>While Turo guests can use the bottom sheet to view the vehicle parking location, hosts would use the bottom sheet to configure it. Consider the example below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tOEW9BqbQbKJRLJ9e2xqxA.png" /><figcaption>UI design by <a href="https://www.linkedin.com/in/irisyfzhao/">Yifan Zhao</a></figcaption></figure><ul><li><strong>Images</strong> <strong>1</strong> and <strong>3</strong> show the same PartiallyExpanded bottom sheet state. In <strong>image</strong> <strong>1 </strong>the sheet occupies 40% of the screen while in <strong>image</strong> <strong>3</strong> it wraps the content height. The red banner in the UI is optional and may not always be present. Depending on the specific business logic, there can be at least 4 distinct states with varying heights.</li><li><strong>Image 2 </strong>illustrates the Expanded bottom sheet state, designed to be nearly full-screen with a slight offset at the top to provide a view of the map.</li></ul><p>Upon a screen state change, we call BottomSheetState.refreshValues, which invokes the defineValues lambda and provides the updated dimensions of the bottom sheet content. These updated dimensions allow the height of the PartiallyExpanded state to be adjusted.</p><h3>Implementation</h3><p>Now, let’s talk about the technical details. If your primary goal is to use the bottom sheet in your project, refer to <a href="https://github.com/Morfly/advanced-bottomsheet-compose">advanced-bottomsheet-compose</a> GitHub repository as it contains the source code and the instructions for integrating it in your project. If you’d like to understand how it works under the hood, feel free to continue reading.</p><p>The core concept behind any bottom sheet implementation is straightforward. We manipulate the <strong>offset</strong> of its layout from the top of the screen. The larger the offset, the less of the bottom sheet is visible, and vice versa. Its height remains unchanged, while the visible portion of the bottom sheet’s layout depends on the offset.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9pR7M-WHLjFZpIYZWtlbxA.png" /></figure><p>The bottom sheet’s offset changes as the user drags it. However, instead of dragging freely, it snaps to specific positions corresponding to the defined bottom sheet states.</p><h4>AnchoredDraggable</h4><p><a href="https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/AnchoredDraggableState">AnchoredDraggable</a> — is a low-level component for building draggable components with anchored states in Compose. This is precisely what we need to leverage for implementing the concepts described above.</p><p>The code for the bottom sheet is pretty straightforward and comprises several steps. First, declare an enum that represents the states of a bottom sheet.</p><pre>enum class SheetValue { Collapsed, PartiallyExpanded, Expanded }</pre><p>Initialize AnchoredDraggableState that uses SheetValue as its T.</p><pre>val state = remember {<br>  AnchoredDraggableState&lt;SheetValue&gt;(<br>    initialValue = SheetValue.PartiallyExpanded,<br>    positionalThreshold = { 0f },<br>    velocityThreshold = { 0f },<br>    animationSpec = SpringSpec(),<br>  )<br>}</pre><p>Finally, define a composable hierarchy that implements a bottom sheet in Compose.</p><pre>// --(1)--<br>BoxWithConstraints {<br>  val layoutHeight = constraints.maxHeight<br><br>  // --(2)--<br>  ScreenContent()<br><br>  Surface(<br>    modifier = Modifier<br>      .fillMaxWidth()<br>      // --(3)--<br>      .offset {<br>        val sheetOffsetY = state.requireOffset().toInt()<br>        IntOffset(x = 0, y = sheetOffsetY)<br>      }<br>      // --(4)--<br>      .anchoredDraggable(state, orientation = Orientation.Vertical)<br>      // --(5)--<br>      .onSizeChanged { sheetSize -&gt;<br>        val sheetHeight = sheetSize.height<br>        val newAnchors = DraggableAnchors {<br>          with(density) {<br>            // Bottom sheet height is 56 dp.<br>            SheetValue.Collapsed at (layoutHeight - 56.dp.toPx())<br>            // Offset is 60% meaning the bottom sheet takes 40% of the screen height.<br>            SheetValue.PartiallyExpanded at (layoutHeight * 0.6f)<br>            // Bottom sheet height is equal to the height of its content.<br>            // If the height of the content is bigger than the screen - fill the entire screen.<br>            SheetValue.Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat()<br>          }<br>        }<br>        state.updateAnchors(newAnchors, state.targetValue)<br>      },<br>  ) {<br>    // --(6)--<br>    BottomSheetContent()<br>  }<br>}</pre><p>Here is what’s going on in the code above.</p><ol><li>We wrap everything with BoxWithConstraints so that we can measure the layoutHeight of a screen. This will be handy when calculating the positions of bottom sheet states further.</li><li>Render the ScreenContent behind the bottom sheet.</li><li>Apply the current bottom sheet offset to its layout using the AnchoredDraggableState.</li><li>Enable bottom sheet dragging by using anchoredDraggable modifier. This will update the offset as soon as the bottom sheet is dragged.</li><li>The crucial step is configuring the bottom sheet states and applying them to the AnchoredDraggableState. We do this in onSizeChanged, which conveniently provides the bottom sheet layout&#39;s height, essential for subsequent calculations. Compose calls onSizeChanged both when the screen first opens and whenever the bottom sheet content&#39;s size changes.</li><li>Finally, we render the actual BottomSheetContent.</li></ol><p>This comprises most of the code required to implement a fully functional bottom sheet. The remaining step is to enable nested scrolling, which is detailed below. Everything else pertains to improvements for code organization and the public API.</p><h4>Nested scrolling</h4><p>If the content within your bottom sheet exceeds its height, you’ll need to implement nested scrolling. I won’t cover this in detail here, but we’re using a <a href="https://github.com/Morfly/advanced-bottomsheet-compose/blob/76b87460bd29f1ede2181a94410ad51742330743/compose-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/BottomSheetScaffold.kt#L347-L401">BottomSheetNestedScrollConnection</a>, which is a slightly modified version of the code from the original Material 3 bottom sheet implementation.</p><pre>// --(1)--<br>val scope = rememberCoroutineScope()<br><br>// --(2)--<br>val nestedScrollConnection = remember(state) {<br>  BottomSheetNestedScrollConnection(<br>    state = state,<br>    orientation = Orientation.Vertical,<br>    onFling = { velocity -&gt;<br>      scope.launch { state.settle(velocity) }<br>    }<br>  )<br>}<br><br>Surface(<br>  modifier = Modifier<br>    // --(3)--<br>    .nestedScroll(nestedScrollConnection)<br>    .offset {...}<br>    .anchoredDraggable(...)<br>    .onSizeChanged {...}<br>) {<br>  ...<br>}</pre><p>Enabling nested scrolling in the bottom sheet requires the following steps.</p><ol><li>Create a coroutine scope instance.</li><li>Instantiate a BottomSheetNestedScrollConnection. Use the coroutine scope to help the anchored draggable settle into specific bottom sheet states during nested scrolling</li><li>Apply the nestedScroll modifier to the bottom sheet layout.</li></ol><p>The code within the offset, anchoredDraggable, and onSizeChanged modifiers remains unchanged from the previous section.</p><h4>SubcomposeLayout</h4><p><a href="https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2)">SubcomposeLayout</a> — is a layout in Compose that allows for the measurement of its child elements before using that data to compose other children. This makes it particularly useful for handling dynamic content, where the size of elements might not be known beforehand.</p><p>To be completely honest, SubcomposeLayout is not strictly necessary for the bottom sheet’s functionality. You can likely implement it without this component. However, it offers advantages in terms of code organization and overall scalability</p><pre>@Composable<br>fun BottomSheetScaffold(<br>  state: AnchoredDraggableState&lt;SheetValue&gt;,<br>  sheetContent: @Composable () -&gt; Unit,<br>  modifier: Modifier = Modifier,<br>  content: @Composable () -&gt; Unit<br>) {<br>  // --(1)--<br>  SubcomposeLayout { constraints -&gt;<br>    val layoutWidth = constraints.maxWidth<br>    val layoutHeight = constraints.maxHeight<br><br>    // --(2)--<br>    val sheetPlaceable = subcompose(slotId = &quot;sheet&quot;) {<br>      Surface(<br>        modifier = Modifier<br>          .anchoredDraggable(...)<br>          .onSizeChanged {...}<br>          .nestedScroll(...)<br>      ) {<br>        sheetContent()<br>      }<br>    }[0].measure(constraints)<br><br>    val bodyPlaceable = subcompose(slotId = &quot;body&quot;) {<br>      Surface(modifier = modifier) {<br>        content()<br>      }<br>    }[0].measure(constraints)<br><br>    // --(3)--<br>    layout(layoutWidth, layoutHeight) {<br>      val sheetOffsetY = state.requireOffset().roundToInt()<br>      val sheetOffsetX = 0<br><br>      bodyPlaceable.placeRelative(x = 0, y = 0)<br>      sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)<br>    }<br>  }<br>}</pre><p>Here is how to implement a bottom sheet with a SubcomposeLayout.</p><ol><li>Wrap everything with a SubcomposeLayout composable.</li><li>We measure the sheet content and screen content in their respective subcompose slots. Although subcompose returns a list of Measurable elements, we are using only a single top-level composable within each subcompose call. Therefore, we measure only the first element in each list, accessed with the index [0].</li><li>Place composables using the latest offset. The lambda in the layout function will be called every time the bottom sheet offset changes.</li></ol><p>In the full implementation of a bottom sheet, the SubcomposeLayout manages the screen content, sheet content, top bar, and snackbar host. Using SubcomposeLayout in this scenario enables efficient measurement and placement of these 4 elements. The code for this implementation can be found <a href="https://github.com/Morfly/advanced-bottomsheet-compose/blob/76b87460bd29f1ede2181a94410ad51742330743/compose-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/BottomSheetScaffold.kt#L206-L265">here</a>.</p><h4>Organizing code</h4><p>The final step involves organizing the code into distinct composable functions internally to achieve better separation of concerns, improve scalability, and provide more customizable public properties. The API of the resulting composables is designed to closely resemble the original BottomSheetScaffold implementation and can be found <a href="https://github.com/Morfly/advanced-bottomsheet-compose/blob/76b87460bd29f1ede2181a94410ad51742330743/compose-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/BottomSheetScaffold.kt#L141">here</a>.</p><p>You can access the bottom sheet’s source code and a sample project demonstrating the steps outlined in this article in the <a href="https://github.com/Morfly/advanced-bottomsheet-compose">advanced-bottomsheet-compose</a> GitHub repository.</p><h4>Resources</h4><ul><li><a href="https://m3.material.io/components/bottom-sheets/overview">Bottom sheets — m3.material.io</a></li><li><a href="https://developer.android.com/develop/ui/compose/touch-input/pointer-input/migrate-swipeable">Migrate from Swipeable to AnchoredDraggable — developer.android.com</a></li><li><a href="https://medium.com/androiddevelopers/advanced-layout-concepts-eb08cf2a3c8">Advanced Layout concepts — medium.com/androiddevelopers</a></li><li><a href="https://developer.android.com/develop/ui/compose/phases">Jetpack Compose phases — developer.android.com</a></li><li><a href="https://fvilarino.medium.com/exploring-jetpack-compose-anchored-draggable-modifier-5fdb21a0c64c">Exploring Jetpack Compose Anchored Draggable Modifier — Francesc Vilarino Guell</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=36953b10b758" width="1" height="1" alt=""><hr><p><a href="https://medium.com/turo-engineering/customizable-bottom-sheet-with-flexible-configuration-for-compose-36953b10b758">Advanced Bottom Sheet With Flexible Configuration for Compose</a> was originally published in <a href="https://medium.com/turo-engineering">Turo Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Boosting Bazel Adoption on Android With Automation]]></title>
            <link>https://medium.com/turo-engineering/boosting-bazel-adoption-on-android-with-automation-6dc79d298628?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/6dc79d298628</guid>
            <category><![CDATA[gradle]]></category>
            <category><![CDATA[bazel]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[migration]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Tue, 12 Dec 2023 18:02:01 GMT</pubDate>
            <atom:updated>2024-05-27T19:40:01.522Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LIbCd-qz1ovcy-epimz3GQ.png" /><figcaption>Illustrations by <a href="https://www.linkedin.com/in/pavlo-stavytskyi-184313129/">Pavlo Stavytskyi</a></figcaption></figure><h4>Migrating Android apps from Gradle to Bazel</h4><p>In recent years, <a href="https://bazel.build/">Bazel</a> has become a compelling alternative for large Android codebases as a build tool of choice. However, due to its relative newness and non-default status on Android, the Bazel ecosystem is not as mature as Gradle’s, making the adoption process more challenging and the learning curve steeper.</p><p>There are many great resources out there that may persuade you to migrate your codebase to Bazel. In this story, I will share Turo’s experience of Bazel adoption and show you how to configure a robust automation platform once you’ve decided to give Bazel a shot.</p><h4>What does Bazel migration mean?</h4><p>The migration process to Bazel essentially entails creating a set of Bazel files for your project and placing them in the appropriate locations, based on the Gradle project structure. Ultimately, the effort unfolds in two key parts.</p><ol><li><strong>Determining how to build each component of the app with Bazel</strong>. This is particularly relevant to custom Gradle tasks or plugins that do not have open-source Bazel alternatives.</li><li><strong>Automating incremental adoption. </strong>For each build feature that necessitates the creation of custom Bazel rules, macros, etc, an automation tool is used to populate it with every related project module.</li></ol><h4>Why automation is needed?</h4><p>Automatically generating Bazel build scripts is notably more efficient than manual creation, especially when dealing with codebases that comprise hundreds or even thousands of modules. Yet, the benefits of automation go beyond merely addressing this challenge.</p><p>Bazel adoption is not an overnight process. Throughout this transition, the build configuration of your project will continuously evolve, with modules and dependencies being added or removed, etc. Using automation tooling allows you to keep Gradle as the source of truth for as long as necessary without putting undue stress on the engineering team.</p><p>Furthermore, it offers flexibility and room for experimentation. Operating two build systems side by side, at least during the migration phase, enables precise evaluation and comparison of their performance. This approach also allows for the gradual integration of Bazel into selected CI workflows, providing the option to transition incrementally while retaining the old build system as a reliable backup for the remaining workflows.</p><h4>Migration tooling overview</h4><p>At Turo, Bazel adoption is powered by two open-source tools, <a href="https://github.com/Morfly/airin">Airin</a> and <a href="https://github.com/Morfly/pendant">Pendant</a>. Airin, a Gradle plugin, scans the project structure, and Pendant is then utilized to generate Starlark scripts that replicate the same project configuration with Bazel.</p><p>You can streamline Bazel adoption for your codebase using Airin as well. To setup the automation tooling the Gradle plugin is applied in the root build.gradle.kts file.</p><pre>// root build.gradle.kts<br>plugins {<br>  id(&quot;io.morfly.airin.android&quot;) version &quot;x.y.z&quot;<br>}</pre><p>Then it is configured using airin extension as shown in the next example.</p><pre>// root build.gradle.kts<br>airin {<br>  targets += setOf(&quot;:app&quot;, &quot;:auth-feature&quot;)<br><br>  register&lt;AndroidLibraryModule&gt; {<br>    include&lt;JetpackComposeFeature&gt;()<br>    include&lt;HiltFeature&gt;()<br>    include&lt;ParcelizeFeature&gt;()<br>    ...<br>  }<br>  register&lt;JvmLibraryModule&gt;()<br><br>  register&lt;RootModule&gt; {<br>    include&lt;AndroidToolchainFeature&gt;()<br>  }<br>}</pre><p>If looking at the code snippet above, you’ll notice that the plugin configuration comprises several key components.</p><ul><li>targets — specifying the migration targets. These modules allow initiation of Bazel adoption not only for themselves but also for all their dependencies, including the transitive ones. A migrateToBazel Gradle task is configured for each module declared here.</li><li>register — registering types of project modules to be migrated to Bazel.</li><li>include — including build features these modules might optionally employ.</li></ul><p>The primary migration target in Android projects is usually the main app module, triggering the migration for every other module in the application.</p><pre>./gradlew app:migrateToBazel</pre><p>However, for incremental Bazel adoption, a partial migration is possible. For instance, one could start by migrating only the authentication feature and all its dependencies.</p><pre>./gradlew auth-feature:migrateToBazel</pre><p>Once invoked, the task generates a set of Bazel files for a migration target, all its dependencies and the root module. Consequently, all of them can be built with Bazel.</p><pre>bazelisk build //app</pre><p>In many scenarios, achieving a fully automatic migration from Gradle to Bazel may not be possible without additional work. This is particularly true if your project involves custom Gradle build logic, tasks, and plugins, or relies on components lacking open-source Bazel alternatives. In such cases, additional effort becomes inevitable for a successful transition.</p><p>Nevertheless, automation tooling remains crucial even in these situations. Every time you determine how to build the missing piece with Bazel, Airin provides an API to seamlessly incorporate the related custom scripts and rule sets into the automation pipeline.</p><p>Ultimately, the automation tool is agnostic to the specific contents of generated Bazel files. Its role is to provide a framework for efficient migration, allowing for either the use of predefined file configurations or full customization for more sophisticated use cases.</p><h3>Understanding the project structure</h3><p>A key aspect of modern Android codebases is their modular architecture. Despite the potential for hundreds or even thousands of modules in your project, they can all be categorized into a small number of groups or module types. In Android codebases, modules typically fall into, but are not limited to, the following types.</p><ul><li><strong>Android module </strong>— a feature or library module that leverages the Android SDK. It relies on com.android.library or com.android.application Gradle plugins.</li><li><strong>Java/Kotlin module — </strong>a library module that exclusively depends on JVM APIs. Typically, it employs Gradle plugins such as java or org.jetbrains.kotlin.jvm.</li><li><strong>Root module</strong> —a module that serves as the foundation for the project, encompassing project-wide configuration settings.</li></ul><p>The categorization into module types suggests that within each type, the build configuration for modules is nearly identical. However, individual modules within a category may still employ different sets of tools and technologies, leading to minor variations in the build configuration.</p><p>In other words, each module category is reinforced with a set of build features that may be optionally incorporated into each instance of the module.</p><ul><li><strong>Android module</strong><br><strong>◦</strong><em> Jetpack Compose feature<br></em><strong>◦</strong><em> Parcelize feature<br></em><strong>◦</strong><em> Hilt feature</em></li></ul><p>For example, certain Android modules might utilize Jetpack Compose, while others may employ features like Parcelize or Hilt, etc. Although these features are optional, their integration into module configurations does not significantly alter the build script structure.</p><h4>Automation tooling design</h4><p>The aforementioned concepts form the foundation of a component-based architecture, representing module types and build features as components. Think of components as lightweight plugins that seamlessly integrate into your Gradle project configuration, offering a framework to replicate it using Bazel.</p><p>A typical configuration for an Android project can be illustrated with the following diagram.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IZ7jINP2q1s_VLojnYd7XQ.png" /><figcaption>Component-based design</figcaption></figure><p>To describe it more formally, the configuration of a Gradle plugin for automated migration to Bazel comprises two main component types.</p><ul><li><strong>Module component — </strong>defines the Bazel files generated for each module type. It can optionally include a set of feature components.</li><li><strong>Feature component — </strong>contributes optional feature-related build configurations to the files generated by a related module component.</li></ul><p>Typically, each feature component is associated with a module component. However, <em>shared</em> feature components have an additional role, contributing to every <em>shared</em> module component. We will take a closer look at shared components later.</p><p>For now, let’s examine how the migration tool operates. The Gradle to Bazel migration flow is depicted in the diagram below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MmLDNMp-WkuhN3AqJVQfsw.png" /><figcaption>Gradle to Bazel migration flow</figcaption></figure><p>The migration process essentially involves 3 primary steps, performed for each module individually.</p><ol><li><strong>Pick</strong> <strong>a</strong> <strong>module component</strong> capable of migrating this specific module. Performed during the Gradle Configuration phase.</li><li><strong>Pick feature component(s)</strong> for corresponding build features applied to the module. Performed during the Gradle Configuration phase.</li><li><strong>Generate Bazel file(s)</strong> by utilizing the chosen components. Performed during the Gradle Execution phase.</li></ol><p>Only one module component can be selected for the module, while multiple feature components are allowed.</p><h4>Gradle task graph</h4><p>During configuration, the Gradle plugin registers a series of tasks for all the modules involved in Bazel migration. Each task, at this point, is already aware of the components it should invoke. The dependency graph of these Gradle tasks is illustrated below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RE2E5BgaGlI5La_9_d0Tfw.png" /><figcaption>Migration task graph</figcaption></figure><ul><li>migrateToBazel — is registered for migration targets. Invokes migration for all direct and transitive dependencies as well as the root module.</li><li>migrateProjectToBazel — is registered for every module that is a direct or a transitive dependency of a migration target. Invokes migration only for this particular module.</li><li>migrateRootToBazelFor*** — is registered for a root module and complements the migration of a specific migration target where *** is its name.</li></ul><p>As these tasks are executed, a corresponding set of components is invoked, leading to the generation of Bazel files.</p><p>The Airin Gradle plugin offers a set of ready-to-use components encompassing commonly used module types and build features. However, if additional flexibility and customization are needed, you can create custom components, as demonstrated in the following sections.</p><h3>Module components</h3><p>Every module component is an abstract class that extends the ModuleComponent base class and implements 2 functions, canProcess and onInvoke.</p><pre>abstract class AndroidLibraryModule : ModuleComponent {<br><br>  override fun canProcess(project: Project): Boolean {...}<br><br>  override fun ModuleContext.onInvoke(module: GradleModule) {...}<br>}</pre><p>The former is invoked during the Gradle Configuration phase and is aimed to filter Gradle modules to which this component is applicable.</p><p>The latter is invoked during the Gradle Execution phase and contains the main logic of the component the purpose of which is to generate Bazel files for the module.</p><p>The easiest way to determine the module type is by examining its applied plugins. For example, an Android library module in Gradle typically relies upon the com.android.library plugin.</p><pre>abstract class AndroidLibraryModule : ModuleComponent {<br><br>  override fun canProcess(project: Project): Boolean =<br>    project.plugins.hasPlugin(&quot;com.android.library&quot;)<br>}</pre><p>Now, it’s time to generate Bazel files. Here, we utilize a Kotlin DSL that enables the creation of various types of Starlark files in a declarative fashion.</p><pre>abstract class AndroidLibraryModule : ModuleComponent {<br><br>  override fun ModuleContext.onInvoke(module: GradleModule) { <br>    val file = BUILD.bazel {<br>        ...<br>    }<br>    generate(file)<br>  }<br>}</pre><p>Depending on the called builder function, a file with a corresponding name will be generated. For instance, BUILD, BUILD.bazel, WORKSPACE, WORKSPACE.bazel, *.bzl.</p><pre>val build = BUILD.bazel { ... }<br>val workspace = WORKSPACE { ... }<br>val mavenDependencies = &quot;maven_dependencies&quot;.bzl { ... }<br><br>generate(build, workspace)<br>generate(mavenDependencies, relativePath = &quot;third_party&quot;)</pre><p>Files are generated in the module directory or a subdirectory if a relative path is specified.</p><h4>Starlark code generation</h4><p>In each builder block, the contents of Bazel files are crafted using a Kotlin DSL that mirrors the syntax of a Starlark language. Essentially, you’re writing Kotlin code that looks like Starlark. The code generator is type-safe, meaning many errors would be caught by the Kotlin compiler before running the program.</p><pre>override fun ModuleContext.onInvoke(module: GradleModule) {<br>  val file = BUILD.bazel {<br>    load(&quot;@io_bazel_rules_kotlin//kotlin:android.bzl&quot;, &quot;kt_android_library&quot;)<br>  <br>    kt_android_library {<br>      name = module.name<br>      srcs = glob(&quot;src/main/**/*.kt&quot;)<br>      custom_package = module.androidMetadata?.packageName<br>      manifest = &quot;src/main/AndroidManifest.xml&quot;<br>      resource_files = glob(&quot;src/main/res/**&quot;)<br>    }<br>  }<br><br>  generate(file)<br>}</pre><p>When executed, it produces an abstract syntax tree similar to the one created by the original Starlark interpreter. This tree is subsequently leveraged to generate formatted Starlark code.</p><blockquote>You can find an in-depth overview of a declarative Starlark code generator in the talk at <a href="https://www.droidcon.com/2022/09/29/advanced-techniques-for-building-kotlin-dsls/">droidcon NYC 2022</a>.</blockquote><p>The Starlark code generator is available as an open-source library <a href="https://github.com/Morfly/pendant">Pendant</a> and is bundled as part of the Airin plugin.</p><h4>Dependencies</h4><p>A Bazel target can possess various types of dependencies, each represented by different function parameters.</p><pre># BUILD.bazel<br>kt_android_library(<br>  ...<br>  deps = [...],<br>  exports = [...],<br>  plugins = [...],<br>)</pre><p>A GradleModule instance provides dependencies mapped per configuration, represented with an argument name. To designate dependencies in the generated code, the `=` function (enclosed in backticks) is used to represent an argument passed to a function.</p><pre>kt_android_library {<br>  ...<br>  for ((config, deps) in module.dependencies) {<br>    config `=` deps.map { it.asBazelLabel().toString() }<br>  }<br>}</pre><p>As a result, the following Starlark code is generated.</p><pre># generated Bazel script<br>kt_android_library(<br>  ...<br>  plugins = [...],<br>  deps = [...],<br>  exports = [...],<br>)</pre><p>To properly map Gradle configurations to respective function arguments in Bazel, we make use of feature components.</p><h3>Feature components</h3><p>Every feature component is an abstract class that extends the FeatureComponent base class and implements 2 functions, canProcess and onInvoke.</p><pre>abstract class JetpackComposeFeature : FeatureComponent() {<br><br>  override fun canProcess(project: Project): Boolean {...}<br><br>  override fun FeatureContext.onInvoke(module: GradleModule) {...}<br>}</pre><p>The former is invoked during the Gradle Configuration phase and is aimed to filter Gradle modules to which this component is applicable.</p><p>The latter is invoked during the Gradle Execution phase and contains the main logic of the component. Its purpose is to modify Bazel files generated by a related module component as well as manage the dependencies of the module.</p><h4>Overriding dependencies</h4><p>When migrating Gradle modules to Bazel, an obvious task is to preserve a correct dependency graph including internal module dependencies and third-party artifacts. As it turns out, the same module might have a different set of dependencies in Gradle and Bazel.</p><p>For example, let’s consider <a href="https://github.com/google/dagger">Hilt</a>, a dependency injection library. In Gradle, your module would need to utilize a Hilt plugin and depend on two artifacts, one of which is a Kotlin symbol processor.</p><pre>// build.gradle.kts<br>plugins {<br>  id(&quot;com.google.dagger.hilt.android&quot;)<br>}<br><br>dependencies {<br>  implementation(&quot;com.google.dagger:hilt-android:x.y.z&quot;)<br>  ksp(&quot;com.google.dagger:hilt-android-compiler:x.y.z&quot;)<br>}</pre><p>However, in Bazel, it has a quite different way of usage.</p><pre># BUILD.bazel<br>kt_android_library {<br>  ...<br>  deps = [&quot;//:hilt-android&quot;]<br>}</pre><p>To address this, a feature component offers a dependency override API. Here is how it looks for the Hilt example.</p><pre>// FeatureComponent.onInvoke<br>onDependency(MavenCoordinates(&quot;com.google.dagger&quot;, &quot;hilt-android&quot;)) {<br>  overrideWith(BazelLabel(path = &quot;&quot;, target = &quot;hilt-android&quot;))<br>}</pre><p>This implies that when applied to any module, the feature component will replace occurrences of the com.google.dagger:hilt-android artifact with the //:hilt-android Bazel target.</p><p>Since Hilt requires more than one dependency in Gradle, the remaining artifacts can be disregarded in Bazel.</p><pre>// FeatureComponent.onInvoke<br>onDependency(MavenCoordinates(&quot;com.google.dagger&quot;, &quot;hilt-android-compiler&quot;)) {<br>  // ignored<br>}</pre><h4>Overriding configurations</h4><p>When setting up a Gradle module, it involves not only specifying dependencies but also assigning them to specific <strong>configurations</strong>, providing instructions to Gradle on how to treat each dependency.</p><pre>// build.gradle.kts<br>dependencies {<br>  implementation(...)<br>  api(...)<br>  ksp(...)<br>  ...<br>}</pre><p>In Bazel, targets are declared using function calls. As an analogue to Gradle configurations, we use specific function parameters for various types of dependencies.</p><pre># BUILD.bazel<br>kt_android_library(<br>  ...<br>  deps = [...],<br>  exports = [...],<br>  plugins = [...],<br>)</pre><p>Similar to dependency overrides, feature components also allow the overriding of configurations. In the example below, all implementation dependencies will be declared as deps in Bazel.</p><pre>// FeatureComponent.onInvoke<br>onConfiguration(&quot;implementation&quot;) {<br>  overrideWith(&quot;deps&quot;)<br>}</pre><p>For exporting transitive dependencies, deps and exports are used as an equivalent to Gradle’s api configuration.</p><pre>// FeatureComponent.onInvoke<br>onConfiguration(&quot;api&quot;) {<br>  overrideWith(&quot;deps&quot;)<br>  overrideWith(&quot;exports&quot;)<br>}</pre><h4>Modifying Bazel scripts</h4><p>Beyond handling dependencies, feature components can also make contributions to the Bazel files generated by module components.</p><p>Let’s revisit the code snippet from the AndroidLibraryModule component that we used in the <strong>module components</strong> section, but this time, let’s slightly update it.</p><pre>// ModuleComponent.onInvoke<br>val file = BUILD.bazel {<br>  _id = &quot;build_file&quot;<br><br>  load(&quot;@io_bazel_rules_kotlin//kotlin:android.bzl&quot;, &quot;kt_android_library&quot;)<br><br>  kt_android_library {<br>    _id = &quot;android_library_target&quot;<br>    <br>    name = module.name<br>    srcs = glob(&quot;src/main/**/*.kt&quot;)<br>    custom_package = module.androidMetadata?.packageName<br>    manifest = &quot;src/main/AndroidManifest.xml&quot;<br>    resource_files = glob(&quot;src/main/res/**&quot;)<br>    for ((config, deps) in module.dependencies) {<br>      config `=` deps.map { it.asBazelLabel().toString() }<br>    }<br>  }<br>}</pre><p>A notable addition here is the introduction of _id fields. These can be defined within any code block enclosed by curly brackets {}. Once defined, you gain the flexibility to edit the contents of these code blocks externally.</p><p>Let’s modify the contents of the generated Bazel file using our feature component. To achieve this, within the onInvoke function, use the onContext call, specifying the type of the context to be modified, along with its identifier.</p><pre>// FeatureComponent.onInvoke<br>onContext&lt;BuildContext&gt;(id = &quot;build_file&quot;) {<br>  `package`(default_visibility = list[&quot;//visibility:public&quot;])<br>}<br><br>onContext&lt;KtAndroidLibraryContext&gt;(id = &quot;android_library_target&quot;) {<br>  enable_data_binding = true<br>}</pre><p>As a result, when the AndroidLibraryModule component is invoked, it will incorporate all the modifications, including the added enable_data_binding argument, as well as the top-level package function call.</p><pre>load(&quot;@io_bazel_rules_kotlin//kotlin:android.bzl&quot;, &quot;kt_android_library&quot;)<br>load(&quot;@rules_jvm_external//:defs.bzl&quot;, &quot;artifact&quot;)<br><br>kt_android_library(<br>    name = &quot;my-library&quot;,<br>    srcs = glob([&quot;src/main/**/*.kt&quot;]),<br>    custom_package = &quot;com.turo.mylibrary&quot;,<br>    manifest = &quot;src/main/AndroidManifest.xml&quot;,<br>    resource_files = glob([&quot;src/main/res/**&quot;]),<br>    deps = [...],<br>    enable_data_binding = True, # added by a feature component <br>)<br><br># added by a feature component<br>package(default_visibility = [&quot;//visibility:public&quot;])</pre><h3>Customizing components with properties</h3><p>In many cases, there is no need to create new custom components. Instead, it’s often easier to configure existing ones.</p><p>For example, let’s examine the AndroidToolchainFeature included in the RootModule. Its purpose is to enable Kotlin, Java, and Android rules in the Bazel workspace and configure respective toolchains.</p><p>This component already generates all the necessary Starlark code. However, it exposes certain properties that allow you to specify versions of rule sets or toolchains.</p><pre>// root build.gradle.kts<br>airin {<br>  register&lt;RootModule&gt; {<br>    include&lt;AndroidToolchainFeature&gt; {<br>      rulesKotlinVersion = &quot;1.8.1&quot;<br>      rulesKotlinSha = &quot;a630cda9fdb4f56cf2dc20a4bf873765c41cf00e9379e8d59cd07b24730f4fde&quot;<br>    }<br>  }<br>}</pre><p>On the other side, within the component definition, the properties are declared using a property delegate.</p><pre>abstract class AndroidToolchainFeature : FeatureComponent() {<br>  val rulesKotlinVersion: String by property(default = &quot;1.8.1&quot;)<br>  val rulesKotlinSha: String by property(default = &quot;a630cda9fdb4f56cf2dc20a4bf873765c41cf00e9379e8d59cd07b24730f4fde&quot;)<br><br>  override fun FeatureContext.onInvoke(module: GradleModule) {<br><br>    onContext&lt;WorkspaceContext&gt; {<br>      val RULES_KOTLIN_VERSION by rulesKotlinVersion<br>      val RULES_KOTLIN_SHA by rulesKotlinSha<br>  <br>      http_archive(<br>          name = &quot;io_bazel_rules_kotlin&quot;,<br>          sha256 = RULES_KOTLIN_SHA,<br>          urls = list[&quot;https://github.com/bazelbuild/rules_kotlin/releases/download/v%s/rules_kotlin_release.tgz&quot; `%` RULES_KOTLIN_VERSION],<br>      )<br>      ...<br>    }<br>  }<br>  ...<br>}</pre><h3>Shared components</h3><p>Often, invoking a feature component solely for the module it belongs to is insufficient. Take the example of <a href="https://github.com/google/dagger">Hilt</a>. Before including it as a dependency in any module, you need to configure Dagger in your Bazel workspace by following these two steps.</p><ul><li>Load Dagger rules in the WORKSPACE file and register artifacts in maven_install.</li><li>Register Hilt rules in the root BUILD.bazel file.</li></ul><p>Check <a href="https://github.com/google/dagger">official Dagger documentation</a> to learn more about how to configure it in Bazel projects.</p><p>In addressing such scenarios, the automation tool employs the concept of shared components. Depending on the component type, the behavior can fall into one of three categories.</p><ul><li><strong>Shared module component — </strong>receives contributions from every shared feature component, even if it is not directly included in it.</li><li><strong>Shared feature component</strong> — contributes to every shared module component.</li><li><strong>Top-level feature component</strong> — does not belong to any module component specifically and contributes to all module components, even non-shared ones.</li></ul><p>This is how they are declared in a Gradle plugin.</p><pre>// root build.gradle.kts<br>airin {<br>  register&lt;AndroidLibraryModule&gt; {<br>    // shared feature component<br>    include&lt;HiltFeature&gt; { shared = true }<br>  }<br><br>  // shared module component<br>  register&lt;RootModule&gt; { shared = true }<br><br>  // top-level feature component<br>  include&lt;AllPublicFeature&gt;()<br>}</pre><p>Assume the RootModule component generates WORKSPACE and BUILD.bazel files.</p><pre>abstract class RootModule : ModuleComponent() {<br>  ...<br><br>  override fun ModuleContext.onInvoke(module: GradleModule) {<br>    val workspace = WORKSPACE {<br>      _id = &quot;workspace_file&quot;<br>    }<br><br>    val build = BUILD.bazel {<br>      _id = &quot;root_build_file&quot;<br>    }<br><br>    generate(build, workspace)<br>  }<br>}</pre><p>With this in mind, the HiltFeature component could contribute to modules that use Hilt as well as to the <strong>root module</strong>.</p><pre>abstract class HiltFeature : FeatureComponent() {<br><br>  override fun canProcess(project: Project) = <br>    hasPlugin(&quot;com.google.dagger.hilt.android&quot;) ||<br>    project.rootProject == project<br><br>  override fun FeatureContext.onInvoke(module: GradleModule) {<br><br>    // contributing to WORKSPACE file<br>    onContext&lt;WorkspaceContext&gt;(id = &quot;workspace_file&quot;) {<br>      val DAGGER_VERSION by&quot;...&quot;<br>      val DAGGER_SHA by &quot;...&quot;<br><br>      http_archive(<br>        name = &quot;dagger&quot;,<br>        strip_prefix = &quot;dagger-dagger-%s&quot; `%` DAGGER_VERSION,<br>        sha256 = DAGGER_SHA,<br>        urls = list[&quot;https://github.com/google/dagger/archive/dagger-%s.zip&quot; `%` DAGGER_VERSION],<br>      )<br>    }<br><br>    // contributing to root BUILD.bazel file<br>    onContext&lt;BuildContext&gt;(id = &quot;root_build_file&quot;) {<br>      load(&quot;@dagger//:workspace_defs.bzl&quot;, &quot;hilt_android_rules&quot;)<br>      hilt_android_rules()<br>    }<br><br>    // contributing to modules that depend on Hilt<br>    onDependency(...) { ... }<br>    ...<br>  }<br>}</pre><h3>To conclude</h3><p>There can be an infinite number of unique Gradle configurations relying on custom build logic, tasks, and plugins without available open-source Bazel alternatives.</p><p>That’s why it’s crucial for the Bazel migration tooling not to attempt to cover them all at once. Of course, due to its open-source nature, Airin provides a predefined set of components for a quick head start with Android projects. However, more importantly, it provides a framework that allows full customization and ability to adjust to the codebase it is applied to.</p><p>Use these links for complete examples of <a href="https://github.com/Morfly/airin/tree/710d4dc95d4d61a55d8ce027f1442979125b4b47/airin-gradle-android/src/main/kotlin/io/morfly/airin/module">module</a> and <a href="https://github.com/Morfly/airin/tree/710d4dc95d4d61a55d8ce027f1442979125b4b47/airin-gradle-android/src/main/kotlin/io/morfly/airin/feature">feature</a> components as well as a <a href="https://github.com/Morfly/airin/tree/710d4dc95d4d61a55d8ce027f1442979125b4b47/samples">sample project</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6dc79d298628" width="1" height="1" alt=""><hr><p><a href="https://medium.com/turo-engineering/boosting-bazel-adoption-on-android-with-automation-6dc79d298628">Boosting Bazel Adoption on Android With Automation</a> was originally published in <a href="https://medium.com/turo-engineering">Turo Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing Nibel: A Navigation Library for Adopting Jetpack Compose in Fragment-Based Apps]]></title>
            <link>https://medium.com/turo-engineering/introducing-nibel-a-navigation-library-for-adopting-jetpack-compose-in-fragment-based-apps-541c7b2f3f84?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/541c7b2f3f84</guid>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[architecture]]></category>
            <category><![CDATA[kotlin]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Thu, 20 Jul 2023 22:55:17 GMT</pubDate>
            <atom:updated>2024-05-27T19:39:33.903Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pwlzZqmjKJ902Mzfs8WH0A.png" /><figcaption>Illustrations by <a href="https://www.linkedin.com/in/pavlo-stavytskyi-184313129/">Pavlo Stavytskyi</a></figcaption></figure><p>Adopting Jetpack Compose in existing codebases is a challenge many teams face nowadays. Recently, we <a href="https://medium.com/turo-engineering/designing-jetpack-compose-architecture-for-a-gradual-transition-from-fragments-on-android-b11ee5f19ba8">published a story</a> about how we designed a Jetpack Compose architecture for the Turo Android app that heavily relies on fragments. Today, we are excited to make it open-source as Nibel.</p><p>Nibel<strong> </strong>— is a type-safe navigation library that enables seamless adoption of Jetpack Compose in Android apps that rely on fragments. When we built it at Turo, our goal was to ensure a proper Jetpack Compose experience for the team when creating new features while keeping them compatible with the rest of the codebase automatically. By leveraging the power of a Kotlin Symbol Processor (KSP) Nibel provides a unified and type-safe way of navigating between screens in the following navigation scenarios:</p><ul><li><strong>fragment → compose</strong></li><li><strong>compose → compose</strong></li><li><strong>compose → fragment</strong></li></ul><p>Nibel supports both <strong>single-module</strong> and <strong>multi-module</strong> navigation out-of-the-box. The latter is especially useful when navigating between feature modules that do not depend on each other directly.</p><p>You can find Nibel and the documentation on <a href="https://github.com/open-turo/nibel">GitHub</a>.</p><p><a href="https://github.com/open-turo/nibel">GitHub - open-turo/nibel: Type-safe navigation library for seamless adoption of Jetpack Compose in fragment-based Android apps.</a></p><p>In this story, you will see how you can <strong>start using Nibel</strong> in your project, the <strong>common scenarios</strong> of adopting Jetpack Compose, and the <strong>customization</strong> options Nibel provides.</p><h3>How to use it?</h3><p>Here are the basic steps for using Nibel in your project to start adopting Jetpack Compose.</p><p><strong>1. Declare a screen. </strong>To start using Nibel, simply annotate your composable function with @UiEntry annotation. This will generate a {ComposableName}Entry class that is used to navigate to this screen.</p><pre>@UiEntry(type = ImplementationType.Fragment)<br>@Composable<br>fun FooScreen(<br>  navigator: NavigationController // optional param<br>) { ... }</pre><p>For screens with arguments just pass your Parcelable args class in the annotation.</p><pre>@UiEntry(<br>  type = ImplementationType.Composable,<br>  args = BarScreenArgs::class<br>)<br>@Composable<br>fun BarScreen(<br>  args: BarScreenArgs, // optional param<br>  navigator: NavigationController // optional param<br>) { ... }</pre><blockquote>We’re going to take a closer look at ImplementationType later in this post.</blockquote><p><strong>2. Navigate between compose screens. </strong>Using NavigationController it is possible to navigate between annotated compose screens. Simply use a navigateTo function by passing an instance of a generated entry as an argument.</p><pre>val args = BarScreenArgs(...)<br>navigator.navigateTo(BarScreenEntry.newInstance(args))</pre><p><strong>3. Navigate to a fragment</strong>. Similarly, a NavigationController can be used to navigate from compose screens to old fragments.</p><pre>class BazFragment : Fragment() { ... }</pre><p>Wrap a fragment instance with FragmentEntry and pass it to navigateTo function.</p><pre>val fragment = BazFragment()<br>navigator.navigateTo(FragmentEntry(fragment))</pre><p><strong>4. Navigate from a fragment. </strong>Annotated compose screens appear as fragments to the outside non-compose world. Treat a generated entry class as a fragment and use a transaction for navigation.</p><pre>class QuxFragment : Fragment() {<br>  ...<br>  requireActivity().supportFragmentManager.commit {<br>    replace(android.R.id.content, FooScreenEntry.newInstance().fragment)<br>  }<br>}</pre><h4>Multi-module navigation</h4><p>In multi-module apps, it is common to have feature modules that do not depend on each other directly. In this case, it is impossible to obtain a direct reference to a generated entry class in another feature module.</p><p>Nibel provides an easy way of multi-module navigation in a type-safe manner using the concept of destinations. Destination — is a simple data type that serves as a navigation intent. It is located in a separate module available to other feature modules.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NncjMbjGZl_uIijtFLc3Yw.png" /></figure><p>Each destination is associated with exactly one screen, so when navigation is required, the instance of a destination is used to reach the target screen.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lKJ7mmKFptBjE1f8QJQnFA.png" /></figure><blockquote>There is no need to have a single navigation module per app. There can be many of them in the app. The key requirement though, is for the destination type to be available for both source and target screens.</blockquote><p><strong>1. Declare a destination. </strong>The most basic destination is an object that implements DestinationWithNoArgs an is declared in a separate navigation module, available to other feature modules.</p><pre>// :navigation module<br>object FooScreenDestination : DestinationWithNoArgs</pre><p>If you need a screen with args, inherit DestinationWithArgs instead.</p><pre>// :feature module, depends on :navigation module<br>data class BarScreenDestination(<br>  override val args: BarScreenArgs // Parcelable args<br>) : DestinationWithArgs&lt;BarScreenArgs&gt;</pre><p><strong>2. Associate a destination with a screen. </strong>Each destination should be associated with exactly one screen using @UiExternalEntry annotation over a composable function.</p><pre>@UiExternalEntry(<br>  type = ImplementationType.Fragment,<br>  destination = BarScreenDestination::class<br>)<br>@Composable<br>fun BarScreen(<br>  args: BarScreenArgs, // optional param<br>  navigator: NavigationController // optional param<br>) { ... }</pre><p>If multi-module navigation from compose to an old fragment is needed, a @LegacyExternalEntry should be applied to the fragment.</p><pre>@LegacyExternalEntry(destination = BasScreenDestination::class)<br>class BazScreenFragment : Fragment() { ... }</pre><p><strong>3. Navigate to a destination. </strong>Use NavigationController to navigate to a destination.</p><pre>val args = BarScreenArgs(...)<br>navigator.navigateTo(BarScreenDestination(args))</pre><p>@UiScreenEntry includes all the functionality of @UiEntry and therefore, if you have a direct reference to the generated entry class, the following code is valid for the same screen.</p><pre>val args = BarScreenArgs(...)<br>navigator.navigateTo(BarScreenEntry.newInstance(args))</pre><p><strong>4. Navigate from a fragment.</strong> Finally, if a multi-module navigation is needed from an old fragment to a compose screen, a destination should be used to obtain a fragment instance and perform a transaction.</p><pre>class QuxScreenFragment : Fragment() {<br>  ...<br>  requireActivity().supportFragmentManager.commit {<br>    val entry = Nibel.newFragmentEntry(BarScreenDestination)!!<br>    replace(android.R.id.content, entry.fragment)<br>  }<br>}</pre><h4>Composable function params</h4><p>Composable functions annotated with @UiEntry or @UiExternalEntry can have any number of parameters as long as they have default values.</p><pre>@UiEntry(type = ImplementationType.Fragment)<br>fun FooScreen(viewModel: FooViewModel = viewModel()) { ... }</pre><p>In addition, there are special types of params that do not require default values, as Nibel can figure out how to provide corresponding instances. Among such params are NavigationController, ImplementationType and args of the same type as in the @UiEntry annotation or in the destination type.</p><pre>@UiEntry(<br>  type = ImplementationType.Composable, <br>  args = BarArgs::class<br>)<br>fun BarScreen(<br>  args: BarArgs,<br>  navigator: NavigationController,<br>  type: ImplementationType<br>) { ... }</pre><blockquote>If argument types do not match, a compile type error will be thrown.</blockquote><p>Alternatively, the values above could be obtained as composition locals.</p><pre>@UiEntry(<br>  type = ImplementationType.Composable, <br>  args = BarArgs::class<br>)<br>fun BarScreen() {<br>  val args = LocalArgs.current as BarArgs<br>  val navigator = LocalNavigationController.current<br>  val type = LocalImplementationType.current<br>}</pre><h3>Common usage scenarios</h3><p>The type of generated entries differs depending on the ImplementationType specified in the annotation of a target screen. Each type serves a specific scenario and can be one of:</p><ul><li>Fragment - generated entry is a fragment that uses the annotated composable as its content. It makes the compose screen appear as a fragment to other fragments and is crucial in <strong>fragment → compose</strong> navigation scenarios.</li><li>Composable - generates a small wrapper class over a composable. It is normally used in <strong>compose → compose</strong> and <strong>compose → fragment</strong> navigation scenarios where the latter is marked with this implementation type.</li></ul><p>Normally there are several scenarios for adding new Jetpack Compose screens to an existing codebase.</p><h4>Scenario 1 — new feature</h4><p>The easiest scenario is when you are creating a completely new feature in a separate module. In this case, all the screens of the feature will use Jetpack Compose.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*slzncXiwFvZ0RniR2j7gbg.png" /></figure><p>The first screen of the feature serves as an external entry, as it allows entering the feature from other modules. It is crucial that it is marked with ImplementationType.Fragment. This will ensure it appears as a fragment to the non-compose code and thus, makes it easy to navigate from old fragments to this new feature.</p><p>All the subsequent screens in the feature should use ImplementationType.Composable. This will result in improved performance since no fragments are generated, leading to fewer class allocations per screen.</p><p>In some cases, you might need to navigate from the new feature back to an old one that relies on fragments. All you have to do is annotate a target fragment with @LegacyExternalEntry and use its associated destination for navigation through NavigationController.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bliFzqiRRhTkwvp7-c4KtA.png" /></figure><h4>Scenario 2 — expanding existing feature</h4><p>Another scenario is when you need to insert a bunch of new consecutive screens in an existing feature. In this case, it is possible to have them as compose screens even though they’re placed in the middle of the flow of fragments.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hGKH2T6loQ71YPp9ykMPUA.png" /></figure><p>The key rule here is the same. The first compose screen should be annotated with ImplementationType.Fragment and all the subsequent ones should use ImplementationType.Composable.</p><h4>Scenario 3 — standalone screens</h4><p>The third scenario is probably the most frequent during the first stages of Jetpack Compose adoption. In this case, standalone compose screens are placed in the middle of the old fragment flows.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*04vTK7Q5m047lOPqeeqFSw.png" /></figure><p>All you need to do in this case is to annotate compose screens with ImplementationType.Fragment, and treat them as fragments outside of a compose code.</p><h3>Customization</h3><p>Nibel provides various customization options so that it can be adapted to the needs of a specific project.</p><p>Before proceeding with this section it is recommended to check our <a href="https://medium.com/turo-engineering/designing-jetpack-compose-architecture-for-a-gradual-transition-from-fragments-on-android-b11ee5f19ba8">previous story</a> which shows more details on Nibel’s internal components and the thought process behind their implementation.</p><p><strong>Applying a theme</strong></p><p>For every screen annotated with ImplementationType.Fragment Nibel generates an entry class which is a fragment that inherits a base ComposableFragment. When using Jetpack Compose with fragments, all the composable UI is set in onCreateView. This means that for every new fragment, a theme must be explicitly applied.</p><pre>// base class for generated fragments<br>// (part of nibel-runtime library)<br>abstract class ComposableFragment : Fragment() {<br><br>  @Composable<br>  abstract fun ComposableContent()<br><br>  override fun onCreateView(<br>    ...<br>  ) = ComposeView(requireContext()).apply {<br>    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)<br>    setContent {<br>      AppTheme { // a theme should be applied here<br>        ...<br>      }<br>    }<br>  }<br>}</pre><p>Since it’s a base class of a 3-rd party library, a custom theme of a specific app could not just be applied there.</p><p>To apply a theme you can implement a RootDelegate which will be just a class with a simple composable function. This function is called at the root of every screen annotated with ImplementationType.Fragment.</p><pre>object CustomRootContent : RootDelegate {<br><br>  @Composable<br>  override fun Content(content: @Composable () -&gt; Unit) {<br>    AppTheme { // applying a custom theme<br>      content()<br>    }<br>  }<br>}</pre><p>Don’t forget to call content function, to proceed with the UI of your screen.</p><p>All that is left to do is apply the CustomRootContent when configuring Nibel.</p><pre>Nibel.configure(rootDelegate = CustomRootContent)</pre><p><strong>Navigation specs</strong></p><p>As we saw above, NavigationController is used to perform navigation between screens. However, the navigateTo function provides space for customization as well.</p><pre>abstract class NavigationController(...) {<br>  ...<br><br>  fun navigateTo(<br>    entry: Entry,<br>    fragmentSpec: FragmentSpec&lt;*&gt;, // fragment navigation specification<br>    composeSpec: ComposeSpec&lt;*&gt; // compose navigation specification<br>  )<br>}</pre><p>As we already know, Nibel enables navigation between fragments and compose screens in various scenarios. When navigating from a composable to a new screen, Nibel utilizes different tooling for navigation under the hood, depending on the target screen.</p><ul><li>If the next screen is a fragment or a composable with ImplementationType.Fragment there will be a fragment transaction performed implicitly.</li><li>If the next screen is composable with ImplementationType.Composable Nibel will use compose navigation library implicitly to perform navigation.</li></ul><p>At any moment, you could switch ImplementationType in the annotation of any screen, and the code will be still compilable. However, Nibel will use a different underlying tool to perform navigation. FragmentSpec is used when navigation between fragments happens under the hood, while ComposeSpec is used for direct navigation between composable functions, also implicitly.</p><p>When performing a fragment transaction under the hood, sometimes you might want to have more control over its specifics. For example, use add vs replace, select a custom container id for the transaction, etc.</p><p>The instance of each navigation spec holds inside implementation details of how the navigation should be performed. Therefore, it could be customized in multiple ways.</p><p>For example, for fragment transactions, you can use an instance of FragmentTransactionSpec and specify the details of a transaction.</p><pre>navigator.navigateTo(<br>  entry = ..., <br>  fragmentSpec = FragmentTransactionSpec(<br>    replace = true,<br>    addToBackStack = true,<br>    containerId = R.id.customContainerId<br>  )<br>)</pre><p>If that’s not enough, you can completely override its behavior by writing the custom navigation logic.</p><pre>class CustomTransactionSpec : FragmentTransactionSpec(...) {<br><br>  override fun FragmentTransactionContext.navigateTo(entry: FragmentEntry) {<br>    this.fragmentManager.commit {<br>      // custom fragment transaction logic<br>    }<br>  }<br>}</pre><pre>navigator.navigateTo(<br>  entry = ..., <br>  fragmentSpec = CustomTransactionSpec()<br>)</pre><p>As for compose specs, a compose navigation library is used under the hood. All the navigation destinations are being added dynamically in ComposeNavigationSpec. You can learn more about how the compose navigation library is used under the hood in our <a href="https://medium.com/turo-engineering/designing-jetpack-compose-architecture-for-a-gradual-transition-from-fragments-on-android-b11ee5f19ba8">previous post</a>.</p><p>Finally, when configuring Nibel you can set any navigation specification by default for all screens in the app.</p><pre>Nibel.configure(<br>  fragmentSpec = CustomFragmentSpec(),<br>  composeSPec = CustomComposeSpec()<br>)</pre><h3>Compatibility with architecture components</h3><p>Modern Android apps use various architecture components such as <a href="https://developer.android.com/training/dependency-injection/hilt-android">Hilt</a>, <a href="https://developer.android.com/topic/libraries/architecture/viewmodel">ViewModel</a>, etc. Let’s see how Nibel could be integrated with them.</p><p>You can declare a view model as a param of a composable function and use hiltViewModel to get its instance. Now, you can use a combination of a Nibel screen and a view model injected by Hilt.</p><pre>@UiEntry(type = Composable, args = FooArgs::class)<br>@Composable<br>fun FooScreen(viewModel: FooViewModel = hiltViewModel()) { ... }</pre><pre>@HiltViewModel<br>class FooViewModel(handle: SavedStateHandle): ViewModel() {<br>  val args = handle.getNibelArgs&lt;FooArgs&gt;()<br>}</pre><p>You can notice, that the screen arguments are automatically available in a SavedStateHandle in a view model.</p><h3>To conclude</h3><p>With Nibel, you can focus on writing new product features for your app with Jetpack Compose while having compatibility with the rest of the codebase, and fragments in particular, handled for you.</p><p>Nibel is highly customizable so it could be applied to various types of projects and navigation scenarios during adopting Jetpack Compose.</p><p>Feel free to check the repository for more documentation and a <a href="https://github.com/open-turo/nibel/tree/main/sample">sample project</a> with more details on how to use Nibel in practice.</p><p><a href="https://github.com/open-turo/nibel">GitHub - open-turo/nibel: Type-safe navigation library for seamless adoption of Jetpack Compose in fragment-based Android apps.</a></p><p>Give it a try and let us know what you think. We hope it will help you to make the process of Jetpack Compose adoption more smooth.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=541c7b2f3f84" width="1" height="1" alt=""><hr><p><a href="https://medium.com/turo-engineering/introducing-nibel-a-navigation-library-for-adopting-jetpack-compose-in-fragment-based-apps-541c7b2f3f84">Introducing Nibel: A Navigation Library for Adopting Jetpack Compose in Fragment-Based Apps</a> was originally published in <a href="https://medium.com/turo-engineering">Turo Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Designing Jetpack Compose architecture for a gradual transition from fragments on Android]]></title>
            <link>https://medium.com/turo-engineering/designing-jetpack-compose-architecture-for-a-gradual-transition-from-fragments-on-android-b11ee5f19ba8?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/b11ee5f19ba8</guid>
            <category><![CDATA[architecture]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[technology]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Mon, 05 Jun 2023 18:50:43 GMT</pubDate>
            <atom:updated>2024-05-27T19:40:18.716Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KfojlJ1Uj9Asu4Wl9ueYAA.png" /><figcaption>Illustrations by <a href="https://www.linkedin.com/in/pavlo-stavytskyi-184313129/">Pavlo Stavytskyi</a></figcaption></figure><h4>Adopting Jetpack Compose at Turo</h4><p>Jetpack Compose is a modern toolkit recommended for building native UI for Android apps. It provides a more intuitive developer experience helping engineers to write less UI code while making it more reliable.</p><p>The engineering team at Turo was excited about adopting Jetpack Compose in our Android app. However, as it usually happens, we have an existing codebase that heavily relies on fragment API. While Jetpack Compose has pretty good compatibility with a legacy UI stack out of the box, just including a new dependency in the project and starting to write composable functions was not enough.</p><p>An important task when integrating such a thing as a core UI toolkit is to make sure it fits well with the existing app architecture and APIs it relies on. More specifically, we wanted to find solutions to the following questions:</p><ul><li><strong>How do we enable our team to write purely-compose features without bothering about compatibility with the rest of the app?</strong></li><li><strong>How do we navigate from fragments to compose screens?</strong></li><li><strong>How do we navigate from compose to fragments?</strong></li><li><strong>How do we make the experience above simple and unified?</strong></li></ul><p>In this story, we will share our experience of adopting Jetpack Compose at Turo by designing the architecture that provides seamless and gradual integration of the modern UI toolkit into the existing codebase. We are going to talk about how we leveraged the power of Kotlin Symbol Processing API to provide a pure Jetpack Compose experience for our Android engineers while keeping the new product features fully compatible with the rest of the app automatically.</p><p>The content in this story is now available as an open-source navigation library — Nibel. You can find it on <a href="https://github.com/open-turo/nibel">GitHub</a>. In this post, you’ll see how it’s implemented under the hood, while the <a href="https://medium.com/turo-engineering/introducing-nibel-a-navigation-library-for-adopting-jetpack-compose-in-fragment-based-apps-541c7b2f3f84">next one</a>, describes how can you use it to migrate your project from fragments to Jetpack Compose.</p><h3>Overview</h3><p>Without further ado, let’s see what experience our engineers have when creating new UI features for the Turo Android app.</p><p>As you can see below, when we’re creating a UI screen, all we need to do is start writing @Composable functions. The only additional step is to annotate it with the @UiEntry annotation and declare a simple object or data class that will register this screen as a destination in a navigation graph.</p><pre>@UiEntry(<br>  destination = VehicleSearchDestination::class,<br>  type = ImplementationType.Fragment<br>)<br>@Composable<br>fun VehicleSearchScreen(viewModel: VehicleSearchViewModel = ...) {<br>  SideEffectHandler(viewModel.sideEffects) {<br>    when (it) {<br>      // navigating to another screen<br>      is VehicleSearchSideEffect.ShowDetails -&gt; navigateTo(<br>        VehicleDetailsDestination(VehicleDetailsArgs(it.vehicleId))<br>      )<br>    }<br>  }<br><br>  // the rest of the compose UI<br>  ...<br>}</pre><p>That’s it. Now this screen is compatible with the rest of the app. You might have noticed we’re using ImplementationType param in the annotation that could be one of two values: Fragment or Composable.</p><p>But what does it mean? In order to keep the screen compatible with the legacy features the Fragment param will generate a corresponding fragment (so-called <em>fragment entry</em>) that will use the annotated compose function as its content. Of course, we can’t just migrate the entire app to compose at once. Therefore, this is helpful when navigating from the legacy screens to the new compose screens.</p><p>Let’s say you’re developing a new product feature with 10 screens using Jetpack Compose. One of those screens will be the <em>external entry point</em> of the feature, meaning that users will navigate to it from other parts of the app. In order for this screen to be compatible with the app codebase, it must also be wrapped in a fragment. This wrapper is generated automatically so that the rest of the app will see this new screen as a fragment and will navigate to it using fragment transactions as usual.</p><p>For the remaining 9 screens that are <em>internal</em> in the feature, there is no need to generate a separate fragment for each. Instead, ImplementationType.Composable is used to generate a thin compose wrapper around the screen (so-called <em>composable entry</em>). This way, navigation between screens inside the feature is done purely with compose.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dntEJFLrenOJYC6eUAYQ8A.png" /><figcaption>Navigation to compose features</figcaption></figure><p>Over time, when more compose screens are added to the app there will be more features that are navigated only from compose screens. These features could completely rely on ImplementationType.Composable even for their <em>external entry points</em>.</p><p>At any point in time, for any screen inside the feature, you could change the ImplementationType value and no other code changes would be required. The code will be still compilable including the navigation between screens.</p><h4>Abstraction over navigation</h4><p>This is possible due to the abstraction layer over the navigation we’ve built. Obviously, in order to navigate between fragments, transactions should be used. For composable screens, we’re using <a href="https://medium.com/r?url=https%3A%2F%2Fdeveloper.android.com%2Fjetpack%2Fcompose%2Fnavigation"><em>compose navigation library</em></a> under the hood. Overall, there are 4 scenarios for navigation between screens that should be covered:</p><ul><li><strong><em>fragment to fragment</em></strong> — using fragment transactions</li><li><strong><em>fragment to compose</em></strong> — using compose navigation</li><li><strong><em>compose to compose</em></strong> — using compose navigation</li><li><strong><em>compose to fragment</em></strong> — using fragment transactions</li></ul><p>However, all of the above is an implementation detail and is hidden from the developer. Instead, we use navigateTo function (as shown in the code snippet above) that provides an abstraction over the navigation and figures out automatically under the hood, which tool for navigation to choose (we will cover it in more detail later in this post).</p><p>This means that <em>compose navigation library</em> for example could be replaced with any other solution at any point in time, and not a single code change in feature modules would be required.</p><p>This is also handy as we don’t have to reinvent the wheel by implementing a custom navigation graph, backstack, etc as everything is handled for us by the underlying tools.</p><h3>The thought process</h3><p>Now that we have seen a brief overview of the architecture, let’s take a closer look at its core components and the thought process behind their design.</p><p>The first question we needed to answer with a fragment-based codebase is what is the most straightforward way to start writing composable screens? One of the first solutions that comes to mind is to start wrapping every composable screen with a fragment as shown below.</p><pre>class VehicleSearchFragment: Fragment() {<br><br>  override fun onCreateView(<br>    inflater: LayoutInflater,<br>    container: ViewGroup?,<br>    savedInstanceState: Bundle?<br>  ) = ComposeView(requireContext()).apply {<br>    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)<br>    setContent {<br>      AppTheme {<br>        VehicleSearchScreen(...)<br>      }<br>    }<br>  }<br>}<br><br>@Composable<br>fun VehicleSearchScreen(...) {<br> ...<br>}</pre><p>One great benefit such an approach provides is compatibility with the rest of the app. If there is a need to navigate <em>to</em> or <em>from</em> this screen you could use fragment transactions as you normally do.</p><h4>Reducing the boilerplate</h4><p>The example above contains a bunch of boilerplate code that could be moved to a base ComposableFragment class. Therefore, instead of overriding onCreateView every time, developers could just use ComposableContent function instead as shown below.</p><pre>class VehicleSearchFragment: ComposableFragment() {<br>    <br>  @Composable<br>  override fun ComposableContent() {<br>    VehicleSearchScreen(...)<br>  }<br>}</pre><h4>Temptation to do it the old way</h4><p>While this is already a solution that might serve the purpose, it has its flaws. Even though our fragment allows writing compose UI, it is still a fragment.</p><p>This leads to a temptation to do certain things in an old imperative way using APIs provided directly by fragments. Therefore, this is not quite a proper compose experience as it makes certain parts of the code less flexible and convenient to maintain or refactor.</p><h4>Ensuring proper compose experience</h4><p>However, fragments like VehicleSearchFragment could be easily autogenerated ensuring compatibility with other screens that rely on fragments as well as pure compose experience.</p><p>Instead of creating a custom fragment that extends ComposableFragment, all we need to do is just annotate a composable function with @UiEntry like shown in the example below.</p><pre>@UiEntry<br>@Composable<br>fun VehicleSearchScreen(...) {<br>  ...<br>}</pre><p>The custom Kotlin Symbol Processor generates the corresponding fragment class automatically providing a proper Jetpack Compose experience for the team.</p><h3>Navigation</h3><p>One of the most important aspects of any architecture on Android is navigation between product features and screens. This is especially challenging and important in multi-module projects as it has a direct impact on many aspects of the development process including build times.</p><p>Let’s take a look at how navigation between features in the Turo app looked before adopting Jetpack Compose architecture in the figure below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DAmpDKlb81-In9mHULJPJw.png" /><figcaption>Relationship between feature modules</figcaption></figure><p>There are a few key points here worth mentioning.</p><ul><li>The app consists of <em>feature</em> and <em>library</em> modules for the most part.</li><li>No <em>feature</em> modules<em> </em>are not allowed to depend on each other.</li><li>In order to navigate between <em>features</em> a <em>navigation</em> module is used. All <em>features</em> depend on <em>navigation</em> while the latter is not allowed to depend on any <em>feature</em> module.</li></ul><p>One great benefit provided by this approach is better build times. This is because changes in a specific feature do not trigger incremental rebuilds of all modules that depend on it (since nothing depends on feature modules except the root <em>app</em> module).</p><p>On the other hand, there is still a flaw with this approach. As mentioned earlier, the <em>navigation</em> module cannot depend on any feature, which means that the navigation process lacks type safety.</p><p>Let’s have a look at how the feature navigation in the app was organized in the past.</p><pre>// :navigation<br><br>object VehicleDetailsNavigation {<br>  const val FRAGMENT_NAME = &quot;com.turo.vehicledetails.VehicleDetailsFragment&quot;<br><br>  @Parcelize<br>  data class Args(val vehicleId: String): Parcelable<br><br>  fun newInstance(vehicleId: String): Fragment {<br>    // instantiate fragment using reflection<br>    val fragment = loadFragment(FRAGMENT_NAME)<br>    fragment.arguments = bundleOf(<br>      &quot;args&quot; to Args(vehicleId = vehicleId)<br>    )<br>    return fragment<br>  }<br>}</pre><p>Since the <em>navigation</em> module does not depend on feature modules, it is not able to refer to fragments directly. Instead, the app was using a fully qualified name of fragment classes to instantiate them using reflection using a custom loadFragment utility function. Consequently, calling the newIntance factory function returned an instance of a fragment that feature modules could use for navigation.</p><p>Overall, as you can see in the snippet above, it was required to write a bunch of boilerplate code in order to navigate from one feature to another.</p><h4>Destinations concept</h4><p>When designing the new architecture we wanted to preserve the benefits of the existing navigation approach while mitigating its flaws.</p><p>Instead of a bunch of boilerplate code above that forces developers to manually instantiate screen entries, we now operate with the concept of <em>destinations</em>.</p><p>The destination is a type that represents a UI screen in a global navigation graph. They should be declared in a navigation module so that every feature could reference them.</p><pre>// :navigation<br><br>@Parcelize<br>data class VehicleDetailsArgs(val vehicleId: String): Parcelable<br><br>class VehicleDetailsDestination(override val args: VehicleDetailsArgs): DestinationWithArgs&lt;VehicleDetailsArgs&gt;</pre><p>Once declared, it needs to be explicitly connected to a certain UI screen using the @UiExternalEntry annotation as shown below.</p><pre>// :features:vehicledetails<br><br>@UiExternalEntry(VehicleDetailsDestination::class)<br>@Composable<br>fun VehicleDetailsScreen(viewModel: VehicleDetailsViewModel = ...) {<br>  ...<br>}</pre><blockquote>It is worth admitting that one piece of information shared earlier in this blog post was not quite correct. We don’t actually use @UiEntry annotation but rather we have 2 its variations which are UiEntry and @UiExternalEntry. We will cover the difference between them shortly in this post.</blockquote><p>As a result, a corresponding fragment class is generated.</p><pre>// generated code<br><br>class VehicleDetailsScreenEntry: ComposableFragment() {<br><br>  @Composable<br>  override fun ComposableContent() = VehicleDetailsScreen()<br>  <br>  companion object: FragmentEntryFactory&lt;VehicleDetailsDestination&gt; {<br>    <br>    fun newInstance(destination: VehicleDetailsDestination): FragmentEntry {<br>      val fragment = VehicleDetailsScreenEntry<br>      fragment.arguments = bundleOf(&quot;args&quot; to destination.args)<br>      return FragmentEntry(fragment)<br>    }<br>  }<br>}</pre><p>From the code snippet above we can see that one of the important parts of each generated fragment is its companion object that serves as a factory for this fragment. On the other hand, a similar factory is being generated for screens that use ImplementationType.Composable along with a corresponding composable entry class, instead of a fragment.</p><pre>// generated code<br><br>@Parcelize<br>class VehicleDetailsScreenEntry(<br>    override val args: Parcelable?,<br>    override val name: String,<br>) : ComposableEntry(args, name) {<br><br>    @Composable<br>    override fun ComposableContent() = VehicleDetailsScreen()<br><br>    companion object: ComposableEntryFactory&lt;VehicleDetailsDestination&gt; {<br><br>        override fun newInstance(destination: VehicleDetailsDestination): ComposableEntry {<br>            val entry = VehicleDetailsScreenEntry(<br>                args = destination.args,<br>                name = buildRouteName(VehicleDetailsScreenEntry::class.qualifiedName!!, destination.args),<br>            )<br>            return entry<br>        }<br>    }<br>}</pre><p>Regardless of an implementation type if it is an external entry, its factory is injected to a map with Dagger, and Anvil in particular, using @IntoMap. Consequently, a corresponding Dagger module needs to be generated as shown below.</p><pre>// generated code<br><br>@Module<br>@ContributesTo(AppScope::class)<br>object VehicleDetailsNavigationModule {<br><br>    @Provides<br>    @SingleIn(AppScope::class)<br>    @IntoMap<br>    @DestinationKey(VehicleDetailsDestination::class) // dagger map key<br>    fun provideVehicleDetailsEntry(): EntryFactory&lt;*, *&gt; {<br>        // returning a reference to a companion object.<br>        return VehicleDestinationEntry<br>    }<br>}</pre><p>For navigation to another screen, a class of a destination is used as a key in order to retrieve a corresponding factory and instantiate the entry.</p><pre>typealias DestinationsMap = Map&lt;Class&lt;out Destination&gt;, @JvmSuppressWildcards EntryFactory&lt;*, *&gt;&gt;<br><br>fun &lt;D : ExternalDestination&gt; DestinationsMap.findEntryFactory(destinationClass: Class&lt;out D&gt;): EntryFactory&lt;D, *&gt; {<br>    return this[destinationClass] as EntryFactory&lt;D, *&gt; <br>}</pre><p>As a result, when navigating to a destination, the following code should be used.</p><pre>val destination = VehicleDetailsDestination(VehicleDetailsArgs(it.vehicleId))<br>navigateTo(destination)</pre><blockquote>It’s worth mentioning that for screens that don’t accept arguments only a single line is required for declaring a corresponding destination type for them.</blockquote><pre>// :navigation<br><br>object VehicleSearchDestination: DestinationWithNoArgs</pre><h4>Internal navigation</h4><p>Creating destination types for <em>feature entry points</em> is required to mitigate the fact that the navigation module does not have direct references to specific classes from the feature modules.</p><p>In case we need to navigate between screens inside a specific feature module, we could just reference them directly as <em>internal destinations</em>. This also helps to avoid unnecessary code generation required to</p><p>In order to understand how internal and external navigation is organized, let’s take a look at the complete hierarchy of Destination types as core architecture components.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BYOqgJyN3yvQsSYxbilqPA.png" /><figcaption>Destination type hierarchy</figcaption></figure><p>We’re already familiar with DestinationWithNoArgs and DestinationWithArgs types that are used to declare destinations for external navigation.</p><p>When navigating internally inside a single feature module, there is no need for declaring redundant destinations as all the respective classes could be referenced directly. Therefore, <em>fragment</em> or <em>composable entries</em> are serving as destinations themselves as they are both the descendants of an Entry interface.</p><p>For fragments, it&#39;s just a simple wrapper class as we want to avoid exposing the fragment instances directly in the feature code.</p><pre>class FragmentEntry(val fragment: Fragment): Entry</pre><p>Similarly, the base class for <em>composable entries</em> also inherits the Entry interface.</p><pre>abstract class ComposableEntry(<br>  open val args: Parcelable?,<br>  open val name: String,<br>) : Entry, Parcelable {<br><br>  @Composable<br>  abstract fun ComposableContent()<br><br>  @Composable<br>  fun ComposableEntry(internalNavController: NavController) {<br>      ...<br>      CompositionLocalProvider(...) {<br>        ComposableContent()<br>      }<br>  }<br>}</pre><p>As a result, when navigating to another screen inside the same feature module, the following code should be used.</p><pre>val entry: FragmentEntry = VehicleDetailsEntry.newInstance(it.vehicleId)<br>navigateTo(entry)</pre><h4>Implementation</h4><p>As we’ve discovered above, each screen is represented by an automatically generated underlying class that could be either a <em>fragment entry</em> or a <em>composable entry</em>. In general, we want to minimize the number of fragment entries in a codebase and use them only where it’s truly needed (E.g. when navigating from legacy fragment screens).</p><p>This means we need to make sure we are able to navigate between different types of entries in a seamless and unified way. Overall, the navigation on the UI level is performed using a concept of side effects which is a stream of the events that is consumed using SideEffectHandler composable function as shown below.</p><pre>@UiEntry<br>@Composable<br>fun VehicleSearchScreen(viewModel: VehicleSearchViewModel = ...) {<br>  SideEffectHandler(viewModel.sideEffects) { [this: NavigationController]<br>    when (it) {<br>      // navigating to another screen<br>      is VehicleSearchSideEffect.ShowDetails -&gt; navigateTo(<br>        VehicleDetailsDestination(VehicleDetailsArgs(it.vehicleId))<br>      )<br>    }<br>  }<br><br>  // the rest of the compose UI<br>  ...<br>}</pre><p>The SideEffectHandler is a simple library function that consumes the side effects from the flow and emits them one by one.</p><pre>@Composable<br>fun &lt;SE&gt; SideEffectHandler(<br>    sideEffects: Flow&lt;SE&gt;,<br>    key: Any = Unit,<br>    handle: NavigationController.(SE) -&gt; Unit,<br>) {<br>    val navigationController = LocalNavigationController.current<br>    LaunchedEffect(key1 = key) {<br>        sideEffects.collectLatest { navigationController.handle(it) }<br>    }<br>}</pre><p>The handle lambda above has a receiver of NavigationController type that provides an abstraction over the navigation layer in the app. This way, it is available as this context when subscribing to side effects on the UI layer.</p><p>The code snippet below provides a simplified version of a navigation controller which is able to handle 2 primary cases for navigation: external and internal. Each time it checks the type of next destination and performs the navigation accordingly.</p><pre>class NavigationControllerImpl(...) : NavigationController {<br>  ...<br><br>  override fun navigateTo(externalDestination: ExternalDestination) {<br>      val destinationEntry = destinationsMap<br>        .findEntryFactory(externalDestination.javaClass)<br>        .newInstance(externalDestination)<br>      navigateTo(internalDestination = destinationEntry)<br>  }<br><br>  override fun navigateTo(internalDestination: Entry) {<br>    when (internalDestination) {<br>      is ComposableEntry -&gt; navigateToComposable(internalDestination)<br>      is FragmentEntry -&gt; navigateToFragment(internalDestination)<br>    }<br>  }<br><br>  // perform a fragment transaction<br>  private fun navigateToFragment(destinationFragment: Fragment) { <br>    ... <br>  }<br><br>  // add a new route with the compose navigation library,<br>  // and navigate to it at once.<br>  private fun navigateToComposable(destinationComposable: ComposableEntry) { <br>    ... <br>  }<br>}</pre><p>With that being said, let’s take a look in more detail at each of the use cases for the navigation between the app screens.</p><p><strong>Fragment to compose. </strong>Navigating between different types of screens is the most challenging part as it requires bridging two different tools. In our particular case, we’re using compose navigation library for this purpose. However, its API is not exposed directly to engineers, and thus, any navigation solution could be used in its place under the hood.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bUDvZneh-5kABirPmeDMqw.png" /><figcaption><strong>Fragment</strong> to <strong>compose</strong> navigation</figcaption></figure><p>When we’re navigating from fragments to compose, we’re using compose navigation. This is possible by using a simple trick that sets up a NavHost composable for every fragment entry and has all its contents as a root destination by default.</p><pre>abstract class ComposableFragment : Fragment() {<br><br>  @Composable<br>  abstract fun ComposableContent()<br><br>  override fun onCreateView(...) = ComposeView(...).apply {<br>    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)<br>    setContent {<br>      AppTheme {<br>        ...<br>        NavHost(..., startDestination = &quot;@root&quot;) {<br>          composable(&quot;@root&quot;) {<br>            ComposableContent()<br>          }<br>        }<br>      }<br>    }<br>  }<br>}</pre><p>When navigation to a composable screen is needed, a new destination is dynamically added to the NavHost inside of the NavigationController as shown in the example below.</p><pre>import androidx.navigation.NavController<br><br>class NavigationControllerImpl(<br>  private val internalNavController: NavController,<br>  private val exploredEntries: MutableMap&lt;String, ComposableEntry&gt;<br>) : NavigationController {<br>  ...<br><br>  private fun navigateToComposable(destinationComposable: ComposableEntry) {<br>    val route = destinationComposable.name<br><br>    if (internalNavController.graph.findNode(route) == null) {<br>      // keeping track of dynamically added entries<br>      // will be handy later, when we will be talking<br>      // about handling configuration changes<br>      exploredEntries[route] = destinationComposable      <br><br>      val navigator = internalNavController.navigatorProvider<br>        .getNavigator(ComposeNavigator::class.java)<br><br>      val destination = ComposeNavigator.Destination(navigator) {<br>        destinationComposable.ComposableEntry(...)<br>      }<br>      destination.route = route<br>      internalNavController.graph.addDestination(destination)<br>    }<br><br>    internalNavController.navigate(route)<br>  }<br>}</pre><blockquote>In this context, a destination is not a concept we’ve discussed earlier but rather a class from the <a href="https://developer.android.com/jetpack/compose/navigation">compose navigation library</a>.</blockquote><p><strong>Compose to compose</strong>. This is a pretty straightforward case, as the navigation between composable screens would be done similarly as above using the compose navigation library under the hood.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jaDtzcPwuVRcQv-oGn7jkg.png" /><figcaption><strong>Compose</strong> to <strong>compose</strong> navigation</figcaption></figure><p>When navigating to a compose screen a respective destination is being added to a navigation graph dynamically similarly to what was described above.</p><p><strong>Compose to fragment</strong>. When navigation to a screen that uses legacy fragments is required, a fragment transaction is performed between the fragment that hosts the composable navigation graph (NavHost in our case) and the next destination fragment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oGI0rs0q40oKtRKLVEstSQ.png" /><figcaption><strong>Compose</strong> to <strong>fragment</strong> navigation</figcaption></figure><h4>Handling configuration changes</h4><p><em>Compose navigation library</em> does not retain dynamically added destinations on configuration changes. However, this is how all the destinations are being added in the NavigationControllerImpl as we saw above. Therefore, every time the UI state is restored, all of them will be lost.</p><p>When restoring the state, NavHost will recreate only those destinations that are explicitly defined using a composable function in its builder. By default, our NavHost has only the original root destination declared, as we have already seen.</p><pre>// inside onCreateView of ComposableFragment<br><br>NavHost(..., startDestination = &quot;@root&quot;) {<br>  composable(&quot;@root&quot;) {<br>    ComposableContent()<br>  }<br>}</pre><p>This means that all the dynamically added destinations will be lost after NavHost is recreated. This may cause the app to crash, as the navigation library will try to restore the back stack that remembers routes that no longer exist.</p><p>To solve this issue, every time NavHost is recreated, we need to ensure that we consider all the routes that were added dynamically by storing the respective ComposableEntry instances with rememberSaveable.</p><pre>// survies configuration changes<br>val exploredEntries = rememberSaveable(...) { <br>  mutableMapOf&lt;String, ComposableEntry&gt;() <br>}<br><br>NavHost(..., startDestination = &quot;@root&quot;) {<br>  composable(&quot;@root&quot;) {<br>    ComposableContent()<br>  }<br><br>  // restoring dynamically added routes<br>  for ((route: String, entry: ComposableEntry) in exploredEntries) {<br>    composable(route) {<br>      entry.ComposableEntry(...)<br>    }<br>  }<br>}</pre><p>If you remember, we register each ComposableEntry in the exploredEntries every time we perform the navigateToComposable navigation in the NavigationControllerImpl.</p><blockquote>All the instances of <em>ComposableEntry</em> type implement <em>Parcelable</em> which makes it easy to serialize/deserialize them during configuration changes.</blockquote><h3>To conclude</h3><p>When discussing the architecture of Android apps, topics like dependency injection, view models, and domain/data layers often come up. However, this story intentionally avoids focusing on them, as they can all seamlessly integrate into the setup described here.</p><p>There are various libraries and patterns for implementing view models available out there. All of them could be integrated with the architecture described here. Similarly, if you need to configure the dependency injection tool of your choice, a corresponding setup could be made in the base classes like ComposableFragment, ComposableEntry and their specific descendants that are generated.</p><p>When migrating an Android codebase to Jetpack Compose it is crucial to provide a proper developer experience from day one. Continuing to rely on legacy APIs significantly prolongs their presence in the codebase, making development and maintenance more complex and less flexible. This can restrict the engineering team from fully experiencing the benefits of using Jetpack Compose.</p><p>A highly effective approach for achieving this goal is leveraging code generation. It serves the purpose of concealing a boilerplate code, primarily aimed at ensuring compatibility with the legacy stack. Another notable advantage of utilizing code generation is the flexibility it offers in modifying the implementation of a symbol processor or base architecture classes. Because the codebase relies on annotations, updates, and adjustments can be made to the underlying core components at any time, with minimal or no code changes required.</p><p>Adopting Jetpack Compose helped the engineering team at Turo to be much more efficient when working on product features while keeping the development process more enjoyable and less error-prone.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b11ee5f19ba8" width="1" height="1" alt=""><hr><p><a href="https://medium.com/turo-engineering/designing-jetpack-compose-architecture-for-a-gradual-transition-from-fragments-on-android-b11ee5f19ba8">Designing Jetpack Compose architecture for a gradual transition from fragments on Android</a> was originally published in <a href="https://medium.com/turo-engineering">Turo Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How we reduced our Android startup time by 77%]]></title>
            <link>https://medium.com/turo-engineering/how-we-reduced-our-android-startup-time-by-77-650cc113c3dc?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/650cc113c3dc</guid>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Mon, 10 Apr 2023 19:30:45 GMT</pubDate>
            <atom:updated>2023-04-10T21:53:14.526Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1kn_k4cwBWB76GGhPDL6Uw.jpeg" /><figcaption>Photo by <a href="https://unsplash.com/photos/kZQ8tkJdqKM">Kevin Fitzgerald</a> on <a href="https://unsplash.com/">Unsplash</a></figcaption></figure><h4>Mobile performance at Turo</h4><p>The startup time of a mobile app is one of the most important indicators of its performance and has a significant impact on the user experience. A fast-loading app not only provides a positive first impression, but also increases engagement and retention, reduces user churn, and can improve visibility in app store rankings.</p><p>In this story, we would like to share our experience of reducing the startup time of the Turo Android app. We are going to cover, what improvements we were able to achieve, what steps we performed and how are we measuring it.</p><blockquote>We reduced the cold startup time of Turo Android app by <strong>77%</strong> on average.</blockquote><p>The same metric was improved by <strong>84%</strong> at the <strong>50th</strong><em> </em>percentile. The figure below shows a comparison of reported metrics <em>before</em> and <em>after</em> we improved the startup time of Turo’s Android app. All the values are measured in milliseconds.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fw65Nybu1jmxyVwzjaP7lg.png" /><figcaption>Startup metrics before and after the improvement, measured in milliseconds</figcaption></figure><p>The illustration below demonstrates how the application looks and feels <em>before</em> and <em>after</em> the improvement that is already available for 50% of our users.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/970/1*_UurvCAc4Z7HNHvr7O7NEA.gif" /><figcaption>Illustration before and after the improvement</figcaption></figure><p><em>The duration of the startup before directly depended on the network connection. Therefore there might be cases where it was slightly faster but it could also take even longer to start the app. This is not the case after the improvement.</em></p><h3>The improvement</h3><p>Before jumping into the specifics of the improvements we’ve made, it’s worth examining the startup process of the Turo app prior to the optimization efforts.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BsE8b86UEU9EevSjwQsHOw.png" /><figcaption>Startup process before improvements</figcaption></figure><p>The startup process of the app consisted of the following stages:</p><ul><li><strong>App initialization</strong>. This includes the application process initialization which the developers have little control over. In addition, it includes all the code, the application runs before rendering the first UI frame. <a href="https://developer.android.com/topic/performance/vitals/launch-time">Android Vitals</a> reported the end of this stage as startup complete.</li><li><strong>Synchronous network requests</strong>. The app was performing a number of network requests while showing a custom splash screen before allowing the user to see a home screen.</li><li><strong>Custom splash animation</strong>. After the network requests were completed, the app was running a custom splash animation that takes roughly 1 second. After that, the app navigated from SplashActivity to HomeActivity.</li><li><strong>Home skeleton. </strong>At this point, the user is able to partially interact with the app while waiting for the home content to load and seeing a shimmer animation. While this stage was already cacheable, there were some improvements required there as well.</li></ul><h4>Splash screen</h4><p>One easy way we were able to improve the startup time of the app was by removing a custom splash animation that was displayed on each launch. While the animation was part of the design language, it was launched after all the synchronous network requests during startup, causing a delay in the overall startup time. By removing the animation, we were able to save approximately 1 second.</p><p>Now that we’ve tackled this item, let’s move on to more technically interesting aspects of startup improvement.</p><p>Initially, we used a dedicated SplashActivity to run all the startup work before routing the app to the HomeActivity. However, the <a href="https://developer.android.com/develop/ui/views/launch/splash-screen/migrate#remove_the_custom_splash_screen_activity">latest guidelines</a> advise against this approach. Therefore, we eliminated the redundant SplashActivity and transferred all the startup logic to our root activity by leveraging the <a href="https://developer.android.com/develop/ui/views/launch/splash-screen">Splash Screen API</a>. This allowed for a unified experience across all Android versions and, as a bonus, improved our startup metrics in the Google Play Console’s <a href="https://developer.android.com/topic/performance/vitals/launch-time">Android Vitals</a> as shown in a scheme below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J-2-7DQMh0Q4IJSBjuvWvA.png" /><figcaption>Splash screen API helps to correct Android Vitals metrics</figcaption></figure><p><em>If an app displays a custom image or animation as a splash screen, the startup time metric in Android Vitals may not accurately reflect the actual startup time. This is because the time spent displaying the custom screen is not counted as part of the startup process. To address this issue, using a Splash Screen API can help explicitly define when the startup process is complete, resulting in more accurate reporting.</em></p><h4>Deferring synchronous network requests</h4><p>Our app was performing a series of network requests while displaying the splash screen in order to fetch the necessary data before users could interact with it. This led to a noticeable slowdown in the startup process of the app, particularly when the network connectivity was poor.</p><p>In addition, the app was performing a different number of startup network requests depending on whether the app is used in <em>guest</em>, <em>host</em> modes, or <em>unauthenticated. </em>This resulted in inconsistent experiences for different types of users.</p><p>As an example, if the app is used in <em>guest </em>mode, a few of the requests help to decide which screen should be shown after the splash screen. By default, it&#39;s the home screen. However, there are special cases where the user could be redirected either straight to an active vehicle reservation or to a feedback screen where they can rate their last experience booking a vehicle.</p><p>Since in the majority of cases, it will be a home screen, we don’t want to slow down every app startup by checking for a minority of scenarios. Therefore, we now open a home screen by default while making those network requests asynchronously. If any redirect is required, a corresponding screen is displayed on top of a home screen.</p><h4>Fetching feature flags</h4><p>While it’s a relatively straightforward optimization in many cases, there are network requests that require special handling. When fetching feature flags from the network, it is not sufficient just to run the network request asynchronously.</p><p>If no feature flag is requested immediately after the app startup, the home screen contents are displayed instantly with no issues.</p><p>However, what if we need to use a feature flag on a home screen? In this case, the network request won’t make it in most cases. This results in using the value from the cache which might be outdated or absent in many cases which in particular may lead to incorrect results for A/B tests.</p><p>To solve the problem, we’re starting to fetch feature flags in the background right on app launch. If any feature flag is required before the request is complete, such a call is suspended until they are fetched from the server. Meanwhile, we’re showing a home screen skeleton with a shimmer animation. All the subsequent feature flag reads in the app are instant as shown in the figure below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DyfXmsIJ6AUGwIZzxlbt2Q.png" /><figcaption>Asynchronous fetching of feature flags on startup</figcaption></figure><p>In order to avoid showing the loading skeleton on each subsequent app startup, we’re using a short-term cache for feature flags. This could be a few hours, a day, or a few days. We are still experimenting with the exact cache expiration values.</p><h4>Baseline Profiles</h4><p>After we removed all the synchronous network requests our startup duration became more deterministic and now it makes sense to apply <a href="https://developer.android.com/topic/performance/baselineprofiles/overview">Baseline Profiles</a>. This is the feature that helps to pre-compile startup code paths ahead of time and boost the app startup time.</p><p>Applying baseline profiles to our app resulted in approximately 15% of improvement according to <a href="https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile">Macrobenchmark</a> results (when compared to the run with no compilation applied).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UXDWi8OPuoTxzbwx4Cqk5g.png" /><figcaption>Results of startup benchmarks</figcaption></figure><h4>Optimizing disk I/O operations</h4><p>When investigating the ways to improve startup times for our app, we noticed that it performs a bunch of disk I/O operations during the startup.</p><p><strong>Strict mode violations</strong>. <a href="https://developer.android.com/reference/android/os/StrictMode">StrictMode</a> is a tool that among other things helps to detect accidental disk I/O operations performed on a main thread. Such operations are especially unwelcome during the application’s startup and it is recommended to defer them or move to a background thread. This tool should be used for <em>debug</em> builds only. When configured, it will post events in <em>logcat</em> that look like the example below and include stack traces that help to identify the source of the issue.</p><pre>StrictMode policy violation; ~duration=107 ms: android.os.strictmode.DiskReadViolation</pre><p>This helped us to identify a bunch of reads from the local storage on the UI thread that could be moved to the background.</p><p><strong>Shared preferences</strong>. When investigating logs from<em> strict mode</em>, we identified that a bunch of the issues, in particular, were caused by reads from SharedPreferences. Therefore, some of them were moved to a background thread. Others, however, needed to be performed before the home screen is displayed, so the code that accesses them was deferred until the very end of the app initialization.</p><p>It is important to emphasize, that SharedPreferences start reading from the disk the moment the object is initialized according to the documentation of context.getSharedPreferences</p><blockquote>Retrieve and hold the contents of the preferences file ‘name’, returning a SharedPreferences through which you can retrieve and modify its values.</blockquote><pre>context.getSharedPreferences(&quot;my-prefs&quot;, Context.MODE_PRIVATE)</pre><p>Often, these objects are eagerly initialized and injected into constructors using dependency injection, which can lead to early disk reads on the UI thread.</p><h4>Lazy initialization</h4><p>A quick solution for the issue with SharedPreferences is to ensure they are being initialized lazily. If Dagger is used as a dependency injection framework in a project, the dagger.Lazy could help to achieve this goal as shown in the example below.</p><pre>class LocalDataSource(val prefs: dagger.Lazy&lt;SharedPreferences&gt;) { <br>  <br>  fun getData(): String? {<br>    return prefs.get().getString(&quot;data&quot;, null)<br>  }<br>}</pre><p>In addition, the lazy injection could be used for other types of classes that might perform extensive work on the UI thread during their initialization.</p><h4>Application lifecycle callbacks</h4><p>Another factor that could negatively impact the startup time is the callbacks that are tied to the app’s lifecycle:</p><ul><li><a href="https://developer.android.com/reference/android/app/Application.ActivityLifecycleCallbacks">Application</a><a href="https://developer.android.com/reference/android/app/Application.ActivityLifecycleCallbacks"><em>.</em></a><a href="https://developer.android.com/reference/android/app/Application.ActivityLifecycleCallbacks"><strong>ActivityLifecycleCallbacks</strong></a> — and especially its functions onActivityCreated, onActivityStarted, and onActivityResumed.</li><li><a href="https://developer.android.com/reference/androidx/fragment/app/FragmentManager.FragmentLifecycleCallbacks">FragmentManager.</a><a href="https://developer.android.com/reference/androidx/fragment/app/FragmentManager.FragmentLifecycleCallbacks"><strong>FragmentLifecycleCallbacks</strong></a><em> </em>— and especially its functions onFragmentCreated, onFragmentViewCreated, and onFragmentResumed.</li></ul><p>Those are easy to miss at the first glance but they could hold a heavy initialization code that would obviously, slow down the app startup time. Moreover, those callbacks could be implicitly used by other third-party SDKs.</p><h4>Third-party SDKs</h4><p>Speaking of third-party SDKs. Many of them suggest they should be initialized in the Application.onCreate. Unfortunately, in some cases, their initialization logic might not be the most efficient in terms of performance. While some of them are pretty easy to fix just by moving their initialization in a background thread or even deferring it until they are actually used, others might be more tricky and take more effort to remove or adapt them.</p><p>As an example, we’re using an SDK that needs to be tied to the lifecycle of every Activity. Moreover, it is implicitly initialized under the hood the first time onActivityCreated is called performing disk I/O and other operations in the UI thread slowing down the app startup. While it’s possible to improve its performance during the initialization it requires additional effort and such cases are something one should be prepared to deal with.</p><h4>Home screen initialization</h4><p>While the Turo Android app already used a local cache for the <em>home screen</em> contents we’ve found an issue that forced the app to show the loading skeleton on every app startup. It turned out one of the network requests at one point started to return a <em>nullable</em> value where a <em>non-null</em> field was expected on a client. This caused the serialization error but was suppressed by one of the RxJava operators like <a href="https://reactivex.io/documentation/operators/catch.html">onErrorResumeNext</a>. Eventually, this led to a portion of the <em>home screen</em> contents to be always requested from the network causing a loading delay, since it was always failing to be cached.</p><h3>Measuring the improvement</h3><p>Making performance improvements is surely an important task, but measuring it is essential.</p><h4>Configuring an A/B test</h4><p>The described startup improvement is already available to 50% of our users as part of the ongoing A/B test. Since we got rid of the old SplashActivity and moved all the startup logic to the HomeActivity, the legacy startup configuration is being simulated in the latter with the help of <em>splash screen API </em>which covers the screen while the old network requests and I/O operations are being performed.</p><p>In order to decide whether the user should be placed in <em>control (slow startup) </em>or <em>treatment (fast startup) </em>we don’t use a traditional feature flag requested from the remote server. This is due to the fact, that it should be requested very early in the app lifecycle which may lead to issues with consistency. Instead, we use a pretty straightforward method to divide users into experiment groups locally on the device, ensuring a <em>50/50</em> split as shown below.</p><pre>val deviceId: String = &lt;locally generated and cached UUID&gt;<br><br>val variant = when (abs(deviceId.hashCode() % 2)) {<br>    0 -&gt; Variant.SLOW_STARTUP<br>    1 -&gt; Variant.FAST_STARTUP<br>    else -&gt; error(&quot;unreachable branch&quot;)<br>}</pre><p>This code narrows down a string into one of two possible numbers: <em>0</em> or <em>1 </em>where each of them is associated with a corresponding variant. In case the string is a randomly generated UUID, the <em>50/50</em> split is guaranteed. In our case, we use a locally generated and cached deviceId.</p><p><em>The experiment is presently in progress, and we are actively gathering and assessing the data.</em></p><h4>Measuring the startup time</h4><p>In addition, we needed to measure the startup time of the app itself. We could not rely on tools like Android Vitals as we needed to have a clear distinction between both variants of an A/B test. Instead, we needed a simple tool that allows identifying a <em>cold startup time</em> and reporting it to our analytics under the corresponding experiment variant.</p><p>In order to measure the startup time, we needed to identify 2 points in time: startup <em>beginning</em> and <em>end</em>.</p><p><strong>Startup beginning</strong>. We consider a time when the system calls ContentProvider.onCreate a starting point for measurements. Why content provider? Simply because content providers are one of the first things written by the developer that is initialized in the app, even before the application class.</p><p>We also track the time since the process started using <a href="https://developer.android.com/reference/android/os/Process#getStartUptimeMillis%28%29">Process.getStartUptimeMillis</a>. However, the majority of the app process initialization is not under the developer’s control, so counting the startup time from this point might be slightly redundant. Therefore, we rely on the former value in the scope of this blog post.</p><p><strong>Startup end. </strong>Since we’re using a <em>splash screen API</em> it might come in handy while identifying when a startup is complete. It has a setKeepOnScreenCondition callback that is called when a splash screen is about to be closed. Under the hood, it’s just OnPreDrawListener<em>. </em>The most straightforward way to do it is shown below.</p><pre>class HomeActivity : AppCompatActivity() {<br>  <br>  override fun onCreate(...) {<br>    val splashScreen = installSplashScreen()<br>    super.onCreate(...)<br><br>    splashScreen.setKeepOnScreenCondition {<br>      handler.postAtFrontOfQueue {<br>        reportStartupComplete()<br>      }<br>      false<br>    }<br>  }<br>}</pre><p>We report the app startup time as a difference between the <em>end</em> and <em>beginning</em> points while a timestamp value for each of them is measured using <a href="https://developer.android.com/reference/android/os/SystemClock#uptimeMillis()">SystemClock.uptimeMillis</a> call.</p><blockquote>If you would like to know more about what metrics to pick in order to measure the Android app startup time or how to identify a cold startup<em>,</em> we highly recommend checking this series of blog posts from Pierre-Yves Ricau: <a href="https://dev.to/pyricau/android-vitals-why-did-my-process-start-4d0e">1</a>, <a href="https://dev.to/pyricau/android-vitals-when-did-my-app-start-24p4">2</a>, <a href="https://dev.to/pyricau/android-vitals-first-draw-time-m1d">3</a>.</blockquote><h4>The issue?</h4><p>Regardless of what metric is being used as a startup beginning, after analyzing the reported data we’ve noticed that the app reported a bunch of enormously large values (e.g. in some cases the app reported startup to take minutes, hours, or even days). As it turns out, in some cases, the app process might be started way before the user clicks on the app icon to launch the app.</p><p>After analyzing the source code of existing startup monitoring solutions on Android like <a href="https://github.com/firebase/firebase-android-sdk/blob/89291239b04e8dddd0d697a65c5581055c24bf46/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java#L76">Firebase</a>, we can see this issue is handled by simply ignoring any value that is greater than 60 seconds. Therefore, we followed a similar approach.</p><h4>Verifying the data</h4><p>But how can we confirm that filtering out all values greater than 60 seconds will keep the reported metrics precise?</p><p>What helped us to verify it is the fact we‘re running a startup A/B test in 2 iterations. The reader might remember, the first change we made was removing a custom splash animation that takes roughly 1 second. This was the only change for the first iteration of the experiment and it provided a static difference in startup time between the <em>control</em> and <em>treatment</em>.</p><p>We analyzed the reported metrics while filtering out all the values greater than 60 seconds. By doing so we observed a static 1-second difference at each percentile between experiment groups. On the other hand, when the filtering was not applied, the difference was growing noticeably the closer we moved to the 99th percentile. Therefore, we confirmed that filtering out values greater than 60 seconds produces the correct result.</p><h3>Learnings</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZaDydKMcMcAbVR0UO58Rwg.jpeg" /><figcaption>Photo by <a href="https://unsplash.com/photos/u6BPMXgURuI">Gabriel Gurrola</a> on <a href="https://unsplash.com/">Unsplash</a></figcaption></figure><p>There are multiple reasons why the startup process of the app might be slowed down. It’s important to start the improvement with those tasks that require the least effort and produce the maximum result. When moving to the other side of the equation, it’s always important to evaluate each step and see if it is worth the effort as compared to the possible improvement it provides.</p><p>Due to the significant difference in performance metrics between <em>before</em> and <em>after </em>states<em>, </em>this project helped to ignite interest in other areas of mobile performance and developer productivity both on Android and iOS platforms at Turo.</p><p>While improving startup time is a challenging task, measuring it brings its own additional challenge. There is no simple API on Android that would provide it. Finding a proper way to report startup metrics requires trial and error while testing every idea or hypothesis is slowed down by the product release process and its gradual rollout to the users.</p><p>In many cases, engineering faces a conflict between allocating time and resources either on product work or performance optimization. However, treating performance as a standalone product feature is inevitable for its success.</p><h4>Resources</h4><ul><li><a href="https://developer.android.com/topic/performance/vitals/launch-time#cold">App startup time — developers.android.com</a></li><li><a href="https://developer.android.com/topic/performance/baselineprofiles/overview">Baseline Profiles — developers.android.com</a></li><li><a href="https://developer.android.com/reference/android/os/StrictMode">StrictMode — developers.android.com</a></li><li><a href="https://dev.to/pyricau/android-vitals-why-did-my-process-start-4d0e">Android Vitals. Why did my process start? — blog by P.Y. Ricau</a></li><li><a href="https://dev.to/pyricau/android-vitals-when-did-my-app-start-24p4">Android Vitals. When did my app start? — blog by P.Y. Ricau</a></li><li><a href="https://dev.to/pyricau/android-vitals-first-draw-time-m1d">Android Vitals. First draw time — blog by P.Y. Ricau</a></li><li><a href="https://github.com/firebase/firebase-android-sdk/blob/89291239b04e8dddd0d697a65c5581055c24bf46/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java">firebase/firebase-android-sdk — github.com</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=650cc113c3dc" width="1" height="1" alt=""><hr><p><a href="https://medium.com/turo-engineering/how-we-reduced-our-android-startup-time-by-77-650cc113c3dc">How we reduced our Android startup time by 77%</a> was originally published in <a href="https://medium.com/turo-engineering">Turo Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Detecting Android memory leaks in production]]></title>
            <link>https://eng.lyft.com/detecting-android-memory-leaks-in-production-29e9c97e2ba1?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/29e9c97e2ba1</guid>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[metrics]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[mobile-development]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Tue, 17 Jan 2023 16:22:58 GMT</pubDate>
            <atom:updated>2023-01-19T19:45:36.857Z</atom:updated>
            <content:encoded><![CDATA[<h4>Monitoring mobile performance and resource consumption at Lyft</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*-Lr6r7x-qsMvgaH_" /><figcaption>Photo by <a href="https://unsplash.com/photos/yo01PjvICZE">Nathan Lange</a>r on <a href="https://unsplash.com/">Unsplash</a></figcaption></figure><p>Android developers have a number of tools in their arsenal for detecting memory leaks such as <a href="https://developer.android.com/studio/profile/memory-profiler">Android Studio Memory Profiler</a>, <a href="https://square.github.io/leakcanary/">LeakCanary</a>, <a href="https://perfetto.dev/">Perfetto</a>, etc. These tools are useful when analyzing app builds locally. However, in production the app is running on a wide range of devices in different circumstances and it is hard to predict all of the edge cases when profiling the build locally.</p><p>At Lyft, we were curious about how our apps behave in production on users’ devices. Thus, we’ve decided to bring observability to various runtime performance metrics and see how it could improve the user experience.</p><p>We’ve already published a blog post about <a href="https://medium.com/lyft-engineering/monitoring-cpu-performance-of-lyfts-android-applications-4e36fafffe12"><em>CPU usage monitoring</em></a> and in this story, we will focus on the <em>memory footprint</em> of mobile apps. While the overall concept of monitoring the memory footprint is applicable to both Android and iOS platforms, we will focus on the former for implementation details.</p><p>Lyft relies on A/B testing when rolling out new features. When a feature is ready for production, it’s covered by a feature flag and is launched as part of an experiment. This experiment is run for a certain group of users in order to compare metrics against the standard version of the app.</p><p>When a large and complex feature is released, it is important to make sure it does not bring any regressions in terms of memory usage. This is especially important if the feature includes native C/C++ code which has a higher chance of introducing memory leaks.</p><p><strong>Therefore, we wanted to test</strong> <strong>the following hypothesis</strong>. For each feature experiment, we measure its memory footprint across all users that have access to it (by reporting metrics to analytics at runtime). Then, we compare it to the standard version of the app. If the variant shows larger memory usage values, this is an indicator of a regression or memory leak.</p><h3>Memory footprint metrics</h3><p>First, we needed to identify the availability of memory metrics on Android, which are not as trivial to collect as one might think. We are interested in the memory usage by the application process or in other words the app’s <strong>memory footprint</strong>.</p><p>Android provides various APIs for retrieving memory usage metrics for apps. However, the hardest part is not retrieving the metrics but making sure they are suitable and provide meaningful data.</p><p>Since Android Studio has a built-in memory profiler, we decided to use it as a reference point. If we get the same value as the memory profiler, our data is correct.</p><p>One of the primary metrics the Android Studio memory profiler shows is called PSS.</p><h4>PSS</h4><p><em>Proportional set size </em>(<em>PSS</em>)<em> </em>— the amount of <strong>private</strong> and <strong>shared</strong> memory used by the app where the amount of shared memory is <strong>proportional</strong> to the number of processes it is shared with.</p><blockquote>For example, if 3 processes are sharing 3MB, each process gets 1MB in PSS.</blockquote><p>Android exposes a Debug API for this data.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/9c9684d21b945dfcb3c9c21e70a3e4dd/href">https://medium.com/media/9c9684d21b945dfcb3c9c21e70a3e4dd/href</a></iframe><p>Debug.MemoryInfo includes PSS as well as the number of components it consists of. The easiest way to view the summary of the data it holds is by calling the getMemoryStats function. It yields key-value pairs with the metrics as shown in the example below.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/85258ea9bbdac9882107631a0d378279/href">https://medium.com/media/85258ea9bbdac9882107631a0d378279/href</a></iframe><p><strong><em>These are the numbers Android Studio normally displays in the Memory Profiler.</em></strong></p><p>In order to get the PSS value without any additional info, it is possible to use the call below.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/a851beba5da21879a85d936e9d87103f/href">https://medium.com/media/a851beba5da21879a85d936e9d87103f/href</a></iframe><h4>USS</h4><p><em>Unique set size</em> (<em>USS</em>) — the amount of <strong>private</strong> memory used by the app excluding shared memory.</p><p>Its value can be derived from the PSS metrics we’ve seen above. In order to get it programmatically, we can again use Debug.MemoryInfo.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e5bb5e36a1b5724ba9944485bf06c476/href">https://medium.com/media/e5bb5e36a1b5724ba9944485bf06c476/href</a></iframe><h4>What’s wrong with PSS and USS?</h4><p>PSS and USS are useful but calling Debug.getMemoryInfo and Debug.getPss can take at least 200ms and 100ms respectively.</p><p>We need to report memory metrics regularly, so it’s not the best idea to use time-consuming APIs for this.</p><p>There is a faster Android API for PSS, but there is a catch. It has a hard sample rate limit of 5 minutes. This means if called more frequently, it returns a cached value from a previous call. This won’t serve our use case well.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/ab0de0015387aa62b23e8f10ebfa7984/href">https://medium.com/media/ab0de0015387aa62b23e8f10ebfa7984/href</a></iframe><p>However, there is a similar metric called RSS.</p><h4>RSS</h4><p><em>Resident set size</em> (<em>RSS</em>) — the amount of <strong>private</strong> and <strong>shared</strong> memory used by the app where <strong>all</strong> shared memory is included.</p><blockquote>For example, if three processes are sharing 3MB, each process gets 3MB in RSS.</blockquote><p>This means that RSS values are pessimistic as they show more memory than the application has actually used. This metric is much faster to retrieve compared to PSS. Moreover, it is fine to use RSS values because we are more interested in comparing variants of the A/B experiment so that we can sacrifice precision to some extent here as a tradeoff.</p><blockquote>Overall, if comparing <strong>set size</strong> metrics at any given point in time, USS would always show the smallest value while RSS is the largest: <strong>RSS</strong> &gt; <strong>PSS</strong> &gt; <strong>USS</strong><em>.</em></blockquote><p>In order to get RSS programmatically, we need to refer to a system file. This file is located at /proc/<strong>[pid]</strong>/statm, where <strong>[pid] </strong>is the ID of an application process. android.os.Process.myPid() can be used to get [pid] programmatically.</p><p>Reading this file yields something like this:</p><pre>3693120 27503 18904 1 0 319129 0</pre><p><a href="https://man7.org/linux/man-pages/man5/proc.5.html">Official Linux documentation</a> helps to shed light on the meaning of these numbers. We are only interested in the second value.</p><ul><li>(2) resident — resident set size, represented in pages.</li></ul><p>The default page size on Linux is 4 kB. Therefore, in order to calculate the RSS metric we use the simple formula below.</p><pre>rssKb = resident * 4</pre><p>We ended up using <strong>RSS</strong> as a <strong>primary metric</strong> for identifying the app’s memory footprint.</p><p>However, that is not all. Memory, private to the app process, includes many components and one of them is the <strong>heap</strong>.</p><p>In order to increase the precision of measurements, we can additionally report the amount of memory allocated by the app in the JVM and native heaps. This might come in handy when trying to narrow down a regression detected with the RSS metric.</p><h4><strong>JVM heap</strong></h4><p>The first metric will help us to identify the amount of memory allocated by the app in the JVM heap.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e19ad8c3f1e470b9f6969a98f8b62443/href">https://medium.com/media/e19ad8c3f1e470b9f6969a98f8b62443/href</a></iframe><h4><strong>Native heap</strong></h4><p>The second metric shows the same but in the native heap. It is especially useful when the app includes custom native C/C++ libraries.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/52915c77add712493d5cd59a4cb8fba8/href">https://medium.com/media/52915c77add712493d5cd59a4cb8fba8/href</a></iframe><h3>Detecting memory leaks</h3><p>Now that we have identified memory usage metrics, let’s see how to use them to catch regressions.</p><p>First, we need to decide when and how often should we report memory metrics to the analytics. We have picked two scenarios:</p><ul><li>Report a snapshot with metrics every time a UI screen closes.</li><li>Report a snapshot with metrics periodically if a user stays on a single UI screen for a long time. <em>Normally, we use 1-minute intervals but this can be configured remotely.</em></li></ul><p>Now, let’s see how to interpret the data.</p><p>A/B experiments allow us to compare reported values between two app variants:</p><ul><li><strong>treatment</strong> — the group of users that use the app with the new feature enabled.</li><li><strong>control</strong> — the group of users that use the app in a normal state with the new feature disabled.</li></ul><h4>Example 1 — no regressions</h4><p>First, as a baseline, we will take a look at a feature which has not introduced any regressions in terms of memory footprint.</p><p>This is an example of an experiment that adds a feature to the Lyft Android app for passengers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Xxh481zdHmZQdrY44eUYIw.png" /><figcaption>Example 1 — an experiment that <strong>did not</strong> introduce any regressions</figcaption></figure><p>As we can see above, both <em>control</em> (<em>green line</em>) and <em>treatment</em> (<em>orange line</em>) variants are the same. This means that the feature has not introduced any regressions and is safe to roll out to production from a memory usage perspective.</p><h4>Example 2 — regression</h4><p>Now let’s take a look at a feature that introduced a regression.</p><p>This is an example of an experiment that adds a feature to the Lyft Android app for drivers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ItHlUjb9k_RO_eXtNwZTRg.png" /><figcaption>Example 2 — an experiment that introduced a<strong> </strong>memory usage <strong>regression</strong></figcaption></figure><p>The new feature here has clearly increased the app’s memory footprint at each percentile. This is an indicator of a regression that allowed us to identify a memory leak.</p><p>This graph is based on the RSS metric. In order to narrow down the root cause of the issue similar graphs with JVM and native heap allocations were used.</p><h4>Example 3 — memory leak at 99th percentile</h4><p>The final example is the most important as it demonstrates the biggest advantage of this memory monitoring approach.</p><p>In the graph below both variants show almost the same values, except for a noticeable difference around the 99th percentile.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IyJKRKX3ab42LaigB8cKNg.png" /><figcaption>Example 3 — an experiment that introduced a <strong>memory leak </strong>at the <strong>99th</strong> percentile</figcaption></figure><p>The memory footprint of the treatment variant has significantly increased at the 99th percentile.</p><p>This led to identifying a memory leak that occurred in very specific circumstances for a small number of users. However, when it occurred it significantly increased memory usage.</p><p>In the case of the second example, it is more likely to detect a memory leak that affects all usages of the app when profiling locally. However, it becomes a much harder task with the third example<em> </em>as the memory leak was related to an edge case which is easy to miss locally.</p><h3>Learnings</h3><p>One of the challenges with implementing such a memory monitoring tool is to pick the right metrics that show valid data. On the other hand, it is important that collecting those metrics is not time- or resource-consuming.</p><p>Another task is to visualize the data in an effective report. Comparing the average values between <em>control</em> and <em>treatment</em> variants on a daily basis does not produce meaningful data. A better approach is to use a percentile distribution from the very beginning of the experiment as shown in the examples above.</p><p>Overall, the longer the experiment runs the better data is. It requires a few days to start getting meaningful data after an experiment launch. There is also a dependency on how many users are exposed to the experiment.</p><p>This approach is especially useful when a memory leak appears around the 99th percentile. This means a memory leak happens due to a specific edge case that would be much harder to detect with local profiling.</p><p>Using tools for local memory leak detection is an important task to prevent promoting regressions to users. Reporting performance metrics at runtime shines a light on issues that would be much harder to detect otherwise.</p><h4>Resources</h4><ul><li><a href="https://developer.android.com/topic/performance/memory-management#calculating_memory_footprint">Memory allocation among processes — developers.android.com</a></li><li><a href="https://youtu.be/w7K0jio8afM?t=738">Understanding Android memory usage — Google I/O ‘18</a></li><li><a href="https://stackoverflow.com/a/2299813/14281926">How do I discover memory usage of my application in Android? — stackoverflow.com</a></li><li><a href="https://man7.org/linux/man-pages/man5/proc.5.html">proc(5) — Linux manual page</a></li></ul><p><em>If you’re interested in working on tooling and performance at Lyft then take a look at our </em><a href="https://www.lyft.com/careers"><em>careers page</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=29e9c97e2ba1" width="1" height="1" alt=""><hr><p><a href="https://eng.lyft.com/detecting-android-memory-leaks-in-production-29e9c97e2ba1">Detecting Android memory leaks in production</a> was originally published in <a href="https://eng.lyft.com/">Lyft Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bazel in Vulkan Projects. Part 2 — Shaders]]></title>
            <link>https://medium.com/codex/bazel-in-vulkan-projects-part-2-shaders-35cdeb0f4c67?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/35cdeb0f4c67</guid>
            <category><![CDATA[graphics-programming]]></category>
            <category><![CDATA[vulkan]]></category>
            <category><![CDATA[bazel]]></category>
            <category><![CDATA[shaders]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Mon, 14 Nov 2022 16:32:21 GMT</pubDate>
            <atom:updated>2023-10-23T22:54:19.664Z</atom:updated>
            <content:encoded><![CDATA[<h3>Building Vulkan shaders with Bazel</h3><h4>Bazel for Vulkan projects</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yKHMCYyVaIATrEZae_f8VQ.jpeg" /><figcaption>Image by <a href="https://unsplash.com/photos/OiiVv1iiB0A">Javier Miranda</a> on <a href="https://unsplash.com/">Unsplash</a></figcaption></figure><p>This is the second part of a series of blog posts about building projects that use Vulkan graphics API with Bazel build system. In the <a href="https://medium.com/codex/vulkan-in-bazel-projects-part-1-environment-setup-e9fc2ec51b">first part</a>, we performed an environment setup for a simple Vulkan project that uses GLM and GLFW libraries. In this blog post, we are going to expand our knowledge and see how to build GLSL shaders with Bazel and run a project that draws a triangle.</p><p>Be sure to check <a href="https://medium.com/codex/vulkan-in-bazel-projects-part-1-environment-setup-e9fc2ec51b">part 1</a> before proceeding with this blog post.</p><p>You can find a complete source code of a sample project on GitHub.</p><p><a href="https://github.com/Morfly/vulkan-bazel-samples">GitHub - Morfly/vulkan-bazel-samples: A sample repo for setting up an environment for applications that use Vulkan graphics API built with Bazel</a></p><h3>Building shaders</h3><p><strong>OpenGL Shading Language</strong> (<strong>GLSL</strong>) — is a high-level shading language with a syntax based on the C programming language. It is also supported by Vulkan.</p><p>We are going to start with creating simple <a href="https://vulkan-tutorial.com/code/09_shader_base.vert">vector</a> and <a href="https://vulkan-tutorial.com/code/09_shader_base.frag">fragment</a> shaders for building a triangle. The source code was taken from the <a href="https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipeline_basics/Shader_modules">official Vulkan tutorial</a>, so feel free to check it out for more details about the shaders code itself.</p><p>Create a shader.vert file and put it in the triangle/shaders directory in the project we used in <a href="https://medium.com/codex/vulkan-in-bazel-projects-part-1-environment-setup-e9fc2ec51b">part 1</a> of this series.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/6beb221cf968f512b4eb2c0b2d35aac9/href">https://medium.com/media/6beb221cf968f512b4eb2c0b2d35aac9/href</a></iframe><p>Similarly, create a file for a fragment shader called shader.frag under the triangle/shaders directory. Add there the code below.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/a2bd25f2a5aa87409aafc0dcdec191cc/href">https://medium.com/media/a2bd25f2a5aa87409aafc0dcdec191cc/href</a></iframe><p>Now, we are ready to create a Bazel target that is going to build our shaders. We are going to use a glsl_shader rule from rules_vulkan we previously included in the project. Create a BUILD file in the same directory and populate it with the following code.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e0566dd3bce0ff4d44a42575c5df41db/href">https://medium.com/media/e0566dd3bce0ff4d44a42575c5df41db/href</a></iframe><p>To verify all was done correctly, run the following commands in the terminal and make sure shaders are built successfully.</p><pre>bazel build //triangle/shaders:vert_shader<br><br>bazel build //triangle/shaders:frag_shader</pre><p>Here is the directory structure we have so far.</p><pre>vulkan-bazel-samples<br>├── <strong>triangle</strong><br>│   └── <strong>shaders</strong><br>│       ├── <strong>shader.frag</strong><br>│       ├── <strong>shader.frag</strong><br>│       └── <strong>BUILD</strong><br>│<br>├── env_setup<br>├── third_party<br>└── WORKSPACE</pre><p>Finally, we are ready to build our application that will use these shaders and draw a triangle.</p><h3>Drawing a triangle</h3><p>In order to build the app that draws a triangle, we will use a source code from an <a href="https://vulkan-tutorial.com/Drawing_a_triangle/Swap_chain_recreation">official Vulkan tutorial</a>. Create a main.cpp file and place it in the triangle directory. Fill it with the <a href="https://vulkan-tutorial.com/code/17_swap_chain_recreation.cpp">source code</a> copied from the Vulkan tutorial.</p><p>We won’t be focusing on the code itself as everything would be identical to the Vulkan tutorial. However, we would need to do a slight correction to the code for our project. Open main.cpp file and find a createGraphicsPipeline function declaration. Here, we would need to edit its first 2 lines where we change paths to our compiled shaders as shown below.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/799ffdf1340235ba5cb078764ae2a168/href">https://medium.com/media/799ffdf1340235ba5cb078764ae2a168/href</a></iframe><p>Now, the only thing left is to define a Bazel target that will build and run our program. To do so, create a BUILD file inside a triangle directory. We are going to use a standard cc_binary rule.</p><p>In the deps parameter we need to include glfw and glm libraries we defined in the previous blog post. In addition, we need to add a Vulkan library itself.</p><p>In the data parameter, we are referencing the shader targets we’ve just created.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b34e5f56adbc82df0457cdeee2d46a6a/href">https://medium.com/media/b34e5f56adbc82df0457cdeee2d46a6a/href</a></iframe><p>Eventually, the final folder structure of our sample project looks like below.</p><pre>vulkan-bazel-samples<br>├── triangle<br>│   ├── shaders<br>│.  │<br>│   ├── <strong>main.cpp</strong><br>│   └── <strong>BUILD</strong><br>│<br>├── env_setup<br>├── third_party<br>└── WORKSPACE</pre><h4>Running the app</h4><p>This is all we need to run a triangle Vulkan app with Bazel.</p><p>Now, use the following command to build and launch our application.</p><pre>bazel run //triangle</pre><p>If all is done correctly, it will run an app that draws a triangle on the screen as shown below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2IXrotgjfEMsc0fq3s62oQ.png" /></figure><h3>Conclusion</h3><p>This is it! In this short series of blog posts, we’ve seen how to perform a basic setup to be able to use Bazel in projects with Vulkan graphics API.</p><p>In <a href="https://medium.com/codex/vulkan-in-bazel-projects-part-1-environment-setup-e9fc2ec51b">part 1</a> we performed an environment setup for the project and now we ran a basic triangle app built with Bazel.</p><p>All the source code from this blog post could be found on GitHub.</p><p><a href="https://github.com/Morfly/vulkan-bazel-samples">GitHub - Morfly/vulkan-bazel-samples: A sample repo for setting up an environment for applications that use Vulkan graphics API built with Bazel</a></p><h4>References</h4><ul><li><a href="https://github.com/jadarve/rules_vulkan"><strong>rules_vulkan</strong></a><strong> </strong>— a repository that enables Bazel support for Vulkan projects, created by Juan David Adarve.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=35cdeb0f4c67" width="1" height="1" alt=""><hr><p><a href="https://medium.com/codex/bazel-in-vulkan-projects-part-2-shaders-35cdeb0f4c67">Bazel in Vulkan Projects. Part 2 — Shaders</a> was originally published in <a href="https://medium.com/codex">CodeX</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bazel in Vulkan Projects. Part 1 — Environment Setup]]></title>
            <link>https://medium.com/codex/vulkan-in-bazel-projects-part-1-environment-setup-e9fc2ec51b?source=rss-2da4afadaa0c------2</link>
            <guid isPermaLink="false">https://medium.com/p/e9fc2ec51b</guid>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[vulkan]]></category>
            <category><![CDATA[technology]]></category>
            <category><![CDATA[bazel]]></category>
            <dc:creator><![CDATA[Pavlo Stavytskyi]]></dc:creator>
            <pubDate>Fri, 06 May 2022 20:54:11 GMT</pubDate>
            <atom:updated>2023-10-23T22:52:58.548Z</atom:updated>
            <content:encoded><![CDATA[<h3>Setting up Bazel for Vulkan projects</h3><h4>Bazel for Vulkan projects</h4><figure><img alt="Running projects that use Vulkan graphics API with Bazel build system." src="https://cdn-images-1.medium.com/max/1024/1*Lbv9k2Hiag46d3ERwEu3ug.jpeg" /><figcaption>Photo by <a href="https://unsplash.com/photos/MrWOCGKFVDg">Javier Miranda</a> on <a href="https://unsplash.com/">Unsplash</a></figcaption></figure><p>In this blog post, we will see how to set up an environment for Vulkan projects built with Bazel. Then, in the <a href="https://medium.com/codex/bazel-in-vulkan-projects-part-2-shaders-35cdeb0f4c67">second part</a>, we are going to see how Bazel can build GLSL shaders for Vulkan.</p><p><strong>Vulkan — </strong>is an open standard for 3D graphics and computing that enables cross-platform access to GPUs. It targets high-performance real-time 3D graphics applications, such as video games and interactive media. You can learn more about Vulkan from its <a href="https://www.vulkan.org/">official page</a>.</p><p><strong>Bazel</strong> — is an open-source build system with advanced local and distributed caching, optimized dependency analysis, and parallel execution. It supports a wide variety of languages including C++. You can learn more about Bazel from its <a href="https://bazel.build/">official page</a>.</p><p>We will stick closely to Vulkan’s <a href="https://vulkan-tutorial.com/Development_environment#page_MacOS">official environment setup guide</a> but build everything with Bazel. To do this, we are going to use the <a href="https://github.com/jadarve/rules_vulkan"><strong>rules_vulkan</strong></a><strong> </strong>project.</p><p>In addition, we will set up tools that are usually used along with Vulkan such as:</p><ul><li><strong>GLM</strong> — a library for linear algebra operations.</li><li><strong>GLFW</strong> — a cross-platform API for creating windows.</li></ul><p>You can find a complete source code from this blog post on GitHub using the link below.</p><p><a href="https://github.com/Morfly/vulkan-bazel-samples">GitHub - Morfly/vulkan-bazel-samples: A sample repo for setting up an environment for applications that use Vulkan graphics API built with Bazel</a></p><h3>Prerequisites</h3><h4>Installing Bazel</h4><p>This blog post assumes you have a Bazel (or its wrapper, Bazelisk) installed on your machine. Visit the <a href="https://bazel.build/install">official documentation</a> to learn how to do it.</p><h4>Downloading Vulkan SDK</h4><p>Before doing any Bazel setup, we must download and install Vulkan SDK to our machine. This step would be exactly the same as you would follow the official Vulkan documentation.</p><ul><li><strong>macOS.</strong> Follow only the <em>Vulkan SDK</em> section from the <a href="https://vulkan-tutorial.com/Development_environment#page_Vulkan-SDK-2">official Vulkan tutorial for macOS</a>.</li><li><strong>Linux.</strong> Follow only the <em>Vulkan Package</em> section from the <a href="https://vulkan-tutorial.com/Development_environment#page_Vulkan-Packages">official Vulkan tutorial for Linux</a>.</li><li><strong>Windows.</strong> Follow only the <em>Vulkan SDK</em> section from the <a href="https://vulkan-tutorial.com/Development_environment#page_Vulkan-SDK">official Vulkan tutorial for Windows</a>.</li></ul><h3>Setting up the project</h3><p>As usually with Bazel projects, we must initialize the project workspace. To do this, in the root directory of the project (let’s call it vulkan-bazel-samples) we need to create a file named WORKSPACE.</p><pre>vulkan-bazel-samples<br>└── <strong>WORKSPACE</strong></pre><h4>Configuring Bazel for Vulkan</h4><p>First, we need to add Vulkan support to our Bazel project. We are going to use the <strong>rules_vulkan</strong> repo for this. Open WORKSPACE file and fill it with the code below.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/1f1d0992ea3022dffec8ebd39ed7a4b7/href">https://medium.com/media/1f1d0992ea3022dffec8ebd39ed7a4b7/href</a></iframe><p>Here we are fetching <strong>rules_vulkan</strong> as a git_repository and configuring it in the project using vulkan_repositories macro.</p><p>We will see how to use it later in the blog post. However, before that, we need to configure additional third-party libraries that are usually used in Vulkan projects.</p><h4><strong>Configuring GLM</strong></h4><p><strong>GLM</strong> — is a library for linear algebra operations.</p><p>First, we need to make it available in our project. To do this, we will use a http_archive build rule which will download the sources from a GitHub archive.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/fea3036bcf4a254dcc4a9603c6895199/href">https://medium.com/media/fea3036bcf4a254dcc4a9603c6895199/href</a></iframe><p>This way we are fetching the library sources in our project and we need to configure a BUILD file that will tell Bazel how to build it. We pass the path to this file as build_file parameter above. This file would be <em>placed</em> in the root directory of the fetched library.</p><p>Obviously, we would need to create this BUILD file. We will need to place it under the third_party/glm folder and call it glm.BUILD. Below are its contents.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b8a6dc0682eec495b95b3b007850f714/href">https://medium.com/media/b8a6dc0682eec495b95b3b007850f714/href</a></iframe><p>Nothing fancy here, as we’re just telling Bazel where to get source files for the library.</p><p>After the actions above, the GLM library will be available as an external workspace @glm. For easier usage, we can create a simple alias for it. To do this, create a third_party/glm/BUILD file and populate it with the contents below.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/5acf34afa3db5f1de1ad0b88becb6615/href">https://medium.com/media/5acf34afa3db5f1de1ad0b88becb6615/href</a></iframe><p>Below is the file structure of the project so far.</p><pre>vulkan-bazel-samples<br>├── <strong>third_party</strong><br>│   └── <strong>glm</strong><br>│       ├── <strong>glm.BUILD</strong><br>│       └── <strong>BUILD</strong><br>│<br>└── WORKSPACE</pre><p>Next, we will include a GLFW library in the project.</p><h4><strong>Configuring GLFW</strong></h4><p><strong>GLFW</strong> — is a cross-platform API for creating windows.</p><p>The algorithm for adding it to the project is similar to GLM. However, we would need to perform a bit more sophisticated configuration. Append the following code to the WORKSPACE file.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4e937f057862502f05d8e1a835e09217/href">https://medium.com/media/4e937f057862502f05d8e1a835e09217/href</a></iframe><p>Similar to GLM, we need to specify a BUILD file for the GLFW library. To do this, under the third_party/glfw directory create a file named glfw.BUILD. Next, we will need to populate it with the contents.</p><p>First, for each of the supported operating systems, we need to pick the right set of implementation source files, define statements, and linker flags. For the sake of saving space, the below snippet defines the corresponding variables but omits their values. However, the full code for this file could be found on <a href="https://github.com/Morfly/vulkan-bazel-samples/blob/main/third_party/glfw/glfw.BUILD">GitHub</a>.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e359ee65f60e6407ec0e2db107210ded/href">https://medium.com/media/e359ee65f60e6407ec0e2db107210ded/href</a></iframe><p>Next, we need to define a cc_library target that will build those sources either for Linux or Windows operating systems.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/27e90a7b97cd0d8f9fce8073afba1137/href">https://medium.com/media/27e90a7b97cd0d8f9fce8073afba1137/href</a></iframe><p>Similarly, to build the GLFW sources for macOS, we need to define an objc_library target.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c12c4f8d59ca60864d3473ac36869981/href">https://medium.com/media/c12c4f8d59ca60864d3473ac36869981/href</a></iframe><p>Finally, we are defining a public Bazel target that links GLFW API headers with the implementations we defined before.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/8679e79c9c913014021851cc29a812b6/href">https://medium.com/media/8679e79c9c913014021851cc29a812b6/href</a></iframe><p>In addition, create a third_party/glfw/BUILD file that defines simple alias for the external @glfw repository.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/1e0895570e68561b3f5478e5757c5b4b/href">https://medium.com/media/1e0895570e68561b3f5478e5757c5b4b/href</a></iframe><p>Here is the updated file structure we have so far.</p><pre>vulkan-bazel-samples<br>├── <strong>third_party<br></strong>│<strong>   </strong>├── glm<br>│   │<br>│   └── <strong>glfw</strong><br>│       ├── <strong>glfw.BUILD</strong><br>│       └── <strong>BUILD</strong><br>│<br>└── WORKSPACE</pre><h3>Writing and building Vulkan code</h3><p>Now, we are ready to add some C++ code that uses Vulkan API. It won’t do anything fancy, just show an empty window and print the list of Vulkan extensions. This is sufficient to demonstrate that the project was configured correctly.</p><p>Create a main.cpp file under the env_setup directory. Fill it with the code below, which is taken from the official <a href="https://vulkan-tutorial.com/Development_environment">Vulkan tutorial</a>.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b55de4be25f145a8566a9a8c9a9bbc96/href">https://medium.com/media/b55de4be25f145a8566a9a8c9a9bbc96/href</a></iframe><p>Next, we need to add a BUILD file for our source code under the env_setup directory.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/289d93642f86bb514fb3d6a282002e8d/href">https://medium.com/media/289d93642f86bb514fb3d6a282002e8d/href</a></iframe><p>We are defining an env_setup binary target that uses both glfw and glm libraries we configured before. In addition, it depends on vulkan_cc_library from the @rules_vulkan external workspace. This is how we enable Vulkan support for this target.</p><p>Below you can find the updated project structure.</p><pre>vulkan-bazel-samples<br>├── <strong>env_setup</strong><br>│   ├── <strong>main.cpp</strong><br>│   └── <strong>BUILD<br></strong>│<br>├── third_party<br>└── WORKSPACE</pre><h4>Running the code</h4><p>Finally, we are ready to build and run the project with Bazel. To do so, run the following command in the terminal.</p><pre>bazel run //env_setup</pre><p>If all is done correctly, you should see an empty window and a bunch of Vulkan extensions printed in the console.</p><h3>Conclusion</h3><p>That’s it! In this blog post, we have seen how to set up a project that uses Vulkan graphics API and build it with Bazel using <strong>rules_vulkan </strong>repo. In addition, we have seen how to add GLM and GLFW libraries to such a project that serve as integral companions when working with graphics APIs.</p><p>In <a href="https://medium.com/codex/bazel-in-vulkan-projects-part-2-shaders-35cdeb0f4c67">part 2</a> we will see how to build GLSL shaders with Bazel and apply this knowledge to run a project that draws a triangle.</p><p>All the source code from this blog post could be found on GitHub.</p><p><a href="https://github.com/Morfly/vulkan-bazel-samples">GitHub - Morfly/vulkan-bazel-samples: A sample repo for setting up an environment for applications that use Vulkan graphics API built with Bazel</a></p><h4>References</h4><ul><li><a href="https://github.com/jadarve/rules_vulkan"><strong>rules_vulkan</strong></a><strong> </strong>— a repository that enables Bazel support for Vulkan projects, created by Juan David Adarve.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e9fc2ec51b" width="1" height="1" alt=""><hr><p><a href="https://medium.com/codex/vulkan-in-bazel-projects-part-1-environment-setup-e9fc2ec51b">Bazel in Vulkan Projects. Part 1 — Environment Setup</a> was originally published in <a href="https://medium.com/codex">CodeX</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>