Optimized Chessboard Pattern VectorDrawable in Kotlin

·

11 min read

Optimized Chessboard Pattern VectorDrawable in Kotlin

Trust me, I would have never been able to make such an efficient VectorDrawable with a designer tool like Sketch, Affinity Designer, or Adobe Illustrator.

This is the story of how I came to draw a chessboard pattern in VectorDrawable format using Kotlin code, and how I ended up with a file 5x smaller than I initially had. I had a lot of fun (and a bit of fun) doing it, so I want to share it with you.

Why

For my Wear OS app that I'll publish on the Play Store soon, I need 3 variants of the same app:

  1. The debug variant
  2. A minified variant that I can sign an update locally, for performance tests
  3. The Play Store variant, that Google will sign with a key I don't have

To easily distinguish these 3 apps on my test devices, I know 2 solutions:

  1. Adding a prefix letter/sign in the app name for the non-published variants
  2. Have a visual mark on the icon of the non-published variants

I used the first approach successfully in my previous job, but this time, I felt like trying the distinctive icon approach. I decided I would use a colored chessboard pattern for my debug and minified variants that I won't publish. Since the devices I'll regularly test on are all running Android 8 or later (API 26+), I can simply leverage adaptive icons where you can point to a buildType dependent resource for the background of the icon.

VectorDrawables in a nutshell

Great! Now, I wanted to get the chessboard pattern. VectorDrawables which can be defined in XML and referenced in an adaptive icon sounded like the most straightforward option to get a nice result. Since a chessboard pattern is only made out of squares, I thought I would not need to use a vector graphics tool like Affinity Design, Sketch, Adobe Illustrator or Inkscape. Instead, I could write the drawing commands directly and ensure I'd get the most optimized result, decreasing the GPU/CPU load.

In my previous job, I have been learning a bit about how to write SVG path data myself so I could make the flag of Catalonia. It's actually very easy if you only do straight lines. It's only a few abbreviations/substitutes to learn for "move", "horizontal (line)", "vertical (line)", and "line". They are respectively "M", "H", "V", and "L" for absolute positions, and if you switch to lowercase, you have it for relative positions. And there's the z character that means "close the path with a straight line if needed".

For example, here's how to draw a rectangle of 2x1: M0,0 H2 V1 H0 V0 z. In plain English, here's what it means: Move to 0,0 (which is the top left corner) and start drawing, horizontal line to 2 (x axis), vertical line to 1 (y axis), horizontal line to 0 (x axis again), vertical line to 0 (y axis again), and finally close the path. If you take a pen and follow these instructions on a piece of paper, you'll see you have just been drawing a 2x1 rectangle as advertised. Note that the spaces are all optional, so we can write M0,0H2V1H0V0z, and save 5 bytes, which is 27.77% since we had 18 bytes with the spaces. Conversely, adding these 5 bytes to the 13 means growing the size of 38.46%. Anyway, you'll see later on why the size matters.

From boring, to warning

I started to draw my chessboard pattern by hand happily, and within a few seconds, an intense feeling of boredom started to gain me as I was making plenty of errors and progressing very slowly. I didn't let it kill the idea. Instead, I did "File > New > Scratch File" in Android Studio (also works in IntelliJ IDEA), selected Kotlin, and started leveraging software to do this cumbersome task. Within 2 minutes, I wrote 2 for loops in a buildString { … }, and it seemed to generate what I was looking for. I copied it in the pathData of the VectorDrawable I had started writing by hand, and after 1 or 2 fixes, plus a size change, it was showing exactly what I wanted… and an extra thing, a warning from the IDE.

