Привет, %username%! Сегодня я хочу поделиться опытом разработки одного приложения для Android и трудностями, с которыми пришлось столкнуться при не совсем честном использовании камеры.
Идея приложения «Страж» жила внутри отдела разработки достаточно давно, но первая реализация появилась на платформе 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.
Спасибо за внимание!
Автор: bejibx