ViewModel에 데이터 저장하기

1. 시작하기 전에

이전 Codelab에서 활동과 프래그먼트의 수명 주기 및 구성 변경에 따른 관련 수명 주기 문제를 알아봤습니다. 앱 데이터를 저장하려면 인스턴스 상태를 저장하는 것이 한 가지 옵션이지만, 여기에는 이 옵션만의 제한사항이 띠릅니다. 이 Codelab에서는 Android Jetpack 라이브러리를 활용하여 앱을 디자인하고 구성 변경 중에 앱 데이터를 보존하는 확실한 방법을 알아봅니다.

Android Jetpack 라이브러리는 멋진 Android 앱을 더 간편하게 개발하기 위해 활용할 수 있는 라이브러리 컬렉션입니다. 이 라이브러리를 사용하면 권장사항을 따를 수 있고 상용구 코드를 작성하지 않아도 되며 복잡한 작업을 간소화할 수 있어 앱 로직과 같이 중요한 코드에 집중할 수 있습니다.

Android Jetpack 라이브러리에 포함된 Android 아키텍처 구성요소는 효율적인 아키텍처로 앱을 디자인하는 데 도움을 줍니다. 아키텍처 구성요소는 권장사항으로, 앱 아키텍처를 안내하는 역할을 합니다.

앱 아키텍처는 일련의 디자인 규칙입니다. 아키텍처는 주택의 청사진과 마찬가지로 앱의 구조를 제시합니다. 훌륭한 앱 아키텍처를 사용하면 유연하고 확장 가능하며 향후 유지관리가 가능한 강력한 코드를 만들 수 있습니다.

이 Codelab에서는 앱 데이터를 저장하는 아키텍처 구성요소 중 하나인 ViewModel을 사용하는 방법을 알아봅니다. 저장된 데이터는 프레임워크에서 구성 변경이나 다른 이벤트 중에 활동과 프래그먼트가 소멸되고 다시 생성되는 경우에도 손실되지 않습니다.

기본 요건

  • GitHub에서 소스 코드를 다운로드하여 Android 스튜디오에서 여는 방법
  • Kotlin에서 활동과 프래그먼트를 사용하여 기본 Android 앱을 만들고 실행하는 방법
  • Material 텍스트 필드 및 TextView, Button과 같은 일반적인 UI 위젯에 관한 지식
  • 앱에서 뷰 결합을 사용하는 방법
  • 활동 및 프래그먼트 수명 주기의 기본사항
  • Android 스튜디오에서 Logcat을 사용하여 앱에 로깅 정보를 추가하고 로그를 읽는 방법

학습할 내용

  • Android 앱 아키텍처의 기본사항
  • 앱에서 ViewModel 클래스를 사용하는 방법
  • ViewModel을 사용하여 기기 구성 변경 시에도 UI 데이터를 유지하는 방법
  • Kotlin의 지원 속성
  • Material Design 구성요소 라이브러리에서 MaterialAlertDialog를 사용하는 방법

빌드할 항목

  • 사용자가 글자가 뒤섞인 단어를 추측할 수 있는 Unscramble 게임 앱

필요한 항목

  • Android 스튜디오가 설치된 컴퓨터
  • Unscramble 앱의 시작 코드

2. 시작 앱 개요

게임 개요

Unscramble 앱은 플레이어 한 명이 즐기는 단어 맞추기 게임입니다. 앱은 글자가 뒤섞인 단어를 한 번에 하나씩 표시하고, 플레이어는 임의 배열된 모든 글자를 사용해 단어를 추측해야 합니다. 단어를 맞히면 점수를 획득하고 맞히지 못하면 횟수에 제한 없이 재시도할 수 있습니다. 현재 표시된 단어를 건너뛰는 옵션도 있습니다. 앱의 왼쪽 상단에 단어 수, 즉 현재 게임에서 플레이한 단어의 수가 표시됩니다. 게임당 단어 10개가 제시됩니다.

8edd6191a40a57e1.png 992bf57f066caf49.png b82a9817b5ec4d11.png

시작 코드 다운로드하기

이 Codelab은 시작 코드를 제공하며 여기서 학습한 기능을 사용하여 시작 코드를 확장할 수 있습니다. 시작 코드에는 이전의 Codelab을 통해 익숙해진 코드와 그렇지 않은 코드가 모두 있을 수 있습니다. 익숙하지 않은 코드에 관해서는 이후 Codelab에서 자세히 알아봅니다.

GitHub의 시작 코드를 사용하는 경우 폴더 이름은 android-basics-kotlin-unscramble-app-starter입니다. Android 스튜디오에서 프로젝트를 열 때 이 폴더를 선택하세요.

  1. 프로젝트에 제공된 GitHub 저장소 페이지로 이동합니다.
  2. 브랜치 이름이 Codelab에 지정된 브랜치 이름과 일치하는지 확인합니다. 예를 들어 다음 스크린샷에서 브랜치 이름은 main입니다.

1e4c0d2c081a8fd2.png

  1. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 팝업을 엽니다.

1debcf330fd04c7b.png

  1. 팝업에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
  2. 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
  3. ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.

Android 스튜디오에서 프로젝트 열기

  1. Android 스튜디오를 시작합니다.
  2. Welcome to Android Studio 창에서 Open을 클릭합니다.

d8e9dbdeafe9038a.png

참고: Android 스튜디오가 이미 열려 있는 경우 File > Open 메뉴 옵션을 대신 선택합니다.

8d1fda7396afe8e5.png

  1. 파일 브라우저에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 8de56cba7583251f.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.

시작 코드 개요

  1. Android 스튜디오에서 시작 코드가 있는 프로젝트를 엽니다.
  2. Android 기기나 에뮬레이터에서 앱을 실행합니다.
  3. 게임에서 제시되는 몇 개 단어를 Submit 버튼과 Skip 버튼을 탭하며 플레이해봅니다. 버튼을 탭하면 다음 단어가 표시되고 단어 수가 높아집니다.
  4. Submit 버튼을 탭할 때만 점수가 높아지는 것을 확인합니다.

시작 코드의 문제

게임을 플레이하면서 다음과 같은 버그가 발생할 수도 있습니다.

  1. Submit 버튼을 클릭해도 앱이 플레이어가 추측한 단어를 확인하지 않습니다. 플레이어가 항상 득점합니다.
  2. 게임을 종료할 방법이 없습니다. 한 게임에서 단어 10개를 초과하여 플레이할 수 있습니다.
  3. 게임 화면에 글자가 뒤섞인 단어, 플레이어의 점수, 단어 수가 표시됩니다. 기기나 에뮬레이터를 회전하여 화면 방향을 변경하면 현재 단어, 점수, 단어 수가 지워지고 게임이 처음부터 다시 시작됩니다.

앱의 주요 문제

기기 방향이 변경되는 경우와 같이 구성이 변경되는 동안 시작 앱이 앱 상태와 데이터를 저장하고 복원하지 않습니다.

