[프로젝트] 스톱워치 안드로이드 앱 개발, 이렇게 시작하세요


Project 스톱워치 스레드
학습 목표
첫 번째로 만들 앱은 바로 스톱워치입니다. 시작하고, 멈추고, 다시 시작하는 기능을 구현합니다. 사전 지식으로 메인 스레드와 백그라운드 스레드가 어떻게 동작하는지를 알아봅시다.
프로젝트 구상하기
스톱워치에는 컨스트레인트 레이아웃을 사용합니다. 6장에서 배운 기본적인 컨스트레인트 레이아웃 배치 방법 이외에도 체인이라는 기능을 사용해 여러 뷰를 적절히 배치시킬 겁니다. 스톱워치를 구현하려면 메인 스레드와 백그라운드 스레드가 무엇이고 어떤 상황에서 쓰이는지 알아야 합니다. 이 기능은 7.1절 ‘사전 지식 : 메인 스레드와 백그 라운드 스레드’에서 살펴보겠습니다.
학습 순서
7.1 사전 지식 : 메인 스레드와 백그라운드 스레드
앱이 처음 시작될 때 시스템이 스레드 하나를 생성하는데 이를 메인 스레드라고 합니다. 메인 스레드의 역할은 크게 두 가지입니다. 첫 번째는 액티비티의 모든 생명주기 관련 콜백 실행을 담당합니다. 두 번째로는 버튼, 에디트, 텍스트와 같은 UI 위젯을 사용한 사용자 이벤트와 UI 드로잉 이벤트를 담당합니다. 그렇기 때문에 UI 스레드라고도 불립니다.
작업량이 큰 연산이나, 네트워크 통신, 데이터베이스 쿼리 등은 처리에 긴 시간이 걸립니다. 이 모든 작업을 메인 스레드의 큐에 넣고 작업하면 한 작업의 처리가 완료될 때까지 다른 작업을 처리하지 못합니다. 사용자 입장에서는 마치 앱이 먹통이 된 것처럼 보이게 됩니다. 몇 초 이상 메인 스레드가 멈추면 “앱이 응답하지 않습니다”라는 메시지를 받게 됩니다.
백그라운드 스레드를 활용하면 이러한 먹통 현상을 피할 수 있습니다(백그라운드 스레드를 워커 스레드라고도 부릅니다). 메인 스레드에서 너무 많은 일을 처리하지 않도록 백그라운드 스레드를 만들어 일을 덜어주는 것이지요. 백그라운드 스레드에서 복잡한 연산이나, 네트워크 작업, 데이터베이스 작업 등을 해주면 됩니다. 여기서 꼭 주의할 일이 하나가 있습니다. 절대로 UI 관련 작업을 백그라운드 스레드에서 하면 안 된다는 점입니다.
예를 하나 들게요. 다음 그림과 같이 여러 백그라운드 스레드에서 직접 뷰의 내용을 바꾼다고 가정할게요. 그럼 바나나 그림 대신에 오렌지가 들어갈까요 아니면 사과가 들어갈까요?
각 백그라운드 스레드가 언제 처리를 끝내고 UI에 접근할지 그 순서를 알 수 없기 때문에 UI는 메인 스레드에서만 수정할 수 있게 한 겁니다. 따라서 백그라운드 스레드에서 UI 자원을 사용하려면 메인 스레드에 UI 자원 사용 메시지를 전달하는 방법을 이용해야 합니다.
UI 스레드에서 UI 작업을 하는 데 Handler 클래스, AsyncTask 클래스, runOnUiThread() 메서드 등을 활용할 수가 있습니다. 이번 스톱워치 예제에서는 사용이 간편한 runOnUiThread() 메서드를 활용해보겠습니다.
7.1.1 runOnUiThread() 메서드
runOnUiThread()는 UI 스레드(메인 스레드)에서 코드를 실행시킬 때 쓰는 액티비티 클래스의 메서드입니다. Activity.java에서 메서드를 살펴보면 다음과 같습니다.
public final void runOnUiThread(Runnable action) { 
    if (Thread.currentThread() != mUiThread) {
        mHandler.post(action); }
    else {
        action.run();
    }
}
if문을 살펴보면, 만약 현재 스레드가 UI 스레드가 아니면 핸들러를 이용해 UI 스레드의 이벤트 큐에 action을 전달post합니다. 만약 UI 스레드이면 action.run()을 수행합니다. 즉 어떤 스레드에 있든지 runOnUiThread() 메서드는 UI 스레드에서 Runnable 객체를 실행합니다.
다음과 같은 UI 관련 코드를 runOnUiThread()로 감싸주어 사용하면 됩니다.
runOnUiThread(object :Runnable{ 
    override fun run() {
        doSomethingWithUI() // 여기에 원하는 로직을 구현하세요. 
    }
})
코틀린의 SAM 변환(2.8.3절 참고)을 사용하면 더 간단히 표현할 수도 있습니다.
runOnUiThread {
    doSomethingWithUI() // 여기에 원하는 로직을 구현하세요.
}
7.2 준비하기 : 프로젝트, SDK 버전
본격적인 앱 구현에 앞서 프로젝트를 생성하겠습니다. 첫 번째 앱이라서 그런지 왠지 모를 설렘이 가득합니다. 차근차근 따라와주세요.
7.2.1 프로젝트 생성
실습에 사용할 프로젝트를 만들어주세요.
• 액티비티 : Empty Activity
• 프로젝트 이름 : StopWatch
• 언어 : Kotlin
• Minimum SDK : API 26 : Android 8.0 (Oreo)
7.2.2 SDK 버전 설정
스톱워치 앱 프로젝트를 생성할 때 Minimum SDK 버전을 API 26 : Android 8.0 (Oreo)으로 지정해주었습니다. 앱이 돌아가는 최소 사양을 API 26으로 명시해준 것이지요. Minimum SDK 버전뿐만 아니라 프로젝트에서 설정해주는 SDK 버전으로는 타깃 SDK 버전과 컴파일 SDK 버전이 있습니다. 현재 프로젝트의 모든 SDK 버전은 [Gradle Scripts] → build.gradle (Module: app) 파일에서 확인할 수 있습니다.
❶ compileSdk : Gradle에 어떤 안드로이드 SDK 버전으로 앱을 컴파일할 것인지를 명시합니 다. 가장 최신 API 버전(현 시점 API 31)으로 지정을 하면, 개발 시점 기준으로 모든 API를 사용할 수 있습니다. 컴파일 SDK 버전이므로 런타임에는 영향을 미치지 않습니다. 디프리케이티드 APIDeprecated API 체크나, 기존 코드의 컴파일 체크 그리고 새로운 API에 대비하 려면 항상 최신 SDK 버전으로 지정하는 것이 좋습니다.
❷ minSdk : 앱을 사용할 수 있는 최소한의 API 레벨을 명시합니다. 안드로이드는 앱을 설치하려는 기기의 API 레벨이 minSdk 버전보다 낮으면 설치되지 않도록 합니다.
❸ targetSdk : 앱이 기기에서 동작할 때 사용하는 SDK 버전입니다. 기기 버전이 API 28이고 targetSdk 버전이 API 24이면 앱은 API 24를 기본으로 동작합니다. 새로운 안드로이드 API 버전이 출시될 때마다 보안과 성능이 좋아지고 사용자 환경이 전반적으로 개선되므로 구글에서는 앱 개발 당시의 최신 API를 targetSdk로 지정할 것을 권장하고 있습니다.
디프리케이티드 API(Deprecated API)
아직까지 사용은 할 수 있지만 조만간 사라질 수 있다고 예고된 API를 말합니다.
7.3 레이아웃 구성하기
기능을 구현하기 전에 우리가 구성할 레이아웃을 살펴봅시다.
7.3.1 기본 레이아웃 설정
레이아웃으로 사용자에게 보일 화면을 구성합니다.
To Do 01 [app] → [res] → [layout] → activity_main.xml을 열어주세요. 뷰 모드에서 [Split] 모드를 선택해주세요.
To Do 02 activity_main.xml 파일에 “Hello World” 텍스트뷰가 있습니다. 사용하지 않을 것이므로 이 텍스트뷰를 삭제합시다. ❶ 태그 전체를 코드에서 직접 삭제하거나, ❷ 레이아웃 미리보기에서 텍스트뷰를 클릭한 후에 Del 키를 눌러 삭제하면 됩니다.
7.3.2 colors.xml과 strings.xml 설정
스톱워치에서 4가지 색상과 3가지 문자열을 사용할 겁니다. 색상과 문자열을 차례대로 추가해줍니다.
To Do 01 activity_main.xml 파일에 “Hello World” 텍스트뷰가 있습니다. 사용하지 않을 것이므로 이 텍스트뷰를 삭제합시다. ❶ <TextView> 태그 전체를 코드에서 직접 삭제하거나, ❷ 레이아웃 미리보기에서 텍스트뷰를 클릭한 후에 Del 키를 눌러 삭제하면 됩니다.
 

     #FFBB86FC
     #FF6200EE
     #FF3700B3
     #FF03DAC5
     #FF018786
     #FF000000
     #FFFFFFFF


    #603CFF 
    #FF6767 
    #E1BF5A
