对实现移动应用界面设计的思考

1. 屏幕

1.1. 屏幕的职责

现在的移动设备都使用触摸屏,触摸屏承担了两项职责:展示界面和处理用户操作指令。界面上展示的东西又可以分为内容和样式。比如展示一行大标题,标题文字是内容,字体、字号、颜色、背景色等等是样式。处理用户操作指令也可以分成接收指令和执行指令两部分。

代码1 屏幕的职责

复制代码
屏幕 = 展示界面 + 处理用户操作指令 = (内容 + 样式) + (接收指令 + 执行指令)

对于简单的应用,这几部分可以放在一起,比如放到活动Activity或组合式函数Composable中。如果界面较为复杂,就需要将职责分配到不同的对象,让每个对象足够简单、清晰、可测试。换句话说,要分离关注点,重新分解并组合上面的式子。

1.2. 重新分解屏幕职责

代码2 重新分解屏幕职责

复制代码
屏幕 = 内容 + (样式 + 接收指令) + 执行指令

内容是静态的,可以封装成被动对象(见《架构蓝图:软件架构4+1视图模型》的"视图之间的联系"部分)作为参数传递给组合式函数。样式和接收指令部分放在组合式函数中。处理指令部分可以抽象成一个接口,和内容一起传递给组合式函数。

代码3 屏幕代码示例

复制代码
@Composable
fun MyScreen(displayState: DisplayState, actionHandler: ActionHandler) {
    Text(
            text = displayState.message,
            fontSize = 20.dp,
            modifier = Modifier.clickable { 
                actionHandler.onAction(UserClickAction())
            }
    )
}

可以看到组合式函数MyScreen的职责有两个:

  1. 将内容与样式关联起来。
  2. 将用户操作映射为指令,传递给指令处理函数。

同时另外两项工作是MyScreen不应该考虑的:

  1. 生产内容。
  2. 执行用户操作指令。

这样设计的组合式函数非常简单,没有复杂逻辑(分支、循环),很容易编码和测试。

2. 内容

2.1. 展示内容

内容自身是静态的,没有行为。随着用户输入数据的变化,以及用户发出新的操作指令,内容会发生变化,或者说会产生新版本。这类场景最适合使用Flow处理。我们让ViewModel返回内容流对象,让屏幕收集流,展示内容。

复制代码
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val screenModel = MyScreenModel(MyRepository())
    val actionHandler = MyActionHandler()

    setContent {
        var displayState by remember { mutableStateOf(DisplayState()) }
        LaunchedEffect(true) {
            screenModel.displayState.collect { displayState = it }
        }

        MyScreen(displayState, actionHandler)
    }
}

2.2. 生成内容

现在考虑如何生成内容。内容包括什么呢?内容的来源通常有3个部分:本地数据(包括数据库、媒体文件、偏好设置等)、远程数据(比如发送HTTP请求获得的数据)、用户输入的数据。前两部分都是后端数据,可以通过仓库Repository提供统一的接口。具体做法后面会介绍。现在只需要考虑:

代码4 分解内容

复制代码
内容 = 用户输入的数据 + 后端数据

直观的想法是建立用户输入数据流和后端数据流,通过Flow.combine()方法合并成内容流。这里有一个问题,两部分内容不是平等或独立的,用户输入的数据可能影响后端数据流,二者更像是流水线上前后两个步骤的关系:用户输入流的变化会改变后端数据流。搭建流水线要做两件事:

  1. 建立用户输入流。用户输入的变化,通过用户输入流进行通知。
  2. 通过flatMapLatest将用户输入流和后端数据流连接起来,产生内容流。
复制代码
class MyScreenModel(private val repository: MyRepository) : ViewModel(), ActionHandler {

    private val input = MutableSharedFlow<InputState>(replay = 1)

    val displayState = input.flatMapLatest { inputState ->
        repository.queryLocalDatabase(inputState.username, viewModelScope).map { displayState(it, inputState) }
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DisplayState())