onSaveInstanceState() 콜백을 사용하여 이 문제를 해결할 수 있습니다. 하지만 onSaveInstanceState() 메서드를 사용하려면 번들에 상태를 저장하는 추가 코드를 작성하고 이 상태를 검색하는 로직을 구현해야 합니다. 저장할 수 있는 데이터의 양이 최소한입니다.

이러한 문제는 이 개발자 과정에서 배우는 Android 아키텍처 구성요소를 사용하여 해결할 수 있습니다.

시작 코드 둘러보기

다운로드한 시작 코드에는 게임 화면 레이아웃이 미리 디자인되어 있습니다. 이 개발자 과정에서는 게임 로직을 구현하는 데 중점을 둡니다. 아키텍처 구성요소를 사용하여 권장 앱 아키텍처를 구현하고 앞에서 언급한 문제를 해결합니다. 시작하는 데 도움이 되는 다음과 같은 몇 가지 파일을 둘러보겠습니다.

game_fragment.xml

  • Design 뷰에서 res/layout/game_fragment.xml을 엽니다.
  • 여기에는 앱의 유일한 화면인 게임 화면의 레이아웃이 포함됩니다.
  • 이 레이아웃에는 플레이어의 단어를 위한 텍스트 필드와 점수 및 단어 수를 표시하는 TextViews가 포함됩니다. 게임 플레이를 위한 안내와 버튼(Submit, Skip)도 있습니다.

main_activity.xml

단일 게임 프래그먼트가 있는 기본 활동 레이아웃을 정의합니다.

res/values 폴더

이 폴더의 리소스 파일에 대해 잘 알고 있을 것입니다.

  • colors.xml에는 앱에서 사용되는 테마 색상이 있습니다.
  • strings.xml에는 앱에 필요한 모든 문자열이 있습니다.
  • themes 폴더와 styles 폴더에는 앱의 UI 맞춤설정이 있습니다.

MainActivity.kt

활동의 콘텐츠 뷰를 main_activity.xml.로 설정하기 위한 기본 템플릿 생성 코드가 있습니다.

ListOfWords.kt

이 파일에는 게임에서 사용되는 단어의 목록뿐만 아니라 게임당 최대 단어 수와 올바르게 추측한 모든 단어에 적용할 점수가 상수로 포함되어 있습니다.

GameFragment.kt

앱의 유일한 프래그먼트로, 여기서 대부분의 게임 작업이 발생합니다.

  • 글자가 뒤섞인 현재 단어(currentScrambledWord), 단어 수(currentWordCount), 점수(score)를 나타내는 변수가 정의됩니다.
  • game_fragment 뷰에 액세스할 권한이 있는 결합 객체 인스턴스 binding이 정의됩니다.
  • onCreateView() 함수는 결합 객체를 사용하여 game_fragment 레이아웃 XML을 확장합니다.
  • onViewCreated() 함수는 버튼 클릭 리스너를 설정하고 UI를 업데이트합니다.
  • onSubmitWord()Submit 버튼의 클릭 리스너입니다. 이 함수는 글자가 뒤섞인 다음 단어를 표시하고 텍스트 필드를 지우고 플레이어의 단어를 검증하지 않고 점수와 단어 수를 높입니다.
  • onSkipWord()Skip 버튼의 클릭 리스너입니다. 이 함수는 점수를 제외하고 onSubmitWord()와 유사하게 UI를 업데이트합니다.
  • getNextScrambledWord()는 단어 목록에서 임의의 단어를 선택하여 단어에 있는 글자를 섞는 도우미 함수입니다.
  • restartGame() 함수와 exitGame() 함수는 각각 게임을 다시 시작하고 종료하는 데 사용됩니다. 이 함수는 나중에 사용합니다.
  • setErrorTextField()는 텍스트 필드의 내용을 지우고 오류 상태를 재설정합니다.
  • updateNextWordOnScreen() 함수는 글자가 뒤섞인 새로운 단어를 표시합니다.

3. 앱 아키텍처 알아보기

아키텍처는 앱에서 클래스 간의 책임을 할당하는 가이드라인을 제공합니다. 앱 아키텍처가 잘 디자인되어 있으면 향후 앱을 확장하고 더 많은 기능을 포함할 수 있습니다. 또한 팀 공동작업도 더 간편합니다.

가장 일반적인 아키텍처 원칙은 관심사 분리, 모델에서 UI 만들기입니다.

관심사 분리

관심사 분리 디자인 원칙은 각각 별개의 책임이 있는 여러 클래스로 앱을 나눠야 한다는 원칙입니다.

모델에서 UI 만들기

또 하나의 중요한 원칙은 모델에서 UI를 만들어야 한다는 것입니다. 가급적 지속적인 모델을 권장합니다. 모델은 앱의 데이터 처리를 담당하는 구성요소로, 앱의 Views 객체 및 앱 구성요소와 독립되어 있으므로 앱의 수명 주기 및 관련 문제의 영향을 받지 않습니다.

Android 아키텍처의 기본 클래스 또는 구성요소는 UI 컨트롤러(활동/프래그먼트), ViewModel, LiveData, Room입니다. 이러한 구성요소는 수명 주기의 복잡성을 어느 정도 처리하므로 수명 주기 관련 문제를 피하는 데 도움이 됩니다. LiveDataRoom에 관해서는 이후 Codelab에서 알아봅니다.

다음 다이어그램은 아키텍처의 기본적인 부분을 보여줍니다.

597074ed0d08947b.png

UI 컨트롤러(활동/프래그먼트)

활동과 프래그먼트는 UI 컨트롤러입니다. UI 컨트롤러는 화면에 뷰를 그리고 사용자 이벤트나 사용자가 상호작용하는 다른 모든 UI 관련 동작을 캡처하여 UI를 제어합니다. 앱의 데이터 또는 데이터에 관한 모든 의사 결정 로직은 UI 컨트롤러 클래스에 포함되어서는 안 됩니다.

Android 시스템은 특정 사용자 상호작용을 기반으로 또는 메모리 부족과 같은 시스템 조건으로 인해 언제든지 UI 컨트롤러를 제거할 수 있습니다. 이러한 이벤트는 개발자가 직접 제어할 수 없기 때문에, UI 컨트롤러에 앱 데이터나 상태를 저장해서는 안 됩니다. 대신 데이터에 관한 의사 결정 로직을 ViewModel에 추가해야 합니다.

예를 들어 Unscramble 앱에서 글자가 뒤섞인 단어, 점수, 단어 수는 프래그먼트(UI 컨트롤러)에 표시됩니다. 글자가 뒤섞인 다음 단어를 고르고 점수와 단어 수를 계산하는 등의 의사 결정 코드는 ViewModel에 포함해야 합니다.

ViewModel

ViewModel은 뷰에 표시되는 앱 데이터의 모델입니다. 모델은 앱의 데이터 처리를 담당하는 구성요소로, 아키텍처 원칙에 따라 모델에서 UI가 도출되는 앱을 만들 수 있습니다.

ViewModel은 Android 프레임워크에서 활동이나 프래그먼트가 소멸되고 다시 생성될 때 폐기되지 않는 앱 관련 데이터를 저장합니다. ViewModel 객체는 구성이 변경되는 동안 자동으로 유지되어(활동 또는 프래그먼트 인스턴스처럼 소멸되지 않음) 보유하고 있는 데이터가 다음 활동 또는 프래그먼트 인스턴스에 즉시 사용될 수 있습니다.

