Android ViewModel & ListView 사용하기

목적

ViewModel을 사용해서 fragment 작동 동안 데이터를 유지하고 화면에 표시한다

- ViewModel 사용법을 배운다

- ListView를 통해서 List형 Data를 표현하고 인터렉션 하는 것을 배운다

본 내용은 앞선 포스트에서 연속해서 진행합니다.

2022.01.24 - [Android] - Android Fragment 설정하기

2022.01.24 - [Android] - Android Fragment Navigation with Action

2022.01.25 - [Android] - Android Fragment with Arguments

2022.02.03 - [Android] - Android View Binding (뷰 바인딩)

앞서 뷰바인딩에서 마지막으로 사용되었던 소스를

https://github.com/theyoung/fragmentsetup/tree/2de0436018dde923c210601237c121df6909aa4c

활용합니다.

 

ViewModel 설정하기

ViewModel은 안드로이드 기본 API에 포함되어있지 않습니다. jetpack의 lifecycle package의 구성요소로 포함되어 있습니다.

https://developer.android.com/jetpack/androidx/releases/lifecycle

viewmodel을 app 하위에 있는 build.gradle에 포함시켜 줍니다.

이후 Gradle Sync를 시켜주면 사용을 위한 준비는 끝이 났습니다.

dependencies {

...
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
...
}

 

ViewModel Class 생성

com.example.fragmentsetup.data package에 DataviewModel.kt를 생성해 줍니다.

package com.example.fragmentsetup.data

import androidx.lifecycle.ViewModel

public class DataViewModel : ViewModel() {

    val lists : MutableList<String> = ArrayList<String>()
    val size get() = lists.size

    public fun addMessage(message:String){
        lists.add(message)
    }

    public fun getMessages() : List<String>{
        return lists.toList()
    }

    public fun getMessagesAt(idx : Int) : String{
        return lists.get(idx)
    }

    public fun removeDataAt(index:Int){
        lists.removeAt(index)
    }
}

Data를 저장하게될 MutableList와

  • 데이터를 추가시킬 수 있는 addMessage
  • 데이터를 ImmutableList로 갖어올 getMessage
  • 하나의 데이터만을 갖어올 getMessageAt
  • 특정 위치의 데이터를 삭제할 removeDataAt

메서드를 만들어 줍니다.

이제 해당 ViewModel Class를 사용해 보겠습니다.

 

ViewModel을 Fragment에 적용하기

첫번째 화면인 TestFragment.kt에 다음 내용을 추가합니다.

class TestFragment : Fragment() {
...
    lateinit var viewModel: DataViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
...
        viewModel = ViewModelProvider(requireActivity()).get(DataViewModel::class.java)

        binding.button.setOnClickListener {
...
        }

        return binding.root
    }

...

}

viewModel을 onCreateView이후에 생성할 예정임으로 lateinit을 지정해 주었습니다.

lateinit var viewModel: DataViewModel

onCreateView에서 DataViewModel을 인스턴스화 해줘야 합니다.

viewModel = ViewModelProvider(requireActivity()).get(DataViewModel::class.java)

일반적인 인스턴스 생성코드와는 많이 다릅니다.

해당 코드를 작동하게 되면 다음과 같이 2가지 동작이 라이브러리 내부에서 일어나게 됩니다.

 

ViewModel을 깊게 알아보자

  • ViewModel을 lifecycle과 연동시키기 위한 ViewModelStoreOwner를 정의합니다.
public class ComponentActivity extends androidx.core.app.ComponentActivity implements
        ContextAware,
        LifecycleOwner,
        ViewModelStoreOwner,

....
// Lazily recreated from NonConfigurationInstances by getViewModelStore()
private ViewModelStore mViewModelStore;
private ViewModelProvider.Factory mDefaultFactory;

@NonNull
@Override
public ViewModelStore getViewModelStore() {
    if (getApplication() == null) {
        throw new IllegalStateException("Your activity is not yet attached to the "
                + "Application instance. You can't request ViewModel before onCreate call.");
    }
    ensureViewModelStore();
    return mViewModelStore;
}

