跳至主要內容

译文 | Jetpack Compose 中的 Repository 模式

guodongAndroid大约 26 分钟

提示

本文是《Repository Pattern with Jetpack Compose》的译文,仅供个人学习使用,不得且不可用于商业用途,若是因用于商业用途而产生的一切法律责任与译者无关。

译者已获得原组织/原作者翻译授权,版权归原组织/原作者所有:@kodeco


原文信息:

发布日期:2021年09月27日

原文作者:Pablo Gonzalez Alonso

原文地址open in new window

原文资源open in new window

前言

在 Android 开发中,之前都是必须使用 XML 来构建 UI 布局。但是在 2019 年,Google 引入了一个全新的 UI 构建方式:Jetpack Compose。Compose 借助 Kotlin 强大的功能并使用声明式 API 来构建 UI。

提示

注意:本教程需要你对 Kotlin 有较深的了解以及对 Jetpack Compose 有一些基本的了解。你可以参考 译文 | Jetpack Compose 入门教程 来重温 Compose 的基础知识。

在本教程中,你将联合使用 Jetpack Compose 的强大功能和 Repository 模式(存储库模式)来构建一个英语词典 App。

为了使用 Jetpack Compose,你需要安装 Arctic Foxopen in new window 或以上版本的 Android Studio。注意:Arctic Fox 是第一个支持 Jetpack Compose 的 Android Studio 稳定发布版本。

在构建你的词典 App 期间,你将了解:

  • 读取和显示远端数据。
  • 使用 Room 持久化和恢复本地数据。
  • LazyColumn 中使用分页。
  • 使用 Compose 管理和更新 UI 状态。

你将看到 Jetpack Compose 如何通过消除对 RecyclerView 的依赖并简化状态管理而真正大放异彩。

好的,是时候开始了!

启航

你可以在本文顶部原文信息中访问 原文地址 来获取本教程所需要的资源,也可以通过访问 原文资源 来获取。

使用 Android Studio 打开下载资源中的 starter 项目。你将看到以下文件结构:

Screenshot-2021-06-14-at-00.09.30-289x320

同步项目。然后编译并运行。App 界面如下所示:

device-2021-06-14-001317-180x320

如你所见,现在它还是普普通通的样子。在深入了解代码来让你的 App 更漂亮(呃,我的意思是,更有用)之前,你将先了解关于 Repository 模式的一些知识。

存储库模式

存储库定义数据操作。最常见的操作是创建、读取、更新和删除数据(俗称:CRUD)。这些操作有时需要参数来定义如何运行它们。例如,参数可以是用于过滤结果的搜索词。

Repository 模式(存储库模式)是一种结构设计模式。它有助于组织你访问数据的方式,同时还有助于将问题拆分成更小的部分。有关更多信息和术语,请查看 Clean Architecture for Android: Getting Startedopen in new window

Repository 模式于 2004 年由 Eric Evans 在他的著作 《领域驱动设计:软件核心复杂性应对之道》 中首次引入。

你将在 Jetpack Compose 中实现存储库模式。第一步就是添加 数据源。你将在下面学习它。

理解数据源

将存储库操作委托给相关数据源。数据源可以是远端的或本地的。存储库操作具有确定给定数据源相关性的逻辑。例如:存储库可以从本地数据源提供数据或者通过网络向远端数据源请求数据。

存储 是两个重要的数据源类型。其中,存储 从本地源中获取数据,而 从远端源中获取数据。下图是一个简单的存储库实现示例:

RW-Repository-Illustration-1-446x320

使用存储库

什么时候需要使用存储库?好吧,假设你的 App 的用户想要查看他们的个人资料。该 App 有一个存储库,用于检查存储中是否有用户个人资料的本地副本。如果本地副本不存在,然后存储库将会访问远端源。实现这种类型的存储库如下所示:

Repository-cache-store-example-5-298x320

在本教程中结束时,你将使用含有 存储 两种数据源的存储库模式。换句话说,你的 App 将使用远端和本地两种数据来填充和存储单词。

其他数据源可能依赖于不同类型的源,例如位置服务、权限结果或传感器输入。

例如:用户存储库可以包含两个附加数据源:一个用于验证用户的授权,另一个用于内存缓存。第一个在你需要确认用户是否可以看到个人资料时非常有用,第二个当你不想每次都读取数据库来提供数据时非常有帮助。如下是具有授权和内存缓存的存储库的简单示例:

