You're probably familiar with raster image formats like JPEG, PNG, GIF, and WebP already. They all serve the same purpose: storing a matrix of pixel colors compactly using different compression algorithms, so they take as little space as possible, and can be transferred through networks in a short amount of time.
Compressing images is important, but the goal is to show them on screen. And this is only possible for uncompressed pixel data, which takes several times more memory. The data structure used for this purpose is called a Bitmap. When any raster image is shown on the screen (such as inside an ImageView), it's decoded to a Bitmap first. No matter whether it comes from resources, assets, files, the network, or anywhere else.
In this topic, you'll learn about the operations supported by Bitmaps out of the box: decoding and encoding, resizing, using the image as a shader, and more.
Decoding
If you have a file called res/drawable/mountains.jpg, the Resources.getDrawable(R.drawable.mountains) invocation will return you a BitmapDrawable (as you may remember from the Drawables overview topic).
You can also obtain a raster drawable resource directly as a Bitmap, without a Drawable wrapper. This is done by using BitmapFactory.decodeResource. BitmapFactory contains a bunch of other decoding methods like decodeFile, decodeStream, decodeByteArray, and decodeFileDescriptor. So you can use any type of input.
Resizing
Once you've loaded a Bitmap into memory, it's easy to scale it:
val original = BitmapFactory.decodeResource(
context.resources,
R.drawable.mountains,
)
val quarter = Bitmap.createScaledBitmap(
original,
original.width / 2,
original.height / 2,
true // use bilinear filtering for better quality
)
The problem arises when the source image is too big to fit into application memory. In this case, the image cannot be loaded fully, but it can still be downscaled on the fly thanks to the inSampleSize decoding option. For example, setting it to 2 means that every two by two pixel square of the original image will be merged into a single pixel. This takes four times less memory (and also reduces image quality):
val quarter = BitmapFactory.decodeResource(
context.resources,
R.drawable.mountains,
BitmapFactory.Options().apply {
inSampleSize = 2
}
)
Before we specify inSampleSize (how much smaller image we want), we need to know the dimensions of the image and our target dimensions first. And that's where inJustDecodeBounds option comes into play. When this option is set to true the chosen decode method performs a kind of "dry run", reading image dimensions into the provided options object without decoding the whole image:
val dimensions = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeResource(
context.resources,
R.drawable.mountains,
dimensions
)
val screenWidth = context.resources.displayMetrics.widthPixels
val notTooWide = BitmapFactory.decodeResource(
context.resources,
R.drawable.mountains,
BitmapFactory.Options().apply {
inSampleSize = dimensions.outWidth / screenWidth
}
)
And when you need some serious image-decoding machinery, you'd do well to start by looking at Picasso's BitmapUtils.
Exploring contents
Since every Bitmap is a pixel matrix, you can read the colors of individual pixels. The colors of all four corners, for example:
val leftTop = bitmap.getPixel(0, 0)
val rightTop = bitmap.getPixel(bitmap.width - 1, 0)
val rightBottom = bitmap.getPixel(bitmap.width - 1, bitmap.height - 1)
val leftBottom = bitmap.getPixel(0, bitmap.height - 1)
With the KTX library, you can also use Kotlin's indexing operator for the same purpose:
import androidx.core.graphics.get
...
val leftTop = bitmap[0, 0]
val rightTop = bitmap[bitmap.width - 1, 0]
val rightBottom = bitmap[bitmap.width - 1, bitmap.height - 1]
val leftBottom = bitmap[0, bitmap.height - 1]
So, how is this useful? Well, it depends. For example, if all corner pixels are transparent (Color.alpha(lt) == 0 and so on), then you can determine that the image probably has rounded corners.
Drawing into
A Bitmap can be used as a drawing buffer. This is useful in many cases, such as:
- Augmenting an existing image (usually a photo): adding filters, watermarks, drawing lines and shapes with the user's fingers, adding text, or any other graphics.
- Rasterizing any Drawables (including vector ones) with further analysis, post-processing, or whatever.
- Obtaining a full screenshot of the view hierarchy, which can be attached to a bug report.
- Getting a partial screenshot of any view. This could have a transparent background (which is impossible with traditional full-screen snapshots) and illustrate a particular app feature, for example.
- Post-processing the view subhierarchy. Some effects, like grayscale or color inversion, can be applied to views out of the box using setLayerType. But others can only be done by hand within a Bitmap. This is true of the blur effect in versions before SDK 31, where native support was introduced.
Bitmaps in Android can be mutable or immutable. To draw into, we need a mutable one. We can allocate an empty Bitmap, request that BitmapFactory returns a mutable Bitmap, or make a mutable copy of an existing Bitmap.
In our example, we will use ARGB_8888 config. This means one byte per channel and four bytes per pixel, with transparency. It gives optimal color depth in most cases:
// A)
val blank = Bitmap.createBitmap(
800, 600,
Bitmap.Config.ARGB_8888
)
// B)
val mutable = BitmapFactory.decodeFile(
"/sdcard/photo.jpg",
BitmapFactory.Options().apply {
inMutable = true
}
)
// C)
val mutableCopy = anotherBitmap.copy(
Bitmap.Config.ARGB_8888,
/*isMutable =*/ true
)
Now we can apply some changes to the Bitmap, such as:
Bitmap.setPixel(x, y, color)to change the color of a single pixel.Bitmap.setPixels(…)to replace a specific region of a Bitmap or even the whole thing.Bitmap.eraseColor(color)to fill the whole Bitmap with a color. UnlikeCanvas.drawColor,eraseColorinvolves no blending. This means thatbitmap.eraseColor(TRANSPARENT)will make the whole Bitmap transparent, butcanvas.drawColor(TRANSPARENT)will have no effect at all.
We could also wrap the Bitmap in a Canvas and apply drawing operations to it. This option is quite interesting, so let's take a closer look.
As mentioned in the Graphics overview topic, Canvas is a drawing surface. You can create one easily with Canvas(bitmap). Then, every drawing operation is reflected on the Bitmap. The following snippet is an example of taking an arbitrary drawable and rasterizing it:
fun Context.rasterize(@DrawableRes res: Int): Bitmap {
val drawable = ContextCompat.getDrawable(this, res)!!
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth, drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.draw(canvas)
return bitmap
}
-1 . This isn't a valid createBitmap dimension, meaning an IllegalArgumentException will be thrown.
The View class also has a draw(Canvas) method. But the view lifecycle and dimensions are more complicated, so a view can only be drawn easily if it was actually shown. Otherwise, its size will be 0x0. You can fix this by calling the measure() and layout() methods, which we will talk about in subsequent topics.
Using as a shader
Using the family of Canvas.drawBitmap methods, it's easy to draw scaled and rotated Bitmaps. It's also simple to draw skewed Bitmaps with the help of Matrix or ColorFilter, which can be set through Paint. But with BitmapShader, you can draw any shape you like and fill it with the specified Bitmap. You can also choose whether to CLAMP, REPEAT or MIRROR the Bitmap, if the shape is bigger than the image. Here's an example render for a single Bitmap (left) and a circle containing mirrored tiles (right):
The sample code used to achieve this inside the Activity.onCreate method is below:
// First, we need a Bitmap.
// Let's draw a smirking cat which is, um, a text.
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
.also { it.textSize = resources.displayMetrics.scaledDensity * 30 }
val catSmirk = "😼"
// figure out the size of our cat
val catBounds = Rect().also { paint.getTextBounds(catSmirk, 0, catSmirk.length, it) }
// create a bitmap of according size and paint it black
val catBitmap = Bitmap.createBitmap(catBounds.width(), catBounds.height(), Bitmap.Config.ARGB_8888)
.also { it.eraseColor(Color.BLACK) }
// draw our cat to a Bitmap with help of Canvas
Canvas(catBitmap).drawText(catSmirk, 0f, -catBounds.top.toFloat(), paint)
// now let's show a single cat and a BitmapShader example
setContentView(LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
// show single smiley
addView(ImageView(context).apply {
imageBitmap = catBitmap
scaleType = ImageView.ScaleType.CENTER_INSIDE
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
// draw an oval filled with mirrored tiled bitmap
addView(ImageView(context).apply {
imageDrawable = object : Drawable() {
init {
// reuse the Paint instance
paint.shader = BitmapShader(catBitmap, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR)
}
override fun draw(canvas: Canvas) {
val (l, t, r, b) = bounds
canvas.drawOval(
l.toFloat(), t.toFloat(), r.toFloat(), b.toFloat(), // bounds
paint // paint with bitmap shader
)
}
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
})
A similar pattern can be used for rounding image corners, as suggested in 2012 by Romain Guy (Director of Engineering on Android at Google).
Encoding
Now it's time to save the contents of a Bitmap. You can obtain them with the getPixels() method, but the uncompressed int array would be very large. So it's better to use compress() to convert the Bitmap to one of the common raster formats (JPEG, PNG, or WebP) instead. These formats can be opened by the Gallery application and various other image-related pieces of software.
The compress() method accepts a quality value (0-100), and the OutputStream that will receive the image data. The method returns a boolean value, indicating whether compression to the specified stream was successful:
FileOutputStream("/sdcard/edited.webp").use { os ->
check(photo.compress(Bitmap.CompressFormat.WEBP, 90, os))
}Conclusion
Bitmaps are in-memory representations of raster images, and you now know how to decode and encode them. You have also learned how to scale Bitmaps efficiently, draw into them through Canvas, and draw a Bitmap on a Canvas. This knowledge can be used in many different ways. For example, you could add watermarks to images, develop a simple photo editor, or attach screenshots to bug reports.