Android Studio登录模板解析

前言

今天刚升级新版Android Studio Jellyfish的时候偶然发现Login Views Activity 这样一个示例模板,回想起刚出'AAC'的时候就是基于这个Login模板进行学习的,最快的学习途径就是站在巨人的肩膀上进行学习,所以借此示例对比新版的应用架构指南进行一个对比和学习。

创建入口

创建一个Login Views Activity

一. 整体结构

该示例在Data Layer的实现代码比较简单,更多的是聚焦在UI和ViewModel的交互上面,所以我们的关注点重点在界面层的实现。

二. data

Model
kotlin 复制代码
/**
 * Data class that captures user information for logged in users retrieved from LoginRepository
 */
data class LoggedInUser(
    val userId: String,
    val displayName: String
)
Result
kotlin 复制代码
/**
 * A generic class that holds a value with its loading status.
 * @param <T>
 */
sealed class Result<out T : Any> {

    data class Success<out T : Any>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()

    override fun toString(): String {
        return when (this) {
            is Success<*> -> "Success[data=$data]"
            is Error -> "Error[exception=$exception]"
        }
    }
}
DataSource
kotlin 复制代码
/**
 * Class that handles authentication w/ login credentials and retrieves user information.
 */
class LoginDataSource {

    fun login(username: String, password: String): Result<LoggedInUser> {
        try {
            // TODO: handle loggedInUser authentication
            val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
            return Result.Success(fakeUser)
        } catch (e: Throwable) {
            return Result.Error(IOException("Error logging in", e))
        }
    }

    fun logout() {
        // TODO: revoke authentication
    }
}
Repository
kotlin 复制代码
/**
 * Class that requests authentication and user information from the remote data source and
 * maintains an in-memory cache of login status and user credentials information.
 */

class LoginRepository(val dataSource: LoginDataSource) {

    ''''''

    fun login(username: String, password: String): Result<LoggedInUser> {
        // handle login
        val result = dataSource.login(username, password)

        if (result is Result.Success) {
            setLoggedInUser(result.data)
        }

        return result
    }

    private fun setLoggedInUser(loggedInUser: LoggedInUser) {
        this.user = loggedInUser
        // If user credentials will be cached in local storage, it is recommended it be encrypted
        // @see https://developer.android.com/training/articles/keystore
    }
}

三、ViewModelFactory

kotlin 复制代码
/**
 * ViewModel provider factory to instantiate LoginViewModel.
 * Required given LoginViewModel has a non-empty constructor
 */
class LoginViewModelFactory : ViewModelProvider.Factory {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(LoginViewModel::class.java)) {
            return LoginViewModel(
                loginRepository = LoginRepository(
                    dataSource = LoginDataSource()
                )
            ) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

四、State

kotlin 复制代码
/**
 * Data validation state of the login form.
 */
data class LoginFormState(
    val usernameError: Int? = null,
    val passwordError: Int? = null,
    val isDataValid: Boolean = false
)
kotlin 复制代码
/**
 * Authentication result : success (user details) or error message.
 */
data class LoginResult(
    val success: LoggedInUserView? = null,
    val error: Int? = null
)

/**
 * User details post authentication that is exposed to the UI
 */
data class LoggedInUserView(
    val displayName: String
    //... other data fields that may be accessible to the UI
)

五、界面层

LoginViewModel
kotlin 复制代码
class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {

    private val _loginForm = MutableLiveData<LoginFormState>()
    val loginFormState: LiveData<LoginFormState> = _loginForm

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

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

        if (result is Result.Success) {
            _loginResult.value =
                LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
        } else {
            _loginResult.value = LoginResult(error = R.string.login_failed)
        }
    }

    fun loginDataChanged(username: String, password: String) {
        if (!isUserNameValid(username)) {
            _loginForm.value = LoginFormState(usernameError = R.string.invalid_username)
        } else if (!isPasswordValid(password)) {
            _loginForm.value = LoginFormState(passwordError = R.string.invalid_password)
        } else {
            _loginForm.value = 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
    }
}
LoginActivity
kotlin 复制代码
class LoginActivity : AppCompatActivity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var binding: ActivityLoginBinding

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

        binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val username = binding.username
        val password = binding.password
        val login = binding.login
        val loading = binding.loading

