Программное создание NinePatchDrawable

в 13:43, , рубрики: 9-patch, android, builder, inflater, nine-patch, vector, vector drawable, Разработка под android

В новом Android Lollipop появился такой интересный компонент как VectorDrawable. Если использовать его с умом, можно значительно снизить объем приложения, сэкономив на графических ресурсах, плюс, использование векторной графики освобождает нас от муторного процесса создания изображений под разные плотности экрана. Первая мысль, которая меня посетила, когда я увидел VectorDrawable, была: “Ух ты! А его можно тянуть как NinePatch?”. Оказалось нельзя. Тут можно было бы немного огорчиться и довольствоваться тем, что хотя бы иконки можно в векторе держать. Однако, я на этом решил не останавливаться. В итоге получилась универсальная утилита, которая из любого Drawable способна сделать NinePatchDrawable.

Программное создание NinePatchDrawable - 1

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

Перед тем, как начать чудесное превращение, давайте немного углубимся в детали и выясним, как же нам прийти к желаемому результату. Для начала попытаемся понять, что из себя представляет NinePatchDrawable. А он состоит из двух основных частей, первая из которых — это Bitmap, а вторая массив байт, называемый “chunk”, содержащий в себе информацию о том как эту Bitmap растягивать. Из этого следует, что имея на руках любую Bitmap и соответствующий ей “chunk”, возможно создать NinePatchDrawable в обход стандартной модели: создать 9.png, положить его в проект, скомпилировать.

Однако, на практике не все так просто. Дело в том, что, фактически, не реально найти документацию про то как формируется “chunk”. Его генерацией, ещё на стадии компиляции, занимается утилита «aapt», а в API андроида нет ни одного класса помогающего в генерации “chunk”. Мне удалось выяснить, что из себя представляет большая часть массива. Скажу честно, я не до конца понял какой байт за что отвечает, однако этого оказалось достаточно.

Что такое “chunk”?

Разберем этот массив по байтам. Байты, назначение которых мне не известно, я решил назвать «магическими», в силу их таинственности и загадочности. Итак:

  • [0] — флаг wasDeserialized;
  • [1] — указывает количество точек в массиве XDivs;
  • [2] — указывает количество точек в массиве YDivs;
  • [3] — указывает количество точек в массиве Colors;
  • [4-11] — 8 «магических» байт;
  • [12-27] — 16 байт для Padding;
  • [28-31] — 4 «магических» байта;
  • [32- ~] — далее идет перечисление массивов XDivs, YDivs и Colors.

Нулевой байт хранит в себе булевое значение “wasDeserialized”. Во всех, найденных мной, примерах говорится, что он должен быть 0x01, но если указать вместо этого любое другое значение ни чего страшного не произойдет, он автоматически установится в true при конвертировании массива “chunk” в нативный объект.

Данные о Padding, XDivs, YDivs и Colors хранятся в int(4 байта). Кстати, именно поэтому Padding занимает не 4 байта, по количеству сторон, а 16 байт.

XDivs и YDivs содержат области для растягивания по осям X и Y. Счет ведется с нуля. Первое число указывает на начало первой области, следующее на её конец и так далее. Затем, аналогичным образом описывается следующий массив.

В качестве примера давайте разберем NinePatch изображение 6 на 6.

Программное создание NinePatchDrawable - 2

Здесь, растяжимая область по оси X проходит от 2 до 3 пикселя, а по оси Y от 2 до 4. Значит XDivs будет состоять из [0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00], а YDivs из [0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00]. В качестве первого(второго по порядку) и второго(третий по порядку) байтов массива “chunk”, определяющих размеры XDivs и YDivs, следует указать 0x02, что значит 2 точки по 4 байта каждая.

Для, лучшего понимания того, что должно храниться в массиве Colors я внёс небольшие изменения в предыдущее изображение давайте посмотрим на него, ещё раз.

Программное создание NinePatchDrawable - 3

