Density, Devices and Flaky Tests

Alex Vanyo
Android Developers

--

Writing a custom layout in Compose is a great way to fine-tune how your app’s UI looks, and it isn’t as daunting as it might seem. Once you’ve written your custom logic, you should also write tests for it! However, in writing these tests, you might discover some odd behavior of those tests sometimes passing and sometimes failing, depending on the device you’re running on.

Let’s jump in to analyze a simple (but flaky!) test for verifying the behavior of a Compose layout:

The intent of this test is to check that a Column containing 6 Spacers that are each 10dp high, ends up being 60dp high.

The Column’s coordinates are retrieved with Modifier.onPlaced to verify placement, and the density of the device’s screen is similarly retrieved from LocalDensity.current.

The resulting coordinates are expressed in an integer number of pixels, so we convert our expected 60dp to pixels, using the density to round to an integer with roundToPx().

All of those steps seem innocuous enough, so let’s run the test. Success… or maybe failure. This test may or may not pass, depending on the device we’re running the test on. Let’s take a closer look at what’s going on.

px vs dp vs sp

Different devices have different physical screens (and some might have more than one), which means that they can have both different display physical sizes and different display densities. On many devices, users can also change the effective display density themselves using a “Display size” option.

“Display size” setting under “Display” on a tablet
“Display size” setting under “Display” on a tablet

To ensure that the user interface has a consistent apparent size across different devices, you should specify dimensions of general UI components using density-independent-pixels, or dp. For text, you should use scalable pixels, or sp, which takes into account the user’s preferred text size.

Jetpack Compose has a built-in Dp type and related methods, which adds conversion methods between dp and the underlying pixels, using a Density object. This Density object (which can be retrieved with LocalDensity.current in a @Composable function) is determined by the device the UI is running on. Density.density is the float scaling factor applied to dp values. A Density.density factor of 1.0 means that 1dp will be converted to 1px, whereas a Density.density factor of 2.0 means that 1dp will be converted to 2px. For some realistic values: the Pixel Watch has a default density factor of 2.0, the Pixel 7 Pro has default factor of 2.625, and a 4K Android TV has a default density factor of 4.0.

Step-by-step layout

With this info in mind, let’s take a step-by-step look at how the layout in the test is computed.

The Column will lay out all of its children one-by-one in a vertical column, and its size will therefore be the sum of its children’s size.

Each of the Column’s 6 children is the same: a Spacer that is 10dp high. When the Spacer is measured, its specified size is converted from dps to pixels, and its resulting size will be an integer number of pixels.

This conversion is done with the Density.density factor from dp to pixels, and then rounded to the nearest integer using roundToPx.

This is done for each of the Spacers, and since each one is identical, the resulting height of the Column in pixels will be 6 times the height of any particular Spacer.

Working through an example:

If there is a density factor of 2.0, then each Spacer will have a height of 20px, and the Column will have a height of 120px. This matches our expected height of roundToInt(60 * 2.0) = 120px.

Six 10dp Spacers taking up 120px with a density factor of 2.0
Six 10dp Spacers taking up 120px with a density factor of 2.0

This conversion was simple, and we didn’t have to round since our density was an integer. However, the density may not be an integer! The default density for a device could be a variety of non-integer values, and this number can change for the same device based on the user’s settings.

Let’s try again, this time with a scaling factor of 1.75. Now each Spacer will have a height of roundToInt(10 * 1.75) = 18px, so the Column will have a height of 18 * 6 = 108px. This is different than our expected height of roundToInt(60 * 1.75) = 105px, so our test will fail. Because the height of each Spacer was rounded up to 18px, the Column ends up being 3px taller than we’d otherwise expect. Trying to convert back to dp to compare doesn’t help here either: 108px is just over 61.7dp, so our Column ends up being about 1.7dp taller than we expected.

Six 10dp Spacers taking up 108px with a density factor of 1.75
Six 10dp Spacers taking up 108px with a density factor of 1.75

Conclusion

While the test above doesn’t seem to have any issues at first glance, it may pass or fail depending on the device the test is running on. The typed Dp values in Compose help to ensure APIs are using the correct unit, but they don’t change the fundamental process of how the units are used and converted to display components to the user. Depending on the device and density factor, intermediate rounding means that dimension values may not nicely multiply. A Column with 6 Spacers that are each 10dp high may not be 60dp high. The true behavior is that the Column should be 6 times the rounded height of each Spacer in pixels:

If you are writing a custom layout and testing it, be mindful of how different device densities might affect how rounding accumulates in a way that might be noticeable. Then, either write tests that account for and verify that behavior, or allow for a certain margin of error by not depending on the exact measurements. Otherwise, this rounding may cause tests for custom layouts to frustratingly have different behavior when run across different emulators or physical devices.

--

--