Repository-cache-store-example-6-181x320

存储库模式的好处之一是可以直接添加新的功能层。并且,与此同时,存储库将关注点分开,并将逻辑组织成组件。这些逻辑组件也不必承担过多的责任。这将使代码保持简洁凝练并使代码之间解耦合。

好的,现在已经学习了不少理论知识。开始享受一些编码乐趣吧。

为 Words 创建 UI

现在是时候为你的 App:Words 创建 UI 了。

UI 包中新建一个名为 WordListUi.kt 的文件。在文件内,使用基本的 Scaffold 定义 WordListUi

@Composable
fun WordListUi() {
  Scaffold(
    topBar = { MainTopBar() },
    content = {

    }
  )
}

提示

注意:当拷贝本教程的代码时,你可以使用 Alt + Enter 快捷键自动导入相关的组件。

现在,打开 MainActivity.kt 文件并使用 WordListUi() 替换 onCreate 中的脚手架:

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContent {
    WordsTheme {
      WordListUi()
    }
  }
}

现在,当 App 启动主 Activity 时,将显示在 WordListUi 中定义的 Scaffold

在构建更多 UI 元素之前,你将创建定义每个单词的模型。在 data.words 包中,新建一个名为 Word.kt 的数据类,其中代码如下所示:

data class Word(val value: String)

然后,在 WordListUi.kt 文件中的 WordListUi 下面定义一个用于在列表中显示每一个单词子项的可组合项:

@Composable
private fun WordColumnItem(
  word: Word,
  onClick: () -> Unit,
) {
  Row(                                              // 1
    modifier = Modifier.clickable { onClick() },    // 2
  ) {
    Text(
      modifier = Modifier.padding(16.dp),           // 3
      text = word.value,                            // 4
    )
  }
}

通过添加以上代码,WordColumnItem 可组合项中的几个要点是:

  1. 定义了一个横向展示子项的 Row
  2. 添加了一个修饰符用于捕获点击事件并将事件转发给 onClick 回调。
  3. 在布局中包含 padding,以便内容看起来不那么拥挤。
  4. 使用 word 的值作为文本。

接下来,你将创建一个显示单词的列表。

要在 Compose 中实现上述功能,将以下可组合项添加到 WordListUi.kt 的底部:

@Composable
private fun WordsContent(
  words: List<Word>,
  onSelected: (Word) -> Unit,
) {
  LazyColumn {              // 1
    items(words) { word ->  // 2
        WordColumnItem(     // 3
          word = word
        ) { 
            onSelected(word)
        }
    }
  }
}

在上述代码中:

  1. 创建了一个 LazyColumn 可组合项。
  2. 告诉 LazyColumn 可组合项渲染一个单词列表。
  3. 为列表中的每个子项创建一个WordColumnItem 可组合项。

LazyColumn 可组合项随着用户滚动列表进而渲染列表子项,这比 RecyclerViewListView 简单多了!

接下来使用 RandomWords 来测试布局。将以下代码添加到 WordListUicontent 中:

WordsContent(
  words = RandomWords.map { Word(it) },	// 1                          
  onSelected = { word -> Log.e("WordsContent", "Selected: $word") } // 2
)

在上述代码中主要做了两件事:

  1. 将字符串类型的列表转换为 words 类型的列表。
  2. 将消息打印到 Logcat 以验证按钮点击情况。
Screenshot-2021-06-20-at-01.30.40-202x320

现在,界面看起来杂乱无章,但是它可以让你简单的了解 App 的界面样式。

接下来,你将为主界面创建一个 ViewModel 并为 Words 创建一个存储库。

创建 Main ViewModel

ViewModelAndroid Jetpack 的架构组件。ViewModel's 的主要作用是保存配置变更(例如:屏幕旋转)。

com.raywenderlich.android.words 包下新建一个 MainViewModel.kt 文件:

 // 1
class MainViewModel(application: Application) : AndroidViewModel(application) {
  // 2
  val words: List<Word> = RandomWords.map { Word(it) }
}

在这个 ViewModel 中,你:

  1. 定义了一个与 application 实例关联的 AndroidViewModel 类型的 ViewModel
  2. 它返回与现在 WordListUi 中相同的数据。