앱에 ViewModel을 구현하려면 아키텍처 구성요소 라이브러리에서 가져온 ViewModel 클래스를 확장하고 이 클래스 내에 앱 데이터를 저장합니다.

요약:

프래그먼트/활동(UI 컨트롤러)의 책임

ViewModel의 책임

활동 및 프래그먼트는 뷰와 데이터를 화면에 그리고 사용자 이벤트에 응답합니다.

ViewModel은 UI에 필요한 모든 데이터를 보유하고 처리합니다. 뷰 계층 구조(예: 뷰 결합 객체)에 액세스하거나 활동 또는 프래그먼트의 참조를 보유해서는 안 됩니다.

4. ViewModel 추가

이 작업에서는 앱 데이터(글자가 뒤섞인 단어, 단어 수, 점수)를 저장하는 ViewModel을 앱에 추가합니다.

앱의 아키텍처는 다음과 같은 방식으로 구성됩니다. MainActivityGameFragment가 포함되어 있으며, GameFragmentGameViewModel에 있는 게임 관련 정보에 액세스합니다.

2b29a13dde3481c3.png

  1. Android 스튜디오의 Android 창에서 Gradle Scripts 폴더 아래에 있는 build.gradle(Module:Unscramble.app) 파일을 엽니다.
  2. 앱에서 ViewModel을 사용하려면 dependencies 블록 내에 ViewModel 라이브러리 종속 항목이 있는지 확인합니다. 이 단계는 이미 완료되어 있습니다. 최신 버전의 라이브러리에 따라, 생성된 코드의 라이브러리 버전 번호가 다를 수 있습니다.
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'

Codelab에서 버전을 언급하더라도 항상 최신 버전의 라이브러리를 사용하는 것이 좋습니다.

  1. GameViewModel이라는 새 Kotlin 클래스 파일을 만듭니다. Android 창에서 ui.game 폴더를 마우스 오른쪽 버튼으로 클릭합니다. New > Kotlin File/Class를 선택합니다.

d48361a4f73d4acb.png

  1. 이름을 GameViewModel로 지정하고 목록에서 Class를 선택합니다.
  2. GameViewModelViewModel에서 서브클래스로 분류되도록 변경합니다. ViewModel은 추상 클래스이므로 앱에 사용할 수 있도록 확장해야 합니다. 아래의 GameViewModel 클래스 정의를 참고하세요.
class GameViewModel : ViewModel() {
}

ViewModel을 프래그먼트에 연결하기

ViewModel을 UI 컨트롤러(활동/프래그먼트)에 연결하려면 UI 컨트롤러 내에 ViewModel에 관한 참조(객체)를 만듭니다.

이 단계에서는 해당하는 UI 컨트롤러(GameFragment) 내에 GameViewModel의 객체 인스턴스를 만듭니다.

  1. GameFragment 클래스 상단에 GameViewModel 유형의 속성을 추가합니다.
  2. by viewModels() Kotlin 속성 위임을 사용하여 GameViewModel을 초기화합니다. 이 작업에 관해서는 다음 섹션에서 자세히 설명합니다.
private val viewModel: GameViewModel by viewModels()
  1. Android 스튜디오에서 메시지가 표시되면 androidx.fragment.app.viewModels를 가져옵니다.

Kotlin 속성 위임

Kotlin에는 각 변경 가능한(var) 속성에 자동으로 생성된 기본 getter 함수와 setter 함수가 있습니다. 값을 할당하거나 속성 값을 읽을 때 setter 및 getter 함수가 호출됩니다.

읽기 전용 속성(val)의 경우 변경 가능한 속성과 약간 다릅니다. 기본적으로 getter 함수만 생성됩니다. 읽기 전용 속성의 값을 읽을 때 이 getter 함수가 호출됩니다.

Kotlin에서 속성 위임을 사용하면 getter-setter 책임을 다른 클래스에 넘길 수 있습니다.

이 클래스(대리자 클래스라고 함)는 속성의 getter 및 setter 함수를 제공하고 변경사항을 처리합니다.

대리자 속성은 다음과 같이 by 절 및 대리자 클래스 인스턴스를 사용하여 정의됩니다.

// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()

앱에서 다음과 같이 기본 GameViewModel 생성자를 사용하여 뷰 모델을 초기화하는 경우

private val viewModel = GameViewModel()

기기에서 구성이 변경되는 동안 앱이 viewModel 참조의 상태를 손실하게 됩니다. 예를 들어 기기를 회전하면 활동이 소멸된 후 다시 생성되고 초기 상태의 새로운 뷰 모델 인스턴스가 다시 시작됩니다.

대신 속성 위임 접근 방식을 사용해 viewModel 객체의 책임을 viewModels라는 별도의 클래스에 위임합니다. 즉, viewModel 객체에 액세스하면 이 객체는 대리자 클래스 viewModels에 의해 내부적으로 처리됩니다. 대리자 클래스는 첫 번째 액세스 시 자동으로 viewModel 객체를 만들고 이 값을 구성 변경 중에도 유지했다가 요청이 있을 때 반환합니다.

5. ViewModel로 데이터 이동

앱의 UI 데이터를 UI 컨트롤러(Activity/Fragment 클래스)에서 분리하면 위에서 설명한 단일 책임 원칙을 더 잘 준수할 수 있습니다. 활동과 프래그먼트는 화면에 뷰와 데이터를 그리고, ViewModel은 UI에 필요한 모든 데이터를 보유하고 처리합니다.

이 작업에서는 GameFragment 클래스에서 GameViewModel 클래스로 데이터 변수를 이동합니다.

  1. 데이터 변수 score, currentWordCount, currentScrambledWordGameViewModel 클래스로 이동합니다.
class GameViewModel : ViewModel() {

    private var score = 0
    private var currentWordCount = 0
    private var currentScrambledWord = "test"
...
  1. 확인되지 않는 참조에 관한 오류가 표시됩니다. 이는 속성이 ViewModel에만 공개되며 UI 컨트롤러에서 액세스할 수 없기 때문입니다. 이 오류는 다음에 해결합니다.

이 문제를 해결하기 위해 public 속성의 공개 상태 수정자를 만들 수는 없습니다. 데이터를 다른 클래스가 수정해서는 안 됩니다. 이렇게 수정하는 것은 위험합니다. 외부 클래스가 뷰 모델에 지정된 게임 규칙을 준수하지 않는 예기치 않은 방식으로 데이터를 변경할 수 있기 때문입니다. 예를 들어 외부 클래스가 score를 음수 값으로 변경할 수 있습니다.

ViewModel 내에서는 데이터를 수정할 수 있어야 하므로 데이터는 privatevar이어야 합니다. ViewModel 외부에서는 데이터를 읽을 수 있지만 수정할 수는 없어야 하므로 데이터는 publicval로 노출되어야 합니다. 이 동작을 실현하기 위해 Kotlin에는 지원 속성이라는 기능이 있습니다.

지원 속성

지원 속성을 사용하면 정확한 객체가 아닌 getter에서 무언가를 반환할 수 있습니다.

이미 배운 대로, Kotlin 프레임워크는 모든 속성별로 getter와 setter를 생성합니다.

getter 메서드와 setter 메서드 중 하나 또는 둘 모두를 재정의하여 고유한 맞춤 동작을 제공할 수 있습니다. 지원 속성을 구현하려면 읽기 전용 버전의 데이터를 반환하도록 getter 메서드를 재정의합니다. 지원 속성의 예:

// Declare private mutable variable that can only be modified
// within the class it is declared.
private var _count = 0

// Declare another public immutable field and override its getter method.
// Return the private property's value in the getter method.
// When count is accessed, the get() function is called and
// the value of _count is returned.
val count: Int
   get() = _count

앱에서 앱 데이터가 ViewModel에만 공개되는 예를 생각해보겠습니다.

ViewModel 클래스 내부:

