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等组件的使用方法和衔接方式是一个不错的选择,也能根据示例中存在的问题,去思考理解官方架构指南最佳实践的初衷。

相关推荐
GEEKVIP2 小时前
手机使用技巧:8 个 Android 锁屏移除工具 [解锁 Android]
android·macos·ios·智能手机·电脑·手机·iphone
model20054 小时前
android + tflite 分类APP开发-2
android·分类·tflite
彭于晏6894 小时前
Android广播
android·java·开发语言
与衫5 小时前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql
500了11 小时前
Kotlin基本知识
android·开发语言·kotlin
人工智能的苟富贵12 小时前
Android Debug Bridge(ADB)完全指南
android·adb
小雨cc5566ru17 小时前
uniapp+Android面向网络学习的时间管理工具软件 微信小程序
android·微信小程序·uni-app
bianshaopeng18 小时前
android 原生加载pdf
android·pdf
hhzz18 小时前
Linux Shell编程快速入门以及案例(Linux一键批量启动、停止、重启Jar包Shell脚本)
android·linux·jar
火红的小辣椒19 小时前
XSS基础
android·web安全