🔥Android 推荐架构的登录示例

309 阅读3分钟

前言

最近在学习Android推荐应用架构,抽空写了一个登录例子。

首先,请查看下图,该图显示了所有模块应如何彼此交互 final-architecture

  • View:Activity/Fragment
  • ViewModel: ViewModel + LivaData
  • Repository:Repository一般用来进行复杂的数据转换和处理,依赖于DataSource提供 本地持久性数据 和 服务端数据

注意点:

  1. 分离关注点、在应用的各个模块之间设定明确定义的职责界限。
  2. ViewModel用于持有和管理界面相关的数据,让数据可在发生屏幕旋转等配置更改后继续
  3. ViewModel 不能持有 View层引用,包括Context也不能持有,如果需要建议从构造注入,或者使用AndroidViewModel。
  4. 通过模型驱动界面(最好是持久性模型),应用所基于的模型类应明确定义数据管理职责

一、 Repository实现

1.1 User

定义User的data class

data class LoggedInUser(
    @SerializedName("id")
    var id: Int = 0,
    @SerializedName("email")
    var email: String = "",
    @SerializedName("nickname")
    var nickname: String = "",
    @SerializedName("username")
    var username: String = ""
)
1.2 ApiService
interface ApiService {
    @POST("user/login")
    @FormUrlEncoded
    suspend fun login(
        @Field("username") username: String,
        @Field("password") password: String
    ): ApiResponse<LoggedInUser>

    @GET("user/logout/json")
    suspend fun logout(): ApiResponse<Any>

    companion object {
        private const val BASE_URL = "https://www.wanandroid.com"

        fun create(): ApiService {
            val logger = HttpLoggingInterceptor().also {
                it.level =
                    if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
            }
            val context = App.instance.applicationContext
            val cookieJar =
                PersistentCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(context))

            val client = OkHttpClient.Builder()
                .addInterceptor(logger)
                .cookieJar(cookieJar)
                .build()

            return Retrofit.Builder()
                .baseUrl(BASE_URL)
                .client(client)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(ApiService::class.java)
        }
    }
}
1.3 ApiResult

网络返回进行了包装和处理,网络异常转换成业务提示


sealed class ApiResult<T> {

    data class SuccessResult<T>(
        val data: T?,
        val errorCode: Int = -1,
        val errorMsg: String = ""
    ) : ApiResult<T>()

    data class ErrorResult<T>(val errorCode: Int, val errorMsg: String) : ApiResult<T>()


    inline fun <reified T> ApiResult<T>.onSuccess(action: (T?) -> Unit): ApiResult<T> {
        if (this is SuccessResult) action(data)
        return this
    }

    inline fun <reified T> ApiResult<T>.onError(action: (code: Int, message: String) -> Unit): ApiResult<T> {
        if (this is ErrorResult) action(errorCode, errorMsg)
        return this
    }

    companion object {
        const val SUCCESS_CODE = 0

        fun <T> create(throwable: Throwable): ApiResult<T> {
            // 网络异常转换成业务提示
            val errorMsg = ErrorMsgFactory.create(throwable = throwable)
            return ErrorResult(errorMsg.code, errorMsg.message)
        }

        fun <T> create(response: ApiResponse<T>): ApiResult<T> {
            return if (response.succeeded) {
                // Tips 这里可以根据Code发送EventBus
                SuccessResult(response.data, response.errorCode, response.errorMsg)
            } else {
                ErrorResult(response.errorCode, response.errorMsg)
            }
        }
    }
}
1.4 DataSource

提供DataSource提供数据

class LoginDataSource(
    private val service: ApiService,
    private val preferences: SharedPreferences
) {

    suspend fun login(username: String, password: String): ApiResult<LoggedInUser> {
        return try {
            val response = service.login(username = username, password = password)
            ApiResult.create(response)
        } catch (e: Throwable) {
            ApiResult.create(e)
        }
    }
}
1.5 Repository

Repository 一般用来进行复杂的数据转换和处理,如果数据复杂推荐使用Flow

class LoginRepository(val dataSource: LoginDataSource) {

    suspend fun login(username: String, password: String): Result<LoggedInUser> {
        // handle login
        val result = dataSource.login(username, password)
        return when (result) {
            is ApiResult.SuccessResult -> {
                setLoggedInUser(result.data)
                Result.Success(result.data)
            }
            is ApiResult.ErrorResult -> {
                Result.Error(result.errorMsg)
            }
        }
    }
}

二、 ViewModel实现

2.1 ViewModel

以前在 ViewModel 里使用 LiveData。我们需要以下步骤

class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {

