👍 과거 코드의 문제점

  • ViewModel 사용법의 문제점
loginViewModel = ViewModelProviders.of(this).get(LoginViewModel::class.java);
studentViewModel = ViewModelProviders.of(this).get(StudentViewModel::class.java);
locationViewModel = ViewModelProviders.of(this).get(LocationViewModel::class.java);
  1. View 하나에 여러개의 ViewModel을 사용하며
  2. MVVM을 위반하고 제대로된 역할의 분리가 이루어 지지 않고 있었다.
private fun setBusList(responseAllBusList: List<BusResponse>) {
        binding.tablayout.removeAllTabs()
        binding.tablayout.clearOnTabSelectedListeners()
        for (i in responseAllBusList.indices) {
            setTabLayout(responseAllBusList[i])
            if (choiceDate == i) {
                busList = responseAllBusList[i].getBues()
            }
        }
        try {
            binding.tablayout.getTabAt(choiceDate).select()
        } catch (e: NullPointerException) {
            e.printStackTrace()
        }
        setTabLayoutListener()
}
  1. ViewModel에 써야할 코드를 Activity에 대다수 작성하였다.
  • data binding 사용법의 문제점
private fun dataIntoView(profile: Profile) {
        binding.intro.isSelected = true
        binding.birth.text = profile.userBirth
        binding.intro.text = profile.userIntro
        binding.name.text = profile.userName
        binding.point.text = profile.userPoint.toString()
        if (profile.userPicture != null) {
            Glide.with(this).load(profile.userPicture).into(binding.profileImage)
        }
        if (profile.userGender == "m") {
            Glide.with(this).load(R.drawable.man_icon).into(binding.gender)
        }
        else {
            Glide.with(this).load(R.drawable.woman_icon).into(binding.gender)
        }
        feedAdapter = FeedAdapter(this, profile.userFeeds!!)
        setRecyclerView()
    }
}
  1. 코드에 binding을 사용하며 binding을 완전히 잘못 이해하고 사용하였다.
  2. 고작 findViewById의 몇 글자를 줄이기위한 용도로 잘못 사용하였다.

✌ 리팩토링

  • ViewModel 제대로 사용하기
abstract class BaseActivity<VB : ViewDataBinding, VM : BaseViewModel<*>> : AppCompatActivity() {
    protected lateinit var binding: VB
    protected lateinit var viewModel: VM

    @LayoutRes
    protected abstract fun getLayoutId(): Int

    protected abstract fun getViewModel(): Class<VM>

    protected abstract fun getBindingVariable(): Int

    protected abstract fun initObserver()

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        performDataBinding()
    }

    private fun performDataBinding() {
        binding = DataBindingUtil.setContentView(this, getLayoutId())
        this.viewModel = if(::viewModel.isInitialized) viewModel else ViewModelProviders.of(this).get(getViewModel())
        binding.setVariable(getBindingVariable(), viewModel)
        binding.executePendingBindings()
    }
}
override fun getLayoutId(): Int {
   return R.layout.login_activity
}

override fun getViewModel(): Class<LoginViewModel> {
    return LoginViewModel::class.java
}

override fun getBindingVariable(): Int {
    return BR.viewModel
}
  1. View 하나에 ViewModel 하나를 할당한다.
  2. 코드 수를 줄이기 위해 ViewModel을 제네릭으로 선언하고 BaseActivity에서 ViewModel 생성 이벤트를 처리한다
fun savePickData(data: Intent) {
    tempPictureUri.value = data.data
}

fun cropImage() {
    createFile()
    goToCrop.call()
}

private fun createFile() {
     val file = File(Environment.getExternalStorageDirectory().toString() + "/Weknot")
     if (!file.exists()) file.mkdirs()
     pictureFile.value = File(Environment.getExternalStorageDirectory().toString() + "/Weknot/" + WekonotRandom().random() + ".jpg")
     try {
        pictureFile.value!!.createNewFile()
      } catch (e: IOException) {
        e.printStackTrace()
      }
      pictureUri.value = Uri.fromFile(pictureFile.value)
}

private fun setRequest() {
     val requestFile: RequestBody = RequestBody.create("image/*".toMediaTypeOrNull(), pictureFile.value!!)
     picture.value = MultipartBody.Part.createFormData("picture", pictureFile.value!!.name, requestFile)
     comment.value = RequestBody.create("text/plain".toMediaTypeOrNull(),commentText.value!!)
}

