[Double Check-02] 수정 즉시 업데이트되는 EditText in RecyclerView 만들기 2022. 12. 2. 13:25

안드로이드 앱에서 수정 가능한 아이템 목록이 있는데 수정하면 바로 DB까지 업데이트되도록 EditTextRecyclerView를 사용해서 two way binding으로 구현해보았다. 마치 구글 Keep과 같이 직관적이고 빠릿한 동작을 만들고 싶었다.

C# Observable로 데이터 바인딩 했던 기억이 새록 새록 나면서, 이럴때 어떻게 되는 것인지, 저럴때 어떻게 해야 하는 것인지 의문도 많이 들었다. 그렇다고 동작하면 망고 땡이다라고 생각하고 막 쓰기엔 내가 너무 꼰대라서 조금 괴로웠다. 직접 구현을 하면서 알게된 몇 가지 것들을 정리하고 다음 단계로 넘어가려 한다. 이것들을 알게되기까지에는 약간의 시행착오와 개념을 탑재하는데 시간이 필요했지만 막상 한 번 알고 나면 엄청 편리하게 마구 사용하게 된다.

Double Check Project

Property와 Field

자바에서 멤버 변수를 필드라고 부른다. 멤버 변수에 접근 제한을 위해서 외부에서 접근 할 수 없는 private 변수를 만들고 getter 함수를 구현해서 접근만 가능하게하는 패턴이 자주 사용되는데, 코틀린은 컴파일 시점에 getter, setter 함수를 자동으로 만들어 준다. Kotling은 멤버 변수를 프로퍼티 Property라고 부르는데, 그건 C#이랑 같은듯. val로 선언된 프로퍼티는 getter만 가지고 있다. 물론 직접 getter/setter를 구현할 수도 있다.

Observable과 LiveData

Observable은 C#에서 데이터 바인딩을 써봤을 때 사용했던 경험이랑 거의 동일한 것 같다. notifyPropertyChanged 메소드 이름도 같다. 반면, LiveData는 낯설었는데, 생명주기를 따라서 Observable을 관리해준다고 한다. 모던 앤쥬로이드에는 생명주기에 맞게 컨트롤 해주는 것이 많아진 것 같다. 액티비티나 프레그먼트의 생명주기에 따라서 동작을 처리해야 할 필요성이 많아 짐에 따라서 Lifecycle이라는 모듈까지 만들었다. 너무 일반명사로 이름을 지어서 검색이 어려운데, LivecycleOwner로 검색하는게 낫다. LivecycleOwnerLifecycleObserver로 구분되는데, LivecycleOwner의 상태를 관찰하면서 동작을 구현할 수 있게 해준다.

LiveData 같은 경우는 그냥 LivecycleOwner 인스턴스만 넘겨주면 알아서 라이프 사이클의 onResume/onStart에서 데이터의 변경사항을 수신하고, onDestroy에서는 관찰을 중지한다. 참으로 편리한 세상이다.

주의할 점은 라이브데이터는 상태를 보관하고 있다가 onResume/onStart 상태가 되면 마지막 값을 관찰자에게 다시 전달하는데, 그것을 모르고 구현하는 경우 왕왕 문제를 일으킨다. 예를 들어 사용자의 입력으로 상태가 변경되어서 A에서 B프레그먼트로 이동한 경우, B에서 상태가 변경되어서 다시 A로 이동하는 경우 A에서는 마지막 상태가 다시 입력되면서 그것이 화면을 다시 B로 이동하게 되고 B에서도 마찮가지로 다시 상태가 관찰자에게 전달되면서 전환이 다시 발생한다. 상태에 따라서 화면을 전환하도록 구성했는데, 이전 상태가 다시 들어오니까 화면이 의도치 않게 전환되고, 화면이 전환되면서 이전 상태가 다시 들어와서 또 문제가 되는 것이다.

MutableLiveData와 LiveData

two way data binding으로 UI 입력이 바로 뷰모델에 반영되도록 적용을 하였다. 근데, MutableLiveData라고 하더라도 .value에 값을 넣고 읽어야 하는데, 흠...알아서 그렇게 해주네. 즉, two way data binding을 위해서 MutableLiveData 프로퍼티를 layout.xml에 바로 android:text="@={viewmodel.title}" 이렇게 써줘도 알아서 잘 viewmodel.title.value에다가 읽고 쓰더라. 허참. 편리하긴 한데, 적응이 잘 안된닼ㅋㅋ 일반 String은 그냥 값을 넣고 쓰고, LiveData는 .value로 해주니까 왠지 다른것도 알아서 해줄 것 같고 그런 착각도 들게 만들고ㅎㅎㅎ 암튼, 편리하긴 한데 명시적으로 동작한다고 할 수 있을지는 모르겠다.

