Динамический blur на Android

в 15:58, , рубрики: android, Blur, RenderScript, Разработка под android

Информации о том как быстро размыть картинку на Android существует предостаточно.
Но можно ли сделать это настолько эффективно, чтобы без лагов перерисовывать размытый bitmap при любом изменении контента, как это реализовано в iOS?

Итак, что хотелось бы сделать:

  1. ViewGroup, которая сможет размывать контент, который находится под ней и отображать в качестве своего фона. Назовем ее BlurView
  2. Возможность добавлять в нее детей (любые View)
  3. Не зависеть от какой-либо конкретной реализации родительской ViewGroup
  4. Перерисовывать размытый фон, если контент под нашей View меняется
  5. Делать полный цикл размытия и отрисовки менее чем за 16ms
  6. Иметь возможность изменять стратегию размытия

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

gif с примером (8mb)
Динамический blur на Android - 1

Как получить bitmap с контентом под BlurView?

Создадим Canvas, Bitmap для него, и скажем всей View-иерархии нарисоваться на этом канвасе.

internalBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
internalCanvas = new Canvas(internalBitmap);
...
rootView.draw(internalCanvas);

Теперь в internalBitmap нарисованы все View, которые видны на экране.

Проблемы:
Если у вашего rootView нет заданного фона, нужно предварительно нарисовать на канвасе фон Activity, иначе он будет прозрачным на битмапе.

final View decorView = getWindow().getDecorView();
//Activity's root View. Can also be root View of your layout
final View rootView = decorView.findViewById(android.R.id.content);
final Drawable windowBackground = decorView.getBackground();
...
windowBackground.draw(internalCanvas);
rootView.draw(internalCanvas);

Хотелось бы рисовать только ту часть иерархии, которая нам нужна. То есть под нашей BlurView, с соответствующим размером.
Кроме того, было бы неплохо уменьшить Bitmap, на котором будет происходить отрисовка. Это ускорит отрисовку View иерархии, сам blur, а так же уменьшит потребление памяти.
Добавляем поле scaleFactor, желательно, чтобы этот коэффициент был степенью двойки.

float scaleFactor = 8;

Уменьшаем bitmap в 8 раз и настраиваем матрицу канваса так, чтобы корректно на нем рисовать, учитывая позицию, размер и scale factor:

private void setupInternalCanvasMatrix() {
        float scaledLeftPosition = -blurView.getLeft() / scaleFactor;
        float scaledTopPosition = -blurView.getTop() / scaleFactor;

        float scaledTranslationX = blurView.getTranslationX() / scaleFactor;
        float scaledTranslationY = blurView.getTranslationY() / scaleFactor;

        internalCanvas.translate(scaledLeftPosition - scaledTranslationX, scaledTopPosition - scaledTranslationY);
        float scaleX = blurView.getScaleX() / scaleFactor;
        float scaleY = blurView.getScaleY() / scaleFactor;
        internalCanvas.scale(scaleX, scaleY);
}

Теперь при вызове rootView.draw(internalCanvas), мы будем иметь на internalBitmap уменьшенную копию всей View-иерархии. Выглядеть она будет страшно, но это меня не очень волнует, т.к. дальше я все равно буду ее размывать.

Проблемы:
rootView скажет всем своим детям нарисоваться на канвасе, в том числе и BlurView. Этого хотелось бы избежать, BlurView не должна размывать сама себя. Для этого можно переопределить метод draw(Canvas canvas) в BlurView и делать проверку на каком канвасе ее просят отрисоваться.
Если это системный канвас — разрешаем отрисовку, если это internalCanvas — запрещаем.

Собственно, blur

Я сделал интерфейс BlurAlgorithm и возможность настройки алгоритма.
Добавил 2 реализации, StackBlur (by Mario Klingemann) и RenderScriptBlur.
Чтобы сэкономить память, я записываю результат в тот же bitmap, который мне пришел на вход. Это опционально.
Так же можно попробовать кэшировать outAllocation и переиспользовать его, если размер входного изображения не изменился.

RenderScriptBlur

@Override
public final Bitmap blur(Bitmap bitmap, float blurRadius) {
    Allocation inAllocation = Allocation.createFromBitmap(renderScript, bitmap);
    Bitmap outputBitmap;

    if (canModifyBitmap) {
        outputBitmap = bitmap;
    } else {
        outputBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());
    }

    //do not use inAllocation in forEach. it will cause visual artifacts on blurred Bitmap
    Allocation outAllocation = Allocation.createTyped(renderScript, inAllocation.getType());

    blurScript.setRadius(blurRadius);
    blurScript.setInput(inAllocation);
    blurScript.forEach(outAllocation);
    outAllocation.copyTo(outputBitmap);

    inAllocation.destroy();
    outAllocation.destroy();
    return outputBitmap;
}

Когда перерисовывать blur?

Есть несколько вариантов:

  1. Вручную делать апдейт, когда вы точно знаете, что контент изменился
  2. Завязаться на события скролла, если BlurView находится над скроллящимся контейнером
  3. Слушать вызовы draw() через ViewTreeObserver

Я выбрал 3 вариант, используя OnPreDrawListener. Стоит заметить, что его метод onPreDraw дергается каждый раз, когда любая View в иерархии рисует себя. Это, конечно, не идеал, т.к. придется перерисовывать BlurView на каждый чих, но зато это не требует никакого контроля со стороны программиста.

Проблемы:
onPreDraw будет так же вызван, когда будет происходить отрисовка всей иерархии на internalCanvas, а так же при отрисовке BlurView.
Чтобы избежать этой проблемы, я завел флаг isMeDrawingNow.

Вроде все очевидно, кроме одного момента. Ставить его в false нужно через handler, т.к. диспатч отрисовки происходит асинхронно через очередь событий UI потока. Поэтому нужно так же добавить этот таск в очередь событий, чтобы он выполнился именно в конце отрисовки.

drawListener = new ViewTreeObserver.OnPreDrawListener() {
    @Override
    public boolean onPreDraw() {
        if (!isMeDrawingNow) {
            updateBlur();
        }
        return true;
    }
};
rootView.getViewTreeObserver().addOnPreDrawListener(drawListener);

Производительность

Много статистики я не насобирал, но на Nexus 4, Nexus 5 весь цикл отрисовки занимает 1-4мс на экранах из демо-проекта.
Sony Xperia Z3 compact почему-то справляется гораздо хуже — 8-16мс.
Тем не менее, производительностью я удовлетворен, FPS держится на хорошем уровне.

Заключение

Весь код есть на гитхабе (библиотека и демо-проект) — BlurView.
Идеи, замечания, баг-репорты и пулл реквесты приветствуются.

Автор: Dimezis

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js