[Android] Fragment간 데이터 전달

RecyclerView의 특정 항목을 누르면 MainActivity에서 RecyclerView가 존재하는 프래그먼트를 상세 항목을 보여주는 프래그먼트의 새로운 인스턴스로 교체하여 선택된 데이터의 상세 내역을 보여주도록 해보자. 이를 위해선 아래의 방법들을 알고 있어야 한다.

  • 호스팅 액티비티가 프래그먼트들을 바꿔치기해서 이동을 구현하는 방법
  • 프래그먼트 인자 fragment argument 를 사용해서 프래그먼트 인스턴스에게 데이터를 전달하는 방법
  • UI 변경에 따라 LiveData를 변환 transform 하는 방법

단일 액티비티: 프래그먼트의 우두머리

이전에는 한 액티비티가 다른 액티비티를 시작시켰지만, 본문의 예시 앱인 CriminalIntent 앱에서는 단일 액티비티 아키텍쳐 single activity architecture 를 사용한다. 단일 액티비티 아키텍처를 사용하는 앱은 하나의 액티비티와 다수의 프래그먼트를 가지며, 그 액티비티는 사용자 이벤트에 반응해 프래그먼트들을 상호 교체한다.

리스트의 특정 범죄 데이터를 사용자가 누르면 CrimeListFragment로부터 CrimeFragment로의 이동(혹은 교체)을 구현하기 위해 호스팅 액티비티의 프래그먼트 매니저에서 프래그먼트 트랜잭션을 시작시킨다 그리고 이 일을 수행하는 코드를 CrimeListFragment의 CrimeHolder.onClick(view)에 둔다. 이때 onClick(View)에서는 MainActivity의 FragmentManager 인스턴스를 생성한 후 CrimeListFragment를 CrimeFragement로 교체하는 트랜잭션을 커밋한다.

그런데 CrimeListFragment의 CrimeHolder.onClick(view)의 코드를 아래와 같이 작성하면 작동은 잘 되지만 바람직한 방법은 아니다.

1
2
3
4
5
6
7
fun onClick(view: View) {
val fragment = CrimeFragment.newInstance(crime.id)
val fm = activity.supportFragmentManager
fm.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit()
}

프래그먼트는 독자적이고 구성 가능한 단위가 되어야 하는데 그렇지가 않기 때문이다. 이처럼 액티비티의 FragmentManager에서 다른 프래그먼트로 교체하는 일을 액티비티가 아닌 프래그먼트에서 하려면 이 프래그먼트는 자신을 호스팅하는 액티비티가 어떤 레이아웃을 갖고 어떻게 작동하는지 알아야 한다. 따라서 프래그먼트의 기본 취지에 어긋난다.

즉, 위의 코드에서 CrimeListFragment는 CrimeFragment를 MainActivity에 추가하면서 MainActivity의 레이아웃에 fragment_container가 있을 것이라고 가정한다. 그러나 이런 일은 CrimeListFragment의 호스팅 액티비티인 MainActivity가 해야 할 일이다.

따라서 여기서는 프래그먼트의 독립성을 유지하기 위해 프래그먼트에 콜백 인터페이스를 정의하고 호스팅 액티비티가 해당 콜백 인터페이스를 구현해 프래그먼트 교체를 수행하게 한다.

프래그먼트 콜백 인터페이스

프래그먼트 교체 기능을 호스팅 액티비티에 위임하기 위해 프래그먼트에는 Callbacks라는 이름의 커스텀 콜백 인터페이스를 정의하고, 이 인터페이스에는 프래그먼트가 필요로 하는 일을 수행하게 하는 함수를 정의한다. 그리고 이 프래그먼트를 호스팅하는 모든 액티비티는 반드시 해당 인터페이스를 구현해야 한다.

콜백 인터페이스를 사용하면 어떤 액티비티가 호스팅하는지 알 필요 없이 프래그먼트가 자신을 호스팅하는 액티비티의 함수들을 호출할 수 있다.

콜백 인터페이스를 사용해서 CrimeListFragment의 클릭 이벤트 처리를 호스팅 액티비티에게 위임하자.

콜백 인터페이스 추가하기 (CrimeListFragment.kt)

하나의 콜백 함수를 갖는 Callbacks 인터페이스를 선언하고 Callbacks를 구현하는 객체 참조를 저장하기 위해 callbacks 속성을 추가한다. 그리고 onAttach(context)onDetach()를 오버라이드해 callbacks 속성을 설정 또는 설정 해제한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class CrimeListFragment : Fragment() {

/**
* 호스팅 액티비티에서 구현할 인터페이스
*/
interface Callbacks {
fun onCrimeSelected(crimeId: UUID)
}

// callbacks 속성 추가
private var callbacks: Callbacks? = null

...

// callbacks 속성 설정
override fun onAttach(context: Context) {
super.onAttach(context)
callbacks = context as Callbacks?
}

override fun onCreateView(...): View? {
...
}

override fun onViewCreated(...) {
...
}

// callbacks 속성 해제
override fun onDetach() {
super.onDetach()
callbacks = null
}
...
}

