Context란

Context는 안드로이드 시스템에 대한 인터페이스 역할을 하는 객체이다.

흠…?

 

좀 더 쉽게 풀어보면, Context는 어플리케이션 리소스, 파일, 시스템 서비스 등에 접근할 수 있는 메소드들을 갖고 있다. 그래서 앱의 resources나 File과 같은 시스템 자원에 접근하거나 새로운 Activity 실행, Intent 수신과 같이 애플리케이션 레벨의 작업을 요청할 수 있다.

또한, Context는 추상 클래스이며 구현체는 안드로이드 시스템에 의해 제공된다

Context 쓰임 정리

  • 앱의 리소스에 접근: res, assets, internal storage
  • 컴포넌트 통신: 액티비티 시작, Intent 수신 등
  • 시스템 서비스에 접근: SystemServices
  • 앱 정보에 접근: ApplicationInfo
  • GUI 작업: ex: AlertDialog.Builder(requireContext())

실제로는 훨씬 다양한 상황에서 사용된다!


Context의 종류 (feat. 계층 구조)

Context의 클래스 계층 구조를 보면 재밌는 사실을 확인할 수 있다.

https://medium.com/swlh/context-and-memory-leaks-in-android-82a39ed33002

(1) Application, Service, Activity 모두 Context를 상속받고 있다. Activity가 Context 자리를 this로 대신할 수 있었던 이유가 이것이었다.

 

(2) Context는 Activity, Service, Application 등 종류가 나뉘어 있다.

앱 안에는 여러 Activity와 Service가 존재할 수 있으므로 Activity Context는 여러 객체가 있을 수 있다. 반면, Application은 하나라서 Singleton으로 앱 내에서 하나만 존재한다.

즉, Context는 종류가 나뉘며 각각이 다른 생명주기를 갖고 있다. 따라서, 올바르지 않은 Context를 사용할 경우 메모리 누수가 발생할 수 있다.


Context와 Memory Leak

Context는 크게 ApplicationContext와 나머지 Context, 그중에서도 Activity Context로 나눌 수 있다.

Application Context

ApplicationContext는 Applicaton 생명주기를 따르는 싱글턴 객체이다. 따라서, 앱 전역에서 사용될Context가 필요하다면 ApplicationContext를 사용하자. (ex: RoomDatabase 객체 생성, 싱글턴 객체 생성)

 

싱글턴(or 액티비티 생명주기를 넘어가는) 작업에 Activity Context를 사용할 경우 메모리 누수가 발생하므로 주의해야 한다! Activity가 종료되더라도 다른 객체에서 Activity를 Context로 참조하고 있다면 garbage collector가 메모리를 해제할 수 없기 때문이다.

Activity Context

Activity Context는 Activity의 생명주기에 종속되어 있다. 따라서, Activity 생명주기 내에서 사용될 Context가 필요하다면 Activity Context를 사용하는 것이 좋다.

 

특히, GUI 작업을 한다면 반드시 Application Context가 아닌 Acitivty Context를 사용해야 한다. (ex: 다이얼로그 생성, Toast 출력) 왜냐면 Acitivty의 Theme과 같은 고유한 정보는 해당 Context만이 갖고 있지 때문이다. 다른 Context를 사용할 경우 예상과 다른 결과를 보게 되거나 crash로 앱이 종료될 수도 있다.

정리

  • 앱 전역에서 사용될 Context가 필요하다 → Application Context
  • 액티비티 생명주기 내에서 or GUI 작업에 사용할 Context가 필요하다 → Activity Context

부록) Context를 가져오는 방법

1. Activity

액티비티가 Context 그 자체이므로, this로 Activity Context에 접근할 수 있다.

2. Fragment

context or requireContext 를 통해 Fragment를 hosting하는 Activity Context에 접근할 수 있다.

3. View

view.context 를 통해 View가 포함되어 있는 Activity의 Context에 접근할 수 있다.

4. ContentProvider

ContentProvider 클래스 안에서 context 를 통해 Application Context에 접근할 수 있다.

5. Application Context

각 Context에서 applicationContext을 통해 Application Context에 접근할 수 있다. (ex: context?.applicationContext)


정리

Context는 안드로이드 시스템과 통신할 수 있는 인터페이스로, 앱 리소스, 시스템 작업, 컴포넌트 통신 등을 요청할 수 있다.

Context는 크게 Applicaton Context와 Activity Context로 나눌 수 있는데, 상황에 적절한 것을 사용하지 않으면 메모리 누수나 앱 크래시가 발생한다. 앱 전역에서 사용된다면 Applicaton Context를, 액티비티 내에서 유효하거나 GUI 작업에 필요한 것이라면 Activity Context를 사용하자.