  • _count 속성이 private이며 변경 가능합니다. 따라서 ViewModel 클래스 내에서만 액세스하고 수정할 수 있습니다. 이름 지정 규칙은 private 속성 앞에 밑줄을 붙이는 것입니다.

ViewModel 클래스 외부:

  • Kotlin의 기본 공개 상태 한정자는 public이므로, count는 공개 속성이며 UI 컨트롤러와 같은 다른 클래스에서 액세스할 수 있습니다. get() 메서드만 재정의되므로, 이 속성은 변경할 수 없으며 읽기 전용입니다. 외부 클래스가 이 속성에 액세스하면 _count의 값을 반환하며, 이 값은 수정할 수 없습니다. 이에 따라 ViewModel에 있는 앱 데이터가 외부 클래스로 인해 원치 않게, 안전하지 않게 변경되지 않도록 보호되지만 외부 호출자는 값에 안전하게 액세스할 수 있습니다.

currentScrambledWord에 지원 속성 추가하기

  1. GameViewModel에서 currentScrambledWord 선언을 변경하여 지원 속성을 추가합니다. 이제 GameViewModel 내에서만 _currentScrambledWord에 액세스하고 수정할 수 있습니다. UI 컨트롤러인 GameFragment는 읽기 전용 속성 currentScrambledWord를 사용하여 값을 읽을 수 있습니다.
private var _currentScrambledWord = "test"
val currentScrambledWord: String
   get() = _currentScrambledWord
  1. GameFragment에서 읽기 전용 viewModel 속성인 currentScrambledWord를 사용하도록 updateNextWordOnScreen() 메서드를 업데이트합니다.
private fun updateNextWordOnScreen() {
   binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}
  1. GameFragment에서 onSubmitWord() 메서드와 onSkipWord() 메서드 내의 코드를 삭제합니다. 이러한 메서드는 나중에 구현합니다. 이제 오류 없이 코드를 컴파일할 수 있습니다.

6. ViewModel의 수명 주기

프레임워크는 활동이나 프래그먼트의 범위가 유지되는 동안 ViewModel을 유지합니다. ViewModel은 소유자가 화면 회전과 같은 구성 변경으로 인해 소멸되는 경우에도 소멸되지 않습니다. 소유자의 새 인스턴스는 다음 다이어그램과 같이 기존 ViewModel 인스턴스에 다시 연결됩니다.

91227008b74bf4bb.png

ViewModel 수명 주기 이해하기

GameViewModelGameFragment에 로깅을 추가하여 ViewModel의 수명 주기를 더 잘 이해할 수 있도록 합니다.

  1. GameViewModel.kt에서 로그 구문이 포함된 init 블록을 추가합니다.
class GameViewModel : ViewModel() {
   init {
       Log.d("GameFragment", "GameViewModel created!")
   }

   ...
}

Kotlin은 객체 인스턴스 초기화 중에 필요한 초기 설정 코드를 배치하는 장소로 이니셜라이저 블록(init 블록이라고도 함)을 제공합니다. 이니셜라이저 블록에는 init 키워드 뒤에 중괄호 {}가 붙습니다. 이 코드 블록은 객체 인스턴스가 처음 생성되어 초기화될 때 실행됩니다.

  1. GameViewModel 클래스에서 onCleared() 메서드를 재정의합니다. ViewModel은 연결된 프래그먼트가 분리되거나 활동이 완료되면 소멸됩니다. ViewModel이 소멸되기 직전에 onCleared() 콜백이 호출됩니다.
  2. GameViewModel 수명 주기를 추적하도록 onCleared() 내에 로그 구문을 추가합니다.
override fun onCleared() {
    super.onCleared()
    Log.d("GameFragment", "GameViewModel destroyed!")
}
  1. onCreateView()내의 GameFragment에서 결합 객체의 참조를 가져온 후에 프래그먼트 생성을 기록하는 로그 구문을 추가합니다. onCreateView() 콜백은 프래그먼트가 처음으로 생성되면 트리거될 뿐만 아니라 구성 변경과 같은 모든 이벤트에 관해 프래그먼트가 다시 생성될 때마다 트리거됩니다.
override fun onCreateView(
   inflater: LayoutInflater, container: ViewGroup?,
   savedInstanceState: Bundle?
): View {
   binding = GameFragmentBinding.inflate(inflater, container, false)
   Log.d("GameFragment", "GameFragment created/re-created!")
   return binding.root
}
  1. GameFragment에서 상응하는 활동과 프래그먼트가 소멸될 때 호출되는 onDetach() 콜백 메서드를 재정의합니다.
override fun onDetach() {
    super.onDetach()
    Log.d("GameFragment", "GameFragment destroyed!")
}
  1. Android 스튜디오에서 앱을 실행하고 Logcat 창을 열고 GameFragment로 필터링합니다. GameFragmentGameViewModel이 생성된 것을 볼 수 있습니다.
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
  1. 기기나 에뮬레이터에서 자동 회전 설정을 켜고 화면 방향을 몇 번 바꿉니다. GameFragment는 매번 소멸되고 다시 생성되지만 GameViewModel은 한 번만 생성되며 매번 호출별로 다시 생성되거나 소멸되지 않습니다.
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
  1. 게임을 종료하거나 뒤로 화살표를 사용하여 앱에서 나갑니다. GameViewModel이 소멸되고 콜백 onCleared()가 호출됩니다. GameFragment가 소멸됩니다.
com.example.android.unscramble D/GameFragment: GameViewModel destroyed!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!

7. ViewModel 채우기

이 작업에서는 다음 단어를 가져오고, 플레이어의 단어를 검증하여 점수를 올리고, 단어 수를 확인하여 게임을 종료하는 도우미 메서드로 GameViewModel을 더 채웁니다.

지연 초기화

변수를 선언할 때는 일반적으로 사전에 변수에 초깃값을 제공합니다. 그러나 아직 값을 할당할 준비가 되지 않았다면 나중에 초기화할 수 있습니다. Kotlin에서 속성을 나중에 초기화하려면 지연된 초기화를 의미하는 lateinit 키워드를 사용합니다. 속성을 사용하기 전에 초기화할 것을 보장한다면 lateinit으로 속성을 선언할 수 있습니다. 변수가 초기화될 때까지는 변수에 메모리가 할당되지 않습니다. 초기화하기 전에 변수에 액세스하려고 하면 앱이 비정상 종료됩니다.

다음 단어 가져오기

다음 기능을 사용하여 GameViewModel 클래스에서 getNextWord() 메서드를 만듭니다.