중요

Fragment.onAttach(Context) 생명주기 함수는 프래그먼트가 호스팅 액티비티에 연결될 때 호출된다. 여기서는 onAttach(...)의 인자로 전달된 Context 객체의 참조를 callbacks 속성에 저장하며, CrimeListFragment를 호스팅하는 액티비티 인스턴스가 Context 객체다.

Activity는 Context의 서브 클래스다. 따라서 onAttach(...)의 인자로 Activity 타입을 전달해도 되지만, 슈퍼 타입인 Context를 전달하는 것이 코드의 유연성이 좋다. 또한, onAttach(Activity)는 향후 API 버전에서 없어질 수 있으므로 deprecated, onAttach(Context)를 사용한다.

onAttach(Context)와 반대로, 프래그먼트가 액티비티에서 분리될 때 호출되는 생명주기 함수인 Fragment.onDetach()에서는 callbacks 속성을 null로 설정한다. 이 함수가 호출될 때는 호스팅 액티비티를 사용할 수 없거나 호스팅 액티비티가 계속 존재한다는 보장이 없기 때문이다.

onAttach(Context)에서 인자로 전달된 Context 객체의 참조를 callbacks 속성에 지정할 때는 CrimeListFragment.Callbacks 타입으로 변환한다. 따라서 CrimeListFragment를 호스팅하는 액티비티는 반드시 CrimeListFragment.Callbacks 인터페이스를 구현해야 한다.

호스팅 액티비티의 onCrimeSelected(...) 호출하기 (CrimeListFragment.kt)

어떤 액티비티가 호스팅을 하든 이제는 CrimeListFragment가 호스팅 액티비티의 콜백 구현 함수 (여기서는 onCrimeSelected(UUID)) 를 호출할 수 있게 되었다. CrimeListFragment.Callbacks 인터페이스를 구현하는 호스팅 액티비티면 어떠한 것도 가능하다.

현재는 범죄 리스트의 특정 항목을 누르면 CrimeListFragment의 내부 클래스인 CrimeHolder의 onClick(View)가 호출되고 토스트 메시지만 보여주므로, Callbacks 인터페이스를 통해 호스팅 액티비티의 onCrimeSelected(UUID)를 호출하도록 변경하고 호스팅 액티비티가 Callbacks 인터페이스의 onCrimeSelected(UUID) 구현하도록 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CrimeListFragment : Fragment() {
...
private inner class CrimeHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
...
fun bind(crime: Crime) {
...
}

override fun onClick(v: View) {
// Toast.makeText(context, "${crime.title} pressed!", Toast.LENGTH_SHORT).show()
callbacks?.onCrimeSelected(crime.id)
}
}

...
}

호스팅 액티비티에서 콜백 인터페이스 구현하기 (MainActivity.kt)

1
2
3
4
5
6
7
8
9
10
11
12
private const val TAG = "MainActivity"

class MainActivity : AppCompatActivity(), CrimeListFragment.Callbacks {

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

override fun onCrimeSelected(crimeId: UUID) {
Log.d(TAG, "MainActivity.onCrimeSelected: $crimeId")
}
}

LogCat을 확인해보면 범죄 리스트의 각 항목을 클릭할 때마다 MainActivity의 onCrimeSelected(UUID)가 호출되어 로그 메시지가 나타난다. 이 함수는 Callbacks 인터페이슬르 통해 CrimeListFragment로부터 호출된 것이다.

프래그먼트 교체하기

콜백 인터페이스를 제대로 연결하였으니 사용자가 CrimeListFragment의 범죄 리스트에서 특정 항목을 누르면 MainActivity의 onCrimeSelected(UUID)에서 CrimeListFragment가 CrimeFragment 인스턴트로 교체되도록 변경한다. 현재는 콜백으로 전달되는 Crime 객체의 ID를 사용하지 않는다.

