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

[5주차] Android Basics In Kotlin Unit 3: Navigation (2)(3)

by akxmcse 2021. 11. 22.

PATHWAY 2 Introduction to the Navigation component

1. 프래그먼트 및 프래그먼트 수명 주기

  • 프래그먼트는 간단하게 말해 앱의 사용자 인터페이스에서 재사용 가능한 부분이다.
  • 액티비티와 마찬가지로 프래그먼트는 수명 주기가 있고 사용자 입력에 응답할 수 있다.
  • 프래그먼트는 액티비티가 화면에 표시될 때 액티비티의 뷰 계층 구조 내에 항상 포함된다.
  • 재사용성과 모듈성을 강조하므로 단일 활동에서 여러 프래그먼트를 동시에 호스팅할 수도 있다. 

프래그먼트 수명 주기와 각 상태 간 전환

2. 프래그먼트 구현

  • LetterListFragment 와 WordListFragment를 만들어 각각의 xml 파일에 기존 activity xml 내용을 넣어준다.
  • Activity와 마찬가지로 레이아웃을 확장하고 개별 뷰를 바인딩해야 한다.
    (build.gradle 파일의 buildFeatures 섹션에서 viewBinding 속성을 사용해줘야 한다.)

 

  • LetterListFragment에서 뷰 바인딩을 구현하려면 먼저 null을 허용하는 FragmentLetterListBinding 참조를 가져와야 한다. 그리고 밑줄 없이 바인딩이라는 새 속성을 만들고 _binding!!와 동일하게 설정한다.
class LetterListFragment : Fragment() {
    private var _binding: FragmentLetterListBinding? = null
    private val binding get() = _binding!!
    ...
 }

 

  • onCreate()를 구현하려면 setHasOptionsMenu()를 호출한다.
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setHasOptionsMenu(true)
}

 

  • 프래그먼트에서는 레이아웃이 onCreateView()에서 확장된다. 뷰를 확장하고 _binding 값을 설정한 다음 루트 뷰를 반환하여 onCreateView()를 구현한다.
override fun onCreateView(
   inflater: LayoutInflater, container: ViewGroup?,
   savedInstanceState: Bundle?
): View? {
   _binding = FragmentLetterListBinding.inflate(inflater, container, false)
   val view = binding.root
   return view
}

 

  • recycler 뷰 속성을 만들고 설정해준 뒤  onDestroyView()에서 뷰가 더 이상 없으므로 _binding 속성을 null로 재설정한다.
override fun onDestroyView() {
   super.onDestroyView()
   _binding = null
}

 

  • MainActivity에서 isLinearLayoutManager 속성을 복사하여 recyclerView 속성 선언 바로 아래에 배치한다. 
  • 모든 기능을 LetterListFragment로 이동했으므로 MainActivity 클래스에서 해야 할 작업은 프래그먼트가 뷰에 표시되도록 레이아웃을 확장하는 것뿐이다. 따라서 MainActivity에서 onCreate()를 제외하고 모든 항목을 삭제한다.

비슷한 과정을 거쳐 기존에 만들었던 각 activity들을 fragment로 변환할 수 있다.

 

 

3. Jetpack Navigation Component

Android Jetpack에서 제공하는 Navigation(탐색) component를 통해 앱에서 간단하거나 복잡한 탐색 구현을 처리할 수 있다. 탐색 구성요소에는 Words 앱에서 탐색을 구현하는 데 사용할 세 가지 주요 부분이 있다.

  • Navigation Graph: 탐색 그래프는 앱에서 탐색을 시각적으로 보여주는 XML 파일이다. 파일은 개별 활동 및 프래그먼트에 상응하는 대상과 한 대상에서 다른 대상으로 이동하려고 코드에서 사용할 수 있는 대상 사이의 작업으로 구성된다. 레이아웃 파일과 마찬가지로 Android 스튜디오는 탐색 그래프에 대상과 작업을 추가하는 시각적 편집기를 제공한다.
  • NavHost: NavHost는 활동 내에서 탐색 그래프의 대상을 표시하는 데 사용된다. 프래그먼트 간에 이동하면 NavHost에 표시되는 대상이 업데이트된다. MainActivity에서 NavHostFragment라는 기본 제공 구현을 사용한다.
  • NavController: NavController 객체를 사용하면 NavHost에 표시되는 대상 간 탐색을 제어할 수 있다. 인텐트 대신 탐색 구성요소를 사용하면 NavController의 navigate() 메서드를 호출하여 표시되는 프래그먼트를 교체할 수 있다. 

navigation 라이브러리를 사용하려면 Gradle 파일을 먼저 수정해줘야 한다.

  • build.gradle 파일의 buildscript > ext에서 material_version 아래의 nav_version 2.3.1로 설정한다.
  • navigation dependencies 을 추가한다.
  • buildscript > dependencies에서 클래스 경로를 추가한다.
  • plugins 내에서 androidx.navigation.safeargs.kotlin을 추가한다.

 

4. Navigation graph 사용

