본문 바로가기
Project/멘토링(ColorBlind)

앱에 Camera(CameraX) 연동하기

by 김마리님 2022. 5. 13.

CameraX를 쓰는 이유는 간단합니다. 이게 제일 쉽기 때문입니다.

다른 카메라 쓰면 해당도도 일일히 다 프로그래밍 해줘야 하는데 이 라이브러리는(구글 제공) 해상도가 자동으로 설정되니까 굳이 해상도 관련해서 따로 로직을 짤 필요가 없기 때문입니다.

 

종속성을 추가하는 방법은 다음과 같습니다

좌측 프로젝트 폴더 부분에 보면 코끼리 모양의 Gradle Script 가 있는데, 여기 열어보시면 bulid.gradle이라는 파일이 두개가 있습니다. 이 중에서 꼭, 꼭 Module 표기 된 파일을 여셔야 합니다.

 

해당 파일은 앱을 빌드할때 쓰이는 모듈급(이 용어는 모르셔도 됩니다 아직.) 설정을 모아두는 곳인데, 하단에 보면 implement라는 덩어리들이 보일텝니다. 꼭, 반드시, implement 파일 내부에 넣어주세요. 꼭.

 

아마 expresso-core 밑으로 아무것도 없으실텐데, 해당 코드를 추가해주시면 됩니다.

    //cameraX
    // CameraX core library using the camera2 implementation
    def camerax_version = "1.0.0-rc02"
    // The following line is optional, as the core library is included indirectly by camera-camera2
    implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${camerax_version}"
    // If you want to additionally use the CameraX Lifecycle library
    implementation "androidx.camera:camera-lifecycle:${camerax_version}"
    // If you want to additionally use the CameraX View class
    implementation "androidx.camera:camera-view:1.1.0-beta03"
    // If you want to additionally use the CameraX Extensions library
    implementation "androidx.camera:camera-extensions:1.1.0-beta03"

 

혹시나 싶어서 확인해봅시다. AndroidManifest에 uses-permission에 카메라 있는지 없는지도요. 아마 퍼미션 추가 했으면 있으실테지만 혹시나요.

 

 

시작하기에 앞서 전역변수로 네 가지를 지정합니다.

    private File outputDirectory;
    private ImageCapture imageCapture;
    private Animation.AnimationListener cameraAnimationListener;
    private Uri savedUri;

위에서 각자 파일 위치, 이미지 캡쳐시 카메에 사진을 저장하는 메서드(이것은 카메라 라이브러리에서 옵니다), 애니메이션 시 사용될 매서드, 휴대폰에서 캡쳐될 때 이미지 경로에서 가져오는 파일 url(원래는 uri가 맞는 명칭입니다)을 저장할 매서드입니다.

 

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_camera);

        permissionCheck();
        initView();
        setListener();

        setCameraAnimationListener();
        outputDirectory = getOutputDirectory();

    }

뷰가 만들어질때 하는 일을 봅니다.

initView 와 setListener 을 제외하고 크게 세 가지 입니다.

 

permissionCheck 먼저 봅시다.

    /**
     * 이전 화면에서 카메라 권한을 거부했을수도 있으므로, 새로 퍼미션을 셀프로 확인하는 과정입니다.
     * if문을 통해 확인하고, 퍼미션이 정상으로 들어와있으면 카메라를 오픈합니다.
     * */
    private void permissionCheck() {
        //퍼미션 추가
        List<String> permissionList = new ArrayList<>();
        permissionList.add(Constant.manifest_permission_camera);
        permissionList.add(Constant.manifest_permission_read_external_storage);
        permissionList.add(Constant.manifest_permission_write_external_storage);

        if (!PermissionUtil.checkPermission(this, permissionList)) {
            PermissionUtil.requestPermission(this, permissionList);
        } else {
            openCamera();
        }
    }

해당 카메라의 로직은 다음과 같습니다.

카메라로 미리보기 화면을 띄우고(렌즈로 보는 화면을 말합니다.) -> 촬영버튼 클릭 시, 해당되는 디렉터리에 사진 파일을 기록합니다. -> 미리보기 화면부분에, 촬영시 기록된 사진 파일을 읽어들여 그림을 보여줍니다.

그렇기에 총 세 가지의 권한이 필요하죠. 카메라, 저장소 읽기, 저장소 쓰기.

주석에도 쓰여있지만, 이전 첫 권한질문에서 해당 권한을 거부했을수도 있어요, 그래서 권한을 한번 더 확인하는 곳입니다.