    private val _loginResult = MutableLiveData<LoginResult>()
    val loginResult: LiveData<LoginResult> = _loginResult

    fun login(username: String, password: String) {
        viewModelScope.launch {
            // can be launched in a separate asynchronous job
            val result = loginRepository.login(username, password)

            result.run {
                onSuccess {
                    _loginResult.value =
                        LoginResult(success = LoggedInUserView(displayName = it?.username.toString()))
                }
                onError {
                    _loginResult.value = LoginResult(error = it)
                }
            }
        }
    }
  1. 准备一个 ViewModel 私有的 MutableLiveData (MLD)
  2. 暴露一个不可变的 LiveData
  3. 启动协程,然后将其操作结果赋给 MLD

在 LifeCycle 2.2.0 之后,提供了LiveData 协程构造方法 (coroutine builder),以更加精简的方式实现

class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {

    fun login(username: String, password: String) = liveData {
         // can be launched in a separate asynchronous job
        val result = loginRepository.login(username, password)
        Log.e("LoginViewModel", Thread.currentThread().name)
        result.run {
            onSuccess {
                emit(LoginResult(success = LoggedInUserView(displayName = it?.username.toString())))
            }
            onError {
                emit(LoginResult(error = it))
            }
        }
    }

    fun loginDataChanged(username: String, password: String) = liveData {
        if (!isUserNameValid(username)) {
            emit(LoginFormState(usernameError = R.string.invalid_username))
        } else if (!isPasswordValid(password)) {
            emit(LoginFormState(passwordError = R.string.invalid_password))
        } else {
            emit(LoginFormState(isDataValid = true))
        }
    }

    // A placeholder username validation check
    private fun isUserNameValid(username: String): Boolean {
        return if (username.contains("@")) {
            Patterns.EMAIL_ADDRESS.matcher(username).matches()
        } else {
            username.isNotBlank()
        }
    }

    // A placeholder password validation check
    private fun isPasswordValid(password: String): Boolean {
        return password.length > 5
    }
}
2.1 ViewModelFactory

构造ViewModel需要Repository,所以通过ViewModelFactory注入必要的依赖

class ViewModelFactory : ViewModelProvider.Factory {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(LoginViewModel::class.java)) {

            val service = ApiService.create()
            return LoginViewModel(
                loginRepository = Injection.provideDataRepository()
            ) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

UI实现

表单验证数据状态数据类

data class LoginFormState(
    val usernameError: Int? = null,
    val passwordError: Int? = null,
    val isDataValid: Boolean = false
)

登录返回数据类

data class LoginResult(
    val success: LoggedInUserView? = null,
    val error: String? = null
)
class LoginFragment : Fragment() {
    private var _binding: FragmentLoginBinding? = null
    private val binding get() = _binding!!

    private val loginViewModel: LoginViewModel by viewModels {
        ViewModelFactory()
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentLoginBinding.inflate(inflater, container, false)
        return binding.root
    }

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

    private fun setEvent() {
        registerEditTextChanged(binding.username, binding.password)
        binding.password.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_DONE) {
                login()
            }
            false
        }

        binding.login.setOnClickListener {
            binding.loading.visibility = View.VISIBLE
            login()
        }
    }

    private fun registerEditTextChanged(vararg edits: EditText) {
        for (e in edits) {
            e.doAfterTextChanged {
                validator()
            }
        }
    }

    private fun login() {
        loginViewModel.login(
            binding.username.text.toString(),
            binding.password.text.toString()
        ).observe(viewLifecycleOwner) { loginResult ->
            binding.loading.visibility = View.GONE
            loginResult.error?.let {
                showLoginFailed(it)
            }
            loginResult.success?.let {
                updateUiWithUser(it)
            }
        }
    }

    private fun validator() {
        loginViewModel.loginDataChanged(
            binding.username.text.toString(),
            binding.password.text.toString()
        ).observe(viewLifecycleOwner) { loginFormState ->
            binding.login.isEnabled = loginFormState.isDataValid
            loginFormState.usernameError?.let {
                binding.username.error = getString(it)
            }
            loginFormState.passwordError?.let {
                binding.password.error = getString(it)
            }
        }
    }

    private fun updateUiWithUser(model: LoggedInUserView) {
        val welcome = getString(R.string.welcome) + model.displayName
        Toast.makeText(requireContext(), welcome, Toast.LENGTH_LONG).show()
    }

    private fun showLoginFailed(errorString: String) {
        Toast.makeText(requireContext(), errorString, Toast.LENGTH_LONG).show()
    }
}

总结

使用了WanAndroid的open Api,在此先感谢大佬无私贡献!

示例代码地址github.com/Li-Lee-Roc/…

记得点赞哦!