Как видно из рисунка, изображение можно поделить на 9 регионов. Так вот, Colors определяет как эти регионы рисовывать. Есть 2 варианта:

  1. 0x00000000 (TRASPARENT) регион будет прозрачным.
  2. 0x00000001 (NO_COLOR) регион будет видимым.

Как могло показаться из названия, Colors отвечает за некий цвет, однако, в данном случае, его назначение на много тривиальней — указывать видимость региона.
Для приведенного изображения, если мы хотим оставить все регионы видимыми, нужно 9 раз указать [0x01, 0x00, 0x00, 0x00], а для 3 байта (4 по порядку) массива “chunk” выставить значение равное 0x09.

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

По началу, я сомневался, хватит ли мне этого для создания полноценного NinePatch. Оказалось, хватит. Дело в том, что NinePatch, перед использованием “chunk”, преобразует его при мощи метода validateNinePatchChunk в нативный объект Res_png_9patch. Если заглянуть в исходники, по коду можно увидеть, непонятные для нас байты не используются, а значит, можно заполнить их любыми значениями, например нулями.

NinePatchBuilder

Теперь, зная как с генерировать “chunk”, не составит ни какого труда создать NinePatch из любого изображения, в том числе и Drawable, если, предварительно, нарисовать его на Bitmap. Для упрощения этих действий я решил создать класс NinePatchBuilder.

Следующий код показывает как использовать его в случае с обычным Bitmap.

NinePatchBuilder ninePatchBuilder = new NinePatchBuilder(getResources())
    .addStretchSegmentX(0.49f, 0.51f)
    .addStretchSegmentY(0.49f, 0.51f)
    .setBitmap(bitmap);

Drawable drawable = ninePatchBuilder.build();

Методами addStretchSegment указываются области для растягивания. Поскольку, при создании NinePatch, могут использоваться изображения, размер которых заранее не известен, было решено использовать относительные размеры в диапазоне [0, 1]. При вызове build, в зависимости от установленных параметров и размера Bitmap, сформируется массив “chunk” и создастся NinePatchDrawable.

Вот что происходит внутри NinePatchBuilder-а:

// Код из NinePatchBuilder.
private Drawable buildFromBitmap(Bitmap bitmap) {
    return new NinePatchDrawable(mResources,
            bitmap,
            getChunkByteArray(bitmap),
            getPaddingRect(bitmap.getWidth(), bitmap.getHeight()),
            mSrcName);
}

Код метода getChunkByteArray приводить не буду, так как большая часть его реализации вытекает из описанного ранее алгоритма генерации “chunk”.

Аналогично происходит для Drawable. В качестве Drawable тут может выступать все что угодно, в том числе и VectorDrawable. В итоге, имея лишь одно векторное изображение, мы получаем полный набор NinePatchDrawable, для всех плотностей экрана!
Допустим, у нас есть векторное изображение.

android.xml

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="500dp"
    android:height="500dp"
    android:viewportWidth="500"
    android:viewportHeight="500">

    <group>
        <path
            android:fillColor="#9FBF3B"
            android:pathData="M301.314,83.298l20.159-29.272c1.197-1.74,0.899-4.024-0.666-5.104c-1.563-1.074-3.805-0.543-4.993,1.199
L294.863,80.53c-13.807-5.439-29.139-8.47-45.299-8.47c-16.16,0-31.496,3.028-45.302,8.47l-20.948-30.41
c-1.201-1.74-3.439-2.273-5.003-1.199c-1.564,1.077-1.861,3.362-0.664,5.104l20.166,29.272
c-32.063,14.916-54.548,43.26-57.413,76.34h218.316C355.861,126.557,333.375,98.214,301.314,83.298" />
        <path
            android:fillColor="#FFFFFF"
            android:pathData="M203.956,129.438c-6.673,0-12.08-5.407-12.08-12.079c0-6.671,5.404-12.08,12.08-12.08
c6.668,0,12.073,5.407,12.073,12.08C216.03,124.03,210.624,129.438,203.956,129.438" />
        <path
            android:fillColor="#FFFFFF"
            android:pathData="M295.161,129.438c-6.668,0-12.074-5.407-12.074-12.079c0-6.673,5.406-12.08,12.074-12.08