탐색 그래프(또는 줄여서 NavGraph)는 앱 탐색의 가상 매핑이다. 각 화면(또는 이 경우의 프래그먼트)은 이동할 수 있는 가능한 '대상'이 되고, NavGraph는 각 대상이 서로 관련되는 방식을 보여주는 XML 파일로 나타낼 수 있다.

  • activity_main.xml 파일이 프래그먼트의 NavHost 역할을 할 FragmentContainerView를 포함하도록 한다.
  • 이 시점부터 앱의 모든 탐색은 FragmentContainerView 내에서 실행된다.
  • FragmentContainerView에 app:defaultnavHost라는 속성을 추가하고 "true"로 설정하면 프래그먼트 컨테이너가 탐색 계층 구조와 상호작용할 수 있다.
  • app:navGraph라는 속성을 추가하고 "@navigation/nav_graph"로 설정하면 앱의 프래그먼트가 서로 이동할 수 있는 방법을 정의하는 XML 파일을 가리게 된다.
  • 탐색 그래프 파일(File > New > Android Resource File)을 추가하고 리소스 유형을 Navigation으로 설정한다.
  • FragmentContainerView navGraph 속성에서 nav_graph를 이미 참조했으므로 새 대상을 추가하려면 화면 상단 왼쪽에서 새로 만들기 버튼을 클릭하여 각 프래그먼트의 대상을 만든다.
  • 한 프래그먼트에 마우스 포인터를 놓으면 프래그먼트 오른쪽에 ConstraintLayout 제약 주듯이 원형 연결 지점이 나타나는데 이를 다른 프래그먼트쪽으로 끌어당기면 두 프래그먼트가 연결된다.
  • 이 작업의 이름이 action_letterListFragment_to_wordListFragment와 같이 자동으로 설정되고 이는 코드에서 참조가 가능하다.
  • 인수 지정, 시작 대상 설정까지 마치면 탐색 그래프에 기반한 코드가 생성되어 방금 만든 탐색 작업을 사용할 수 있다. 
  • LetterAdapter.kt를 열어 버튼의 onClickListener() 콘텐츠를 삭제하고 대신 방금 만든 탐색 작업을 넣어준다.

 

5. MainActivity 구성

  • navController 속성을 만든다. onCreate에서 설정되므로 lateinit로 표시된다.
  •  onCreate()에서 setContentView()를 호출한 후 nav_host_fragment(FragmentContainerView의 ID임) 참조를 가져와서 navController 속성에 할당한다.
  • onCreate()에서 setupActionBarWithNavController()를 호출하여 navController를 전달하면 LetterListFragment의 메뉴 옵션과 같은 작업 모음(앱 바) 버튼이 표시된다.
  • onSupportNavigateUp()을 구현한다. XML에서 defaultNavHost true로 설정하는 것과 함께 이 메서드를 사용하면 위로 버튼을 처리할 수 있다.

 

6. 프래그먼트에서 인수 가져오기

  • WordListFragment에서 letterId 속성을 만든다.
  •  onCreateView() onViewCreated()가 아닌 onCreate()를 재정의한다.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    arguments?.let {
        letterId = it.getString(LETTER).toString()
    }
}
  • onViewCreated()의 activity?.intent?.extras?.getString(LETTER).toString()을 letterId로 바꾼다.

이러한 과정을 통해 인텐트 없이 하나의 Activity 내에서 두 화면 간에 이동을 할 수 있다.

 

PATHWAY 3 Architecture components

1. 앱 아키텍처와 ViewModel

The most common architectural principles are: separation of concerns and driving UI from a model.

 

앱 아키텍처 가이드  |  Android 개발자  |  Android Developers

앱 아키텍처 가이드 이 가이드에는 고품질의 강력한 앱을 빌드하기 위한 권장사항 및 권장 아키텍처가 포함되어 있습니다. 이 페이지는 Android 프레임워크 기본을 잘 아는 사용자를 대상으로 합

developer.android.com

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

 

2. ViewModel 프래그먼트 연결 및 데이터 이동

MainActivity에 GameFragment가 포함되어 있으며, GameFragment는 GameViewModel에 있는 게임 관련 정보에 액세스한다.

  • GameViewModel이라는 새 Kotlin 클래스 파일을 만들고 GameViewModel의 객체 인스턴스를 만든다.
private val viewModel: GameViewModel by viewModels()
  • GameFragment 클래스에서 GameViewModel 클래스로 데이터 변수를 이동한다.
class GameViewModel : ViewModel() {

    private var score = 0
    private var currentWordCount = 0
    private var currentScrambledWord = "test"
...
  • 속성이 ViewModel에만 공개되며 UI 컨트롤러에서 액세스할 수 없기 때문에 오류가 난다.
    -> GameViewModel에서 currentScrambledWord 선언을 변경하여 지원 속성을 추가한다.
private var _currentScrambledWord = "test"
val currentScrambledWord: String
   get() = _currentScrambledWord
  • GameFragment에서 읽기 전용 viewModel 속성인 currentScrambledWord를 사용하도록 updateNextWordOnScreen() 메서드를 업데이트한다.
private fun updateNextWordOnScreen() {
   binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}

주의사항) ViewModel에서 변경 가능한 데이터 입력란을 노출하면 안된다. 다른 클래스에서 이 데이터를 수정할 수 없도록 ViewModel 내부의 변경 가능한 데이터는 항상 private여야 한다.

 

 

