코틀린 7장

손현수·2022년 11월 14일

스톱워치 사전지식: 메인 스레드와 백그라운드 스레드

  • 앱이 처음 시작될 때 시스템이 스레드 하나를 생성하는데 이를 메인 스레드라고 한다. 메인 스레드의 역할은 크게 두 가지이다. 첫 번째는 액티비티의 모든 생명 주기 관련 콜백 실행을 담당하고 두 번째로는 버튼, 에디트텍스트와 같은 UI 위젯을 사용한 사용자 이벤트와 UI 드로잉 이벤트를 담당한다. 그렇기 때문에 UI 스레드라고도 불린다.
  • 작업량이 큰 연산이나, 네트워크 통신, 데이터베이스 쿼리 등은 처리에 긴 시간이 걸린다. 이 모든 작업을 메인 스레드의 큐에 넣고 작업하면 한 작업의 처리가 완료될 때까지 다른 작업을 처리하지 못한다. 사용자 입장에서는 마치 앱이 먹통이 된 것처럼 보이게 된다. 몇 초 이상 메인 스레드가 멈추면 "앱이 응답하지 않습니다"라는 메시지를 받게 된다.
  • 백그라운드 스레드를 활용하면 이러한 먹통 현상을 피할 수 있다(백그라운드 스레드를 워커 스레드라고도 부른다). 메인 스레드에서 너무 많은 일을 처리하지 않도록 백그라운드 스레드를 만들어 일을 덜어주는 것이다. 백그라운드 스레드에서 복잡한 연산이나, 네트워크 작업, 데이터베이스 작업 등을 해주면 된다.
  • 여기서 꼭 주의할 일이 하나가 있다. 절대로 UI 관련 작업을 백그라운드 스레드에서 하면 안된다는 점이다.
  • 예를 들어 여러 백그라운드 스레드에서 직접 뷰의 내용을 바꾼다고 가정하자. 백그라운드 스레드 A는 오렌지를 그리려고 하고 백그라운드 스레드 B는 사과를 그리려고 한다. 이때 뷰에는 어떤 과일이 그려질까? 각 백그라운드 스레드가 언제 처리를 끝내고 UI에 접근할지 그 순서를 알 수 없기 때문에 UI는 메인 스레드에서만 수정할 수 있게 한 것이다.
  • 따라서 백그라운드 스레드에서 UI 자원을 사용하려면 메인 스레드에 UI 자원 사용 메시지를 전달하는 방법을 이용해야 한다.
  • UI 스레드(메인 스레드)에서 UI 작업을 하는 데 Handler 클래스, AsyncTask 클래스, runOnUiThread() 메소드 등을 활용할 수가 있다. 스톱워치 프로젝트에서는 사용이 간편한 runOnUiThread() 메소드를 활용한다.

runOnUiThread() 메소드

  • runOnUiThread()는 UI 스레드(메인 스레드)에서 코드를 실행시킬 때 쓰는 액티비티 클래스의 메소드이다. Acticity.java에서 메소드를 살펴보면 다음과 같다.
public final void runOnUiThread(Runnable action) {
	if (Thread.currentThread() != mUiThread) {
    	mHandler.post(action);
    } else{
    	action.run();
    }
}
  • if 문을 살펴보면, 만약 현재 스레드가 UI 스레드가 아니면 핸들러를 이용해 UI 스레드의 이벤트 큐에 action을 전달한다. 만약 UI 스레드이면 action.run()을 수행한다. 즉 어떤 스레드에 있든지 runOnUiThread() 메소드는 UI 스레드에서 Runnable 객체를 실행한다.
  • 다음과 같은 UI 관련 코드를 runOnUiThread()로 감싸주어 사용하면 된다.
runOnUiThread(object : Runnable{
	override fun run() {
    	doSomethingWithUI()	//여기에 원하는 로직을 구현한다.
    }
})
  • 코틀린의 SAM 변환을 사용하면 더 간단히 표현할 수도 있다.
runOnUiThread{
	doSomethingWithUI()	//여기에 원하는 로직을 구현한다.
}

프로젝트 생성

  • StopWatch라는 새로운 프로젝트를 생성하고 acticity_main.xml에서 텍스트뷰를 삭제한다. 그 후 color.xml을 다음과 같이 수정한다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>
    
<!-- 여기서부터 직접 추가한 색  -->
    <color name="blue">#603cff</color>
    <color name="red">#ff6767</color>
    <color name="yellow">#e1bf5a</color>
</resources>