        loginViewModel = ViewModelProvider(this, LoginViewModelFactory())
            .get(LoginViewModel::class.java)

        loginViewModel.loginFormState.observe(this@LoginActivity, Observer {
            val loginState = it ?: return@Observer

            // disable login button unless both username / password is valid
            login.isEnabled = loginState.isDataValid

            if (loginState.usernameError != null) {
                username.error = getString(loginState.usernameError)
            }
            if (loginState.passwordError != null) {
                password.error = getString(loginState.passwordError)
            }
        })

        loginViewModel.loginResult.observe(this@LoginActivity, Observer {
            val loginResult = it ?: return@Observer

            loading.visibility = View.GONE
            if (loginResult.error != null) {
                showLoginFailed(loginResult.error)
            }
            if (loginResult.success != null) {
                updateUiWithUser(loginResult.success)
            }
            setResult(Activity.RESULT_OK)

            //Complete and destroy login activity once successful
            finish()
        })

        username.afterTextChanged {
            loginViewModel.loginDataChanged(
                username.text.toString(),
                password.text.toString()
            )
        }

        password.apply {
            afterTextChanged {
                loginViewModel.loginDataChanged(
                    username.text.toString(),
                    password.text.toString()
                )
            }

            setOnEditorActionListener { _, actionId, _ ->
                when (actionId) {
                    EditorInfo.IME_ACTION_DONE ->
                        loginViewModel.login(
                            username.text.toString(),
                            password.text.toString()
                        )
                }
                false
            }

            login.setOnClickListener {
                loading.visibility = View.VISIBLE
                loginViewModel.login(username.text.toString(), password.text.toString())
            }
        }
    }

    private fun updateUiWithUser(model: LoggedInUserView) {
        val welcome = getString(R.string.welcome)
        val displayName = model.displayName
        // TODO : initiate successful logged in experience
        Toast.makeText(
            applicationContext,
            "$welcome $displayName",
            Toast.LENGTH_LONG
        ).show()
    }

    private fun showLoginFailed(@StringRes errorString: Int) {
        Toast.makeText(applicationContext, errorString, Toast.LENGTH_SHORT).show()
    }
}

六、对比应用架构指南

数据源提供数据
kotlin 复制代码
class LoginDataSource {

    fun login(username: String, password: String): Result<LoggedInUser> {
        try {
            // TODO: handle loggedInUser authentication
            val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
            return Result.Success(fakeUser)
        } catch (e: Throwable) {
            return Result.Error(IOException("Error logging in", e))
        }
    }
    ......
}

这里实现了一个模拟登录接口,它返回了Result,这种方法是使用 Result 类,适用于没有异常处理机制的反应式编程 API比如 LiveData

如果使用Kotlin

  • 一次性操作:建议返回挂起函数suspend fun
  • 数据流 : 建议返回flow

错误抛出: 对于一些数据层可以理解和处理不同类型的错误,并可以使用自定义异常(例如 UserNotAuthenticatedException)公开这些错误。

错误处理:建议使用 Kotlin 的内置处理机制

  • 挂起函数使用 try/catch

  • flow使用catch 运算符

以上都是官方文档的推荐方式,可能有点不太能理解 我们通过一个简单的网络请求示例理解一下。

网络请求示例

首先HTTP 请求返回单个响应,而不是响应流,所以属于一次性操作返回suspend fun # Support adapter for Kotlin Coroutine Flow

一个常见的JSON返回数据结构定义:

json 复制代码
{ "data": ..., "errorCode": 0, "errorMsg": "" }

我们根据 errorCode返回0 认为是成功 403 认为登录失效 其他code 提示错误消息

kotlin 复制代码
class RemoteDataSource @Inject constructor(private val apiService: ApiService) {

    suspend fun login(username: String, password: String): LoggedInUser? {
        val result = apiService.getUserInfo(username, password)
        if (result.errorCode == 0 ){
              return result.data
        } else if (result.errorCode == 403){
            throw UserNotAuthenticatedException()
        }else{
            throw ErrorMessagesException(result.errorCode,result.errorMsg)
        }
    }
}

这个示例与实际使用可能有较大偏差,但是可以根据这个思路进行后续的封装

提供多个State
kotlin 复制代码
class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {

    private val _loginForm = MutableLiveData<LoginFormState>()
    val loginFormState: LiveData<LoginFormState> = _loginForm

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

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