  • allWordsList에서 무작위로 단어를 가져와 currentWord.에 할당합니다.
  • currentWord의 글자를 임의 배열하여 글자가 뒤섞인 단어를 만들고 currentScrambledWord에 할당합니다.
  • 글자가 뒤섞인 단어와 뒤섞이지 않은 단어가 동일한 경우를 처리합니다.
  • 게임 중에 같은 단어가 두 번 표시되지 않도록 합니다.

GameViewModel 클래스에서 다음 단계를 구현합니다.

  1. GameViewModel,에서 MutableList<String> 유형의 새로운 클래스 변수 wordsList를 추가하여 게임에 사용하는 단어의 목록을 보유함으로써 반복된 단어가 제시되지 않도록 합니다.
  2. 플레이어가 추측해야 할 단어를 보유하는 currentWord라는 또 다른 클래스 변수를 추가합니다. 이 속성은 나중에 초기화할 것이므로 lateinit 키워드를 사용합니다.
private var wordsList: MutableList<String> = mutableListOf()
private lateinit var currentWord: String
  1. init 블록 위에 매개변수가 없고 아무것도 반환하지 않는 getNextWord()라는 새 private 메서드를 추가합니다.
  2. allWordsList에서 무작위로 단어를 가져와 currentWord에 할당합니다.
private fun getNextWord() {
   currentWord = allWordsList.random()
}
  1. getNextWord()에서 currentWord 문자열을 문자 배열로 변환하여 tempWord라는 새 val에 할당합니다. 단어의 글자를 뒤섞으려면 Kotlin 메서드 shuffle()을 사용하여 이 배열의 글자 순서를 바꿉니다.
val tempWord = currentWord.toCharArray()
tempWord.shuffle()

ArrayMutableList와 비슷하지만 초기화될 때 고정 크기를 가집니다. Array는 크기를 확장하거나 축소할 수 없는(크기를 조절하려면 배열을 복사해야 함) 반면, MutableList에는 add() 함수와 remove() 함수가 있어 크기를 늘리고 줄일 수 있습니다.

  1. 글자 순서를 바꾼 단어가 원래 단어와 동일한 경우가 있습니다. shuffle 호출 주위에 다음 while 루프를 추가하여 글자가 뒤섞인 단어가 원래 단어와 동일하지 않을 때까지 루프를 계속합니다.
while (String(tempWord).equals(currentWord, false)) {
    tempWord.shuffle()
}
  1. 단어가 이미 사용되었는지 여부를 확인하는 if-else 블록을 추가합니다. wordsListcurrentWord가 포함된 경우 getNextWord()를 호출하고 포함되지 않은 경우 새롭게 글자가 뒤섞인 단어로 _currentScrambledWord 값을 업데이트하고, 이 새 단어를 wordsList에 추가하고, 단어 수를 높입니다.
if (wordsList.contains(currentWord)) {
    getNextWord()
} else {
    _currentScrambledWord = String(tempWord)
    ++currentWordCount
    wordsList.add(currentWord)
}
  1. 다음과 같이 완성된 getNextWord() 메서드를 참고하세요.
/*
* Updates currentWord and currentScrambledWord with the next word.
*/
private fun getNextWord() {
   currentWord = allWordsList.random()
   val tempWord = currentWord.toCharArray()
   tempWord.shuffle()

   while (String(tempWord).equals(currentWord, false)) {
       tempWord.shuffle()
   }
   if (wordsList.contains(currentWord)) {
       getNextWord()
   } else {
       _currentScrambledWord = String(tempWord)
       ++currentWordCount
       wordsList.add(currentWord)
   }
}

currentScrambledWord 지연 초기화

글자가 뒤섞인 다음 단어를 가져오는 getNextWord() 메서드를 만들었습니다. GameViewModel이 처음 초기화될 때 이 메서드를 호출합니다. init 블록을 사용하여 클래스에서 현재 단어와 같은 lateinit 속성을 초기화합니다. 그 결과, 화면에 표시되는 첫 번째 단어는 test가 아니라 글자가 뒤섞인 단어가 됩니다.

  1. 앱을 실행합니다. 첫 번째 단어는 항상 'test'입니다.
  2. 앱 시작 시 글자가 뒤섞인 단어를 표시하려면 getNextWord() 메서드를 호출해야 합니다. 그러면 currentScrambledWord가 업데이트됩니다. GameViewModelinit 블록 내에 있는 getNextWord() 메서드를 호출합니다.
init {
    Log.d("GameFragment", "GameViewModel created!")
    getNextWord()
}
  1. _currentScrambledWord 속성에 lateinit 한정자를 추가합니다. 초깃값이 제공되지 않았으므로 String 데이터 유형의 명시적인 언급을 추가합니다.
private lateinit var _currentScrambledWord: String
  1. 앱을 실행합니다. 앱이 시작될 때 글자가 뒤섞인 새로운 단어가 표시됩니다. 대단하죠!

8edd6191a40a57e1.png

도우미 메서드 추가하기

다음으로 ViewModel 내의 데이터를 처리하고 수정하는 도우미 메서드를 추가합니다. 이 메서드는 이후 작업에서 사용합니다.

  1. GameViewModel 클래스에서 nextWord().라는 다른 메서드를 추가합니다. 목록에서 다음 단어를 가져오고 단어 수가 MAX_NO_OF_WORDS보다 적으면 true를 반환합니다.
/*
* Returns true if the current word count is less than MAX_NO_OF_WORDS.
* Updates the next word.
*/
fun nextWord(): Boolean {
    return if (currentWordCount < MAX_NO_OF_WORDS) {
        getNextWord()
        true
    } else false
}

8. 대화상자

시작 코드에서 단어 10개가 플레이된 후에도 게임이 종료되지 않았습니다. 사용자가 단어 10개를 진행한 후에 게임이 종료되고 최종 점수 대화상자가 표시되도록 앱을 수정합니다. 또한 사용자에게 다시 플레이하거나 게임을 종료할 옵션도 제공합니다.

62aa368820ffbe31.png

이제 처음으로 앱에 대화상자를 추가합니다. 대화상자는 사용자에게 결정을 내리거나 추가 정보를 입력하라는 메시지를 표시하는 작은 창(화면)입니다. 일반적으로 대화상자는 전체 화면을 가득 채우지 않으며 사용자가 조치를 취해야 계속 진행할 수 있도록 합니다. Android는 다양한 유형의 대화상자를 제공합니다. 이 Codelab에서는 알림 대화상자에 관해 배웁니다.

알림 대화상자 분석

f8650ca15e854fe4.png

