안드로이드 앱개발

이미지 피커 라이브러리 <뭉치피커> 개발기

팀(Tim) 2022. 3. 29. 18:01

회사의 앱이 TedBottomPicker라는 라이브러리를 사용하고 있었다.

하지만 안드로이드11이 나오면서 Scoped storage 대응이 필요해졌고, 자잘한 버그가 있었기에 이참에 이미지피커 라이브러리를 만들기로 했다.

 

결과물은 여기에서 볼 수 있다.

디자이너가 붙지 않았기에 UI는 그저 TedBottomPicker와 동일하게 만들었다.

 

만들면서 중요하게 생각한 것은 두가지였다.

1. Scoped Storage 대응

2. onActivityResult 함수 대신 Activity Result Api의 registerForActivityResult 를 사용하기

 

PhotoExt.kt 파일을 보자.

 

1. 카메라로 사진을 찍은 후 사진을 저장할 경로 얻기

 

사진을 저장할 경로를 얻을때, Android 11부터 앱은 외부 저장소에 자체 앱별 디렉터리를 생성할 수 없기 때문에

getExternalFilesDir(Environment.DIRECTORY_PICTURES)

이 함수를 이용했다. 반환된 File 객체는 외부저장소에 내 패키지이름으로 된 폴더가 있고, 그 아래에 Pictures 라는 폴더를 가리키게 된다.

* 외부저장소는 꼭 sd카드의 저장소가 아니다. 내부저장소를 논리적으로 나눈 공간 또한 외부저장소라고 한다. 이름 때문에 헷갈리지 말자.

또한 외부저장소의 앱별디렉터리는 외부저장소에서 내 앱을 위한 프라이빗한 공간이고, 외부저장소의 공용디렉터리는 갤러리앱에서 표시되는 사진 같은 여러 앱에서 사용하는 디렉터리를 말한다.

 

사족 1.

 

val storageDir = File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "moongchiPicker")
    
if(!storageDir.exists()) storageDir.mkdirs()

이렇게 패키지명/Pictures/ 아래에는 폴더를 만들 수 있다. 자체 앱별 디렉터리를 만들지 못한다는 말은 패키지명/ 밑에 디렉터리를 만들지 못한다는 말이다.

또한 패키지명/  밑에 디렉터리를 만들 수는 없지만, txt 등 파일은 만들 수 있다.

 

사족 2.

 

getExternalFilesDir() : 저절로 지워지지 않는 파일을 저장하기 적합.

getExternalCacheDir() : 저절로 지워지는 캐시파일을 저장하기 적합.

 

-> 카메라로 찍은 사진을 저장할 파일을 어느 경로에 만들지 고민했다. 이 파일은 서버에 업로드하기 때문에 업로드 후 바로 삭제하면 되기 때문에 사실 둘 중 어느 곳에 저장해도 상관없었다.

-> getExternalCacheDir() 을 써도 캐시를 비우는걸 시스템에 의존하지 말라고한다. 실험결과 캐시가 3G가 넘도록 캐시파일은 저절로 지워지지 않았다. 도대체 언제 지워지는지 모르겠다. 결론적으로, 파일을 지우는건 개발자 책임인 것 같다.

 

사족 3.

 

외부저장소의 앱별 디렉터리를 접근하는데는 권한이 필요가 없다.

 

2. 외부 공용 미디어 파일 읽어오기

 

Scoped Storage에 대응하기 위해 미디어를 로드할때 MediaStore Api를 이용해야했다.

 

internal suspend fun Context.loadImagesFromPublicExternalStorage(maxFileCount: Int): List<Uri> {
    return withContext(Dispatchers.IO) {
        val collection = sdkAndUp(Build.VERSION_CODES.Q) {
            MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
        } ?: MediaStore.Images.Media.EXTERNAL_CONTENT_URI

        val projection = arrayOf(
            MediaStore.Images.Media._ID
        )

        contentResolver.query(
            collection,
            projection,
            null,
            null,
            MediaStore.Images.ImageColumns.DATE_ADDED + " DESC"
        )?.use { cursor ->
            val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)

            val uris = mutableListOf<Uri>()
            while (cursor.moveToNext() && uris.size < maxFileCount) {
                val id = cursor.getLong(idColumn)
                val contentUri = ContentUris.withAppendedId(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    id
                )
                uris.add(contentUri)
            }
            uris
        } ?: listOf<Uri>()
    }
}

 

사족 1.

third party 미디어 관련 라이브러리와의 호환성을 위해 Scoped Storage 이외에 파일 api나 fopen() 같은 함수로도 접근이 가능하다고 하다.

 

3. 카메라 앱으로 사진찍기를 요청할 ActivityResultLauncher 만들기

 

먼저, ActivityResultLauncher를 launch 하는 시점에서 인자를 넘겨야한다는 점이 맘에 들지 않아 StatefulActivityResultLauncher 라는 클래스를 만들었다. 이후 카메라 캡쳐를 요청하는 StatefulActivityResultLauncher를 만들었다.

 

internal open class StatefulActivityResultLauncher<T>(
    private val activityResultLauncher: ActivityResultLauncher<T>,
    private val launchParam: T? = null
) {

    fun launch() {
        if (launchParam != null) {
            activityResultLauncher.launch(launchParam)
        } else {
            Logger.d("cannot launch [StatefulActivityResultLauncher] : launchParam is null")
        }
    }
}

 