CrimeListFragment를 CrimeFragment로 교체하기 (MainActivity.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MainActivity : AppCompatActivity(), CrimeListFragment.Callbacks {

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

override fun onCrimeSelected(crimeId: UUID) {
val fragment = CrimeFragment()
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit()
}
}

FragmentTransaction.replace(Int, Fragment)는 액티비티에 현재 호스팅된 프래그먼트를 두 번째 인자로 전달된 프래그먼트로 교체하여 첫 번째 인자로 전달된 리소스 ID를 갖는 컨테이너에 넣는다. 만일 기존에 호스팅된 프래그먼트가 없으면 FragmentTransaction.add(Int, Fragment)를 호출할 때와 같게 새로운 프래그먼트로 추가된다.

지금은 범죄의 상세 내역 화면이 비어 있는데, 어떤 Crime 객체를 보여줄 것인지 CrimeFragment에게 알려주지 않았기 때문이다. 이 작업은 잠시 후에 하고 프래그먼트 간의 이동을 구현하는데 추가로 해야 할 작업을 먼저 실시하자.

현재 화면에서 백 버튼을 클릭해보면 범죄 리스트를 보여주는 CrimeListFragment 화면으로 돌아가지 않는다. 앱을 시작할 당시에 실행되었던 MainActivity 인스턴스만이 앱의 백 스택에 존재했기 때문이다. 프래그먼트 교체 트랜잭션을 백 스택에 추가해서 구현하여 이전 프래그먼트로 돌아가도록 구현한다.

플래그먼트 트랜잭션을 백 스택에 추가하기 (MainActivity.kt)

1
2
3
4
5
6
7
8
9
10
11
class MainActivity : AppCompatActivity(), CrimeListFragment.Callbacks {
...
override fun onCrimeSelected(crimeId: UUID) {
val fragment = CrimeFragment()
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, fragment)
.addToBackStack(null) // 백 스택에 추가
.commit()
}
}

이처럼 트랜잭션을 백 스택에 추가하면 사용자가 백 버튼을 누를 때 해당 트랜잭션이 취소되면서 이전 상태로 복원되며, 여기서는 CrimeFragment가 CrimeListFragment로 교체된다.

FragmentTransaction.addToBackStack(String)을 호출할 때 백 스택 상태의 이름을 나타내는 문자열을 인자로 전달할 수 있다. 그러나 여기서는 그럴 필요가 없어서 null을 전달하였다.

프래그먼트 인자

프래그먼트 인자 fragment argument를 사용하면 프래그먼트에 속하는 어딘가에 데이터를 저장할 수 있다. 여기서 프래그먼트에 속하는 ‘어딘가’인자 번들 argument bundle 을 말한다. 프래그먼트는 자신의 부모 액티비티나 다른 외부 소스에 의존하지 않고 인자 번들로부터 데이터를 가져올 수 있다.

프래그먼트 인자는 프래그먼트의 캡슐화를 도와준다. 그리고 캡슐화가 잘된 프래그먼트는 재사용할 수 있는 구성 요소가 되므로 어떤 액티비티에도 쉽게 호스팅될 수 있다.

프래그먼트 인자를 생성하기 위해 우선 Bundle 객체를 생성한다. 이 Bundle 객체는 액티비티의 인텐트 엑스트라와 마찬가지로 키와 값의 쌍으로 된 데이터를 포함하며, 각 쌍의 데이터를 인자라고 한다. 그다음에 타입마다 따로 있는 Bundle의 put 함수들을 이용해서 인자들을 Bundle 객체에 추가한다.

Bunlde 객체에 인자들을 추가하는 예시

모든 프래그먼트 인스턴스는 자신에게 첨부된 Bundle 객체에 프래그먼트 인자들을 저장할 수 있다.

1
2
3
4
5
val args = Bundle().apply {
putSerializable(ARG_MY_OBJECT, myObject)
putInt(ARG_MY_INT, myInt)
putCharSequence(ARG_MY_STRING, myString)
}

인자를 프래그먼트에 첨부하기

인자 번들을 프래그먼트에 추가할 때는 Fragment.setArguments(Bundle)을 호출한다. 단, 프래그먼트가 생성되어 해당 프래그먼트가 액티비티에 추가되기 전에 프래그먼트에 첨부해야 한다.

이렇게 하려면 newInstance(...)라는 이름의 함수를 포함하는 동반 객체 companion object를 Fragment 클래스에 추가하는 것이 좋다. 이 함수에서는 프래그먼트 인스턴스와 번들 인스턴스를 생성하고 번들 인스턴스에 인자를 저장한 후 프래그먼트 인자로 첨부한다.

그리고 호스팅 액티비티가 프래그먼트의 인스턴스를 필요로 할 때 이 프래그먼트의 생성자를 직접 호출하는 대신 newInstance(...) 함수를 호출하면 된다. 그러면 이 함수에서 필요한 인자들을 전달할 수 있다.

newInstance(UUID) 함수 작성하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private const val ARG_CRIME_ID = "crime_id"

