Динамічний blur на Android

Інформації про те як швидко розмити картинку на 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.
Щоб заощадити пам'ять, я записую результат в той же 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.
Ідеї, зауваження, баг-репорти і пулл реквесты вітаються.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.