【现代 Android APP 架构】05. 处理 UI 层事件

640 阅读5分钟

简单、武断地重复一下Brooks法则:向进度落后的项目中增加人手,只会使进度更加落后。——《人月神话》

UI 层事件,是指那些在 UI 层产生的、并且应当在 UI 层进行处理的事件。例如点击按钮后的响应、Toast 的展示和隐藏等等。

UI 事件是一个动态的概念,如果是纯静态的页面,是不涉及 UI 事件的。

在 UI 层主要处理两种逻辑,业务逻辑展示逻辑

  • 业务逻辑: 业务数据变化相关的逻辑,一般由 Domain 层或者 Data 层处理。在有 UI 的场景中,由 ViewModel 负责处理。在不同的展示平台上,这部分逻辑是 统一 的。
  • 展示逻辑: UI State 变化引起的 UI 展示变更,例如页面跳转、显示 Toast 等。在不同的展示平台上,这部分逻辑允许存在 差异

UI 事件决策树

  1. 根据事件由谁解决,分发给 UI 视图或者 ViewModel
  2. 根据事件是否仅仅为 UI 变更,分发给 ViewModel 或者 UI 自身

处理用户事件

仅 UI 变化/触及业务逻辑

如果用户事件仅仅引起展示层的变化,例如一个长文本 TextView展开/收起,这种情况下,文本数据其实早已加载完成,只不过展示了部分而已。展开/收起的事件,可以交由自定义 View 进行处理。

如果用户事件涉及业务数据变化,例如刷新页面中的列表数据,这种情况下,事件要交给 ViewModel 来处理,并且通过 Flow 返回结果。

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        // ===> 仅仅是 UI 变化,由 View 自行处理
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // ===> ViewModel 负责业务逻辑(页面刷新)
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

RecyclerView 复杂场景下的业务逻辑处理

前文讲到,涉及业务逻辑的 UI 事件,应当交给 ViewModel 进行处理。在 Activity 中可以很容易地办到这件事,因为 Activity 天然持有 ViewModel 的引用。但是如果是要在 ReceiverView 等自定义控件中进行处理,情况就变得复杂了。究其原因,是因为不应该在这些自定义控件当中,持有 ViewModel 的引用。这样会造成 ViewModel 与控件的耦合,破坏两者各自的独立性。

以 RecyclerView 为例,在“新闻列表”页里,每一个 Item 是一条新闻,用户可以点击“收藏”按钮对它进行收藏操作。

为了完成收藏操作,ViewModel 需要知道要收藏的文章 id,当用户点击收藏按钮时,调用 ViewModel.addBoomkark(newsId) ,完成收藏操作 —— 但这样会导致 Adapter 持有了 ViewModel 对象,造成耦合。

相应的解决方案,是由 ViewModel 生成一个 NewsItemUiState 对象,由它来承担收藏文章的职责,相当于进行了一层中转。

data class NewsItemUiState( // ===> 功能简单的数据类
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit // ===> Lambda 表达式执行收藏任务
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
)
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // ===> 将“收藏文章”的操作封装成 Lambda 表达式进行传递
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

借助 高阶函数,将 ViewModel 的 addBookmark 提取出来,交给 Adapter 进行调用。只把 Adapter 关心的功能传给它,这样能有效避免 ViewModel & Adapter 两者发生耦合。

处理 ViewModel 事件

从 ViewModel 触发的 UI 事件,最终通常会引起 UI State 发生变化 。在编写 UI 层代码时,应当始终坚持单向数据流原则。

典型场景:登录后进行页面跳转

设计这样一个 LoginUiState,记录页面加载和用户登录状态。

data class LoginUiState(
    val isLoading: Boolean = false, // ===> 页面加载态
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false // ===> 用户是否已登录
)

LoginViewModel 负责生成 LoginUiState,而 LoginActivity 则继续监听该 UiState。

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() // ===> 不可变,用于发射
    /* ... */
}

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState -> // ===> collect 终态操作
                    if (uiState.isUserLoggedIn) {
                        // ===> 如果已登录,则跳转到主页
                    }
                    ...
                }
            }
        }
    }
}

UiState 的链式反应

前文提到 ViewModel 事件通常会引起 UiState 变更,这种变更有时也会成对出现。例如下面这种场景:网络异常时显示 SnackBar,当显示完成后,又需要调用 ViewModel.userMessageShown() 来触发 UiState 刷新。也许看起来略显繁琐,但这就是为了保持统一所付出的代价。

// ===> 最近新闻流的 UiState
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null // ===> 记录错误信息,如“网络异常”
)

class LatestNewsViewModel(/* ... */) : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true)) // ===> 初始化为 Loading
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun refreshNews() {
        viewModelScope.launch {
            // ===> 网络异常,则发送提示语
            if (!internetConnection()) {
                _uiState.update { currentUiState ->
                    currentUiState.copy(userMessage = "No Internet connection")
                }
                return@launch
            }

            // ===> 向下为网络正常的逻辑
            ...
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null) // ===> 展示完提示语后,将其隐藏
        }
    }
}

ViewModel 本身不关心 UI 层对于提示语的具体实现,它只知道 要显示某条提示语,至于是显示成 Toast 还是 SnackBar,则完全由 UI 层控制。

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // ===> 当提示语隐藏时(可能由用户触发,或者超时自动隐藏),通知 ViewModel
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

导航跳转事件

在 Android Jetpack 当中,导航(Navigation)也被作为一种事件 来处理。位于 UI 层的 NavController 提供了页面间跳转的 navigate() 函数。

class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        binding.helpButton.setOnClickListener {
            navController.navigate(...) // ===> 跳转到“帮助”页
        }
    }
}

如果在跳转前需要进行参数校验,或者是保存数据等业务逻辑,通常由 ViewModel 完成这部分任务,并在随后发射的 UiState 中通知 UI 层执行跳转。

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) { // ===> 完成参数校验、数据保存后
                        // 跳转到 Home 页
                    }
                    ...
                }
            }
        }
    }
}

参考资料