쾌락코딩

How The Android Image Loading Library Glide and Fresco Works?(번역)

|

원본 - How The Android Image Loading Library Glide and Fresco Works?

저는 오늘 어렵게 슥듭한 지식을 공유해보려합니다.

지식은 그것을 갈망하는 자에게 온다.

따라서 아주 주의 깊게 글을 읽어주세요. 개발자들은 커피를 좋아하죠. 커피 한 잔들 하시고 시작해봅시다.

시작

안드로이드에서 이미지(bitmaps)을 다루는 일은 메모리 부족 현상(OOM)을 일으킬 수 있어 매우 까다로운 작업입니다. OOM은 안드로이드 개발자에게 가장 큰 악몽과도 같죠.

다행히도 우리 모두는 open-source 세상에 살고있습니다. 저도 오픈소스를 굉장히 사랑합니다. 거의 모든 것을 Android 커뮤니티에서 얻고 있는 만큼, 저도 제 지식을 여러분께 공유하는 것을 좋아합니다.

아무튼, 이제 ImageView에 이미지를 로딩하는 과정에서 마주치는 문제들을 살펴봅시다.

  • Out of memory(OOM) 에러
  • 느린 로딩 문제
  • UI의 반응이 멈추는 현상. 부드로운 스크롤이 되지 않는 현상

우리는 이런 현상을 심각하게 받아들여야만 합니다. 그리고 이런 문제들은 GlideFresco같은 라이브러리를 사용하여 해결할 수 있습니다.

저는 이런 이슈들을 마주칠 때 마다 잠을 설치곤 했습니다. 그러다가 어느날 안드로이드에 library라는 단어를 알게되었는데요, 예전에는 안드로이드에 라이브러리를 쓸 수 있다는 사실조차 알지 못했습니다. 저는 라이브러리가 무엇인지에 대해 읽었고, 이미지 로딩을 위해 Glide라는 라이브러리를 알게 되었죠.

이게 어떻게 문제들을 해결하는지 하나씩 알아봅시다.

Out of memory error

가장 큰 악몽이죠. 이를 해결하기 위해 Glide는 다운샘플링이란 것을 해줍니다. 다운샘플링은 bitmap(image)을 실제 view가 요구하는 사이즈로 줄여주는 것을 의미합니다. 예를 들어 우리에게 20002000 사이즈 이미지가 있다고 해봅시다. view size는 400400입니다. 이때 Glide는 20002000이미지를 로딩하는게 아니라, 다운 샘플링하여 400400으로 만들고 이를 view에 보여주는 방식입니다.

GlideApp.with(context).load(url).into(imageView);

Glide는 imageView를 파라미터로 받기 때문에 imageView의 사이즈를 알 수 있습니다.

Glide는 원본 이미지 전체를 메모리에 로딩하지 않고 다운 샘플링을합니다. 이를 통해 bitmap은 메모리를 적게 차지하고, OOM문제는 해결됩니다. 행복하네요.

Slow loading

느린 로딩은 bitmap을 view에 로딩할 때 생기는 또 다른 문제입니다. 주된 원인은 view가 window를 벗어났음에도 불구하고 다운로딩이나 bitmap을 디코딩하는 작업들을 취소하지 않기 때문입니다. 따라서 더이상 필요하지 않은 작업들이 계속해서 불필요하게 동작하는 것이죠. Glide는 이것도 해결해줍니다. 적절히 불필요한 작업들을 취소하고 오직 사용자에게 보여지는 image만 로딩합니다. 이게 이미지를 빠르게 로딩해주는 비결입니다.

Glide는 Activity와 fragment의 lifecycle을 알고 있습니다. 이를 통해 어떤 이미지들이 취소되어야 하는지를 알 수 있게 됩니다.

다른 방법으로는 메모리 캐시를 만드는 방법인데요, 이를 통해 매번 bitmap을 디코딩할 필요가 없어집니다. Glide는 설정가능한 사이즈의 캐시를 만들어서 bitmap을 캐싱합니다.

캐싱에는 두 가지 레벨이 있습니다.

  1. 메모리 케시
  2. 디스크(Disk) 캐시

Glide에 URL을 넘기면, 아래와 같은 일이 벌어집니다.

  1. memory에 URL key에 해당하는 이미지가 있는지 확인
  2. memory에 캐시된게 있다면 그대로 가져와서 사용
  3. memory에 없다면, disk에 있는지 확인
  4. disk에 있다면 disk로부터 bitmap을 로딩후 memory 캐시로 옮기고, bitmap을 view에 로딩
  5. disk에 없다면, 네트워크에서 이미지를 다운로드하고 disk캐시에 옮긴 후 또 한 번 memory 캐시에 옮김. 그리고 bitmap을 view에 로드.

이런 방법으로 이미지 로딩을 항상 빠르게 작업합니다.

Unresponsive UI

UI가 반응하지 않는 가장 크고 중요한 문제는 main 쓰레드에서 너무 많은 작업이 일어나기 때문입니다. 우리는 UI 랜더링과 관련된 모든 작업은 메인 쓰레드에서 동작해야한다는 사실을 알고 있습니다. 그리고 Android는 UI를 16ms마다 업데이트 하죠. 만약 어떤 작업이 16ms보다 더 걸린다면 android는 그 update를 skip하게 되고 결과적으로 frame이 skip됩니다. frame을 skip하는 것은 초당 frame을 적게 만드는 원인이죠.

대학 시절에는 더 높은 초당 프레임 수(FPS)를 가진 movie clip위해 고군분투 했었습니다. FPS가 높을 수록 더 부드럽게 재생되니까요.

만약 FPS가 낮으면 사용자들은 반응성이 좋지 않은 지연된 UI를 보게 됩니다. bitmap을 로드하는 동안, 심지어는 백그라운드에서 로드하더라도 UI는 지연됩니다. 왜 그럴까요?

그 이유는 bitmap은 사이즈가 너무 크기 때문에 가비지 컬렉터(GC)가 매우 바쁘게 움직이기 때문입니다.

실제로 가비지 컬렉터가 실행되는 동안, 애플리케이션은 동작하지 않습니다.

가비지 컬렉터는 동작할 시간이 필요하고, 동작하는동안 시스템이 여러 frame을 skip하게끔 만듭니다. 따라서 가비지 컬렉터가 범인이죠.

Glide는 이를 어떻게 해결할까요?

Bitmap Pool을 사용하여 해결합니다.

Glide는 bitmap pool 컨셉을 사용하여 가능한 가비지 컬렉터 호출을 최소화 합니다.

Bitmap pool을 사용함으로써 애플리케이션에서 메모리의 지속적인 할당 및 할당 해제를 방지하고, GC 오버헤드를 줄이며 애플리케이션이 원활하게 샐행되도록합니다.

어떻게 메모리의 지속적인 할당 및 해제를 피하는 걸까요?

bitmap의 inBitmap 프로퍼티를 사용합니다(이는 bitmap 메모리를 재사용 합니다).

Re-using Bitmaps (100 Days of Google Dev)

Android 애플리케이션에서 몇 개의 비트맵을 로드해야한다고 생각해봅시다.

