💊코루틴&리스트
코루틴이란?
코루틴은 자신이 실행된 스레드를 정지시키지 않으면서 비동기적으로 실행되는 비동기적인 코드 블록이다. 코루틴은 복잡한 멀티태스킹을 구현하거나 직접 다중 스레드를 관리하는 것에 대한 걱정없이 구현할 수 있다. 그 구현 방법으로 인해, 코루틴은 전통적인 다중 스레딩 옵션을 이용하는 것보다 훨씬 효율적이며 리소스도 덜 사용한다. 코루틴을 사용하면 스레드와 관련된 이벤트와 결과를 다루기 위한 콜백 없이 순차적으로 코드를 작성할 수 있어 이해 및 유지보수하기가 훨씬 쉽다.
스레드와 코루틴
스레드는 cpu 사용과 시스템 오버헤드라는 관점에서 볼 때 유한한 리소스라는 점이 문제다. 내부적으로 스레드 생성, 스케줄링, 파기를 위해 많은 작업이 진행된다. 현대적인 cpu들이 수많은 스레드를 실행할 수 있지만, 특정 시점에 병렬적으로 실제 실행될 수 있는 숫자는 cpu 코어 수로 제한된다.cpu 코어 숫자보다 많은 수의 스레드가 필요하면, 시스템은 스레드 스케줄링을 수행해 사용할 수 있는 코어들 사이에서 이 스레드들의 실행을 공유할 수 있는 정책을 결정한다.
코루틴 스코프
모든 코루틴은 명시적인 스코프 안에서 실행됨으로써 개별 코루틴이 아닌 그룹으로 관리되어야 한다. 이는 특히 코루틴을 취소 및 정리하고 코루틴이 '누수'되지 않았음을 보장하는 데 중요하다. CoroutineScope 클래스를 이용하면 사용자가 임의의 스코프를 지정할 수 있다.
GlobalScope - 이를 사용하면 애플리케이션 라이프사이클 전체와 관련된 최상위 코루틴을 실행할 수 있다. 이 스코프의 코루틴은 불필요한 경우에도 잠재적으로 실행될 가능성이 있으므로 안드로이드 애플리케이션에서는 사용을 권장하지 않는다. 여기에서 실행되는 코루틴들은 비구조적인 동시성을 사용하고 있는 것으로 간주된다.
viewModelScope - 젯팩 아키텍처의 ViewModel 컴포넌트를 사용할 때 ViewModel 인스턴스 안에서의 사용을 명시적으로 제공한다. ViewModel 인스턴스 안에서 이 스코프로 실행된 코루틴들은 해당 ViewModel 인스턴스가 파기되는 시점에 코틀린 런타임 시스템에 의해 자동으로 취소된다.
lifecycleScope - 모든 라이프사이클 소유자는 하나의 lifecycleScope와 연관되어 있다. 이 스코프는 해당 라이프사이클 소유자가 파기될 때 취소되며, 이는 컴포저블과 액티비티 안에서 코루틴을 실행할 때 매우 유용하다.
대부분의 컴포저블 안에서 코루틴 스코프에 접근하는 최고의 방법은 rememberCoroutineScope() 함수를 호출하는 것이다. coroutineScope는 코루틴을 실행하는 데 사용될 디스패처를 선언하며(이는 오버라이드될 수 있다), 코루틴이 시작될 때 코루틴이 해당 스코프 안에 포함되어 있는지 항상 참조되어야 한다. 스코프 안에서 실행 중인 모든 코루틴은 스코프 인스턴스의 cancel() 메서드를 호출해서 취소할 수 있다.
일시 중단 함수는 코루틴 코드를 포함하는 특수한 유형의 코틀린 함수다. 메인 함수를 막지 않는 상태로 실행되면서 오랜 시간 동안 계산을 할 수 있는 함수를 의미한다.
코루틴은 다양한 유형의 비동기 처리를 위한 스레드를 유지한다. 한 코루틴을 실행할 때 다음 중 특정한 디스패처를 명시할 수 있다.
Dispatchers.Main - 메인 스레드에서 해당 코루틴을 실행한다. UI를 변경하거나 경량의 태스크를 실행하기 위한 일반적인 목적의 코루틴에 적합하다.
Dispatchers.IO - 네트워크, 디스크, 데이터베이스 작업을 수행하는 코루틴에 적합하다.
Dispatchers.Default - 데이터 정렬, 복잡한 계산 수행과 같이 많은 CPU를 수행하는 태스크에 효과적이다.
코루틴 빌더는 지금까지 설명한 모든 컴포넌트를 포함해 코루틴을 실행한다. 이를 위해 여섯 가지 빌더를 제공한다. launch는 현재 스레드를 중단하지 않고 코루틴을 시작하며 호출자에게 결과를 반환하지 않는다. async는 하나의 코루틴을 시작하고 호출자가 await() 함수를 이용해 결과를 기다리게 한다. 현재 스레드를 중지시키지 않는다. 여러 코루틴을 동시에 실행해야 할 때는 async를 사용한다. async 빌더는 다른 중지된 함수 안에서만 사용할 수 있다. withContext는 부모 코루틴에서 사용된 것과 다른 컨텍스트에서 코루틴을 실행할 수 있다. 이 빌더는 한 코루틴으로부터 결과를 반환할 때 async의 유용한 대안을 제공한다. coroutineScope는 중지되어 있는 함수가 여러 코루틴을 동시에 실행하면서 동시에 모든 코루틴이 완료되었을 때만 특정한 액션을 발생시켜야 하는 상황에 적합하다. supervisorScope는 coroutineScope와 유사하나, 한 코루틴에서 실패가 발생하더라도 다른 모든 자식 코루틴을 취소하지 않는다. runBlocking은 한 코루틴을 실행하고 해당 코루틴이 완료될 때까지 현재 스레드를 중지시킨다.
launch나 async 같은 모든 코루틴 빌더 호출은 하나의 잡 인스턴스를 반환하며, 이를 활용해 해당 코루틴의 라이프사이클을 추적하고 관리할 수 있다. 해당 코루틴 안에서 빌더를 호출하면 새로운 잡 인스턴스가 생성되며, 이는 직전 부모 잡의 자식이 되어 부모-자식 관계 트리를 구성한다.
아래는 코루틴 중지에 관한 것인데, 이 버튼을 클릭하면 performSlowTask()라는 이름의 일시중단 함수를 호출한다. 이 코드에서는 launch 빌더 호출에서 코루틴의 스코프를 얻어 참조하고, 결국 일시 중지 함수인 performSlowTask()를 호출한다.
채널을 사용하면 데이터 스트림을 포함하는 코루틴 사이의 커뮤니케이션을 간단하게 구현할 수 있다. Channel 인스턴스를 생성한 뒤 send() 메서드를 호출해서 데이터를 전달하는 간단한 형태를 띤다. 전송된 데이터는 동일한 Channel 인스턴스의 receive() 메서드 호출을 통해 다른 코루틴으로 전달된다.
부작용
코루틴을 부모 컴포저블의 범위 안에서 실행하는 것은 안전하지 않다. 코루틴은 컴포저블 안에서 이와 같이 호출할 수 없으며, 호출 시 심각한 부작용이 일어난다. 젯팩 컴포즈의 컨텍스트에서 보면, 비동기적인 코드가 해당 컴포저블의 라이프사이클을 고려하지 않고 다른 스코프로부터 컴포저블의 상태를 변경하고자 할 때 부작용이 발생한다. 해당 컴포저블이 존재하는 동안 코루틴이 차례로 계속 실행될 가능성의 리스크가 있다. 여기서는 특히 그 코루틴이 여전히 실행되면서 다음번 컴포저블이 코루틴을 실행할 때 그 상태를 변경하는 것이 문제가 된다. 이 문제를 피하기 위해서 LaunchedEffect 또는 SideEffect 컴포저블 바디 안에서 코루틴을 실행해야 한다. -> 부모 컴포저블의 라이프사이클을 인식하기에 코루틴을 안전하게 실행할 수 있다.
코루틴 실행 코드를 포함한 LaunchedEffect 컴포저블이 호출되면, 해당 코루틴은 즉시 실행되고 비동기 코드 수행을 시작한다. 부모 컴포저블이 완료되는 즉시, 해당 LaunchedEffect 인스턴스와 코루틴은 파기된다.
여기서 key 파라미터 값은 재구성을 통해 코루틴의 동작으로 통제한다. 이 값이 변경되지 않는 한, LaunchedEffect는 해당 부모 컴포저블의 여러 재구성 과정에서도 동일한 코루틴을 유지한다. 그러나 key 값이 변경되면 LaunchedEffect는 현재 코루틴을 취소하고 새로운 코루틴을 실행한다.
SideEffect 코루틴은 부모의 재구성이 완료된 뒤 실행된다. SideEffect는 key 파라미터를 받지 않으며, 부모 컴포저블이 재구성될 때마다 수행된다.
리스트와 그리드
리스트는 수많은 아이템을 스크롤할 수 있어서 자주 이용된다. 리스트 안의 각 아이템은 하나의 컴포저블로 나타나며, 이 컴포저블은 하위 컴포저블을 포함할 수 있다. 길이가 긴 리스트를 다룰 때는 LazyColumn, LazyRow, LazyVerticalGrid를 이용하는 것이 좋다. 이 컴포넌트들은 사용자에게 실제로 보이는 아이템들만 만든다. Column, Row 컴포저블은 LazyRow, LazyColumn에 비해 일부 피처 또는 성능상 우위점은 적지만 짧고 기본적인 아이템 리스트를 표시할 때는 매우 좋은 옵션이다.
지연 리스트는 LazyColumn, LazyRow 컴포저블을 이용해 만든다. 이들은 그 자식들을 LazyListScope 블록 안에 배치하며, 이 블록이 제공하는 추가 피처를 이용해 리스트 아이템들을 관리하고 커스터마이즈할 수 있다.
ScrollState를 이용해서 스크롤을 활성화할 수 있는데, Row, Column은 추가 단계(ScrollState 인스턴스를 만들어야 함.)가 필요하지만 Lazy는 기본으로 지원한다.
Row, Column 기반 리스트에서의 프로그래밍적 스크롤은 ScrollState 인스턴스의 다음 함수들을 호출해서 실행한다.
animateScrollTo(value: Int) - 애니메이션을 이용해 지정한 픽셀 위치까지 부드럽게 스크롤한다. - 픽셀 기준
scrollTo(value: Int) - 지정한 픽셀 위치까지 곧바로 스크롤한다.
animateScrollTo(index: Int) - 지정한 리스트 아이템까지 부드럽게 스크롤한다. (첫 번째 아이템이 0번이다) - 스크롤 위치
scrollTo(index: Int) - 지정한 리스트 아이템까지 곧바로 스크롤한다. (첫 번째 아이템이 0번이다)
스티키 헤더
스티키 헤더는 지연 리스트에서만 이용할 수 있는 피처다. 이를 이용하면 리스트 아이템들을 한 헤더 그룹 아래 모을 수 있다. 스티커 헤더는 LazyListScope의 stickyHeader() 함수를 이용해 만든다.
이 헤더들은 현재 그룹이 스크롤되는 동안 화면에서 계속 표시되기 때문에 스티키 헤더라고 불린다. 그룹이 뷰에서 모두 사라지면 다음 그룹의 헤더가 해당 위치를 차지한다.
스티키 헤더를 이용할 때는 리스트 콘텐츠를 groupBy() 함수를 이용해 매핑한 Array 또는 List에 저장해야 한다. groupBy() 함수는 람다를 받는다. 이 람다는 데이터의 그룹핑 방법을 정의하는 셀렉터를 정의하기 위해 이용된다. 이 셀렉터는 이후 각 그룹의 요소들에 접근하는 키의 역할을 한다.
LazyRow와 LazyColumn을 이용하면 리스트를 특정한 아이템 위치까지 스크롤했을 때 특정한 액션을 수행할 수 있다. 사용자가 리스트의 끝까지 스크롤했을 때만 '맨 처음으로 스크롤하기' 버튼을 표시하는 경우 등에 매우 유용하다.
그리드 레이아웃은 LazyVerticalGrid 컴포저블을 이용해 만들 수 있다. 그리드의 형태는 cells 파라미터를 통해 제어하며, 이 파라미터는 적응 모드 또는 고정 모드로 설정할 수 있다. 적응 모드에서는 그리드가 이용할 수 있는 공간에 맞게 행과 여르이 수를 계산하며, 이때 아이템 사이의 공간은 최소 지정 셀 크기가 된다. 반면 고정 모드에서는 표시할 행의 수를 전달하면 이용할 수 있는 공간의 폭을 채우기 위해 각 열의 폭을 동일한 크기로 조정한다.
ListDemo 프로젝트 만들기
리스트를 만들고, rememberScrollState() 함수를 호출해서 ScrollState 상태 인스턴스를 얻어야 한다. 상태를 얻은 뒤에는 verticalScroll() 모디파이어를 통해 이를 Column에 전달한다.
그 다음으로는 Row, Column에 대해서 수동 스크롤을 생성해준다.
Row 리스트도 만들어보면, 아래처럼 나온다.
@Composable
fun MainScreen() {
ColumnList()
// RowList()
}
@Composable
fun RowList() {
val scrollState = rememberScrollState()
Row(Modifier.horizontalScroll(scrollState)) {
repeat(50) {
Text(" $it ",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(5.dp))
}
}
}
@Composable
fun ColumnList() {
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
Column {
Row {
Button(onClick = {
coroutineScope.launch {
scrollState.animateScrollTo(0)
}
},
modifier = Modifier.weight(0.5f)
.padding(2.dp)) {
Text("Top")
}
Button(onClick = {
coroutineScope.launch {
scrollState.animateScrollTo(scrollState.maxValue)
}
},
modifier = Modifier.weight(0.5f)
.padding(2.dp)) {
Text("End")
}
}
Column(Modifier.verticalScroll(scrollState)) {
repeat(500) {
Text(
"List Item $it",
style = MaterialTheme.typography.h4,
modifier = Modifier.padding(5.dp)
)
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DefaultPreview() {
ListDemoTheme {
MainScreen()
}
}
LazyListDemo 프로젝트 만들기
리스트 데이터 추가하고, 데이터는 문자열 배열 타입이며 car_array라는 리소스 이름으로 선언되어 있다. 파일에서 데이터를 참조할 때는 이 이름을 이용한다.
프로젝트에 xml 파일을 추가했으므로 이를 파싱해서 배열로 읽는다. 이 배열은 이후 LazyColumn 컴포넌트에서 읽을 수 있는 형태로 데이터를 제공한다. 안드로이드 resources 인스턴스의 getStringArray() 메서드를 이용한다. 데이터는 메인 액티비티를 만들 때 초기화해야 하므로, onCreate() 메서드에서 이 작업을 수행한다. 추가로 MainScreen 함수로 데이터를 전달하고 Preview 컴포저블로 일부 샘플 데이터를 제공하는 코드도 작성한다. 컴포저블로 일부 샘플 데이터를 제공하는 코드도 작성한다.
리스트의 각 아이템은 차량 제조사와 모델을 포함한다. 각 아이템은 텍스트 콘텐츠 외에 제조사 로고 이미지를 포함한다. 로고 이미지들은 웹 서버에서 제공되며 Image 컴포저블 안에서 다운로드하고 렌더링된다. 리스트 안의 각 아이템을 표시하는 컴포저블을 만들어야 한다. 그리고 LazyColumn을 추가해서 지연 리스트를 만들어준다. LazyColumn 컴포저블을 호출하고 LazyListScope의 itmes() 함수를 이용해 itemArray의 각 요소를 MyListItem 함수로 전달하는 작업을 반복한다. 그리고 리스트 아이템 클릭하면 동작하는 이벤트를 적용한다.
@Composable
fun MainScreen(itemArray: Array<out String>) {
val context = LocalContext.current
val onListItemClick = { text : String ->
Toast.makeText(
context,
text,
Toast.LENGTH_SHORT
).show()
}
LazyColumn {
items(itemArray) { model ->
MyListItem(item = model, onItemClick = onListItemClick)
}
}
}
@Composable
fun MyListItem(item: String, onItemClick: (String) -> Unit) {
Card(
Modifier
.padding(8.dp)
.fillMaxWidth()
.clickable { onItemClick(item) },
shape = RoundedCornerShape(10.dp),
elevation = 5.dp) {
Row(verticalAlignment = Alignment.CenterVertically) {
ImageLoader(item)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = item,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(8.dp)
)
}
}
}
@Composable
fun ImageLoader(item: String) {
val url = "https://www.ebookfrenzy.com/book_examples/car_logos/" + item.substringBefore(" ") + "_logo.png"
Image(
painter = rememberImagePainter(url),
contentDescription = "car image",
contentScale = ContentScale.Fit,
modifier = Modifier.size(75.dp)
)
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
val itemArray: Array<String> = arrayOf("Cadillac Eldorado",
"Ford Fairlane", "Plymouth Fury")
LazyListDemoTheme {
MainScreen(itemArray = itemArray)
}
}
지연 리스트가 제공하는 스티키 헤더와 스크롤 식별
스티키 헤더를 추가해서 작동을 시켜준다. 그럼 제조사 이름을 포함한 페더가 표시되면서 리스트가 화면에 뜰 것이다. 그 다음, 스크롤 위치에 반응시키고, 스크롤 버튼 추가하고 완성된 앱을 테스트한다.