internal fun ComponentActivity.registerTakePictureLauncher(
    onSuccess: (fileUri: Uri) -> Unit,
    onFailed: (Throwable) -> Unit
): StatefulActivityResultLauncher<Uri> {
    val contentUri = getContentUriFromFile(createImageFilePrivate())

    return registerForActivityResult(ActivityResultContracts.TakePicture()) { isPictureSaved ->
        if (isPictureSaved) {
            onSuccess(contentUri)
        } else {
            onFailed(TakePictureFailedException())
        }
    }.toStatefulActivityResultLauncher(contentUri)
}
private fun registerCameraRequest(
    mediaType: PetMediaType,
    moongchiPickerListener: MoongchiPickerListener
): StatefulActivityResultLauncher<Uri> {
    return when (mediaType) {
        PetMediaType.IMAGE -> {
            registerTakePictureRequest(
                onSuccess = { moongchiPickerListener.onSubmitMedia(listOf(it)) },
                onFailed = { moongchiPickerListener.onFailed(it) }
            )
        }
        PetMediaType.VIDEO -> {
            registerTakeVideoRequest(
                onSuccess = { moongchiPickerListener.onSubmitMedia(listOf(it)) },
                onFailed = { moongchiPickerListener.onFailed(it) }
            )
        }
    }
}
        val cameraRequest = registerCameraRequest(mediaType, moongchiPickerListener)
        
          return object : MoongchiPickerDialogListener {
            override fun onClickCamera() {
                cameraRequest.launch()
            }

         
        }

이후 맨 아래코드처럼 이미지피커의 실질적 화면에 해당하는 MoonchiPickerDialog의 리스너를 만들때 활용했다.

 

사족 1.

 

하지만 여기서 문제가 발생했다.

DialogFragment는 화면회전, 메모리 초과 등의 이유로 시스템에 의해 파괴되고 재생성된다. 이때 시스템은 무조건 기본생성자로 DialogFragment를 생성하기 때문에 DialogFragment는 기본생성자를 써야한다.

대신 newInstance() 함수로 DialogFragment 생성한다. 여기에서 DialogFragment에 넘길 데이터를 bundle 형식으로 저장하게된다.

 

이때 bundle에 데이터가 저장되려면 Serializable해야한다. 하지만 Listener는 아무리 Serializable을 상속받더라도 리스너가 참조하고 있는 객체가 Serializable 하지 않으면 런타임에러가 발생한다.

 

나는 처음에 MoogchiPickerListener를 Bundle 에 저장했다가 런타임에러를 경험했다. 

MoonchiPickerDialog를 켠채로 화면을 돌리거나, 홈버튼을 눌러 밖으로 나가면 앱이 크래쉬가 났다.

 

그 이유는 화면을 돌리거나, 홈버튼을 눌러 밖으로 나가면 프래그먼트 재생성시 상태를 보전하기 위해 DialogFragment의 bundle을 실제로 직렬화해서 저장을 하게 되는데 이때 MoogchiPickerListener가 Serializable하지 않아 크래쉬가 난 것이다.

 

이것을 해결하기 위해 다른 이미지피커 라이브러리를 살펴보니 애초에 내가 한 것처럼 리스너를 다이어로그에 넘기는 방식이 아닌, onActivityResult나 Activity Result API 를 써서 사용자의 Activity에서 사진 캡쳐에 대한 결과를 처리하도록 하고 있었다.

 

하지만 TedBottomPicker는 내가 한 것처럼 리스너를 넘기는 방식으로 구현되있었기에 어찌된 것인지 살펴보니 TedBottomPicker는 화면회전과 같은 configuration change 일때 아예 TedBottomPicker를 닫아버리는 방식을 채용하고 있었다.

 

즉, 시스템이 멋대로 다이어로그를 재생성해버리는게 문제이니 재생성하지 않게 만든 것이다.

 

나도 그 방식을 사용하기로 했다.

 

newInstance() 에서 bundle에 Listener를 넣는 대신, 그냥 moongchiPickerDialogListener 멤버변수에 리스너를 대입했다.

    fun newInstance(
            mediaType: PetMediaType,
            moongchiPickerDialogListener: MoongchiPickerDialogListener,
            maxSelectableMediaCount: Int = 1,
            maxVisibleMediaCount: Int = MAX_VISIBLE_MEDIA_COUNT
        ): MoongchiPickerDialog {
            val args = Bundle()
            args.putSerializable(EXTRA_MEDIA_TYPE, mediaType)
            args.putInt(EXTRA_MAX_SELECTABLE_MEDIA_COUNT, maxSelectableMediaCount)
            args.putInt(EXTRA_MAX_VISIBLE_MEDIA_COUNT, maxVisibleMediaCount)
            val fragment = MoongchiPickerDialog()
            fragment.setMoongchiPickerDialogListener(moongchiPickerDialogListener)
            fragment.arguments = args
            return fragment
        }

 

이후 onViewCreate에서 moongchiPickerDialogListener를 검사해 null 이면 다이어로그를 닫았다.

왜냐하면 시스템에 의해서 재생성된 경우, moongchiPickerDialogListener는 값이 대입되지 않아 null이기 때문이다.

 

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        if (moongchiPickerDialogListener == null) {
            dismiss()
            return
        }

 

이렇게 하면 진정으로 configuration change나 lowMemory일때 프래그먼트가 파괴되는 경우에 대한 대비를 하는게 아니지만, 미디어피커의 경우 반드시 다이어로그 상태가 보전되어야 하는 것이 아니기 때문에 이렇게만해도 충분했다.

하지만 다시 만든다면 다이어로그 상태가 보전되도록 만들 것 같다. (...)