bitmap 두 개(bitmapOne, bitmapTwo)를 하나씩 로드해야 한다고 해보죠. bitmapOne을 로드할 때면 bitmapOne을 위한 memory를 할당하게 됩니다. 이후 더 이상 bitmapOne이 필요 없게 되더라도 bitmap을 재활용하지 마세요(재활용에는 가비지컬렉터 호출이 필요하니까요). 대신 bitmapOne을 bitmapTwo의 inBitmap으로 사용하세요. 이런 식으로 동일한 메모리를 bitmapTwo를 위해 재사용할 수 있습니다.

어떻게 동작하는지 코드를 살펴봅시다.inBitmap 프로퍼티를 주의 깊게 살펴보세요.

Bitmap bitmapOne = BitmapFactory.decodeFile(filePathOne);
imageView.setImageBitmap(bitmapOne);
// lets say , we do not need image bitmapOne now and we have to set // another bitmap in imageView
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(filePathTwo, options);
options.inMutable = true;
options.inBitmap = bitmapOne;
options.inJustDecodeBounds = false;
Bitmap bitmapTwo = BitmapFactory.decodeFile(filePathTwo, options);
imageView.setImageBitmap(bitmapTwo);

bitmapTwo를 디코드하는 동안 bitmapOne의 매모리를 재사용하고 있습니다.

이런 방법으로 더이상 bitmapOne를 참조하지 않음에도 가비지 컬렉터가 호출되지 않게 할 수 있습니다. 오히려 bitmapTwo가 bitmapOne이 사용했던 메모리를 그대로 사용할 수 있게 되었죠.

한 가지 중요한 사실은, bitmapOne의 사이즈가 bitmapTwo의 사이즈와 동일하거나 더 커야한다는 것입니다. 그래야 bitmapOne의 메모리가 재사용 될 수 있습니다.

Android 버전에 따라 bitmap 재사용을 할 때 고려해야할 몇 가지 사항들이 있습니다. 이 프로젝트를 참조하시면 좋을것 같네요.

아무튼 Glide는 bitmap을 위해 bitmap pool을 사용합니다.

bitmap pool을 더 이상 필요하지 않은 비트맵들이지만 새로운 비트맵을 위해 재사용 될 수 있는 비트맵 리스트라고 봐도 좋습니다.

어떤 비트맵이라도 재활용될 수 있다면, Glide는 그 비트맵을 bitmap pool에 넣습니다.

Glide가 새로운 bitmap을 로드해야 할 때면, 같은 메모리의 bitmap pool으로부터 재사용 가능한 bitmap을 찾아 가져갑니다. GC가 호출되지 않고, recycling도 없게됩니다.

Fresco 역시 Glide와 같은 일을 합니다. 약간 다를수는 있어도 그 컨셉은 거의 동일해요.

시간내어 긴 글 읽어주셔서 감사합니다.

RecyclerView Anti-Patterns(번역)

|

원문 - RecyclerView Anti-Patterns

안드로이드의 RecyclerView는 기존 ListView를 대체하면서도 굉장히 유용한 first-party 라이브러리입니다. 저는 지금까지 RecycerView의 안티 패턴들을 종종 보았고, adapter 컨셉을 잘 못 이해하고 짠 코드들을 봐왔습니다. 이와 관련된 코드들을 리뷰해본 경험, 그리고 제 후배에게 해준 상세한 설명을 바탕으로, 이와 관련된 내용을 여러분께 공유하고자 합니다. 이는 안드로이드 개발자라면 꼭 알아야만 하는 컨셉입니다.

석기 시대

RecyclerView 내부에서 일어나는 일들을 파악하기 위해서는, RecyclerView 없이 그렇게 동작하도록 구현해보면 됩니다. 아마도 BaseAdapter를 상속한 어떤 클래스를 구현해 보았다면 아래와 같은 코드를 보신적 있을거에요. 예를 들어, 커스텀 Spinner Adapter같은거 말이죠. 오직 TextView 하나만 보여주는 Adapter 구현을 살펴볼까요?

class ListViewAdapter(context: Context) : ArrayAdapter<Data>(context, R.layout.item) {

    //..Other overrides

    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val itemAtPosition = getItem(position)!!
        //Inflate
        var itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)

        //Bind
        val tvText = itemView!!.findViewById<TextView>(R.id.textView)
        tvText.text = itemAtPosition.text

        return itemView
    }
}

잘 동작하는 코드이긴 한데요, 혹시 잘못된 점이 보이시나요? 잘 보시면 매번 view를 inflating하고 있어요. 이는 ListView를 스크롤 할 때 마다 성능에 큰 영향을 미칩니다. 이를 최적화 하기 위해서 Adapter 인터페이스, 특히 getView 메서드를 살펴볼 필요가 있습니다. convertView 파라미터는 nullable이고, 주석은 아래와 같아요.

재사용  수도 있는 old view.
주의: 이것을 사용할 때는 non-null인지 확인해야 하고, 올바른 타입을 확인해야합니다.
만약  view 올바른 데이터를 뿌리도록 변환할  없다면,  메서드는 새로운 view 생성할  있습니다.

“재사용 될 old view”라고 언급되어있습니다. adapter는 사용자 화면 밖으로 나간 view를 재생성하는 게 아니라 재활용(재사용) 합니다. 이는 사용자가 더 부드럽게 스크롤 할 수 있게끔 해줍니다.

Checkout excalidraw!

그러므로 Adpater의 재활용 기능을 사용하도록 코드를 최적화해봅시다. 먼저 convertView가 null인지 아닌지를 확인하고, 오직 null일 때만 view를 inflate합니다. 만약 null이 아니라면, 우리는 재활용된 view를 얻었다는 것이고 inflate해줄 필요가 없어집니다.

val itemView : View
if (convertView == null) {
    itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
} else {
    itemView = convertView
}

이제 우리는 오직 뷰가 재활용 되지 않았을 때에만 infalte합니다. 즉, 처음에만 뷰를 생성해주는 거죠. 전체 코드는 아래와 같습니다.

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    val itemAtPosition = getItem(position)!!
    //Inflate
    val itemView : View

    if (convertView == null) {
        itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
    } else {
        itemView
    }

    //Bind
    val tvText = itemView.findViewById<TextView>(R.id.textView)
    tvText.text = itemAtPosition.text

    return itemView
}

여기서 조금 더 최적화할 수 있습니다. 현재는 모든 아이템마다 findViewById를 사용하고 있습니다. 이것 보다 더 좋은 방법이 있는데요, 이 코드 부분을 view를 inflation 하고 난 직후에만 수행하도록 해봅시다. 이를 위해 나오는 패턴이 바로 ViewHolder 패턴입니다. view의 레퍼런스를 저장할 클래스 하나를 만들어 볼게요.

inner class ViewHolder {
  lateinit var tvText : TextView
}

그리고 View의 setTag 함수를 사용할겁니다. 뷰가 처음 생성될 때, 새로운 ViewHolder 객체를 만들고, item vie의 tag에 ViewHolder를 할당합니다. 다음번에 view가 재사용 될때는, 단지 tag를 가져와서 ViewHolder 타입으로 형변환을 해주면됩니다. 자, 이제 우리는 오직 inflation이 처음 일어날 때만 findViewById를 수행하게 되었습니다.

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    val itemAtPosition = getItem(position)!!

    //Inflate
    val viewHolder : ViewHolder
    val itemView : View

    if (convertView == null) {
        itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)

        viewHolder = ViewHolder()
        viewHolder.tvText = itemView.findViewById(R.id.textView)

        itemView.tag  = viewHolder
    } else {
        itemView = convertView
        viewHolder = itemView.tag as ListViewAdapter.ViewHolder
    }

    viewHolder.tvText.text = itemAtPosition.text

    return itemView
}