색상을 코드로만 지정할 수 있는 것이 아닙니다. 색상 선택기를 이용하는 방법도 있습니다. 색상을 지정하는 코드 왼쪽 ❶에서 코드가 나타내는 색을 미리 보여주는데요, 이 사각형을 클릭하면 색상 선택기가 뜹니다. 여기서 선택한 색상대로 자동으로 코드가 바뀝니다. 실제로 코딩할 때 꽤 유용하게 쓰이니 기억해두세요.
To Do 02 [app] → [res] → [values] →strings.xml에서 ‘시작’, ‘일시정지’, ‘초기화’ 문자열을 추가해줍시다.
 

StopWatch 


    시작 
    일시정지 
    초기화
이렇게 색깔과 문자열을 추가했으므로 이제 다시 레이아웃 파일로 돌아가서 뷰 구성을 해주겠습니다.
7.3.3 버튼 추가
아직은 빈 화면입니다. 오른쪽 화면과 같이 [초기화] 버튼과 [시작] 버튼을 만들어봅시다.
To Do 01 [app] → [res] → [layout] → activity_main.xml에 [시작] 버튼 코드를 추가합니다.
 



    
❶ [시작] 버튼의 ID를 지정합니다. ID는 버튼을 클릭하는 함수에서 사용합니다. 버튼임을 알려주려고 접두사 btn_을 사용했습니다. ❷ 버튼 크기를 내용 크기로 감싸는 wrap_content 속성으로 통일해 적용했습니다. ❸ 수직 방향 속성을 app:layout_ contraintBottom_toBottomOf=“parent”로 지정해 버튼 아래쪽을 부모 레이아웃 아래쪽에 배치했습니다. ❹ 수평 방향으로는 부모 레이아웃의 시작점과 끝점에 맞춤으로써 버튼이 가운데 정렬되도록 했습니다(6.4절 ‘컨스트레인트 레이아웃’ 참조) ❺ 부모 레이아웃 아래쪽에 80dp만큼의 여백을 둡니다. ❻ 패딩값을 주어 버튼 내부에 여백을 둡니다.
❼ 버튼의 배경색을 colors.xml에서 지정한 파란색으로 해줍니다. ❽ 텍스트를 strings.xml 에서 지정한 ‘시작’으로 지정해줍니다. ❾ 텍스트의 색, 크기, 스타일 속성을 지정해줍니다.
To Do 02 이번에 [초기화] 버튼 코드를 추가합니다.
code
❶ [시작] 버튼은 수직 방향 제약이 부모 레이아웃 아래쪽에 있습니다. [초기화] 버튼은 [시작] 버튼 위에 위치해야 하므로 layout_contraintBottom_ toTopOf에 “@+id/btn_start” 속성을 사용합니다. 버튼의 아래쪽을 ID가 btn_start인 뷰 위쪽에 배치시킨다는 뜻입니다.
7.3.4 텍스트뷰 추가 : 체인 사용해보기
흐르는 시간을 표현해줄 텍스트뷰를 만들어보겠습니다.
To Do 텍스트뷰 추가하기
To Do 01 [app] → [res] → [layout] → activity_main.xml에 ❶ 분, ❷ 초, ❸ 밀리초 텍스트뷰 코드를 추가해봅시다.

    android:layout_width="wrap_content" 
    android:layout_height="wrap_content" 
    android:text="00" android:textSize="45sp"
