scope 함수는 특정 객체에 대한 일시적인 스코프(코드 블록)를 생성해서 그 객체와 관련된 작업을 수행하는 함수이다.

목적은 코드를 더 간결하게 만드는 것에 있다.

사용 예시

아래 코드는 스코프 함수인 let을 사용하고 있다.

Person("Alice", 20, "Amsterdam").let {
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}

살펴보면, 객체의 확장 함수 형태로 함수 let을 호출하고 있으며, let은 람다를 인자로 받는다.

람다는 수신 객체(Person)를 인자로 받아서 작업을 수행하는 블록이며, it으로 수신 객체에 접근하고 있다.

 

참고로 스코프 함수를 호출할 때 사용하는 객체를 수신 객체, 수신자, context object 등으로 표현하는데, 나는 수신 객체가 편해서 그렇게 부르고 있다.

let을 사용하지 않은 코드

val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)

위 코드는 let을 사용하지 않게 바꾼 것이다. 솔직히 이 예시만 봐선 간결해졌는지 잘 모르겠다. 하지만, alice 객체를 사용하는 작업들을 하나로 묶었다는 점은 확실히 눈에 들어온다!


Scope 함수의 종류

스코프 함수에는 let, run, also, apply, with 5개가 있는데, 함수마다 호출 형식에 약간의 차이가 있다. 그 차이를 아래에 표로 첨부했다. 눈에 잘 안 들어온다면 우선 스킵하고 그 아래의 함수 소개로 고고.

함수 객체 참조 리턴 값  확장 함수인가?
let it 람다 리턴 값
Yes 
run this 람다 리턴 값 Yes
run 없음 람다 리턴 값 NO (객체 없이 호출)
with this 람다 리턴 값 NO (인자로 객체 전달)
apply this 수신 객체 Yes
also it 수신 객체 Yes

함수의 호출 형태에서 구분되는 요소는 3가지이다

  • 객체 참조: 수신 객체의 참조를 this로 하는가, it으로 하는가
  • 리턴 값: 리턴 값이 람다의 반환 값인가, 수신 객체 자체인가?
  • 호출 형태: 확장 함수로 호출하는가, 일반 함수처럼 호출 하는가?

1. let

fun <T, R> T.let(block: (T) -> R): R

let은 객체(T)의 확장 함수 형태로 사용된다. 함수의 인자로 람다 블록을 입력 받는데, 람다는 자기 자신(T)을 받아서 R을 반환하며, 람다 안에서 T는 it으로 접근 가능하다. let은 최종적으로 람다의 반환 값 R을 리턴한다.

chain operation에서 사용

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let { 
    println(it)
}

// vs
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)

위와 같이 chain operation의 끝에서 값을 받아서 최종 결과를 만들어 내는 형태로 사용할 수 있다.

그 아랫 줄의 코드는 같은 상황에서 let을 사용하지 않는 경우이다. 한 줄이라서 어떻게 해도 가독성에 차이는 없어 보이지만, let에 들어갈 코드가 많아진다면 let 사용문이 좀 더 간결하게 느껴질 것 같다.

객체가 non-null 일 경우의 작업에 사용

let을 가장 많이 활용하는 형태이다. nullable 객체에 대해 non-null일 경우에 수행할 작업을 정의할 수 있다.

또한, 블록 안에서 non-null로 casting되어서 !!이나 ? 없이 접근할 수 있다.

val str: String? = "Hello"
val str2: String? = null
val length = str?.let { it.length }
val length2 = str2?.let { it.length }
val length3 = str2?.let { it.length } ?: 0
print(length) // 5
print(length2) // null
print(length3) // 0
  • str가 null이 아니므로, length에는 람다의 결과 값인 str의 length가 할당된다.
  • str2는 null이기 때문에 let 함수가 실행되지 않고 null을 반환한다. elvis(?:) 연산자로 null일 때의 처리를 해줄 수도 있다.

2. with

fun <T, R> with(receiver: T, block: T.() -> R): R

with는 let과 달리 일반 함수 형태로 사용된다. 객체 T를 인자로 받아서 임의의 결과 값 R을 반환하는 람다를 입력 받으며, 함수의 최종 결과로 람다의 반환 값 R을 반환한다.

람다에서 받는 객체 T를 보면 T.() 라고 되어 있는데, 이렇게 받으면 확장 함수와 같이 처럼 this로 T에 접근할 수 있다. 또한, this 없이도 T의 멤버에 접근할 수도 있다.

단순히 함수를 호출하는 경우 or 함수 호출을 그룹화 하는 경우

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("인자 $this로 with를 호출했다.")
    println("이것은 $size개의 원소를 갖고 있다.")
}

with(viewModel) {
	dataA.observe(viewLifecycleOwner) {
			// ... 
	}
	this.dataB.observe(viewLifecycleOwner) {
			// ...
		}
}

공식 문서에 따르면 객체의 메서드의 결과 값이 필요하지 않고 호출만 하는 경우에 사용하길 추천하고 있다.

특히, 객체의 여러 함수를 호출할 때 보기 좋게 모아두는 용도로 사용할 수 있다.