c6.675,0,12.079,5.409,12.079,12.08C307.24,124.03,301.834,129.438,295.161,129.438" />
        <path
            android:fillColor="#9FBF3B"
            android:pathData="M126.383,297.598c0,13.45-10.904,24.354-24.355,24.354l0,0c-13.45,0-24.354-10.904-24.354-24.354V199.09
c0-13.45,10.904-24.354,24.354-24.354l0,0c13.451,0,24.355,10.904,24.355,24.354V297.598z" />
        <path
            android:fillColor="#9FBF3B"
            android:pathData="M140.396,175.489v177.915c0,10.566,8.566,19.133,19.135,19.133h22.633v54.744
c0,13.451,10.903,24.354,24.354,24.354c13.451,0,24.355-10.903,24.355-24.354v-54.744h37.371v54.744
c0,13.451,10.902,24.354,24.354,24.354s24.354-10.903,24.354-24.354v-54.744h22.633c10.569,0,19.137-8.562,19.137-19.133V175.489
H140.396z" />
        <path
            android:fillColor="#9FBF3B"
            android:pathData="M372.734,297.598c0,13.45,10.903,24.354,24.354,24.354l0,0c13.45,0,24.354-10.904,24.354-24.354V199.09
c0-13.45-10.904-24.354-24.354-24.354l0,0c-13.451,0-24.354,10.904-24.354,24.354V297.598z" />
    </group>
</vector>

Преобразовать его в NinePatch не составит большого труда.

NinePatchBuilder ninePatchBuilder = new NinePatchBuilder(resources)
    .addStretchSegmentX(0.49f, 0.51f)
    .addStretchSegmentY(0.49f, 0.51f)
    .setDrawable(R.drawable.android,
            (int) resources.getDimension(R.dimen.android_width),
            (int) resources.getDimension(R.dimen.android_height));

Дополнительно нужно указать размеры в пикселях, так как не все Drawable имеют фиксированные размеры. В момент вызова build, Drawable нарисуется на Bitmap и уже эта Bitmap используется для создания NinePacthDrawable.

Особые случаи

Естественно, слепо использовать нарисованный Drawable, не всегда удачное решение, так как есть, например, DrawableContainer и его наследники. Для поддержки таких сложный объектов пришлось пойти на определенную хитрость.

// Код из NinePatchBuilder.
if (drawable instanceof DrawableContainer) {
    final XmlPullParser parser = mResources.getXml(drawableId);
    final AttributeSet attrs = Xml.asAttributeSet(parser);
    int type = XmlPullParser.START_DOCUMENT;
    try {
        while ((type=parser.next()) != XmlPullParser.START_TAG &&
                type != XmlPullParser.END_DOCUMENT) {
            // Empty loop
        }
    } catch (XmlPullParserException | IOException e) {
        e.printStackTrace();
    }
    if (type == XmlPullParser.START_TAG) {
        Drawable result = null;
        try {
            result = drawable.getClass().newInstance();
        } catch (InstantiationException | IllegalAccessException e) {
            e.printStackTrace();
        }

        if (result != null) {
            try {
                result.inflate(new ResourceWrapper(mResources), parser, attrs);
                return result;
            } catch (XmlPullParserException | IOException e) {
                e.printStackTrace();
            }
        }
    }
}

Это часть кода из NinePatchBuilder, он создает новый Drawable того же класса, что и исходный, используя Class.newInstance(), а затем наполняет его при помощи метода inflate. Все это похоже на то, что происходит внутри LayoutInflater, за исключением ResourceWrapper. В нем и таится вся суть. Если заглянуть в работу метода inflate, то мы увидим, дочерние Drawable получаются методом getDrawable из переданных, в качестве параметра, ресурсов. Для получения желаемого результата достаточно переопределить этот метод.

// Код из NinePatchBuilder.
private class ResourceWrapper extends Resources {