/>


    android:layout_width="wrap_content" 
    android:layout_height="wrap_content" 
    android:text=":00" 
    android:textSize="45sp"
/>


    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text=".00"
    android:textSize="30sp"
    />
❶ 분을 나타내는 텍스트뷰입니다. ID로 tv_minute를 사용했습니다. 텍스트뷰를 나타내는 접두사로 tv_를 사용했습니다. ❷ 초를 나타내는 텍스트뷰입니다. ID로 tv_second를 사용했습니다. ❸ 밀리초를 나타내는 텍스트뷰입니다. ID로 tv_millisecond를 사용했습니다. 아무런 제약도 추가해주지 않아서 왼쪽 상단에 텍스트뷰들이 뭉쳐 있습니다.
To Do 02 레이아웃 미리보기 창에서 텍스트뷰들을 드래그하여, 원하는 위치로 놓아줍니다.
To Do 수직 방향 제약 추가하기
세 텍스트뷰의 수직 방향에 제약을 추가해줍시다. 초를 나타내는 텍스트뷰를 중심으로 왼쪽에는 분을 나타내는 텍스트뷰, 오른쪽에는 밀리초를 나타내는 텍스트뷰를 위치시키겠습니다. 이번에는 코드로 직접 하지 않고, 레이아웃 미리보기 창에서 손쉽게 제약을 추가해주는 방법으로 설명하겠습니다. 먼저 초 텍스트뷰의 위쪽을 부모 레이아웃의 위쪽에 맞추겠습니다.
To Do 01 텍스트뷰를 클릭 후 위쪽 동그라미를 잡고 부모 레이아웃의 상단에 드래그해주세요. 그럼 마진을 주지 않았으므로, 오른쪽 상단에 딱 붙게 됩니다. 이 작업은 텍스트뷰에 app:layout_ constraintTop_toTopOf = “parent” 속성을 추가한 것과 동일한 효과를 줍니다. 왼쪽에 코드창을 보면 신기하게도 해당 코드가 추가되었을 겁니다.
To Do 02 초 텍스트뷰 아래쪽을 [초기화] 버튼의 상단에 드래그합시다. 그럼 텍스트뷰가 상하 제약의 중간 지점에 놓입니다.
이제 분 텍스트뷰와 밀리초 텍스트뷰를 일직선 위에 놓이도록 제약을 추가하겠습니다. 모든 텍스트뷰를 일직선 위에 정확히 놓으려면 베이스라인을 사용해야 합니다.
양 쪽 텍스트뷰에 수직 방향 제약들을 추가해봅시다.
To Do 03 ❶ 분 텍스트뷰 위에서 마우스 우클릭 → ❷ Show baseline을 선택해줍니다.
To Do 04 그럼 다음과 같이 텍스트의 아래쪽에 베이스라인 막대가 보입니다. ❶ 분 텍스트뷰 막대를 ❷ 초 텍스트뷰 막대 모양에 드래그해줍니다. ❸ 밀리초 텍스트뷰 역시 똑같은 방법으로 베이스 라인을 정렬해주세요.
드디어 모든 텍스트가 완벽하게 정렬되었네요. 마음이 편안해집니다. 만약 가운데 있는 초 텍스트 뷰의 상하 위치를 변경하면 분 텍스트뷰와 밀리초 텍스트뷰도 함께 움직이겠죠? 이로써 세 텍스트뷰의 수직 방향 제약을 모두 추가했습니다.
Note
왜 텍스트뷰 정렬에 베이스라인을 사용해야 할까요?
분과 밀리초 텍스트뷰의 아래쪽을 각각 가운데에 있는 초 텍스트뷰의 아래쪽에 맞추면 될 것 같은데 왜 베이스라인을 사용해야 할까요? 일단 한번 베이스라인 없이 정렬을 맞춰보도록 하겠습니다.
양 옆 텍스트뷰의 아래쪽을 가운데 텍스트뷰의 아래쪽에 맞도록 제약을 추가하면 다음과 같이 정렬됩니다.
❶ 빨간색 선 기준으로 모든 텍스트뷰들의 bottom, 즉 아래쪽이 정확히 일직선으로 정렬됩니다.
❷ 하지만 파란색 선을 주목해주세요. 텍스트 크기가 다른 밀리초 텍스트뷰 안의 텍스트 위 치가 다른 둘과 다릅니다. 텍스트뷰 수직 위치는 정렬되었지만 텍스트는 그렇지 못합니다.
이 문제를 해결하려면 뭐가 필요할까요? 바로 앞에서 배운 베이스라인이 필요합니다! 베이스라인을 이용하면 오른쪽 그림과 같이 텍스트뷰 안의 텍스트를 일직선으로 정렬할 수 있습니다.
To Do 수평 방향 제약 추가하기
수평 방향은 제약이 아직 없기 때문에 여백이 제각각이라 보기가 불편하네요. 바로 조치를 취해야겠습니다. 컨스트레인트 레이아웃에는 뷰 여러 개의 수직 또는 수평 여백을 손쉽게 관리하는 체인 Chain을 제공합니다.
체인을 추가해보겠습니다. UI를 구성할 때 굉장히 빈번히 사용되니 잘 따라와주세요.
To Do 01 ❶ Ctrl 키를 누른 상태에서(맥OS는 Command 키) → ❷ 세 텍스트뷰를 모두 클릭하여 선택한 후 → 선택한 텍스트뷰 위에서 마우스 우클릭 → ❸ [Chains] → [Create Horizontal Chain]을 선택해주세요.
그럼 다음과 같이 세 텍스트뷰가 수평 방향으로 균등한 여백을 두고 위치하게 됩니다.
이런 식으로 균등하게 여백을 분배하는 것이 언제나 알맞지는 않을 수 있습니다. 스톱워치 에서는 오히려 세 텍스트뷰가 딱 붙어있는 것이 더 적절할 것 같습니다. 가까이 붙여보겠습니다.
To Do 02 ❶ 세 텍스트뷰 중 아무 텍스트뷰 위에서 마우스 우클릭 → ❷ [Chains] → [Horizontal Chain Style] → [packed]를 선택해주세요.
그러면 우리가 원하는 모양이 나옵니다.
여기까지 마치고 나면 코드 창에서 빨간 밑줄이 사라질 겁니다. 모든 뷰들의 제약을 적절히 추가했기 때문입니다. 처음 앱을 만드는 예제인데 잘 따라오셨나요? 이제 모든 UI가 준비되었으니 본격적으로 코드를 작성해봅시다.
체인이 제공하는 모드별 모습은 다음 표에서 확인하세요.
STEP 1 7.4 버튼에 이벤트 연결하기
MainActivity.kt의 전반적인 코드의 틀을 잡고 버튼에 이벤트를 연결해주겠습니다. 일단 클릭 이벤트를 처리할 리스너 인터페이스를 구현해줍니다. 더 자세한 구현 방법은 다음 코드를 작성하면서 배워봅시다.
To Do 01 [app] → [java] → [com.example.stopwatch] → MainActivity.kt에 다음과 같은 코드를 작성해주세요.
package com.example.stopwatch