참조

익숙한 Serializable과 Parcelable

Serializable과 Parcelable, 익숙한 녀석들이다. Bundle에 객체를 담아 Intent와 arguments를 통해 다른 곳으로 전달하려면 객체의 클래스가 둘 중 하나를 구현(상속)해야 한다.

나는 지금까지 Serializable이 비효율적이라고 해서 코틀린에서 제공하는 @Parcelize 을 사용하거나, 별 생각 없이 Serializable을 사용하곤 했다.

그러다 둘이 어떤 차이가 있고, 왜 Serializable이 비효율적인지 알아보기로 했다.

직렬화

Serializable: 직렬화 가능한, ‘직렬화’ 많이 들어보긴 했는데..

https://www.geeksforgeeks.org/serialization-in-java/

  • 직렬화(serialization): 객체를 바이트 단위의 연속적인 데이터(바이트 스트림)로 변경하는 작업
  • 역직렬화(deserialization): 바이트 스트림을 원래 객체로 변환하는 작업

Serializable과 Parcelable은 모두 직렬화와 관련이 있다. 객체를 주고 받으려면 객체를 직렬화 해야 한다.

왜 직렬화가 필요할까?

직렬화는 서로 다른 메모리 영역을 갖는 컴포넌트 간 객체를 주고 받을 때 사용한다. 객체는 대부분 다른 객체를 가리키는 참조 필드를 갖고 있는데, 이 주소 값을 다른 메모리에서는 사용할 수가 없다. 따라서, 이 참조 변수를 그것이 가리키는 실제 값으로 변환하는 작업이 필요하다.

Serializable

Serializable은 Java에서 제공하는 표준 인터페이스이다. Serializable을 구현한 클래스는 직렬화 대상이 된다.

장점

  • 사용하기 편하다. 따로 구현할 코드가 없다.

단점

  • Reflection을 사용하기 때문에 느리고 메모리를 많이 쓴다.

Reflection

  • Reflection: Java에서 제공하는 API로, 런타임에 객체의 정보를 분석하는 기법

객체의 프로퍼티는 런타임에 동적으로 변하기 때문에 컴파일 타임에 결정할 수 없다. 그래서 Reflection을 통해 정보를 분석한 후 직렬화 한다.

하지만 Reflection 과정에서 여러 중간 객체가 생성된다. 그리고 중간 객체를 생성하고 GC를 통해 제거하는 과정에서 메모리와 CPU를 사용하므로 그만큼 리소스가 소모되는 작업이라고 할 수 있다.

Parcelable

안드로이드 SDK에서 제공하는 인터페이스이다. 이것을 구현한 클래스의 객체는 직렬화 가능하다.

Parcelable은 Reflection이 런타임에 하는 작업을 개발자가 대신 한다. 개발자가 직접 직렬화/역직렬화 하는 로직을 작성해야 한다.

장점

Serializable에 비해 빠르고 리소스를 덜 소모한다.

Reflection 과정 없이 미리 작성된 로직을 바탕을 빠르게 직렬화/역직렬화 할 수 있다.

단점

직접 작성해야 하는 코드가 많다. class User(val id: Int, val name: String)에 대해 필요한 코드는 다음과 같다.

