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

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

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

gif с примером (8mb)


Как получить 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. Вариант с RenderScript выполняется на GPU.
Чтобы сэкономить память, я записываю результат в тот же 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мс на экранах из демо-проекта.
Galaxy nexus — около 10мс.
Sony Xperia Z3 compact почему-то справляется хуже — 8-16мс.
Тем не менее, производительностью я удовлетворен, FPS держится на хорошем уровне.

Заключение


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

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


  1. adombrovsky
    03.06.2016 19:03

    Спасибо за статью. Очень интересно, но в GitHub проекте не очень информативный скриншот. Блюр сливается с фоном изображения.


  1. Temtaime
    03.06.2016 23:50
    -7

    А теперь возьмите китайфон с двуядерным цпу и дохлой графикой и побудьте пользователем своего приложения.
    Нравится?


    1. Dimezis
      04.06.2016 00:10
      +5

      Возьмите, отпишите о производительности. Может взять еще телефон на 1.6 Андроиде? :)
      На Galaxy Nexus, который тоже довольно старый и не шибко мощный, все работает быстро.
      Претензия странная.


      1. Beanut
        04.06.2016 17:04

        Попробовал на Galaxy S2. API 16. Все работает, только как-то странно мерцает, что-ли.


        1. Dimezis
          06.06.2016 13:55

          У меня есть мысли по этому поводу. Думаю, это связано с артефактами блюра на краях битмапа.
          Немного позже попробую сделать workaround.


    1. ishkulov
      05.06.2016 21:56

      Можно на старых устройствах просто не активировать этот эффект, как это реализовано на iOS (до iPhone 4S и iPad 4 blur заменяется transparency).


  1. KumoKairo
    04.06.2016 00:00
    +2

    Я помню на iOS очень быстрый блюр получается если через скомпиленный GLSL шейдер картинку скармливать, GPU такие задачи решает в разы быстрее, чем CPU. Может на Android подобная возможность есть? Не пробовали через шейдеры делать?


    1. Dimezis
      04.06.2016 00:12
      +3

      Собственно, блюр с помощью RenderScript как раз выполняется на GPU, и пока что является самым быстрым вариантом.
      Может можно придумать действительно что-то еще быстрее на шейдерах, но я пока не пробовал.


  1. 110
    04.06.2016 17:22

    Проект под Android Studio?


    1. Dimezis
      05.06.2016 12:00
      +1

      Да


  1. Link20
    04.06.2016 17:22
    +1

    Здравствуйте, спасибо за статью, объясните, пожалуйста, почему выбрана 16 сдк минимальной версией?


    1. Dimezis
      05.06.2016 00:57
      +1

      Библиотека на 14 minSdk.
      А в демо-проект 16 версия, видимо, случайно попала. Спасибо, сменю на 14.


  1. sompylasar
    04.06.2016 22:27

    Чем отличается от https://github.com/500px/500px-android-blur ?


    1. Dimezis
      05.06.2016 11:08
      +1

      Самое основное отличие в том, что в 500px нужно самому решать когда перерисовывать блюр. Дело даже не в том, что это неудобно, а в том, что это в принципе не всегда возможно.

      Еще я попробовал на своем демо-проекте заюзать их либу и вижу, что их BlurringView почему-то не нарисовала блюр в правой части таба. Просто пустое место. А если в качестве blurredView ей указать root layout окна, она вообще крашится со StackOverflowError.

      В целом, мне кажется, что она менее гибкая и менее удобная. Единственный плюс (имхо), это XML атрибуты для настройки. Надо взять на вооружение.


      1. sompylasar
        05.06.2016 13:17

        Спасибо, замечательно! В таком случае можете предложить свой модуль здесь: https://github.com/react-native-fellowship/react-native-blur/issues/21 (опишите эти преимущества, там пока сошлись на варианте от 500px).


  1. IgeNiaI
    05.06.2016 00:58

    Выложите, пожалуйста, готовый .apk. Не у всех есть желание запускать рабочий компьютер, да и не у всех вообще есть такая возможность.


    1. Dimezis
      05.06.2016 11:59
      +1

      1. IgeNiaI
        06.06.2016 04:27

        На Galaxy S2 api 23 работает отлично


  1. basnopisets
    07.06.2016 14:06

    Статью не читай, сразу пиши комментарии) Вопрос снимается — прочитал статью невнимательно