대신 여기서는 권한을 거부하면 바로 쫓아내야하죠? 그래서 권한요청에 대한 결과를 오버라이딩 하는거예요. 그냥 권한 다 있으면 openCamera 로 카메라 여는 거고요.

오버라이딩 부분 볼까요?

    /**
     * if 문에서 PermissionUtil.requestPermission(this, permissionList); 의 결과를 오버라이드합니다
     * 여기서도 거부를 누른다면 카메라를 종료합니다.
     */
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);

        if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            DebugLogUtil.logD(TAG, "승인");
            openCamera();
        } else {
            DebugLogUtil.logD(TAG, "승인거부");
            onBackPressed();
        }
    }

권한들이 전부 승인되어 있으면 승인한 것으로 간주하고, 권한 중 하나라도 승인되어 있지 않다면 승인 거부하며 onBackPress(안드로이드에서 뒤로 가는 버튼을 눌렀을때 호출되는 기본 매서드입니다.)를 통해 이전 화면으로 보내버립니다.

 

openCamera는 일단 나중에 보고, 필요한 나머지 매서드를 봅시다.

/**
 * 카메라 셔터 애니메이션을 활성화 하는 로직입니다.
 * 애니메이션 파일은 res->anim 내부에 들어있습니다.
 * */
private void setCameraAnimationListener() {
    cameraAnimationListener = new Animation.AnimationListener() {
        @Override
        public void onAnimationStart(Animation animation) {

        }

        @Override
        public void onAnimationEnd(Animation animation) {
            frameLayoutShutter.setVisibility(View.GONE);
            showCaptureImage();
        }

        @Override
        public void onAnimationRepeat(Animation animation) {

        }
    };
}

해당 매서드는 애니메이션을 미리 설정해두는 매서드입니다. 앞서 전역변수에 cameraAnimationListener을 설정해두었죠. 그 부분의 값을 채워넣는 부분이고, 애니메이션 전역변수를 호출할때 지금 설정해두는 애니메이션이 활성화 될 거예요.

오버라이딩 된 매서드 이름이 직관적이죠? 말 그대로 애니메이션이 시작할때, 애니메이션이 끝날때, 혹시 애니메이션을 반복으로 걸어두었을때 어떤 행동을 취할 것이냐~ 를 묻는 오버라이딩들이예요.

저는 카메라 셔터 깜빡임이 끝났을때, 미리보기를 띄울 것이므로 셔터 애니메이션이 끝나고(onEnd) 미리보기 화면을 띄워주세요~ 하고 있습니다. showCameraCapture도 일단 나중에 이야기 할게요.

 

마지막으로 저장된 폴더를 지정하는 매서드입니다.

    /**
     * 외부 저장소에서 앱 이름으로 갤러리를 불러옵니다. 이후 사진을 저장할때 쓰입니다.
     * */
    private File getOutputDirectory() {
        File[] externalMediaDirs = this.getExternalMediaDirs();
        File mkdir = externalMediaDirs[0];
        DebugLogUtil.logD(TAG, mkdir);
        if (mkdir != null) {
            File fileByName = new File(mkdir, getString(R.string.app_name));
            if(!fileByName.exists()){
                fileByName.mkdir();
            }
            mkdir = fileByName;

        } else {
            mkdir = null;
        }

        if(mkdir != null && mkdir.exists()) {
            return mkdir;
        } else {
            return getFilesDir();
        }
    }

전문 용어로 외부저장소, 라고 부르는 것으로 sdcard의 저장 경로를 지정하고, 만약 디렉터리가 따로 없다면 디렉터리를 지정하는 매서드입니다.

먼저 File[] externalMediaDirs = this.getExternalMediaDirs(); 해당 부분을 통해 sdCard 내부의 media부분의, 패키지명으로 만들어진 폴뎌의 경로를 불러옵니다. 만약 해당 값이 없다고 해도 알아서 만들어서 가져옵니다.

그렇기 때문에 mkdir에서 File[] 배열의 가장 첫번째 값을 불러옵니다.

이것이 정상적으로 이루어졌다면 mkdir이 비어있지 않겠죠? 이제 사진을 저장할 디렉터리를 만듭니다.

이제 패키지로 된 경로에, 디렉터리 명을 지정하여 새로운 File 개채(FileByName)를 만들어줍니다. 이 때, mkdir뒤의 app_name 부분이 갤러리의 폴더 명이 되니까, 자유롭게 지정해도 됩니다.

(만약 app_name 부분을 수정하고 싶다면 res -> string 으로 들어가시면 app_name이 있을거예요. 거기 수정하면 그대로 반영됩니다.)