색상 선택기 사용하기

  • 색상을 코드로만 지정할 수 있는 것은 아니다. 색상 선택기를 이용하는 방법도 있다. 색상을 지정하는 코드 왼쪽에서 코드가 나타내는 색을 미리 보여주는데, 이 사각형을 클릭하면 색상 선택기가 뜬다. 여기서 선택한 색상대로 자동으로 코드가 바뀐다. 실제로 코딩할 때 유용하게 쓰이니 기억해두자

  • 다음은 strings.xml에 '시작', '일시정지', '초기화' 문자열을 추가해준다.

<resources>
    <string name="app_name">StopWatch</string>
    
<!--  여기서부터 추가한 문자열  -->
    <string name="start">시작</string>
    <string name="pause">일시정지</string>
    <string name="refresh">초기화</string>
</resources>

버튼 추가

시작 버튼

    <Button
        android:id="@+id/btn_start"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginBottom="80dp"
        android:padding="20dp"
        android:backgroundTint="@color/blue"
        android:text="@string/start"
        android:textColor="@color/white"
        android:textSize="16sp"
        android:textStyle="bold"/>
  • 시작 버튼의 ID를 지정한다. ID는 버튼을 클릭하는 함수에서 사용
  • 수직 방향 속성을 지정하여 버튼 아래쪽을 부모 레이아웃 아래쪽에 배치한다.
  • 수평 방향으로는 부모 레이아웃의 시작과 끝점에 맞춤으로써 버튼이 가운데 정렬되도록 한다.
  • padding값을 주어 버튼 내부에 여백을 둔다.
  • 버튼의 배경색을 colors.xml에서 지정한 파란색으로 설정한다.
  • 텍스트를 strings.xml에서 지정한 '시작'으로 지정한다.
  • 텍스트의 색, 크기, 스타일 속성을 지정한다.

초기화 버튼

    <Button
        android:id="@+id/btn_refresh"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/btn_start"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginBottom="50dp"
        android:padding="20sp"
        android:text="@string/refresh"
        android:backgroundTint="@color/yellow"
        android:textSize="16sp"
        android:textColor="@color/white"
        android:textStyle="bold"/>
  • 시작 버튼은 수직 방향 제약이 부모 레이아웃 아래쪽에 있다. 초기화 버튼은 시작 버튼 위에 위치해야 하므로 초기화 버튼의 아래쪽 끝을 시작 버튼의 위쪽 끝으로 맞춰준다.

텍스트뷰 추가: 체인 사용해보기

  • 흐르는 시간을 표현해줄 텍스트뷰를 만들어본다.
    <TextView
        android:id="@+id/tv_minute"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="00"
        android:textSize="45sp"/>

    <TextView
        android:id="@+id/tv_second"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text=":00"
        android:textSize="45sp"/>

    <TextView
        android:id="@+id/tv_millisecond"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text=".00"
        android:textSize="30sp"/>

수직 방향 제약 추가하기

  • 초 텍스트뷰를 클릭 후 위쪽 동그라미를 잡고 부모 레이아웃의 상단에 드래그한다. 이 작업은 텍스트뷰에 app:layout_constraintTop_toTopOf = "parent" 속성을 추가한 것과 동일한 효과를 준다. 코드창을 보면 해당 코드가 추가된다.
  • 초 텍스트뷰 아래쪽 동그라미를 초기화 버튼의 상단에 드래그한다. 그럼 초 텍스트뷰가 상하 제약의 중간 지점에 놓인다. 이제 분 텍스트뷰와 밀리초 텍스트뷰를 일직선 위에 놓이도록 제약을 추가해본다. 모든 텍스트뷰를 일직선 위에 정확히 놓으려면 베이스라인을 사용해야 한다.
  • 분 텍스트뷰 위에서 마우스 우클릭->Show baseline을 선택. 그러면 텍스트의 아래쪽에 베이스라인 막대가 보인다. 분 텍스트뷰 막대를 초 텍스트뷰 막대 모양에 드래그한다. 밀리초 텍스트뷰 역시 같은 방법으로 베이스 라인을 정한다.
  • 이제 모든 텍스트가 완벽하게 정렬되었다. 만약 가운데 있는 초 텍스트뷰의 상하 위치를 변경하면 분 텍스트뷰와 밀리초 텍스트뷰도 함께 움직인다. 이로써 세 텍스트뷰의 수직 방향의 제약을 모두 추가했다.

