본문 바로가기
  • GDG on campus Ewha Tech Blog
3-1기 스터디/안드로이드 기초

[6주차] Android Basics In Kotlin Unit 3 Navigation(4), Unit 4 Internet(1)

by 해시브라운 🥔 2021. 11. 23.

Cupcake App 개요

  1. startFragment에서 one cupcake 버튼을 클릭하면 flavorFragment로 넘어가고, 컵케이크 한 개에 해당하는 금액이 표시된다.
  2. flaverFragment에서 맛을 선택하고 Next 버튼을 클릭하면 pickupFragment로 넘어간다.
  3. pickupFragment에서 요일을 선택하고 Next 버튼을 누르면 summaryFragment로 넘어간다.

1. Navigator Editor에서 Action 연결하기

  • Fragment를 동작 순서에 맞게 탐색 그래프를 연결한다.
  • startFragment가 NavHost에 표시될 첫 번째 프래그먼트이다.

2. 버튼을 눌러 fragment 이동

findNavController().navigate(R.id.action_startFragment_to_flavorFragment)

공유 ViewModel1. OrderViewModel 생성

class OrderViewModel : ViewModel() {

}
  • Model 패키지를 생성하고, 패키지 안에 OverViewModel.kt이라는 이름의 클래스를 생성한다.
    ViewModel로 사용하기 위해 클래스를 ViewModel에서 확장해야 한다.
  • 데이터를 하나의 ViewModel에 저장하고 여러 fragment가 이 ViewModel의 데이터에 접근한다.
  • onClickListener에서 findNavController() 메서드를 사용하여 NavController를 가져오고 거기에서 navigate()를 호출하여 작업 ID인 R.id.action_startFragment_to_flavorFragment를 전달한다.

공유 ViewModel

데이터를 하나의 ViewModel에 저장하고 여러 fragment가 이 ViewModel의 데이터에 접근한다.

1. OrderViewModel 생성

Model 패키지를 생성하고, 패키지 안에 OverViewModel.kt이라는 이름의 클래스를 생성한다.
ViewModel로 사용하기 위해 클래스를 ViewModel에서 확장해야 한다.

class OrderViewModel : ViewModel() {

}

2. LiveData

속성 유형을 LiveData로 하여 데이터가 변경될 때 UI를 업데이트할 수 있도록 한다. 데이터 변수를 private으로 하고, 외부에서 접근하는 setter 함수는 public 상태인 것을 확인하자.

private val _quantity = MutableLiveData<Int>(0)
val quantity: LiveData<Int> = _quantity

fun setQuantity(numberCupcakes: Int) {
    _quantity.value = numberCupcakes
}

activityViewModels()

1. viewModels vs activityViewModels

viewModels()는 현재 fragment 범위에서 ViewModel을 사용한다. 즉 fragment 마다 인스턴스가 다르다.
activityViewModels는 activity 범위에서 ViewModel을 사용한다. 즉 하나의 activiy에 속하는 fragment들에서 동일하게 유지된다.

2. 각 fragment에 OderViewModel 정의

viewModel을 공유하는 frament에 아래 코드를 추가해 OrderViewModel을 sharedViewModel이라는 이름으로 사용할 수 있도록 한다.

private val sharedViewModel: OrderViewModel by activityViewModels()

3. OrderViewModel의 데이터 업데이트

StartFragment에서 누르는 버튼에 따라 quantity 데이터 값을 업데이트해야 한다.
navigate로 fragment를 이동하기 전에 OrderViewModel에 정의해둔 set 메서드를 이용해 데이터를 업데이트한다.

fun orderCupcake(quantity: Int) {
    sharedViewModel.setQuantity(quantity)
    findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}

데이터 결합 & ViewModel

지난 시간에 배운 데이터 결합을 사용하면 코드에서 실수로 UI를 업데이트하지 않은 경우 오류가 발생하는 것을 방지할 수 있다.

1. xml에 코드 추가

<data>
  <variable
       name="viewModel"
       type="com.example.cupcake.model.OrderViewModel" />
</data>

2. fragment의 binding에 코드 추가

fragment의 sharedViewModel을 레이아웃의 뷰 모델 viewModel 변수에 결합한다.

binding?.apply {
    viewModel = sharedViewModel
}

(이 viewModel이 연결된 xml의 <data><variable name="viewModel" ... 이거!)
apply 범위 함수

emily.apply{
    firstName = "Lucy"
    lastName = "Kim"
}
// 위 아래 같은 코드
emily.firstName = "Lucy"
emily.lastName = "Kim"

3. 라디오 버튼의 checked 속성