만약 해당되는 디렉터리가 없다면 mkdirs() 를 통해 새롭게 해당 디렉터리를 생성해줍니다. 그리고 mkdir 부분을, 우리가 만들었던 디렉터리 경로로 재지정해줍니다.

이게 안 만들어졌을 수 있으니까, mkdir이 비어있는지, 또 해당 경로가 존재하는 경로인지(mkdir.exist()) 확인 후 정상 지정이 되었다면 해당 경로를 리턴하고, 아니라면 sd카드가 아닌 일반 파일 시스템 경로를 리턴합니다.

 

 

이제 본격적으로 카메라를 띄웁니다.

/**
 * 미리보기 표면과 Android CameraX의 라이프사이클을 동기화하는 로직입니다.
 * 해당 동기화로 인해 화면에 카메라가 나타나게 됩니다.
 */
private void openCamera() {
    DebugLogUtil.logD(TAG, "openCamera");

    ListenableFuture cameraProviderFuture = ProcessCameraProvider.getInstance(this);
    cameraProviderFuture.addListener((Runnable)(() -> {

        ProcessCameraProvider cameraProvider = null;
        try {
            cameraProvider = (ProcessCameraProvider)cameraProviderFuture.get();
        } catch (ExecutionException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Preview preview = (new Preview.Builder()).build();
        preview.setSurfaceProvider(previewView.getSurfaceProvider());
        imageCapture = new ImageCapture.Builder().build();
        CameraSelector cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA;

        try {
            cameraProvider.unbindAll();
            cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture);
        } catch (Exception exception) {
            exception.printStackTrace();
        }

    }), ContextCompat.getMainExecutor(this));

}

 

이 부분부터 이미지 캡쳐까지는 사실 하나의 거대한 프레임워크라고 봐도 무방합니다. 구글에서 카메라 이렇게 띄우세요~ 하고 틀을 잡아둔거죠.

해당 카메라를 연결하는 작업은 비동기(쉽게 말해 다른 작업이 돌아가는 와중에 순서 상관 없이 혼자 돌아가는걸 말합니다.)로 진행됩니다. 그렇기 때문에 ListneableFuture을 이용하여 비동기시의 결과에 대한 미래의 콜백을 받는 역할을 합니다.

카메라의 경우 싱글턴 인스턴스(하나의 인스턴스에서 움직인다고 생각하면 됩니다.) 를 사용하기 때문에 가벼운 정적 리소스를 먼저 지정하고, 무거운 리소스(카메라 실행장치 등)을 나중에 지정해도 되기 때문에 먼저 가벼운 정적 리소스에 대한 콜백을 받습니다.

ProcessCameraProvider.getInstance가 그 정적 리소스를 지정하는 값이 되지요. 그리고 그 addListener을 통해 결과에 대한 콜백을 지정합니다.

우리가 사용할 인스턴스에서 ProcessCameraProvider를 다시 호출합니다. (cameraProvider = (ProcessCameraProvider)cameraProviderFuture.get();)

그리고 Preview를 통해 렌즈를 통해 볼 프리뷰 화면을 구성합니다. 여기서 setSurface를 통해 어느 화면에 노출할 것인지를 표기하게 되죠.

또한 이미지에 대한 캡쳐 부분도 구성해둡니다.

먼저 혹시 모르는 연동 생명주기가 있다면 unbind를 합니다.

이제 마지막으로 앞서 말했던 무거운 리소스들을 앱과의 라이프사이클 바인딩을 통해 해당 매서드의 생명주기를 지정받게 됩니다.

(이 부분을 뭐라 설명해야 할지 어렵네요. 쉽게 말해서 카메라 액티비티가 생성되고 종료되어 사라질때까지의 생명주기를 따라간다고 생각하시면 됩니다.)

마지막으로 ContextCompat.getMainExecute를 통해 연동된 카메라를 메인 매서드에 연결합니다.

이러면 렌즈를 통해 보이는 화면이 구성됩니다.

 

이제 버튼을 눌렀을때 사진을 찍고 저장하는 일을 해봅시다.

    private void setListener() {
        imageViewPhoto.setOnClickListener(v -> savePhoto());
    }
/**
 * 이미지를 캡쳐하는 로직입니다.
 * 이전에 동기화에 쓰였던 imageCapture = new ImageCapture.Builder().build()에서 사진을 콜백하여 파일에 저장합니다.
 * */