    init {
        onAction(InitializeAction())
    }

    fun displayState(backendEntity: BackendEntity, inputState: InputState): DisplayState {
        return DisplayState(/* ... */)
    }

    override fun onAction(action: Any, context: Context?) {
        var newInputState: InputState? = null
        when (action) {
            is SomeAction -> newInputState = InputState("...")
            is InitializeAction -> newInputState = InputState("初始化")
        }

        newInputState?.let {
            viewModelScope.launch {
                input.emit(it)
            }
        }
    }
}

这个方法来自于[翻译]安卓开发者该如何解决ViewModel的Flow Collector泄漏问题?

3. 执行用户指令

前面我们已经把处理用户指令的过程分解为接收指令和执行指令。接收指令部分(将用户操作映射为指令)由组合式函数负责,现在只需要考虑执行指令。执行指令可能产生两个效果:改变用户输入数据,发送请求到后端。在这里,除了发送HTTP或RPC请求之外,对本地数据库或媒体文件的访问也当做发送请求到后端。第一个效果已经通过用户输入流处理了。所以只要考虑第二点。请求可以分为读请求和写请求。读写请求都可能更新内容流,因此相关职责要分配给持有内容流的ViewModel。读请求的处理相对简单,可以参考前面对用户输入流的处理。写请求可以分为新建对象、修改对象、删除对象3类。一些和业务流程有关的写请求,最后也可以归结到这3类之中。

首先考虑使用本地数据库作为后端。Room库为DAO提供了数据更新自动通知机制,因此新建、修改和删除这些操作产生的变化会通过数据流自动更新界面。不需要我们做额外的工作。

现在来看需要发送网络请求的情况。对于每个远程服务,可以在本地数据库中建立一个缓存表保存远程对象状态。每次远程请求成功后,将应答包含的对象信息写入缓存表。界面通过监听缓存表变动实现自动更新。

复制代码
网络请求 -> 更新本地数据库 -> 更新数据流 -> 更新界面

这样在处理后端数据源时,不再需要区分本地数据库和远端服务,可以将本地数据库和远程服务封装成一个仓库Repository。业务代码只需依赖仓库,而不用关注背后实际的细节。

这里介绍一下新建远程对象的情况。新建对象时,本地已经拥有了新对象的全部业务属性。当然可能还要等待后端服务分配对象主键。此时后端服务可以返回新主键而非全部属性。仓库设置对象主键后插入本地数据库。这样可以减少网络请求成本。

4. 示例代码

复制代码
package com.tommwq.roomdemo

import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tommwq.roomdemo.ui.theme.RoomDemoTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

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

    // 也可以使用注入获得Repository和ScreenModel。
    val screenModel = MyScreenModel(MyRepository())

    setContent {
      RoomDemoTheme {
        Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {

          var displayState by remember { mutableStateOf(DisplayState()) }
          LaunchedEffect(true) {
            screenModel.displayState.collect { displayState = it }
          }

          MyScreen(displayState, screenModel)
        }
      }
    }
  }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyScreen(displayState: DisplayState, actionHandler: ActionHandler) {
  Column {
    Text(
      text = displayState.message
    )
    TextField(
      value = displayState.username,
      onValueChange = {
        if (it != displayState.username) {
          actionHandler.onAction(ChangeUsernameAction(it))
        }
      }
    )
  }
}

/**
 * 输入状态。
 */
data class InputState(val username: String, val changeTimes: Int = 0)

/**
 * 显示状态。
 */
data class DisplayState(val message: String = "", val username: String = "", val changeTimes: Int = 0)

/**
 * 后端数据。
 */
data class BackendEntity(val message: String)

class MyRepository {
  fun queryLocalDatabase(username: String, scope: CoroutineScope): Flow<BackendEntity> {
    return flow {
      while (true) {
        val time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
        emit(BackendEntity("$time Hello, $username"))
        delay(1000)
      }
    }.shareIn(scope, replay = 1, started = SharingStarted.WhileSubscribed())
  }

