관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[안드로이드] 안드로이드 카카오톡 로그인 구현 및 MVVM 적용 (feat. SingleLiveEvent) 본문

안드로이드/코틀린 & 아키텍처 & Recent

[안드로이드] 안드로이드 카카오톡 로그인 구현 및 MVVM 적용 (feat. SingleLiveEvent)

막무가내막내 2020. 6. 22. 17:06
728x90

[2021-04-13 업데이트]

카카오 로그인 API가 업데이트되어 다른 내용이 있을 수 있습니다. 공식문서가 잘 되어 있으므로 공식문서 위주로 참고하시고 모르는게 있으면 이 포스팅에서 찾아보심이 좋을 것 같습니다. 감사합니다 :)

 

 

https://youngest-programming.tistory.com/93

 

[안드로이드] 카카오톡 로그인

구글로그인과 페이스북로그인에 이어서 카카오톡 로그인을 해본 걸 정리하는 포스팅을 갖도록 해보겠습니다. [2020-06-21 업데이트] 참고로 저는 구글 파이어베이스의 OAuth 토큰을 사용하기 위해

youngest-programming.tistory.com

이전에 위와 같이 카카오톡 로그인을 구현한적 있었는데 1년 사이에 v1 -> v2 로 바뀌면서 구현방식이 달라졌나봅니다.

 

 

 

그 새로운 버전에 대해서는 공식문서와

블로그로는 밑을 참고했습니다.

참고 : https://lakue.tistory.com/40

 

[Android/안드로이드] 카카오톡 로그인 (v2 ‘사용자 정보 요청’ API로 업데이트)

이전에 카카오톡으로 로그인을 할 경우 다음과 같은 메시지와 같이 사용자 정보 데이터를 가져올 수가 없었습니다. application(id=383706, name='Test2') using deprecated api(/v1/user/me) 이 에러는 카카..

lakue.tistory.com

 


처음에 MVC 방식으로 구현한 후

MVVM 으로 카카오톡 로그인을 로직을 변경해봤습니다.

 

다음과 같이 구현했습니다.

 

먼저 카카오톡 로그인 관련 디펜던시를 추가해줍니다.

이런 기본설정이나 카카오톡로그인시에 필요한 클래스들은 위 블로그 사이트들을 들어가거나 공식문서에 가면 자세하게 나옵니다. (그래서 몇개는 생략한게 있을 수 있습니다.)

 

[app gradle]

/*카카오로그인*/
    implementation group: project.KAKAO_SDK_GROUP, name: 'usermgmt', version: project.KAKAO_SDK_VERSION

 

[project gradle]

allprojects {
    repositories {
        google()
        jcenter()
        mavenCentral()
        maven{
            url "https://maven.google.com"
        }
        /*카카오*/
        maven { url 'http://devrepo.kakao.com:8088/nexus/content/groups/public/'}
    }
}

 

[gradle.properties]

#kakao login
KAKAO_SDK_GROUP=com.kakao.sdk
KAKAO_SDK_VERSION=1.27.0

 

카카오 API 키와 Hash Key를 각각 안드로이드와 카카오개발자관리(?) 사이트에 필요하고 등록해야한다. (이것은 위에 참조된 블로그들에 가면 자세히 나오니 참고 바랍니다.)

 


LoginActivity 라는 곳을 중심으로 구현했습니다.

[manifest]

Application(name) 을 참조하고 meta-data 넣었음을 참고!

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.mtjin.nomoneytrip">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".di.KoinApplication"
        android:allowBackup="true"
        android:icon="@drawable/logo"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <meta-data
            android:name="com.kakao.sdk.AppKey"
            android:value="@string/kakao_app_key" />
        <activity android:name=".views.login.LoginActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

 

[KoinApplication]

저는 Koin을 사용해서 카카오톡 관련 코드와 Koin 관련 코드가 섞여 있습니다.  startKoin{} 빼고 카카오톡 로그인 관련 코드입니다.

package com.mtjin.nomoneytrip.di

import android.app.Application
import com.kakao.auth.*
import com.mtjin.nomoneytrip.BuildConfig
import com.mtjin.nomoneytrip.module.*
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.core.logger.Level


class KoinApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        instance = this
        // Kakao Sdk 초기화
        KakaoSDK.init(KakaoSDKAdapter())

        startKoin {
            if (BuildConfig.DEBUG) {
                androidLogger()
            } else {
                androidLogger(Level.NONE)
            }
            androidContext(this@KoinApplication)
            modules(
                repositoryModule,
                localDataModule,
                remoteDataModule,
                viewModelModule,
                apiModule
            )
        }
    }

    override fun onTerminate() {
        super.onTerminate()
        instance = null
    }

    class KakaoSDKAdapter : KakaoAdapter() {
        override fun getSessionConfig(): ISessionConfig {
            return object : ISessionConfig {
                override fun getAuthTypes(): Array<AuthType> {
                    return arrayOf(AuthType.KAKAO_LOGIN_ALL)
                }

                override fun isUsingWebviewTimer(): Boolean {
                    return false
                }

                override fun isSecureMode(): Boolean {
                    return false
                }

                override fun getApprovalType(): ApprovalType? {
                    return ApprovalType.INDIVIDUAL
                }

                override fun isSaveFormData(): Boolean {
                    return true
                }
            }
        }

        // Application이 가지고 있는 정보를 얻기 위한 인터페이스
        override fun getApplicationConfig(): IApplicationConfig {
            return IApplicationConfig { getGlobalApplicationContext() }
        }
    }

    companion object {
        private var instance: KoinApplication? = null

        fun getGlobalApplicationContext(): KoinApplication? {
            checkNotNull(instance) { "This Application does not inherit com.kakao.GlobalApplication" }
            return instance
        }
    }

}

 


 

뺴먹었는데 프로젝트 구조는 다음과 같습니다.

 

base
model
view
DI & utils

 

 


카카오톡 로그인에 필요한 세션콜백(SessionCallback) 클래스입니다.  뷰에서 가져다가 사용할 것 입니다.

package com.mtjin.nomoneytrip.views.login

import android.util.Log
import com.kakao.auth.ISessionCallback
import com.kakao.network.ErrorResult
import com.kakao.usermgmt.UserManagement
import com.kakao.usermgmt.callback.MeV2ResponseCallback
import com.kakao.usermgmt.response.MeV2Response
import com.kakao.util.exception.KakaoException

class SessionCallback : ISessionCallback {
    // 로그인에 성공한 상태
    override fun onSessionOpened() {
        requestMe()
    }

    // 로그인에 실패한 상태
    override fun onSessionOpenFailed(exception: KakaoException) {
        Log.e(LoginActivity.TAG, "onSessionOpenFailed : " + exception.message)
    }

    // 사용자 정보 요청
    private fun requestMe() {
        UserManagement.getInstance()
            .me(object : MeV2ResponseCallback() {
                override fun onSessionClosed(errorResult: ErrorResult) {
                    Log.e(LoginActivity.TAG, "세션이 닫혀 있음: $errorResult")
                }

                override fun onFailure(errorResult: ErrorResult) {
                    Log.e(LoginActivity.TAG, "사용자 정보 요청 실패: $errorResult")
                }

                override fun onSuccess(result: MeV2Response) {
                    Log.i(LoginActivity.TAG, "사용자 아이디: " + result.id)
                }
            })
    }
}

다음은 액티비티 관련 클래스 입니다.

 

[BaseActivity]

package com.mtjin.nomoneytrip.base

import android.os.Bundle
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import io.reactivex.disposables.CompositeDisposable

abstract class BaseActivity<B : ViewDataBinding>(
    @LayoutRes val layoutId: Int
) : AppCompatActivity() {
    lateinit var binding: B
    private val compositeDisposable = CompositeDisposable()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, layoutId)
        binding.lifecycleOwner = this
    }

    protected fun showToast(msg: String) {
        Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
    }

    override fun onDestroy() {
        super.onDestroy()
        compositeDisposable.clear()
    }
}

 

[LoginActivity]

위 BaseActivity를 상속받습니다. 데이터바인딩 작업과 뷰모델의 카카오톡 로그인을 옵저빙하는 코드와 세션 초기화하는 코드가 있습니다.

package com.mtjin.nomoneytrip.views.login

import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.Observer
import com.kakao.auth.AuthType
import com.kakao.auth.ISessionCallback
import com.kakao.auth.Session
import com.kakao.util.exception.KakaoException
import com.mtjin.nomoneytrip.R
import com.mtjin.nomoneytrip.base.BaseActivity
import com.mtjin.nomoneytrip.databinding.ActivityLoginBinding
import com.mtjin.nomoneytrip.views.main.MainActivity
import org.koin.androidx.viewmodel.ext.android.viewModel