왜 텍스트뷰 정렬에 베이스라인을 사용할까?

  • 분과 밀리초 텍스트뷰의 아래쪽을 각각 가운데에 있는 초 텍스트뷰의 아래쪽에 맞추면 될 것 같은데 이럴 경우 문제가 발생한다. 만약 양옆 텍스트뷰의 아래쪽을 가운데 텍스트뷰의 아래쪽에 맞도록 제약을 추가하면 텍스트뷰들의 bottom은 정확히 일직선으로 정렬되겠지만, 문제는 텍스트가 정렬이 되지 않는다. 텍스트의 크기가 같은 분, 초 텍스트는 텍스트뷰의 아래쪽을 맞추는 정렬을 하더라도 텍스트 정렬에 문제가 없지만 텍스트의 크기가 다른 밀리초의 경우 텍스트뷰의 아래쪽을 맞추는 정렬을 하면 텍스트의 크기가 다르기 때문에 텍스트 위치가 다른 둘과 다르다. 이 때문에 베이스라인을 사용하는 것이다. 베이스라인을 이용하면 텍스트뷰 안의 텍스트를 일직선으로 정렬할 수 있다.

수평 방향 제약 추가하기

  • 컨스트레인트 레이아웃에는 뷰 여러 개의 수직 또는 수평 여백을 손쉽게 관리하는 체인을 제공한다. 체인을 추가해본다. UI를 구성할 때 굉장히 빈번히 사용되니 잘 기억하자.
  • ctrl 키를 누른 상태에서 세 텍스트를 모두 클릭하여 선택한 후 선택한 텍스트뷰 위에서 마우스 우클릭->Chains->Create Horizontal Chain을 선택. 그럼 세 텍스트뷰가 수평 방향으로 균등한 여백을 두고 위치하게 된다.
  • 이렇게 균등하게 여백을 분배하는 것이 언제나 알맞지는 않을 수 있다. 스톱워치에서는 오히려 세 텍스트뷰가 딱 붙어있는 것이 더 적절하다. 세 텍스트뷰를 가까이 붙여본다.
  • 세 텍스트뷰 중 아무 텍스트뷰 위에서 마우스 우클릭->Chains->Horizontal Chain Style->packed 선택. 그러면 우리가 원하는 모양이 나온다.

버튼에 이벤트 연결하기

  • MainActivity.kt의 전반적인 코드의 틀을 잡고 버튼에 이벤트를 열어준다. 일단 클릭 이벤트를 처리할 리스너 인터페이스를 구현해준다. MainActivity.kt에 다음과 같이 코드를 작성한다.
class MainActivity : AppCompatActivity(), View.OnClickListener {//클릭 이벤트 처리 인터페이스
    var isRunning = false//실행 여부 확인용 변수

    private lateinit var btn_start: Button
    private lateinit var btn_refresh: Button
    private lateinit var tv_millisecond: TextView
    private lateinit var tv_second: TextView
    private lateinit var tv_minute: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //뷰 가져오기
        btn_start = findViewById(R.id.btn_start)
        btn_refresh = findViewById(R.id.btn_refresh)
        tv_millisecond = findViewById(R.id.tv_millisecond)
        tv_second = findViewById(R.id.tv_second)
        tv_minute = findViewById(R.id.tv_minute)

        //버튼별 OnClickListener 등록
        btn_start.setOnClickListener(this)
        btn_refresh.setOnClickListener(this)
    }

    //클릭 이벤트 처리
    override fun onClick(v: View?) {
        when(v?.id) {
            R.id.btn_start -> {
                if(isRunning) {
                    pause()
                } else {
                    start()
                }
            }
            R.id.btn_refresh -> {
                refresh()
            }
        }
    }

    private fun start() {

    }

    private fun pause() {

    }

    private fun refresh() {

    }
}
  • 스톱워치가 현재 실행되고 있는지를 확인하는 데 사용하는 isRunning 변수를 false로 초기화해 생성한다.
  • findViewById() 함수로 xml 레이아웃 파일에서 정의한 뷰들을 액티비티에서 사용할 수 있게 가져온다.
  • btn_start와 btn_refresh에 구현한 리스너를 등록한다. setOnClickListener() 메소드를 이용해서 onClickListener를 추가해주어야 클릭이 가능해진다.
  • 클릭 이벤트가 발생했을 때 어떤 기능을 수행할지 구현한다. 따라서 View.OnClickListener 인터페이스는 반드시 onClick() 함수를 오버라이드해야 한다. 클릭이벤트가 발생했을 때 뷰 ID가 R.id.start이고, 스톱워치가 동작 중이라면 일시정지하고, 정지 상태라면 시작한다. 뷰 ID가 R.id.refresh이면 초기화 메소드가 실행된다.
  • start(), pause(), refresh()는 이제부터 하나씩 구현해본다.

