Fragment에 대해 알아보자 | Android Study

🙄 Fragment?

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

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

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

View 클래스 중 액티비티와 생명주기가 같은 클래스가 등장한다면 Activity의 코드 중 일부를 분리해서 개발할 수 있게될 것이고, 이런 목적으로 등장한 클래스가 바로 Fragment이다.

한 줄로 요약하면, 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

좋은 웹페이지 즐겨찾기