class User(val id: Int, val name: String) : Parcelable {
    constructor(parcel: Parcel) : this(
        parcel.readInt(),
        parcel.readString() ?: ""
    ) {
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeInt(id)
        parcel.writeString(name)
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator<User> {
        override fun createFromParcel(parcel: Parcel): User {
            return User(parcel)
        }

        override fun newArray(size: Int): Array<User?> {
            return arrayOfNulls(size)
        }
    }
}

(좀 길긴 하다;;)

Serializable vs Parcelable

그래서 어떤 걸 사용하는 게 좋을까? 개발 서적을 볼 때마다 항상 보는 문장이 있는데, 프로그래밍에 정답은 없다는 것이다.

개인적인 견해로는, 요새 하드웨어 성능이 좋으니까 속도가 중요한 상황이라면 Parcelable을, 그렇지 않다면 Serializable을 사용해도 괜찮지 않을까? 저렇게 긴 코드를 작성하는 것보다 핵심 로직에 집중하는 게 더 나을 것 같다.

하지만 Parcelize가 있다!

사실 현재 코틀린에서는 Parcelize를 제공하고 있다. Parcelize는 Serializable의 간편함과 Parcelable의 속도 측면의 장점을 모두 누릴 수 있는 기술로, Parcelable의 구현을 자동으로 생성해준다.

plugins {
    id("kotlin-parcelize")
}
import kotlinx.parcelize.Parcelize

@Parcelize
class User(val name: String, val email: String): Parcelable

위와 같이 Parcelable 인터페이스를 구현하고 @Parcelize 어노테이션을 달면 구현 끝!


참조

개요

보통 인터넷이나 리소스 파일의 이미지 크기는 우리가 보여줄 ImageView의 크기보다 큰 경우가 많다. 만약 원본 이미지를 그대로 ImageView에 로딩한다면, 불필요하게 높은 해상도의 이미지로 메모리를 낭비하게 된다. 게다가, Bitmap은 메모리를 많이 차지하므로 Out Of Memory가 발생할 수도 있다.

 

예를 들어, 129x86 pixel의 썸네일 ImageView에 1024x768 pixel 이미지를 로딩한다고 생각해보자. Glide에서 사용하는 RGB_565 포맷으로 129x86 pixel을 Bitmap 변환하면 129862byte = 22KB가 된다. 그러나 1024x768 pixel은 1.5MB가 된다. 이런 썸네일이 리스트로 사용된다면 꽤 문제가 될 것 같다.

 

따라서, 이미지를 메모리 효율적으로 로딩하려면 크기를 줄여서 Bitmap으로 변환하는 과정이 필요하다.

1. 원본 이미지 크기와 타입 읽기

BitmapFactory는 다양한 이미지 리소스로부터 Bitmap을 생성하는 메서드를 제공한다. decodeByteArray(), decodeFile(), decodeResource()등이 있다. 이러한 메서드로 디코딩을 하면 메모리에 Bitmap이 바로 할당된다.

 

그러나 BitmapFactory 클래스는 이미지의 메모리 할당을 피하면서 크기와 타입 정보를 알 수 있는 기능을 제공한다. BitmapFactory.Options를 사용하면 메타 데이터를 읽고, 수정을 할 수 있다.

inJustDecodeBounds 속성을 true로 설정하고 이미지 데이터를 Bitmap으로 변환하면, Options에 Bitmap 메모리 할당 없이 메타 데이터만 생성된다.

val options: BitmapFactory.Options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(resources, R.id.myimage, options)
val imageHeight: Int = options.outHeight;
val imageWidth: Int = options.outWidth;
val imageType: String = options.outMimeType;

2. 이미지 크기 줄이기

원본 이미지의 크기를 알았으니, ImageView의 면적을 바탕으로 크기를 줄일 것인지 결정할 수 있다.

크기를 줄일 땐 inSampleSize 속성을 사용하는데, 이것은 압축할 정도를 뜻한다. 예를 들어, inSampleSize = 4라면, 4x4개의 pixel을 한 개의 pixel로 합친다는 의미로 메모리는 1/16만큼 줄어든다.

아래는 inSampleSize를 결정하는 코드와 설명이다. 자세한 내용은 Android Developers에서 확인할 수 있다.

코드)

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
}
  1. reqWidth, reqHeight: 요구(ImageView) 크기
  2. inSampleSize = 1로 설정 (최소 값이 1임)
  3. 일단 원본 width, height를 2로 나눈다 ⇒ halfWidth, halfHeight
  4. halfWidth와 halfHeight를 inSampleSize로 나누고, 요구 크기와 비교한다.
    1. 요구 크기보다 크거나 같다면, inSampleSize를 증가시켜도 된다는 뜻이므로 2배 증가시킨 후 4번 과정을 다시 진행한다.
    2. 나눈 너비와 높이 중 요구 크기보다 작은 값이 하나라도 있다면 inSampleSize의 감소를 중단한다.
💡 inSampleSize를 2의 제곱으로 증가시키는 이유는 decoder가 최종적으로 inSampleSize를 2의 제곱으로 반올림해서 사용하기 때문이다.

3. 이미지 resize 및 로딩 전체 코드

imageView.setImageBitmap(
        decodeSampledBitmapFromResource(resources, R.id.myimage, 100, 100)
)

fun decodeSampledBitmapFromResource(
        res: Resources,
        resId: Int,
        reqWidth: Int,
        reqHeight: Int
): Bitmap {
    val options = BitmapFactory.Options()
    options.inJustDecodeBounds = true
    BitmapFactory.decodeResource(res, resId, options)

    // Calculate inSampleSize
    inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
    options.inJustDecodeBounds = false

    // Decode bitmap with inSampleSize set
    return BitmapFactory.decodeResource(res, resId, options)
}