여기서 우리는, 조금더 실수를 방지하도록 코드를 수정할 수 있는데요, findViewById 로직을 ViewHolder 내부로 옮기고, tvTex를 불변값으로 선언합니다.

inner class ViewHolder(val itemView: View) {
    val tvText : TextView = itemView.findViewById(R.id.textView)
}
//In getView, use as follows:
viewHolder = ViewHolder(itemView)

이렇게 하면, 만약 tvText 할당을 까먹었더라도 에러를 내뿜지 않겠죠. 이렇게 해서 Adatper가 뷰를 재사용하고, inflation을 방지하며, findViewById를 매번 호출하지 않는 코드를 만들어보았습니다. 그러나 최적화 이점을 얻기 위한 이 모든 과정이 쉽지만은 않습니다. ListView를 만들 때 마다 이와같은 로직을 다시 작성해야할텐데, 이는 명백히 boilerplate입니다. 따라서 이를 쉽게 작성하도록 추상 클래스를 작성할 수도 있습니다.

abstract class AbstractListViewAdapter<T: Any,VH : AbstractListViewAdapter.ViewHolder>(
    context: Context,
    resId: Int
) : ArrayAdapter<T>(context, resId) {

    abstract class ViewHolder(val itemView: View)

    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val itemAtPosition = getItem(position)!!

        //Inflate
        val viewHolder: VH
        val itemView: View

        if (convertView == null) {
            viewHolder = onCreateViewHolder(parent, getItemViewType(position))
            itemView = viewHolder.itemView
            itemView.tag = viewHolder
        } else {
            itemView = convertView
            viewHolder = itemView.tag as VH
        }

        onBindViewHolder(viewHolder, position)

        return itemView
    }

    abstract fun onBindViewHolder(viewHolder: VH, position: Int)

    abstract fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH

}

이 추상 클래스는 실제로 RecycerView.Adapter가 하는 일의 일부분입니다. RecyclerView는 우리가 ListView adatper를 위해 작성했던 boilerplate 코드들을 모두 다룹니다. 물론 RecyclerView는 그것보다 더 많은 일을 하지만, 오늘은 더 많은 것을 다루진 않을 예정입니다.

청동기 시대

컨셉은 알았으니, 이제 RecyclerView의 anti pattern을 설명해드릴게요. 첫 번째는 view를 완전히 재사용하지 않는 다는 것입니다. 예를들어 아래의 코드를 살펴볼게요.

class RecyclerViewAdapter(
    private val onItemClick : (Data) -> Unit
) : RecyclerView.Adapter<RecyclerViewAdapter.MyViewHolder>() {

    //..Other overrides
    private val itemList: List<Data> = //...DO STUFFS

    inner class MyViewHolder(val itemView: View) : RecyclerView.ViewHolder(itemView) {
        val tvText : TextView = itemView.findViewById(R.id.textView)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
        return MyViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        val itemAtPosition = itemList[position]
        holder.tvText.text = itemAtPosition.text
        holder.tvText.setOnClickListener {
            onItemClick(itemAtPosition)
        }
    }

}

여기서 재활용 할 수 있는 부분이 어디일까요? 바로 OnClickListener입니다. 현재는 매번 새로운 listener를 설정해주고 있는데요, 만약 ViewHolder 내부 초기화 시기에 이를 설정하거나, onCreateView에서 하면 어떨까요. 그럼 오직 한 번만 실행될 것이고 나중에 재활용할 수 있을 것입니다. 그리고 데이터 클래스 전체를 콜백으로 내보내는 대신, 오직 ViewHolder의 position만 리턴할 수 있습니다. 이렇게 하여 ViewHolder 밖의 로직이 position으로부터 데이터를 가져올 수 있게 합니다.

inner class MyViewHolder(
    itemView: View,
    private val onTextViewTextClicked: (position: Int) -> Unit
) : RecyclerView.ViewHolder(itemView) {
    val tvText: TextView = itemView.findViewById(R.id.textView)
    init {
        tvText.setOnClickListener {
            onTextViewTextClicked(adapterPosition)
        }
    }
}

호출자에게 itemList를 노출하여 itemList[index]와 같이 사용하도록 하는 대신에, adapter 내부의 로직을 캡슐화 하겠습니다. adapter는 adapterPosition을 가지고 그 위치에 맞는 데이터로 변경할 수 있는 item list를 이미 알고 있습니다. 이를 통해 다른 콜백 함수를 노출시키고 호출자로 데이터를 리턴합니다.

//onItemClick is a parameter in Adapter constructor
private val onTextViewTextClicked = { position: Int ->
    onItemClick.invoke(itemList[position])
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
    val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
    return MyViewHolder(itemView, onTextViewTextClicked)
}

이렇게 하여 OnItemClickListener도 재사용되고 로직은 여전히 adapter 내부에 캡슐화됩니다.

두 번째 anti-pattern은 adapter 내부에 로직을 가지고 있는 것입니다. adapter와 ViewHolder는 오직 ViewHolder를 사용자에게 보여주는 작업만 할 뿐 그 외에 어떤일도 해서는 안됩니다. 로직은 호출자로 떠넘겨야 합니다. 아래는 adapter 내부에 로직이 있는 코드 샘플입니다.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
    val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
    return MyViewHolder(
        itemView = itemView,
        onTextViewTextClicked = { position: Int ->
            val itemAtIndex = itemList[position]
            val intent = getDetailActivityIntent(itemAtIndex)
            parent.context.startActivity(intent)
        })
}

나중에 우리는 같은 UI지만 item을 클릭했을 때 수행되는 액션이 다를 경우가 있을겁니다. 그러나 이런 상태라면 adapter 내부에 로직이 들어가있으므로 같은 UI를 대상으로 adapter를 재사용할 수가 없겠죠. 따라서 우리는 callback/interface로 로직을 노출시켜야 합니다. 만약 여러개의 view를 위해 interface/callback이 여러개 필요하다면, 코드를 더욱 더 서술적으로 표현하여 이를 유지보수하는 분들이 감사하도록 해주세요.

//Which is more descriptive
//Which one shows you all the possible interactions at a first glance?

class RecyclerViewAdapter(
    private val onAddClick: (itemAtIndex: Data) -> Unit,
    private val onRemoveClick: (itemAtIndex: Data) -> Unit,
    private val onItemClick: (itemAtIndex: Data) -> Unit
)

class RecyclerViewAdapter(
    private val onItemViewClick: (clickedViewId: Int, itemAtIndex: Data) -> Unit
)

황금기

세 번째 anti-parttern은 ViewHolder 내부의 view 상태를 직접 변경하는 것입니다. 예를 들어, CheckBox의 상태를 변경하는 ViewHolder를 살펴봅시다.

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    //Note: checkbox clickable is set to false to control the logic ourselves
    holder.itemView.setOnClickListener {
        //Toggle
        holder.checkBox.isChecked = holder.checkBox.isChecked.not()
    }
}

