냐냐한 Dev Study/Android

[학습] 레모네이드 앱 프로젝트 (Android,Kotlin)

소소하냐 2023. 1. 10. 14:14

"[학습] Android Basics in Kotlin 과정 소개" > Unit 1: Kotlin 기초 > "App에 버튼 생성"  중 다음 내용을 요약했습니다.

 

- Project: Lemonade app (원문, 원문 최종 업데이트일: 2022.03.23)


학습일: 2023.01.10

 

- 앱 소개: 레모네이드 한 잔이 나올 때까지 레몬 즙을 짜는 간단한 대화형 앱 

- 사용 요소: ConstraintLayout, TextView, ImageView

- 주요 학습 내용

  • 지금까지 배운 내용으로 "다운받은 소스"에 필요한 코드를 추가하여 앱이 동작하도록 합니다. 
  • 테스트 코드가 "다운받은 소스"에 포함되어있습니다. 추가한 코드가 테스트를 통과하는지 확인합니다. 

 

- 앱 화면

 

레모네이드 앱

 

프로젝트 코드 다운로드 

1. 아래 링크로 이동합니다. 

    https://github.com/google-developer-training/android-basics-kotlin-lemonade-app 

 

2. 해당 Git 브랜치가 "main"으로 설정되어 있는지 확인합니다.

 

3. Code 버튼 클릭 > "Download ZIP" 버튼 클릭하여 다운로드 받은 후 압축을 풉니다. 

 

4. 안드로이드 스튜디오에서 해당 프로젝트를 엽니다. (File > Open)

 

5. Run 버튼을 클릭하여, 프로젝트가 정상적으로 실행되는 것을 확인합니다. 

   

정상적으로 실행은 되지만, 아직 완성된 프로젝트는 아닙니다. 

필요한 코드를 추가하여, 앱을 완성해야 합니다. 

// TODO 주석을 참고 하여 코드를 완성합니다. 

 

레이아웃 코드 

레이아웃 코드는 다운로드 받은 코드에서 변경된 내용이 없습니다. 

다운로드 받은 코드 그대로이지만, 참고용으로 함께 공유합니다. 

 

최종 레이아웃 코드

<?xml version="1.0" encoding="utf-8"?>
<!--
  ~ Copyright (C) 2021 The Android Open Source Project.
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~     http://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing, software
  ~ distributed under the License is distributed on an "AS IS" BASIS,
  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
  -->
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/constraint_Layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/text_action"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/lemon_select"
        android:textSize="18sp"
        android:textAppearance="@style/TextAppearance.AppCompat.Body1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toTopOf="@id/image_lemon_state"/>

    <ImageView
        android:id="@+id/image_lemon_state"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

문자열 리소스

문자열 리소스를 한글로 변경했습니다. 

squeeze_count의 경우 단위 테스트에서 사용되는 부분이 있어 영어를 그대로 사용했습니다. 

 

최종 문자열 리소스 코드 

<!--
  ~ Copyright (C) 2021 The Android Open Source Project.
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~     http://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing, software
  ~ distributed under the License is distributed on an "AS IS" BASIS,
  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
  -->
<resources>
    <string name="app_name">Lemonade</string>
    <string name="lemon_select">레몬을 클릭하여 선택합니다!</string>
    <string name="lemon_squeeze">레몬을 클릭하여 즙을 냅니다!</string>
    <string name="lemon_drink">레모네이드를 클릭하여 마십니다!</string>
    <string name="lemon_empty_glass">클릭하여 다시 시작합니다!</string>
    <string name="squeeze_count">Squeeze count: %1$d, keep squeezing!</string>
</resources>

 

Kotlin 소스 코드

// TODO:로 표시된 주석을 참고하여 작업을 완료한 저의 소스 코드입니다. (각자의 방식으로 더 좋은 코드를 작성하실 수 있습니다.)

// TODO: 주석을 한글로 변경하였습니다. 

