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