참조

UI 스레드

안드로이드에서 UI의 조작은 메인 스레드에서만 가능하게 되어있다. 왜냐면 여러 스레드에서 동시에 UI를 변경하려고 하면, 결과를 예측할 수 없기 때문이다. 그래서 메인 스레드를 UI 스레드라고 부르기도 한다.

UI 충돌

스레드 통신

멀티 스레드 환경에서 작업 스레드의 결과를 UI에 반영하려면 어떻게 해야 할까? 메인 스레드로 작업을 전달하는 것을 생각해볼 수 있다. 즉, 스레드 간 통신이 필요한데, 이것을 Looper와 Handler를 통해서 해결할 수 있다.

Looper

Looper는 하나의 스레드에 연결되어, 스레드의 MessageQueue에 들어오는 Message를 관리하는 객체를 말한다.

 

Message는 또 뭘까? Message는 스레드가 주고 받는 데이터 조각으로, 작업 내용과 필요한 데이터를 담을 수 있다.

 

Looper는 이름처럼 반복문을 돌며 스레드의 MessageQueue에 Message가 들어오는지 확인한다. Message가 들어오면, Message의 처리를 누군가에게 맡기는데, 그 누군가를 Handler라고 한다. Handler는 말 그대로 Message를 다루는 객체이다.

Handler

Handler는 스레드의 메시지큐에 Message를 적재하고, Looper의 호출로 Message를 처리하는 역할을 한다. Handler는 오직 하나의 스레드와 메시지큐에 연결된다.

스레드 통신 과정

스레드 통신 과정 (메인 스레드←작업 스레드)

  1. 작업 스레드에서 결과물을 담은 Message를 생성한다.
  2. 메인 스레드에 연결된 Handler의 sendMeesage(Message)로 메인 스레드에 메시지를 전송한다
  3. Handler가 메인 스레드의 MessageQueue에 Message를 적재한다.
  4. Looper가 Message를 확인한다. Handler.handleMessage(Message)를 호출하여 Handler에게 메시지 처리를 맡긴다.

Message와 Runnable

스레드 간 통신은 Message로 이뤄진다고 했는데, Runnable을 통해서도 가능하다. 즉, Message 전송, Runnable 전송 2가지 방식이 있다. Message는 Handler.sendMessage(Message), Runnable은 Handler.post(Runnable)로 전송한다.

Thread + Looper 생성 방법

1. 기본 Thread 생성, Handler에 Looper 암시적 연결

var handler: Handler? = null
val thread1 = Thread {  // Runnable 익명 객체 구현
    Looper.prepare()
    handler = Handler() // 생성한 스레드의 Looper와 MessageQueue에 암시적으로 연결된다 
    Looper.loop()
}
thread.start()
  • 스레드를 생성하고, Looper.prepare()로 Looper와 MessageQueue를 생성한다.
  • handler = Handler()를 실행하면 Handler를 생성한 스레드의 looper와 MessageQueue에 Handler가 연결된다.
  • Looper.loop()를 실행하면 looper가 MessageQueue를 돌기 시작한다.

이렇게 하면 외부 스레드에서 handler를 통해 thread1에게 메시지를 보낼 수 있다.

하지만 위 Handler->Looper 연결법은 암시적인 방법이라 deprecated 되었다. 

2. HandlerThread 생성, Handler에 Looper 명시적 연결

val thread2 = HandlerThread("Handler Thread2")
var handler = Handler(thread2.looper)
  • HandlerThread는 Looper를 기본적으로 탑재하고 있는 스레드이다.
    • Thread는 Looper를 갖지 않을 수도 있다. 통신이 필요 없는 스레드도 있기 때문이다. 1번 예제의 Looper.prepare()가 Looper를 생성하는 과정이다.
  • 외부에서 Handler를 만들 때 명시적으로 thread2의 Looper를 전달하여 명시적으로 Handler를 Thread2에 연결하고 있다.

3. 작업 스레드에서 메인 스레드용 Handler 생성

val thread3 = Thread {
    val handler = Handler(Looper.getMainLooper())
    handler.post { 
        // Runnable 
    }
}
thread3.start()
  • 작업 스레드에서 메인 스레드에 작업을 전달하기 위한 Handler를 생성했다.
  • Looper.getMainLooper(): 메인 스레드의 Looper를 가져온다.
  • handler.post(Runnable): 메인 스레드에 실행 블록을 전송한다.

참조

+ Recent posts