"비동기적 실행을 위한 동시 실행 설계 패턴"

 

 

On Android, it's essential to avoid blocking the main thread. The main thread is a single thread that handles all updates to the UI. It's also the thread that calls all click handlers and other UI callbacks. As such, it has to run smoothly to guarantee a great user experience. For your app to display to the user without any visible pauses, the main thread has to update the screen roughly every 16ms, which is about 60 frames per second. Many common tasks take longer than this, such as parsing large JSON datasets, writing data to a database, or fetching data from the network. Therefore, calling code like this from the main thread can cause the app to pause, stutter, or even freeze. And if you block the main thread for too long, the app may even crash and present an Application Not Responding dialog.

 

→메인 쓰레드는 UI 업데이트를 총괄하는 하나의 쓰레드로, 시간이 소요가 클 것으로 예상되는 일반적인 작업들이 메인 쓰레드를 차단하지 않도록 하는것이 필수적

 

 

기능

  • 경량 (Light-weight)코루틴을 실행 중인 스레드를 차단하지 않는 정지(suspension) 를 지원하므로, 단일 스레드에서 많은 코루틴을 실행할 수 있습니다.
  • 메모리 누수 감소 : 구조화된 동시 실행( structured concurrency )을 사용하여 범위(scope) 내에서 작업합니다.
  • 기본으로 제공되는 취소(Cancelation) 기능 : 실행 중인 코루틴 계층 구조를 통해 자동으로 취소(Cancelation)이 전달됩니다.
  • Jetpack 통합 : 많은 Jetpack 라이브러리에 코루틴을 완전히 지원하는 확장 프로그램 포함. 일부 라이브러리에서 구조화된 동시 실행에 사용할 수 있는 코루틴 스코프도 지원

 

Process :보조기억장치의 '프로그램'이 메모리 상으로 적재되어 실행되면 '프로세스'가 된다. '힙'을 할당 받음
Thread : Process 하위에 종속되는 보다 작은 단위로, 작업의 단위. 각 쓰레드는 독립된 메모리 영역인 스택(Stack)을 갖는다.
Coroutine : 작업의 단위가 되는 객체. JVM Heap 에 적재되어 (코틀린 기준) 프로그래머의 코드에 의해 Context Switching 없이 동시성 보장

 

경량

→  쓰레드는 병렬성(Parallelism)을 만족하는 것이 아니라 동시성(Concurrency)을 만족

→  동시성 만족을 위해 OS 커널에 의한 Context Switching (시분할기법)

 

→  코루틴은 동시성 만족을 위해 프로그래머에 의해 스케줄링 

총체적으로 봤을 때 커널 입장에서 Preempting Scheduling (선점형 스케줄링) 을 통하여 각 태스크를 얼만큼 수행할지, 혹은 무엇을 먼저 수행할지를 결정하여 알맞게 동시성을 보장하게 되는 것이다.

작업의 단위를 Object 로 축소하면서 하나의 Thread 가 다수의 코루틴을 다룰 수 있기 때문에, 작업 하나하나에 Thread 를 할당하며 메모리 낭비, Context Switching 비용 낭비를 할 필요가 없음같은 프로세스 내의 Heap 에 대한 Locking 걱정 또한 사라짐

 

 

구성

build.gradle(Module)

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

 

 

실행

 

 

예시에서 주어진 Repository

sealed class Result<out R> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

class LoginRepository(private val responseParser: LoginResponseParser) {
    private const val loginUrl = "https://example.com/login"

    // Function that makes the network request, blocking the current thread
    fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {
        val url = URL(loginUrl)
        (url.openConnection() as? HttpURLConnection)?.run {
            requestMethod = "POST"
            setRequestProperty("Content-Type", "application/json; utf-8")
            setRequestProperty("Accept", "application/json")
            doOutput = true
            outputStream.write(jsonBody.toByteArray())
            return Result.Success(responseParser.parse(inputStream))
        }
        return Result.Error(Exception("Cannot open HttpURLConnection"))
    }
}

→ 이 코드에서 주요하게 봐야할 점은 LoginResponse를 받아오기 위한 makeLoginRequest 가 동기식으로, 호출 스레드를 차단한다는 점이다.

 

동기식

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        val jsonBody = "{ username: \"$username\", token: \"$token\"}"
        loginRepository.makeLoginRequest(jsonBody)
    }
}