  1. 알림 대화상자
  2. 제목(선택사항)
  3. 메시지
  4. 텍스트 버튼

최종 점수 대화상자 구현하기

머티리얼 디자인 구성요소 라이브러리의 MaterialAlertDialog를 사용하여 머티리얼 가이드라인을 따르는 대화상자를 앱에 추가합니다. 대화상자는 UI와 관련이 있으므로, GameFragment가 최종 점수 대화상자 생성과 표시를 담당합니다.

  1. 먼저 score 변수에 지원 속성을 추가합니다. GameViewModel에서 score 변수 선언을 다음과 같이 변경합니다.
private var _score = 0
val score: Int
   get() = _score
  1. GameFragment에서 showFinalScoreDialog()라는 비공개 함수를 추가합니다. MaterialAlertDialog를 만들려면 MaterialAlertDialogBuilder 클래스를 사용하여 대화상자의 부분을 단계별로 빌드합니다. 프래그먼트의 requireContext() 메서드를 사용하여 콘텐츠를 전달하는 MaterialAlertDialogBuilder 생성자를 호출합니다. requireContext() 메서드는 null이 아닌 Context를 반환합니다.
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
}

이름에서 알 수 있듯이 Context는 애플리케이션이나 활동, 프래그먼트의 컨텍스트나 현재 상태를 나타냅니다. 활동, 프래그먼트, 애플리케이션과 관련된 정보를 포함하고 있으며 일반적으로 리소스, 데이터베이스, 기타 시스템 서비스에 액세스하는 데 사용됩니다. 이 단계에서는 프래그먼트 컨텍스트를 전달하여 알림 대화상자를 만듭니다.

Android 스튜디오에서 메시지가 표시되면 import com.google.android.material.dialog.MaterialAlertDialogBuilder 처리합니다.

  1. 알림 대화상자의 제목을 설정하는 코드를 추가하고 strings.xml의 문자열 리소스를 사용합니다.
MaterialAlertDialogBuilder(requireContext())
   .setTitle(getString(R.string.congratulations))
  1. 최종 점수를 표시하도록 메시지를 설정한 후 이전에 추가한 읽기 전용 버전의 점수 변수(viewModel.score)을 사용합니다.
   .setMessage(getString(R.string.you_scored, viewModel.score))
  1. 뒤로 키를 눌러 알림 대화상자를 취소할 수 없도록 만듭니다. setCancelable() 메서드를 사용하고 false를 전달하면 됩니다.
    .setCancelable(false)
  1. setNegativeButton() 메서드와 setPositiveButton() 메서드를 사용하여 EXITPLAY AGAIN의 두 텍스트 버튼을 추가합니다. 람다에서 각각 exitGame()restartGame()을 호출합니다.
    .setNegativeButton(getString(R.string.exit)) { _, _ ->
        exitGame()
    }
    .setPositiveButton(getString(R.string.play_again)) { _, _ ->
        restartGame()
    }

처음 접하실 수도 있지만 이 구문은 setNegativeButton(getString(R.string.exit), { _, _ -> exitGame()})의 약식 표현입니다. 여기서 setNegativeButton() 메서드는 두 매개변수, 즉 String과 함수 DialogInterface.OnClickListener()(람다로 표현 가능)를 사용합니다. 전달되는 마지막 인수가 함수이면 괄호 바깥에 람다 표현식을 배치할 수 있습니다. 이를 후행 람다 구문이라고 합니다. 람다를 괄호 안에 배치하거나 바깥에 배치하여 코드를 작성하는 방법이 모두 허용됩니다. setPositiveButton 함수의 경우도 마찬가지입니다.

  1. 마지막으로 알림 대화상자를 만들고 표시하는 show()를 추가합니다.
      .show()
  1. 다음과 같이 완성된 showFinalScoreDialog() 메서드를 참고하세요.
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
       .setTitle(getString(R.string.congratulations))
       .setMessage(getString(R.string.you_scored, viewModel.score))
       .setCancelable(false)
       .setNegativeButton(getString(R.string.exit)) { _, _ ->
           exitGame()
       }
       .setPositiveButton(getString(R.string.play_again)) { _, _ ->
           restartGame()
       }
       .show()
}

9. Submit 버튼의 OnClickListener 구현

이 작업에서는 추가한 ViewModel과 알림 대화상자를 사용하여 Submit 버튼 클릭 리스너의 게임 로직을 구현합니다.

글자가 뒤섞인 단어 표시하기

  1. 아직 삭제하지 않았다면 GameFragment에서 Submit 버튼을 탭할 때 호출되는 onSubmitWord() 내의 코드를 삭제합니다.
  2. viewModel.nextWord() 메서드의 반환 값에 관한 확인을 추가합니다. true인 경우 다른 단어를 사용할 수 있으므로 updateNextWordOnScreen()을 사용하여 글자가 뒤섞인 단어를 화면에 업데이트합니다. true가 아닌 경우 게임이 종료되므로 최종 점수가 포함된 알림 대화상자를 표시합니다.