接下来,在 MainActivity.kt 中使用委托获取 MainViewModel。将以下代码添加到 MainActivity 中的 onCreate 上方:

private val viewModel by viewModels<MainViewModel>()

框架会自动将当前 application 的实例注入到 MainViewModel 中。

现在,你需要修改 WordListUi 来接收数据。替换 WordListUi 为以下代码:

@Composable
fun WordListUi(words: List<Word>) { // 1
  Scaffold(
    topBar = { MainTopBar() },
    content = {
      WordsContent(
        words = words,              // 2
        onSelected = { word -> Log.e("WordsContent", "Selected: $word") }
      )
    }
  )
}

在上述代码中,你:

  1. WordListUi 添加了一个新的参数 words
  2. words 列表传入 WordsContent。注意:现在单词由 MainViewModel 生成。

然后,打开 MainActivity 并将 viewModel 中的 words 传入 WordListUi

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContent {
    WordsTheme {
      WordListUi(words = viewModel.words)
    }
  }
}

如果你编译并运行 App,App 与之前并无二致。但是,现在 App 可以在配置变更时保持组件。这种感觉很棒吧?现在 ViewModel 已经就绪,是时候构建存储库了。

构建 WordRepository

接下来,你将从远端数据源开始创建 WordRepository 和协作者。

为了从网络上加载数据,你需要一个客户端。在 data 包下新建一个名为 AppHttpClient.kt 的文件。然后添加一个名为 AppHttpClient 的顶级属性:

val AppHttpClient: HttpClient by lazy {
  HttpClient()
}

上述代码延迟初始化 Ktor 客户端用于触发 HTTP 请求。

接下来,在 data.words 包中新建一个包 remote 并新建一个名为 WordSource.kt 的文件,然后在其中添加以下代码:

                            // 1
class WordSource(private val client: HttpClient = AppHttpClient) {                           
    								// 2
  suspend fun load(): List<Word> = withContext(Dispatchers.IO) {     
    client.getRemoteWords() // 3
      .lineSequence()       // 4
      .map { Word(it) }     // 5
      .toList()             // 6
  }
}

上述代码:

  1. AppHttpClient 设置为 HttpClient 的默认值。
  2. 使用 withContext 确保你的代码运行在后台线程,而不是主线程。
  3. 使用 getRemoteWords 加载所有的单词为字符串。这是个扩展函数,稍后将定义它。
  4. 将所有行作为一个序列(Sequence)。
  5. 将每一行转换为一个 Word
  6. 将序列转换为列表。

接下来,将以下代码添加到 WordSource 下方:

private suspend fun HttpClient.getRemoteWords(): String = get("https://pablisco.com/define/words")

此扩展函数在 HttpClient 上执行 GET 请求。有很多 get 重载函数,因此请确保导入这个确切的重载函数:

import io.ktor.client.request.*

现在,在 data.words 包下新建一个名为 WordRepository.kt 的类并添加以下代码:

class WordRepository(private val wordSource: WordSource = WordSource()) {
  suspend fun allWords(): List<Word> = wordSource.load()
}

WordRepository 使用 WordSource 获取完整的单词列表。

现在,存储库已经就绪,打开 WordsApp.kt 并在其中添加一个延迟属性:

val wordRepository by lazy { WordRepository() }

然后,使用以下代码替换 MainViewModel 的主体:

private val wordRepository = getApplication<WordsApp>().wordRepository
val words: List<Word> = runBlocking { wordRepository.allWords() }

编译并运行,稍等片刻,你将看到从网络上加载的单词列表。

Screenshot-2021-06-21-at-00.22.59-201x320

随着存储库的就绪,是时候在 Jetpack Compose 中管理 UI 状态了。

在 Compose 中使用 State

在 Compose 中有两个互补的概念:StateMutableState。简单看下它们的接口定义:

interface State<out T> {
    val value: T
}

interface MutableState<T> : State<T> {
    override var value: T
}

它们都提供一个 value,但是 MutableState 允许你修改 value。Compose 会观察它们的变更。它们的每次变更都会触发 重组。重组有点像旧视图在 UI 需要更新时重新绘制的方式。然而,Compose 依赖一个可变更数据,当可变更数据发生变更时会智能的重绘和更新可组合项。

请谨记上述内容,然后更新 MainViewModel, 使用 State 代替 List

