https://androidweekly.net/issues/issue-416

Designing and Working with Single View States on Android

https://zsmb.co/designing-and-working-with-single-view-states-on-android/

ViewState是用于标识View状态的对象,通过ViewState,可以将View的显示逻辑与控制逻辑抽离。在MVI、MVVM等设计模式中都可以看到它的身影。

用例:加载联系人信息

这个页面有三个状态:Loading、Loaded、Error。

loading_loaded_error.png
loading_loaded_error.png

共用data类

  • 优点:类数目最少
  • 缺点:会出现诸如errored=true, loading=true的异常状态
1
2
3
4
5
6
data class ProfileViewState(
val errored: Boolean = false,
val loading: Boolean = false,
val name: String? = null,
val email: String? = null
)

Sealed classes(密封类)

  • 优点:防止进入错误状态
  • 缺点:无法共用数据
1
2
3
4
5
6
7
8
9
10
sealed class ProfileViewState

object Loading : ProfileViewState()

object Error : ProfileViewState()

data class ProfileLoaded(
val name: String,
val email: String
) : ProfileViewState()

View类里面的绘制方法如下,注意exhaustive扩展函数能够在我们的when语句漏写某个分支时,在编译过程就抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private fun render(viewState: ProfileViewState) {
when (viewState) {
Loading -> {
viewFlipper.displayedChild = Flipper.LOADING
}
Error -> {
viewFlipper.displayedChild = Flipper.ERROR
}
is ProfileLoaded -> {
viewFlipper.displayedChild = Flipper.CONTENT
profileNameText.text = viewState.name
profileEmailText.text = viewState.email
}
}.exhaustive
}

ViewFlipper可以用来展示多个Children中的一个:

1
2
3
4
5
6
7
8
9
class ProfileFragment : Fragment() {
private object Flipper {
const val LOADING = 0
const val CONTENT = 1
const val ERROR = 2
}

// ...
}

布局文件:

1
2
3
4
5
6
7
8
9
10
<ViewFlipper
android:id="@+id/viewFlipper"
android:layout_width="match_parent"
android:layout_height="match_parent">

<include layout="@layout/profile_loading" />
<include layout="@layout/profile_content" />
<include layout="@layout/profile_error" />

</ViewFlipper>

共享数据,独立状态

如列表下滑加载更多,已经获取到的item仍然显示在界面上。

load_more.png
load_more.png

使用data class来做ViewState。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// data class
data class ListViewState(
val isLoading: Boolean,
val items: List<String>
)

// View.java
private fun render(viewState: ListViewState) {
progressBar.isVisible = viewState.isLoading
wordAdapter.submitList(viewState.items)
}

// 增加Error状态
sealed class ListViewState

object Error: ListViewState()

data class ListReady(
val isLoading: Boolean,
val items: List<String>
): ListViewState()

更加复杂的UI

对于包含更多元素的UI,在when语句之前要将共用逻辑抽出,以简化when代码块。

upload_sample.png
upload_sample.png

复杂写法,每个分支里处理所有UI元素,容易遗漏,维护困难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private fun render(viewState: UploadViewState) {
when (viewState) {
Initial -> {
uploadProgressText.isVisible = false
progressBar.isVisible = false
uploadDoneIcon.isVisible = false
uploadStatusText.isVisible = false
retryUploadButton.isVisible = false
}
is UploadInProgress -> {
uploadProgressText.isVisible = true
progressBar.isVisible = true
uploadDoneIcon.isVisible = false
uploadStatusText.isVisible = false
retryUploadButton.isVisible = false
progressBar.setProgressWithAnimation(viewState.percentage.toFloat())
uploadProgressText.text = "${viewState.percentage}%"
}
UploadFailed -> {
uploadProgressText.isVisible = false
progressBar.isVisible = false
uploadDoneIcon.isVisible = false
uploadStatusText.isVisible = true
uploadStatusText.text = "Sorry, something went wrong."
retryUploadButton.isVisible = true
}
UploadSuccess -> {
uploadProgressText.isVisible = false
progressBar.isVisible = false
uploadDoneIcon.isVisible = true
uploadStatusText.isVisible = true
uploadStatusText.text = "Upload complete!"
retryUploadButton.isVisible = false
}
}.exhaustive
}

简化写法,提取各个状态专有UI元素,优先处理;when语句只处理共享UI元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private fun render(viewState: UploadViewState) {
uploadProgressText.isVisible = viewState is UploadInProgress
progressBar.isVisible = viewState is UploadInProgress
retryUploadButton.isVisible = viewState is UploadFailed
uploadDoneIcon.isVisible = viewState is UploadSuccess
uploadStatusText.isVisible =
viewState is UploadFailed || viewState is UploadSuccess

when (viewState) {
Initial -> {
// Empty
}
is UploadInProgress -> {
progressBar.setProgressWithAnimation(viewState.percentage.toFloat())
uploadProgressText.text = "${viewState.percentage.toInt()}%"
}
UploadFailed -> {
uploadStatusText.text = "Sorry, something went wrong."
}
UploadSuccess -> {
uploadStatusText.text = "Upload complete!"
}
}.exhaustive
}

使用 ConstraintLayout Groups 简化

一个Group可以同时控制多个View的可见性。

1
2
3
4
5
<androidx.constraintlayout.widget.Group
android:id="@+id/errorViews"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="errorTitle, errorIcon, errorMessage" />

Easy Android Scopes

https://ryanharter.com/blog/2020/03/easy-android-scopes/

介绍Activity、Fragment发生Configuration Changed事件时的数据保存方法,类似AndroidX中的ViewModel。

作者实现了自己的一套Scoped Delegate方案。

Jetpack ViewModel and string resources

https://www.rockandnull.com/android-viewmodel-resources/

在使用ViewModel时,作者试图将ViewModel实现与Android解耦,以便ViewModel可以无缝迁移到其它平台(如iOS、Web)。文中提供了两种思路。

方案一:使用ResourceId

将资源id作为LiveData进行发送,而非字符串本身。

1
2
3
4
5
6
7
8
9
10
// ViewModel.java
val errorMessageLiveData = MutableLiveData<Int>()
[...]
errorMessageLiveData.value = R.string.error_message_1

// Activity.java
errorMessageLiveData.observe(this, Observer {
Toast.makeText(context, getString(it), Toast.LENGTH_LONG).show()
}
)

方案二:使用资源工厂类(Resources Helper)

该类用于抽象不同平台的资源获取方式。

1
2
3
4
5
class ResourcesHelper(private val applicationContext: Context) {

val errorMessage
get() = applicationContext.getString(R.string.error_message)
}

将该类注入(inject)到ViewModel中,可以使其独立于不同平台实现,并且能够进行mock/fake。

在ViewModel中使用Context

使用AndroidViewModel代替ViewModel

1
2
3
4
class MyViewModel(private val application: Application) : AndroidViewModel(application) {
val errorMessage
get() = application.resources.getString(R.string.some_string)
}

这种写法有一个问题是,用户切换语言无法生效。以及在测试时必须人工提供一个Context对象。

##