다음과 같이 결합 표현식을 사용하면 viewModel의 flavor 값이 vanilla일 경우 이 라디오 버튼을 선택된 상태로 설정한다.

android:checked="@{viewModel.flavor.equals(@string/vanilla)}"

결합표현식을 @로 시작하고 {}로 묶여있다는 것을 기억하자.

4. 리스너 결합

다음과 같이 리스너 결합이라는 람다 표현식을 사용하면 라디오 버튼이 눌렸을 때 viewModel의 flavor를 설정할 수 있다.

android:onClick="@{() -> viewModel.setFlavor(@string/vanilla)}"

SimpleDateFormat

1. options 채우기

SimpleDateFormat 클래스를 통해 날짜의 형식 지정(날짜 → 텍스트) 및 파싱(텍스트 → 날짜)이 가능하다.

    private fun getPickupOptions(): List<String> {
        val options = mutableListOf<String>()
        val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
        val calendar = Calendar.getInstance()
        repeat(4) {
            options.add(formatter.format(calendar.time))
            calendar.add(Calendar.DATE, 1)
        }
        return options
    }
  • Locale.getDefault()를 통해 사용자 기기에 설정된 언어 정보를 가져와 SimpleDateFormat 생성자에 전달한다.
  • calendar 변수에는 현재 날짜 및 시간이 포함된다.
  • repeat문을 돌며 options에 오늘, 1일 후, 2일 후, 3일 후 날짜를 "E MMM d" 포맷으로 담는다.

2. OrderViewModel에 dateOptions 데이터 추가

val dateOptions = getPickupOptions()

3. dateOptions로 레이아웃 업데이트

위에서 vanilla 라디오 버튼에서 설정한 것과 비슷하게 viewModel의 dateOptions에서 날짜를 가져와 checked 속성을 정한다. 버튼이 눌렸을 때는 setDate를 실행해 dateOptions을 설정한다. text는 오늘 날짜로 설정한다.

   android:checked="@{viewModel.date.equals(viewModel.dateOptions[0])}"
   android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[0])}"
   android:text="@{viewModel.dateOptions[0]}"

초깃값 설정

resetOder() 메서드를 정의하고 init에서 실행해 초깃값을 설정한다.

    init {
        resetOrder()
    }
    fun resetOrder() {
        _quantity.value = 0
        _flavor.value = ""
        _date.value = dateOptions[0]
        _price.value = 0.0
    }

fragment_summary.xml 수정

viewModel을 사용하도록 수정한다. quantity, flavor, date를 viewModel의 값을 불러와 text에 표시한다.

android:text="@{viewModel.quantity.toString()}"

뷰 모델에서 가격 업데이트

3개 fragment에서 가격을 화면에 보여준다. 이 부분을 뷰 모델을 사용하도록 수정한다.

1. 가격 상수와 업데이트 메서드

OrderViewModel에 가격 상수를 추가한다. (클래스 외부에 private const로)

private const val PRICE_PER_CUPCAKE = 2.00

update 할 메서드를 클래스 내부에 추가한다.

private fun updatePrice() {
    _price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
}

이 메서드를 setQuantity() 메서드에서 실행해서 수량이 바뀌면 가격도 업데이트한다.

2. 레이아웃 text 수정

가격을 띄우는 TextView를 다음과 같이 수정한다.

android:text="@{@string/subtotal_price(viewModel.price)}"

당일 수령 추가 요금

당알 수령 시 요금을 상수로 정의하고,

private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00

updatePrice(), setDate()를 수정해서 수령 날짜를 오늘로 선택했을 때 viewModel에 저장된 price 값을 업데이트한다.

viewModel에서 가격이 수정되었지만 화면에 표시되는 가격은 그대로다!
LiveData를 관찰하고 있지 않기 때문!

LiveData를 관찰하도록 viewLifecycleOwner 설정

다음과 같이 price를 띄우는 3개 fragment에서 lifecycleOwner를 설정한다.

        binding?.apply {
            nextButton.setOnClickListener { goToNextScreen() }
            viewModel = sharedViewModel
            lifecycleOwner = viewLifecycleOwner
        }

LiveData에서 가격을 현지 통화 형식으로

Transformation.map()을 사용해 현지 통화를 사용하도록 가격 형식을 지정할 수 있다.

private val _price = MutableLiveData<Double>()
val price: LiveData<String>

위 코드를 아래 코드로 변경

private val _price = MutableLiveData<Double>()
val price: LiveData<String> = Transformations.map(_price) {
   NumberFormat.getCurrencyInstance().format(it)
}

onClickListner 대신 리스너 결합으로