class MainViewModel(application: Application) : AndroidViewModel(application) {

  private val wordRepository = getApplication<WordsApp>().wordRepository
  private val _words = mutableStateOf(emptyList<Word>()) // 1
  val words: State<List<Word>> = _words                  // 2

  fun load() = effect { 
    _words.value = wordRepository.allWords()             // 3
  }
  
  private fun effect(block: suspend () -> Unit) {
    viewModelScope.launch(Dispatchers.IO) { block() }    // 4
  }
}

经过上述代码的修改,你:

  1. 创建了一个持有 Words 空列表的内部 MutableState
  2. MutableState 对外暴露为不可变状态。
  3. 添加一个函数来加载单词列表。
  4. 添加一个在 ViewModel's 协程作用域内执行操作的工具函数。使用这个函数,你可以确保代码仅在 ViewModel 活跃时且不是在主线程中执行。

现在,打开 MainActivity.kt 并更新主 Activity 的内容。使用以下代码替换 onCreate

super.onCreate(savedInstanceState) {
    viewModel.load()                    // 1
    setContent {
        val words by viewModel.words      // 2
        WordsTheme {
            WordListUi(words = words)       // 3
        }
    }
}

在上述代码中:

  1. 调用 ViewModelload 函数开始加载所有单词。
  2. 使用委托来消费单词。从 ViewModel 到达这里的任何更新都会触发布局重组。
  3. 现在,你可以把 words 传入 WordListUi 了。

以上这些意味着 UI 将在调用 load() 后对新单词做出响应。

接下来,当你了解 Flows 以及它们如何在你的 App 中发挥作用时,你将获得理论提升。

升级 State 为 Flow

正如 App 现在所做的那样,从 ViewModel 中暴露 State 实例会使其过于依赖 Compose。这种依赖性使得将 ViewModel 移动到不使用 Compose 的其他模块变得困难。例如:如果你在 Kotlin Multiplatform 模块中共享逻辑,那么移动 ViewModel 将会很困难。通过创建协程可以解决此依赖性问题,因为你可以使用 StateFlow 而不是 State

Flows 隶属于协程库,是可供一个或多个组件消费的数据流。它们默认是冷的,这意味着它们仅会在被消费时才开始生产数据。

SharedFlow 是一个特殊的 Flow —— 它是热流。这意味着它不需要消费者就可以发送数据。当一个 SharedFlow 发送一个新的数据时,replay 缓存会保留它,当有新的消费者时重新发送它。如果缓存满时,会销毁旧的数据。默认情况下,缓存大小为 0。

还有一个特殊的 SharedFlow —— StateFlow。它总是有且仅有一个数据。实际上,它与 Compose 中的 State 很像。

在接下来的步骤中,你将利用 StateFlow 将变更的结果传递到 UI 并提升 App 的结构。

使用 StateFlow 传递结果

更新 App 以使用 StateFlow,打开 MainViewModel.kt 并将 State 从 Compose 更改为 StateFlow,同时将 mutableStateOf 更改为 MutableStateFlow。代码如下所示:

private val _words = MutableStateFlow(emptyList<Word>())
val words: StateFlow<List<Word>> = _words

StateStateFlow 非常相似,因此你不必更新大量现有代码。

MainActivity.kt 中,使用 collectAsStateStateFlow 转换为 Compose 中的 State

val words by viewModel.words.collectAsState()

现在,MainViewModel 不依赖 Compose 了。接下来,当 App 加载数据时需要显示一个加载中的状态。

显示加载中状态

现在,单词列表加载缓慢。你肯定不想你的用户在加载单词列表期间一直盯着空白的屏幕,所以当他们在等待时,你将创建一个加载中的状态给他们视觉反馈。

首先在 MainViewModel.kt 中创建 StateFlow,将以下内容添加到 MainViewModel 的顶部:

private val _isLoading = MutableStateFlow(true)
val isLoading: StateFlow<Boolean> = _isLoading

isLoading 表示 App 是否处于加载中。现在,在网络加载单词的前后更新 _isLoading 的值。使用以下代码替换 load

fun load() = effect {
  _isLoading.value = true
  _words.value = wordRepository.allWords()
  _isLoading.value = false
}

在上述代码中,你首先设置 _isLoading 的状态为 “加载中”,并在从存储库加载完所有单词后将其设置为 “未加载”。

