Fragment에 대해 알아보자 | Android Study

hoya·2022년 3월 27일
5

Android Study

목록 보기
13/19
post-thumbnail

🙄 Fragment?

액티비티처럼 이용할 수 있는 View

기존에는 휴대폰 화면이 작았기 때문에 Activity 만을 이용하여 화면을 구성해도 아무런 문제가 없었다. 그러나, 태블릿 PC의 등장으로 화면이 넓어지며 Activity 하나로 UI, 사용자 이벤트를 처리하기엔 너무 복잡해지고 코드가 길어졌다.

그래서 처음으로 나온 대안이 View 클래스로 Activity 의 역할을 대신하는 것이었지만, View 는 오로지 화면 출력만을 위해 설계된 클래스이기 때문에 Activity 의 생명주기를 이용할 수 없어 모든 역할을 대신할 수 없었다.

고심하던 중, View 클래스 중 액티비티와 생명주기가 같은 클래스가 있다면 Activity 의 코드 중 일부를 분리해서 개발할 수 있게 될 것이라는 아이디어가 나왔고, 그렇게 구현된 클래스가 바로 Fragment이다.

특징

  • Fragment 는 독립적일 수 없다.

    • Activity 혹은 부모 Fragment 에 종속적이다.
  • Fragment 는 자체적으로 생명주기를 가진다.

  • Fragment 는 재사용이 가능하다.

    • Fragment 는 여러 Activity 에서 생성 및 사용할 수 있다.
  • Activity 내에서 실행 중 추가, 삭제, 교체 등이 가능하다.

자세히 알아보기 전, Fragment의 기본 클래스 두 개를 조금만 알아보도록 하자.

FragmentManager

FragmentManagerActivity 혹은 Fragment에서 휘하의 Fragment 를 관리하는 클래스로, 각각 하나씩만 가지고 있다. 이 클래스를 통해 Activity -Fragment 혹은 부모 Fragment - 자식 Fragment서로 상호작용을 할 수 있게 된다.

FragmentTransaction

FragmentTransaction 에서 실질적으로 Fragment 를 추가, 삭제, 교체 등 여러 작업을 진행한다. 뿐만 아니라, Fragment 의 백스택 관리, Fragment 전환 시 애니메이션 설정 역시 FragmentTransaction 을 이용해 진행한다.

🙋🏼‍♂️ 백스택? : 사용자가 뒤로가기 버튼을 눌렀을 때 Activity 처럼 이전 Fragment 화면이 나오게 설정하는 것을 의미한다.

참고로, 사용하는 메소드는 아래와 같다.

  • add() : 새로운 Fragment 를 컨테이너에 추가한다.

  • replace() : 기존 컨테이너에 있는 Fragment 를 대체한다.

  • remove() : 컨테이너에 있는 Fragment 를 제거한다.

  • show() : 컨테이너에 있는 Fragment 를 보여준다. (visibility = true)

  • hide() : 컨테이너에 있는 Fragment 를 숨긴다. (visibility = false)

  • commit() : 작업을 실행한다.

  • commitNow() : 백스택이 없을 경우에만 사용하며, 작업을 즉시 수행한다.

🤔 Fragment LifeCycle

기본적으로 FragmentActivity 위에서 생성되기 때문에, Activity 의 생명주기와 함께 봐야 할 필요가 있다.

또, FragmentActivity 와 달리 Fragment View 의 생명주기도 가지고 있다. 두 개의 생명주기를 사진을 보며 잘 파악해보자.

  • onAttach()
    FragmentActivity 에 포함되는 순간 호출된다. 즉, Activity 에 종속되는 과정.
  • onCreate()
    Fragment 가 생성됐을 때 호출되지만, Fragment View 는 포함되지 않은 상태이다. 이 곳에서 View 관련 세팅을 진행하면 안정성을 보장받지 못한다.
  • onCreateView()
    Fragment 의 UI 구성을 위해 호출되며 Fragment View 의 생명주기가 생성된다.

  • onViewCreated()
    onCreateView() 를 통해 View 객체를 전달받는다. 이 단계에서 View 의 초기 세팅을 하면 안정성을 보장받을 수 있다.

  • onResume()
    Fragment 와 사용자가 상호작용이 가능한 상태이다.
  • onDestroyView()
    Fragment 가 화면에서 사라진 후, Fragment View 의 생명주기를 없앤다. 이 시점에서 Fragment View 에 대한 모든 참조를 제거해야 가비지 컬렉터가 Fragment View 를 수거해갈 수 있다. 만약 백스택 처리를 했다면 onDestroy() 로 가지 않고 이 단계에서 머무른다.

  • onDetach()
    FragmentActivity 에서 완전히 제거될 때 호출된다.


🙃 실습

📌 XML 설정

