-->

[Kotlin] Android bitmap 최적화

 

 

저번에 겪었던 OutOfMemory가 단순 빌드 이슈가 아닌 성능 이슈로 다가왔다.

내 쪽에서 테스트는 문제가 없었는데 다른 기기들에서 간헐적으로 발생한다는 것.

아마 맨 처음에 만들었을 때 4개 정도 사진 찍을거로 생각해 이미지 최적화까지는 하지 않았었는데 

그게 20개로?왜?늘어서 캐시가 감당할 수 없을 정도의 메모리를 사용하고 있던 것.

그래서 저번에 만들었던 custom capture pad를 kotlin으로 마이그레이션 하면서 동시에 최적화 작업도 해보았다.

https://minggu92.tistory.com/11

 

[Android / Java] Camera Pad 만들기

[Android / Java] Camera Pad 만들기 * 목표 : 안드로이드의 카메라 기능 및 Custom Adapter를 이용해 Camera Pad 기능을 구현해보자 * 사이드 목표 :  - 1) 프래그먼트 화면에서 실행할 수 있도록 구현  - 2..

minggu92.tistory.com

(2년 전에 만든 건데...)

 

일단 OutOfMemory 이슈는 try catch로 잡힌다는 걸 알게 되었고... (심지어 app crash가 발생하지도 않음)

그에 따른 예외처리를 따로 해줘야 할 필요가 느껴진다.

아무리 자바엔 GC가 있다지만 사진을 이미지 축소하지 않고 사용하면 캐시 메모리는 금방 차 버린다. 명시적으로 메모리 해제할 일이 생길지도..

그래서 BitmapFactory의 isSampleSize Options을 줘서 크기를 1/4로 줄였다.

동적으로 ImageView의 크기를 계산해 isSampleSize option을 할당하는 방법도 있지만

그렇게까지 할 필요가 없어 코드는 사용하지 않았다.

Bitmap을 recycle 하는 방법도 있는데 동적으로 생성된 캡처 패드 객체라서 사용하기 어려워 보인다.

안드로이드를 손에서 놓은 지 좀 되었지만 최신 버전들을 사용해서 깔끔하게 작성해 보고 싶은 욕구가 생겼다.

 

package com.ming.gu.function

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.graphics.drawable.BitmapDrawable
import android.media.ExifInterface
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewManager
import android.view.ViewTreeObserver
import android.widget.*
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import com.gun0912.tedpermission.util.ObjectUtils
import com.ming.gu.R
import com.ming.gu.model.CaptureItem
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*

class CustomCapturePad {
    private var bnCaptureSave: Button? = null
    private var bnCaptureClear: Button? = null
    private var bnCaptureClose: ImageButton? = null
    private var mCurrentPhotoPath: String? = null
    private var ivCapture: ImageView? = null
    private var inputBitmap: Bitmap? = null
    private var outputBitmap: Bitmap? = null
    private var mFragment: Fragment? = null
    private var imgWidth: Int = 0
    private var imgHeight: Int = 0

    @SuppressLint("InflateParams", "QueryPermissionsNeeded", "SimpleDateFormat")
    fun makeCapturePad(inputFragment: Fragment, inputCaptureItem: CaptureItem?) {
        mFragment = inputFragment
        inputBitmap = inputCaptureItem!!.capture

        //새 inflater 생성
        val inflater = mFragment!!.activity
            ?.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater

        //새 레이아웃 객체생성
        val linearLayout = inflater.inflate(R.layout.popup_capture, null) as LinearLayout

        //레이아웃 배경 투명도 주기
        val myColor = ContextCompat.getColor(mFragment!!.requireContext(), R.color.o60)
        linearLayout.setBackgroundColor(myColor)

        //레이아웃 위에 겹치기
        val params = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.MATCH_PARENT
        )

        //바깥 터치 안되게
        linearLayout.isClickable = true
        linearLayout.isFocusable = true
        mFragment!!.requireActivity().addContentView(linearLayout, params)