MainActivity.kt 中使用 isLoading 以显示合适的 UI 状态。将 setContent 内的 words 声明下方的代码更新为:

val isLoading by viewModel.isLoading.collectAsState()
WordsTheme {
  when {
    isLoading -> LoadingUi()
    else -> WordListUi(words)
  }
}

这里,如果状态是加载中,Compose 将渲染 LoadingUi 而不是 WordListUi

重新编译并运行,你将看到 App 现在有一个加载指示器:

2021-07-13-00.09.28.gif

新的加载指示器看起来不错。然而,不能让 App 每次都从网络加载所有单词吧?如果数据缓存在本地数据存储中则不会每次从网络加载。

使用 Room 存储单词

这些单词现在加载速度很慢,因为 App 每次运行时都会加载所有单词。你不希望你的 App 执行此操作!

因此,你将使用 Jetpack Room 为从网络加载的单词构建一个 Store。

首先,在 data.words 中创建一个名为 local 的包。然后,在 data.words.local 包中创建一个名为 LocalWord.kt 的类:

@Entity(tableName = "word")      // 1
data class LocalWord(
  @PrimaryKey val value: String, // 2
)

本地表示与 Word 具有相同的结构,但有两个主要区别:

  1. Entity 注解 告诉 Room 实体表的名称。
  2. 每个 Room 实体都必须有一个主键。

接下来,在 local 包中为 Word 定义一个名为 WordDao.ktData Access Object (DAO)

@Dao                                                 // 1
interface WordDao {
  @Query("select * from word order by value")        // 2
  fun queryAll(): List<LocalWord>
  
  @Insert(onConflict = OnConflictStrategy.REPLACE)   // 3
  suspend fun insert(words: List<LocalWord>)

  @Query("select count(*) from word")                // 4
  suspend fun count(): Long
}

在上述代码中,你使用 Room 定义了四个数据库操作:

  1. @Dao 表示这个接口是一个 DAO。
  2. queryAll 使用 @Query 注解定义了一个 Sqlite 查询操作。该查询操作要求所有值按 value 属性排序。
  3. insert 将单词添加或更新到数据库中。
  4. count 确定表是否为空。

现在,你在 data.words 包中新建一个名为 AppDatabase.kt 的文件,并在其中创建一个数据库,以便 Room 可以识别 EntityDAO

