Android6.0でもカメラを使いたい 2
はじめに
続きです。以前から課題となっていた、画面を回転させた時にTextureViewのサイズが変わってしまうことについてです。
デバイスの回転
以前作成したときは、画面が回転したことを検出するためにAndroidManifest.xmlでActivityに「android:configChanges="orientation|screenSize"」を追加して、「onConfigurationChanged」からTextureViewのサイズ変更等を行っていました。
しかし、これには2つ問題がありました。
- 画面を回転させた時に画面が再描画されないため、Portraitモード / LandscapeモードでLayoutを変更することができない
- デバイスを180度回転させた場合に、「onConfigurationChanged」が呼ばれない
特に2つ目が問題で、PortraitモードからLandscapeモード、またはその逆に切り替わった場合にしか「onConfigurationChanged」が呼ばれないため、TextureViewが上下ひっくり返った状態になってしまいます。
ぐぬぬ…。onConfigurationChangedで 0° -> 90°の回転は検出できるのに、90° -> 270°の回転が検出できない(´ . .̫ . `)
— MasuiMasanori(山寨版) (@masanori_msl) 2016年5月15日
他に何か設定が必要なのか…? #android
そのため、AndroidManifest.xmlの「android:configChanges~」を削除して、DisplayManager.DisplayListenerを使うことにしました。
これで、デバイスを90度回転させた場合も、180度回転させた場合もそれぞれ「onDisplayChanged」が呼ばれるため、それぞれの向きに合わせてサイズ調整を行うことができます。
Camera2Activity.java
~省略~ private void openCamera(int width, int height) { // 画面回転を検出. displayListener = new DisplayManager.DisplayListener() { @Override public void onDisplayAdded(int displayId) { } @Override public void onDisplayChanged(int displayId) { // Displayサイズの取得. Point displaySize = new Point(); getWindowManager().getDefaultDisplay().getSize(displaySize); configureTransform(displaySize.x, displaySize.y); int intNewRotationNum = getWindowManager().getDefaultDisplay().getRotation(); // 端末を180度回転させると2回目のconfigureTransformが呼ばれないのでここで実行. if(Math.abs(intNewRotationNum - intLastRotationNum) == 2){ configureTransform(previewSize.getWidth(), previewSize.getHeight()); } intLastRotationNum = intNewRotationNum; } @Override public void onDisplayRemoved(int displayId) { } }; displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); displayManager.registerDisplayListener(displayListener, backgroundHandler); ~省略~
Matrix
さて、いよいよ最大の問題であった画面回転時のTextureViewのサイズ調整です。
Camera2Activity.java
~省略~ private void configureTransform(int viewWidth, int viewHeight){ // 画面の回転に合わせてTextureViewの向き、サイズを変更する. if (previewTextureView == null || previewSize == null){ return; } runOnUiThread( () ->{ // 1回目は画面サイズ、2回目はTextureViewのサイズが渡される. RectF rctView = new RectF(0, 0, viewWidth, viewHeight); RectF rctPreview = new RectF(0, 0, previewSize.getHeight(), previewSize.getWidth()); float centerX = rctView.centerX(); float centerY = rctView.centerY(); Matrix matrix = new Matrix(); int deviceRotation = getWindowManager().getDefaultDisplay().getRotation(); if(deviceRotation == Surface.ROTATION_90 || deviceRotation == Surface.ROTATION_270){ rctPreview.offset(centerX - rctPreview.centerX(), centerY - rctPreview.centerY()); matrix.setRectToRect(rctView, rctPreview, Matrix.ScaleToFit.FILL); // 縦または横の画面一杯に表示するためのScale値を取得. float scale = Math.max( (float) viewHeight / previewSize.getHeight() , (float) viewWidth / previewSize.getWidth() ); matrix.postScale(scale, scale, centerX, centerY); // ROTATION_90: 270度回転、ROTATION_270: 90度回転. matrix.postRotate((90 * (deviceRotation + 2)) % 360, centerX, centerY); } else{ // ROTATION_0: 0度回転、ROTATION_180: 180度回転. matrix.postRotate(90 * deviceRotation, centerX, centerY); } previewTextureView.setTransform(matrix); } ); } ~省略~
このコードもCamera2Basicのものをほぼすべて踏襲しています。
ここで難関だったのは、Matrixを使ってViewを回転させるだけでなく画面の回転によってもViewのサイズが変わってしまうことでした。
更に、実はこの「configureTransform」は1回画面を回転させるごとに2回呼ばれており、以下のような流れとなっていました。
- 1回目は引数に画面のサイズが渡され、TextureViewは画面回転前より縮小される。
- 1回目のTextureViewの縮小により、TextureView.SurfaceTextureListenerの「onSurfaceTextureSizeChanged」が呼ばれる。
- 「onSurfaceTextureSizeChanged」の引数で渡される「width」「height」を使って2回目の「configureTransform」呼び出し。
Step3の2回目の「configureTransform」によって、画面が回転してもTextureViewを同じ大きさに保つことができます。
なお、TextureViewのサイズを決定しているのは「matrix.setRectToRect」で、「matrix.postScale」は処理を外してもほぼ大きさが変わらず、TextureViewのサイズを縦または横の画面一杯に表示するために実行しているのかな?とは思っているものの、今ひとつ必要性はわかっていません。
ソースコード
最後にこれまでのコードを載せておきます。
Camera2Activity.java
import android.Manifest; import android.annotation.TargetApi; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.ImageFormat; import android.graphics.Matrix; import android.graphics.Point; import android.graphics.RectF; import android.graphics.SurfaceTexture; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCaptureSession; import android.hardware.camera2.CameraCharacteristics; import android.hardware.camera2.CameraDevice; import android.hardware.camera2.CameraManager; import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.params.StreamConfigurationMap; import android.hardware.display.DisplayManager; import android.media.Image; import android.media.ImageReader; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.os.Handler; import android.os.HandlerThread; import android.support.annotation.NonNull; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.util.Size; import android.util.SparseIntArray; import android.view.Surface; import android.view.TextureView; import android.view.View; import android.view.WindowManager; import android.widget.Button; import android.widget.Toast; import com.annimon.stream.Optional; import com.annimon.stream.Stream; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; import java.util.Arrays; @TargetApi(Build.VERSION_CODES.LOLLIPOP) public class Camera2Activity extends AppCompatActivity { private final static int REQUEST_PERMISSION_CAMERA = 1; private final static int REQUEST_PERMISSION_STORAGE = 2; private final static int TEXTURE_MAX_WIDTH = 1920; private final static int TEXTURE_MAX_HEIGHT = 1080; private Size previewSize; private PreviewTextureView previewTextureView; private CameraDevice cameraDevice; private CaptureRequest.Builder previewBuilder; private CameraCaptureSession previewSession; private int intSensorOrientation; private ImageReader imgReader; private DisplayManager displayManager; private DisplayManager.DisplayListener displayListener; private HandlerThread backgroundThread; private Handler backgroundHandler; private boolean isFlashlightSupported; // 画像保存時Storageの権限を要求した場合に、BackgroundThread再開後に画像保存処理を行うためのフラグ. private boolean isPictureTaken = false; private Image capturedImage; // 端末を180度回転させた場合に、2回目のconfigureTransformを呼ぶのに使う. private int intLastRotationNum = -1; private static final SparseIntArray ORIENTATIONS = new SparseIntArray(); static { ORIENTATIONS.append(Surface.ROTATION_0, 90); ORIENTATIONS.append(Surface.ROTATION_90, 0); ORIENTATIONS.append(Surface.ROTATION_180, 270); ORIENTATIONS.append(Surface.ROTATION_270, 180); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // フルスクリーン表示. getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); setContentView(R.layout.activity_camera2); initCameraView(); } @Override public void onResume(){ super.onResume(); startBackgroundThread(); if(displayManager != null && displayListener != null){ displayManager.registerDisplayListener(displayListener, backgroundHandler); } // Storageの権限要求後は画像の保存処理を行う. if(isPictureTaken){ prepareSavingImage(); isPictureTaken = false; } } @Override public void onPause(){ if(previewSession != null) { previewSession.close(); previewSession = null; } if(cameraDevice != null){ cameraDevice.close(); cameraDevice = null; } if(displayManager != null && displayListener != null){ displayManager.unregisterDisplayListener(displayListener); } stopBackgroundThread(); super.onPause(); } @Override public void onDestroy(){ if(imgReader != null){ imgReader.close(); imgReader = null; } super.onDestroy(); getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } @Override public void onRequestPermissionsResult(int intRequestCode , @NonNull String[] strPermissions , @NonNull int[] intGrantResults) { super.onRequestPermissionsResult(intRequestCode, strPermissions, intGrantResults); if(intGrantResults.length <= 0){ return; } switch (intRequestCode){ case REQUEST_PERMISSION_CAMERA: if (intGrantResults[0] == PackageManager.PERMISSION_GRANTED) { runOnUiThread( () ->{ // 権限が許可されたらプレビュー画面の使用準備. openCamera(previewTextureView.getWidth(), previewTextureView.getHeight()); } ); } else{ // 権限付与を拒否されたらMainActivityに戻る. finish(); } break; case REQUEST_PERMISSION_STORAGE: if (intGrantResults[0] == PackageManager.PERMISSION_GRANTED) { // Storageへのアクセスが許可されたら、OnResumeで画像の保存処理実行. isPictureTaken = true; } else{ // 権限付与を拒否されたらMainActivityに戻る. finish(); } break; } } @TargetApi(Build.VERSION_CODES.M) private void requestCameraPermission(){ if(checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){ // 権限が許可されていたらプレビュー画面の使用準備. openCamera(previewTextureView.getWidth(), previewTextureView.getHeight()); } else{ requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_PERMISSION_CAMERA); } } @TargetApi(Build.VERSION_CODES.M) private void requestStoragePermission(){ if(checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED && checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){ // 権限付与済みであれば画像を保存する. prepareSavingImage(); } else{ requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE} , REQUEST_PERMISSION_STORAGE); } } private void initCameraView(){ // プレビュー用のViewを追加. previewTextureView = (PreviewTextureView) findViewById(R.id.texture_preview_camera2); previewTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { @Override public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { // Textureが有効化されたらプレビューを表示. // OS6.0以上ならCameraへのアクセス権確認. if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){ requestCameraPermission(); } else{ openCamera(width, height); } } @Override public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { configureTransform(width, height); } @Override public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { return true; } @Override public void onSurfaceTextureUpdated(SurfaceTexture surface) { } }); Button btnTakingPhoto = (Button) findViewById(R.id.btn_taking_photo_camera2); if(btnTakingPhoto != null){ btnTakingPhoto.setOnClickListener( (View v) ->{ takePicture(); } ); } } private void openCamera(int width, int height) { // 画面回転を検出. displayListener = new DisplayManager.DisplayListener() { @Override public void onDisplayAdded(int displayId) { } @Override public void onDisplayChanged(int displayId) { // Displayサイズの取得. Point displaySize = new Point(); getWindowManager().getDefaultDisplay().getSize(displaySize); configureTransform(displaySize.x, displaySize.y); int intNewRotationNum = getWindowManager().getDefaultDisplay().getRotation(); // 端末を180度回転させると2回目のconfigureTransformが呼ばれないのでここで実行. if(Math.abs(intNewRotationNum - intLastRotationNum) == 2){ configureTransform(previewSize.getWidth(), previewSize.getHeight()); } intLastRotationNum = intNewRotationNum; } @Override public void onDisplayRemoved(int displayId) { } }; displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); displayManager.registerDisplayListener(displayListener, backgroundHandler); // Camera機能にアクセスするためのCameraManagerの取得. CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); try { // Back Cameraを取得してOpen. for (String strCameraId : manager.getCameraIdList()) { // Cameraから情報を取得するためのCharacteristics. CameraCharacteristics characteristics = manager.getCameraCharacteristics(strCameraId); Integer facing = characteristics.get(CameraCharacteristics.LENS_FACING); if (facing == null || facing == CameraCharacteristics.LENS_FACING_FRONT) { // Front Cameraならスキップ. continue; } Integer cameraOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); intSensorOrientation = (cameraOrientation != null)? cameraOrientation: 0; // 端末がFlashlightに対応しているか確認. Boolean isAvailable = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE); isFlashlightSupported = (isAvailable != null && isAvailable); // ストリームの設定を取得(出力サイズを取得する). StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); if(map == null) { continue; } Size[] sizes = map.getOutputSizes(ImageFormat.JPEG); // 配列から最大の組み合わせを取得する. Size maxImageSize = new Size(width, height); OptionalmaxSize = Stream.of(sizes) .max((a, b) -> Integer.compare(a.getWidth(), b.getWidth())); if(maxSize != null){ maxImageSize = maxSize.get(); } // 画像を取得するためのImageReaderの作成. imgReader = ImageReader.newInstance(maxImageSize.getWidth(), maxImageSize.getHeight(), ImageFormat.JPEG, 2); imgReader.setOnImageAvailableListener( (ImageReader reader)-> { capturedImage = reader.acquireLatestImage(); // OS6.0以上ならStorageへのアクセス権を確認. if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { requestStoragePermission(); } else{ prepareSavingImage(); } } , backgroundHandler); int displayOrientation = getResources().getConfiguration().orientation; switch(displayOrientation){ case Configuration.ORIENTATION_LANDSCAPE: previewTextureView.setAspectRatio(maxImageSize.getWidth(), maxImageSize.getHeight()); break; case Configuration.ORIENTATION_PORTRAIT: previewTextureView.setAspectRatio(maxImageSize.getHeight(), maxImageSize.getWidth()); break; } // 取得したSizeのうち、画面のアスペクト比に合致していてTEXTURE_MAX_WIDTH・TEXTURE_MAX_HEIGHT以下の最大値をセット. previewSize = new Size(640, 480); final float aspectRatio = ((float)maxImageSize.getHeight() / (float)maxImageSize.getWidth()); Optional setSize = Stream.of(sizes) .filter(size -> size.getWidth() <= TEXTURE_MAX_WIDTH && size.getHeight() <= TEXTURE_MAX_HEIGHT && size.getHeight() == (size.getWidth() * aspectRatio)) .max((a, b) -> Integer.compare(a.getWidth(), b.getWidth())); if(setSize != null){ previewSize = setSize.get(); } try { manager.openCamera(strCameraId, new CameraDevice.StateCallback() { @Override public void onOpened(@NonNull CameraDevice camera) { if(cameraDevice == null){ cameraDevice = camera; } runOnUiThread( () -> { // カメラ画面表示中はScreenをOffにしない. getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); }); createCameraPreviewSession(); } @Override public void onDisconnected(@NonNull CameraDevice cmdCamera) { cmdCamera.close(); cameraDevice = null; } @Override public void onError(@NonNull CameraDevice cmdCamera, int error) { cmdCamera.close(); cameraDevice = null; } }, backgroundHandler); }catch (SecurityException s){ } } } catch (CameraAccessException e) { } } private void prepareSavingImage(){ backgroundHandler.post( ()->{ try { try { ByteBuffer buffer = capturedImage.getPlanes()[0].getBuffer(); byte[] bytes = new byte[buffer.capacity()]; buffer.get(bytes); saveImage(bytes); }catch (FileNotFoundException f) { } } catch (IOException e) { } finally { if (capturedImage != null) { capturedImage.close(); } } } ); } private void saveImage(byte[] bytes) throws IOException { OutputStream output = null; try { // ファイルの保存先のディレクトリとファイル名. String strSaveDir = Environment.getExternalStorageDirectory().toString() + "/DCIM"; String strSaveFileName = "pic_" + System.currentTimeMillis() +".jpg"; final File file = new File(strSaveDir, strSaveFileName); // 生成した画像を出力する. output = new FileOutputStream(file); output.write(bytes); // 保存した画像を反映させる. MediaScannerConnection.scanFile( getApplicationContext() , new String[]{strSaveDir + "/" + strSaveFileName} , new String[]{"image/jpeg"} , (String path, Uri uri) ->{ runOnUiThread( ()->{ Toast.makeText(getApplicationContext(), "Saved: " + path, Toast.LENGTH_SHORT).show(); // もう一度カメラのプレビュー表示を開始する. if(cameraDevice == null){ // 権限確認でPause状態から復帰したらCameraDeviceの取得も行う. openCamera(previewTextureView.getWidth(), previewTextureView.getHeight()); } else{ createCameraPreviewSession(); } } ); }); } finally { if (output != null) { output.close(); } } } private void createCameraPreviewSession(){ if(cameraDevice == null || ! previewTextureView.isAvailable() || previewSize == null) { return; } SurfaceTexture texture = previewTextureView.getSurfaceTexture(); if(texture == null) { return; } texture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); Surface surface = new Surface(texture); try { // プレビューウインドウのリクエスト. previewBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); } catch (CameraAccessException e) { } previewBuilder.addTarget(surface); try { cameraDevice.createCaptureSession(Arrays.asList(surface, imgReader.getSurface()) , new CameraCaptureSession.StateCallback() { @Override public void onConfigured(@NonNull CameraCaptureSession session) { previewSession = session; if(cameraDevice == null) { return; } setCameraMode(previewBuilder); try { previewSession.setRepeatingRequest(previewBuilder.build(), null, backgroundHandler); } catch (CameraAccessException e) { } } @Override public void onConfigureFailed(@NonNull CameraCaptureSession session) { } }, null); } catch (CameraAccessException e) { } } private void takePicture() { if(cameraDevice == null || previewSession == null) { return; } try { final CaptureRequest.Builder captureBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); captureBuilder.addTarget(imgReader.getSurface()); setCameraMode(captureBuilder); // 画像の回転を調整する. captureBuilder.set(CaptureRequest.JPEG_ORIENTATION , (ORIENTATIONS.get(getWindowManager().getDefaultDisplay().getRotation()) + intSensorOrientation + 270) % 360); // プレビュー画面の更新を一旦ストップ. previewSession.stopRepeating(); // 画像の保存. previewSession.capture(captureBuilder.build(), null, null); } catch (CameraAccessException e) { } } private void configureTransform(int viewWidth, int viewHeight){ // 画面の回転に合わせてTextureViewの向き、サイズを変更する. if (previewTextureView == null || previewSize == null){ return; } runOnUiThread( () ->{ RectF rctView = new RectF(0, 0, viewWidth, viewHeight); RectF rctPreview = new RectF(0, 0, previewSize.getHeight(), previewSize.getWidth()); float centerX = rctView.centerX(); float centerY = rctView.centerY(); Matrix matrix = new Matrix(); int deviceRotation = getWindowManager().getDefaultDisplay().getRotation(); if(deviceRotation == Surface.ROTATION_90 || deviceRotation == Surface.ROTATION_270){ rctPreview.offset(centerX - rctPreview.centerX(), centerY - rctPreview.centerY()); matrix.setRectToRect(rctView, rctPreview, Matrix.ScaleToFit.FILL); // 縦または横の画面一杯に表示するためのScale値を取得. float scale = Math.max( (float) viewHeight / previewSize.getHeight() , (float) viewWidth / previewSize.getWidth() ); matrix.postScale(scale, scale, centerX, centerY); // ROTATION_90: 270度回転、ROTATION_270: 90度回転. matrix.postRotate((90 * (deviceRotation + 2)) % 360, centerX, centerY); } else{ // ROTATION_0: 0度回転、ROTATION_180: 180度回転. matrix.postRotate(90 * deviceRotation, centerX, centerY); } previewTextureView.setTransform(matrix); } ); } private void setCameraMode(CaptureRequest.Builder requestBuilder){ // AutoFocus requestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE); // モノクロで表示. requestBuilder.set(CaptureRequest.CONTROL_EFFECT_MODE, CaptureRequest.CONTROL_EFFECT_MODE_MONO); // 端末がFlashlightに対応していたら自動で使用されるように設定. if(isFlashlightSupported){ requestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH); } } private void startBackgroundThread() { backgroundThread = new HandlerThread("CameraPreview"); backgroundThread.start(); backgroundHandler = new Handler(backgroundThread.getLooper()); } private void stopBackgroundThread(){ backgroundThread.quitSafely(); try{ backgroundThread.join(); backgroundThread = null; backgroundHandler = null; }catch (InterruptedException e){ } } }
- 行数を減らすため、catch句のe.printStackTrace()は削除しています。処理を適宜追加してください。
問題点
とりあえず一通り動くようにはなったものの、今把握しているだけでも以下の問題があります。
- 自動でFlashlightを使う設定にしているつもりなのに動作しない。
- ホーム画面や他のアプリを開いた後戻ってきた場合は問題なくカメラのプレビューが再開されるが、SMSなど通知から返信などを行った後に戻るとプレビューの映像が止まってしまう。
また、非同期処理ということで、RxJAVA、RxAndroidも使ってみたいな、と企んでいますので、次回はこの辺りの話になる予定です。