Введение


При разработке под 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!
Поделиться с друзьями
-->

Комментарии (7)


  1. mmMike
    22.09.2016 11:39
    -1

    кто столкнулся с необходимостью реализовать наложение маски вручную,

    Я то думал что Вы использование Render Script опишете…
    На нем действительно вручную и можно делать очень интересные и быстрые обработки изображения.


    А увидел использование библиотечного android.graphics.BitmapShader


    А в чем смысл статьи?
    В том, что использование android.graphics.Shader лучше чем собственная реализация на Java?


    Или я чего то не вижу? Смотрю исходники и не понимаю, что в них такого не стандартного.


    1. ArkadyGamza
      22.09.2016 12:14
      +1

      Действительно, вещи описаны простые и стандартные, и опытные девелоперы это все знают. Но меня поразило как много ответов на stackoverflow дают откровенно вредительские рекомендации. Я надеюсь, что эта статья будет полезной для начинающих разработчиков, которые в первый раз столкнулись с такой задачей.


      1. mmMike
        22.09.2016 12:21
        -4

        Я надеюсь, что эта статья будет полезной для начинающих разработчиков, которые в первый раз столкнулись с такой задачей.

        Аа… понятно. Тогда да. Уважаю. Не пожалели времени.
        С учетом того, что статей на русском довольно мало. Для кого то это может быть проблемой.


        Хотя я вот пару минут вглядывался в исходники в ступоре "чего здесь нестандартного… может чего не понимаю" ( ;


        как много ответов на stackoverflow дают откровенно вредительские рекомендации

        Последнее место где смотреть нужно. Обычно такой бред пишут.
        Место для ленивых, которым влом читать документацию.


        1. Divers
          22.09.2016 16:44

          Не в обиду, но какое-то старперское брюзжание.


  1. Starche
    22.09.2016 16:41

    Думал над этой задачей, и смотрел в сторону imagemagick. Погуглил — вроде есть возможность его подключить в андроидное приложение. Интересно, будет ли выигрыш в производительности, или наоборот


  1. willykolepniy
    23.09.2016 19:08

    Благодарю за статью, не разу не сталкивался с подобными задачами, теперь знаю что делать


  1. Mort
    24.09.2016 01:24

    Спасибо за сравнительные метрики в конце статьи.