스톱워치 시작 기능 구현하기

  • 시작 버튼을 누르면 스톱워치가 시작되고 시작 버튼이 일시정지로 바뀐다. 일시정지와 초기화를 하려면 이를 관리할 변수가 있어야 한다. 시간을 관리할 변수를 먼저 생성해본다.
    var timer : Timer? = null//timer 변수 추가
    var time = 0//time 변수 추가
  • start() 함수를 다음과 같이 구현한다.
    private fun start() {
        btn_start.text = "일시정지"
        btn_start.setBackgroundColor(getColor(R.color.red))
        isRunning = true

        timer = timer(period = 10) {
            time++//10밀리초 단위 타이머

            val milli_second = time % 100
            val second = (time % 6000) / 100
            val minute = time / 6000

            tv_millisecond.text = if (milli_second < 10) ".0${milli_second}" else ".${milli_second}"//밀리초
            tv_second.text = if(second < 10) ":0${second}" else ":${second}"//초
            tv_minute.text = "${minute}"
        }
    }
  • 시작 버튼을 클릭하면 텍스트를 "일시정지"로 변경하고 색상을 빨강으로 변경한다.
  • 스톱워치가 시작되었으므로 isRunning값을 true로 변경
  • 코틀린에서 제공하는 timer(period = [주기]) {} 함수는 일정한 주기로 반복하는 동작을 수행할 때 유용하게 쓰인다. {}안에 쓰인 코드들은 모두 백그라운드 스레드에서 실행된다. 주기를 나타내는 period 변수를 10으로 지정했으므로 10밀리초마다 실행된다.
  • 0.01초마다 time에 1을 더한다. 1000밀리초가 1초이고 주기를 10밀리초로 설정했기 때문.
  • time 변수를 활용해 밀리초, 초, 분을 계산한다.
  • 0.01초마다 분, 초, 밀리초 텍스트를 변경해준다. 시간이 한 자리일 때 전체 텍스트 길이를 두 자리로 유지하려고 if문을 추가했다.
  • 이 상태로 에뮬레이터를 실행하면 에러가 발생한다. 백그라운드 스레드에서 UI 작업을 했기 때문인데 코드를 수정해준다.
            runOnUiThread {
                if(isRunning) {
                    tv_millisecond.text = if (milli_second < 10) ".0${milli_second}" else ".${milli_second}"//밀리초
                    tv_second.text = if(second < 10) ":0${second}" else ":${second}"//초
                    tv_minute.text = "${minute}"
                }
            }
  • runOnUIThread를 사용해서 텍스트뷰를 수정하는 코드를 감싸준다. 그러면 UI 작업이 백그라운드 스레드가 아닌 UI 스레드에서 일어난다.
  • isRunning이 true일 경우에만 UI가 업데이트되게 해주었다. 사용자가 타이머를 정지하는 시점과 UI 스레드에서 코드가 실행되는 시점이 다를 수 있기 때문이다.
  • 에뮬레이터를 실행해보면 정상 작동하는 것을 볼 수 있다.

일시정지 기능 구현하기

  • pause() 함수를 다음과 같이 구현한다.
    private fun pause() {
        btn_start.text = "시작"
        btn_start.setBackgroundColor(getColor(R.color.blue))
        
        isRunning = false
        timer?.cancel()//타이머 멈추기
    }
  • 일시정지 버튼을 누르면 다시 텍스트를 "시작"으로 바꾸고 배경색을 파란색으로 바꾼다.
  • 일시정지 상태이므로 isRunning을 false로 바꾼다.
  • 현재 실행되는 타이머를 cancel() 함수를 호출해 멈춘다. cancel()함수는 백그라운드 스레드에 있는 큐를 깔끔하게 비워준다.

초기화 기능 구현하기

  • 초기화 버튼을 누르면 타이머가 실행 중이든, 일시정지 상태이든 시간이 00:00:00으로 초기화되어야 한다.
  • refresh() 함수를 다음과 같이 구현한다.
    private fun refresh() {
        timer?.cancel()
        btn_start.text="시작"
        btn_start.setBackgroundColor(getColor(R.color.blue))
        isRunning = false

        time = 0
        tv_millisecond.text = ".00"
        tv_second.text = ":00"
        tv_minute.text = "00"
    }
  • 백그라운드 스레드에서 실행 중인 타이머를 멈춘다.
  • 버튼에 시작 문구를 노출하고 버튼색을 파란색으로 바꾼다.
  • isRunning을 false로 바꾼다.
  • time을 0으로 초기화하고 분, 초, 밀리초 텍스트뷰 모두 "00"으로 초기화한다.
profile
안녕하세요.

0개의 댓글