class CrimeFragment : Fragment() {
...
override fun onStart() {
...
}

companion object {
fun newInstance(crimeId: UUID): CrimeFragment {
val args = Bundle().apply {
putSerializable(ARG_CRIME_ID, crimeId)
}
return CrimeFragment().apply {
arguments = args
}
}
}
}
  • newInstance(UUID) 함수에서는 UUID 타입의 인자를 받아서 인자 번들 인스턴스를 생성하고 인자를 저장하며, 프래그먼트 인스턴스를 생성한 후 인자 번들을 프래그먼트에 첨부한다.
  • 아래의 동반 객체 리턴 값에서 arguments는 Fragment의 속성이며, 코틀린에서는 속성에 값을 설정할 때 setter를 자동 호출한다. 따라서 끝에 있는 arguments = argssetArguments(args)와 같다.

다음으로 MainActivity에서 CrimeFragment 인스턴스를 생성할 때 UUID를 인자로 전달해 CrimeFragment.newInstance(UUID)를 호출하도록 변경한다.

`CrimeFragment.newInstance(UUID) 사용하기 (MainActivity.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
class MainActivity : AppCompatActivity(), CrimeListFragment.Callbacks {
...

override fun onCrimeSelected(crimeId: UUID) {
// val fragment = CrimeFragment()
val fragment = CrimeFragment.newInstance(crimeId)
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, fragment)
.addToBackStack(null)
.commit()
}
}

코드의 독립성이 액티비티와 프래그먼트 양쪽 모두에 필요한 것은 아니다. 즉, MainActivity는 CrimeFragment에 관해 많은 것을 알아야 한다. 예를 들면, CrimeFragment가 newInstance(UUID) 함수를 갖고 있다는 것 등이다.

호스팅 액티비티는 자신의 프래그먼트들을 호스팅하는 방법을 자세히 알아야 하므로 지극히 정상적이다. 이와 달리 프래그먼트는 자신의 호스팅 액티비티를 자세히 알 필요가 없다.

프래그먼트 인자 가져오기

프래그먼트가 자신에게 전달된 인자를 액세스할 때는 Fragment 클래스의 arguments 속성을 참조하면 된다. 그런 다음에 Bundle의 get 함수들 중 하나를 호출하면 된다.

프래그먼트 인자에서 범죄 데이터 ID 얻기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private const val TAG = "CrimeFragment"
private const val ARG_CRIME_ID = "crime_id"

class CrimeFragment : Fragment() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
crime = Crime()
val crimeId: UUID = arguments?.getSerializable(ARG_CRIME_ID) as UUID
Log.d(TAG, "args bundle crime ID: $crimeId")
// 궁극적으로는 데이터베이스로부터 데이터를 로드해야 한다.
}
...
}
  • 코틀린에서는 속성을 참조할 때 getter를 자동 호출해준다. 따라서 끝에 새로 추가한 코드의 arguments 대신 getArguments()를 사용해도 된다.

상세 내역 화면에 보여줄 Crime 객체를 LiveData 변환으로 얻기

CrimeFragment가 범죄 데이터 ID를 갖게 되었으니 이 ID를 갖는 범죄 데이터가 화면에 보이도록 데이터베이스에서 범죄 데이터(Crime 객체)를 가져와보자. 이렇게 하기 위해 ViewModel의 서브 클래스로 CrimeDetailViewModel을 생성해 데이터베이스를 검색할 것이다. ViewModel을 사용하면 장치 회전 시에도 데이터가 보존되므로 데이터베이스 검색 쿼리를 매번 다시 할 필요가 없기 때문이다.

지정된 ID를 갖는 범죄 데이터를 CrimeFragment가 CrimeDetailViewModel에 요청하면 리포지터리의 getCrime(UUID)를 호출한 후 쿼리 결과로 받은 범죄 데이터를 CrimeFragment에 전달한다. 이때 리포지터리와 CrimeDetailViewModel, 그리고 CrimeFragment 간의 데이터 전달을 쉽게 하기 위해 Crime 객체를 갖는 LiveData를 사용한다.

CrimeDetailViewModel 생성하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class CrimeDetailViewModel(): ViewModel() {

// ①
private val crimeRepository = CrimeRepository.get()
// ②
private val crimeIdLiveData = MutableLiveData<UUID>()

// ③
var crimeLiveData: LiveData<Crime?> =
Transformations.switchMap(crimeIdLiveData) { crimeId ->
crimeRepository.getCrime(crimeId)
}

// ④
fun loadCrime(crimeId: UUID) {
crimeIdLiveData.value = crimeId
}
}

crimeRepository 속성은 CrimeRepository 인스턴스 참조를 보존한다. 이렇게 속성을 사용한 이유는 향후에 CrimeDetailViewModel의 여러 곳에서 CrimeRepository 인스턴스를 사용하기 때문이다.

crimeIdLiveData 속성은 변경 가능한 UUID 타입의 데이터를 저장한 LiveData를 참조한다. 여기서는 CrimeFragment가 현재 화면에 보여주거나 보여줄 범죄 데이터 ID가 LiveData에 저장된 데이터다. CrimeDetailViewModel 인스턴스가 최초 생성될 때는 crimeIdLiveData가 설정되지 않는다. 그러나 향후에 CrimeFragment 인스턴스가 생성될 때 CrimeFragment의 onCreate(Bundle?)에서 CrimeDetailViewModel.loadCrime(UUID)를 호출하므로, 이때 crimeIdLiveData가 범죄 ID로 설정되어 어떤 범죄 데이터를 가져올 것인지 CrimeDetailViewModel이 알 수 있다.

crimeLiveData 속성은 상세 내역 화면에 보여줄 Crime 객체를 저장한 LiveData를 참조하며, 이 LiveData는 Transformations.switchMap(crimeIdLiveData) {...}로부터 반환한다. 그리고 switchMap(crimeIdLiveData) {...}에서는 인자로 전달된 crimeIdLiveData의 범죄 ID를 갖는 범죄 데이터를 데이터베이스로부터 가져와서 LiveData로 반환한다.

Transformations 클래스는 두 LiveData 객체 간의 변환을 해주는 함수들을 갖고 있다. switchMap(LiveData<X>, Function<X, LiveData<Y>!>) 함수에서는 첫 번째 인자로 전달된 LiveData에 설정된 각 값에 대해 두 번째 인자의 함수를 적용해서 변환하며, 이 결과를 LiveData로 반환한다.

switchMap(...) 함수에는 crimeIdLiveData 속성이 첫 번째 인자로 전달되고, 두 번째 인자의 변환 함수로는 람다식이 지정되었다. 이 람다식의 crimeRepository.getCrime(crimeId) 함수는 crimeIdLiveData 속성값(범죄 ID)을 갖는 범죄 데이터를 데이터베이스에서 검색해 가져와서 Crime 객체를 갖는 LiveData로 반환한다. 그리고 이 LiveData가 crimeIdLiveData 속성에 설정된다. 이렇게 상세 내역 화면에 보여줄 Crime 객체를 저장한 LiveData가 준비된다.

CrimeFragment를 CrimeDetailViewModel에 연결하기 (CrimeFragment.kt)

onCreate(...)에서 CrimeDetailViewModel의 loadCrime(UUID)를 호출해 CrimeFragment를 CrimeDetailViewModel과 연결한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CrimeFragment : Fragment() {

private lateinit var crime: Crime
...
private val crimeDetailViewModel: CrimeDetailViewModel by lazy {
ViewModelProvider(this).get(CrimeDetailViewModel::class.java)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
crime = Crime()
val crimeId: UUID = arguments?.getSerializable(ARG_CRIME_ID) as UUID
// Log.d(TAG, "args bundle crime ID: $crimeId")
crimeDetailViewModel.loadCrime(crimeId)
}
...
}

다음으로 CrimeDetailViewModel의 crimeLiveData가 변경되는지 관찰해서 새 데이터가 있으면 UI를 변경하도록 CrimeFragment를 변경한다. 그리고 Observer의 import문도 추가한다.

범죄 데이터 변경 관찰하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
...
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.Observer
...

class CrimeFragment : Fragment() {

private lateinit var crime: Crime
...

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

override fun onCreateView(...): View? {
...
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
crimeDetailViewModel.crimeLiveData.observe(
viewLifecycleOwner,
Observer { crime ->
crime?.let {
this.crime = crime
updateUI()
}
})
}

override fun onStart() {
...
}

private fun updateUI() {
titleField.setText(crime.title)
dateButton.text = crime.date.toString()
solvedCheckBox.isChecked = crime.isSolved
}
...
}

CrimeFragment는 자신의 crime 속성에 Crime 객체 참조를 따로 갖고 있다. 이 속성은 사용자가 화면에서 변경한 현재의 데이터를 갖는 Crime 객체를 나타낸다. 반면에 CrimeDetailViewModel.crimeLiveData의 Crime 객체 데이터는 데이터베이스에 현재 저장된 것을 나타낸다. CrimeFragment의 crime 속성을 사용해서 현재 화면의 데이터를 데이터베이스에 변경하는 것은 잠시 후에 다룬다.

CriminalIntent 앱의 백 스택

애니메이션 깜빡거림 없애기

위의 이미지에서 수갑 이미지가 있는 범죄 데이터를 선택하면 CrimeFragment가 상세 내역 화면을 보여줄 때 CheckBox의 표시가 깜빡거리면서 나타나는 것을 볼 수 있다. 이것은 정상으로, 사용자가 범죄 리스트에서 특정 데이터를 선택할 때 CrimeFragment가 시작되어 해당 데이터의 데이터베이스 쿼리가 시작된다. 그리고 쿼리가 끝나면 CrimeFragment의 CrimeDetailViewModel.crimeLiveData 옵저버가 실행되어 각 위젯의 데이터(제목, 발생일자, 해결 여부)를 화면에 보여준다. 이때 CheckBox는 클릭 시 생동감을 주기 위해 기본적으로 애니메이션을 수행해서 깜빡거리는 것처럼 보인다. 이런 깜빡거림은 View.jumpDrawablesToCurrentState()를 호출해서 애니메이션을 생략하면 해결할 수 있다.

CheckBox의 깜빡거림만이 아닌 상세 내역 화면 전체가 나타나는데 Delay가 생긴다면, 일정 개수의 범죄 데이터를 메모리에 미리 로드한 후 공유되는 곳에 보존해서 사용하면 된다. 본문의 앱에서는 이런 시간 지연이 없으니 필요에 따라 애니메이션 정도만 생략해주면 된다.

데이터베이스 변경하기

사용자가 상세 내역 화면을 벗어날 때 사용자가 변경한 데이터를 데이터베이스에 저장해야 한다.

우선 기존의 범죄 데이터를 변경하는 함수와 새로운 데이터를 추가하는 함수를 CrimeDao에 추가한다. 단, 새 데이터 추가 함수는 링크 추가 예정에서 다룬다.

데이터베이스 함수 추가하기 (database/CrimeDao.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Dao
interface CrimeDao {

@Query("SELECT * FROM crime")
fun getCrimes(): LiveData<List<Crime>>

@Query("SELECT * FROM crime WHERE id=(:id)")
fun getCrime(id: UUID): LiveData<Crime?>

@Update
fun updateCrime(crime: Crime)

@Insert
fun addCrime(crime: Crime)
}

변경 함수와 추가 함수의 애노테이션에는 매개변수를 지정하지 않아도 Room이 적합한 SQL 명령을 생성한다.

updateCrime() 함수에는 @Update 애노테이션을 사용한다. 이 함수는 Crime 객체를 인자로 받아 이 객체에 저장된 ID를 사용해서 데이터베이스 테이블의 관련 행을 찾은 후 이 객체의 데이터로 변경한다.

addCrime() 함수에는 @Insert 애노테이션을 사용한다. 이 함수는 인자로 받은 Crime 객체의 데이터를 데이터베이스 테이블에 추가한다.

다음으로 방금 CrimeDao에 추가한 두 함수를 호출하도록 리포지터리를 변경한다. 다시 말하지만, 이 DAO 함수들이 LiveData를 반환하므로 Room은 CrimeDao.getCrimes()CrimeDao.getCrime(UUID)의 데이터베이스 쿼리를 백그라운드 스레드로 자동 실행된다. 이 경우 LiveData가 해당 데이터를 main 스레드로 전달하기 때문에 UI를 변경할 수 있다.

그러나 변경이나 추가의 경우에는 Room이 백그라운드 스레드로 자동 실행하지 못한다. 따라서 백그라운드 스레드로 변경이나 추가 함수들을 호출해야 하는데, 이때 주로 executor를 사용한다.

Executors 사용하기

Excutors는 스레드를 참조하는 객체다. Excutors 인스턴스는 execute라는 함수를 가지며, 이 함수는 실행할 코드 블록을 받는다. Executors 인스턴스를 생성하면 이 인스턴스가 새로운 백그라운드 스레드를 사용해 블록의 코드를 실행한다. 따라서 main 스레드를 방해하지 않고 데이터베이스 작업을 안전하게 수행할 수 있다.

여기서는 Executors를 CrimeDao에 직접 구현할 수 없다. 정의한 인터페이스를 기반으로 Room이 함수를 자동 생성하기 때문이다. 따라서 CrimeRepository에 Excutors를 구현해야 한다.

executor를 사용해서 데이터 변경과 추가하기 (CrimeRepository.kt)

Executors 인스턴스의 참조를 저장하는 속성과 Executors를 사용하는 함수를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CrimeRepository private constructor(context: Context){
...

private val crimeDao = database.crimeDao()
private val executor = Executors.newSingleThreadExecutor()

fun getCrimes(): LiveData<List<Crime>> = crimeDao.getCrimes()

fun getCrime(id: UUID): LiveData<Crime?> = crimeDao.getCrime(id)

fun updateCrime(crime: Crime) {
executor.execute {
crimeDao.updateCrime(crime)
}
}

fun addCrime(crime: Crime) {
executor.execute {
crimeDao.addCrime(crime)
}
}
...
}

newSingleThreadExecutor() 함수는 새로운 스레드를 참조하는 executors 인스턴스를 반환한다. 따라서 이 인스턴스를 사용해서 실행하는 어떤 작업도 main 스레드와 별개로 수행되므로 UI를 방해하지 않는다. updateCrime(Crime)addCrime(Crime) 모두 execute {} 블록 내부에서 DAO 함수를 호출한다.

프래그먼트 생명주기에 맞춰 데이터베이스에 데이터 쓰기

마지막으로 사용자가 상세 내역 화면에서 입력한 데이터를 데이터베이스에 쓰도록 변경한다. 이 작업은 사용자가 상세 내역 화면을 벗어날 때 수행한다.

데이터베이스에 변경하기 (CrimeDetailViewModel.ky)

1
2
3
4
5
6
7
8
9
10
11
class CrimeDetailViewModel: ViewModel() {
...

fun loadCrime(crimeId: UUID) {
crimeIdLiveData.value = crimeId
}

fun saveCrime(crime: Crime) {
crimeRepository.updateCrime(crime)
}
}

saveCrime(Crime)에서는 인자로 받은 Crime 객체를 데이터베이스에 변경한다. CrimeRepository는 백그라운드 스레드에서 데이터베이스의 데이터 변경을 처리하므로 saveCrime(Crime) 함수의 코드는 매우 간단하다.

다음으로 사용자가 변경한 범죄 데이터를 데이터베이스에 저장하도록 CrimeFragment를 변경한다.

onStop()에서 저장하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CrimeFragment : Fragment() {
...

override fun onStart() {
...
}

override fun onStop() {
super.onStop()
crimeDetailViewModel.saveCrime(crime)
}

private fun updateUI() {
...
}
...
}

Fragment.onStop()은 프래그먼트가 중단 상태 (프래그먼트 화면 전체가 안보이게 될 때)가 되면 언제든 호출된다. 따라서 여기서는 사용자가 상세 내역 화면을 떠나거나 (백 버튼) 작업을 전환 (홈 버튼 혹은 오버뷰 화면에서 다른 앱으로 전환)하면 데이터가 저장된다. 그러므로 onStop()에서 데이터를 저장하면 사용자가 상세 내역 화면을 떠나거나 메모리 부족으로 안드로이드가 프로세스를 종료할 때도 데이터가 유실되지 않고 저장된다.


💁🏻‍♂️ 궁금증 프래그먼트 인자를 사용하는 이유는?

본문에서 프래그먼트의 새로운 인스턴스를 생성할 때 인자를 전달하려고 newInstance(...) 함수를 프래그먼트에 추가하였다. 이런 패턴은 코드 구성 관점이나 프래그먼트 인자 모두에 유용하다. 이와는 달리, 생성자를 사용하면 프래그먼트 인스턴스에 인자를 전달할 수 없다.

예를 들어, newInstance(UUID) 함수를 추가하는 대신 UUID 타입의 범죄 ID를 인자로 받는 생성자를 CrimeFragment에 추가할 수 있다. 그런데 이 방법에는 결함이 있다. 장치 회전에 따른 구성 변경이 생기면 현재 액티비티의 프래그먼트 매니저는 구성 변경이 생기기 전에 호스팅되었던 프래그먼트 인스턴스를 자동으로 재생성한다. 그다음으로는 재생성된 프래그먼트 인스턴스를 새 액티비티 인스턴스에 추가한다. 그리고 구성 변경 후에 프래그먼트 매니저가 프래그먼트를 다시 생성할 때는 해당 프래그먼트의 인자가 없는 기본 생성자를 호출한다. 따라서 구성 변경 후에는 새로 생성된 프래그먼트 인스턴스가 범죄 ID를 받지 못하게 된다.

그렇다면 프래그먼트 인자를 사용할 때는 무엇이 다를까? 프래그먼트 인자는 프래그먼트 생애에 걸쳐 보존된다. 구성 변경이 생기더라도 프래그먼트 매니저가 새 프래그먼트 인스턴스를 생성하면서 프래그먼트 인자를 다시 첨부하기 때문이다. 따라서 새 프래그먼트는 첨부된 인자 번들을 사용해서 자신의 상태 데이터를 다시 생성할 수 있다.

그런데 이렇게 복잡하게 프래그먼트 인자를 사용하지 않고 프래그먼트의 인스턴스 변수를 사용해서 상태 데이터를 보존하면 되지 않을까? 그러나 항상 보존된다는 보장이 없다. 구성 변경이 생기거나 사용자가 다른 앱 화면으로 이동해서 안드로이드 운영체제가 프래그먼트를 다시 생성하면 프래그먼트의 모든 인스턴스 변수들이 갖는 값이 없어진다.

다른 방법으로는 SIS Saved Instance State 매커니즘이 있다. 이때는 범죄 ID를 프래그먼트 인스턴스 변수에 저장하고 프래그먼트가 소멸하면 자동 호출되는 onSaveInstanceState(Bundle)에서 범죄 ID를 Bundle 객체에 저장했다가 나중에 프래그먼트 인스턴스가 재생성되면 호출되는 onCreate(Bunlde)에서 Bundle 객체의 범죄 ID를 꺼내어 사용하면 된다. 이 방법도 모든 경우에 통용된다.

그런데 이 방법은 유지 보수가 어렵다. 기간이 지난 후에 해당 프래그먼트의 코드를 다시 보면서 또 다른 인자를 추가할 때 onSaveInstanceState(Bundle)에서 해당 인자를 저장했는지 기억하기 어렵기 때문이다.

따라서 모든 경우에 프래그먼트의 상태 데이터를 잘 보존하려면 프래그먼트 인자를 사용하는 것이 가장 좋다.

그외의 Fragment간 데이터 전달 방법들

👨🏻‍💻 챌린지 DiffUtil : 효율적으로 RecyclerView 다시 로드하기

본문의 코드는 상세 내역 화면의 데이터를 변경하고 리스트 화면으로 돌아오면 CrimeListFragment가 모든 범죄 데이터를 RecylcerView에 다시 채워서 보여준다. 하나의 범죄 데이터만 변경되었을 뿐인데 이렇게 모두 변경하는 것은 매우 비효율적이다.

변경된 범죄 데이터와 연관된 행만 다시 채워서 보여주도록 CrimeListFragment의 RecyclerView를 변경하자.

CrimeListFragment의 내부 클래스로 정의된 CrimeAdapter의 슈퍼 클래스를 RecycelrView.Adapter<CrimeHolder> 대신 androidx.recyclerview.widget.ListAdapter<Crime, CrimeHolder> 로 변경하면 된다.

ListAdapter는 현재의 RecyclerView 데이터와 새로 RecyclerView에 설정하는 데이터 간의 차이를 아는 RecyclerView의 어댑터다. 이런 차이점 비교는 백그라운드 스레드에서 수행되므로 UI에 영향을 주지 않는다. 그리고 비교가 끝난 후 ListAdapter는 변경된 데이터의 행들만 다시 채워서 보여주도록 RecyclerView에게 알려준다.

ListAdapter는 androidx.recyclerview.widget.DiffUtil을 사용해서 데이터 셋의 어떤 부분이 변경되었는지 판단한다. 이 챌린지를 완료하려면 DiffUtil.ItemCallback<Crime>을 구현하는 클래스를 ListAdapter에 제공해야 한다.

또한, 변경된 범죄 리스트가 RecyclerView의 어댑터에 전달되도록 ListAdapter.submitList(MutableList<T>?)를 호출해서 CrimeListFragment를 변경한다 (UI를 변경할 때마다 RecyclerView의 어댑터를 새로운 어댑터 객체에 다시 지정하지 않게 한다).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class CrimeListFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
crimeListViewModel.crimeListLiveData.observe(
viewLifecycleOwner,
Observer { crimes ->
updateUI(crimes)
})
}

...

private fun updateUI(crimes: List<Crime>) {
adapter = CrimeAdapter(crimes)
crimeRecyclerView.adapter = adapter

// ListAdapter에게 새로운 리스트가 생겼다는 것을 submitList()를 통해 알려준다.
adapter?.submitList(crimes)
}

private inner class CrimeHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
...
}

/**
* CrimeAdapter의 슈퍼 클래스를 ListAdapter로 변경
* RecycelrView.Adapter<CrimeHolder> 대신 androidx.recyclerview.widget.ListAdapter<Crime, CrimeHolder> 로 변경하면 된다.
*/
private inner class CrimeAdapter(var crimes: List<Crime>) : ListAdapter<Crime, CrimeHolder>(CrimeDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: CrimeHolder {
val view = layoutInflater.inflate(R.layout.list_item_crime, parent, false)
return CrimeHolder(view)
}

// getItemCount() 오버라이드 함수를 제거한다. ListAdapter가 해당 메서드를 구현하기 때문

override fun onBindViewHolder(holder: CrimeHolder, position: Int) {
val crime = getItem(position)
holder.bind(crime)
}
}

// DiffUtil.ItemCallback을 구현하는 클래스 생성 -> ListAdapter에게 제공
private class CrimeDiffCallback : DiffUtil.ItemCallback<Crime>() {
override fun areItemsTheSame(oldItem: Crime, newItem: Crime): Boolean {
return oldItem.id == newItem.id
}

override fun areContentsTheSame(oldItem: Crime, newItem: Crime): Boolean {
return oldItem == newItem
}
}
...
}

API 문서 페이지

댓글