@SuppressWarnings("WeakerAccess") /* synthetic access */
void ensureViewModelStore() {
    if (mViewModelStore == null) {
        NonConfigurationInstances nc =
                (NonConfigurationInstances) getLastNonConfigurationInstance();
        if (nc != null) {
            // Restore the ViewModelStore from NonConfigurationInstances
            mViewModelStore = nc.viewModelStore;
        }
        if (mViewModelStore == null) {
            mViewModelStore = new ViewModelStore();
        }
    }
}
....

참고로 ViewModelStore는 내부적으로 HashMap을 사용해서 해당 lifecycle에서 사용되는 ViewModel을 관리합니다.

위의 코드를 붙여놓은 이유는 viewModel의 Lifecycle을 명확히 하기 위해서 입니다.

즉, ComponentActivity가 instance 종료되면 해당 클래스에서 유지하는 attribute인 mViewModelStore도 같이 삭제 되게 됩니다.

  • viewModelStore에 get을 통해 얻어온 modelClass를 instance 생성해 줍니다.
public open class ViewModelProvider(
    private val store: ViewModelStore,
    private val factory: Factory
) {
   ....
   
    public open operator fun <T : ViewModel> get(key: String, modelClass: Class<T>): T {
        var viewModel = store[key]
        if (modelClass.isInstance(viewModel)) {
            (factory as? OnRequeryFactory)?.onRequery(viewModel)
            return viewModel as T
        } else {
            @Suppress("ControlFlowWithEmptyBody")
            if (viewModel != null) {
                // TODO: log a warning.
            }
        }
        viewModel = if (factory is KeyedFactory) {
            factory.create(key, modelClass)
        } else {
            factory.create(modelClass)
        }
        store.put(key, viewModel)
        return viewModel
    }

위 코드에서 주의 깊게 봐야할 부분은 store.put <- 이 부분입니다. 앞서서 설명한 ViewModelStore에 key value hashmap을 통해서 put되는 것을 확인할 수있습니다.

참고로 hashmap은 thread safe하지 않습니다. viewModel은 thread-safe하게 design되지 않은것 같습니다.

해서 관련 문서를 찾아 보았는데, 구글에서도 thread에서 사용하지 말고 Main Thread 하나에서만 사용하라고 합니다.

By design, 
Android View objects are not thread-safe
. An app is expected to create, use, and destroy UI objects, all on the main thread. If you try to modify or even reference a UI object in a thread other than the main thread, the result can be exceptions, silent failures, crashes, and other undefined misbehavior.

https://developer.android.com/topic/performance/threads#:~:text=By%20design%2C%20Android%20View%20objects,crashes%2C%20and%20other%20undefined%20misbehavior.

위의 코드를 통해서 알수있는 내용이 한가지가 더 있습니다.

viewModel이 얼마나 오래 살수있는지에 대한 Scope입니다.

viewModel = ViewModelProvider(requireActivity()).get(DataViewModel::class.java)
class TestFragment : Fragment() {
....

viewModel = ViewModelProvider(this).get(DataViewModel::class.java)

위 두 코드의 차이점은

  • 위에 코드는 Fragment가 위치하는 Activity가 살아있는 동안 ViewModel이 삭제 되지 않습니다.
  • 아래 코드는 Fragment가 살아있는 동안만 ViewModel이 살수 있습니다.

이점을 이용해서 우리는

  • Activity 이하에 있는 모든 fragments는 동일 ViewModel을 공유할 수 있다.
  • Data Size가 큰 Activity 또는 Fragement가 Destroy되지 않는 다면 메모리 Leak이 될 수 있다

는 점을 확인 할 수 있습니다. 

2020.03.27 - [Android] - android memory leak 처리

Context를 다른 컴포넌트에 위임할 때는 WeakReferece를 사용하세요.

 

ViewModel에 Data 추가하기

 

fragment_test에서 Next버튼을 누르면 EditText에 있는 값을 viewModel에 넣도록 해보겠습니다.

class TestFragment : Fragment() {
...
    lateinit var viewModel: DataViewModel

    override fun onCreateView(
...
    ): View? {
...
        binding.button.setOnClickListener {
            val str = binding.editTextTextPersonName.text.toString() ?: "hello"
            viewModel.addMessage(str)
...
        }

viewModel.addMessage를 통해서 간단히 데이터를 넣는 것을 확인 할 수있습니다.

이 데이터는 이제 fragement_second와 fragment_third에서도 공유가 가능합니다.

그 이유는 ViewModelProvider를 생성할때 fragment lifecycle이 아닌 activity lifecycle에 연동을 시켰기 때문입니다.

viewModel = ViewModelProvider(requireActivity()).get(DataViewModel::class.java)

위의 코드에서 'requireActivity'를 사용했다는 것을 잊지 마세요.

같은 방법으로 SecondFragement.kt에도 viewModel을 생성해 주겠습니다.

class SecondFragment : Fragment() {
    lateinit var viewModel : DataViewModel
    var _binding : FragmentSecondBinding? = null
    val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
//        val view = inflater.inflate(R.layout.fragment_second, container, false)
        _binding = FragmentSecondBinding.inflate(inflater, container, false)
        val view = binding.root

        viewModel = ViewModelProvider(requireActivity()).get(DataViewModel::class.java)
        val list = viewModel.getMessages()
        list.forEach { str ->
            Log.d(this.javaClass.name, str)
        }
        
        ....

기존에 존재하던 findbyid를 view binding으로 변경하였습니다.

그리고 앞서와 동일한 방식으로 viewModel을 가져왔습니다.

여기서 혼동하지 말아야 할 것은

viewModel = ViewModelProvider(requireActivity()).get(DataViewModel::class.java)

상위 코드를 썻다고 해서 TestFragment와 다른 viewModel을 갖어온것이 아니라는 것입니다.

동일한 Activity lifecycle의 동일한 viewModel을 갖어 왔다는 것을 잊지 마세요.

그럼 그냥 get만 하면 안될까요? 네 안됩니다. get을 위해서는 ViewModelProvider를 얻어와야 하는데 해당 Provider가 Activity에 종속되기 때문입니다.

위까지 만들고 코드를 돌려보면

        val list = viewModel.getMessages()
        list.forEach { str ->
            Log.d(this.javaClass.name, str)
        }

위의 내용으로인해서 LogCat에 TestFragement에서 입력한 값이 삭제 되지 않고 쌓이는 것을 확인할 수 있습니다.

이제 LogCat에 log로 보여지는 내용들을 ListView에 보여주는 일만 남았습니다.

 

Listview 설정하기

fragment_text(왼쪽)에서 Next버튼을 누르면 TextEdit에 있던 데이터가 fragment_second(오른쪽)로 넘어가서 지금까지 Next로 저장했던 Data를 표시하는 부분을 만들어 보겠습니다.

fragment_second.xml을 열어서

버튼 하단에 Listview를 삽입하였습니다.

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

    <!-- TODO: Update blank fragment layout -->
    <TextView
        android:id="@+id/textView2"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@string/fragment_second" />

    <Button
        android:id="@+id/button2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="Next"
        app:layout_constraintEnd_toEndOf="@+id/textView2"
        app:layout_constraintStart_toStartOf="@+id/textView2"
        app:layout_constraintTop_toTopOf="@+id/textView2" />

    <ListView
        android:id="@+id/listview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="64dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@+id/textView2"
        app:layout_constraintStart_toStartOf="@+id/textView2"
        app:layout_constraintTop_toBottomOf="@+id/button2" />

</androidx.constraintlayout.widget.ConstraintLayout>

참고로 요즘은 recycleview를 많이 사용하는데,

아무래도 안드로이드 List 화면의 근본은 ListView니까 Listview를 사용했습니다. 이후에 MVVM 모델을 반영하면서 RecycleView로 변경하겠습니다.

 

ListView를 위한 개체를 만들자

Listview는 ListView에 표시될 여러개의 개체가 리스트 형태로 표현되는 방식으로 여러개의 데이터를 표현합니다.

그렇게 하기위해서 하나의 개체에 대한 Layout을 만들어 줘야 합니다.

layout으로 element_sigle.xml을 하나 추가합니다.

해당 개체는 TextView를 이용해서 앞서 fragment_text.xml에서 넘어온 데이터를 표현할 것입니다.

LinearLayout하나에 TextView하나를 설정해 주었습니다.

TextView의 id는 text_string이라고 지정하였습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/directions"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    android:background="?attr/selectableItemBackground"
    >

    <TextView
        android:id="@+id/text_string"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:layout_weight="1"
        android:lineSpacingExtra="8sp"
        android:text="TextView"
        android:textSize="24sp"
        android:textStyle="bold" />
</LinearLayout>

이제 Listview와 그 Listview안에 표시할 객체까지 구조를 잡았습니다.

이제 위의 layout을 사용하는 List부분을 작성해 보겠습니다.

 

ListViewAdapter 생성하기

com.example.fragmentsetup에 ListViewAdapter를 만들어 줍니다. 해당 view는 BaseAdapter를 생성 받으면 됩니다.

물론 Adapter가 다양하게 있지만, 역시 안드로이드 List의 근본 Adapter는 BaseAdapter겠죠!

package com.example.fragmentsetup

....

class ListViewAdapter(val context : Context, var list: DataViewModel) : BaseAdapter() {

    val layoutInflater : LayoutInflater = LayoutInflater.from(context)

    override fun getCount(): Int {
        return list.size
    }

    override fun getItem(idx : Int): String {
        return list.getMessagesAt(idx)
    }

    override fun getItemId(idx: Int): Long {
        return idx.toLong()
    }

    override fun getView(position: Int, convertView: View?, container: ViewGroup?): View? {
        var view : View?

        if (convertView == null){
            view = layoutInflater.inflate(R.layout.element_single,container,false)
        } else {
            view = convertView
        }

        view?.findViewById<TextView>(R.id.text_string)?.text = list.getMessagesAt(position)

        return view;
    }

}

위와 같이 간단한 Adapter를 만들어 줍니다.

가장 중요한 Method는 다음 2가지 입니다.

  • getCount : 목록에 표시될 전체 갯수를 알려줍니다.
  • getView : ListView에 붙이게 할 개체를 갖어옵니다.

List를 만들때 위의 두가지만 생각합면 됩니다. 물론 view holder라는 것을 만들어서 리스트 개체를 별도 관리 해야겠지만, 화면에 리스트를 표시하기위해 반듯이 필요한 행위가 아니라는 점을 이해하시면 됩니다.

class ListViewAdapter(val context : Context, var list: DataViewModel) : BaseAdapter() {

ListViewAdapter에서 2가지 파라메터를 받았는데요.

첫번째는 Adapter를 사용할 Fragment 또는 Activity의 context입니다. 이를 이용해서 layoutInfater를 활용 할 수있게 됩니다.

val layoutInflater : LayoutInflater = LayoutInflater.from(context)
override fun getView(position: Int, convertView: View?, container: ViewGroup?): View? {
    var view : View?

    if (convertView == null){
        view = layoutInflater.inflate(R.layout.element_single,container,false)
    } else {

getView가 불리워질때 현재 만들 리스트의 

  • position : 몇번째 개체를 만드는지 알려줌으로써 목록 데이터에서 몇번째를 갖어 오면 되는지 알 수 있게 됩니다.
  • convertView : 만약 앞서서 이미 만든적이 있는 개체라면 해당 View resource를 재활용 하게 합니다.
  • container : 해당 view가 붙게될 parent layout view를 갖어오게 되는데 여기서는 ListView를 갖어오게 됩니다.

정보를 알려주게 됩니다.

해당 정보를 바탕으로 저희는 viewModel에서 정보를 얻어와서 화면에 표시하게 만들어 줄 수 있습니다.

view?.findViewById<TextView>(R.id.text_string)?.text = list.getMessagesAt(position)

위의 코드는 viewModel에서 특정 position의 String Data를 목록 객체 중 text_string이라고하는 id를 갖는 View에 데이터를 넣어주는 행위를 하게 합니다.

이제 여기까지만 해도 입력된 데이터가 리스트로 나오는 것을 볼 수 있습니다.

 

ViewModel 데이터를 삭제하자

이제 특정 Data를 longClick하면 목록에서 삭제하는 기능을 만들어 보려고 합니다.

앞서 만든 getView에 다음과 같은 코드를 넣어주세요

    override fun getView(position: Int, convertView: View?, container: ViewGroup?): View? {

...
        view?.setOnLongClickListener() {
            Toast.makeText(context, "long clicked" + position, Toast.LENGTH_SHORT).show()
            deleteAt(position)
            true
        }

특정 위치의 개체를 롱클릭하면 사용자 입력이 몇번째 개체에서 되었음을 알 수 있게 됩니다.

해당 position의 데이터를 작세하는 기능을 만들어 보겠습니다.

class ListViewAdapter(val context : Context, var list: DataViewModel) : BaseAdapter() {

...

    private fun deleteAt(idx: Int){
        list.removeDataAt(idx)
        notifyDataSetChanged()
    }
 ...

위와 같이 deleteAt 메서드를 부르면 ViewModel에서 특정 영역의 데이터를 삭제 하고 notifyDataSetchanged()를 통해서, 해당 Adapter의 데이터가 새로 생성되어야 함을 공지하게 됩니다.

그럼 다시 목록을 그리게 됩니다.

참고로 notifyDataSetchanged는 이후에 MVVM live data를 통해서 최적화 될 것입니다.

 

목록 개체를 click하면 Data를 ThirdFragment로 넘겨주자

위에서 onLongClick을 알아보았는데요. 같은 방법으로 onClick도 가능합니다.

특정 개체 Element를 클릭하면 ThirdFragment로 Data를 넘겨주는 기능을 간단하게 만들어 주겠습니다.

2022.01.25 - [Android] - Android Fragment with Arguments

위에서 자세한 설명은 이미 한 부분이라 자세한 설명없이 수정된 부분만 공유하겠습니다.

Navigation graph에서 thirdFragment에 Arguments를 추가합니다.

해당 Arguments를 통해서 Action에 데이터를 포함해서 넘겨줍니다.

view?.setOnClickListener() {
    val str = it.findViewById<TextView>(R.id.text_string).text.toString()
    Toast.makeText(context, "clicked " + str, Toast.LENGTH_SHORT).show()
    val action = SecondFragmentDirections.actionSecondFragmentToThirdFragment(str)
    it.findNavController().navigate(action)
    true
}

해당 데이터를 받는 ThirdFragment에서 Arguments를 얻어와서 textView에 표시합니다.

class ThirdFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val view = inflater.inflate(R.layout.fragment_third, container, false)
        view.findViewById<Button>(R.id.button3).setOnClickListener {
            view.findNavController().popBackStack()
        }
        val param = ThirdFragmentArgs.fromBundle(requireArguments()).str
        view.findViewById<TextView>(R.id.textView3).text = param
        return view

    }

}

위에까지만 만들고 나면 하기와 같이 작동하는 것을 확인 할 수 있습니다.

 

이렇게 작동합니다.

정말 별거 안 만들었는데, 시간이 많이 걸렸네요.

 

마지막으로 on click이나 longclick과 같은 합수들은 lamda를 이용한 파라메터 입력으로 처리도 가능합니다.

        view?.setOnLongClickListener() {
            Toast.makeText(context, "long clicked" + position, Toast.LENGTH_SHORT).show()
            deleteAt(position)
            true
        }
        val lamda : (v:View)-> Unit = {
            Toast.makeText(context, "elements deleted", Toast.LENGTH_SHORT).show()
            deleteAt(position)
            true
        }
        view?.setOnLongClickListener(lamda)

위 두개의 작동은 동일합니다.

lamda를 파라메터로 사용할 경우 작동을 외부에서 주입을 통해 처리할 수 있는 유연성을 얻을 수 있습니다.

 

소스는 여기에 있습니다.

https://github.com/theyoung/fragmentsetup/tree/bd68b4ed58be24010fb79368ce9585e913a9e88a

 

ListView로 만든것을 Recycler View Adapter로 재 개발해 보겠습니다.

2022.02.12 - [Android] - Android BaseAdapter vs RecyclerAdapter 작동 원리 (RecyclerView 개발)

728x90
반응형