private fun onSubmitWord() {
    if (viewModel.nextWord()) {
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. 앱을 실행합니다. 몇 단어를 플레이해봅니다. 아직 Skip 버튼을 구현하지 않았으므로 단어를 건너뛸 수 없습니다.
  2. 텍스트 필드가 업데이트되지 않으므로 플레이어가 이전 단어를 수동으로 삭제해야 합니다. 알림 대화상자의 최종 점수가 항상 0입니다. 이러한 버그를 다음 단계에서 수정합니다.

a4c660e212ce2c31.png 12a42987a0edd2c4.png

플레이어의 단어를 검증하는 도우미 메서드 추가하기

  1. GameViewModel에서 매개변수가 없고 반환 값이 없는 새로운 비공개 메서드 increaseScore()를 추가합니다. score 변수를 SCORE_INCREASE 단위로 높입니다.
private fun increaseScore() {
   _score += SCORE_INCREASE
}
  1. GameViewModel에서 Boolean을 반환하고 String(플레이어의 단어)을 매개변수로 반환하는 isUserWordCorrect()라는 도우미 메서드를 추가합니다.
  2. isUserWordCorrect()에서 플레이어의 단어를 검증하고 플레이어가 추측한 단어가 올바르면 점수를 높입니다. 그러면 알림 대화상자의 최종 점수가 업데이트됩니다.
fun isUserWordCorrect(playerWord: String): Boolean {
   if (playerWord.equals(currentWord, true)) {
       increaseScore()
       return true
   }
   return false
}

텍스트 필드 업데이트

텍스트 필드에 오류 표시하기

머티리얼 텍스트 필드의 경우 TextInputLayout에 오류 메시지를 표시하는 기능이 내장되어 있습니다. 예를 들어 다음 텍스트 필드에서 라벨 색상이 변경되고 오류 아이콘이 표시되고 오류 메시지가 표시됩니다.

520cc685ae1317ac.png

텍스트 필드에 오류를 표시하려면 코드에서 동적으로 또는 레이아웃 파일에서 정적으로 오류 메시지를 설정하면 됩니다. 다음은 코드에서 오류를 설정하고 재설정하는 예시입니다.

// Set error text
passwordLayout.error = getString(R.string.error)

// Clear error text
passwordLayout.error = null

시작 코드에는 도우미 메서드인 setErrorTextField(error: Boolean)가 이미 정의되어 있어서 텍스트 필드에 오류를 설정하고 재설정할 수 있습니다. 오류를 텍스트 필드에 표시할지 여부에 따라 truefalse를 입력 매개변수로 사용하여 이 메서드를 호출합니다.

시작 코드의 코드 스니펫

private fun setErrorTextField(error: Boolean) {
   if (error) {
       binding.textField.isErrorEnabled = true
       binding.textField.error = getString(R.string.try_again)
   } else {
       binding.textField.isErrorEnabled = false
       binding.textInputEditText.text = null
   }
}

이 작업에서는 onSubmitWord() 메서드를 구현합니다. 단어가 제출되면 사용자가 추측한 단어를 원래 단어와 비교해 검증합니다. 단어가 옳으면 다음 단어로 이동합니다(게임이 종료된 경우 대화상자 표시). 단어가 옳지 않으면 텍스트 필드에 오류를 표시하고 현재 단어를 유지합니다.

  1. GameFragment,onSubmitWord() 시작 부분에 playerWord라는 val을 만듭니다. 플레이어의 단어를 binding 변수의 텍스트 필드에서 추출하여 저장합니다.
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    ...
}
  1. onSubmitWord()playerWord 선언 아래에서 플레이어의 단어를 검증합니다. playerWord를 전달하고 isUserWordCorrect() 메서드를 사용하여 플레이어의 단어를 확인하는 if 문을 추가합니다.
  2. if 블록 내에서 텍스트 필드를 재설정하고 false를 전달하는 setErrorTextField를 호출합니다.
  3. 기존 코드를 if 블록 내부로 이동합니다.
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }
}
  1. 사용자 단어가 옳지 않은 경우 텍스트 필드에 오류 메시지를 표시합니다. 위의 if 블록에 else 블록을 추가하고 true를 전달하는 setErrorTextField()를 호출합니다. 완성된 onSubmitWord() 메서드는 다음과 같습니다.
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    } else {
        setErrorTextField(true)
    }
}
  1. 앱을 실행합니다. 몇 단어를 플레이해봅니다. 플레이어의 단어가 옳은 경우 Submit 버튼을 클릭하면 단어가 지워지며, 단어가 옳지 않으면 'Try again'이라는 메시지가 표시됩니다. Skip 버튼이 여전히 작동하지 않는 것을 볼 수 있습니다. 이 구현은 다음 작업에서 추가합니다.

a10c7d77aa26b9db.png

10. Skip 버튼 구현하기

이 작업에서는 Skip 버튼을 클릭한 경우를 처리하는 onSkipWord()의 구현을 추가합니다.

  1. onSubmitWord()와 마찬가지로 onSkipWord() 메서드에 조건을 추가합니다. true인 경우 화면에 단어를 표시하고 텍스트 필드를 재설정합니다. false이고 이번 게임에 더 이상 남은 단어가 없는 경우 최종 점수가 포함된 알림 대화상자를 표시합니다.