onViewCreated()에서 setOnClickListener로 클릭 리스너를 직접 설정하는 코드를 삭제하고 리스너 결합으로 수정한다.

1. 각 xml에 각 fragment 이름의 variable을 추가한다.

<variable
    name="startFragment"
    type="com.example.cupcake.StartFragment" />

2. StartFragment

binding?.apply 블록을 전부 지우고 아래 코드처럼 this 키워드를 사용하도록 수정한다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    binding?.startFragment = this
}

xml 영역에서는 각 버튼에 android:onClick="@{() -> startFragment.orderCupcake(1)}"와 같이 onClick 속성을 추가한다.

3. 나머지 fragment

명시적으로 onClickListener 생성했던 코드를 지우고 다음과 같이 수정한다.

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

    binding?.apply {
        lifecycleOwner = viewLifecycleOwner
        viewModel = sharedViewModel
        flavorFragment = this@FlavorFragment
    }
}

xml 영역의 버튼도 onClick 속성을 추가한다.

android:onClick="@{() -> flavorFragment.goToNextScreen()}"

Back Stack

사용자가 Activity 간 이동을 하면 activity는 back stack에 푸시된다. 뒤로 가기를 누르면 back stack에 있던 activity가 팝 되면서 이전 페이지로 돌아가는 것이다.

Cupcake 앱의 경우 MainActivity안에 fragment가 순서대로 푸시된다. Cancel 버튼을 눌러 StartFragment로 돌아가도록 하려면 한 번에 여러 fragment를 팝 해야 한다.

1. Nav Graph 수정

flavorFragment, pickupFragment, summaryFragment 각각에서 startFragment로 연결되는 화살표(action)를 만든다.

2. Cancel 버튼 생성

xml 파일에 cancel 버튼을 추가한다. Material Outlined Button을 사용해 style을 정해줄 수 있다.

<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle" />

3. Cancel 버튼 onClick

3개 fragment에 cancelOrder() 메서드를 만든다. Cancel을 진행하면 sharedViewModel의 값을 리셋하고, 각 fragment에서 startFragment로 연결되는 action의 이름으로 navigate 한다.

fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_flavorFragment_to_startFragment)
}

버튼을 클릭하면 cancelOrder()가 실행되도록 리스너 결합을 사용한다.

<Button
    android:onClick="@{() -> flavorFragment.cancelOrder()}" />

4. Back Stack에서 여러 개 제거 popUpTo, popUpToInclusive

app:popUpTo를 설정하면 summaryFragment에서 cancel 했을 때 flavorFragment와 pickupFragment가 한 번에 사라진다. 하지만 처음 있던 StartFragment와 cancel 버튼을 눌렀을 때 생성된 StartFragment 두 개가 남는다.

app:popUpToInclusive="true"를 설정하면 StartFragment가 하나만 남게 된다.


설정은 Nav graph에서 action 화살표를 누르면 우측 Pop Behavior에서 설정할 수 있다.

주문 전송 - email Intent

1. R.string.order_details

Strings.xml에서 order_details는 아래 코드처럼 작성되어있다.

<string name="order_details">Quantity: %1$s cupcakes \n Flavor: %2$s \nPickup date: %3$s \n Total: %4$s</string>

인수에 1부터 4까지 번호가 매겨져 있고 다음과 같이 사용할 수 있다. getString하면 아래 문자열이 생성된다.
getString(R.string.order_details, "12", "Chocolate", "Sat Dec 12", "$24.00")

Quantity: 12 cupcakes
Flavor: Chocolate
Pickup date: Sat Dec 12
Total: $24.00

2. 암시적 인텐트 - email

sendOrder 메서드 내에 암시적 인텐트를 만든다.

val intent = Intent(Intent.ACTION_SEND)
    .setType("text/plain")
    .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
    .putExtra(Intent.EXTRA_TEXT, orderSummary)

3. strings.xml 복수형 리소스

컵케이크가 하나일 때는 cupcakes가 아닌 cupcake가 출력되어야 한다.

<plurals name="cupcakes">
    <item quantity="one">%d cupcake</item>
    <item quantity="other">%d cupcakes</item>
</plurals>

getQuantityString(R.string.cupcakes, 1, 1) 호출 시 문자열 "1 cupcake"를 반환한다.

참고: getQuantityString()을 호출할 때는 올바른 복수형 문자열을 선택하는 데 첫 번째 수량 매개변수가 사용되므로 수량을 두 번 전달해야 합니다. 두 번째 수량 매개변수는 실제 문자열 리소스의 %d 자리 표시자에 사용됩니다.

order_details에 작성되어있던 cupcakes를 지우고, sendOrder()를 다음과 같이 수정한다.