이렇게 작성하고 100개의 아이템을 밀어 넣은 후, 처음 두 세개의 아이템을 check한 뒤 아래로 스크롤 하면, 다른 포지션에 있는 아이템들은 check하지 않았음에도 check되어있는 것을 확인할 수 있습니다. 이는 다시한 번 말하지만 view가 재사용되기 때문에 일어나는 현상입니다. check된 view가 재사용될 때, check된 채로 나타납니다. 따라서 우리는 항상 onBindViewHolder에서 다시 binding을 해줘야만해요. 그리고 우리는 data class의 isChecked 값의 기본값을 false로 설정함으로써 문제를 해결할 수 있습니다.

data class Data(
    val text: String,
    val isChecked: Boolean = false
)

onBind 메서드에서 값을 다시 바인딩 해줍니다.이

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    holder.checkBox.isChecked = itemList[position].isChecked
    holder.itemView.setOnClickListener {
        holder.checkBox.isChecked = holder.checkBox.isChecked.not()
    }
}

이제 다시 테스트해봅시다. 데이터를 밀어넣고, 몇 개를 check하고, 스크롤을 내리면 제대로 동작 합니다. 그러나 스크롤을 다시 올리면, check된 모든 아이템들의 상태가 다 날라가버립니다! 이는 data class 내부의 isChecked 상태가 변경된게 아니기 때문입니다. 여전히 기본값인 false인채로 남아있어요. RecyclerView를 스크롤 할 때, 데이터를 다시 재활용된 view에 바인딩 하기위하여 onBind 메서드가 실행됩니다. 위 코드에서는, check된 상태가 전부 false로 대체되네요. 이를 해결하기 위해 adapter 코드를 아래와 같이 수정해봅시다.

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    val itemAtPosition = itemList[position]
    holder.checkBox.isChecked = itemAtPosition.isChecked

    holder.itemView.setOnClickListener {
        itemList[position] = itemAtPosition.copy(
            isChecked = itemAtPosition.isChecked.not()
        )
        notifyItemChanged(position)
    }

}

이렇게 하면 재활용된 상태 문제를 해결할 수 있습니다. 이제 우리는 앱에 database를 추가하고 사용자가 “저장”버튼을 클릭할 때, database에 어떤 아이템이 check되었는지를 영구 저장할 수 있습니다. 따라서 우리는 itemList를 public 변수로 만들어 다른 클래스가 접근할 수 있게 해줄 수 있습니다. 예를 들어 “저장” 버튼이 눌리면 fragment가 saveToDb(adapter.itemList)를 호출하는 식으로요. 그리고 사용자는 한 번에 모두 선택하기와 모두 해제하기 같은 기능을 원할 수 도 있습니다. 따라서 함수 두 개를 추가해볼게요: adapter 내부의 unSelectAll과 selectAll

fun unselectAll() {
    itemList.map {  data->
        data.copy(isChecked = false)
    }
    notifyDataSetChanged()
}
fun selectAll() {
    itemList.map { data ->
        data.copy(isChecked = true)
    }
    notifyDataSetChanged()
}

오직 상태가 변경된 경우에만 notify함으로써 로직을 개선할 수 있습니다.

fun unselectAll() {
    itemList.mapIndexed { position, data ->
        if (data.isChecked) {
            notifyItemChanged(position)
            data.copy(isChecked = false)
        }
    }
}
fun selectAll() {
    itemList.mapIndexed { position, data ->
        if (!data.isChecked) {
            notifyItemChanged(position)
            data.copy(isChecked = true)
        }
    }
}

만약 지금 상태에서 처음 adapter코드를 본다면, 아마도 너무 많은 것들이 포함되어있다고 생각하실 겁니다. 만약 양쪽으로 가는 pagination 기능이 추가되거나, 나중에 아이템 삭제/숨김 기능 혹은 그 이상 다른 기능들이 추가되면 어떨까요? adapter가 하는 일은 단지 ViewHolder를 바인딩 할 뿐 데이터의 상태를 제어해선 안됩니다. 우리의 bindView는 현재 로직을 가지고 있어요! 우리는 adapter를 가능한 추상적으로 만들어야 함을 잊어선 안됩니다. 추상화시키는 한 가지 방법은 선택, 선택 해제, 추가, 삭제 및 기타 여러 로직들을 presenter나 ViewModel로 떠넘기는 것입니다. 그렇게 해서 adapter는 오직 사용자가 아이템을 업데이트하고 싶을 때마다 넘겨주는 item list를 받기만 하면 됩니다. 리팩토링을 한 이후 우리의 adapter는 아래와 같습니다. 재사용이 가능해졌고, 어떤 로직도 가지고 있지 않으며, itemList는 불변 List로 설정되어 내부에서 변경이 불가능하게 만들어졌습니다. 이제 상태를 변경하는 방법은 오직 새로운 list(상태가 변경된)를 받는 것 뿐입니다.