<?xml version="1.0" encoding="utf-8"?>
<!-- MainActivity -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/Theme.SampleFragment.AppBarOverlay">

        <TextView
            android:id="@+id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:minHeight="?actionBarSize"
            android:padding="16dp"
            android:text="@string/app_name"
            android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title" />

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabs"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <com.google.android.material.tabs.TabItem
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="first"/>

            <com.google.android.material.tabs.TabItem
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="second"/>

            <com.google.android.material.tabs.TabItem
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="third"/>

        </com.google.android.material.tabs.TabLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <LinearLayout
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"/>

</LinearLayout>

Activity 의 XML에 TabLayout 을 추가해 탭을 클릭할 때마다 Fragment 를 실행하도록 할 예정이다.

<?xml version="1.0" encoding="utf-8"?>
<!-- Fragment -->
<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/constraintLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/section_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello First Fragment"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Fragment 의 XML인데, TextView 를 그냥 가운데에 둔 것이다. XML을 잘 설정했다면, 아래와 같은 화면이 나올 것이다.


📌 MainActivity

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        supportFragmentManager.beginTransaction()
            .replace(R.id.container, FirstFragment.newInstance(5))
            .commit()

        binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
            override fun onTabSelected(tab: TabLayout.Tab?) {
                when (tab?.text.toString()) {
                    "first" -> supportFragmentManager.beginTransaction()
                        .replace(R.id.container, FirstFragment.newInstance(5))
                        .commit()
                    "second" -> supportFragmentManager.beginTransaction()
                        .replace(R.id.container, SecondFragment.newInstance(5))
                        .commit()
                    else -> supportFragmentManager.beginTransaction()
                        .replace(R.id.container, ThirdFragment.newInstance(5))
                        .commit()
                }
            }
            override fun onTabUnselected(tab: TabLayout.Tab?) {
                // NOT IMPLEMENTS
            }

            override fun onTabReselected(tab: TabLayout.Tab?) {
                // NOT IMPLEMENTS
            }
        })
    }
}

위에서 이야기했던 FragmentManagerFragmentTransaction을 이용하여 Fragment를 화면에 나타나게 하는 코드이다.

supportFragmentManager.beginTransaction()
	.replace(R.id.container, FirstFragment.newInstance(5))
	.commit()

supportFragmentManager 를 이용해 ActivityFragmentManager 를 가져오는 것과 beginTransaction()을 이용해 기존에 설정한 컨테이너에 Fragment 화면을 담는 모습을 확인할 수 있다.

여기서 드는 의문점인데, 저렇게 일일이 beginTransaction 을 작업 실행 때마다 코드에 넣을 필요가 있을까? 이런 식으로 하면 안되는걸까?

binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
    val transaction = supportFragmentManager.beginTransaction()

    override fun onTabSelected(tab: TabLayout.Tab?) {
        when (tab?.text.toString()) {
            "first" -> transaction
                .replace(R.id.container, FirstFragment.newInstance(5))
                .commit()
            "second" -> transaction
                .replace(R.id.container, SecondFragment.newInstance(5))
                .commit()
            else -> transaction
                .replace(R.id.container, ThirdFragment.newInstance(5))
                .commit()
        }
    }
}

아쉽지만 화면을 담는 작업을 마치고 commit()을 완료한 FragmentTransaction 객체에 다시 commit() 명령을 시도하면 오류가 발생한다. 하나의 Transaction 에는 하나의 commit(), 반드시 기억하도록 하자.

그렇다면, newInstance() 코드는 무엇일까?


📌 Fragment

class FirstFragment : Fragment() {

    private var _binding : FragmentFirstBinding? = null
    private val binding get() = _binding!!
    private var num : Int? = null