// TODO: 표시된 부분에 코드를 추가했습니다. 

 

최종 Kotlin 소스 코드

/*
 * Copyright (C) 2021 The Android Open Source Project.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.example.lemonade

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.ImageView
import android.widget.TextView
import com.google.android.material.snackbar.Snackbar

private const val TAG = "MainActivity"

class MainActivity : AppCompatActivity() {

    /**
     * DO NOT ALTER ANY VARIABLE OR VALUE NAMES OR THEIR INITIAL VALUES.
     *
     * Anything labeled var instead of val is expected to be changed in the functions but DO NOT
     * alter their initial values declared here, this could cause the app to not function properly.
     */
    private val LEMONADE_STATE = "LEMONADE_STATE"
    private val LEMON_SIZE = "LEMON_SIZE"
    private val SQUEEZE_COUNT = "SQUEEZE_COUNT"
    // SELECT represents the "pick lemon" state
    private val SELECT = "select"
    // SQUEEZE represents the "squeeze lemon" state
    private val SQUEEZE = "squeeze"
    // DRINK represents the "drink lemonade" state
    private val DRINK = "drink"
    // RESTART represents the state where the lemonade has been drunk and the glass is empty
    private val RESTART = "restart"
    // Default the state to select
    private var lemonadeState = "select"
    // Default lemonSize to -1
    private var lemonSize = -1
    // Default the squeezeCount to -1
    private var squeezeCount = -1

    private var lemonTree = LemonTree()
    private var lemonImage: ImageView? = null


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

        // === DO NOT ALTER THE CODE IN THE FOLLOWING IF STATEMENT ===
        if (savedInstanceState != null) {
            lemonadeState = savedInstanceState.getString(LEMONADE_STATE, "select")
            lemonSize = savedInstanceState.getInt(LEMON_SIZE, -1)
            squeezeCount = savedInstanceState.getInt(SQUEEZE_COUNT, -1)
        }
        // === END IF STATEMENT ===

        lemonImage = findViewById(R.id.image_lemon_state)
        setViewElements()
        lemonImage!!.setOnClickListener {
            // TODO: 이미지를 클릭했을 때 상태를 처리하는 메서드 호출
            clickLemonImage()
        }
        lemonImage!!.setOnLongClickListener {
            // TODO:'false'를 squeeze 횟수를 보여주는 함수로 바꾸기
            showSnackbar()
        }
    }

    /**
     * === DO NOT ALTER THIS METHOD ===
     *
     * This method saves the state of the app if it is put in the background.
     */
    override fun onSaveInstanceState(outState: Bundle) {
        outState.putString(LEMONADE_STATE, lemonadeState)
        outState.putInt(LEMON_SIZE, lemonSize)
        outState.putInt(SQUEEZE_COUNT, squeezeCount)
        super.onSaveInstanceState(outState)
    }

    /**
     * Clicking will elicit a different response depending on the state.
     * This method determines the state and proceeds with the correct action.
     */
    private fun clickLemonImage() {
        // TODO: 'if' 또는 'when' 조건문을 사용하여 이미지가 클릭됐을 때 lemonadeState를 추적합니다.
        //  레모네이드를 만드는 과정의 다음 단계로 상태를 변경해야 할 수도 있습니다.
        //  (또는 레몬을 짜는 경우에는 현재 상태를 약간 변경해야합니다.) 이 조건문에서 작업되어야 합니다.

        // TODO: SELECT 상태에서 이미지가 클릭되면, 상태는 SQUEEZE가 되어야 합니다.
        //  - lemonSize 변수는 LemonTree class의 'pick()' 메서드를 사용하여 설정됩니다.
        //  - squeezeCount는 아직 레몬을 짜지 않았으므로 0이어야 합니다.
        if(lemonadeState == SELECT){
            lemonadeState = SQUEEZE
            lemonSize =lemonTree.pick()
            squeezeCount = 0
            // 몇 번 짜야하는지 표시가 안되서, 임의로 추가한 LOG입니다.
            Log.i(TAG, "lemonSize:$lemonSize")
        }
        // TODO: SQUEEZE 상태에서 이미지가 클릭되면 squeezeCount는 1씩 증가시키고, lemonSize는 1씩 감소시켜야 합니다.
        //  - lemonSize가 0이 되면, 주스가 되고 DRINK 상태가 되어야 합니다.
        //  - 또한, lemonSize는 더이상 관련이 없으므로 -1로 설정되어야 합니다.
        else if(lemonadeState == SQUEEZE){
            squeezeCount++

            if(--lemonSize == 0){
                lemonadeState = DRINK
            }
        }
        // TODO: DRINK 상태에서 이미지가 클릭되면 상태는 RESTART가 되어야 합니다.
        else if(lemonadeState == DRINK){
            lemonadeState = RESTART
            lemonSize = -1
        }
        // TODO: RESTART 상태에서 이미지가 클릭되면 상태는 SELECT가 되어야 합니다.
        else if(lemonadeState == RESTART){
            lemonadeState = SELECT
        }

        // TODO: 마지막으로, 함수가 종료되기 전에 뷰 요소들을 설정하여 UI를 상태에 맞게 합니다.
        setViewElements()
    }

    /**
     * Set up the view elements according to the state.
     */
    private fun setViewElements() {
        val textAction: TextView = findViewById(R.id.text_action)
        // TODO: lemonadeState를 추적하는 조건문을 설정합니다.

        // TODO: 각 상태에서, textAction TextView는 string resource 파일에서 알맞은 문자열로 설정합니다.
        //  문자열들은 상태에 맞게 이름지어져 있습니다.

        // TODO: 또한, 각 상태에서, lemonImage는 drawable resource에서 알맞은 drawable로 설정합니다.
        //  drawable은 문자열과 같은 이름을 갖지만, drawable은 문자열이 아니라는 것을 기억하십시오.

        when(lemonadeState){
            SELECT->{
                textAction.text = getString(R.string.lemon_select)
                lemonImage!!.setImageResource(R.drawable.lemon_tree)
            }
            SQUEEZE->{
                textAction.text = getString(R.string.lemon_squeeze)
                lemonImage!!.setImageResource(R.drawable.lemon_squeeze)
            }
            DRINK->{
                textAction.text = getString(R.string.lemon_drink)
                lemonImage!!.setImageResource(R.drawable.lemon_drink)
            }
            RESTART->{
                textAction.text = getString(R.string.lemon_empty_glass)
                lemonImage!!.setImageResource(R.drawable.lemon_restart)
            }

        }
    }

    /**
     * === DO NOT ALTER THIS METHOD ===
     *
     * Long clicking the lemon image will show how many times the lemon has been squeezed.
     */
    private fun showSnackbar(): Boolean {
        if (lemonadeState != SQUEEZE) {
            return false
        }
        val squeezeText = getString(R.string.squeeze_count, squeezeCount)
        Snackbar.make(
            findViewById(R.id.constraint_Layout),
            squeezeText,
            Snackbar.LENGTH_SHORT
        ).show()
        return true
    }
}