→ UI 스레드가 차단됨 → OS는 onDraw()를 호출할 수 없으므로 앱이 정지되고 애플리케이션 응답 없음(ANR) 대화상자가 표시될 수 있습니다.

 

 

비동기식 

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        // Create a new coroutine to move the execution off the UI thread
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}
  • viewModelScope는 ViewModel KTX 확장 프로그램에 포함된 사전 정의된 CoroutineScope입니다. 모든 코루틴은 범위 내에서 실행해야 합니다. CoroutineScope는 하나 이상의 관련 코루틴을 관리합니다.
  • launch는 코루틴을 만들고 함수 본문의 실행을 해당하는 디스패처에 전달하는 함수입니다.
  • Dispatchers.IO는 이 코루틴을 I/O 작업용으로 예약된 스레드에서 실행해야 함을 나타냅니다.

 

Login함수의 실행 순서

  • 앱이 기본 스레드의 View 레이어에서 login 함수를 호출합니다.
  • launch가 새 코루틴을 만들며, I/O 작업용으로 예약된 스레드에서 독립적으로 네트워크 요청이 이루어집니다.
  • 코루틴이 실행되는 동안 네트워크 요청이 완료되기 전에 login 함수가 계속 실행되어 결과를 반환합니다. 편의를 위해 지금은 네트워크 응답이 무시됩니다.
  • 이 코루틴은 viewModelScope로 시작되므로 ViewModel 범위에서 실행됩니다. 사용자가 화면 밖으로 이동하는 것으로 인해 ViewModel이 소멸되는 경우 viewModelScope가 자동으로 취소되고 실행 중인 모든 코루틴도 취소됩니다.

 

 

기본 안전(Main-Safety)을 위한 코루틴 사용

기본 안전(Main-Safety) : 기본 스레드에서 UI 업데이트를 차단하지 않는 함수 

앞서 나온 makeLoginRequest는를 비동기식으로 안전하게 사용하기 위해서는,  호출하는 모든 항목이 명시적으로 실행을 기본 스레드 외부로 이동해야 합니다. (viewModelScope.launch(Dispatchers.IO))

 

Repository

class LoginRepository(...) {
    ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {

        // Move the execution of the coroutine to the I/O dispatcher
        return withContext(Dispatchers.IO) {
            // Blocking network request code
        }
    }
}

→ suspend 키워드를 통해 Coroutine 내에서 함수가 호출되도록 강제합니다.

→ withContext(Dispatchers.IO)는 코루틴 실행을 I/O 스레드로 이동하여 호출 함수를 기본 안전 함수로 만들고 필요에 따라 UI를 업데이트하도록 설정합니다.

 

☞ Coroutine 내에서 makeLoginRequest함수 호출을 강제하고, 함수가 호출되면 withContext(Dispatchers.IO)를 통해 I/O 스레드로 이동하여 실행합니다.

 

 