class RecyclerViewAdapter(
    val onCheckToggled: (position: Int, itemAtPosition: Data) -> Unit
) : RecyclerView.Adapter<RecyclerViewAdapter.MyViewHolder>() {

    //..Other overrides
    private var itemList: List<Data> = listOf<Data>()

    fun submitList(itemList: List<Data>) {
        this.itemList = itemList
        notifyDataSetChanged()
    }

    inner class MyViewHolder(
        itemView: View,
        onItemClick: (position: Int) -> Unit
    ) : RecyclerView.ViewHolder(itemView) {

        val checkBox: CheckBox = itemView.findViewById(R.id.checkBox)

        init {
            checkBox.setOnClickListener {
                onItemClick(adapterPosition)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
        return MyViewHolder(
            itemView = itemView,
            onItemClick = { position ->
                val itemAtPosition = itemList[position]
                this.onCheckToggled(position, itemAtPosition)
            }
        )
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        val itemAtPosition = itemList[position]
        holder.checkBox.isChecked = itemAtPosition.isChecked
    }

}

하지만 현재 adapter는 item 변경에 대해 충분히 notify하고 있지 않습니다. 위치가 변경되었을 때 마다 매번 전체 list를 다시 바인딩하고 싶지 않아요. 만약 우리가 추가, 삭제, 변경을 모두 구분할 수 있으면 괜찮지 않을까요?

fun submitList(newList: List<Data>) {
    val oldList = this.itemList

    val maxSize = Math.max(newList.size, oldList.size)
    for (index in 0..maxSize) {
        val newData = newList.getOrNull(index)
        val oldData = oldList.getOrNull(index)

        if (newData == null) {
            notifyItemRemoved(index)
            return
        }

        if (oldData == null) {
            notifyItemInserted(index)
            return
        }

        if (newData != oldData) {
            notifyItemChanged(index)
            return
        }
    }
}

위 코드는 아주 간단한 diffing(구별을 위한) 메서드 입니다. 실제로는 새로운 아이템이 기존 리스트 사이에 들어와서, 기존의 아이템이 단지 위치만 바뀐 경우도 고려해야합니다. 그리고 이 코드는 main thread에서 diffing(구별)하고 있기 때문에 비효율적입니다. 100개의 아이템이 있다면, main thread에서 100번의 반복문을 수행한다는 의미니까요.

이런 boilerplate와 diffing을 간단히 하기 위해서 ListAdatper가 등장했습니다. ListAdapter는 ListView를 위한 adapter는 아니구요, 오히려 RecyclerView.Adapter를 확장한 adatper입니다. ListAdatper 생성자는 DiffUtil.ItemCallBack 또는 AsyncDifferConfig를 받습니다. 대부분은 DiffUtil.ItemCallBack만으로 충분합니다.

class RecyclerViewAdapter(
    val onCheckToggled: (position: Int, itemAtPosition: Data) -> Unit
) : ListAdapter<Data, RecyclerViewAdapter.MyViewHolder>(
     object: DiffUtil.ItemCallback<Data>() {

        override fun areItemsTheSame(oldItem: Data, newItem: Data): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Data, newItem: Data): Boolean {
            return oldItem == newItem
        }

    }
)

areItemsTheSame 이 true를 반환하면 areContentsTheSame을 수행합니다. 만약 item이 서로 같고, 내용물(content)도 같다면, 이는 변화가 없다는 것을 의미하죠. 한편 item이 서로 같지만, 내용물이 다르다면, 이는 변경으로 간주합니다. 그러나 item이 서로 다를 경우, 이는 아이템이 추가되었거나, 삭제되었거나, 위치가 변경되었을 경우입니다. 이게 모든 diffing 메서드를 수면 아래로 숨겨주는 것이죠. 추가적으로, 이 모든 작업이 백그라운드 thread에서 수행됩니다. 그저 새로운 list를 submitList 함수로 넘겨주면 boilerplate 로직을 다 수행해줍니다. 혹시 관심있으시다면 AsyncListDiffer 내부 로직을 확인하실 수 있어요. 대부분의 경우, state를 ViewModel이나 Presenter에 유지하는게 좋기 때문에, 이를 강제하는 ListAdapter를 사용하는게 좋습니다. 게다가 라이브러리가 당신을 위해 대부분의 일을 해주니까 굉장히 편합니다.


어떻게 Adapter의 API가 ArrayAdatper로 시작하여 ListAdapter로 진화되어왔는지 이해하셨기를 바랍니다. 아직 ListAdapter를 사용해 보시지 않으셨다면 꼭 써보시길 추천드리구요, 이때까지 얼마나 많은 작업들을 손수 해왔는지, 이를 사용함으로써 코드 퀄리티가 얼마나 향상되는지를 느껴보시기 바랍니다.

7 common mistakes you might be making when using Kotlin Coroutines(번역)

|

원본 - 7 common mistakes you might be making when using Kotlin Coroutines

개인적으로 코틀린 코루틴은 비동기, 동시성 코드 작성을 매우 쉽게 해준다고 생각합니다. 하지만 코루틴을 사용하는 많은 개발자들이 흔히들 하는 실수 몇 가지들이 있더라구요.

일반적인 실수 #1: 코루틴을 launch할 때 새로운 job 객체를 생성한다.

때때로 코루틴을 취소하는 등, 코루틴을 다루기 위해 job이 필요합니다. launchasync 코루틴 빌더 모두 job을 파라미터로 받기 때문에, 새로운 job 객체를 생성하고 launch{}와 같은 코루틴 빌더의 파라미터로 넘기는 것을 생각하기 쉽습니다. 이렇게 할 경우 job에 대한 레퍼런스를 가지게 되고, 추후에 .cancel()같은 메서드를 호출할 수 있겠죠.

fun main() = runBlocking {

    val coroutineJob = Job()
    launch(coroutineJob) {
        println("performing some work in Coroutine")
        delay(100)
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine was cancelled")
        }
    }

    // cancel job while Coroutine performs work
    delay(50)
    coroutineJob.cancel()
}

코드는 문제없어 보입니다. 코드를 돌려보면 코루틴은 잘 취소되었음을 알수 있습니다.

>_

performing some work in Coroutine
Coroutine was cancelled

Process finished with exit code 0

그러나 이번엔, CoroutineScope안에서 코루틴을 실행해보고, Coroutine의 Job이 아닌 이 scope를 취소시켜 볼게요.

fun main() = runBlocking {

    val scopeJob = Job()
    val scope = CoroutineScope(scopeJob)

    val coroutineJob = Job()
    scope.launch(coroutineJob) {
        println("performing some work in Coroutine")
        delay(100)
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine was cancelled")
        }
    }

    // cancel scope while Coroutine performs work
    delay(50)
    scope.cancel()
}

scope가 취소될 경우 scope안의 모든 코루틴은 취소되어야만 합니다. 하지만, 위 코드를 실행시켜보면 그렇게 동작하지 않아요.

>_

performing some work in Coroutine

Process finished with exit code 0

“Coroutine was cancelled”는 로그에 찍히지 않습니다.

왜 그럴까요?

코루틴은 비동기, 동시성 코드를 안전하게 제공하기 위해 “구조적 동시성(Structured Concurrency)”라고 불리는 특징을 가집니다. “구조적 동시성” 메커니즘 중 하나는 scope가 취소되면 CoroutineScope의 모든 코루틴이 취소된다는 것입니다. 이 메커니즘이 제대로 동작하기 위해서는 Scope의 Job과 Coroutine의 Job 사이의 계층이 잘 형성되어있어야 합니다. 아래 이미지 처럼요.

image

그러나 아까 본 예시에는 예상치 못한 일이 일어납니다. 별개의 Job 객체를 launch()빌더에 넘김으로써, 이를 해당 코루틴과 무관한 Job으로 정의하게 된것입니다. 대신, 이는 새로운 코루틴의 parent job이 됩니다. parent job은 Coroutine ScopeJob이 아니며 단지 새롭게 객체화된 Job 객체일 뿐입니다.

따라서, 이 코루틴의 job은 더이상 CoroutineScopejob과 연관되지 않습니다.

image

우리는 구조적 동시성을 무너뜨렸고 그 결과 scope를 취소하더라도 더 이상 코루틴은 취소되지 않게 되는 것이죠.

이 문제의 해결법은 간단히 launch{}가 반환하는 job을 사용하여 코루틴을 제어하는 것입니다.

fun main() = runBlocking {
    val scopeJob = Job()
    val scope = CoroutineScope(scopeJob)

    val coroutineJob = scope.launch {
        println("performing some work in Coroutine")
        delay(100)
    }.invokeOnCompletion { throwable ->
        if (throwable is CancellationException) {
            println("Coroutine was cancelled")
        }
    }

    // cancel while coroutine performs work
    delay(50)
    scope.cancel()
}

이렇게 하면, Scope를 취소했을 때 Scope내의 모든 코루틴은 잘 취소됩니다.

>_

performing some work in Coroutine
Coroutine was cancelled

Process finished with exit code 0

일반적인 실수 #2: 잘못된 SuperviserJob 사용

때때로 아래와 같은 이유 때문에 SupervisorJob을 사용하고 싶을 겁니다.

  1. 예외(exception)가 job 계층 전반에 퍼지는 것을 막기 위해
  2. 코루틴중 하나가 실패할 때 형제(sibling) 레벨의 코루틴들의 취소를 막기 위해

launch{}async{} 코루틴 빌더는 Job을 파라미터로 받기 때문에, SupervisorJob을 이 코루틴 빌더의 파라미터에 넘기자고 생각할 수 있습니다.

launch(SupervisorJob()){
    // Coroutine Body
}

그러나, 실수#1과 동일하게, 구조적 동시성의 취소(cancellation) 메커즘을 망가뜨리는 행위입니다. 이는 supervisorScope{} scoping 함수를 사용하여 해결할 수 있습니다.

supervisorScope {
    launch {
        // Coroutine Body
    }
}

일반적인 실수 #3: cancellation을 지원하지 않음

suspend 함수 내부에서 factorial number 계산처럼 무거운 작업을 수행하고 싶다고 가정해봅시다.

// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * n
suspend fun calculateFactorialOf(number: Int): BigInteger =
    withContext(Dispatchers.Default) {
        var factorial = BigInteger.ONE
        for (i in 1..number) {
            factorial = factorial.multiply(BigInteger.valueOf(i.toLong()))
        }
        factorial
    }

이 suspend 함수는 “협력적 취소(cancellation)”를 지원하지 않는 문제가 있습니다. 이는 실행된 코루틴이 조기에 취소되더라도, 계산이 끝나기 전에는 절대 취소되지 않는다는 것을 의미합니다. 이런 현상을 피하기 위해서 주기적으로 아래 나열된 것들을 잘 사용해야 합니다.

아래에는 ensureActive() 를 사용하여 cancellation을 지원하는 방법이 있습니다.

suspend fun calculateFactorialOf(number: Int): BigInteger =
    withContext(Dispatchers.Default) {
        var factorial = BigInteger.ONE
        for (i in 1..number) {
            ensureActive()
            factorial = factorial.multiply(BigInteger.valueOf(i.toLong()))
        }
        factorial
    }

코틀린 표준 라이브러리의 suspend 함수들(e.g ‘delay()’)은 모두 cancellation에 협력적입니다. 다만 직접 만든 suspend 함수라면 절대로 cancellation을 잊어선 안됩니다.

일반적인 실수 #4: 네트워크 요청이나 database 쿼리시 dispatcher를 변경한다.

이건 사실 ‘실수’라고 하긴 그렇지만, 분명히 코드를 좀 더 읽기 어렵게하고, 아마도 효율성을 아주 약간 떨어뜨리는 일입니다. 몇몇 개발자들은 네트워크 요청을 위한 Retrofit, 혹은 database연산을 위한 Room을 사용하는 suspend function 호출 전에 백그라운드 dispatcher로 바꿔야 한다고 생각합니다.

사실 이는 불필요한 작업입니다. 모든 suspend 함수는 ‘main safe’ 해야한다는 규칙이 있고, 다행히도 Retrofit과 Room은 이를 잘 지키고 있기 때문입니다. 더 자세한 내용은 여기에 있습니다.

일반적인 실수 #5: try/catch를 사용한 예외 처리

코루틴 예외를 다루기란 힘든 일입니다. 저 역시 이를 이해하기위해 많은 시간을 할애했으며, 다른 개발자들에게 설명하기위해 작성과 발표를 했습니다. 심지어는 이를 요약하여 Cheat Sheet을 만들기도 했죠.

코루틴 예외처리에는 정말 직관적이지 않은 것 하나가 있는데, 그것은 예외로 인해 실패한 코루틴을 try/catch로 잡아낼 수 없다는 것입니다.

fun main() = runBlocking<Unit> {
    try {
        launch {
            throw Exception()
        }
    } catch (exception: Exception) {
        println("Handled $exception")
    }
}

코드를 돌려보시면 예외처리가 되지 않아 충돌이 납니다.

>_

Exception in thread "main" java.lang.Exception

Process finished with exit code 1

코틀린 코루틴은 비동기 코드를 기존 코딩 구조처럼 사용할 수 있도록 보장합니다. 하지만 이 경우에는 사실이 아닌데요, 많은 개발자의 예상과는 달리 전통적인 try-catch 블럭으로 예외를 다룰 수 없습니다. 예외 처리를 하고 싶다면 코루틴 내부에서 직접 try-catch를 사용하던지 혹은 CoroutineExceptionHandler을 사용해야 합니다.

더 자세한 이야기는 앞서 언급한 이 포스팅을 참고해주세요.

일반적인 실수 #6: 자식 코루틴에 CoroutineExceptionHandler 삽입

짧고 간단하게 말하겠습니다. 자식 코루틴을 통해 CoroutineExceptionHandler 를 삽입하는 것은 아무런 효과도 없습니다. 예외 처리는 부모 코루틴에게 전파되기 때문입니다. 따라서 CoroutineScope 에 삽입하던지 혹은 root나 부모 코루틴에 삽입해야만 합니다.

일반적인 실수 #7: CancellationExceptions 캐치

코루틴이 취소될 때, 현재 실행중인 suspend 함수는 코루틴 내에서 CancellationException를 던집니다. 이는 보통 코루틴을 “예외적으로” 완료시키는 행위이며 실행중인 코루틴은 즉각 종료됩니다.

