Работа с SurfaceView в Android
Здравствуйте, Хабравчане!
При работе с 2D графикой в Android отрисовку можно выполнять используя Canvas. Проще всего это сделать с помощью своего класса, унаследованного от View. Необходимо просто описать метод onDraw(), и использовать предоставленный в качестве параметра canvas для выполнения всех необходимых действий. Однако этот подход имеет свои недостатки. Метод onDraw() вызывается системой. Вручную же можно использовать метод invalidate(), говорящий системе о необходимости перепрорисовки. Но вызов invalidate() не гарантирует незамедлительного вызова метода onDraw(). Поэтому, если нам необходимо постоянно делать отрисовку (например для какой-либо игры), вышеописанный способ вряд ли стоит считать подходящим.
Существует еще один подход — с использованием класса SurfaceView. Почитав официальное руководство и изучив несколько примеров, решил написать небольшую статью на русском, которая возможно поможет кому-то быстрее освоиться с данным способом выполнения отрисовки. Статья рассчитана на новичков. Никаких сложных и хитрых приемов здесь не описано.
Класс SurfaceView
Особенность класса SurfaceView заключается в том, что он предоставляет отдельную область для рисования, действия с которой должны быть вынесены в отдельный поток приложения. Таким образом, приложению не нужно ждать, пока система будет готова к отрисовке всей иерархии view-элементов. Вспомогательный поток может использовать canvas нашего SurfaceView для отрисовки с той скоростью, которая необходима.
Вся реализация сводится к двум основным моментам:
- Создание класса, унаследованного от SurfaceView и реализующего интерфейс SurfaceHolder.Callback
- Создание потока, который будет управлять отрисовкой.
Создание класса
Как было сказано выше, нам потребуется свой класс, расширяющий SurfaceView и реализующий интерфейс SurfaceHolder.Callback. Этот интерфейс предлагает реализовать три метода: surfaceCreated(), surfaceChanged() и surfaceDestroyed(), вызываемые соответственно при создании области для рисования, ее изменении и разрушении.
Работа с полотном для рисования осуществляется не напрямую через созданный нами класс, а с помощью объекта SurfaceHolder. Получить его можно вызовом метода getHolder(). Именно этот объект будет предоставлять нам canvas для отрисовки.
В конструкторе класса получаем объект SurfaceHolder и с помощью метода addCallback() указываем что хотим получать соответствующие обратные вызовы.
В методе surfaceCreated(), как правило, необходимо начинать выполнять отрисовку, а в surfaceDestroyed() наоборот завершать ее. Оставим пока тела этих методов пустыми и реализуем поток, отвечающий за отрисовку.
Реализация потока отрисовки
Создадим класс, унаследованный от Thread. В конструкторе он будет принимать два параметра: SurfaceHolder и Resources для загрузки картинки, которую мы будем рисовать на экране. Также в классе нам понадобится переменная-флаг, указывающая на то, что производится отрисовка и метод для установки этой переменной. Ну и, конечно, потребуется переопределить метод run().
В итоге получим следующий класс:
Для того, чтобы результат не выглядел совсем уж пресно, загруженную картинку увеличим в три раза, и немного сместим к центру экрана. Сделаем это при помощи матрицы преобразований. Также, не чаще чем раз в 30 миллисекунд, будем поворачивать картинку на 2 градуса вокруг ее центра. Само рисование на canvas лучше, конечно, вынести в отдельный метод, но в данном случае мы всего лишь очищаем экран и рисуем изображение. Так что можно оставить как есть.
Начало и завершение отрисовки
Теперь, после того как мы написали поток, управляющий отрисовкой, вернемся к редактированию нашего класса SurfaceView. В методе surfaceCreated() создадим поток и запустим его. А в методе surfaceDestroyed() завершим его работу. В итоге класс MySurfaceView примет следующий вид:
Следует отметить, что создание потока должно выполняться в методе surfaceCreated(). В примере LunarLander из официальной документации создание потока отрисовки происходит в конструкторе класса, унаследованного от SurfaceView. Но при таком подходе может возникнуть ошибка. Если свернуть приложение нажатием клавиши Home на устройстве, а затем снова открыть его, то возникнет исключительная ситуация IllegalThreadStateException.
Activity приложения может выглядеть следующим образом:
Результат работы программы выглядит так (из за вращения изображение немного размазано, но на устройстве выглядит вполне приемлемо):
Источник
Android. Surface
Дисклеймер
Данная статья предназначена для начинающих андроид разработчиков с небольшим опытом работы с видео и/или камерой, особенно тех кто начал разбирать примеры grafika и кому они показались сложными — здесь будет рассмотрен похожий код с упрощенным описанием основных шагов, проиллюстрированных диаграммами состояний.
Почему в заголовке вынесен класс Surface? В android множество классов имеют в своем названии слово Surface (Surface, SurfaceHolder, SurfaceTexture, SurfaceView, GLSurfaceView) они не связаны общей иерархией тем не менее объединены низкоуровневой логикой работы с вывод изображений. Мне показалось разумным использовать его в названии чтобы подчеркнуть попытку раскрытия работы именно с этой частью SDK.
Пример использования с разным API
Попробуем написать следующий пример: будем брать preview с камеры, накладывать на него анимированный drawable, выводить это все на экран и по необходимости записывать в файл. Полный код будет лежать https://github.com/tttzof351/AndroidSurfaceExample/
Для вывода на экраны мы воспользуемся GLSurfaceView, для записи классами MediaCodec и EGLSurface, а с камерой общаться через API V2. Общая схема примерно следующая:
Наложение нескольких Surface
Surface — фактически дескриптор области в памяти, которую нужно заполнить изображением. Скорее всего, мы получаем его пытаясь вывести что-то на экран или в файл, таким образом он работает как буфер для некоторого “процесса” который производит данные.
Чтобы создать наложение из нескольких Surface воспользуемся OpenGL.
Для этого мы создадим две квадратные external-текстуры и получим из них Surface-ы
В коде это будет выглядеть как то так:
XYZ координаты
Теперь нам нужно понять как создать и расположить текстуры, а для этого придется вспомнить как устроена координатная сетка в OpenGL: ее центр совпадает с центром сцены (окна), а границы нормированы т.е от -1 до 1.
На этой сцене мы хотим задать два прямоугольника (работа идет на плоскости поэтому все z координаты логично установлены в 0f) — красным мы обозначим тот куда будем помещать preview для камеры, а синим для анимированного drawable-а:
Выпишем наши координаты явно:
UV координаты
Достаточно ли этого? Оказывается, что нет 🙁
Текстура это отображение картинки на область сцены и чтобы его правильно совершить нужно указать в какое точно место точки на картинке попадут внутри этой области — для этого в OpenGL применяются UV координаты — они выходят из левого нижнего угла и имеют границы от 0 до 1 по каждой из осей.
Работает это следующим образом — каждой вершине нашей области мы зададим UV координаты и будем искать соответствующие точки на изображении, считая что там ширина и высота равны по 1.
Рассмотрим на примере — будем считать что камера отдает нам изображение в перевернутом и отраженном состоянии и при этом мы хотим показать только правую-верхнюю часть т.е взять 0.8 по широты и высоте изображения.
Тонкий момент — на данном этапе мы не знаем соотношения сторон области на экране — у нас есть только квадрат в относительных координатах, который заполнит собой всю сцену и соответственно растянется. Если бы мы делали fullscreen камеру то наши относительные размеры (2 по каждой стороне) растянулись бы до условных 1080×1920. Будем считать что размеры сцены мы зададим такие что их соотношение будет равно соотношению камеры.
Посмотрим куда перейдут координаты — правая верхняя точка нашей области (1, 1, 0) должна перейти в UV координату (0, 0), левая нижняя в (0.8f, 0.8f) и т. д
Таким образом получим соответствие XYZ и UV:
Если соотношение сторон между preview с камеры и областью на экране совпадало изначально то оно очевидным образом продолжит сохранятся т.к в нашем случаи мы просто умножили на 0.8f.
А что будет есть мы зададим значения больше 1? В зависимости от настроек которые мы передали OpenGL-у мы получим точки какой то части изображения. В нашем примере будет повторяться последняя линия по соответствующей оси и мы увидим артефакты в виде “полосок”
Итог: если мы хотим сжать/вырезать изображение сохраняя при этом позицию области на экране то UV координаты наш выбор!
Зададим координаты для наших текстур
Шейдеры
Иметь статичные XYZ и UV-координаты не очень удобно — мы например можем захотеть перемещать и масштабировать жестами наши текстуры. Чтобы их трансформировать заведем две матрицы для каждой текстуры: MVPMatrix и TexMatrix для для XYZ и UV координат соответственно.
Каждая OpenGL2 должна содержать шейдеры для того, чтобы вывести что-то на экран. Конечно, это не там тема которую можно раскрыть в одном абзаце, тем не менее в нашем случае они будут тривиальными, а потому можно быстро понять что они что они делают, без особого знания материала.
Прежде всего шейдера два — vertex и fragment.
Первый (vertex) будет обрабатывает наши вершины, а именно просто перемножать наши XYZ / UV координаты с соответствующими им матрицами и заполнять OpenGL переменную gl_Position которая как раз отвечает за финальное положение нашей текстуры на экране.
Второй (fragment) должен заполнить gl_FragColor пикселями изображения.
Итого имеем: переменные внутри vertex шейдера мы должны заполнить поля нашими данными, а именно:
- MVPMatrix ->uMVPMatrix
- TexMatrix -> uTexMatrix
- наши XYZ координаты вершины ->aPosition
- UV координаты ->aTextureCoord
vTextureCoord — нужна для проброса данных из vertex шейдера в fragment шейдер
В fragment шейдере мы берем преобразованные UV координаты и используем их для отображения пикселей изображения в области текстуры.
Ради справки укажем чем отличаются типы:
- uniform — переменная такого типа будет сохранять значения при многократном вызове, мы используем один шейдер которые вызывается последовательно для двух текстур, так что все равно будем перезаписывать при каждой отрисовки
- attribute — данные такого типа читаются из вершинного буфера, их нужно загружать при каждой отрисовки
- varying — нужны для передачи данных из vertex шейдера в fragment
Как передать параметры в шейдер? Для этого вначале нужно получить id (указатель) переменной:
Теперь по этому id нужно загрузить данные:
Непосредственно отрисовка
После того как мы заполнили наши шейдеры всеми данными мы должны попросить текстуру обновить изображение, а OpenGL отрисовать наши вершины:
В нашем примере мы разобьем работу с OpenGL сценой на два классы — непосредственно сцены и текстуры:
StateMachine / Машина состояний / Конечный автомат
Все API которое мы предполагаем использовать в нашем примере принципиально асинхронное (ну может за исключением анимированного Drawable-а). Мы будем заворачивать такие вызовы в отдельные StateMachine-ы — подходе когда явно выписывают состояния системы, а переходы между ними происходят через отправку событий.
Давайте на простом примере посмотрим как это будет выглядеть, предположим у нас есть такое код:
В целом все хорошо — красиво и компактно, но мы попробуем переписать его в следующим образом:
С одной стороны получилось сильно больше, тем не менее появилось несколько неявных, но полезных свойств: многократное нажатие теперь не приводит к лишним запускам loadImage, хотя и не очевидно с таким объемом, но мы избавились от вложенного вызова колбеков, чем и будем в последствии пользоваться, а еще стиль написания метода transition позволяет построить диаграмму переходов которая один в один повторяет код т.е в нашем случаи:
Серым указаны переходы, которые не выписаны явно. Часто их логируют или кидают исключение, считая признаком ошибки. Мы пока обойдемся простым игнорированием и в дальнейшем не будем указывать на схемах.
Создадим базовый интерфейсы для StateMachine:
GLSurfaceView
Самый простой способ вывести что-то на экран используя OpenGL в android это класс GLSurfaceView — он автоматически создает новые поток для рисования, запуск/пауза которого происходит по методам GLSurfaceView::onResume/onPause.
Для простоты мы будем задавать нашей вьюхе соотношение 16:9
Сам процесс отрисовки вынесен в отдельный колбек — GLSurfaceView.Renderer.
Завернув его в StateMachine-у мы получим что-то вроде этого:
Давайте нарисуем диаграмму переходов:
Теперь наш код пытается что-то выводить на экран, правда пока у него это получается плохо — ни чего кроме черного экрана мы не увидим. Как не сложно догадаться дело в том, что в наши Surface-ы сейчас ни чего не попадает т.к мы пока не реализовали источники изображений. Давайте это исправим — первым делом создадим CanvasDrawable:
Теперь секцию в GLSurfaceMachine мы можем дополнить отрисовкой canvasDrawable на canvas-е которые предоставляет surface у соответствующей текстуры:
После чего увидим что-то наподобие:
Camera API V2
Зеленый прямоугольник это конечно весело и интригующе, но пора попробовать вывести preview с камеры на оставшейся surface.
Давайте выпишем этапы работы с камерой:
- Ожидаем получение permission-а. У нас это будет состояние WaitingStart
- Получаем инстанс camera manager-а, находим логический id (обычно их два — для back и front, а логический он потому что на современных девайсах камера может состоять из множества датчиков) нужной камеры, выбираем подходящий размер, открываем камеру, получаем cameraDevice. Состояние WaitingOpen
- Имея открытую камеру мы обратимся в обратимся за получением Surface-а для вывода изображения. Состояние WaitingSurface
- Теперь имея cameraDevice, Surface мы должны открыть сессию чтобы камера наконец начала передавать данные. Состояние WaitingSession
- Теперь мы можем захватить preview. Состояние StartingPreview
Проиллюстрируем нашу текущую схему:
MediaCodec
MediaCodec класс для низкоуровневой работы с системными кодеками, в общем виде его API это набор input/output буферов (звучит, к сожалению, проще чем работать с ним) в которые помещаются данные (сырые или закодированные зависит от режима работы encoder/decoder), а на выходе мы получаем результат.
Несмотря на то, что к качестве буферов обычно выступают ByteBuffer, для работы с видео можно использовать Surface который вернет нам MediaCodec::createInputSurface, на нем мы должны отрисовывать кадры, которое хотим записать (при таком подходе документация обещает нам ускорение кодирования за счет использования gpu).
Хорошо, теперь мы должны научиться отрисовывать уже существующие Surface-ы которое мы создали в GLSurfaceMachine на Surface от MediaCodec-а. При этом важно помнить: Surface это объект который создает consumer-ом и прочитать что-то из него в общем случаи нельзя т.е нет условного метода getBitmap/readImage/…
Мы поступим следующим образом: на основе существующего GL контекста мы создадим новый который будем иметь общую с ним память, а потому мы сможем использовать переиспользовать там id-шники текстур которые мы создали ранее. Затем используя новый GL контекст и Surface от MediaCodec-а, мы создадим EGLSurface — внеэкранный буфер на котором мы так же сможем создать наш класс OpenGLScene. Затем при каждой отрисовке кадра мы попробуем параллельно записывать кадр на файл.
EGL означает интерфейс взаимодействия OpenGL API с оконной подсистемой платформы, работу с ним мы украдем из grafika. Конвейер (EncoderHelper) с MediaCodec-ом напрямую описывать тоже не буду, приведу лишь итоговую схему взаимодействия наших компонентов:
Источник