fun deleteFile() {
    tempPictureUri.value = null
    pictureFile.value = null
    pictureUri.value = null
    backMessageToast.call()
}
  1. 데이터 처리 관련 이벤트를 모두 ViewModel에서 관리한다.
open class SingleLiveEvent<T> : MutableLiveData<T>() {

    private val mPending = AtomicBoolean(false)

    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        if (hasActiveObservers()) {
            Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
        }

        // Observe the internal MutableLiveData
        super.observe(owner, Observer { t ->
            if (mPending.compareAndSet(true, false)) {
                observer.onChanged(t)
            }
        })
    }

    @MainThread
    override fun setValue(t: T?) {
        mPending.set(true)
        super.setValue(t)
    }

    /**
     * Used for cases where T is Void, to make calls cleaner.
     */
    @MainThread
    fun call() {
        value = null
    }

    companion object {
        private val TAG = "SingleLiveEvent"
    }
}
val openFeedWrite: SingleLiveEvent<Any> = SingleLiveEvent()

fun onClickWrite() {
    openFeedWrite.call()
}



with(viewModel) {
    openFeedWrite.observe(this@FeedFragment, Observer {
        startActivityWithFinish(FeedWriteActivity::class.java)
    })
}
  1. Activity간의 이동이나 ToastMessage와 같은 Activity에서 처리해야 하는 코드는 SingleLiveEvent를 통해 ViewModel에서 call하고 Activity에서 observe해서 처리해주도록 한다.
  • data binding 제대로 사용하기
<data>
    <variable
        name="viewModel"
        type="com.example.weknot_android.viewmodel.LoginViewModel" />
</data>
android:text="@={viewModel.request.id}"
  1. xml과 ViewModel을 연결한다.
  2. binding을 통해 데이터가 바로 처리될 수 있도록 한다.
@BindingAdapter("adapter")
fun setAdapter(view: RecyclerView, adapter: RecyclerView.Adapter<*>) {
        view.adapter = adapter
}
app:adapter="@{viewModel.feedAdapter}"
  1. 필요에 따라 BindingAdapter를 사용한다.

👌 Before & After

  • 리팩토링 전 로그인 코드
  1. LoginActivity
class LoginActivity : BaseActivity<LoginActivityBinding>() {
    private lateinit var loginViewModel: LoginViewModel

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

        initViewModel()

        observeLoginViewModel()

        clickEvent()
    }

    private fun initViewModel() {
        loginViewModel = ViewModelProviders.of(this).get(LoginViewModel::class.java)!!
    }

    private fun observeLoginViewModel() {
        loginViewModel.getErrorMessage().observe(this, Observer { message: String -> simpleToast(message) })

        loginViewModel.getData().observe(this, Observer { data: UserData -> 
            insertUserData(data) 
            startActivityWithFinish(MainActivity::class.java)
        })
    }

    private fun clickEvent() {
        binding.loginBtn.setOnClickListener {
            viewModel.setRequest()
            if (isEmpty()) {
                simpleToast(R.string.empty_message)
                return
            }
            viewModel.login() 
        }

        binding.registerBtn.setOnClickListener {
            startActivityWithFinish(RegisterActivity::class.java)
        }
    }

    private fun isEmpty(): Boolean {
        return viewModel.request.id!!.isEmpty() || viewModel.request.password!!.isEmpty()
    }
}
  1. LoginViewModel
class LoginViewModel(application: Application) : BaseViewModel<LoginData>(application) {
    private val signComm = SignComm()

    var request = LoginRequest()

    fun login() {
        addDisposable(signComm.login(request), dataObserver)
    }

    private fun insertLoginData(loginData: LoginData) {
        insertToken(loginData.token)
        insertUser(loginData.user)
        insertId(loginData.user.id)
    }

    private fun insertToken(token: String) {
        this.token = token
    }

    private fun insertUser(user: User) {
        repository.insertUser(user)
    }

    private fun insertId(id: String) {
        userId = id
    }
}
  1. BaseViewModel
val data = MutableLiveData<D>()
val successMessage = MutableLiveData<String>()
val errorMessage = MutableLiveData<String>()