private final void savePhoto() {
    if(imageCapture == null) {
        DebugLogUtil.logD(TAG, "imageCapture is null");
        return;
    }

    File photoFile = new File(outputDirectory, (new SimpleDateFormat(""yyyyMMdd_HHmmss"", Locale.KOREA).format(System.currentTimeMillis()) + ".png"));
    ImageCapture.OutputFileOptions outputOption = new ImageCapture.OutputFileOptions.Builder(photoFile).build();
    imageCapture.takePicture(outputOption, ContextCompat.getMainExecutor(this), new ImageCapture.OnImageSavedCallback() {
        @Override
        public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
            savedUri = Uri.fromFile(photoFile);
            Animation animation = AnimationUtils.loadAnimation(CameraActivity.this, R.anim.camera_shutter);
            animation.setAnimationListener(cameraAnimationListener);
            frameLayoutShutter.setAnimation(animation);
            frameLayoutShutter.setVisibility(View.VISIBLE);
            frameLayoutShutter.startAnimation(animation);
        }

        @Override
        public void onError(@NonNull ImageCaptureException exception) {
            exception.printStackTrace();
            onBackPressed();
        }
    });
}

 

먼저 카메라를 열때 구성하고 바인딩했던 imageCapture가 있는지 확인합니다.

이미지 파일을 전달받을 파일을 먼저 구성합니다. (    File photoFile = new File(outputDirectory, (new SimpleDateFormat(""yyyyMMdd_HHmmss"", Locale.US).format(System.currentTimeMillis()) + ".png"));)

이 때, outputDictionary 뒷 부분은 파일의 이름이 들어가는 곳인데, 날짜가 제일 무난해서 날짜와 시간으로 넣었어요. yy는 연, MM은 달, dd는 날짜, HH는 시간(24시간), mm은 분, ss은 초입니다. Local 부분은 위치, current 는 해당 안드로이드가 가진 시스템 시간을 의미합니다.

그리고 OutputOption을 통해 ImageCapture을 내부의 사진을 photoFile로 재구성할거라고~ 설정을 합니다.

이제 imageCapture에 해당 옵션과, 해당 스레드, 그리고 이미지 캡쳐가 완료되면 할 콜백을 넣습니다.

사진이 올바르게 저장되면, savedUri에 사진이 저장된 경로를 저장해줍니다. 이걸로 나중에 미리보기 화면을 가져옵니다.

나머지 아래 코드들은 전부 애니메이션을 설정하는 코드입니다. 천천히 따라해보세요 :)

만일 에러가 난다면 onError로 이동합니다.

 

이제  animation.setAnimationListener(cameraAnimationListener); 부분을 통해 리스너를 등록했으므로 앞서 설정했던 애니메이션 리스너가 발동됩니다. 애니메이션이 끝나면 showImageCapture 매서드가 실행된다고 했습니다.

    /**
     * 미리보기 화면을 보여주는 로직입니다.
     * Boolean으로 리턴을 받는 이유는 이후 뒤로가기 버튼(onBackPress)시 화면이 떠있다면 미리보기 화면을 지우고, 없다면 카메라를 나가게 하기 위함입니다.
     */
    private Boolean showCaptureImage() {
        if(frameLayoutPreview.getVisibility() == View.GONE) {
            frameLayoutPreview.setVisibility(View.VISIBLE);
            imageViewPreview.setImageURI(savedUri);
            return false;
        }
        return true;
    }

여기서부터는 주석을 잘 읽어주세요.

미리보기 화면이 다른 프레임 레이아웃을 띄우는 형태이기 때문에, 화면이 하나 더 떠있는 형태라면 뒤로가기 버튼을 눌렀을때 화면이 내려가고 다시 카메라가 나와야겠죠? 그래서 해당방식을 사용하는거에요.

 

그 뒤로가기는~ 원래 액티비티의 기본 매서드이기 때문에 오버라이딩해서 받아요.

@Override
public void onBackPressed() {
    if(showCaptureImage()) {
        hideCaptureImage();
    } else {
        onBackPressed();
    }
}

만약 이미지 미리보기가 떠있다면 이 미리보기를 숨겨야하죠?

 

그래서 마지막으로 hideCapture을 하는거랍니다.

/**
 * 미리보기 화면을 숨기는 로직입니다.
 */
private void hideCaptureImage() {
    imageViewPreview.setImageURI(null);
    frameLayoutPreview.setVisibility(View.GONE);
}

 

코딩을 처음 해보시는 분께는 어렵습니다. 예제 깃허브도 보면서 차근차근 해보세요.

반응형