3. ViewModel의 수명주기

4. 게임 앱 구현

  • 지연 초기화: 변수를 선언할 때는 일반적으로 사전에 변수에 초깃값을 제공하지만 아직 값을 할당할 준비가 되지 ㅇ않은 경우 나중에 초기화할 수 있도록 지연 초기화를 지원한다. 이 때 lateinit 키워드를 사용하며, 변수가 초기화될 때까지는 변수에 메모리가 할당되지 않는다.
  • 알림 대화상자
    제목, 메세지, 텍스트 버튼으로 구성되어 있다.
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()
}
  • 텍스트 필드에 오류 표시: 머티리얼 텍스트 필드의 경우 TextInputLayout에 오류 메시지를 표시하는 기능이 내장되어 있다.
    // Set error text
    passwordLayout.error = getString(R.string.error)
    
    // Clear error text
    passwordLayout.error = null
    
    ---
    
    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
       }
    }

 

5. Livedata

LiveData는 수명 주기를 인식하는 식별 가능한 데이터 홀더 클래스이다.

  • LiveData는 데이터를 보유한다.
  • LiveData는 식별 가능하다.
  • LiveData는 수명 주기를 인식한다.
  • MutableLiveData는 변경 가능한 버전의 LiveData이다.

 

6. 게임 앱 구현

  • 글자가 뒤섞인 현재 단어에 LiveData 추가하기
private fun getNextWord() {
 ...
   } else {
       _currentScrambledWord.value = String(tempWord)
       ...
   }
}​

 

  • LiveData 객체에 관찰자 연결하기
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

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

 

  • 점수 및 단어 수에 observer 연결하기
    LiveData로 점수 및 단어 수 래핑
    private fun showFinalScoreDialog() {
       MaterialAlertDialogBuilder(requireContext())
           .setTitle(getString(R.string.congratulations))
           .setMessage(getString(R.string.you_scored, viewModel.score.value))
           ...
           .show()
    }​
    관찰자를 점수 및 단어 수에 연결
    viewModel.currentWordCount.observe(viewLifecycleOwner,
       { newWordCount ->
           binding.wordCount.text =
               getString(R.string.word_count, newWordCount, MAX_NO_OF_WORDS)
       })​

7. 데이터 결합

뷰 결합은 단방향이다. 뷰를 코드에 바인딩할 수는 있지만 코드를 뷰에 바인딩할 수는 없다. 따라서 뷰 결합을 사용하면 뷰에서 앱 데이터를 참조할 수 없다. 이때 데이터 결합을 사용하면 된다.
데이터 결합을 사용할 때 주요 이점은 활동에서 많은 UI 프레임워크 호출을 삭제할 수 있어 파일이 더욱 단순해지고 더 손쉬운 유지관리가 가능하다는 점이다.

또한 앱 성능이 향상되며 메모리 누수 및 null 포인터 예외를 방지할 수 있다.

binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord //뷰결합
android:text="@{gameViewModel.currentScrambledWord}" //데이터결합
  • 뷰 결합을 데이터 결합으로 변경
    buildFeatures {
       dataBinding = true
    }​
  • 레이아웃 파일을 데이터 결합 레이아웃으로 변환
    <layout 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">
    
       <data>
    
       </data>
    
       <ScrollView
           android:layout_width="match_parent"
           android:layout_height="match_parent">
    
           <androidx.constraintlayout.widget.ConstraintLayout
             ...
           </androidx.constraintlayout.widget.ConstraintLayout>
       </ScrollView>
    </layout>​
  • onCreateView() 메서드 시작 부분에서 데이터 결합을 사용하도록 binding 변수의 인스턴스화를 변경
    binding = DataBindingUtil.inflate(inflater, R.layout.game_fragment, container, false)​

데이터 결합 변수 추가 후 결합 표현식을 사용해 UI를 업데이트하도록 하면 결합 표현식과 함께 LiveData를 사용할 수 있다.

<data>
   ...
   <variable
       name="maxNoOfWords"
       type="int" />
</data>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)

   binding.gameViewModel = viewModel

   binding.maxNoOfWords = MAX_NO_OF_WORDS
...
}
<TextView
   android:id="@+id/word_count"
   ...
   android:text="@{@string/word_count(gameViewModel.currentWordCount, maxNoOfWords)}"
   .../>
   
   <TextView
   android:id="@+id/score"
   ...
   android:text="@{@string/score(gameViewModel.score)}"
   ... />
viewModel.score.observe(viewLifecycleOwner,
   { newScore ->
       binding.score.text = getString(R.string.score, newScore)
   })

viewModel.currentWordCount.observe(viewLifecycleOwner,
   { newWordCount ->
       binding.wordCount.text =
           getString(R.string.word_count, newWordCount, MAX_NO_OF_WORDS)
   })

댓글