/**
 * A Lemon tree class with a method to "pick" a lemon. The "size" of the lemon is randomized
 * and determines how many times a lemon needs to be squeezed before you get lemonade.
 */
class LemonTree {
    fun pick(): Int {
        return (2..4).random()
    }
}

 

  • clickLemonImage()에는 if문, setViewElements()에는 when문을 사용했습니다.
  • SELECT 상태에서 클릭할 때, 레몬을 몇 번 짜야하는지(lemonSize) 표시가 없어 Log를 추가하였습니다: 
    Log.i(TAG, "lemonSize:$lemonSize") 

--------------------------------

앱 테스트

app > java > com.example.lemonade (androidTest) > LemonadeTests 파일에 있는 테스트 코드가 모두 성공하는지 확인합니다. 

 

클래스 내의 모든 테스트 실행

방법 1. 프로젝트 창에서 LemonadeTests 우클릭 > Run 'LemonadeTests' 선택 

 

방법 2. LemonadeTests 파일내에서 class LemonadeTests : BaseTest() 왼쪽 옆에 있는 ▶▶ 버튼을 클릭 

 

개별 테스트 실행

방법 3. LemonadeTests 파일내에서 각 함수 옆에 있는 ▶ 버튼 클릭

 

 

앱 테스트에 대한 자세한 내용은 원문 > "7. 테스트 안내" 부분을 참고 하세요. 

 