@Database(entities = [LocalWord::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
  abstract val words: WordDao
}

这个抽象数据库将 LocalWord 定义为唯一的实体。它还将 words 定义为抽象属性来获取 WordDao 的实例。

Room 编译器会生成其工作所需的所有内容。

现在,AppDatabase 已经准备就绪,下一步就是在 Store 中使用 Dao。在 data.words.local 中新建一个名为 WordStore.kt 的文件,并在其中创建 WordStore

class WordStore(database: AppDatabase) {
  // 1
  private val words = database.words

  // 2
  fun all(): List<Word> = words.queryAll().map { it.fromLocal() }

  // 3
  suspend fun save(words: List<Word>) {
    this.words.insert(words.map { it.toLocal() })
  }

  // 4
  suspend fun isEmpty(): Boolean = words.count() == 0L
}

private fun Word.toLocal() = LocalWord(
  value = value,
)

private fun LocalWord.fromLocal() = Word(
  value = value,
)

映射函数 toLocalfromLocalWordLocalWord 相互转换。

上面的代码对 WordStore 执行以下操作:

  1. WordDao 另存为内部实例 words
  2. 调用 all 函数进而使用 WordDao 来访问 LocalWord 实例。然后,使用 map 函数将其转换为 Words
  3. 使用 save 传入 Words 列表,并将它们转换为 Room 识别的值并保存。
  4. 添加一个判断是否有已保存单词的函数。

由于你已经添加了将单词保存到数据库的逻辑,下一步是更新 WordRepository.kt 以使用此逻辑。替换 WordRepository 为以下代码:

class WordRepository(
  private val wordSource: WordSource,
  // 1
  private val wordStore: WordStore,
) {

  // 2
  constructor(database: AppDatabase) : this(
    wordSource = WordSource(),
    wordStore = WordStore(database),
  )

  // 3
  suspend fun allWords(): List<Word> = 
    wordStore.ensureIsNotEmpty().all()

  private suspend fun WordStore.ensureIsNotEmpty() = apply {
    if (isEmpty()) {
      val words = wordSource.load()
      save(words)
    }
  }
}

这里的一个关键组件是扩展函数:ensureIsNotEmpty。如果 WordStore 中的数据库为空,它将填充该数据库。

  1. 要让 ensureIsNotEmpty 工作,你添加了 WordStore 作为构造器属性。
  2. 为了方便,你添加了一个次构造器。它接收一个数据库,然后使用该数据库创建 WordStore
  3. 然后,你在调用 all 函数之前先调用了 ensureIsNotEmpty 以确保 Store 有数据。

使用私有数据库和公共 wordRepository 更新 WordsApp,以便与新更新的 WordRepository 配合使用。将 WordsApp 的正文替换为:

// 1
private val database by lazy { 
    Room.databaseBuilder(this, AppDatabase::class.java, "database.db").build() 
}

// 2
val wordRepository by lazy { WordRepository(database) }

每个 Android 进程都会创建一个 Application 对象,并且只有一个。这是手动注入单例的好地方并且还有它们需要的 Android 上下文。

  1. 首先,你想要定义一个名为 database.dbAppDatabase 类型的 Room 数据库。你必须让它变成惰性的,因为当你使用 this 实例化数据库时,你的 App 还不存在。
  2. 然后,你使用上一步刚刚创建的数据库定义了一个 WordRepository 的实例。你也需要将其变成惰性的以避免过早的实例化数据库。

编译并运行。第一次运行时你仍然需要等待较长的加载时间,但是之后,每次启动 App 时单词将会立刻加载。

Screenshot-2021-06-21-at-00.22.59-313x500

接下来你需要处理的事情是:确保你不会在内存中加载数以千计的单词。当大型数据集运行在低内存的设备上将会发生意想不到的问题。最好只将正在显示或即将显示的单词保留在内存中。

添加分页

为了避免将字典中存在的所有可能单词加载到内存中,而不仅仅是当前正在查看的单词,你需要向 App 添加分页。

Jetpack Paging 3 库有一个专门用于此目的的 Compose 配套库。在继续之前,你需要先了解此库中的一些重要概念:

  • PagingSource:在 load 时使用 LoadParams 获取 LoadResult 实例。
  • LoadParams:告诉 PagingSource 要加载多少项,它还包括一个键。该键通常是页码,但也可以是任何内容。
  • LoadResult:一个密封类,通知你是否存在页或加载该页时是否发生错误。
  • Pager:一个便利的工具类,可帮助你将 PagingSource 转换为 PagingData 类型的 Flow
  • PagingData:你将在 UI 中使用的页的最终表示形式。

幸运的是,Room 与 Jetpack Paging 3 配合良好,并且具有内置功能。因此,你可以修改 WordDao.kt 中的 queryAll 来启用分页:

@Query("select * from word order by value")
fun queryAll(): PagingSource<Int, LocalWord>

打开 WordStore.kt 你将发现编译器对 all 中的语法提示错误。你将在下面解决这个问题。

WordStore.kt 底部添加以下代码:

private fun pagingWord(
  block: () -> PagingSource<Int, LocalWord>,
): Flow<PagingData<Word>> =
  Pager(PagingConfig(pageSize = 20)) { block() }.flow
    .map { page -> page.map { localWord -> localWord.fromLocal() } }

在这里,你使用 PagerPagingSource 转换为 PagingData 类型的 Flow。嵌套映射将每个 PagingDataLocalWords 转换为常规 Word 实例。

分页就绪后,你就可以更新 all 了:

fun all(): Flow<PagingData<Word>> = pagingWord { words.queryAll() }

你需要在更多地方更新代码以避免编译错误。

WordRepository.kt 中更新 allWords 以便它返回一个 Flow 而不是 List

suspend fun allWords(): Flow<PagingData<Word>> = ...

注意,你还可以删除返回类型并让编译器推断该类型。

现在,打开 MainViewModel.kt 并更新以下声明:

private val _words = MutableStateFlow(emptyFlow<PagingData<Word>>())
val words: StateFlow<Flow<PagingData<Word>>> = _words

接下来,在 WordListUi.kt 中更新 WordListUi 以便接收一个 Flow 而不是 List

fun WordListUi(words: Flow<PagingData<Word>>) {
  ...
}

要使 wordsLazyColumn 一起使用,你必须更改收集单词的方式。

如下所示更改 WordsContent 的函数体:

private fun WordsContent(
  words: Flow<PagingData<Word>>,
  onSelected: (Word) -> Unit,
) {
  // 1
  val items: LazyPagingItems<Word> = words.collectAsLazyPagingItems() 
  LazyColumn {
    // 2
    items(items = items) { word ->   
    // 3
      if (word != null) {                      
        WordColumnItem(
          word = word
        ) { onSelected(word) }
      }
    }
  }
}

你在这里做了三件新事情:

  1. 将页收集到 LazyPagingItems 实例中。LazyPagingItems 使用协程管理页的加载。
  2. 使用 Paging 库重载的 items 函数。这个新版本采用 LazyPagingItems 而不是简单的 列表
  3. 检查该项是否为空。请注意,如果启用了占位符,则该值可能为空。

编译并运行 App。你会发现它和以前一样。但是,性能得到了提高,因为现在 App 不会一次性将整个单词列表存储在内存中。

Screenshot-2021-06-21-at-00.22.59-313x500

搜索词典

你已经在你的 App 中加载了单词列表,然而仅仅是单词列表好像没有什么用。要是想滑动查找以 B 开头的单词需要滑动较长时间。因此,你需要给用户提供一个搜索单词的方式。

为此,你首先需要能够在 MainViewModel.kt 中表示当前的搜索查询。将以下代码添加到 MainViewModel 的顶部:

private val _search = MutableStateFlow(null as String?)
val search: StateFlow<String?> = _search

fun search(term: String?) {
  _search.value = term
}

名为 _search 的私有 StateFlow 保存当前查询。当有人调用 search 时,它会将更新发送给收集者。

接下来,你必须更新 WordListUi 的参数,如下所示:

fun WordListUi(
  words: Flow<PagingData<Word>>,
  search: String?,
  onSearch: (String?) -> Unit,
)

在上述代码中,你添加了要搜索的字符串和触发实际搜索的回调。

WordListUi 中,使用 SearchBar 替换 MainTopBar

topBar = {
  SearchBar(
    search = search,
    onSearch = onSearch,
  )
}

SearchBar 可组合项并不是 Jetpack 库的内置组件,但是它包含在 starter 项目中,如果你要查看它的话,可以在 ui.bars 中找到它。

MainActivity.kt 中,在 setContent 中的顶部添加以下内容以收集搜索状态,如下所示:

val search by viewModel.search.collectAsState()

然后,更新对 WordListUi 的调用。从 ViewModel 传递搜索词和搜索函数:

WordListUi(
  words = words,
  search = search,
  onSearch = viewModel::search
)

编译并运行。你将看到一个带有搜索图标的新顶部栏。单击该图标可展开搜索输入框:

2021-07-06-23.42.23

此时,你的搜索函数不会响应输入的搜索词。现在就解决这个问题。

响应搜索

为了完善你的搜索功能,你需要检索数据并更新每次搜索的 UI。为此,你将在 WordDao 中添加 searchAll

@Query("select * from word where value like :term || '%' order by value")
fun searchAll(term: String): PagingSource<Int, LocalWord>

searchAll 与上一个函数 queryAll 相比有一个主要区别:where 条件。仔细观察下:

where value like :term || '%'

where 过滤以给定 :term 字符串相似的单词。

接下来,在 WordStore.kt 中的 all 函数中使用 searchAll

fun all(term: String): Flow<PagingData<Word>> =
	pagingWord { words.searchAll(term) }

WordRepository.kt 中,如下所示添加 allWords 的重载函数:

suspend fun allWords(term: String): Flow<PagingData<Word>> =
	wordStore.ensureIsNotEmpty().all(term)

基本上,你将一个 term 传递给 all 函数。和以前一样,使用 ensureIsNotEmpty 来确保 Store 不为空。

接下来,你需要确保 App 可以显示当前的搜索结果。首先在 MainViewModel.kt 中的 MainViewModel 的顶部添加以下代码:

private val allWords = MutableStateFlow(emptyFlow<PagingData<Word>>())
private val searchWords = MutableStateFlow(emptyFlow<PagingData<Word>>())

在上述代码中,你声明了两个单独的 MutableStateFlow 属性:一个用于所有的单词,另一个用于搜索结果单词。

接下来,更新 load 函数,使用 allWords 而不是 _words。代码如下所示:

fun load() = effect {
    _isLoading.value = true
    allWords.value = wordRepository.allWords()
    _isLoading.value = false  
}

现在,找到 MainViewModel 顶部声明 words 的位置:

val words: StateFlow<Flow<PagingData<Word>>> = _words

使用如下代码替换 words

@OptIn(ExperimentalCoroutinesApi::class)
val words: StateFlow<Flow<PagingData<Word>>> = 
  search
    .flatMapLatest { search -> words(search) }
    .stateInViewModel(initialValue = emptyFlow())

编译器还无法识别 words译者注:这里是指 search -> words(search) 中的 words(search) 函数,稍后会添加此函数代码),但稍后你会修复它。

