Динамический blur на Android
Информации о том как быстро размыть картинку на Android существует предостаточно.
Но можно ли сделать это настолько эффективно, чтобы без лагов перерисовывать размытый bitmap при любом изменении контента, как это реализовано в iOS?
Итак, что хотелось бы сделать:
- ViewGroup, которая сможет размывать контент, который находится под ней и отображать в качестве своего фона. Назовем ее BlurView
- Возможность добавлять в нее детей (любые View)
- Не зависеть от какой-либо конкретной реализации родительской ViewGroup
- Перерисовывать размытый фон, если контент под нашей View меняется
- Делать полный цикл размытия и отрисовки менее чем за 16ms
- Иметь возможность изменять стратегию размытия
В статье я постараюсь обратить внимание только на ключевые моменты, за деталями прошу сюда.
Создадим 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 — запрещаем.
Я сделал интерфейс BlurAlgorithm и возможность настройки алгоритма.
Добавил 2 реализации, StackBlur (by Mario Klingemann) и RenderScriptBlur.
Чтобы сэкономить память, я записываю результат в тот же bitmap, который мне пришел на вход. Это опционально.
Так же можно попробовать кэшировать outAllocation и переиспользовать его, если размер входного изображения не изменился.
@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;
}
Есть несколько вариантов:
- Вручную делать апдейт, когда вы точно знаете, что контент изменился
- Завязаться на события скролла, если BlurView находится над скроллящимся контейнером
- Слушать вызовы 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.
Идеи, замечания, баг-репорты и пулл реквесты приветствуются.