    companion object {
        fun newInstance(count : Int): FirstFragment{
            val args = Bundle()
            args.putInt("number", count)
            val fragment = FirstFragment()
            fragment.arguments = args
            return fragment
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        if(arguments != null) {
            num = requireArguments().getInt("number")
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentFirstBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.count.text = num.toString()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

Activity 와 마찬가지로 Fragment 도 재생성 되는 경우가 많다. 이렇게 재생성되는 과정에서 Activity 혹은 Fragment 에게 전달받은 데이터를 보존하기 위해, newInstance() 메소드에서 스스로를 생성한다.

테스트를 위해 밑에 count를 넣었고, 화면을 돌려보았으나 데이터는 그대로 유지되는 것을 확인할 수 있었다.

만약 보존할 데이터가 없다면 굳이 newInstance() 를 사용할 필요는 없다.

override fun onTabSelected(tab: TabLayout.Tab?) {
    when (tab?.text.toString()) {
        "first" -> supportFragmentManager.beginTransaction()
            .replace(R.id.container, FirstFragment())
            .commit()
        "second" -> supportFragmentManager.beginTransaction()
            .replace(R.id.container, SecondFragment())
            .commit()
        else -> supportFragmentManager.beginTransaction()
            .replace(R.id.container, ThirdFragment())
            .commit()
    }
}

위와 같이 코드를 설정한다면, Fragment기본 생성자를 알아서 호출하여 화면을 띄워준다.

그리고 _binding = null 부분이 이해가 안간다면 해당 포스팅을 참고하면 된다. 짧게 요약하면 메모리 누수를 방지하기 위해 Fragment View에 대한 참조를 제거하여 가비지 컬렉터가 수거해가도록 하는 것이다.


📌 Fragment 간 데이터 공유

데이터를 공유하는 방법에는 여러가지 방법이 존재한다.

  1. Bundle - FragmentManager 로 전달하는 방법
  2. Fragment Result API 를 사용하여 전달하는 방법
  3. Fragment 간 공통의 ViewModel 로 전달하는 방법

그 중에서 ViewModel 을 이용해 데이터를 공유하는 방법에 대해 알아본다. ViewModel 을 다룬 포스팅에서도 살짝 다룬 적이 있는데, 한번 코드를 넣어가며 자세히 보도록 하자.

우선 XML에 Button 을 추가한다.

ViewModel

class MainViewModel : ViewModel() {

    private val _count = MutableLiveData<Int>()
    val count : LiveData<Int> get() = _count

    init {
        _count.value = 5
    }

    fun getUpdatedCount(plusCount: Int){
        _count.value = (_count.value)?.plus(plusCount)
    }
}

ViewModel 을 위와 같이 간단하게 작성한다.

Fragment

class FirstFragment : Fragment() {

    private var _binding : FragmentFirstBinding? = null
    private val binding get() = _binding!!
    private val TAG = "FirstFragment"


    private val viewModelFactory = ViewModelProvider.NewInstanceFactory()
    private val viewModel: MainViewModel by lazy {
        ViewModelProvider(requireActivity(), viewModelFactory)[MainViewModel::class.java] // 데이터 공유
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, "onCreate()")

    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentFirstBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        Log.d(TAG, "onViewCreated()")

        viewModel.count.observe(viewLifecycleOwner) {
            binding.count.text = it.toString()

            Log.d(TAG, "Observing. . . . . . ")
        }

        binding.plus.setOnClickListener {
            viewModel.getUpdatedCount(1)
        }
    }
 }

버튼을 클릭했을 때 ViewModelcount 변수의 크기가 1 증가하는 코드로 구성하였다. 가장 키포인트로 봐야할 것은 아래의 코드이다.

private val viewModel: MainViewModel by lazy {
	ViewModelProvider(requireActivity(), viewModelFactory)[MainViewModel::class.java] // 데이터 공유
}

이 부분에서, ViewModelStoreOwner, 즉 ViewModelStore 의 주인으로 부모 Activity 를 넘겨준다.

만약, 여기서 requireActivity() 가 아니라 this 로 매개변수를 넘겨주었다면, Fragment 간 데이터 공유는 할 수 없고 해당 Fragment 에서만 ViewModel 의 데이터가 저장되는 결과가 나올 것이다.

세 번째 Fragment 에는 this 로 매개변수를 넘겨주니 데이터 공유가 안되는 것을 확인할 수 있다.


📌 Fragment With LiveData

LiveDataFragment 와 같이 사용할 때 생기는 문제점이 있다. 아래의 코드를 보자.

viewModel.count.observe(viewLifecycleOwner) {
	binding.count.text = it.toString()

	Log.d(TAG, "Observing. . . . . . ")
}

여기서, viewLifeCycleOwner 는 무슨 뜻일까? 위에서 이야기했던, Fragment View 의 생명주기를 뜻한다.

그렇다면 왜 Fragment 의 생명주기가 아닌 Fragment View 의 생명주기를 넘겨주어야 하는지 알아보도록 하자.

Fragment 가 재개되는 과정을 보면 Activity 와 달리 onDestroy() 메소드가 실행되지 않는 것을 확인할 수 있다.

그 과정에서 onCreateView() 가 여러번 실행될 수 있는데, 그렇게 되면 LiveData 에 있는 기존 Observer 는 사라지지 않고, 새로운 Observer 가 등록되어 여러번 Observer 가 호출되는 현상이 발생한다.

그렇기 때문인지 실제 안드로이드 스튜디오에서도 매개변수를 this, 즉 Fragment 의 생명주기를 넘겨주려 하면 빨간줄로 경고를 표시한다. 이를 무시하고 실행하면 아래와 같은 결과가 나온다.

Fragment 에서 생명주기를 관리한다면 안드로이드 스튜디오에서 친절하게 경고해주니 부담이 덜 가지만, 만약 BaseFragment 에서 lifecycleOwnerthis 로 설정한다면 오류가 발생할 수 있으니, 꼭 숙지해두도록 하자.


Fragment 를 쓰면서도 왜 쓰는지에 대해서 생각이 많이 부족했던 것 같아 포스팅을 하며 여러 내용을 정리하였다.

해당 포스팅을 보신 여러분께도 많은 도움이 되었길 바랍니다........... 🥲


참고 및 출처

The Android Lifecycle cheat sheet — part III : Fragments
Fragment 1 — Fragment의 이해와 생성
안드로이드 공식 문서
깡쌤의 안드로이드 프로그래밍
Fragment Lifecycle과 LiveData

profile
즐겁게 하자 🤭

0개의 댓글