Very long vector path (1504 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector.

(yes, it should be "Consider", without "ing)

I understood this could affect the device performance when it would draw the icon, something I definitely don't want to cause.

Here's the initial code that I had written for boxes of size 2:

fun generateChessboardPattern1(size: Int): String = buildString {
    for (x in 0 until size step 2) {
        for (y in 0 until size step 2) {
            if ((x - y) % 4 != 0) continue
            append("M$x,${y}H${x + 2}V${y + 2}H${x}z")
        }
    }
}

As you can see, for every box that needs to be drawn, it adds something like that: Mx,yHaVbHcz. That is at least 11 characters per box, and on a 14x14 grid (28x28 with boxes of 2x2 technically), it quickly grew beyond the kilobyte worth of vector drawing instructions.

I didn't want to go raster, and I wanted to keep the same grid size, so I looked for ways I could end up with the same result while having less instructions.

Iterating on optimizations

Minimizing the move commands

In the previous approach, the move command (Mx,y) accounted for 4 characters, or about a third of the entire command, so my first thought was to reduce these calls. With the code just below, I now get only one every 2 lines instead of for every box to fill:

fun generateChessboardPattern2(size: Int): String = buildString {
    check(size % 4 == 0) { "Size must be a multiple of 4" }
    for (y in 0 until size step 4) {
        append("M0,$y")
        for (x in 0 until size step 4) {
            append('H'); append(x + 2)
            append('V'); append(y + 4)
            append('H'); append(x + 4)
            append('V'); append(y)
        }
        append('V'); append(y + 2)
        append('H'); append(0)
        append('V'); append(y)
        append("z ")
    }
}

Of course, it introduced a new restriction: the size now had to be a multiple of 4. That was fine for me as it would not be customer facing. However, I was thinking I could do better.

Removing the move commands altogether

First, I asked myself: "Can I draw that chessboard with just a single path?"

During my experiments, I had found out that lowercase h and v commands were relative position variants, which made it much easier to experiment manually right in the XML.

I tried the simplest version that would answer my question.

This is the path data I got: M0,0h2v4h2V2H0z, and this is what I was seeing in the IDE:

manual test, 2 green squares in chessboard pattern on a 2x2 grid

Then, I wanted to see what would happen if I removed the leading M0,0. The preview didn't change a bit. I tried breaking the thing (successfully) and when I reverted the bad things I had just done: it was showing the right thing again. I just realized that any path would start at 0,0 by default, which is exactly where I need to start for my use case!

Here is the complete VectorDrawable if you want to try for yourself in an actual Android Studio project.

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="4"
    android:viewportHeight="4">
    <path
        android:fillColor="#C0C0"
        android:pathData="h2v4h2V2H0z"
        android:strokeWidth="0.3"
        android:strokeColor="#0F0" />
</vector>

The 2x2 grid (or 4x4 here, technically) seemed like a special case, and I was wondering what would happen for the filling for the boxes that I wanted empty that would be surrounded by boxes I wanted filled. Would they be filled as well, or would they be empty as I wanted?

I continued manually, and step by step, I got the following path data: h2v8h2V0h2v8h2 v-2H0v-2h8v-2H0z, which was fortunately exactly the result I was looking for:

manual test, green squares in chessboard pattern on a 4x4 grid

Then I went to write the code, and also designed the function so it was supporting a variable box size, and so it could evolve to support rectangle boards as well as rectangle boxes. It's missing a few preconditions, for example to reject a boxSize that is greater than the size, but it's only run locally on my dev machine here, I don't see why I would try such a thing.

Instead of drawing the boxes one by one, or 2 lines by 2 lines with nested for loops, it draws the vertical lines, and then, draws the horizontal lines before closing the path.

purple chessboard pattern

The previous implementation had a quadratic complexity, and the result had a size that would grow a lot with the grid size, while the one below has a linear complexity, with a result that grows only linearly with the size as well.

Here is the code:

fun Int.isOdd() = this % 2 != 0 // It's odd that this isn't built into Kotlin, isn't it?

fun generateChessboardPattern3(
    size: Int,
    boxSize: Int
): String = buildString {
    val boxWidth = boxSize
    val boxHeight = boxSize
    val width = size
    val height = size
    for (x in 0 until width step boxWidth) {
        append("h${boxWidth}")
        if (x + boxWidth == width) break // Don't draw the last line.
        val verticalOffset = if ((x / boxWidth).isOdd()) 0 else height
        append('V')
        append(verticalOffset)
    }
    for (y in height downTo  0 step boxHeight) {
        append("v-${boxHeight}")
        if (y - boxHeight == 0) break // Don't draw the last line.
        val horizontalOffset = if ((y / boxHeight).isOdd()) height else 0
        append('H')
        append(horizontalOffset)
    }
    append('z')
}

The last tiny optimization

Before I had even started writing the 3rd version above, I was thinking that the potentially equivalent v-2 was longer than V8, but at the same time, v-2 is shorter than V10. I didn't let this stop me from writing a simple working version first, but then, I changed 3 lines in the second for loop for the 4th iteration, allowing me to shave a few extra bytes in the result where possible:

fun generateChessboardPattern4(
    size: Int,
    boxSize: Int
): String = buildString {
    val boxWidth = boxSize
    val boxHeight = boxSize
    val width = size
    val height = size
    for (x in 0 until width step boxWidth) {
        append("h${boxWidth}")
        if (x + boxWidth == width) break // Don't draw the last line.
        val verticalOffset = if ((x / boxWidth).isOdd()) 0 else height
        append('V')
        append(verticalOffset)
    }
    for (y in height downTo  0 step boxHeight) {
        val targetY = y - boxHeight // <- changed
        append(if (targetY >= 10) "v-${boxHeight}" else "V${targetY}") // <- changed
        if (targetY == 0) break // Don't draw the last line. // <- changed
        val horizontalOffset = if ((y / boxHeight).isOdd()) height else 0
        append('H')
        append(horizontalOffset)
    }
    append('z')
}

With the following code:

println("approach number 3:")
println(generateChessboardPattern3(size = 28, boxSize = 2))
println("approach number 4:")
println(generateChessboardPattern4(size = 28, boxSize = 2))

I checked how they compared:

approach number 3:
h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2v-2H0v-2H28v-2H0v-2H28v-2H0v-2H28v-2H0v-2H28v-2H0v-2H28v-2H0v-2H28v-2H0v-2z
approach number 4:
h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2V0h2V28h2v-2H0v-2H28v-2H0v-2H28v-2H0v-2H28v-2H0v-2H28v-2H0V8H28V6H0V4H28V2H0V0z

With just 5 characters saved, clearly, we're seeing the effects of the law of diminishing returns, but I still took it. Time has already been spent on it, no significant downside… ¯_(ツ)_/¯

The last two possible optimizations (that I didn't check if aapt2 was doing) are renaming the android xml namespace to a, and removing all the non mandatory spaces and line breaks. Anyway, I saved 81 bytes there (more than 20% less compared to the previous iteration).

Counting the savings

That was quite a journey! To be honest, it was very quick on my end, but writing the article made it much longer… 😅 Sharing is caring!

If you take the entire file size, we started at 1812 bytes, and the most optimized version is now 318 bytes, which 5.7x smaller!

screenshot showing the file sizes decrease after each iteration

If you take just the path data, we started from 1504 bytes/chars down to 131 bytes/chars, or 11.4x smaller!

Bonus 1: How to draw lines that are neither horizontal, neither vertical?

It's very easy, just like we've been doing Mx,y to move to a position in the beginning, use Lx,y, and it'll draw a line to the given absolute position, or use the lowercase variant for a relative position (lx,y). Now I want to learn how to draw curves… maybe later.

Bonus 2: How to convert a VectorDrawable to a pixel-perfect PNG?

For this article, I've been looking for a way to get a png out of my VectorDrawables.

I first failed to find a proper way to do it, so I was using screenshots and cropped roughly. Then I failed at finding a working tool to convert them to SVG before I could extract a PNG. I then successfully did the conversion by hand, where I happily learned a bit more about SVG. Then some folks in the community pointed me to VdPreview.getPreviewFromVectorXml(), located in com.android.tools:sdk-common, a dependency of AGP (the Android Gradle Plugin). So, I added the dependency, and with the following code, I can now get a nice PNG file from any Android VectorDrawable:

import com.android.ide.common.vectordrawable.VdPreview
import java.io.File
import javax.imageio.ImageIO

fun saveImageFromVectorDrawableAndReturnErrors(
    xml: String,
    maxDimension: Int,
    outputFile: File
): String? {
    val errors = StringBuilder()
    val bufferedImage = VdPreview.getPreviewFromVectorXml(
        VdPreview.TargetSize.createFromMaxDimension(maxDimension),
        xml,
        errors
    )
    ImageIO.write(bufferedImage, "png", outputFile)
    return errors.takeIf { it.isNotEmpty() }?.toString()
}

It'd probably be worth wrapping that in a CLI tool, it's very easy to do with Kotlin scripts since they support dependencies.


To wrap it up, here's a chessboard sized grid drawn with the generated code I've been showing you. And about the cover picture, the Kotlin logos with chessboard/checkerboard patterns are self-made, I got to learn a bit about SVG as well, which uses the exact same format for pathData (but requires an initial move instruction [Mx,y]).

chessboard pattern, 8x8, like an actual chessboard

Of course, you can copy the code above 100% freely, no mention needed.

If you enjoyed this article or found it useful, which I hope so, please add a thumbs up or something, you can very easily connect with a GitHub, Google, Apple, or even a LinkedIn account! (email also works). 🙏 You can also click the subscribe button on this page to see my future dev blog posts, or/and you can follow me on Twitter.

Have a great day!

Did you find this article valuable?

Support Louis CAD by becoming a sponsor. Any amount is appreciated!