앱 테스트 실패에 대한 내용 추가 (2023.01.11)

어제(2023.01.10)까지는 테스트가 계속 성공했습니다. 

오늘(2023.01.11) 다시 테스트를 실행하니, 일부 테스트가 실패합니다. 그것도 랜덤하게 실패합니다. 

각 테스트를 개별로 테스트를 하면 성공합니다. 순서를 타는건가 했는데 아니었습니다. 

 

에뮬레이터에서 설정(Settings) 창이 열려있으면 오류가 발생했습니다.

해당 원인은 모르겠습니다만, 설정 창이 영향을 주는 것 같습니다. 

 

오류 메세지는 아래와 같이, TextView 와 view.getText()가 일치 하지 않는 다는 내용입니다. 

androidx.test.espresso.base.DefaultFailureHandler$AssertionFailedWithCauseError: 'an instance of android.widget.TextView and view.getText() equals string from resource id: <2131689523>' doesn't match the selected view.
Expected: an instance of android.widget.TextView and view.getText() equals string from resource id: <2131689523> [lemon_squeeze] value: 레몬을 클릭하여 즙을 냅니다!
Got: view.getText() was "레몬을 클릭하여 선택합니다!"

하지만, 이러한 오류가 왜 발생했을까 추적하기 위해, 오류 메세지 발생 전 내용을 확인해 보았습니다. 

01-11 01:57:19.825 21611 21611 I ViewInteraction: Performing 'single click' action on view view.getId() is <2131230939/com.example.lemonade:id/image_lemon_state>
01-11 01:57:19.827 586 719 I InputDispatcher: Dropping event because there is no touchable window or gesture monitor at (539, 1253) in display 0.
01-11 01:57:19.827 586 719 W InputDispatcher: Permission denied: injecting event from pid 21611 uid 10149
01-11 01:57:20.087 586 1053 W InputManager-JNI: Input channel object 'b213090 Splash Screen com.example.lemonade.test (client)' was disposed without first being removed with the input manager!
01-11 01:57:20.153 21611 21611 I ViewInteraction: Checking 'MatchesViewAssertion{viewMatcher=(view has effective visibility <VISIBLE> and view.getGlobalVisibleRect() to return non-empty rectangle)}' assertion on view has drawable resource 2131165325
01-11 01:57:20.153 21611 21611 E ViewAssertions: '(view has effective visibility <VISIBLE> and view.getGlobalVisibleRect() to return non-empty rectangle)' check could not be performed because view 'has drawable resource 2131165325' was not found.
01-11 01:57:20.157 21611 21611 I ViewInteraction: Performing 'single click' action on view view.getId() is <2131230939/com.example.lemonade:id/image_lemon_state>

'single click'을 수행하는데, 해당 이벤트를 수행하지 못하고 삭제되었다는 내용으로 읽힙니다. 

그러니, 이벤트가 하나씩 밀리게 되니 원하는 상태가 나오지 않는 것이죠. 

 

우선 원인은 알 수 없지만. ㅠㅠ 

에뮬레이터에서 실행되는 다른 창들도 닫아주고, 설정(Settings) 창은 필히 닫아주고 다시 실행해보세요. 

저의 경우 실패하던 것이 이전처럼 정상적으로 통과되었습니다.

 

 

 

끝까지 읽어 주셔서 감사합니다. ^^