        //버튼
        ivCapture = linearLayout.findViewById(R.id.iv_capture_view)
        bnCaptureSave = linearLayout.findViewById(R.id.btn_capture_save)
        bnCaptureClear = linearLayout.findViewById(R.id.btn_capture_clear)
        bnCaptureClose = linearLayout.findViewById(R.id.btn_capture_close)

        ivCapture?.setImageBitmap(inputBitmap) //최초 로드시 기존꺼 세팅

        //disable both buttons at start
        bnCaptureSave?.isEnabled = false
        bnCaptureSave?.backgroundTintList = mFragment?.requireContext()
            ?.let { ContextCompat.getColor(it, R.color.disable) }
            ?.let { ColorStateList.valueOf(it) }


        //get ImageView size
        ivCapture?.viewTreeObserver?.addOnPreDrawListener(object :
            ViewTreeObserver.OnPreDrawListener {
            override fun onPreDraw(): Boolean {
                imgWidth = ivCapture?.measuredHeight!!
                imgHeight = ivCapture?.measuredWidth!!
                ivCapture?.viewTreeObserver?.removeOnPreDrawListener(this)
                return true
            }
        })
        ivCapture?.setOnClickListener {
            val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)

            // Ensure that there's a camera activity to handle the intent
            if (takePictureIntent.resolveActivity(mFragment!!.requireActivity().packageManager) != null) {

                // Create the File where the photo should go
                var photoFile: File? = null
                try {
                    //set Temp FileDirectory
                    val tempDir = mFragment!!.requireActivity().cacheDir
                    //                    File tempDir = new File(mFragment.getContext().getFilesDir() + "/temp");

                    //set Temp File Name
                    val timeStamp = SimpleDateFormat("yyyyMMdd").format(Date())
                    val imageFileName = "JPEG_" + timeStamp + "_"
                    val tempImage = File.createTempFile(
                        imageFileName,  /* prefix */
                        ".jpg",  /* suffix */
                        tempDir /* directory */
                    )

                    // Save a file: path for use with ACTION_VIEW intents
                    mCurrentPhotoPath = tempImage.absolutePath
                    Log.i(TAG, "temp mCurrentPhotoPath : $mCurrentPhotoPath")
                    photoFile = tempImage
                } catch (ex: IOException) {
                    // Error occurred while creating the File
                }
                // Continue only if the File was successfully created
                if (photoFile != null) {
                    val photoURI = FileProvider.getUriForFile(
                        mFragment!!.requireActivity(),
                        mFragment!!.requireContext().packageName + ".fileprovider",
                        photoFile
                    )
                    takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI)
                    mFragment!!.startActivityForResult(
                        takePictureIntent,
                        10
                    )
                }
            }
        }

        //CAPTURE SAVED
        bnCaptureSave?.setOnClickListener {
            try {
                val drawable = ivCapture?.drawable as BitmapDrawable
                val bitmap = drawable.bitmap
                inputCaptureItem.capture = outputBitmap
                outputBitmap = null
                if (bitmap != null) {
                    inputCaptureItem.status = true
                } else {
                    inputCaptureItem.status = false
                    inputCaptureItem.capture = (ResourcesCompat.getDrawable(
                        mFragment!!.requireActivity().resources,
                        R.drawable.icon_camera_999,
                        null
                    ) as BitmapDrawable?)!!.bitmap //default image
                }
                mCurrentPhotoPath = "" //initialize
                mFragment!!.onActivityResult(501, 10, null) //update item
                if (linearLayout.parent != null) (linearLayout.parent as ViewManager).removeView(
                    linearLayout
                )
            } catch (e: Exception) {
                Log.w(TAG, "SAVE ERROR!", e)
            }
        }

        bnCaptureClear?.setOnClickListener {
            Toast.makeText(mFragment!!.activity, "clear", Toast.LENGTH_LONG).show()
            ivCapture?.setImageBitmap(null)
            //disable both buttons at start
            bnCaptureSave?.isEnabled = true
            //        bnCaptureClear.setEnabled(false);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                bnCaptureSave?.backgroundTintList = mFragment?.requireContext()
                    ?.let { it1 -> ContextCompat.getColor(it1, R.color.button_default_color) }
                    ?.let { it2 -> ColorStateList.valueOf(it2) }
                //            bnCaptureClear.setBackgroundTintList(ColorStateList.valueOf(mFragment.getActivity().getResources().getColor(R.color.disable)));
            }
        }

        bnCaptureClose?.setOnClickListener {
            if (linearLayout.parent != null) (linearLayout.parent as ViewManager).removeView(
                linearLayout
            )
        }
    }

    fun onActivityResult(requestCode: Int, resultCode: Int) {
        try {
            //after capture
            when (requestCode) {
                10 -> {
                    if (resultCode == Activity.RESULT_OK) {
//                        val file = mCurrentPhotoPath?.let { File(it) }
//                        var bitmap = MediaStore.Images.Media
//                            .getBitmap(
//                                mFragment!!.requireActivity().contentResolver,
//                                Uri.fromFile(file)
//                            )
                        val options: BitmapFactory.Options = BitmapFactory.Options()
                        options.inSampleSize = 4
                        var bitmap = BitmapFactory.decodeFile(mCurrentPhotoPath, options)

                        if (!ObjectUtils.isEmpty(bitmap)) {
                            val ei = ExifInterface(mCurrentPhotoPath!!)
                            val orientation = ei.getAttributeInt(
                                ExifInterface.TAG_ORIENTATION,
                                ExifInterface.ORIENTATION_UNDEFINED
                            )

                            try {
                                bitmap = when (orientation) {
                                    ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90f)
                                    ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180f)
                                    ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270f)
                                    ExifInterface.ORIENTATION_NORMAL -> bitmap
                                    else -> bitmap
                                }
                                outputBitmap = bitmap //원본은 회전시켜 저장
//                                options.inSampleSize = calculateInSampleSize(options, imgWidth, imgHeight)
//                                bitmap = Bitmap.createScaledBitmap(bitmap, imgWidth, imgHeight, true)

                                ivCapture?.setImageBitmap(bitmap)
                                bnCaptureSave!!.isEnabled = true
                                bnCaptureClear!!.isEnabled = true
                                bnCaptureSave!!.backgroundTintList = mFragment?.requireContext()
                                    ?.let {
                                        ContextCompat.getColor(it, R.color.button_default_color)
                                    }?.let { ColorStateList.valueOf(it) }
                            } catch (e: OutOfMemoryError) {
                                Log.e(TAG, "OutOfMemoryError!", e)
                            } finally {
//                                if (!bitmap.isRecycled) bitmap.recycle();
                            }
                        }
                    }
                }
            }
        } catch (e: Exception) {
            Log.w(TAG, "onActivityResult Error !", e)
        }
    }

    fun decodeSampledBitmapFromResource(
        res: Resources,
        resId: Int,
        reqWidth: Int,
        reqHeight: Int
    ): Bitmap {
        // First decode with inJustDecodeBounds=true to check dimensions
        return BitmapFactory.Options().run {
            inJustDecodeBounds = true
            BitmapFactory.decodeResource(res, resId, this)

            // Calculate inSampleSize
            inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)

            // Decode bitmap with inSampleSize set
            inJustDecodeBounds = false

            BitmapFactory.decodeResource(res, resId, this)
        }
    }

    //축소 버전을 메모리로 로드
    private fun calculateInSampleSize(
        options: BitmapFactory.Options,
        reqWidth: Int,
        reqHeight: Int
    ): Int {
        // Raw height and width of image
        val (height: Int, width: Int) = options.run { outHeight to outWidth }
        var inSampleSize = 1

        if (height > reqHeight || width > reqWidth) {

            val halfHeight: Int = height / 2
            val halfWidth: Int = width / 2

            // Calculate the largest inSampleSize value that is a power of 2 and keeps both
            // height and width larger than the requested height and width.
            while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
                inSampleSize *= 2
            }
        }

        return inSampleSize
    }

    companion object {
        val TAG = CustomCapturePad::class.java.simpleName.trim() //CustomCapturePad"

        //카메라에 맞게 이미지 로테이션
        fun rotateImage(source: Bitmap, angle: Float): Bitmap {
            val matrix = Matrix()
            matrix.postRotate(angle)
            return Bitmap.createBitmap(
                source, 0, 0, source.width, source.height,
                matrix, true
            )
        }
    }
}

 