在这里,你使用 search StateFlow 来生成新的 Flow。如果没有搜索请求,新 Flow 会选择 allWords;如果有搜索请求,新 Flow 会选择 searchWords。这要归功于 flatMapLatest

由于你不再使用 _words,因此可以将其删除。

最后,在 MainViewModel 的底部添加以下函数:

// 1
private fun words(search: String?) = when {
  search.isNullOrEmpty() -> allWords
  else -> searchWords
}

// 2
private fun <T> Flow<T>.stateInViewModel(initialValue : T): StateFlow<T> =
    stateIn(scope = viewModelScope, started = SharingStarted.Lazily, initialValue = initialValue)
  
fun search(term: String?) = effect {
  _search.value = term
  // 3
  if (term != null) {
    searchWords.value = wordRepository.allWords(term)
  }
}

删除旧版本的 search

添加上述的代码后,你的 App 中将发生以下情况:

  1. words 根据 search 参数是否为 null 或为空字符串来决定使用 allWords 还是 searchWords
  2. 你使用 flatMapLatest 返回 Flow 而不是 StateFlow。通过 stateIn,你可以将 Flow 作为 StateFlow 返回。返回的 StateFlow 绑定了 viewModelScope。然后,它在发送数据之前等待收集器。最后,它还提供了一个初始值。
  3. 如果搜索词不为空,你的 App 将使用新的搜索词更新 searchWords