/*
* Skips the current word without changing the score.
*/
private fun onSkipWord() {
    if (viewModel.nextWord()) {
        setErrorTextField(false)
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. 앱을 실행하고 게임을 플레이합니다. Skip 버튼과 Submit 버튼이 의도한 대로 작동하는 것을 확인할 수 있습니다. 멋집니다!

11. ViewModel에 데이터가 보존되는지 확인

이 작업에서는 GameFragment에 로깅을 추가하여 구성 변경 중에 앱 데이터가 ViewModel에 보존되는지 관찰합니다. GameFragmentcurrentWordCount에 액세스하려면 지원 속성을 사용하여 읽기 전용 버전을 노출해야 합니다.

  1. GameViewModel에서 currentWordCount 변수를 마우스 오른쪽 버튼으로 클릭하고 Refactor > Rename...을 선택합니다. 새 이름 앞에 밑줄을 붙입니다(_currentWordCount).
  2. 지원 필드를 추가합니다.
private var _currentWordCount = 0
val currentWordCount: Int
   get() = _currentWordCount
  1. GameFragmentonCreateView()에서 return 문 위에 앱 데이터, 단어, 점수, 단어 수를 출력하는 또 다른 로그를 추가합니다.
Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
       "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
  1. Android 스튜디오에서 Logcat을 열고 GameFragment로 필터링합니다. 앱을 실행하고 몇 단어를 플레이해봅니다. 기기의 방향을 변경합니다. 프래그먼트(UI 컨트롤러)가 소멸된 후 다시 생성됩니다. 로그를 관찰하면 이제 점수와 단어 수가 증가하는 것을 확인할 수 있습니다.
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: Word: oimfnru Score: 0 WordCount: 1
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9

기기 방향이 변경되는 동안 ViewModel의 앱 데이터가 보존된 것을 확인할 수 있습니다. 이후 Codelab에서 LiveData 및 데이터 결합을 사용하여 UI에서 점수 값과 단어 수를 업데이트합니다.

12. 게임 재시작 로직 업데이트하기

  1. 앱을 다시 실행하고 한 게임의 모든 단어를 플레이해봅니다. Congratulations! 알림 대화상자에서 PLAY AGAIN을 클릭합니다. 단어 수가 이제 MAX_NO_OF_WORDS 값에 도달하여 앱에서 더 플레이할 수 없습니다. 게임을 처음부터 다시 플레이하려면 단어 수를 0으로 재설정해야 합니다.
  2. 앱 데이터를 재설정하려면 GameViewModelreinitializeData()라는 메서드를 추가합니다. 점수와 단어 수를 0으로 설정합니다. 단어 목록을 지우고 getNextWord() 메서드를 호출합니다.
/*
* Re-initializes the game data to restart the game.
*/
fun reinitializeData() {
   _score = 0
   _currentWordCount = 0
   wordsList.clear()
   getNextWord()
}
  1. GameFragmentrestartGame() 메서드 상단에서 새로 생성된 메서드인 reinitializeData()를 호출합니다.
private fun restartGame() {
   viewModel.reinitializeData()
   setErrorTextField(false)
   updateNextWordOnScreen()
}
  1. 앱을 다시 실행합니다. 게임을 플레이합니다. Congratulations 대화상자에 도달하면 Play Again을 클릭합니다. 이제 게임을 다시 플레이할 수 있습니다.

최종 앱을 정리해보겠습니다. 게임에서는 플레이어가 추측하도록 글자가 뒤섞인 단어 10개가 무작위로 제시됩니다. 단어를 Skip하거나 추측한 후 Submit을 탭할 수 있습니다. 추측한 단어가 옳으면 점수가 높아집니다. 추측한 단어가 틀렸으면 텍스트 필드에 오류 상태가 표시됩니다. 새로운 단어가 제시될 때마다 단어 수도 높아집니다.

아직은 화면에 표시되는 점수와 단어 수가 업데이트되지 않습니다. 그러나 정보는 여전히 뷰 모델에 저장되고 기기 회전과 같은 구성 변경 도중에도 보존됩니다. 화면에 표시되는 점수와 단어 수는 이후 Codelab에서 업데이트합니다.

f332979d6f63d0e5.png 2803d4855f5d401f.png

10개 단어가 끝나면 게임이 종료되고 최종 점수가 포함된 알림 대화상자가 표시되어 게임을 종료하거나 다시 플레이할 수 있는 옵션이 제공됩니다.

d8e0111f5f160ead.png

수고하셨습니다. 첫 ViewModel을 만들고 데이터를 저장하셨습니다.

13. 솔루션 코드

GameFragment.kt

import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.example.android.unscramble.R
import com.example.android.unscramble.databinding.GameFragmentBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder

/**
 * Fragment where the game is played, contains the game logic.
 */
class GameFragment : Fragment() {

    private val viewModel: GameViewModel by viewModels()

    // Binding object instance with access to the views in the game_fragment.xml layout
    private lateinit var binding: GameFragmentBinding

    // Create a ViewModel the first time the fragment is created.
    // If the fragment is re-created, it receives the same GameViewModel instance created by the
    // first fragment

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout XML file and return a binding object instance
        binding = GameFragmentBinding.inflate(inflater, container, false)
        Log.d("GameFragment", "GameFragment created/re-created!")
        Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
                "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
        return binding.root
    }

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

        // Setup a click listener for the Submit and Skip buttons.
        binding.submit.setOnClickListener { onSubmitWord() }
        binding.skip.setOnClickListener { onSkipWord() }
        // Update the UI
        updateNextWordOnScreen()
        binding.score.text = getString(R.string.score, 0)
        binding.wordCount.text = getString(
            R.string.word_count, 0, MAX_NO_OF_WORDS)
    }

    /*
    * Checks the user's word, and updates the score accordingly.
    * Displays the next scrambled word.
    * After the last word, the user is shown a Dialog with the final score.
    */
    private fun onSubmitWord() {
        val playerWord = binding.textInputEditText.text.toString()

        if (viewModel.isUserWordCorrect(playerWord)) {
            setErrorTextField(false)
            if (viewModel.nextWord()) {
                updateNextWordOnScreen()
            } else {
                showFinalScoreDialog()
            }
        } else {
            setErrorTextField(true)
        }
    }

    /*
    * Skips the current word without changing the score.
    */
    private fun onSkipWord() {
        if (viewModel.nextWord()) {
            setErrorTextField(false)
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }

    /*
     * Gets a random word for the list of words and shuffles the letters in it.
     */
    private fun getNextScrambledWord(): String {
        val tempWord = allWordsList.random().toCharArray()
        tempWord.shuffle()
        return String(tempWord)
    }

    /*
    * Creates and shows an AlertDialog with the final score.
    */
    private fun showFinalScoreDialog() {
        MaterialAlertDialogBuilder(requireContext())
            .setTitle(getString(R.string.congratulations))
            .setMessage(getString(R.string.you_scored, viewModel.score))
            .setCancelable(false)
            .setNegativeButton(getString(R.string.exit)) { _, _ ->
                exitGame()
            }
            .setPositiveButton(getString(R.string.play_again)) { _, _ ->
                restartGame()
            }
            .show()
    }

    /*
     * Re-initializes the data in the ViewModel and updates the views with the new data, to
     * restart the game.
     */
    private fun restartGame() {
        viewModel.reinitializeData()
        setErrorTextField(false)
        updateNextWordOnScreen()
    }

    /*
     * Exits the game.
     */
    private fun exitGame() {
        activity?.finish()
    }

    override fun onDetach() {
        super.onDetach()
        Log.d("GameFragment", "GameFragment destroyed!")
    }

    /*
    * Sets and resets the text field error status.
    */
    private fun setErrorTextField(error: Boolean) {
        if (error) {
            binding.textField.isErrorEnabled = true
            binding.textField.error = getString(R.string.try_again)
        } else {
            binding.textField.isErrorEnabled = false
            binding.textInputEditText.text = null
        }
    }

    /*
     * Displays the next scrambled word on screen.
     */
    private fun updateNextWordOnScreen() {
        binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
    }
}

GameViewModel.kt

import android.util.Log
import androidx.lifecycle.ViewModel

/**
 * ViewModel containing the app data and methods to process the data
 */
class GameViewModel : ViewModel(){
    private var _score = 0
    val score: Int
        get() = _score

    private var _currentWordCount = 0
    val currentWordCount: Int
        get() = _currentWordCount

    private lateinit var _currentScrambledWord: String
    val currentScrambledWord: String
        get() = _currentScrambledWord

    // List of words used in the game
    private var wordsList: MutableList<String> = mutableListOf()
    private lateinit var currentWord: String

    init {
        Log.d("GameFragment", "GameViewModel created!")
        getNextWord()
    }

    override fun onCleared() {
        super.onCleared()
        Log.d("GameFragment", "GameViewModel destroyed!")
    }

    /*
    * Updates currentWord and currentScrambledWord with the next word.
    */
    private fun getNextWord() {
        currentWord = allWordsList.random()
        val tempWord = currentWord.toCharArray()
        tempWord.shuffle()

        while (String(tempWord).equals(currentWord, false)) {
            tempWord.shuffle()
        }
        if (wordsList.contains(currentWord)) {
            getNextWord()
        } else {
            _currentScrambledWord = String(tempWord)
            ++_currentWordCount
            wordsList.add(currentWord)
        }
    }

    /*
    * Re-initializes the game data to restart the game.
    */
    fun reinitializeData() {
       _score = 0
       _currentWordCount = 0
       wordsList.clear()
       getNextWord()
    }

    /*
    * Increases the game score if the player's word is correct.
    */
    private fun increaseScore() {
        _score += SCORE_INCREASE
    }

    /*
    * Returns true if the player word is correct.
    * Increases the score accordingly.
    */
    fun isUserWordCorrect(playerWord: String): Boolean {
        if (playerWord.equals(currentWord, true)) {
            increaseScore()
            return true
        }
        return false
    }

    /*
    * Returns true if the current word count is less than MAX_NO_OF_WORDS
    */
    fun nextWord(): Boolean {
        return if (_currentWordCount < MAX_NO_OF_WORDS) {
            getNextWord()
            true
        } else false
    }
}

14. 요약

  • Android 앱 아키텍처 가이드라인에서는 책임이 서로 다른 클래스를 분리하고 모델에서 UI를 만들도록 권장합니다.
  • UI 컨트롤러는 Activity 또는 Fragment와 같은 UI 기반 클래스입니다. UI 컨트롤러에는 UI 및 운영체제 상호작용을 처리하는 로직만 포함해야 합니다. UI에 표시할 데이터의 소스여서는 안 됩니다. UI에 표시할 데이터와 모든 관련 로직은 ViewModel에 배치합니다.
  • ViewModel 클래스는 UI 관련 데이터를 저장하고 관리합니다. ViewModel 클래스를 사용하면 화면 회전과 같이 구성을 변경할 때도 데이터를 유지할 수 있습니다.
  • ViewModel은 권장되는 Android 아키텍처 구성요소 중 하나입니다.

15. 자세히 알아보기