fun main() = runBlocking {

    val job = launch {
        println("Performing network request in Coroutine")
        delay(1000)
        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

0.5초가 지나고 나면 delay() suspend 함수는 CancellationException를 던지고, 코루틴은 예외적으로 완료되며 실행도 종료됩니다.

>_

Performing network request in Coroutine

Process finished with exit code 0

이제는 delay()가 네트워크 요청을 나타낸다고 생각하고, 네트워크 처리 예외를 처리하기 위해 try-catch 블럭으로 감싸서 모든 예외를 캐치해봅시다.

fun main() = runBlocking {

    val job = launch {
        try {
            println("Performing network request in Coroutine")
            delay(1000)
        } catch (e: Exception) {
            println("Handled exception in Coroutine")
        }

        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

catch 구문이 네트워크 실패 HttpExceptions를 캐치해낼 뿐만 아니라 CancellationExceptions까지도 잡아버립니다! 따라서 코루틴은 “예외적으로 완료”되지 않고 계속해도 동작하게됩니다.

>_

Performing network request in Coroutine
Handled exception in Coroutine
Coroutine still running ...

Process finished with exit code 0

이는 디바이스 자원을 낭비하며 때에 따라서는 앱 충돌로 이어질 수 있습니다.

이 문제를 해결하기 위해서는 아래처럼 ``HttpExceptions` 만 캐치하거나,

fun main() = runBlocking {

    val job = launch {
        try {
            println("Performing network request in Coroutine")
            delay(1000)
        } catch (e: HttpException) {
            println("Handled exception in Coroutine")
        }

        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

CancellationExceptions를 다시 던지면 됩니다.

fun main() = runBlocking {

    val job = launch {
        try {
            println("Performing network request in Coroutine")
            delay(1000)
        } catch (e: Exception) {
            if (e is CancellationException) {
                throw e
            }
            println("Handled exception in Coroutine")
        }

        println("Coroutine still running ... ")
    }

    delay(500)
    job.cancel()
}

이렇게 해서 코틀린 코루틴을 사용할 때 흔히 하는 일반적인 실수 7개를 살펴보았습니다. 만약 더 많은 실수들을 알고 계시다면 댓글로 알려주세요!

그리고 다른 개발자분들이 같은 실수를 반복하지 않도록 이 포스팅을 많이 공유해주세요. 감사합니다!

Coroutine suspend function - when does it start, suspend or terminate?(번역)

|

원본 - Coroutine suspend function: when does it start, suspend or terminate?

코루틴 라이프사이클을 잘 컨트롤하는 방법 알기

거의 3년이란 시간동안 코루틴을 잘 이해하기위해 suspend function이 정확히 무엇인지를 탐구해왔습니다.

Understanding suspend function of Kotlin Coroutines

그러나 아직 답을 내지 못한 질문이 있었는데, 언제 그게 시작되고, 멈추고(suspend), 종료되는가?라는 질문입니다. 이것을 알고 코루틴을 사용하면 보다 더 정확하게 사용할 수 있습니다.

언제 suspend되는가?

suspend 함수를 다루는 글이니까 suspension 지점부터 살펴볼게요.

suspend 재진입

아래와 같이 delay와 함께 launch를 사용하는 코드가 있습니다.

fun launchWithDelay() {
    runBlocking {
        launch {
            println("First start")
            delay(200)
            println("First ended")
        }

        launch {
            println("Second start")
            delay(300)
            println("Second ended")
        }
    }
}

첫 번째 코루틴이 delay를 만나게 되면 이 함수는 일시중지(suspend)되고, 두 번째 코루틴이 진행됩니다.

따라서 결과는 아래와 같습니다.

1st start
2nd start
1st end
2nd end

delay없이 suspend

만약 delay를 없앤다면 어떨까요?

fun launchWithoutDelay() {
    runBlocking {
        launch {
            println("1st start")
            println("1st end")
        }

        launch {
            println("2nd start")
            println("2nd end")
        }
    }
}

결과는 아래와 같습니다.

1st start
1st end
2nd start
2nd end

보시다시피 더이상 두 함수를 교차 진행하지 않습니다. 코루틴 블럭에 delay가 없다면 일시중지하지 않는것 처럼 보입니다.

yield와 함께쓰는 suspend

만약 일시중지를 하고는 싶은데, delay를 하고싶지는 않다면 어떻게 할까요? yield를 사용하시면 됩니다.

fun launchWithYield() {
    runBlocking {
        launch {
            println("1st start")
            yield()
            println("1st end")
        }

        launch {
            println("2nd start")
            yield()
            println("2nd end")
        }
    }
}

이제 delay를 사용했을 때와 같은 결과를 얻을 수 있습니다.

1st start
2nd start
1st end
2nd end

이쯤해서, yield란 무엇일까요?

다른 코루틴이 실행 가능한 상태라면, 현재 코루틴 디스패쳐의 thread(혹은 thread pool)를 다른 코루틴 에게 양보합니다

이 suspending 함수는 취소 가능합니다. 만약 yield 함수가 호출되었을 때 혹은 이 함수가 dispatch를 기다리고 있는 상황에서 현재 코루틴의 Job이 이미 취소 되었거나 완료되었다면, 이 함수는 CancellationException을 내뿜으며 resume됩니다. 이는 즉각 취소됨을 의미합니다. 만약 이 함수가 일시중지 된 상태에서 job이 취소된다면(yield함수로 인해 일시중단 되어 코루틴을 빠져나온 상태), 성공적으로 resume되지 못합니다.

간단히 말하자면 yield는 다른 suspension 함수로 제어를 넘길 수 있는지 확인하는 check point이며, 코루틴이 취소 되었는지를 확인하는 check point이기도 합니다.

언제 시작는가?

start되는 시점을 살펴봅시다.

  • async-await의 경우, 코루틴은 오직 start 함수가 호출될 때 시작됩니다.
  • runBlocking의 경우, blocking되기 때문에 호출되자 마자 즉시 시작됩니다.
  • launch의 경우는 언제 시작될까요?

Join없이 Start

간단한 예제를 볼게요.

fun startWithoutJoin() {
    runBlocking {
        println("runBlocking start")
        launch {
            println("Launch start")
        }
        println("runBlocking end")
    }
}

결과는 아래와 같습니다.

runBlocking start
runBlocking end
Launch start

luanch는 오직 suspend 함수(여기서는 runBlocking 함수)가 끝이 날때 시작되는 것을 볼수 있습니다.

Join과 함께 Start

조금 더 빨리, runBlocking이 종료되기 전에 시작시키고 싶다면, jobjoin을 호출하면 됩니다.

fun startWithJoin() {
    runBlocking {
        println("runBlocking start")
        val job = launch {
            println("Launch start")
        }
        job.join()
        println("runBlocking end")
    }
}

결과는 아래와 같습니다.

runBlocking start
Launch start
runBlocking end

join에는 한가지 제약이 있습니다. 이 함수는 laucnh가 끝날 때까지 runBlocking 블럭을 block시킵니다. 이는 launch를 다른 쓰레드로 실행했을 때도 동일합니다.

fun startWithJoin() {
    runBlocking {
        println("runBlocking start")
        val job = launch(Dispatchers.IO) {
            println("Launch start")
        }
        job.join()
        println("runBlocking end")
    }
}

Yield와 함께 Start

launch가 시작되기도 하면서 기존 suspend 함수를 block하지도 않으려면, yield를 사용하면 됩니다. 위에서 설명드린것 처럼 yield는 다른 코루틴이 시작되도록 하니까요.

동일한 쓰레드에서 실행할 경우, 2개의 yield가 필요합니다.

fun startWithYield() {
    runBlocking {
        println("runBlocking start")
        launch {
            repeat(3) {
                println("Launch start")
                yield()
            }
        }
        yield()
        println("runBlocking end")
    }
}

runBlocking의 yieldlaunch가 시작되게 합니다. launch안의 yield는 runBlocking이 함수를 종료할 수 있도록 다시 제어권을 넘깁니다.

결과는 아래와 같습니다.

runBlocking start
Launch start
runBlocking end
Launch start
Launch start

그러나 다른 쓰레드에서 launch한다면, runBlocking에 단 하나의 yield만 있으면 됩니다.

fun startWithYield() {
    runBlocking {
        println("runBlocking start")
        launch(Dispatchers.IO) {
            repeat(3) {
                println("Launch start")
                // yield()   // This is not needed anymore
            }
        }
        yield()
        println("runBlocking end")
    }
}

언제 종료되는가?

yield와 같은 함수를 만났을 때만 일시중지(suspend)된다는 점을 고려해봤을 때, 종료(취소)는 어떨까요? 종료는 일지중지와 다르게 즉각 반응할까요? 아니면 yield와 같은 함수가 필요할까요?

CancellationException 알기

취소에 대해서 실습을 해보기 전에, 코루틴이 취소되었다는 것을 알아차리는 방법부터 알아봅시다.

코루틴이 취소되었을 때, CancellationException 이 트리거됩니다.

try-catch-finally 를 사용하여 이를 잡아낼 수 있습니다.

val job = launch {
    try {
        // Coroutine task
    } catch (e: CancellationException) {
        // Cancel is triggered
    } finally {
        // Opportunity to clear up
    }
}

Yield 없이 종료

오래 걸리는 loop작업을 취소시켜 봅시다.

@Test
fun terminateWithoutYielding() {
    runBlocking {
        println("Launching...")
        val job = launch {
            try {
                println("Start looping")
                repeat(2000) {
                    repeat(2000) {
                        repeat(2000) {}
                    }
                }
                println("Done looping")
            } catch (e: CancellationException) {
                println("It is cancelled")
            } finally {
                println("Finally all finish")
            }
        }

        println("Launched...")
        println("Cancelling...")
        job.cancel()
        println("Cancelled...")
    }
}

결과는 아래와 같습니다.

Launching...
Launched...
Cancelling...
Cancelled...

launch가 트리거되기도 전에 취소되었으니 당연한 결과입니다.

하지만 다른 쓰레드에서 launch하게 된다면 상황이 다릅니다.

@Test
fun terminateWithoutYielding() {
    runBlocking {
        println("Launching...")
        val job = launch(Dispatchers.IO) {
            try {
                println("Start looping")
                repeat(2000) {
                    repeat(2000) {
                        repeat(2000) {}
                    }
                }
                println("Done looping")
            } catch (e: CancellationException) {
                println("It is cancelled")
            } finally {
                println("Finally all finish")
            }
        }

        println("Launched...")
        println("Cancelling...")
        job.cancel()
        println("Cancelled...")
    }
}

cancel이 호출될 때, 다른 쓰레드에서 laucnh가 trigger된 상태입니다.

Launching...
Launched...
Cancelling...
Start looping
Cancelled...
Done looping
Finally all finish

그래서 job은 해당 코루틴을 완료하기 전까지 취소되지 않습니다. 그 이유는, 공식 문서에 나와있듯 코루틴 취소에는 협력(cooperation)이 필요하기 때문입니다.

Yield와 함께 종료하기

동일한 쓰레드에서 yield를 사용하여 launch가 실행되게 하고 싶다면 아래와 같이 할 수 있습니다.

fun terminateWithYielding() {
    runBlocking {
        println("Launching...")
        val job = launch {
            try {
                println("Start looping")
                repeat(2000) {
                    repeat(2000) {
                        repeat(2000) {}
                    }
                }
                println("Done looping")
            } catch (e: CancellationException) {
                println("It is cancelled")
            } finally {
                println("Finally all finish")
            }
        }

        println("Launched...")
        yield()
        println("Cancelling...")
        job.cancel()
        println("Cancelled...")
    }
}

여기서는 launch 블락이 runBlocking의 cancel() 호출 전에 시작됨에따라 cancel() 호출의 효력이 없습니다.

Launching...
Launched...
Start looping
Done looping
Finally all finish
Cancelling...
Cancelled...

따라서 launch의 작업이 실행되고 있는 동안 cancel이 호출되지 않았기 때문에 전혀 취소 작업이 일어나지 않습니다.

cancel을 하기 위해서는 launch내부에 yield를 넣으면 됩니다.

@Test
fun terminateWithYielding() {
    runBlocking {
        println("Launching...")
        val job = launch {
            try {
                println("Start looping")
                repeat(2000) {
                    repeat(2000) {
                        repeat(2000) {
                            yield()
                        }
                    }
                }
                println("Done looping")
            } catch (e: CancellationException) {
                println("It is cancelled")
            } finally {
                println("Finally all finish")
            }
        }

        println("Launched...")
        yield()
        println("Cancelling...")
        job.cancel()
        println("Cancelled...")
    }
}

결과는 아래와 같습니다.

Launching...
Launched...
Start looping
Cancelling...
Cancelled...
It is cancelled
Finally all finish

launch가 트리거 되었음을 확인할 수 있습니다. 그러나 그 안에서 yieldcancel함수가 호출될 수 있도록 한 것입니다. cancel()된 이후 launch내부의 yield에서 취소었는지를 확인하고, 취소되었다면 코루틴을 종료합니다.

다른 쓰레드에서 launch하더라도 비슷합니다.

@Test
fun terminateWithYielding() {
    runBlocking {
        println("Launching...")
        val job = launch(Dispatchers.IO) {
            try {
                println("Start looping")
                repeat(2000) {
                    repeat(2000) {
                        repeat(2000) {
                            yield()
                        }
                    }
                }
                println("Done looping")
            } catch (e: CancellationException) {
                println("It is cancelled")
            } finally {
                println("Finally all finish")
            }
        }

        println("Launched...")
        yield()
        println("Cancelling...")
        job.cancel()
        println("Cancelled...")
    }
}

yield함수가 코루틴이 취소될 수 있게 합니다.

Launching...
Launched...
Start looping
Cancelling...
Cancelled...
It is cancelled
Finally all finish

코루틴을 시작, 일시 중지, 종료 하기 위해서는 join, delay, yield같은 suspend 함수를 사용해야 한다는 것을 배웠습니다. 그저 쉽게 되는 일이 아니었습니다. 이제 이 사실을 알았으니 더 많은 것을 컨트롤 할 수 있게 되었습니다.

Scoped Storage — Android(번역)

|

원본 - Scoped Storage — Android

도입부

android storage & file storage 시리즈의 마지막으로 scoped storage의 장점에대해 학습해보겠습니다. 안드로이드의 파일 저장소에 대한 기본적인 개념을 익히기 위해서는 Part 1Part 2 를 참조하시면 좋습니다. Scoped storage는 android 10에서 도입되었으며 개발자에게는 앱에 이 기능을 적용시키기 전까지 해당 기능을 유보할 수 있는 선택권이 주어집니다. 그러나 android 11 부터는 scoped storage가 의무 적용되어야 하기 때문에 유보할 수 없습니다.

핵심 기능

  1. 본인의 앱에 한하여 어떠한 저장소 권한 없이도 내부 & 외부 저장소에 무제한 접근할 수 있습니다.
  2. 앱의 특정 미디어 파일을 media collection에 쓰려는 경우 어떤 저장소 권한도 필요하지 않습니다. 그러나 해당 앱의 파일 및 다른 앱이 만든 파일들을 모두 조회하시려면 읽기 권한이 필요합니다.
  3. 오직 특정 앱에만 해당하는 컬렉션으로 파일을 구성합니다.
  4. 다른 앱의 파일 및 앱 관련 데이터들은 scoped storage에 저장되기 때문에 보호됩니다.
  5. 사용자가 앱을 삭제하는 경우, 해당 앱에 저장된 파일들은 모두 제거됩니다.

주의 : 공유 저장소(shared storage)에 파일을 저장하는 앱의 경우 구체적인 특정 파일에 저장한 모든 파일을 앱 디렉토리로 옮겨 추후에도 그 파일들이 유지되도록 해주어야 합니다.

아래는 media store에 파일을 저장하는 코드 스니펫입니다. Android 10부터는 어떤 저장소 권한도 필요하지 않습니다.

fun saveFileToMediaStore() {

    // 경로와 이미지 정보 명시
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, "paris")
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
        put(MediaStore.Images.Media.IS_PENDING, 1)
    }

		// 파일이 생성될 저장소 명시
    val imageUri =
        appContext.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)

		// assets으로 부터 파일을 읽고 위에서 생성한 파일 쓰기
    val assetManager = appContext.assets
    val inputStream: InputStream
    var bitmap: Bitmap? = null
    try {
        inputStream = assetManager.open(SAMPLE_FILE_NAME)
        bitmap = BitmapFactory.decodeStream(inputStream)
    } catch (e: Exception) {
        Timber.e(e)
    }

    appContext.contentResolver.openOutputStream(imageUri!!).use { out ->
        bitmap!!.compress(Bitmap.CompressFormat.JPEG, 90, out)
    }

    values.clear()
    values.put(MediaStore.Images.Media.IS_PENDING, 0)
    appContext.contentResolver.update(imageUri, values, null, null)

}

주의 : 읽기 권한은 앱 특화 파일일지라도 필요합니다. 그 이유는 시스템이 그 파일을 이전 버전의 앱에 해당하는 것으로 간주하기 때문입니다.

IS_PENDING vlalue는 1 에서 0으로 업데이트 해줍니다. 1인 경우는 특정 파일이 쓰기 혹은 기타 작업을 하고 있다는 것을 나타내는 것이고, 그에 따라 이미지 컬렉션에 노출되지 않습니다. 0으로 할당되면, 그때서야 컬렉션에 노출됩니다.

위 작업을 하고나면 File Manager Application을 사용하여 Picutures 디렉토리에 파일이 저장된 것을 확인할 수 있습니다.