  fun invokeRemoteService(scope: CoroutineScope) {
    scope.async {
      // val data = invokeNetworkService()
      // saveToLocalDatabase(data)
    }
  }
}

class InitializeAction
class InvokeRemoteServiceAction
data class ChangeUsernameAction(val username: String)

/**
 * 用户操作处理器。
 */
interface ActionHandler {

  /**
   * 处理用户操作,必要时更新状态。
   *
   * @param action 用户操作
   * @param context 活动上下文
   */
  fun onAction(action: Any, context: Context? = null)
}


class MyScreenModel(private val repository: MyRepository) : ViewModel(), ActionHandler {

  private val input = MutableSharedFlow<InputState>(replay = 1)

  val displayState = input.flatMapLatest { inputState ->
    repository.queryLocalDatabase(inputState.username, viewModelScope).map { displayState(it, inputState) }
  }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DisplayState())

  init {
    onAction(InitializeAction())
  }

  /**
   * 根据后端数据和输入状态生成展示状态。
   * @param backendEntity 后端数据
   * @param inputState 输入状态
   */
  fun displayState(backendEntity: BackendEntity, inputState: InputState): DisplayState {
    return DisplayState("时间 ${backendEntity.message} 修改次数 ${inputState.changeTimes}", inputState.username)
  }

  /**
   * 处理用户操作,必要时更新输入状态。
   *
   * @param action 用户操作
   * @param context 活动上下文
   */
  override fun onAction(action: Any, context: Context?) {
    var oldInputState = input.replayCache.lastOrNull()
    var newInputState: InputState? = null
    when (action) {
      is ChangeUsernameAction -> newInputState = InputState(action.username, (oldInputState?.changeTimes ?: 0) + 1)
      is InitializeAction -> newInputState = InputState("用户")
      is InvokeRemoteServiceAction -> repository.invokeRemoteService(viewModelScope)
    }

    newInputState?.let {
      viewModelScope.launch {
        input.emit(it)
      }
    }
  }
}

5. 参考资料

  1. [翻译]安卓开发者该如何解决ViewModel的Flow Collector泄漏问题?[翻译]安卓开发者该如何解决ViewModel的Flow Collector泄漏问题? - 掘金
  2. Android开发中"真正"的仓库模式 Android开发中"真正"的仓库模式 - 掘金
  3. Room监听本地数据变化原理 Room监听本地数据变化原理 - 掘金
  4. 架构蓝图:软件架构4+1视图模型 https://blog.csdn.net/tq1086/article/details/132437666
相关推荐
sunly_2 小时前
Flutter:InheritedWidget数据共享
android·javascript·flutter
Q8137574602 小时前
智能算法引领金融创新:正大科技的智能分析框架
android·开发语言·kotlin
张铁铁是个小胖子3 小时前
整合seata遇到的问题
android
一航jason4 小时前
Android Jetpack Compose 现有Java老项目集成使用compose开发
android·java·android jetpack
猿小蔡-Cool4 小时前
Android 中的 Zygote 和 Copy-on-Write 机制详解
android·zygote
顾北川_野5 小时前
android 默认关闭增强型4GLTE开关;去掉VT视频通话功能及菜单
android·开发语言
海绵波波1075 小时前
聊天服务器(7)数据模块
android·服务器·adb
软件聚导航6 小时前
uniapp 实现 ble蓝牙同时连接多台蓝牙设备,支持app、苹果(ios)和安卓手机,以及ios连接蓝牙后的一些坑
android·ios·uni-app
深海呐11 小时前
Android 最新的AndroidStudio引入依赖失败如何解决?如:Failed to resolve:xxxx
android·failed to res·failed to·failed to resol·failed to reso
解压专家66611 小时前
安卓解压软件推荐:高效处理压缩文件的实用工具
android·智能手机·winrar·7-zip