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走漏问题?juejin.cn/post/731461…
  2. Android开发中“真正”的库房形式 juejin.cn/post/731969…
  3. Room监听本地数据改动原理 juejin.cn/post/724661…
  4. 架构蓝图:软件架构4+1视图模型 blog.csdn.net/tq1086/arti…