Введение
При разработке под Android довольно часто возникает задача наложить маску на изображение. Чаще всего требуется закруглить углы у фотографий или сделать изображение полностью круглым. Но иногда применяются маски и более сложной формы.
В этой статье я хочу проанализировать имеющиеся в арсенале Android-разработчика средства для решения таких задач и выбрать наиболее удачное из них. Статья будет полезна в первую очередь тем, кто столкнулся с необходимостью реализовать наложение маски вручную, не пользуясь сторонними библиотеками.
Я предполагаю, что читатель имеет опыт в разработке под Android и знаком с классами Canvas, Drawable и Bitmap.
Код, используемый в статье, можно найти на GitHub
Постановка задачи
Допустим, у нас есть две картинки, которые представлены объектами Bitmap. Одна из них содержит исходное изображение, а вторая — маску в своем альфа-канале. Требуется отобразить изображение с наложенной маской.
Обычно маска храниться в ресурсах, а изображение загружается по сети, но в нашем примере обе картинки загружаются из ресурсов следующим кодом:
private void loadImages() {
mPictureBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture);
mMaskBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.mask_circle).extractAlpha();
}
Обратите внимание на .extractAlpha()
: этот вызов создает Bitmap с конфигурацией ALPHA_8, значит, на один пиксел расходуется один байт, который кодирует прозрачность этого пиксела. В таком формате очень выгодно хранить маски, так как цветовая информация в них не несет полезной нагрузки и ее можно выкинуть.
Теперь, когда изображения загружены, можно переходить к самому интересному — накладыванию маски. Какие средства для этого могут применяться?
PorterDuff modes
Одним из предлагаемых решений может стать использование PorterDuff-режимов наложения изображения на холст (Canvas). Давайте освежим в памяти, что это такое.
Теория
Введем обозначения (как в стандарте):
- Da (destination alpha) —исходная прозрачность пиксела холста;
- Dc (destination color) — исходный цвет пиксела холста;
- Sa (source alpha) — прозрачность пиксела накладываемого изображения;
- Sc (source color) — цвет пиксела накладываемого изображения;
- Da’ — прозрачность пискела холста после наложения;
- Dc’ — цвет пискела холста после наложения.
Режим определяется правилом, по которому определяются Da’ и Dc’ в зависимости от Dc, Da,Sa, Sc.
Таким образом, у нас есть 4 параметра для каждого пиксела. Формула, по которой из этих четырех параметров получаются цвет и прозрачность пиксела итогового изображения, и есть описание режима наложения.
[Da’, Dc’] = f(Dc, Da, Sa, Sc)
Например, для режима DST_IN справедливо
Da' = Sa·Da
Dc' = Sa·Dc
или в компактной записи [Da’, Dc’] = [Sa·Da, Sa·Dc]. В документации Android это выглядит как
Надеюсь, теперь можно давать ссылку на не в меру лаконичную документацию от Google. Без предварительного объяснения созерцание оной зачастую вводит разработчиков в ступор: developer.android.com/reference/android/graphics/PorterDuff.Mode.html.
Но соображать в уме, как будет выглядеть итоговая картинка по этим формулам, довольно утомительно. Гораздо удобнее воспользоваться вот такой шпаргалкой по режимам наложения:
Из этой шпаргалки сразу видно интересующие нас режимы SRC_IN и DST_IN. Они, по сути, являются пересечением непрозрачных областей холста и накладываемого изображения, при этом DST_IN оставляет цвет холста, а SRC_IN меняет цвет. Если на холсте изначально была отрисована картинка, то выбираем DST_IN. Если на холсте изначально была нарисована маска — выбираем SRC_IN.
Теперь, когда все стало понятно, можно писать код.
SRC_IN
Довольно часто на stackoverflow.com встречаются ответы, где при использовании PorterDuff рекомендуют выделять память под буфер. Иногда даже это предлагается делать при каждом вызове onDraw. Конечно, это крайне неэффективно. Нужно стараться избегать вообще любого выделения памяти на куче в onDraw. Тем более удивительно наблюдать там Bitmap.createBitmap, который запросто может потребовать несколько мегабайт памяти. Простой пример: картинка 640*640 в формате ARGB занимает в памяти более 1,5 Мб.
Чтобы этого избежать, буфер можно выделять заранее и переиспользовать его в вызовах onDraw.
Вот пример Drawable, в которой используется режим SRC_IN. Память под буфер выделяется при изменении размера Drawable.
public class MaskedDrawablePorterDuffSrcIn extends Drawable {
private Bitmap mPictureBitmap;
private Bitmap mMaskBitmap;
private Bitmap mBufferBitmap;
private Canvas mBufferCanvas;
private final Paint mPaintSrcIn = new Paint();
public MaskedDrawablePorterDuffSrcIn() {
mPaintSrcIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
}
public void setPictureBitmap(Bitmap pictureBitmap) {
mPictureBitmap = pictureBitmap;
}
public void setMaskBitmap(Bitmap maskBitmap) {
mMaskBitmap = maskBitmap;
}
@Override
protected void onBoundsChange(Rect bounds) {
super.onBoundsChange(bounds);
final int width = bounds.width();
final int height = bounds.height();
if (width <= 0 || height <= 0) {
return;
}
mBufferBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); mBufferCanvas = new Canvas(mBufferBitmap);
}
@Override
public void draw(Canvas canvas) {
if (mPictureBitmap == null || mMaskBitmap == null) {
return;
}
mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, null);
mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, mPaintSrcIn);
//dump the buffer
canvas.drawBitmap(mBufferBitmap, 0, 0, null);
}
В примере выше сначала на холст буфера рисуется маска, потом в режиме SRC_IN рисуется картинка.
Внимательный читатель заметит, что этот код неоптимален. Зачем перерисовывать холст буфера при каждом вызове draw? Ведь можно делать это только когда что-то изменилось.
public class MaskedDrawablePorterDuffSrcIn extends MaskedDrawable {
private Bitmap mPictureBitmap;
private Bitmap mMaskBitmap;
private Bitmap mBufferBitmap;
private Canvas mBufferCanvas;
private final Paint mPaintSrcIn = new Paint();
public static MaskedDrawableFactory getFactory() {
return new MaskedDrawableFactory() {
@Override
public MaskedDrawable createMaskedDrawable() {
return new MaskedDrawablePorterDuffSrcIn();
}
};
}
public MaskedDrawablePorterDuffSrcIn() {
mPaintSrcIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
}
@Override
public void setPictureBitmap(Bitmap pictureBitmap) {
mPictureBitmap = pictureBitmap;
redrawBufferCanvas();
}
@Override
public void setMaskBitmap(Bitmap maskBitmap) {
mMaskBitmap = maskBitmap;
redrawBufferCanvas();
}
@Override
protected void onBoundsChange(Rect bounds) {
super.onBoundsChange(bounds);
final int width = bounds.width();
final int height = bounds.height();
if (width <= 0 || height <= 0) {
return;
}
if (mBufferBitmap != null
&& mBufferBitmap.getWidth() == width
&& mBufferBitmap.getHeight() == height) {
return;
}
mBufferBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); //that's too bad
mBufferCanvas = new Canvas(mBufferBitmap);
redrawBufferCanvas();
}
private void redrawBufferCanvas() {
if (mPictureBitmap == null || mMaskBitmap == null || mBufferCanvas == null) {
return;
}
mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, null);
mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, mPaintSrcIn);
}
@Override
public void draw(Canvas canvas) {
//dump the buffer
canvas.drawBitmap(mBufferBitmap, 0, 0, null);
}
@Override
public void setAlpha(int alpha) {
mPaintSrcIn.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter cf) {
//Not implemented
}
@Override
public int getOpacity() {
return PixelFormat.UNKNOWN;
}
@Override
public int getIntrinsicWidth() {
return mMaskBitmap != null ? mMaskBitmap.getWidth() : super.getIntrinsicWidth();
}
@Override
public int getIntrinsicHeight() {
return mMaskBitmap != null ? mMaskBitmap.getHeight() : super.getIntrinsicHeight();
}
}
DST_IN
В отличие от SRC_IN, при использовании DST_IN надо изменить порядок рисования: сначала на холст рисуется картинка, а сверху маска. Изменения по сравнению с предыдущим примером будут такие:
mPaintDstIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, null);
mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, mPaintDstIn);
Что любопытно, этот код не дает ожидаемого результата, если маска представлена в формате ALPHA_8. Если же она представлена в неэффективном формате ARGB_8888, то все прекрасно. Вопрос на stackoverflow.com на данный момент висит без ответа. Если кто-то знает причину — просьба поделиться знанием в комментариях.
CLEAR + DST_OVER
В примерах выше память под буфер выделялась только при изменении размера Drawable, что уже намного лучше, чем выделять ее при каждой отрисовке.
Но если подумать, то в некоторых случаях можно обойтись вообще без выделения буфера и рисовать сразу на холст, который нам передали в draw. При этом нужно иметь в виду, что на нем уже что-то нарисовано.
Для этого в холсте мы как бы прорезаем дырку по форме маски с помощью режима CLEAR, а затем рисуем картинку в режиме DST_OVER — образно говоря, подкладываем картинку под холст. Через эту дырку видно картинку и эффект получается как раз такой, как нам нужно.
Такой трюк можно использовать, когда известно, что маска и изображение не содержат полупрозрачных областей, а только либо полностью прозрачные либо полностью непрозрачные пикселы.
Код будет выглядеть так:
mPaintDstOver.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER));
mPaintClear.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
//draw the mask with clear mode
canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintClear);
//draw picture with dst over mode
canvas.drawBitmap(mPictureBitmap, 0, 0, mPaintDstOver);
У этого решения есть проблемы с прозрачностью. Если мы захотим реализовать метод setAlpha, то через изображение будет просвечивать фон окна, а вовсе не то, что было нарисовано на холсте под нашим Drawable. Сравните изображения:
Слева — как должно быть, справа — как получается, если использовать CLEAR + DST_OVER в комбинации с полупрозрачностью.
Как видим, использование режимов PorterDuff на Android связано либо с выделением лишней памяти, либо с ограничением применения. К счастью, есть способ избежать всех этих проблем. Достаточно воспользоваться BitmapShader.
BitmapShader
Обычно, когда упоминаются шейдеры, вспоминают OpenGL. Но не стоит пугаться, использование BitmapShader на Android не требует от разработчика знаний в этой области. По сути, реализации android.graphics.Shader описывают алгоритм, который определяет цвет каждого пиксела, то есть являются пискельными шейдерами.
Как их использовать? Очень просто: если шейдер зарядить в Paint, то все, что рисуется с помощью этого Paint, будет брать цвет пикселов из шейдера. В пакете есть реализации шейдеров для рисования градиентов, комбинирования других шейдеров и (самый полезный в контексте нашей задачи) BitmapShader, который инициализируется с помощью Bitmap. Такой шейдер возвращает цвет соответствующих пикселов из Bitmap, которое было передано при инициализации.
В документации есть важное уточнение: рисовать шейдером можно все, кроме Bitmap. На самом деле, если Bitmap в формате ALPHA_8, то при отрисовке такого Bitmap с помощью шейдера все прекрасно работает. А наша маска как раз в таком формате, так давайте попробуем отобразить маску с помощью шейдера, который использует изображения цветка.
По шагам:
- создаем BitmapShader, в который загружаем изображение цветка;
- создаем Paint, в который заряжаем этот BitmapShader;
- рисуем маску с помощью этого Paint.
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
}
public void draw(Canvas canvas) {
if (mPaintShader == null || mMaskBitmap == null) {
return;
}
canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintShader);
}
Все очень просто, не так ли? На самом деле, если размеры маски и изображения не совпадают, то мы увидим не совсем то, что ожидали. Маска будет замощена изображениями, что соответствует использованному режиму Shader.TileMode.REPEAT
.
Чтобы привести размер картинки к размеру маски, можно воспользоваться методом android.graphics.Shader#setLocalMatrix, в который нужно передать матрицу преобразования. К счастью, вспоминать курс аналитической геометрии не придется: класс android.graphics.Matrix содержит удобные методы формирования матрицы. Будем сжимать шейдер так, чтобы изображение полностью поместилось в маску без искажений пропорций, и сдвинем его так, чтобы совместить центры изображения и маски:
private void updateScaleMatrix() {
if (mPictureBitmap == null || mMaskBitmap == null) {
return;
}
int maskW = mMaskBitmap.getWidth();
int maskH = mMaskBitmap.getHeight();
int pictureW = mPictureBitmap.getWidth();
int pictureH = mPictureBitmap.getHeight();
float wScale = maskW / (float) pictureW;
float hScale = maskH / (float) pictureH;
float scale = Math.max(wScale, hScale);
Matrix matrix = new Matrix();
matrix.setScale(scale, scale);
matrix.postTranslate((maskW - pictureW * scale) / 2f, (maskH - pictureH * scale) / 2f);
mBitmapShader.setLocalMatrix(matrix);
}
Также использование шейдера дает нам возможность легко реализовать методы изменения прозрачности нашего Drawable и установки ColorFilter. Достаточно вызвать одноименные методы шейдера.
public class MaskedDrawableBitmapShader extends Drawable {
private Bitmap mPictureBitmap;
private Bitmap mMaskBitmap;
private final Paint mPaintShader = new Paint();
private BitmapShader mBitmapShader;
public void setMaskBitmap(Bitmap maskBitmap) {
mMaskBitmap = maskBitmap;
updateScaleMatrix();
}
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
updateScaleMatrix();
}
@Override
public void draw(Canvas canvas) {
if (mPaintShader == null || mMaskBitmap == null) {
return;
}
canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintShader);
}
private void updateScaleMatrix() {
if (mPictureBitmap == null || mMaskBitmap == null) {
return;
}
int maskW = mMaskBitmap.getWidth();
int maskH = mMaskBitmap.getHeight();
int pictureW = mPictureBitmap.getWidth();
int pictureH = mPictureBitmap.getHeight();
float wScale = maskW / (float) pictureW;
float hScale = maskH / (float) pictureH;
float scale = Math.max(wScale, hScale);
Matrix matrix = new Matrix();
matrix.setScale(scale, scale);
matrix.postTranslate((maskW - pictureW * scale) / 2f, (maskH - pictureH * scale) / 2f);
mBitmapShader.setLocalMatrix(matrix);
}
@Override
public void setAlpha(int alpha) {
mPaintShader.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter cf) {
mPaintShader.setColorFilter(cf);
}
@Override
public int getOpacity() {
return PixelFormat.UNKNOWN;
}
@Override
public int getIntrinsicWidth() {
return mMaskBitmap != null ? mMaskBitmap.getWidth() : super.getIntrinsicWidth();
}
@Override
public int getIntrinsicHeight() {
return mMaskBitmap != null ? mMaskBitmap.getHeight() : super.getIntrinsicHeight();
}
}
На мой взгляд, это самое удачное решение задачи: не требуется выделение буфера, нет проблем с прозрачностью. Более того, если маска простой геометрической формы, то можно отказаться от загрузки Bitmap с маской и рисовать маску программно. Это позволит сэкономить память, необходимую для хранения маски в виде Bitmap.
Например, используемая в этой статье в качестве примера маска — это довольно простая геометрическая фигура, которую несложно отрисовать.
public class FixedMaskDrawableBitmapShader extends Drawable {
private Bitmap mPictureBitmap;
private final Paint mPaintShader = new Paint();
private BitmapShader mBitmapShader;
private Path mPath;
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
mPath = new Path();
mPath.addOval(0, 0, getIntrinsicWidth(), getIntrinsicHeight(), Path.Direction.CW);
Path subPath = new Path();
subPath.addOval(getIntrinsicWidth() * 0.7f, getIntrinsicHeight() * 0.7f, getIntrinsicWidth(), getIntrinsicHeight(), Path.Direction.CW);
mPath.op(subPath, Path.Op.DIFFERENCE);
}
@Override
public void draw(Canvas canvas) {
if (mPictureBitmap == null) {
return;
}
canvas.drawPath(mPath, mPaintShader);
}
@Override
public void setAlpha(int alpha) {
mPaintShader.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter cf) {
mPaintShader.setColorFilter(cf);
}
@Override
public int getOpacity() {
return PixelFormat.UNKNOWN;
}
@Override
public int getIntrinsicWidth() {
return mPictureBitmap != null ? mPictureBitmap.getWidth() : super.getIntrinsicWidth();
}
@Override
public int getIntrinsicHeight() {
return mPictureBitmap != null ? mPictureBitmap.getHeight() : super.getIntrinsicHeight();
}
}
Поскольку шейдер можно использовать для рисования чего угодно, то можно попробовать нарисовать текст, например:
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
mPaintShader.setTextSize(getIntrinsicHeight());
mPaintShader.setStyle(Paint.Style.FILL);
mPaintShader.setTextAlign(Paint.Align.CENTER);
mPaintShader.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
}
@Override
public void draw(Canvas canvas) {
if (mPictureBitmap == null) {
return;
}
canvas.drawText("A", getIntrinsicWidth() / 2, getIntrinsicHeight() * 0.9f, mPaintShader);
}
Результат:
RoundedBitmapDrawable
Полезно знать о существовании в Support Library класса RoundedBitmapDrawable. Он может пригодиться, если нужно только скруглить края или сделать картинку полностью круглой. Внутри используется BitmapShader.
Производительность
Давайте посмотрим, как перечисленные выше решения влияют на производительность. Для этого я использовал RecyclerView с сотней элементов. Графики GPU monitor сняты при быстром скроллинге на достаточно производительном смартфоне (Moto X Style).
Напомню, что на графиках по оси абсцисс — время, по оси ординат — количество миллисекунд, затраченное на отрисовку каждого кадра. В идеале график должен помещаться ниже зеленой линии, что соответствует 60 FPS.
Plain BitmapDrawable (no masking)
SRC_IN
BitmapShader
Видно, что использование BitmapShader позволяет добиться такого же высокого фреймрейта, что и без накладывания маски вообще. В то время как SRC_IN решение уже нельзя признать достаточно производительным, интерфейс ощутимо «подтормаживает» при быстром скроллинге, что подтверждается графиком: многие кадры отрисовываются дольше 16 мс, а некоторые и больше 33 мс, то есть FPS падает ниже 30.
Выводы
На мой взгляд, преимущества подхода с использованием BitmapShader очевидны: не надо выделять память под буфер, отличная гибкость, поддержка полупрозрачности, высокая производительность.
Неудивительно, что именно этот подход используется в библиотечных реализациях.
Делитесь своими мыслями в комментариях!
Да пребудет с вами stackoverflow.com!
Автор: Badoo