값을 계산하기 위한 helper 객체로 활용하는 경우

val firstAndLast = with(numbers) { 
	"FirstElement is ${first()}, the last is ${last()}"
} 
println(firstAndLast)

어떤 객체를 활용해서 값을 계산하고, 결과 값이 필요할 때 사용할 수 있다.


3-1. run (extension)

fun <T, R> T.run(block: T.() -> R): R

run은 확장 함수 형태로 호출된다. 람다 블록은 자신을 T.()로 입력 받기 때문에 this로 접근 가능하고, 임의의 결과 R을 반환한다.

확장 함수로 호출한다는 점을 제외하면 with와 완전히 같지만, 확장 함수 방식 덕분에 null-safe(?.)호출이 가능하다. (object?.run())

객체를 초기화하고 값을 계산하는 경우

val service = MultiportService("<https://example.kotlinlang.org>", 80)
val result = service.run {
    port = 8080
    query(prepareRequest() + " to port $port")
}

// with을 활용한 같은 코드
val letResult = with(service) {
    port = 8080
    query(it.prepareRequest() + " to port ${it.port}")
}

run은 this로 객체 접근이 가능하고 결과 값을 반환하므로, 객체를 초기화 하고 그것으로 값을 계산하는 상황에서 사용하는 것을 공식 문서에서 권장하고 있다.

그 아래는 with로 같은 상황의 코드를 작성한 것이다. 큰 차이가 없어서 앞으로 뭘 사용해야 가독성이 나아질지 고민될 것 같다. with는 읽었을 때 “T객체로 ~~작업을 합니다” 라는 의미가 있어서 개인적으로 객체를 초기화 하는 내용이 있된다면 run이 조금 더 적절할 것 같다.

3-2. run (not extension)

fun <R> run(block: () -> R): R

run은 수신 객체 없이 함수만 호출하는 한 가지의 형태가 더 있다. 입력 받는 람다는 매개 변수 없이 반환 값 R만 존재한다. 함수의 최종 결과는 람다의 R을 반환한다.

여러 중간 값과 함수 호출을 거쳐서 최종 결과를 계산하는 경우

val hexNumberRegex = run {
    val digits = "0-9"
    val hexDigits = "A-Fa-f"
    val sign = "+-"
    Regex("[$sign]?[$digits$hexDigits]+")
}

위 코드는 Regex 객체를 초기화 하고 있다. 그 과정에서 몇 개의 중간 값들을 통해 최종 객체를 생성하는데, 이런 과정을 run 블록으로 묶어 가독성을 높일 수 있다.


4. apply

fun <T> T.apply(block: T.() -> Unit): T

apply는 확장 함수로 호출되며, 람다에서 수신 객체를 인자로 입력 받는다. 하지만 let, with, run과 다르게 람다에 반환 값이 없고 함수의 최종 결과로 수신 객체 T를 다시 반환한다. 람다의 매개변수 타입이 T.()이므로 this로 수신 객체에 접근한다. ****

객체를 초기화(설정 변경) 하는 경우

val adam = Person("Adam").apply {
    age = 32
    city = "London"        
}

this로 멤버에 쉽게 접근 가능하고 자기 자신을 리턴하기 때문에 객체의 초기화나 설정을 변경할 때 유용하다.


5. also

fun <T> T.also(block: (T) -> Unit): T

apply처럼 확장 함수로 호출되고 수신 객체를 다시 리턴한다. apply와 달리 람다에서 (T)로 수신 객체를 받아서 it으로 접근한다.

객체를 부가적으로 활용해서 다른 작업을 할 때 (ex 함수 인자로 사용)


val numbers = arrayListOf("one", "two", "three")
numbers
    .also { println("add 하기 전에 print: $it") }
    .add("four")

수신 객체를 위한 작업보다 수신 객체를 이용한 다른 작업을 할 때 사용하는 것이 권장된다. 함수의 이름처럼 ***“아, 그리고 ~~한 작업도 함께 해라”***라고 해석할 수 있다. ******


함수별 용도

이 함수는 반드시 이런 상황에서만 써야 해! 이런 건 없지만, 공식 문서에서 권장하는 함수별 이용 상황을 다시 정리해보면 다음과 같다.

  • let
    • non-null 객체에 대한 작업을 수행
    • chain operation의 마지막에 사용하여 가독성 향상
  • with
    • 객체의 함수 호출을 그룹핑 and 결과 값이 필요하지 않을 때
  • run
    • 객체의 설정을 변경하고 그것을 활용한 연산 결과를 반환
    • 하나의 작업이지만 여러 중간 값과 함수 호출이 필요할 때 한 블록으로 모아서 가독성 향상
  • apply
    • 객체의 설정 변경 (ex: 프로퍼티 초기화)
  • also
    • 객체를 타겟팅한 작업이 아니라 객체를 활용한 다른 작업을 수행 (ex 함수 인자로 사용)

참조

의존성 주입(Dependency Injection, DI)이란

의존성 주입이란, 의존성을 클래스 밖에서 생성해서 내부로 전달하는 프로그래밍 방식이다.