        if (result is Result.Success) {
            _loginResult.value =
                LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
        } else {
            _loginResult.value = LoginResult(error = R.string.login_failed)
        }
    }
    ......

这里可以看到ViewModel中根据业务相关数据提供两个State,分别提供LoginFormState登录表单State和LoginResult登录结果的State

一般情况下提供单个uiState如果界面显示多块不相关的数据 ,可以提供多个uiState

数据模型和业务模型分离
ini 复制代码
_loginResult.value = LoginResult(success = LoggedInUserView(displayName = result.data.displayName))    

登录成功的result转换成一个LoggedInUserView,这里符合官方文档中的业务模型和数据模型分离 参考:业务模型

分离模型类可以带来以下好处:

  • 将数据减少到只包含需要的内容,从而节省应用内存。
  • 根据应用所使用的数据类型来调整外部数据类型 - 例如,应用可以使用不同的数据类型来表示日期。
  • 更好地分离关注点 - 例如,如果预先定义了模型类,大型团队的成员便可以在功能的网络层和界面层单独开展工作。
区分事件和状态
kotlin 复制代码
class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {

   private val _loginForm = MutableLiveData<LoginFormState>()
   val loginFormState: LiveData<LoginFormState> = _loginForm

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

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

       if (result is Result.Success) {
           _loginResult.value =
               LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
       } else {
           _loginResult.value = LoginResult(error = R.string.login_failed)
       }
   }
   ......

在ViewModel未区分事件和状态,首先来看看事件和状态的区别

事件 状态
暂时性、不可预测,且存在时间有限。 始终存在。
状态生成的输入。 状态生成的输出。
界面或其他来源的生成对象。 供界面使用。

其实最大的区别就是状态始终存在,但是事件是一次性的、暂时性、不可预测的。

  • 通过一个简单的场景就能区分状态和事件,例如当屏幕旋转会导致Activity进行重建,这时我们需要恢复的是状态,而事件属于不需要恢复的一次性的。

根据上面的代码通过Toast进行提示错误,当屏幕旋转会导致Activity进行重建,会重新订阅LoginResult状态,LiveData是粘性事件设计会取到旧的数据,导致Toast再次弹出。反复切换横竖屏,Toast会反复的弹出,这明显是一个Bug。

状态和界面一致性
kotlin 复制代码
data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

class NewsViewModel(...) : ViewModel() {  
    ...
    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
     
   fun updateState(){
      _uiState.update {
        it.copy(newsItems = newsItems)
     }
   }
}

官方文档中MutableStateFlow#update{}进行更新状态,然后通过it.copy复制一个旧状态更新其中的某一个值再发射出去。

首先MutableStateFlow.update{}是一个扩展函数,它可以对MutableStateFlow进行原子性更新,即在同一时刻只有一个线程可以修改 MutableStateFlow 的值,从而确保线程安全。

arduino 复制代码
mutable.value = 1
mutable.emit(2)
mutable.update {3}

既然都保持线程安全为啥还要使用 it.copy() ,可以使用如下方式更新状态

scss 复制代码
    val stateFlow = MutableStateFlow(0) 
    // 更新 MutableStateFlow 的值 
    stateFlow.update { currentValue ->
        currentValue + 1 
    }

其实这样做有两个原因

  • 保持状态不可变性: 由于 _uiState 是一个可变的状态流,我们希望在更新状态时保持不可变性。因此,我们使用 copy 函数来创建一个新的状态对象,而不是直接修改原始对象。
  • 保持状态的一致性:当屏幕旋转会导致Activity进行重建,会重新订阅状态恢复上一次状态,这时我们需要保证界面和状态一致性,使用这种做法更像UI=f(state) 状态即是当前构建的UI页面。

官方文档是这样描述的:

这就像同一枚硬币的两面,界面是界面状态的直观呈现。对界面状态所做的任何更改都会立即反映在界面中。

总结

这个示例作为初学者学习LiveData、ViewModel等组件的使用方法和衔接方式是一个不错的选择,也能根据示例中存在的问题,去思考理解官方架构指南最佳实践的初衷。

相关推荐
阿巴斯甜16 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker17 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952718 小时前
Andorid Google 登录接入文档
android
黄林晴19 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android