import android.graphics.Bitmap

class CaptureItem {
    var title: String? = null
    var realPath: String? = null
    var category: String? = null
    var capture: Bitmap? = null
    var status: Boolean? = null
    override fun toString(): String {
        return "CaptureItem [" +
                "  title : $title"+
                ", category : $category"+
                ", realPath : $realPath"+
                ", capture : $capture"+
                ", status : $status"+
                "]"
    }
}

<참조>

https://developer.android.com/topic/performance/graphics/load-bitmap?hl=ko 

 

큰 비트맵을 효율적으로 로드  |  Android 개발자  |  Android Developers

이미지의 모양과 크기는 다양합니다. 많은 경우 이미지는 일반적인 애플리케이션 사용자 인터페이스(UI)에서 요구하는 크기보다 큽니다. 예를 들어, 시스템 Gallery 애플리케이션은 Android 기기의

developer.android.com

https://www.androidpub.com/android_dev_qna/1282821

 

Android: [FAQ] OutOfMemoryError bitmap size exceeds VM budget 메모리 에러 - 자주하는 질문 - 안드로이드 개발

메모리 부족 관련 질문들이 있어서 정리 합니다. (기억을 더듬어 작성하는거라 잘못된 부분이 있으면 댓글로 말씀주세요)안드로이드에서 OutOfMemoryError라 발생하는 가장 많은 경우는 바로 비트맵