two way binding 많이 써도 문제 없나?

이쯤에서 몇가지 의문이 드는데, 관찰가능한 양방향 바인딩 객체를 만들려면 비용이 많이 들것 같은데, 많이 써도 문제 없을까? 성능을 체크해 보았다.

플로팅 버튼을 누르면 10000개의 아이템이 추가되도록 했더니 앱이 죽는다. 1000개로 해보니까 메모리가 대략 200MB 정도 늘어난다. 메모리는 native 쪽에서 대부분 사용하고 있으며 data class를 만드는 것만으로는 거의 차이가 없고, 바인딩 되어 있는 리스트에 추가 할 때 메모리가 많이 사용된다. 이것 저것 대충 해봤는데, 단방향으로 변경해도 비슷하게 증가하고 LiveData 사용안하고 String으로 해도 동일하다. 그냥 RecyclerView 자체가 그 정도 소모하나 보다. 프로파일링은 나중에 다시 더 해봐야겠다.

Performance profiling

갯수의 제한은 있어야 할 것 같으니 아이템은 넉넉하게 430개 제한을 두기로 했다. 내 생일에 맞춰섴ㅋㅋㅋ 제한은 동시에 적용하지 않으면 제한되지 않은 데이터가 사용되면서 문제가 될 수 있으니 View, ViewModel, Dataa Layer에 동시에 제한을 추가하도록 하였다.

텍스트의 길이는 120자로 제한하기로 했다. 마찬가지로 모든 로직에 동일하게 제한을 적용한다.

코드는 깃헙에 브랜치 따놨다.

Two Way Binding Edittext in RecyclerView

class EditTaskFragment : Fragment() {

    private val viewModel by viewModels<EditTaskViewModel>()

    private var _binding: EditTaskFragBinding? = null

    private lateinit var listAdapter: CheckItemListAdapter

    // This property is only valid between onCreateView and
    // onDestroyView.
    private val binding get() = _binding!!

    override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View? {

        _binding = EditTaskFragBinding.inflate(inflater, container, false)
        binding.viewmodel = viewModel
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Set the lifecycle owner to the lifecycle of the view
        binding.lifecycleOwner = this.viewLifecycleOwner
        viewModel.title.observe(this.viewLifecycleOwner) {
            Log.d("JSM", "Changed ${viewModel.title.value}")
        }
        setupListAdapter()
        binding.fab2.setOnClickListener {
            repeat(999) {
                addNewItem()
            }
        }
    }

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

    private fun setupListAdapter() {
        if (viewModel != null) {
            listAdapter = CheckItemListAdapter(viewModel)
            binding.checkitemList.adapter = listAdapter
            binding.button.setOnClickListener() {
                addNewItem()
            }
        } else {
            Log.w("JSM_TEST", "ViewModel not initialized when attempting to set up adapter.")
        }
    }

    private fun addNewItem() {
        val item = CheckItem()
        item.contents.value = item.id
        item.contents.observe(this.viewLifecycleOwner) {
            Log.d("JSM", "Changed item contents ${item.id} : ${item.contents.value}")
        }
        viewModel.items.add(item)
        listAdapter?.notifyItemInserted(viewModel.items.size - 1)
    }
}

어느 시점에 Data layer와 싱크를 맞출 것인가?

마지막 고민은 언제 Data layer와 싱크를 맞출지 결정하는 것이다. 최대한 민첩하게 사용자의 입력에 반응하고 항상 최신의 상태를 유지하는 것이 중요하지만 타이핑 되는 횟수만큼 DB를 업데이트하는 것은 상당히 무모한 도전이다. 지금 생각으로는 업데이트 매니저를 두고 요청을 받아서 지연 시간 후에 업데이트 하되, 지연시간내에 후속 요청이 들어오면 시간을 연장하고 마지막 내용으로 업데이트를 진행하는 것이다. 지연시간은 1~3초 정도로 짧게 유지하면 될 것 같다. 이부분은 일단 구현한 다음에 코드와 함께 정리해보려고 한다.

Double Check는 오픈소스 프로젝트로 동작하는 코드를 누구나 받아서 사용가능하다.

댓글