Привет, %username%! Сегодня я хочу поделиться опытом разработки одного приложения для Android и трудностями, с которыми пришлось столкнуться при не совсем честном использовании камеры.
Идея приложения «Страж» жила внутри отдела разработки достаточно давно, но первая реализация появилась на платформе Symbian 2 года назад. Сама идея незамысловата – делать фотографии человека, взявшего телефон в руки. В первой реализации приложение было разделено на сигнальные модули и модули обратных вызовов. Сигнальные модули отвечали за регистрацию изменений определённого состояния телефона. Например: извлечение или установка SIM-карты или карты памяти, входящий или исходящий звонок, или совсем хитрые – главным сенсором был сенсор акселерометра, который определял момент поднятия телефона со стола. Модули обратных вызовов – это действия, которые выполняются по сигналам сенсоров. Были реализованы фотография и запись звука.
При портировании приложения на платформу Android подход заметно поменялся. Да и вообще от старого приложения осталась только идея, оно перестало быть модульным, а из всего функционала остался только функционал фотографирования. О реализации этого функционала и хочется рассказать.
Сначала приведу вольный перевод официальной документации, касающейся вопроса пользования камерой.
Чтобы получить картинку нужно:
Данный класс не потокобезопасный. Большинство операций (превью, фокусировка, получение фото) асинхронны и возвращают результат через коллбэки, которые будут вызваны в том же потоке, в котором был вызван метод open. Методы данного класса ни в коем случае не должны вызываться сразу из нескольких потоков.
Предупреждение: Разные устройства на ОС Android могут иметь разные возможности камеры (например, разрешение, возможность автофокусировки и т.п.).
Здесь перевод заканчивается и начинается самое интересное.
Из всего вышеперечисленного в глаза бросаются следующие проблемы:
С ними-то мы и будем бороться.
Когда возникает проблема из разряда «в доках написано, что так сделать нельзя», перво-наперво нужно заглянуть в исходники. Из них стало понятно, что прорисовка превью вынесена на уровень нативного кода setPreviewDisplay(Surface). Была принята попытка быстро разобраться в том, как вообще система определяет, стартовали мы превью или нет. Быстро пробраться через тернии C++ кода не получилось, поэтому я пошёл по пути наименьшего сопротивления — создал превью, но отобразил его незаметно для пользователя. Если поискать на stackoverflow, то можно найти другой способ – передавать в setPreviewDisplay SurfaceHolder, созданный динамически. А раз объект не добавлен в разметку Activity, то и отображаться он не будет. К сожалению, данный метод работает только для старых версий Android (до 3.0, если не ошибаюсь). В новых версиях разработчики исправили данное недоразумение.
Таким образом, приходим к единственному выводу – мы должны так или иначе отобразить превью на экране, вопрос теперь только в том, можно ли сделать это незаметно? К счастью, ответ – «да, можно». И вот что для этого нужно:
Прозрачное Activity делается одной строчкой манифеста, для этого определим её так:
и создадим для нее следующую несложную разметку:
Объект SurfaceHolder создается и добавляется в разметку динамически. В принципе можно было добавить его сразу в разметку, данный момент был вынесен в код, чтобы не лезть в разметку при необходимости переопределить поведение объекта.
Итак, прозрачное Activity есть, SurfaceHolder создаем динамически, что дальше? Дальше дело за главным – инициализировать камеру и сделать фото. Идея здесь в том, чтобы сделать фото сразу на старте Activity и закрыть её как можно быстрее. Определим нашу Activity так:
Таким образом, в неё будут сыпаться события от SurfaceHolder’а (surfaceCreated, surfaceChanged, surfaceDestroyed) и Camera (onPictureTaken). Внутренний класс CameraPreview нужен исключительно для того, чтобы, как я отмечал выше, быстро и безболезненно внести правки в поведение нашего SurfaceView в случае необходимости. Далее приведу скопом методы Activity
Что интересного в данном коде? Распишу по пунктам.
Итак, настало время немного обобщить происходящее. Вот что происходит в приложении:
Таким образом, общий порядок вызовов получается следующий:
Приложение работает и стабильно делает фотки на моём телефоне (Nexus 4). Кроме него тестировал и на других моделях, в том числе Motorola Droid RAZR и HTС Sensation. Как я уже упоминал выше – на разных телефонах камеры работают по-разному. На некоторых телефонах, когда делается фото, слышен звук затвора. На других – фотография повернута не в ту сторону и исправляется это только редактированием EXIF’а. На некоторых телефонах и вовсе (я полагаю, из-за особенностей оболочки) порядок вызова методов жизненного цикла Activity может заметно отличаться. Связано всё это не только с огромным количеством производителей устройств на Android’е, но и с невероятной фрагментацией самой ОС (интересную заметку по этому поводу можно найти на 57 странице 1 номера журнала «Хакер» за 2014 год). Поэтому очень сильно хотелось бы:
Это все вопрос развития в недалеком будущем.
Сейчас же приложение доступно на Google.Play в текущей версии. Оно бесплатно, поскольку главной целью при его создании было исследование глубин Андроида. Для интересующихся ссылка на google.play.
Спасибо за внимание!
Идея приложения «Страж» жила внутри отдела разработки достаточно давно, но первая реализация появилась на платформе Symbian 2 года назад. Сама идея незамысловата – делать фотографии человека, взявшего телефон в руки. В первой реализации приложение было разделено на сигнальные модули и модули обратных вызовов. Сигнальные модули отвечали за регистрацию изменений определённого состояния телефона. Например: извлечение или установка SIM-карты или карты памяти, входящий или исходящий звонок, или совсем хитрые – главным сенсором был сенсор акселерометра, который определял момент поднятия телефона со стола. Модули обратных вызовов – это действия, которые выполняются по сигналам сенсоров. Были реализованы фотография и запись звука.
При портировании приложения на платформу Android подход заметно поменялся. Да и вообще от старого приложения осталась только идея, оно перестало быть модульным, а из всего функционала остался только функционал фотографирования. О реализации этого функционала и хочется рассказать.
Делаем фотографию
Сначала приведу вольный перевод официальной документации, касающейся вопроса пользования камерой.
- За фотографии в Android отвечает класс Camera.
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
Чтобы получить картинку нужно:
- Найти Id нужной камеры, используя методы getNumberOfCameras и getCameraInfo );
- Получить ссылку на объект камеры методом open .
- Получить текущие (по-умолчанию) настройки методом getParameters .
- При необходимости изменить параметры и установить их заново методом setParameters ;
- При необходимости установить ориентацию камеры методом setDisplayOrientation (НЕТ вертикальному видео!);
- ВАЖНО: Передать в метод setPreviewDisplay правильно инициализированный объект SurfaceHolder. Если этого не сделать, то камера не сможет начать превью.
- ВАЖНО: Вызвать метод startPreview ), который начнет обновлять SurfaceHolder. Вы ОБЯЗАНЫ начать превью перед тем как сделать снимок.
- Наконец-то вызвать метод takePicture и дождаться когда данные вернуться в onPictureTaken ;
- После вызова метода takePicture превью будет остановлено. Если нужно сделать еще фото, то придется вызвать startPreview снова;
- Если же камера больше не нужна, то сначала нужно остановить превью методом stopPreview;
- ВАЖНО: Вызвать метод release() чтобы освободить ресурсы камеры для других приложений. Приложение должно немедленно освобождать ресурсы камеры в методе onPause (и получать их обратно в методе onResume ).
Данный класс не потокобезопасный. Большинство операций (превью, фокусировка, получение фото) асинхронны и возвращают результат через коллбэки, которые будут вызваны в том же потоке, в котором был вызван метод open. Методы данного класса ни в коем случае не должны вызываться сразу из нескольких потоков.
Предупреждение: Разные устройства на ОС Android могут иметь разные возможности камеры (например, разрешение, возможность автофокусировки и т.п.).
Здесь перевод заканчивается и начинается самое интересное.
Из всего вышеперечисленного в глаза бросаются следующие проблемы:
- Надо показывать превью.
- На разных устройствах камера может работать по-разному.
С ними-то мы и будем бороться.
Когда возникает проблема из разряда «в доках написано, что так сделать нельзя», перво-наперво нужно заглянуть в исходники. Из них стало понятно, что прорисовка превью вынесена на уровень нативного кода setPreviewDisplay(Surface). Была принята попытка быстро разобраться в том, как вообще система определяет, стартовали мы превью или нет. Быстро пробраться через тернии C++ кода не получилось, поэтому я пошёл по пути наименьшего сопротивления — создал превью, но отобразил его незаметно для пользователя. Если поискать на stackoverflow, то можно найти другой способ – передавать в setPreviewDisplay SurfaceHolder, созданный динамически. А раз объект не добавлен в разметку Activity, то и отображаться он не будет. К сожалению, данный метод работает только для старых версий Android (до 3.0, если не ошибаюсь). В новых версиях разработчики исправили данное недоразумение.
Таким образом, приходим к единственному выводу – мы должны так или иначе отобразить превью на экране, вопрос теперь только в том, можно ли сделать это незаметно? К счастью, ответ – «да, можно». И вот что для этого нужно:
- Прозрачная Activity.
- FrameLayout размером 1 на 1 пиксель в левом верхнем углу нашей Activity.
Прозрачное Activity делается одной строчкой манифеста, для этого определим её так:
<activity
android:name=".activities.CameraActivity"
android:exported="false"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
и создадим для нее следующую несложную разметку:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/surfaceHolder"
android:layout_width="1.0px"
android:layout_height="1.0px" />
Объект SurfaceHolder создается и добавляется в разметку динамически. В принципе можно было добавить его сразу в разметку, данный момент был вынесен в код, чтобы не лезть в разметку при необходимости переопределить поведение объекта.
Итак, прозрачное Activity есть, SurfaceHolder создаем динамически, что дальше? Дальше дело за главным – инициализировать камеру и сделать фото. Идея здесь в том, чтобы сделать фото сразу на старте Activity и закрыть её как можно быстрее. Определим нашу Activity так:
public class CameraActivity extends Activity implements Camera.PictureCallback, SurfaceHolder.Callback
{
private static final int NO_FRONT_CAMERA = -1;
private Camera mCamera;
private boolean mPreviewIsRunning = false;
private boolean mIsTakingPicture = false;
public class CameraPreview extends SurfaceView
{
public CameraPreview(Context context)
{
super(context);
}
}
...
Таким образом, в неё будут сыпаться события от SurfaceHolder’а (surfaceCreated, surfaceChanged, surfaceDestroyed) и Camera (onPictureTaken). Внутренний класс CameraPreview нужен исключительно для того, чтобы, как я отмечал выше, быстро и безболезненно внести правки в поведение нашего SurfaceView в случае необходимости. Далее приведу скопом методы Activity
Немного кода
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.surface_holder);
SurfaceView surfaceView = new CameraPreview(this);
((FrameLayout) findViewById(R.id.surfaceHolder)).addView(surfaceView);
SurfaceHolder holder = surfaceView.getHolder();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB)
holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
holder.addCallback(this);
}
@Override
protected void onResume()
{
startPreview();
super.onResume();
}
@Override
protected void onPause()
{
stopPreview();
super.onPause();
}
@Override
public void surfaceCreated(SurfaceHolder surfaceHolder)
{
final int cameraId = getFrontCameraId();
if (cameraId != NO_FRONT_CAMERA)
{
try
{
mCamera = Camera.open(cameraId);
Camera.Parameters parameters = mCamera.getParameters();
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT)
parameters.setRotation(270);
List<String> flashModes = parameters.getSupportedFlashModes();
if (flashModes != null && flashModes.contains(Camera.Parameters.FLASH_MODE_OFF))
parameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
List<String> whiteBalance = parameters.getSupportedWhiteBalance();
if (whiteBalance != null && whiteBalance.contains(Camera.Parameters.WHITE_BALANCE_AUTO))
parameters.setWhiteBalance(Camera.Parameters.WHITE_BALANCE_AUTO);
List<String> focusModes = parameters.getSupportedFocusModes();
if (focusModes != null && focusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO))
parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO);
List<Camera.Size> sizes = parameters.getSupportedPictureSizes();
if (sizes != null && sizes.size() > 0)
{
Camera.Size size = sizes.get(0);
parameters.setPictureSize(size.width, size.height);
}
List<Camera.Size> previewSizes = parameters.getSupportedPreviewSizes();
if (previewSizes != null)
{
Camera.Size previewSize = previewSizes.get(previewSizes.size() - 1);
parameters.setPreviewSize(previewSize.width, previewSize.height);
}
mCamera.setParameters(parameters);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1)
mCamera.enableShutterSound(false);
}
catch (RuntimeException e)
{
A.handleException(e, true);
finish();
return;
}
}
else
{
Log.e(Value.LOG_TAG, "Could not find front-facing camera");
finish();
return;
}
try
{
mCamera.setPreviewDisplay(surfaceHolder);
}
catch (IOException ioe)
{
A.handleException(ioe, true);
finish();
}
}
@Override
public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height)
{
startPreview();
}
@Override
public void surfaceDestroyed(SurfaceHolder surfaceHolder)
{
releaseCamera();
}
@Override
public void onPictureTaken(byte[] bytes, Camera camera)
{
mIsTakingPicture = false;
releaseCamera();
//noinspection PrimitiveArrayArgumentToVariableArgMethod
new SaveImageTask().execute(bytes);
finish();
}
private int getFrontCameraId()
{
final int numberOfCameras = Camera.getNumberOfCameras();
for (int i = 0; i < numberOfCameras; i++)
{
Camera.CameraInfo info = new Camera.CameraInfo();
Camera.getCameraInfo(i, info);
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) return i;
}
return NO_FRONT_CAMERA;
}
private void startPreview()
{
if (!mPreviewIsRunning && mCamera != null)
{
try
{
mCamera.startPreview();
mCamera.autoFocus(new Camera.AutoFocusCallback()
{
@Override
public void onAutoFocus(boolean b, Camera camera)
{
if (!mIsTakingPicture)
{
try
{
mIsTakingPicture = true;
mCamera.setPreviewCallback(null);
mCamera.takePicture(null, null, CameraActivity.this);
}
catch (RuntimeException e)
{
A.handleException(e, true);
finish();
}
}
}
});
mPreviewIsRunning = true;
}
catch (Exception e)
{
A.handleException(e, true);
finish();
}
}
}
private void stopPreview()
{
if (!mIsTakingPicture && mPreviewIsRunning && mCamera != null) {
mCamera.stopPreview();
mPreviewIsRunning = false;
}
}
private void releaseCamera()
{
if (mCamera != null)
{
mCamera.setPreviewCallback(null);
mCamera.stopPreview();
mCamera.release();
mCamera = null;
}
}
Что интересного в данном коде? Распишу по пунктам.
- Самое важное – порядок вызова методов. В документации говорится, что нужно вызвать и в каком порядке, но не указывается когда именно. Например, метод setPreviewDisplay. Если инициализировать камеру и вызвать этот метод сразу в onCreate или в onResume, то фото сделать не получится. Тогда откуда узнать, когда нужно вызывать этот метод? Правильный ответ – из комментариев к методу setPreviewDisplay в исходниках. Вот небольшая выдержка оттуда:
The android.view.SurfaceHolder must already contain a surface when this method is called. If you are usingandroid.view.SurfaceView, you will need to register a android.view.SurfaceHolder.Callback withandroid.view.SurfaceHolder.addCallback(android.view.SurfaceHolder.Callback) and wait forandroid.view.SurfaceHolder.Callback.surfaceCreated(android.view.SurfaceHolder) before calling setPreviewDisplay() or starting preview.
This method must be called before startPreview().
- Второй момент связан с жизненным циклом объекта SurfaceHolder относительно Activity. Жизненный цикл Activity можно найти в документации, а вот с SurfaceHolder’ом всё непонятно, поэтому пришлось выяснять это опытным путём:
onCreate(Bundle savedInstanceState) onResume() onPause() surfaceCreated(SurfaceHolder surfaceHolder) surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height) onStop() surfaceDestroyed(SurfaceHolder surfaceHolder)
- Следующий интересный момент связан с порядком вызовов методов жизненного цикла Activity. Вы можете спросить: «Зачем нужны все эти проверки в духе if (mCamera != null) и переменные mPreviewIsRunning, mIsTakingPicture?». К сожалению, единственный ответ, который я могу дать в данном случае – так надо. И дело тут в том, что в некоторых ситуациях порядок вызовов методов жизненного цикла Activity может отличаться от указанного в официальных доках (от вот этой диаграммы, например ). В основном казусы происходят, когда на телефоне включена блокировка экрана. У меня бывали случаи, когда метод onStop вызывался два раза подряд, а после этого, минуя onStart, как ни в чём не бывало, вызывался onResume. При этом порядок вызова методов может отличаться на разных аппаратах, даже не смотря на одну и ту же версию Android на борту. Я долго пытался в этом разобраться, понять, почему это происходит. В результате только потратил на это кучу времени и написал текущую реализацию.
Итак, настало время немного обобщить происходящее. Вот что происходит в приложении:
- Стартуем Activity на нужное событие (в моем случае — на включение экрана).
- В onCreate создаем SurfaceHolder и регистрируем Activity для получения коллбэков.
- Ждем вызова surfaceCreated и в нём инициализируем камеру.
- После того, как камера инициализирована, пытаемся вызвать takePicture. Поскольку порядок вызова методов сильно зависит от аппарата, версии ОС и типа блокировки экрана, пытаемся в методах onResume| surfaceChanged стартовать превью, а в onPause останавливать её. При этом onResume| onPause могут случиться как до, так и после surfaceCreated, поэтому везде проверяем камеру на «инициализированность».
- Метод surfaceChanged, согласно документации, гарантированно вызывается хотя бы раз после surfaceCreated, но теоретически может быть вызван еще сколько угодно раз в процессе получения фотографии. Добавляем переменную mPreviewIsRunning для того, чтобы ненароком не стартануть превью несколько раз. Стартуем превью, вызываем takePicture, ждём.
- Ловим фотографию в onPictureTaken. Освобождаем камеру, создаем AsyncTask для сохранения картинки, закрываем Activity.
Таким образом, общий порядок вызовов получается следующий:
onCreate(Bundle savedInstanceState) onResume() onPause() surfaceCreated(SurfaceHolder surfaceHolder) surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height) onPictureTaken(byte[] bytes, Camera camera) onStop() surfaceDestroyed(SurfaceHolder surfaceHolder)
Заключение
Приложение работает и стабильно делает фотки на моём телефоне (Nexus 4). Кроме него тестировал и на других моделях, в том числе Motorola Droid RAZR и HTС Sensation. Как я уже упоминал выше – на разных телефонах камеры работают по-разному. На некоторых телефонах, когда делается фото, слышен звук затвора. На других – фотография повернута не в ту сторону и исправляется это только редактированием EXIF’а. На некоторых телефонах и вовсе (я полагаю, из-за особенностей оболочки) порядок вызова методов жизненного цикла Activity может заметно отличаться. Связано всё это не только с огромным количеством производителей устройств на Android’е, но и с невероятной фрагментацией самой ОС (интересную заметку по этому поводу можно найти на 57 странице 1 номера журнала «Хакер» за 2014 год). Поэтому очень сильно хотелось бы:
- Добавить профили для разных моделей телефонов и делать фотографию с учетом этого профиля. Например, для телефонов, издающих звук затвора при фотографировании добавить мьют непосредственно перед фотографированием.
- Хорошенько погонять приложение на большом наборе тестовых моделек и попытаться понять причину различия в вызове методов Activity.
- Поглубже закопаться в исходники Android’а. Залезть, наконец, в нативную часть и разобраться, почему takePicture можно вызывать только после инициализации превью. Подумать, как еще можно с этим бороться.
Это все вопрос развития в недалеком будущем.
Сейчас же приложение доступно на Google.Play в текущей версии. Оно бесплатно, поскольку главной целью при его создании было исследование глубин Андроида. Для интересующихся ссылка на google.play.
Спасибо за внимание!