class LoginActivity : BaseActivity<ActivityLoginBinding>(R.layout.activity_login) {
    private val viewModel: LoginViewModel by viewModel()

    // 세션 콜백 구현
    private val sessionCallback: ISessionCallback = object : ISessionCallback {
        override fun onSessionOpened() {
            Log.i(TAG, "로그인 성공")
            val intent: Intent = Intent(this@LoginActivity, MainActivity::class.java)
            startActivity(intent)
            finish()
        }

        override fun onSessionOpenFailed(exception: KakaoException) {
            Log.e(TAG, "로그인 실패", exception)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.vm = viewModel
        Session.getCurrentSession().addCallback(sessionCallback)
        initViewModelCallback()
    }

    private fun initViewModelCallback() {
        with(viewModel) {
            kakaoLogin.observe(this@LoginActivity, Observer {
                kakaoLogin.value?.addCallback(SessionCallback())
                kakaoLogin.value?.open(AuthType.KAKAO_LOGIN_ALL, this@LoginActivity)
            })
        }
    }

    override fun onActivityResult(
        requestCode: Int,
        resultCode: Int,
        data: Intent?
    ) {
        // 카카오톡|스토리 간편로그인 실행 결과를 받아서 SDK로 전달
        if (Session.getCurrentSession().handleActivityResult(requestCode, resultCode, data)) {
            return
        }
        super.onActivityResult(requestCode, resultCode, data)
    }

    override fun onDestroy() {
        super.onDestroy()
        // 세션 콜백 삭제
        Session.getCurrentSession().removeCallback(sessionCallback)
    }

    companion object {
        const val TAG: String = "LoginActivityTAG"
    }
}

 

 


utils에 선언해 놓은 클래스인데 MutableLiveData 를 상속받아 구현된 클래스 입니다. LiveData 가 불필요하게 옵저빙 되는 것을 방지해주는데 

자세한 내용은 다음을 참고합니다.

https://www.charlezz.com/?p=1152

 

SingleLiveEvent로 이벤트 처리하기 | 찰스의 안드로이드

AAC lifecycle컴포넌트의 등장과 함께 MVVM패턴이 다소 변경되었습니다. 기존의 방식은 다음 그림과 같습니다. Activity와 ViewModel의 의존성을 분리하고 ViewModel은 Navigator를 이용하여 Activity에게 이벤트�

www.charlezz.com

https://medium.com/@abhishektiwari_51145/how-to-use-singleliveevent-in-mvvm-architecture-component-b7c04ed8705

 

How to use SingleLiveEvent in MVVM + Architecture Component.

But why should i use it? Hell, What is SingleLiveEvent anyway?

medium.com

[SingleLiveEvent] 이것을 ViewModel에서 MutableLiveData 대신 사용할 것입니다

package com.mtjin.nomoneytrip.utils

import android.util.Log
import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import java.util.concurrent.atomic.AtomicBoolean

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"
    }
}

 

 

다음은 뷰모델 클래스입니다.

[BaseViewModel]

package com.mtjin.nomoneytrip.base

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import io.reactivex.disposables.CompositeDisposable

abstract class BaseViewModel : ViewModel() {
    protected val compositeDisposable = CompositeDisposable()

    private val _isLoading = MutableLiveData<Boolean>(false)
    val isLoading: LiveData<Boolean> get() = _isLoading

    override fun onCleared() {
        compositeDisposable.dispose()
        super.onCleared()
    }

    protected fun showProgress() {
        _isLoading.value = true
    }

    protected fun hideProgress() {
        _isLoading.value = false
    }
}

 

 

[ LoginViewModel ]

앞서 정의한 SingleLiveEvent 를 사용하여 로그인시마다 액티비티에 콜백을 보내주도록 했습니다. 그리고 카카오톡 로그인 버튼 클릭시 kakaoLogin() 이 호출됩니다. (xml에서 onClick 정의해놨습니다.)

package com.mtjin.nomoneytrip.views.login

import androidx.lifecycle.LiveData
import com.kakao.auth.Session
import com.mtjin.nomoneytrip.base.BaseViewModel
import com.mtjin.nomoneytrip.data.login.source.LoginRepository
import com.mtjin.nomoneytrip.utils.SingleLiveEvent