www.androidpub.com

https://mainia.tistory.com/468

 

안드로이드 (Android) Bitmap 구현, 관리 하기

안드로이드 (Android) Bitmap 구현, 관리 하기 개발환경 : JDK 1.5, eclipse-galileo, android googleAPI 7, window XP 모든 프로그램에서 이미지 관리의 기본은 비트맵이다. 안드로이드에서도 마찬가지로 이미..

mainia.tistory.com

https://leveloper.tistory.com/167

 

[Android] ViewTreeObserver란? - View가 그려지는 시점 알아내기

ViewTreeObserver란? Android Developers에서는 ViewTreeObserver를 다음과 같이 설명하고 있다. A view tree observer is used to register listeners that can be notified of global changes in the view tree...

leveloper.tistory.com

https://stackoverflow.com/questions/25719620/how-to-solve-java-lang-outofmemoryerror-trouble-in-android

 

How to solve java.lang.OutOfMemoryError trouble in Android

Altough I have very small size image in drawable folder, I am getting this error from users. And I am not using any bitmap function in code. At least intentionally :) java.lang.OutOfMemoryError ...

stackoverflow.com

https://creaby.tistory.com/1

 

다수의 비트맵 처리 시 메모리 관리 (1)

그동안 하이브리드 앱을 만들다가 작년에 영상처리를 해주는 앱을 만들게 되었는 데, 이때 영상처리 파트 쪽에서 고화질의 이미지를 넘겨주길 원했습니다. 문제는 앱의 프로세스 상 한번에 수

creaby.tistory.com

https://marlboroyw.tistory.com/481

 

Android(Java) 에서 OutOfMemory 를 catch 할 수 있을까?

Stackoverflow 를 둘러보던 중, 아래와 같은 글을 발견했다. Java 에서 try-catch 로 OutOfMemoryError 를 잡을 수 있나?? 아니 어떻게 OS 단에서 컨트롤 하는 메모리에러를 잡지? 라고 생각하며 스크롤을 내렸

marlboroyw.tistory.com

 

+ Recent posts