의존성은 클래스 A가 클래스 B의 객체를 참조할 때, A가 B에 의존한다 또는 A가 B에 대한 의존성을 갖는다고 한다.

따라서, 의존성 주입은 어떤 클래스의 내부에서 사용되는 객체를 클래스 외부에서 생성해서 전달하는 것이다.


객체를 내부에서 생성할 때 발생하는 문제

왜 굳이 객체를 클래스 안에서 생성하지 않고 밖에서 생성하려는 걸까? 객체를 내부에서 생성하는 예시는 다음과 같다.

class Bread() {
    private val milk: Milk = SeoulMilk()
}

이러한 방식의 문제는 특정한 클래스와 강하게 결합한다는 점이다. 어떤 타입의 구현 클래스가 당장 하나 뿐이면 괜찮을 수도 있다. 하지만 여러 타입의 Milk가 존재한다면? 혹은 앞으로 Milk 타입의 다른 구현 클래스가 추가된다면?

interface Milk
class SeoulMilk() : Milk
class MaeilMilk() : Milk
class KonkukMilk() : Milk

빵집마다 사용하는 Milk가 다를 수 있으므로 Milk마다 별도의 Bread 클래스를 정의해야 할 것이다.

// 내부에서 객체를 생성하는 방식
class SeoulMilkBread() : Bread { ... ]
class MaeilMilkBread() : Bread { ... }
class KonkukMilkBread() : Bread { ... }

하지만 빵을 만들기 위한 재료(의존성)는 Milk 외에도 여러 가지가 있다. 따라서, 이러한 방식은 엄청나게 많은 클래스를 만들게 된다.

반면, 외부에서 의존성을 전달하면 하나의 Bread 클래스를 상황에 따라 재사용 할 수 있게 된다.

// 의존성 주입 (생성자 주입)
class Bread(private val milk: Milk) { 
    // ... 
}

val bread1 = Bread(SeoulMilk())
val bread2 = Bread(MaeilMilk())
val bread3 = Bread(KonkukMilk())

// 프로퍼티(setter) 주입 방식
class Bread() {
	lateinit var milk: Milk
}
val bread = Bread()
bread.milk = SeoulMilk()
bread.milk = MaeilMilk()

의존성 주입의 장점

위에서 살펴본 DI의 장점은 클래스를 재사용 할 수 있다는 것이었다. (클래스 재사용성 or 유연성)

의존할 타입의 구체 클래스의 종류가 하나임이 확실하다면, DI의 이점은 없는 것일까? 당연히 다른 장점이 있다!

1. 생성자의 변경으로부터 보호할 수 있다

class SeoulMilk(val a: T1, val b: T2, val c: T3) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(T1(), T2(), T3())
}

// 생성자 시그니쳐 변경
class SeoulMilk(val a: S1, val b: T2, val c: W1, val d: S2) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(S1(), T2(), W1(), S2())
}

구현 클래스의 생성자는 변경될 가능성이 높다. 따라서, 클래스 안에서 객체를 생성할 경우 생성자가 변경되면 생성 코드를 일일이 수정해야 한다. 사용하는(클라이언트) 클래스가 하나라면 모르겠지만, 여러 클래스에서 의존하고 있다면 매번 함께 변경해줘야 할 것이다.

반면, DI를 사용하면 객체를 생성하는 코드를 별도의 클래스에서 관리한기 때문에 생성 코드를 한 번만 변경하면 된다. DI 프레임워크를 사용하면 변경하지 않아도 되는 경우도 있다.

2. 단위 테스트가 가능하다.

단위 테스트를 할 때는 특정 시나리오에서 클래스가 잘 동작하는지 확인하기 위해 테스트 용 임시 객체를 사용한다. 그런데, 객체를 내부에서 생성하면 테스트 환경에서 만든 임시 객체를 사용할 수 없다. 따라서, 클라이언트 클래스를 인터페이스에 의존하게 하고 외부에서 구현체를 주입하도록 바꾸면, 테스트 용 객체를 전달해서 단위 테스트를 할 수 있게 된다.

3. 객체 생성 코드가 분리되어 클래스가 간결해진다 (부가적인 요소)

Bread와 Milk 예시에서는 생성 방식이 간결하지만, 실제 개발 과정에서는 객체 생성 코드가 복잡한 경우도 있다. 이럴 때, 객체의 생성과 사용 코드를 분리하면 클라이언트 클래스의 코드 간결해진다. 하지만, 이런 DI 적용을 고려할 때 장점이 이것 하나인 상황이라면, DI를 굳이 적용하지 않아도 된다고 생각한다.


장점 정리

  • 클래스를 재사용 할 수 있다. (재사용성, 유연성)
  • 단위 테스트를 할 때 훨씬 편하다 (테스트 편의성)
  • 구현 클래스의 변경으로부터 보호할 수 있다. (ex: 생성자 시그니처 변경)
  • 객체 생성 코드가 분리되어 클래스가 간결해진다 (부수적 장점)

참조

익숙한 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)
}

참조

+ Recent posts