참고자료
Android Developer 도큐먼트 - ViewPager
Android Developer 도큐먼트 - PagerAdapter
Android Developer 도큐먼트 - FragmentStatePagerAdapter
ViewPager의 치명적인 문제점
ViewPager2
를 사용했습니다. ViewPager2
이전에 ViewPager
라는 것이 있다는걸 알았지만, 사용해본적은 없었습니다. ViewPager2
의 내부 구조를 공부하던 중 ViewPager
에 대한 이해(deprecated된 이유, 장단점)가 선행되어야 함을 느꼈습니다!ViewPager는 콘텐츠가 있는 다양한 View를 스와이프하는 것을 제어할 수 있습니다
ViewPager는 PagerAdapter를 상속해서 구현하도록 하는 Adapter패턴으로 이루어져 있습니다
PagerAdapter는 추상 Class이므로 PagerAdapter를 상속해서 사용해야 합니다.
FragmentPagerAdapter
FragmentStatePagerAdapter
PagerAdapter
는 ViewPager를 구현하는데 필요한 adapter를 제공하는 Base class입니다.
보통 PagerAdapter
를 직접 구현해 사용하기 보다, PagerAdapter
를 이미 상속하고 있는 FragmentPagerAdapter
, FragmentStatePagerAdapter
를 사용합니다.
PagerAdapter
을 상속하고 있는 하위 클래스들은 반드시 instantiateItem
, destroyItem
, getCount
, isViewFromObject
를 오버라이딩해야합니다.
instantiateItem()
는 파라미터로 View와 position을 전달받고, 오버라이딩 하지 않으면 Exception을 던집니다. PagerAdapter
를 상속하고 있는 FragmentPagerAdapter
, FragmentStateAdapter
은 instantiateItem()
을 필수로 상속받아야 합니다.PagerAdapter
에 정의되어 있는 destroyItem()
을 보면 instantiateItem()
처럼 오버라이딩 하지 않으면 Exception을 던집니다. isViewFromObject()
는 인자로 들어온 View가 instantiateItem()
메소드에서 반환된 object객체와 연관이 있는 뷰 인지를 true/false로 반환합니다
즉 인자로 들어온 View 인스턴스가 특정 페이지의 Object 인스턴스의 View인지 true/false로 반환하는 메서드입니다
getCount()
를 오버라이딩 하는 메서드는 view의 전체 개수를 반환하도록 작성되어야 합니다이렇게
PagerAdapter
의 4가지 추상 메서드는PagerAdapter
를 상속받는 class들이 ViewPager를 구현하기 위해 반드시 정의해야하는 콜백에 대해서만 정의하고 있었습니다.
FragmentPagerAdapter
는 PagerAdapter
를 상속받아 ViewPager를 구현할 때 "fragment 인스턴스는 모두 메모리에 남겨둔다"라고 했습니다. FragmentPagerAdapter
가 PagerAdapter
의 4가지 메소드를 오버라이딩 하고 있는 모습을 보면, fragment를 메모리에 어떤 방식으로 저장하고, 언제 가져다 쓰는건지 알 수 있을 것입니다!
FragmentPagerAdapter
가 instantiateItem()
를 오버라이딩해서 어떤 역할을 수행하고 있는지 보겠습니다!
instantiateItem()
는 파라미터로 받은 position에 대한 페이지(ViewPager의 화면 하나라고 생각하시면 됩니다)를 만들어 리턴하면 PagerAdapter
는 해당 인스턴스를 가지고 ViewPager의 특정 position에 View를 추가합니다.(1) fragmentManager로부터 fragment를 찾아서
(2) 해당 fragment가 null이라 인스턴스가 없으면
(3) 파라미터로 받은 container의 position에 fragment 인스턴스를 add()해서 만들어 넣고,
(4) null이 아니면 attach로 view를 붙이고
(5) fragment를 반환한다
add()는 fragment 인스턴스 자체를 새롭게 "생성"하는 것이고
attach()는 fragment 인스턴스에 view를 "붙이는"것 이라는 차이가 있습니다. 해당 개념은 [안드로이드 공식문서 파헤치기] Fragment-2편을 참고해주세요!
요약하면 instantiateItem()
는 특정 fragment 인스턴스가 있으면 view만 붙이고 없으면 fragment 인스턴스를 만들어서 리턴해주는 역할을 합니다
destroyItem()
은 파라미터로 View, position, object를 받는데 position에 해당하는 fragment의 View를 detach()하도록 오버라이딩 하고 있습니다
즉 PagerAdapter
는 instantiateItem()
와 destroyItem()
을 통해 fragment의 인스턴스는 메모리에 모두 저장해두고 화면에 보이거나 사라지는 fragment의 View만 붙였다가(attach) 떼면서(datach) ViewPager를 구현하는 것이었습니다! 그래서 메모리 사용량은 많지만 화면이 버벅이지 않고 잘 넘어가는 거였구나
getCount()
는 FragmentPagerAdapter
도 추상메서드로 정의하고 있기 때문에 FragmentPagerAdapter
를 상속하여 사용할 때 정의해주어야 합니다.getItem()
는 FragmentPagerAdapter
가 추상메서드로 정의하고 있기 때문에 FragmentPagerAdapter
를 상속하여 사용할 때 정의해주어야 합니다.class ViewPagerTestFragment : BaseViewUtil.BaseFragment<FragmentViewPagerTestBinding>(com.ummaaack.myapplication.R.layout.fragment_view_pager_test) {
private lateinit var adapter :ViewPagerAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentList: List<Fragment>
fragmentList = listOf(
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
)
adapter = ViewPagerAdapter(parentFragmentManager)
adapter.fragments.addAll(fragmentList)
binding.vp.adapter = adapter
}
}
class ViewPagerAdapter(fragmentManager: FragmentManager) : FragmentPagerAdapter(fragmentManager) {
override fun getCount(): Int {
return fragments.size
}
override fun getItem(position: Int): Fragment {
return fragments[position]
}
val fragments = mutableListOf<Fragment>()
override fun instantiateItem(container: ViewGroup, position: Int): Any {
Log.e("ㅡㅡㅡㅡinstantiateItemㅡㅡㅡㅡ", "$position")
return super.instantiateItem(container, position)
}
override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
Log.e("ㅡㅡㅡㅡdestroyItemㅡㅡㅡㅡ", "$position")
super.destroyItem(container, position, `object`)
}
}
fragment 6개를 만들어서 instantiateItem()
destroyItem
에 로그를 찍어보면 0번째 position의 fragement가 화면에 보일때 instatiateItem()
가 2번 호출되면서 0,1번 position의 View가 화면에 attach됩니다
이후 1번 포지션으로 화면을 넘기면 2번 포지션의 view가 attach되고 2번 포지션으로 화면을 넘기면 3번 포지션의 view가 attach되고 동시에 0번 포지션의 view가 detach됩니다
마지막 5번 포지션으로 화면을 넘기면 새로 attach되는 view없이 3번 포지션의 view만 detach됩니다
여기서 중요한건 사용자가 보고있는 position의 "다음" position의 view를 미리 attach한다는 것과 동시에 position의 2번째 이전 position의 view를 detach한다는 것입니다.
viewPager를 사용할 때 양 사이드의 화면을 조금 보여줘야 하는 상황이 있을 수 있어서 view를 바로 떼지않고, 다음 view를 미리 attach하는 것입니다
이를 통해 FragmentPagerAdapter
가 view를 재활용하지 않는다는 것도 알 수 있었습니다
앞에서 FragmentStatePagerAdapter
는 필요에 따라 메모리에서 Fragment를 제거하고 다시 생성하며 상태를 유지하고, 메모리에는 각 프래그먼트의 상태값만 계속 저장하기 때문에 메모리 입장에서는 부담이 덜한다고 언급했었습니다
FragmentPagerAdapter
는 프래그먼트를 제거하지 않고 View만 붙였다 떼었다했지만, FragmentStatePagerAdapter
는 프래그먼트는 제거하고 상태만 저장한다 것을 기억하고 FragmentStateAdapter
가 오버라이딩하고 있는 메서드들을 확인해보겠습니다
private ArrayList<Fragment.SavedState> mSavedState = new ArrayList<>();
private ArrayList<Fragment> mFragments = new ArrayList<>();
...
public Object instantiateItem(@NonNull ViewGroup container, int position) {
// If we already have this item instantiated, there is nothing
// to do. This can happen when we are restoring the entire pager
// from its saved state, where the fragment manager has already
// taken care of restoring the fragments we previously had instantiated.
if (mFragments.size() > position) {
Fragment f = mFragments.get(position);
if (f != null) {
return f;
}
}
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction();
}
Fragment fragment = getItem(position);
if (DEBUG) Log.v(TAG, "Adding item #" + position + ": f=" + fragment);
if (mSavedState.size() > position) {
Fragment.SavedState fss = mSavedState.get(position);
if (fss != null) {
fragment.setInitialSavedState(fss);
}
}
while (mFragments.size() <= position) {
mFragments.add(null);
}
fragment.setMenuVisibility(false);
if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) {
fragment.setUserVisibleHint(false);
}
mFragments.set(position, fragment);
mCurTransaction.add(container.getId(), fragment);
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED);
}
return fragment;
}
FragmentStatePagerAdapter
는 멤버변수로 Fragment 인스턴스를 정장하는 배열인 mFragments
, Fragment 인스턴스의 상태를 저장하는 배열인 mSavedState
가 선언되어 있습니다
여기서, Framgnet 인스턴스의 상태란 프래그먼트의 로컬 변수에 저장된 값들과 프래그먼트 뷰의 상태를 말하는 것입니다.
인자로 ViewGroup와 position을 받습니다. 여기서 mFragments
의 배열 사이즈가 인자로 받은 position의 값보다 클 경우엔 position을 index로 하는 원소(프래그먼트 인스턴스)를 꺼내옵니다. 이 원소가 null이면 destroyItem()
에 의해 이전에 메모리에서 제거된 히스토리가 있는 원소(프래그먼트 인스턴스) 것이고, null이 아니라면 아직 메모리에서 제거되지 않은 것이므로 해당 프래그먼트의 인스턴스를 바로 반환하고 끝이납니다.
만약 position에 해당하는 프래그먼트 인스턴스가 메모리에 존재하지 않는다면 mSaveState
배열에서 position의 index에 해당하는 프래그먼트의 상태값을 가져와서, 새로 생성해야하는 프래그먼트의 초기 상태값을 해당 상태값으로 설정합니다. 이후 인자로 받은 container에 프래그먼트 인스턴스를 재생성하고(add()이므로 인스턴스 자체를 생성), 상태를 복원합니다.
이처럼 상태를 저장해두고 인스턴스를 새로 생성할때 상태값으로 세팅하기 때문에 인스턴스가 재생성되어도 페이지를 보여주는데 필요한 데이터들이 초기화되지 않는 것입니다.
인자로 받은 프래그먼트 인스턴스가 호스트되어있는 액티비티에 추가(add())되어있는 경우에만 mSavedState
배열에서 인덱스가 position인 원소의 값에 프래그먼트의 상태를 저장합니다.
이런 프래그먼트의 상태들을 저장한 후, mFragments
배열에서 position의 값을 null로 만들고 프래그먼트 인스턴스를 제거(remove())합니다. 즉 destroyItem()
메서드가 호출되면 인자로 전달된 position에 존재하는 프래그먼트의 상태값만 저장한 후 인스턴스는 메모리에서 제거하는 것입니다
isViewFromObject(), getItem(), getCount()는 FragmentPagerAdapter와 동일해서 생략하겠습니다
class ViewPagerTestFragment : BaseViewUtil.BaseFragment<FragmentViewPagerTestBinding>(com.ummaaack.myapplication.R.layout.fragment_view_pager_test) {
private lateinit var adapter :ViewPagerAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentList: List<Fragment>
fragmentList = listOf(
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
Example2Fragment(),
)
adapter = ViewPagerAdapter(parentFragmentManager)
adapter.fragments.addAll(fragmentList)
binding.vp.adapter = adapter
}
}
class ViewPagerAdapter(fragmentManager: FragmentManager) : FragmentStatePagerAdapter(fragmentManager) {
override fun getCount(): Int {
return fragments.size
}
override fun getItem(position: Int): Fragment {
return fragments[position]
}
val fragments = mutableListOf<Fragment>()
override fun instantiateItem(container: ViewGroup, position: Int): Any {
Log.e("ㅡㅡㅡㅡinstantiateItemㅡㅡㅡㅡ", "$position")
return super.instantiateItem(container, position)
}
override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
Log.e("ㅡㅡㅡㅡdestroyItemㅡㅡㅡㅡ", "$position")
super.destroyItem(container, position, `object`)
}
PagerAdapter
, FragmentPagerAdapter
, FragmentStatePagerAdapter
을 통해 화면을 스와이프하는 ViewPager를 잘 구현하고 있었는데 2019년 ViewPager2
가 등장했습니다. 이유는 PagerAdapter
에게 데이터 변경과 관련된 문제가 있었기 때문입니다.
PagerAdapter
는 뷰페이져의 페이지 개수가 변경된다거나, 페이지에 데이터가 변경될 때 ViewPager에게 데이터 변경 콜백을 보내서 뷰페이저를 갱신하는 notifyDataSetChanged()
라는 메서드를 제공하고 있었습니다.
PagerAdapter.notifyDataSetChanged()
를 호출하면 메모리에 저장되어 있는 모든 페이지의 인스턴스에 대해 갱신처리가 이루어지는 로직입니다 이때 내부적으로 PagerAdapter.getItemPosition()
메소드를 호출해 새로운 포지션을 가져오게끔 되어있는데, getItemPosition()
이 리턴하는 값이 POSITION_UNCHANGED(포지션에 변경이 없음)
으로 고정되어 있던 것입니다.
따라서 notifyDataSetChanged()
를 호출해 페이지 갱신 로직을 요청해도 페이지에 변경이 없다는 상태로 고정되어있어 갱신이 이루어지지 않은 것입니다.이러한 문제점으로 인해 구글은 ViewPager2
를 새롭게 등장시켰습니다!