ViewModel

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun makeLoginRequest(username: String, token: String) {
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            val result = try {
                loginRepository.makeLoginRequest(jsonBody)
            } catch(e: Exception) {
                Result.Error(Exception("Network request failed"))
            }
            when (result) {
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

makeLoginRequest가 suspend 함수이므로 코루틴은 여전히 필요하며, 모든 suspend 함수는 코루틴에서 실행되어야 합니다.

  • launch가 Dispatchers.IO 매개변수를 사용하지 않아 viewModelScope에서 실행된 코루틴이 기본 스레드(UI Thread)에서 실행됩니다.
  • 네트워크 요청의 결과가 이제 성공 또는 실패 UI를 표시하도록 처리됩니다.

이제 로그인 함수가 다음과 같이 실행됩니다.

  • 앱이 기본 스레드의 View 레이어에서 login() 함수를 호출합니다.
  • launch가 기본 스레드에 새 코루틴을 만들고 코루틴이 실행을 시작합니다.
  • 코루틴 내에서 이제 loginRepository.makeLoginRequest() 호출은 makeLoginRequest()의 withContext 블록 실행이 끝날 때까지 코루틴의 추가 실행을 정지합니다.
  • withContext 블록이 완료되면 login()의 코루틴이 네트워크 요청의 결과와 함께 기본 스레드에서 실행을 재개합니다.

 

마무리

1. UI 업데이트를 총괄하는 메인 쓰레드가 차단되는 것을 방지하는 것이 필수적으로 요청에 대한 결과가 바로 이어지지 않는 경우 비동기식 프로그래밍이 필요

2. Coroutine은 Context Switching 없이 코드를 통해 동시성을 보장가능하게 해주는 객체 (비동기적 실행을 위한 동시 실행 설계 패턴)

3. 기본안전을 위해 Coroutine 사용을 강제하는 suspend 키워드 권장(Main-Safety)

 


 

Android의 Kotlin 코루틴  |  Android Developers

 

Android의 Kotlin 코루틴  |  Android Developers

Android의 Kotlin 코루틴 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 코루틴은 비동기적으로 실행되는 코드를 간소화하기 위해 Android에서 사용할 수 있는 동

developer.android.com

🤔 Thread vs Coroutine 전격 비교 (velog.io)

 

🤔 Thread vs Coroutine 전격 비교

비슷해보이는 두 녀석의 차이점을 파헤쳐보자!

velog.io

 

 

로이킴 - 북두칠성 

더 슬퍼오면 주변에 심어진
수많은 나무들을 바라봐
아무도 알아주진 않지만
우뚝 서 있잖아

 
 
 

2023-07-19 17:50

 

'Pictures' 카테고리의 다른 글

속초  (0) 2023.08.30

다음은 프로젝트 진행에 있어 문제 해결에 많은 어려움을 겪었던 코드이다.

class PicturesAdapter() : RecyclerView.Adapter<PictureViewHolder>() {
    private lateinit var itemBinding: ItemPhotoCheckingBinding
    private val pictures : ArrayList<Uri> = ArrayList()


    inner class PictureViewHolder(itemView : View) : RecyclerView.ViewHolder(itemView){
        fun bind(pos : Int){
            val targetUri = pictures[pos]
            GlideApp.with(itemView.context)
                .load(targetUri)
                    .into(itemBinding.photo)
            itemBinding.clearBtn.setOnClickListener {
                removeItem(targetUri)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PictureViewHolder {
        itemBinding = ItemPhotoCheckingBinding.inflate(LayoutInflater.from(parent.context))
        return PictureViewHolder(itemBinding.root)
    }

    override fun getItemCount(): Int {
        return pictures.size
    }

    override fun onBindViewHolder(holder: PictureViewHolder, position: Int) {
        holder.bind(position)
    }


    private fun removeItem(targetUri :Uri){
        val pos = pictures.indexOf(targetUri)
        pictures.removeAt(pos)
        notifyItemRemoved(pos)
    }

    fun addPicture(uri : Uri){
        pictures.add(0,uri)
        notifyItemInserted(0)
    }
}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

처음에는 bind(pos : Int) 시에 position이 fix 되어 문제가 발생한다고 생각하였다.

absoluteAdapterPosition, bindingAdapterPosition 등을 사용하여 해결하려 했으나 문제는 viewBinding에 있었다.

 

해당 코드를 살펴보면 onCreateViewHolder에서 바인딩 인스턴스를 생성하여 root 레이아웃을 itemView로 건네고 있습니다.여기까진 문제가 없을 수 있으나 itemView 내부의 view ( photo, clearBtn ) 에 대해서 onCreateViewHolder에서 생성한 동일한 바인딩 객체에 참조 하는 것을 확인할 수 있습니다. RecyclerView의 목적처럼 뷰홀더를 재사용하는것이 불가능해집니다.

 

해결책

1. itemView를 넘기는 기존의 방식을 사용하여 해결 (findViewByID) -  viewbinding을 사용하는 의미가 퇴색됨

    inner class PictureViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
            fun bind(pos : Int){
                val _photo = itemView.findViewById<ImageView>(R.id.photo)
                val _clearBtn = itemView.findViewById<ImageView>(R.id.clearBtn)

                val targetUri = pictures[pos]
                GlideApp.with(itemView.context)
                    .load(targetUri)
                    .into(_photo)
                _clearBtn.setOnClickListener {
                    imageManager.deleteImage(targetUri)
                    removeItem(targetUri)
                }
            }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PictureViewHolder {
        itemBinding = ItemPhotoCheckingBinding.inflate(LayoutInflater.from(parent.context))
        return PictureViewHolder(itemBinding.root)
    }

 

 

2. viewBinding을 활용한 ViewHolder 사용 ( 권장 )

    inner class PictureViewHolder(private val itemBinding: ItemPhotoCheckingBinding) : RecyclerView.ViewHolder(itemBinding.root){
        fun bind(pos : Int){
            val targetUri = pictures[pos]
            GlideApp.with(itemView.context)
                .load(targetUri)
                    .into(itemBinding.photo)
            itemBinding.clearBtn.setOnClickListener {
                imageManager.deleteImage(targetUri)
                removeItem(targetUri)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PictureViewHolder {
        itemBinding = ItemPhotoCheckingBinding.inflate(LayoutInflater.from(parent.context))
        return PictureViewHolder(itemBinding)
    }

 

설정

 

사용하고자 하는 module의 build.gradle에 추가

android {
        ...
        viewBinding {
            enabled = true
        }
    }

 

사용

 

모듈에 뷰 결합을 사용하도록 설정되면 모듈에 포함된 각 XML 레이아웃 파일의 결합 클래스가 생성됩니다.

(루트 뷰에  tools:viewBindingIgnore="true" 속성이 포함된 경우 제외)

결합 클래스의 이름은 XML 파일의 이름을 카멜 표기법으로 변환하고 끝에 'Binding'을 추가하여 생성되며, 루트뷰와 ID가 있는 모든 뷰의 참조가 포함됩니다.

 

 

                 activity_main.xml           →     activityMainBinding

                 fragment_example.xml  →     FragmentExampleBinding

 

 

 

 

 

 

Activity

      

기존 

class MainActivity : AppCompatActivity() {
	private lateinit var viewExample: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        viewExample = findViewById(R.id.view_example)
    }
}

 

뷰 바인딩 방식

class MainActivity : AppCompatActivity() {
	private lateinit var viewExample: TextView

	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater) // 뷰 바인딩 초기화
        setContentView(binding.root) // setContentView에 뷰 바인딩의 루트 요소를 전달
        
        viewExample = binding.viewExample  //id도 camelCase로 대체하여 접근
	}
}

XML 레이아웃 파일의 결합 클래스 (ActivityMainBinding)에 포함된 정적 메서드 inflate()를 호출하여 인스턴스를 생성합니다. 해당 인스턴스의 루트 뷰를 setContentView에 전달하여 화면상의 활성 뷰로 만듭니다.

 

실제 사용할 때는 레이아웃 파일의 결합 클래스를 binding으로 받아와 binding.viewExample과 같이 접근하는 것이 용이할 것이다.

 

 

Fragment

 

기존

class MyFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_my, container, false)
    }
}

 

 

 

View Binding 방식

class MyFragment : Fragment() {

    private var _binding: FragmentMyBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentMyBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

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

        binding.textViewMessage.text = "Hello, this is the fragment!"
    }
}

 

Fragment 생명주기와 관련되어 간략하게 언급하면 프래그먼트는 뷰보다 오래 지속되기 때문에 OnDestoryView()에서 

ViewBinding을 해제하지 않으면 메모리 누수의 원인이 됩니다.

 

추가적으로 참조 해제를 위한 _binding 변수는 nullable로 설정하고, 실제 뷰들에 대한 참조를 담고 있는 binding 변수는 non-nullable타입으로 설정하여 안전한(Null-Safe) 패턴을 적용하는 것이 권장됩니다.

 

 

 

 

findViewById와의 차이점

  • Null-Safety      :     뷰 결합은 뷰의 직접 참조를 생성합니다. 유효하지 않은 뷰 ID로 인해 null 포인터 예외가 발생할 위험이 없습니다. 
  • Type-Safety     :    각 바인딩 클래스에 있는 필드의 유형이 XML 파일에서 참조하는 뷰와 일치합니다. 즉, type casting에 있어 예외가 발생할 위험이 없습니다.

 

 


 

 

해당 글은 다음을 참조하여 공부한 내용을 정리한 글입니다.

 

 

뷰 결합  |  Android 개발자  |  Android Developers

 

뷰 결합  |  Android 개발자  |  Android Developers

뷰 결합 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 뷰 결합 기능을 사용하면 뷰와 상호작용하는 코드를 쉽게 작성할 수 있습니다. 모듈에서 사용 설정

developer.android.com

 

 

+ Recent posts