fun sendOrder() {
    val numberOfCupcakes = sharedViewModel.quantity.value ?: 0
    val orderSummary = getString(
        R.string.order_details,
        resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes),
        sharedViewModel.flavor.value.toString(),
        sharedViewModel.date.value.toString(),
        sharedViewModel.price.value.toString()
    )

    val intent = Intent(Intent.ACTION_SEND)
        .setType("text/plain")
        .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
        .putExtra(Intent.EXTRA_TEXT, orderSummary)

    if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
        startActivity(intent)
    }
}

멀티스레딩과 동시 실행

단일 실행 경로로도 많은 작업을 할 수 있지만 앱이 커짐에 따라 동시 실행을 고려해야 한다. 동시 실행으로 여러 코드를 병렬로 실행해 리소스를 효율적으로 사용한다. 하지만 thread 관련해 다음과 같은 문제가 존재한다.

많은 리소스가 필요한 thread

스레드를 만들고 전환하고 관리하는 데는 동시에 관리할 수 있는 원시 스레드 수를 제한하는 시스템 리소스와 시간이 사용된다. (만들기 비용)

실행 중인 앱에는 여러 스레드가 있지만 각 앱에는 전용 스레드가 하나 있고 특히 앱의 UI를 담당한다. 이 스레드를 기본 스레드 또는 UI 스레드라고도 한다. (UI 스레드와 기본 스레드가 다른 경우도 있다.)

예측할 수 없는 동작 & 경합 상태 (경쟁 상태)

프로세서가 여러 스레드의 명령어 집합 간에 전환할 때 스레드가 실행되는 정확한 시간과 스레드가 일시 중지되는 시점은 개발자가 제어할 수 없다. 즉 스레드를 직접 사용할 때 순서를 예측할 수 없다.

여러 스레드가 동시에 메모리의 동일한 값에 액세스 할 때 경합 상태가 발생할 수 있다.

성능 문제, 경합 상태, 재현하기 어려운 버그는 스레드 직접 사용을 권장하지 않는 이유! 대신 동시 실행 코드 작성에 도움이 되는 코루틴이라는 Kotlin의 기능에 관해 알아보자.

Coroutine

코루틴은 멀티태스킹을 지원하지만 단순히 스레드로 작업하는 것과는 다른 추상화를 제공한다. 코루틴의 주요 기능 중 하나는 상태를 저장하여 중단했다가 재개할 수 있다는 것이다.

  • Job: 취소 가능한 작업 단위
  • CoroutineScope: launch() 및 async()와 같은 새 코루틴을 만드는 데 사용되는 함수는 CoroutineScope를 확장한다.
  • Dispatcher: 코루틴이 사용할 스레드를 결정한다. 코루틴이 실행에 사용할 지원 스레드를 관리하므로 개발자가 새 스레드를 사용할 시기와 위치를 파악하지 않아도 된다. Main dispatcher는 항상 기본 스레드에서 코루틴을 실행하지만 Default나 IO, Unconfined와 같은 디스패처는 다른 스레드를 사용한다.
  • GlobalScope는 앱이 실행되는 동안 내부의 코루틴이 실행되도록 한다. 실제 어플에서 코루틴을 사용할 때는 다른 scope를 사용하기 때문에 권장되는 방식이 아니다.
  • launch()는 반환 값이 코루틴의 범위 밖에서 필요하지 않을 때 사용한다.

runBlocking

새 코루틴을 시작하고 완료될 때까지 현재 스레드를 차단한다.

launch()와 유사한 async() 함수는 Deferred 유형의 값을 반환한다.
Deferred는

  • 미래 값 참조를 보유할 수 있는 취소 가능한 Job
  • 즉시 값을 반환하는 것처럼 함수를 계속 호출할 수 있다.
  • 비동기 작업이 언제 반환될지 확실히 알 수 없기 때문에 자리표시자 역할만 한다.
  • 나중에 이 객체에 값이 반환된다는 것을 보장한다.
  • 현재 코드 줄이 Deferred의 출력을 기다리도록 하려면 코드 줄에서 await()를 호출하면 된다.

Thread를 Coroutine으로

// Thread
fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

/*
Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting
*/
// Coroutine
import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               delay(5000)
           }
       }
   }
}

/*
Thread[DefaultDispatcher-worker-2 @coroutine#2,5,main] has started 
Thread[DefaultDispatcher-worker-2 @coroutine#2,5,main] - Starting 
Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main] has started 
Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main] - Starting 
Thread[DefaultDispatcher-worker-2 @coroutine#3,5,main] has started 
Thread[DefaultDispatcher-worker-2 @coroutine#3,5,main] - Starting
*/

댓글