    public ResourceWrapper(Resources resources) {
        super(resources.getAssets(), resources.getDisplayMetrics(), resources.getConfiguration());
    }

    @Override
    public Drawable getDrawable(int id) throws NotFoundException {
        return buildFromDrawable(id, mDrawableWidth, mDrawableHeight);
    }

    @Override
    public Drawable getDrawable(int id, Theme theme) throws NotFoundException {
        return buildFromDrawable(id, mDrawableWidth, mDrawableHeight);
    }
}

Благодаря такому «финту ушами» мы реализовали полную поддержку всех наследников DrawableContainer с любым уровнем вложенности, и если вы преобразуете StateListDrawable, то на выходе получится StateListDrawable состоящая из NinePatchDrawable.

XML и кеширование

Одного билдера мне оказалось мало, я решил пойти дальше и сделать класс NinePatchInflater, собирающий NinePatch из XML файла. В итоге, наш Drawable можно описать следующим образом:

<nine-patch-plus
    xmlns:auto="http://schemas.android.com/apk/res-auto"
    auto:src="@drawable/android"
    auto:width="@dimen/android_width"
    auto:height="@dimen/android_height"
    auto:stretchX="0.49, 0.51"
    auto:stretchY="0.49, 0.51"
    />

Файл должен находиться в папке “xml”. Теперь, код, занимающийся созданием такого Drawable, можно сократить до одной строки.

Drawable drawable = NinePatchInflater.inflate(resources, R.xml.vector_drawable_nine_patch);

Кроме вынесения большей части кода в отдельный файл у inflater-a есть ещё один большой плюс — это кеширование по id ресурса. Дело в том, что создание Drawable может оказаться весьма дорогой операцией, особенно в нашем случае, когда для получения одного Drawable, приходится создавать кучу не нужных, в будущем, объектов. К счастью большая часть необходимой работы уже выполнена в классе ConstantState, нам лишь необходимо сохранять ConstantState созданных Drawable в кеше и, при необходимости, создавать новые Drawable при помощи метода ConstantState.newDrawable(). Не буду углубляться в подробности, статья и так получилась развернутой, к тому же я не придумал ни чего нового, именно таким способом происходит кеширование в классе Resources.

Заключение

Получилось неплохо, однако, создать полноценную обёртку над ресурсами, что бы можно было вставлять ссылки на эти файлы прямо в XML разметке, не прибегая к написанию программного кода, так и нет получилось. Как оказалось, при создании View, местами, используются методы с модификатором доступа «по умолчанию», а иногда, на прямую вызываются статические методы класса Drawable. Несмотря на это, считаю, что желаемый результат был достигнут, хоть и не в полной мере.

Проект на GitHub: NinePatchBuildUtils

Как подключить к своему проекту

Есть 2 варианта.
1 вариант (в лоб):

  • Скопировать папку с модулем «ninepatchbuildutils» в свой проект
  • В файл settings.gradle добавить:
    include ':ninepatchbuildutils'
  • В фаил build.gradle модуля приложения добавить зависимость:
    compile project(':ninepatchbuildutils')
  • Пересобрать проект

2 вариант (элегантный):

  • Скачать проект с гита в отдельную папку, например «NinePatchBuildUtils»
  • В файл settings.gradle добавить:
    include ':ninepatchbuildutils'
    project(':ninepatchbuildutils').projectDir = new File('<Путь к папке с проектами>/NinePatchBuildUtils/ninepatchbuildutils/')
    

    Также можно использовать относительный путь:

    project(':ninepatchbuildutils').projectDir = new File(settingsDir, '../NinePatchBuildUtils/ninepatchbuildutils/')
  • В фаил build.gradle модуля приложения добавить зависимость:
    compile project(':ninepatchbuildutils')
  • Пересобрать проект

Ссылки для особо упоротых как я любопытных:
Исходники NinePatch: NinePatch.java и NinePatch.cpp
Место где можно почитать про Res_png_9patch: ResourceTypes.h и ResourceTypes.cpp

Спасибо за внимание!

Автор: KamiSempai

Источник

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


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