import androidx.appcompat.app.AppCompatActivity 
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.TextView 
import java.util.*
import kotlin.concurrent.timer

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() {
        // ❽ 초기화하는 로직
    } 
}
❶ 클릭 이벤트를 처리하는 View.OnClickListener 인터페이스를 구현합니다. ❷ 스톱워치가 현재 실행되고 있는지를 확인하는 데 사용하는 isRunning 변수를 false로 초기화해 생성합니다. ❸ findViewById() 함수로 xml 레이아웃 파일에서 정의한 뷰들을 액티비티에서 사용할 수 있게 가져옵니다.
❹ btn_start와 btn_refresh에 구현한 리스너를 등록합니다. setOnClickListener() 메서드를 이용해서 onClickListener를 추가해주어야 클릭이 가능해집니다. ❺ 클릭 이벤트가 발생했을 때 어떤 기능을 수행할지 구현합니다. 따라서 View.OnClickListener 인터페이스는 반드시 onClick() 함수를 오버라이드해야 합니다. 클릭 이벤트가 발생했을 때 뷰 ID가 R.id.btn_start이고, 스톱워치가 동작 중이라면 일시정지하고, 정지 상태라면 시작합니다. 뷰 ID가 R.id.btn_refresh이면 초기화 메서드가 실행됩니다.
❻ start(), ❼ pause(), ❽ refresh()는 각각 시작, 일시정지, 초기화 함수입니다. 아직 로직을 구현하지 않았습니다. 이제부터 하나씩 구현해보겠습니다.
STEP 2 7.5 스톱워치 시작 기능 구현하기
[시작] 버튼을 누르면 스톱워치가 시작되고 [시작] 버튼 텍스트가 ‘일시정지’로 바뀝니다. 이제부터 구현해봅시다. 일시정지와 초기화를 하려면 이를 관리할 변수가 있어야 합니다. 시간을 관리할 변수를 생성해봅시다.
To Do 01 [app] → [java] → [com.example.stopwatch] → MainActivity.kt에 다음과 같이 타이머 관련 변수 timer와 time을 추가하고 초기화합니다.
class MainActivity : AppCompatActivity(), View.OnClickListener {
    var isRunning = false
    var timer : Timer? = null // ❶ timer 변수 추가 
    vartime=0 // ❷ time 변수 추가 
    ... 생략 ...
}
To Do 02 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을 더합니다(period 값, 즉 주기가 10밀리초이기 때문입니다. 참고 로 1000밀리초는 1초입니다). ❺ time 변수를 활용해 밀리초, 초, 분을 계산합니다.
❻ 분, 초, 밀리초 텍스트뷰를 0.01초마다 갱신해줍니다. 시간이 한 자리일 때 전체 텍스트 길이를 두 자리로 유지하려고 if문을 추가했습니다. 0:10.8과 0:1.1처럼 텍스트 길이가 짧아 졌다 길어졌다하면 보기 불편하기 때문입니다.
To Do 03 안드로이드 스튜디오 메뉴에서 [Run] → [Run ‘app’]을 눌러 앱을 실행해보세요.
To Do 04 에뮬레이터에 실행된 앱에서 ❶ [시작] 버튼을 눌러 타이머를 시작합시다. 헉, 앱이 종료되었습니다. 왜 그럴까요? 사망한 앱의 사인을 알려주는 로그캣Logcat을 살펴보며 이유를 찾아봅시다.
To Do 05 ❶ 안드로이드 스튜디오의 왼쪽 아래에 위치한 [Logcat] 탭을 선택해주세요. 여기에 빠르게 로그가 흘러갑니다. ❷ 현재는 [Debug] 모드이기 때문에 다양한 로그들이 출력되는데요, 다음과 같이 [Error]만 보여주게 바꿔주세요.
그러면 로그캣에 빨간 줄이 뭉텅이로 출력됩니다. 이 빨간 로그를 잘 해석하면 왜 우리 앱이 중단되었는지 감이 올듯 싶습니다.
❸ ‘Only the original thread that created a view hierarchy can touch its views.’라는 문구가 보입니다. ‘뷰의 계층 구조를 생성한 메인 스레드에서만 그 뷰들에 접근 할 수 있다’라고 하네요. 아~ 백그라운드 스레드에서 UI 작업을 해주었기 때문에 일어난 에러군요!
자 그럼, 코드를 수정해줍시다.
To Do 06 분, 초, 밀리초를 출력하는 텍스트뷰가 UI 스레드에서 실행되도록 start() 함수를 다음과 같이 수정합니다.
code
private fun start() {
    btn_start.text = "일시정지"
    btn_start.setBackgroundColor(getColor(R.color.red))
    isRunning = true

    // 스톱워치를 시작하는 로직
    timer = timer(period = 10) {
        time++

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

        runOnUiThread { // ❶ UI 스레드 생성
            if (isRunning) { // ❷ UI 업데이트 조건 설정
                // 밀리초
                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 스레드에서 코드가 실행되는 시점이 다를 수 있기 때문입니다.
To Do 07 다시 ❶ 안드로이드 스튜디오 메뉴에서 [Run] → [Run ‘app’]을 눌러 앱을 실행하세요. 그리고 ❷ 실행된 앱의 [시작] 버튼을 눌러보세요. 그러면 정상 동작할 겁니다(아직 타이머를 멈추는 기능이 없네요).
code
private fun start() {
    btn_start.text = "일시정지"
    btn_start.setBackgroundColor(getColor(R.color.red))
    isRunning = true

    // 스톱워치를 시작하는 로직
    timer = timer(period = 10) {
        time++

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

        runOnUiThread { // ❶ UI 스레드 생성
            if (isRunning) { // ❷ UI 업데이트 조건 설정
                // 밀리초
                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}"
            }
        }; 
    }
}
이것으로 시작하기 기능 구현을 마쳤습니다. 메인 스레드와 백그라운드 스레드를 정확히 이해하고 활용하는 것은 안드로이드 프로그래밍에서 굉장히 중요한 부분입니다. 이해를 하는 데 도움이 되었으면 좋겠습니다.
STEP 3 7.6 일시정지 기능 구현하기
일시정지 기능을 구현해봅시다.
To Do 01 pause() 함수를 다음과 같이 구현합니다.
private fun pause() {
    // ❶ 텍스트 속성 변경
    btn_start.text = "시작"
    btn_start.setBackgroundColor(getColor(R.color.blue))

    isRunning = false // ❷ 멈춤 상태로 전환 
    timer?.cancel() // ❸ 타이머 멈추기
}
❶ [일시정지] 버튼을 누르면 다시 텍스트를 "시작"으로 바꾸고 배경색을 파란색으로 바꿉니다. ❷ 일시정지 상태이므로 isRunning을 false로 바꿉니다. ❸ 현재 실행되는 타이머를 cancel() 함수를 호출해 멈춥니다(cancel() 함수는 백그라운드 스레드에 있는 큐를 깔끔하게 비워줍니다).
STEP 4 7.7 초기화 기능 구현하기
초기화 기능을 추가해줍시다. [초기화] 버튼을 누르면 타이머가 실행 중이든, 일시정지 상태이든 시간이 00:00.00으로 초기화되어야 합니다.
To Do 01 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로 바꿉시다. ❹ 일단 0.01초에 1씩 늘어났던 time을 0으로 초기화해주고, 분, 초, 밀리초 텍스트뷰 모두 “00”으로 초기화합니다.
자 이렇게 모든 기능이 완벽하게 구현되었습니다. 계란 삶을 때나, 운동할 때 한번 직접 만든 스톱 워치를 사용해보는 건 어떨까요?
7.8 테스트하기
To Do 01 안드로이드 스튜디오 메뉴에서 [Run] → [Run ‘app’]을 선택하여 에뮬레이터나 본인의 기기에서 앱을 실행해보세요.
To Do 02 앱이 잘 실행되면 다음과 같이 테스트해보세요.
❶ [시작] 버튼을 눌러주세요. [시작] 버튼을 누르면 스톱워치가 시작 되고, [일시정지] 버튼이 보입니다.
❷ [일시정지] 버튼을 눌러보세요. 스톱워치가 멈추고 다시 [시작] 버튼이 보입니다. 여기서 다시 [시작] 버튼을 누르면 초가 이어져 동작합니다.
❸ [초기화] 버튼을 눌러보세요. 스톱워치가 초기화됩니다.
이렇게 스톱워치가 초기화되었음을 확인할 수 있습니다. 이 스톱워치로 숨 오래 참기 시합을 하거나, 정해진 시간 안에 코딩 문제 풀기 등 여러 활동에 활용보세요.
학습 마무리
스톱워치 UI를 구성하며 버튼을 부모 컨스트레인트 레이아웃의 아래로 정렬시켰습니다. 그리고 가운데 있는 텍스트뷰를 부모 레이아웃과 [초기화] 버튼 사이에 위치하도록 제약을 추가했습니다. 딱 기준이 되는 위치를 먼저 정해 놓은 것이지요. 그 후 왼쪽 텍스트뷰와 오른쪽 텍스트뷰는 가운데 있는 텍스트뷰의 베이스라인에 맞도록 정렬시켰습니다.
스톱워치 구현은 간단해 보이지만 스레드 개념이 있어 약간은 어렵게 느껴질 수 있습니다. 앞으로 배울 예제에서 네트워크 요청을 서버로 보내거나, 데이터베이스 관련 작업을 할 때 백그라운드 스레드를 또 이용할 겁니다.
핵심 요약
1 서로 크기가 다른 텍스트뷰 안의 텍스트의 정렬을 맞추는 데 베이스라인을 이용합니다.
2 서로 크기가 다른 뷰들의 여백을 수직 혹은 수평 방향으로 일정하게 분배하는 데 체인을 사용합니다.
3 UI 변경은 UI 스레드(메인 스레드)에서만 가능합니다.
4 백그라운드 스레드에서는 네트워크 작업, 데이터베이스 작업, 계산량이 많은 작업을 하면 됩니다.

홍정아
블록체인 개발 회사, 앱 개발 스타트업 CTO를 거쳐 현재는 테크 스타트업의 대표입니다. 유튜브에서 ‘코틀린 강의’를 검색하면 제일 상단에서 Code with Joyce 채널에 2020년 연재한 코틀린 강의를 만날 수 있습니다.

Leave a Reply

©2020 GoldenRabbit. All rights reserved.
상호명 : 골든래빗 주식회사
(04051) 서울특별시 마포구 양화로 186, 5층 512호, 514호 (동교동, LC타워)
TEL : 0505-398-0505 / FAX : 0505-537-0505
대표이사 : 최현우
사업자등록번호 : 475-87-01581
통신판매업신고 : 2023-서울마포-2391호
master@goldenrabbit.co.kr
개인정보처리방침
배송/반품/환불/교환 안내