class LoginViewModel(private val loginRepository: LoginRepository) : BaseViewModel() {
    private val _kakaoLogin = SingleLiveEvent<Session>()

    val kakaoLogin: LiveData<Session> get() = _kakaoLogin

    fun kakaoLogin() {
        _kakaoLogin.value = loginRepository.kakaoLogin()
    }

}

 

 

 


Model 에 들어가기전 XML 코드 입니다. 

뷰모델을 가져다 쓰고 카카오톡 로그인시 뷰모델 함수가 호출됩니다. (참고로 카카오톡 로그인 버튼은 기존 카카오톡에서 제공하는것이아닌 커스텀으로 구현했습니다.)

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>

        <variable
            name="vm"
            type="com.mtjin.nomoneytrip.views.login.LoginViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".views.login.LoginActivity">

        <androidx.appcompat.widget.AppCompatImageView
            android:id="@+id/iv_app_logo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="160dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/ic_app_main_logo" />

        <com.skydoves.elasticviews.ElasticImageView
            android:id="@+id/iv_kakao_login"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="131dp"
            android:onClick="@{()->vm.kakaoLogin()}"
            app:imageView_duration="300"
            app:imageView_scale="0.7"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/iv_app_logo"
            app:srcCompat="@drawable/button_kakao" />

        <com.skydoves.elasticviews.ElasticImageView
            android:id="@+id/iv_email_login"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            app:imageView_duration="300"
            app:imageView_scale="0.7"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/iv_kakao_login"
            app:srcCompat="@drawable/button_email" />

        <com.skydoves.elasticviews.ElasticImageView
            android:id="@+id/iv_signup"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="21dp"
            app:imageView_duration="300"
            app:imageView_scale="0.7"
            app:layout_constraintEnd_toStartOf="@id/view"
            app:layout_constraintHorizontal_chainStyle="packed"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/iv_email_login"
            app:srcCompat="@drawable/button_signup" />

        <View
            android:id="@+id/view"
            android:layout_width="1dp"
            android:layout_height="12dp"
            android:background="@color/colorWhiteGray"
            app:imageView_duration="300"
            app:imageView_scale="0.7"
            app:layout_constraintBottom_toBottomOf="@id/iv_signup"
            app:layout_constraintEnd_toStartOf="@id/iv_question"
            app:layout_constraintStart_toEndOf="@id/iv_signup"
            app:layout_constraintTop_toTopOf="@id/iv_signup" />

        <com.skydoves.elasticviews.ElasticImageView
            android:id="@+id/iv_question"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="21dp"
            app:imageView_duration="300"
            app:imageView_scale="0.7"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@id/view"
            app:layout_constraintTop_toBottomOf="@id/iv_email_login"
            app:srcCompat="@drawable/button_question" />


    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

 

 

 

 

 


참고 : model 쪽은 Local은 아직 구현안했고 (자동로그인 구현예정) 로그인 기본적인 기능만 구현한 것이기 때문에 양이 적습니다. 

 

레포지토리입니다.

[LoginRepositoryImpl]

package com.mtjin.nomoneytrip.data.login.source

import com.kakao.auth.Session
import com.mtjin.nomoneytrip.data.login.source.remote.LoginRemoteDataSource

class LoginRepositoryImpl(private val loginRemoteDataSource: LoginRemoteDataSource) :
    LoginRepository {
    override fun kakaoLogin(): Session = loginRemoteDataSource.kakaoLogin()
}

 


 

RemoteDataSource 입니다.

[LoginRemoteDataSourceImpl]

package com.mtjin.nomoneytrip.data.login.source.remote

import com.kakao.auth.Session

class LoginRemoteDataSourceImpl : LoginRemoteDataSource {
    override fun kakaoLogin(): Session = Session.getCurrentSession()
}

 

 


LocalDataSource 는 만들어만 놨고 구현된건 아직 없습니다.

 


 

 

최종 화면 입니다.

 

 

 

아직 미완성이고 부족하지만 카카오톡 로그인 관련을 기존 MVC 에서 MVVM으로 변경해보았습니다. 

 

 

 

댓글과 공감은 큰 힘이 됩니다. 감사합니다!!

 

 

728x90
Comments