val baseObserver: DisposableSingleObserver<String>
    get() = object : DisposableSingleObserver<String>() {
        override fun onSuccess(s: String) {
            successMessage.value = s
            isLoading.value = false

        }

        override fun onError(e: Throwable) {
             errorMessage.value = e
             isLoading.value = false
        }
    }

val dataObserver: DisposableSingleObserver<D>
    get() = object : DisposableSingleObserver<D>() {
        override fun onSuccess(t: D) {
            data.value = t
            isLoading.value = false
        }

        override fun onError(e: Throwable) {
            errorMessage.value = e
            isLoading.value = false
        }
    }

  • 리팩토링 후 로그인 코드
  1. LoginActivity
class LoginActivity : BaseActivity<LoginActivityBinding, LoginViewModel>() {

    override fun getLayoutId(): Int {
        return R.layout.login_activity
    }

    override fun getViewModel(): Class<LoginViewModel> {
        return LoginViewModel::class.java
    }

    override fun getBindingVariable(): Int {
        return BR.viewModel
    }

    override fun initObserver() {
        with(viewModel) {
            openMain.observe(this@LoginActivity, Observer {
                startActivityWithFinish(MainActivity::class.java)
            })

            openSignUp.observe(this@LoginActivity, Observer {
                startActivityWithFinish(SignUpActivity::class.java)
            })

            loginEvent.observe(this@LoginActivity, Observer {
                if (isEmpty()) {
                    simpleToast(R.string.empty_message)
                    return@Observer
                }
                viewModel.login()
            })

            onErrorEvent.observe(this@LoginActivity, Observer {
                simpleToast(it.message)
            })
        }
    }

    private fun isEmpty(): Boolean {
        return viewModel.request.id!!.isEmpty() || viewModel.request.password!!.isEmpty()
    }
}
  1. LoginViewModel
class LoginViewModel(application: Application) : BaseViewModel<LoginData>(application) {
    private val signComm = SignComm()

    var request = LoginRequest()

    val loginEvent: SingleLiveEvent<Any> = SingleLiveEvent()
    val openSignUp: SingleLiveEvent<Any> = SingleLiveEvent()
    val openMain: SingleLiveEvent<Any> = SingleLiveEvent()

    fun login() {
        addDisposable(signComm.login(request), dataObserver)
    }

    private fun insertLoginData(loginData: LoginData) {
        insertToken(loginData.token)
        insertUser(loginData.user)
        insertId(loginData.user.id)
    }

    private fun insertToken(token: String) {
        this.token = token
    }

    private fun insertUser(user: User) {
        repository.insertUser(user)
    }

    private fun insertId(id: String) {
        userId = id
    }

    fun onClickLogin() {
        loginEvent.call()
    }

    fun onClickSignUp() {
        openSignUp.call()
    }

    override fun onRetrieveDataSuccess(data: LoginData) {
        insertLoginData(data)
        openMain.call()
    }

    override fun onRetrieveBaseSuccess(message: String) { }
}
  1. BaseViewModel
val baseObserver: DisposableSingleObserver<String>
    get() = object : DisposableSingleObserver<String>() {
        override fun onSuccess(s: String) {
            onRetrieveBaseSuccess(s)
            isLoading.value = false

        }

        override fun onError(e: Throwable) {
             onErrorEvent.value = e
             isLoading.value = false
        }
    }

val dataObserver: DisposableSingleObserver<D>
    get() = object : DisposableSingleObserver<D>() {
        override fun onSuccess(t: D) {
            onRetrieveDataSuccess(t)
            isLoading.value = false
        }

        override fun onError(e: Throwable) {
            onErrorEvent.value = e
            isLoading.value = false
        }
    }

protected abstract fun onRetrieveDataSuccess(data: D)
protected abstract fun onRetrieveBaseSuccess(message: String)
  1. login_activity.xml
<data>
    <variable
        name="viewModel"
        type="com.example.weknot_android.viewmodel.LoginViewModel" />
</data>

android:text="@={viewModel.request.id}"

android:text="@={viewModel.request.password}" 

android:onClick="@{() -> viewModel.onClickLogin()}"

🙌 마무리

이때까지 MVVM과 binding을 잘못 사용하고 있었다는 것에 배신감(?)을 느꼈다.
자주 구글링하고 더 좋은 코드를 만들도록 노력 해야겠다.