编译并运行以测试你辛苦构建的搜索功能。重新启动 App 并打开搜索输入框。比如搜索“Hello”:

Screenshot-2021-07-07-at-01.30.43-199x320

太棒了!你的搜索功能实现了:它会过滤掉所有其他单词,只显示你搜索的单词。

显示空的搜索结果

现在,如果你的搜索没有结果,这将导致屏幕一片空白。但是,给用户一个反馈而不是空白屏幕就好了。因此,你将实现一个界面,告诉用户他们的搜索结果为空。

首先,在 WordlistUi.kt 最后添加以下可组合项:

@Composable
private fun LazyItemScope.EmptyContent() {
  Box(
    modifier = Modifier.fillParentMaxSize(),
    contentAlignment = Alignment.Center,
  ) {
    Text(text = "No words")
  }
}

这是一个仅显示 Text 的简单可组合项。你将在没有搜索结果时使用它。这个可组合项扩展自 LazyItemScope。这意味着你可以使用 fillParentMaxSize 而不是 fillMaxSize。如此可以保证此布局填充 LazyColumn 的大小。

然后,在 WordsContentLazyColumn 中,如果没有项目,则调用 item。在 LazyColumn 中的底部使用 EmptyContent 显示空消息:

if(items.itemCount == 0) {
  item { EmptyContent() }
}

编译并运行。现在有一个界面清楚地向用户显示没有搜索结果。

Screenshot-2021-07-13-at-22.33.01-200x320

最后,恭喜你完成你的 App。你的用户可以查找英语单词的定义。你的 App 将帮助人们学习英语并在拼字游戏(Scrabble:歪果仁常玩的游戏)中获胜。

总结

通过本教程,你了解了:

  1. 什么是数据源,
  2. 什么是存储库模式,
  3. 如何在 Compose 中使用存储库模式,
  4. 在 Compose 中使用 State,
  5. 为什么放弃使用 Compose 的 State 而使用 Kotlin Flow,
  6. Room 的简单使用

最后,你认为在 Jetpack Compose 中使用 Repository 模式如何?

Jetpack Compose Roadmap