Kotlin-安卓开发之大展拳脚-全-

Kotlin 安卓开发之大展拳脚(全)

原文:zh.annas-archive.org/md5/8cda75b92be20e76079f5e1d17cbd326

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

作为一名 Android 开发者,我认为自己很荣幸能成为这样一个社区的一员,这个社区有能力触及并改善全球用户的日常生活。Android 开发不仅仅是编写代码;它还关乎创造能够引起共鸣、激发灵感和以有意义方式连接人们的体验。我必须承认,我对 Android 开发的热情源于我们作为开发者对个人和社区产生的深远影响。

Android 社区是一个充满活力和动态的生态系统,以创新、协作和对卓越不懈追求为特点。从简单的应用早期到今天功能丰富的复杂应用,Android 开发者不断推动着可能性的边界。这本书是对这种创新精神的致敬。它旨在帮助你积累构建对用户真正有价值的应用程序所需的技术和知识。

无论你是在创建消息应用、社交网络平台还是视频流媒体服务(正如本书所做的那样),Android 开发的核心理念保持不变——对质量的承诺、对用户体验的关注以及学习和适应的渴望。当你开始这段旅程时,请记住,你是一个全球开发者社区的一员,你们有着共同的激情和奉献。我们一起可以继续创新,并创建出对世界产生影响的优秀应用。

这本书面向的对象

如果你是一名中级 Android 工程师,这本书适合你,因为它将教你如何解决在实际应用中出现的各种问题,并可作为你日常工作的参考。这本书也可以帮助初级工程师,因为它将开始让他们接触复杂问题及其最佳解决方案。

对 Android 和 Kotlin 概念有基本了解将大有裨益,例如视图(Views)、活动(Activities)、生命周期(lifecycles)和 Kotlin 协程(Kotlin coroutines)。

这本书涵盖的内容

第一章《为您的消息应用构建 UI》中,你将开始构建 WhatsPackt 消息应用,重点关注做出关键技术决策和创建开发所需的结构。本章将指导你定义应用的结构和导航,设置和组织模块,以及选择依赖注入框架。你还将通过 Jetpack Navigation 和 Jetpack Compose 获得实际操作经验,以构建主屏幕、聊天列表和消息列表,为应用的用户界面打下坚实的基础。

第二章《设置 WhatsPackt 的通讯能力》中,您将探索如何使用 WebSockets 将 WhatsPackt 通讯应用连接到后端服务器,实现实时一对一的对话。本章涵盖了建立 WebSocket 连接、在 ViewModel 中处理消息以及实施最佳实践来更新用户界面和管理消息存储。此外,您还将学习如何管理同步和错误处理,并实现推送通知以提醒用户有新消息。到本章结束时,您将全面了解创建强大通讯系统所需的基本技术。

第三章《备份您的 WhatsPackt 消息》中,您将专注于 WhatsPackt 通讯应用中的数据处理和持久性,确保消息被正确存储并且可以快速检索,即使在设备故障或意外删除的情况下。本章介绍了 Room,一个简化 Android 数据库管理的持久性库,并指导您了解其架构和实现。您还将学习创建有效的缓存机制,为 Firebase 设置和安全的云存储进行备份,并使用 WorkManager 安排异步任务,确保您的聊天数据的安全和可靠性。到本章结束时,您将为您的通讯应用拥有一个强大的数据持久性策略。

第四章《构建 Packtagram UI》中,您将从设置一个健壮的项目结构、定义文件层次结构和模块开始创建 Packtagram,一个类似 Instagram 的社交网络应用。本章涵盖了项目组织的基本方面和选择合适的架构模式以实现可扩展性。然后,您将开发用户友好的新闻源和故事界面,确保无缝的导航和交互。此外,您还将学习如何使用 Retrofit 和 Moshi 从服务器检索数据,并实施有效的数据缓存策略,通过减少网络调用来提高性能和用户体验。

第五章《使用 CameraX 创建照片编辑器》中,您将通过集成 CameraX,一个用于无缝拍照和编辑的强大工具,来增强 Packtagram 应用。本章将指导您实现 CameraX,以改变摄影体验,使用户能够通过直观的编辑工具调整和个性化他们的照片。此外,您还将探索使用机器学习来识别照片主题并建议相关标签,为应用的功能添加智能层。

第六章将视频和编辑功能添加到 Packtagram 中,您将通过集成视频功能来提升 Packtagram 应用的功能,将其转变为一个综合的多媒体平台。本章涵盖了使用 CameraX 库捕获高质量视频,并使用 FFmpeg 处理添加字幕和滤镜等任务。您还将学习如何高效地将视频上传到 Firebase 的云存储,确保大文件处理的流畅性和用户体验的改善。到本章结束时,您将显著丰富 Packtagram,使其成为一个适用于照片和视频分享的多功能平台。

第七章启动视频流应用并添加身份验证,您将开始创建 Packtflix 视频流应用,重点关注多媒体内容交付和用户身份验证。本章从从头开始设置项目结构和模块开始。您将使用 OAuth2 实现强大的用户身份验证,以确保对账户和个人偏好的安全访问。在身份验证之后,您将使用 Jetpack Compose 构建动态和响应式的列表来展示电影,并为每部电影或系列创建详细的屏幕,为用户提供所有必要的信息。到本章结束时,您将为您的流应用打下坚实的基础。

第八章使用 ExoPlayer 将媒体播放添加到 Packtflix 中,您将通过集成 ExoPlayer 强大的视频播放功能来增强 Packtflix 应用,ExoPlayer 是一个提供广泛定制和多种媒体格式支持的通用库。本章从概述 Android 中的媒体选项开始,强调 ExoPlayer 的优势。您将学习 ExoPlayer 的基础知识,包括其架构和关键组件,以及如何将其集成到您的应用中。随后,您将创建一个响应式的视频播放用户界面,管理播放控制,并调整视频质量。此外,您还将添加字幕以确保可访问性,通过高质量的视频内容丰富用户体验。

第九章扩展 Packtflix 应用中的视频播放功能,您将通过扩展视频播放功能来扩展 Packtflix 应用的能力,重点关注画中画PiP)模式和媒体投射。本章将指导您创建一个覆盖在其他应用之上的小型视频播放器,使用户在多任务处理时可以继续观看。此外,您还将学习使用 MediaRouter 和 Cast SDK 将视频播放传输到更大的屏幕,例如带有 Google Chromecast 的电视。到本章结束时,您将深刻理解 PiP 功能和媒体投射,显著提升您的 Android 应用的用户体验。

为了充分利用这本书

本书涵盖的软件/硬件 操作系统要求
Android Studio Jellyfish | 2023.3.1 Windows, macOS, 或 Linux

您需要在您的计算机上安装 Android Studio,因为它是本章中使用的首选开发环境。此外,建议您对 Git 有基本的了解,因为您将需要它来下载和管理书中提供的代码仓库。

如果您正在使用这本书的数字版,我们建议您自己输入代码或从书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载这本书的示例代码文件github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin。如果代码有更新,它将在 GitHub 仓库中更新。

我们还提供来自我们丰富的图书和视频目录的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“例如,在:app模块的build.gradle文件中,在dependencies部分包含以下代码。”

代码块设置如下:

class MessagesRepository @Inject constructor(    private val dataSource: MessagesSocketDataSource ): IMessagesRepository {    override suspend fun getMessages(): Flow<Message> {        return dataSource.connect()    }

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

Scaffold(
    topBar = {
        TopAppBar(
            title = {
               Text(stringResource(R.string.chat_title,
               uiState.name.orEmpty()))
            }
        )
    },

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“Android Studio 将为我们提供一系列模板以开始。我们将选择Empty Activity选项,如下面的截图所示:”

小贴士或重要提示

看起来像这样。

联系我们

欢迎读者反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 与我们联系,并提供材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.

分享你的想法

一旦你阅读了《使用 Kotlin 在 Android 开发中茁壮成长》,我们很乐意听听你的想法!请 点击此处直接进入此书的亚马逊评论页面 并分享你的反馈。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

你喜欢在旅途中阅读,但无法随身携带你的印刷书籍吗?

你的电子书购买是否与你的选择设备不兼容?

不要担心,现在,每当你购买 Packt 书籍时,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠远不止于此,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781837631292

  1. 提交你的购买证明

  2. 就这样!我们将直接将你的免费 PDF 和其他优惠发送到你的电子邮件。

第一部分:创建 WhatsPackt,一个消息应用

在本部分中,您将学习如何构建和构建名为 WhatsPackt 的消息应用,使用 WebSockets 实现实时通信,并使用 Firebase 的 Room 和云存储确保数据持久性和备份。您将在创建用户界面、处理消息同步和实现推送通知方面获得实践经验,最终构建一个强大且可靠的消息系统。

本部分包括以下章节:

  • 第一章, 构建您的消息应用 UI

  • 第二章, 设置 WhatsPackt 的消息功能

  • 第三章, 备份您的 WhatsPackt 消息

第一章:为您的消息应用构建 UI

在本章的第一部分,我们将开始构建一个名为 WhatsPackt 的消息应用(指的是您可能已经了解的一个流行的消息应用)。在这个项目的这个阶段,我们必须做出一些重要的技术决策并创建构建它所需的结构。这是我们关注的重点,以及我们将在应用的用户界面上进行工作。

到本章结束时,您将获得从零开始创建消息应用的实际经验,组织并定义应用模块,决定您将使用哪个依赖注入框架,使用 Jetpack Navigation 在应用功能之间导航,以及使用 Jetpack Compose 构建用户界面的主要部分。

本章组织了以下主题:

  • 定义应用结构和导航

  • 构建主屏幕

  • 构建聊天列表

  • 构建消息列表

技术要求

Android Studio 是官方标准的 集成开发环境IDE)用于开发 Android 应用。尽管如果您愿意,可以使用其他 IDE、编辑器和 Android 工具,但本书中的所有示例都将基于此 IDE。

因此,我们建议您使用安装了最新稳定版 Android Studio 的计算机。如果您还没有安装,可以在此处下载:developer.android.com/studio。按照安装步骤,您将能够安装 IDE 并设置至少一个安装了 Android SDK 的模拟器。

安装完成后,我们可以开始创建项目。Android Studio 将提供一系列模板以供选择。我们将选择 Empty Activity 选项,如图下截图所示:

图 1.1:选择 Empty Activity 选项的 Android Studio 新项目模板

图 1.1:选择 Empty Activity 选项的 Android Studio 新项目模板

然后您将被要求选择项目名称和包名称:

图 1.2:Android Studio – 添加新项目名称和包名称

图 1.2:Android Studio – 添加新项目名称和包名称

之后,您就准备就绪了!Android Studio 将生成所需的主要文件夹和文件,以便您可以开始我们的项目。您的项目结构应如下所示:

图 1.3:Android Studio – 项目模板结构

图 1.3:Android Studio – 项目模板结构

注意,本章的所有代码都可以在这个书的 GitHub 仓库中找到:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-1/WhatsPackt

现在,我们已经准备好开始编码我们的新消息应用。为此,我们必须做出一些重要的技术决策:我们必须决定我们的项目将如何结构化,我们将如何在不同屏幕或功能之间导航,以及我们将如何设置和提供所需的组件(定义和组织每个组件之间的依赖关系)。

定义应用结构和导航

在设计应用结构之前,我们必须对它应包含的基本功能有一个基本的概念。在我们的案例中,我们希望有以下功能:

  • 创建新对话或访问已存在的对话的主屏幕

  • 包含所有对话的列表

  • 单个对话的屏幕

由于这是一个即将投入生产的应用,我们必须在设计代码库时考虑到它应该易于扩展和维护。在这方面,我们应该使用模块化。

模块化

模块化是将应用程序的代码划分为松散耦合且自包含的部分的实践,每个部分都可以独立编译和测试。这种技术允许开发者将大型和复杂的应用程序分解成更易于维护的更小部分。

通过模块化 Android 应用程序,模块可以并行构建,这可以显著提高构建时间。此外,独立的模块可以单独测试,这使得识别和纠正错误变得更加容易。

虽然在 Android 开发中创建模块最常见的方法是通过 Gradle 依赖项利用 Android 库系统,但 Bazel 和 Buck 等替代构建系统也促进了模块化。Bazel 提供了一个强大的系统来声明模块和依赖项,其并行构建能力可以导致更快的构建时间。同样,Buck 也通过提供细粒度的构建规则和加速增量构建来支持模块化开发。

通过探索各种构建系统,如 Gradle、Bazel 和 Buck,开发者可以找到最适合其 Android 应用程序的模块化方法。每个构建系统都提供独特的功能来管理依赖项和组织代码,使开发者能够实现各种模式以实现模块化架构。

在组织模式中,最常见的是按层进行模块化和按功能模块进行模块化。

层次模块化

通常,根据开发者选择的架构,通过将组件分组到一系列层中来结构化应用。一个流行的架构是清洁架构,它将代码库分为数据、领域(或业务)和表示层。

采用这种方法,每个模块都专注于架构的特定层,例如表示层、领域层或数据层。这些模块通常彼此之间更加独立,并且可能根据它们所属的层具有不同的责任和技术。遵循此模式,我们的应用结构将如下所示:

图 1.4:按层进行应用模块化

图 1.4:按层进行应用模块化

从这张图中,你可以看到为什么层模块化也被称为垂直模块化。

按功能模块化

当按功能(或使用横向模块化)对应用进行模块化时,应用被划分为专注于特定功能或相关任务的模块,例如身份验证或导航。这些横向模块可以共享公共组件和资源。我们可以在以下图中看到这种结构:

图 1.5:按功能进行应用模块化

图 1.5:按功能进行应用模块化

在我们的案例中,我们将有一个主要的 app 模块,它将依赖于我们应用需要的每个功能模块(每个我们将要实现的功能都有一个)。然后,每个功能模块也将依赖于另外两个公共模块(在这个例子中,我们将它们分为 commoncommon_framework,第一个用于包含与框架无关的代码,第二个用于使用依赖于 Android 框架的代码)。

这种模式的主要优势之一是它可以随着公司的发展而扩展,如果它演变成基于功能的团队(每个团队都专注于单个或一组功能),那么它就可以实现。这将使每个团队负责一个功能模块,或一组功能模块,他们将对这些模块中的代码拥有所有权。这也允许团队在问题空间和功能方面更容易地实现自治。

WhatsPackt 模块化

在我们的 WhatsPackt 示例中,我们将结合两种模块化方法:

  • 我们将使用基于功能的模块化来构建我们的功能。

  • 我们将使用基于层的模块化来构建公共模块。这将允许我们在功能模块之间共享公共代码。

我们模块的结构及其依赖关系如下所示:

图 1.6:我们的应用模块结构和依赖关系

图 1.6:我们的应用模块结构和依赖关系

现在,我们将开始在 Android Studio 中创建这个结构。要创建一个模块,请按照以下步骤操作:

  1. 选择文件 | 新建... | 新建模块

  2. 创建新模块对话框中,选择Android 模板。

  3. 填写模块名称包名称语言字段,如图所示:

图 1.7:创建新模块对话框

图 1.7:创建新模块对话框

  1. 点击完成

我们需要对所有我们想要构建的模块执行此相同的过程,除了 :app 模块,它应该在创建项目时已经创建。这将成为我们进入应用的主要入口点。因此,我们必须创建以下模块:

  • 😗***通用:领域

  • 😗***通用:数据

  • 😗***通用:框架

  • 😗***功能:创建聊天

  • 😗***功能:会话

  • 😗***功能:聊天

一旦我们完成这些,我们应该已经构建了以下项目结构:

图 1.8:项目结构,包括所有模块

图 1.8:项目结构,包括所有模块

下一步是设置模块之间的依赖关系。我们将在每个模块的 build.gradle 文件中执行此操作。例如,在 :app 模块的 build.gradle 文件中,在 dependencies 部分包含以下代码:

dependencies {
    implementation project(':feature:chat')
    implementation project(':feature:conversations')
    implementation project(':feature:create_chat')
    // The rest of dependencies
}

现在我们已经准备好了应用模块,我们可以开始进行下一步:依赖注入。

依赖注入

依赖注入是一种在软件工程中用于解耦应用程序中对象并减少它们之间依赖的设计模式和技巧。在 Android 中,依赖注入涉及向另一个类提供一个类或组件的实例,而不是在类内部显式创建它。

通过在 Android 应用中实现依赖注入,你可以使应用代码更加模块化、可重用和可测试。依赖注入还有助于提高代码库的可维护性并减少应用程序架构的复杂性。

在 Android 开发中使用的最流行的依赖注入库如下:

  • Dagger (dagger.dev/):Dagger 是由 Google 开发的一个编译时依赖注入库,它使用注解和代码生成来创建一个依赖图,该图可用于向应用组件提供依赖。其主要优势是它在编译时构建这个依赖图,而其他库(如 Koin)则在运行时进行。对于大型应用,这可能会导致性能问题。

  • Hilt (dagger.dev/hilt/):Hilt 是一个基于 Dagger 构建的依赖注入库,它为 Android 应用提供了简化依赖注入的方法。它减少了 Dagger 所需的样板代码,并为 Android 特定组件(如活动和片段)提供了预定义的绑定。

  • Koin (insert-koin.io/):Koin 是一个专注于简洁和易用的 Kotlin 轻量级依赖注入库。它使用 领域特定语言DSL)来定义依赖并提供给应用组件,这使得设置和开始使用它变得更加容易。

最终,选择依赖注入库取决于你的具体需求和偏好,Dagger 和 Koin 都是值得考虑的选项,具体取决于你的需求。在这种情况下,我们将使用 Hilt,因为它是目前 Google 的推荐。

在我们的项目中设置 Hilt,请按照以下步骤操作:

  1. 将 Hilt Gradle 插件添加到你的项目级别的 build.gradle 文件中(将 [版本] 替换为你可用的最新版本):

    buildscript {
        repositories {
            google()
        }
        dependencies {
            classpath "com.google.dagger:hilt-android-
                gradle-plugin:[version]"
        }
    }
    
  2. 在你的应用级别的 build.gradle 文件中应用 Hilt Gradle 插件并启用视图绑定:

    apply plugin: 'kotlin-kapt'
    apply plugin: 'dagger.hilt.android.plugin'
    android {
        ...
        buildFeatures {
            viewBinding true
        }
    }
    dependencies {
        implementation "com.google.dagger:hilt-
            android:[version]"
        kapt "com.google.dagger:hilt-android-
            compiler:[version]"
        ...
    }
    
  3. 最后,在我们的 :app 模块中创建一个 Application 类。Application 类作为维护全局应用状态的基础类(这指的是在整个应用生命周期中需要维护的数据或设置)。虽然它不是默认创建的,但创建一个自定义的 Application 类对于初始化任务至关重要,例如设置依赖注入框架或初始化库。在这个特定实例中,为了让 Hilt 正常工作,你应该使用 @HiltAndroidApp 注解来标注你的 Application 类:

    @HiltAndroidApp
    class WhatsPacktApplication : Application() {
        // ...
    }
    

到此,我们已经准备就绪——一旦我们在这个项目中进一步推进,我们将继续定义模块和依赖项。

导航

下一步是决定我们处理应用中屏幕和功能之间导航的方法。需要注意的是,我们将使用 Jetpack Compose 来构建应用的用户界面,因此所选方法必须与之兼容。

在这种情况下,我们将使用 Navigation Compose,因为它提供了一种简单且易于使用的处理 Android 应用内导航的方法。以下是使用 Navigation Compose 的好处:

  • 声明式 UI:Navigation Compose 遵循与 Jetpack Compose 相同的声明式方法,这使得理解和维护应用中的导航流程更加容易。

  • 类型安全:使用 Navigation Compose,你可以以类型安全的方式定义你的导航图和操作。这有助于防止由错误的导航操作名称和参数引起的运行时崩溃。

  • 动画和过渡支持:Navigation Compose 提供了内置的动画屏幕过渡支持,这使得创建平滑且视觉上吸引人的导航体验变得容易。

  • 深度链接:Navigation Compose 支持深度链接,允许你创建可以直接导航到应用中特定屏幕或操作的 URL。这对于实现应用快捷方式、通知或共享内容等功能非常有用。

  • 与 Jetpack Compose 集成:作为 Jetpack Compose 家族的一部分,Navigation Compose 与其他 Compose 库和组件无缝集成,允许你在整个应用中构建一致的 UI 和导航体验。

  • 模块化和可扩展性:Navigation Compose 使您能够构建模块化的导航图,这使得扩展您的应用和管理复杂的导航流程变得更加容易。

总结来说,Navigation Compose 简化了导航管理,提高了我们应用的健壮性,并将帮助我们创建一个更一致、更易访问和更具视觉吸引力的用户体验。

要开始使用 Navigation Compose,我们必须做以下事情:

  1. 首先,我们需要在我们的 Gradle 文件中包含所需的依赖项:

    dependencies {
        implementation "androidx.navigation:navigation-
        compose:2.5.3"
    }
    

注意

在撰写本书时,前一个代码版本是当时最新的稳定版本,但当你阅读本书时,可能已经有一个新版本了。

  1. 接下来,在app模块中,创建一个名为ui.navigation的新包。然后,创建一个名为WhatsPacktNavigation的文件。

  2. 现在,创建一个NavHost可组合项,并提供一个NavController实例。NavHost可组合项充当管理应用中不同可组合项之间导航的容器。它作为中央枢纽,在这里定义导航路由,并根据导航状态切换可组合项。您的应用程序中的每个屏幕或视图都对应于NavHost可以显示的可组合项。在这里,我们将首先创建WhatsPacktNavigation可组合项函数。这将负责持有NavHost

    import androidx.compose.runtime.Composable
    import androidx.navigation.compose.NavHost
    import
    androidx.navigation.compose.rememberNavController
    @Composable
    fun WhatsPacktNavigation() {
        val navController = rememberNavController()
        NavHost(navController = navController,
        startDestination = "start_screen") {
            // Add composable destinations here
        }
    }
    
  3. 一旦我们创建了第一个屏幕(我们将称之为MainScreen),我们将完成NavHost,如下所示:

        NavHost(navController = navController,
        startDestination = "start_screen") {
            composable("start_screen") {
            MainScreen(navController) }
        }
    
  4. 我们还可以在路由中包含动态参数,如下所示:

    NavHost(
        navController = navController,
        startDestination = "start_screen"
    ) {
        composable("start_screen") {
            MainScreen(navController) }
        composable("chat/{chatId}") { backStackEntry ->
            val chatId =
                backStackEntry.arguments?.getString(
                    "chatId")
            ChatScreen(navController, chatId)
        }
    }
    

    在这里,我们有一个第二个可组合项,它定义了与"chat/{chatId}"路由相关联的另一个导航目的地。{chatId}部分是一个动态参数,可以在导航到该目的地时传递。

使用这两个配置——即带有和不带有参数的导航——应该可以满足我们的需求,但由于我们正在使用基于功能的模块化,我们可能会遇到必须从一个模块导航到另一个模块,而它们之间没有直接依赖关系的问题。在这种情况下,我们将使用深度链接。

NavHost,你需要添加一个带有你想要用于该目的地的 URI 模式的deepLink参数。这个模式应该包括一个方案、一个主机和一个可选的路径。在我们的例子中,如果我们有一个ChatScreen,它接受一个chatId参数,我们可以添加一个类似这样的深度链接URI

NavHost(
    navController = navController,
    startDestination = "start_screen")
{
    composable("start_screen") { MainScreen(navController)
    }
    composable(
        route = "chat?id={id}",
        deepLinks = listOf(navDeepLink { uriPattern =
            "whatspackt://chat/{id}" })
    ) { backStackEntry ->
        ChatScreen(
            navController,
            backStackEntry.arguments?.getString("id"))
    }
}

为了使我们的NavHost更简洁,并将路由和 URI 的定义委托给每个屏幕,一个常见的做法是使用常量来定义路由。以下是一个示例:

@Composable
fun ChatScreen(
    ...
) {
    object {
        val uri = "whatspackt://chat/{id}"
        val name = "chat?id={id}"
    }
}

通过这样做,开发者可以轻松地以集中化的方式管理、更新和维护路由。

然后,在NavHost中,我们将使用这些常量来定义uriPattern

composable(
    route = NavRoutes.Chat,
    arguments = listOf(
        navArgument(NavRoutes.ChatArgs.ChatId) {
            type = NavType.StringType
        }
    )
) { backStackEntry ->
    val chatId = backStackEntry.arguments?.getString(
        NavRoutes.ChatArgs.ChatId)
    ChatScreen(chatId = chatId, onBack = {
        navController.popBackStack() })
}

而不是将此信息添加到每个屏幕,更好的选择是创建一个类,我们将把所有的路由常量放在这个类中:

object NavRoutes {
    const val ConversationsList = "conversations_list"
    const val NewConversation = "create_conversation"
    const val Chat = "chat/{chatId}"
    object ChatArgs {
        const val ChatId = "chatId"
    }
}

将路由的定义放在同一个地方将有助于阅读和维护我们的代码,这样我们可以轻松地以集中化的方式管理、更新和维护路由,同时提高代码的可读性并减少由于硬编码或代码库中重复字符串引起的错误的可能性。

我们将把这个包含此类的文件放在我们的 :common:framework 模块中,因为我们需要从每个功能模块访问这些常量。另一个常见的做法是创建一个专门的 :common:navigation 模块,并在其中添加路由定义甚至 NavHost 定义。在我们的情况下,我们将使用最新的方法定义路由——即路由常量:

package com.packt.whatspackt.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.packt.feature.chat.ui.ChatScreen
import androidx.navigation.navArgument
import com.packt.framework.navigation.NavRoutes
@Composable
fun MainNavigation(navController: NavHostController) {
    NavHost(
        navController,
        startDestination = NavRoutes.ConversationsList)
    {
        addConversationsList(navController)
        addNewConversation(navController)
        addChat(navController)
    }
}

在前面的代码中,我们完成了 NavHost 的定义。

在我们的应用中,我们希望导航到应用的三个不同部分(会话列表、创建新聊天和单个聊天屏幕)。可以通过在 NavGraphBuilder 上使用扩展函数将导航目标添加到 NavHost。这些扩展函数定义如下:

private fun NavGraphBuilder.addConversationsList(
    navController: NavHostController
) {
    composable(NavRoutes.ConversationsList) {
        ConversationsListScreen(
            onNewConversationClick = {
                navController.navigate(
                    NavRoutes.NewConversation)
            },
            onConversationClick = { chatId ->
                navController.navigate(
                NavRoutes.Chat.replace("{chatId}", chatId))
            }
        )
    }
}
private fun NavGraphBuilder.addNewConversation(
navController: NavHostController) {
    composable(NavRoutes.NewConversation) {
        CreateConversationScreen(onCreateConversation = {
            navController.navigate(NavRoutes.Chat)
        })
    }
}
private fun NavGraphBuilder.addChat(navController:
NavHostController) {
    composable(
        route = NavRoutes.Chat,
        arguments = listOf(navArgument(
        NavRoutes.ChatArgs.ChatId) {
            type = NavType.StringType
        })
    ) { backStackEntry ->
        val chatId = backStackEntry.arguments?.getString(
            NavRoutes.ChatArgs.ChatId)
        ChatScreen(chatId = chatId, onBack = {
            navController.popBackStack() })
    }
}

这里,addConversationsList(navController) 设置 ConversationsListScreen 并为导航到 NewConversationChat 目标定义了点击监听器。

然后,addNewConversation(navController) 设置 CreateConversationScreen 并在创建新会话时定义了一个点击监听器,用于导航到 Chat 目标。

最后,addChat(navController) 设置 ChatScreen 并从 backStackEntry 中提取 chatId 参数。它还定义了一个点击监听器,用于使用 navController.popBackStack() 返回到上一个屏幕。

现在,我们几乎准备好第一次点击 运行 按钮了。但首先,为了避免编译问题,我们应该在各自的模块中创建屏幕的可组合组件:

  • ConversationsListScreen:feature:conversations

  • CreateConversationScreen:feature:create_chat

  • ChatScreen:feature:chat

例如,我们可以创建 ChatScreen 并将其保留如下:

package com.packt.feature.chat.ui
import androidx.compose.runtime.Composable
@Composable
fun ChatScreen(
    chatId: String?,
    onBack: () -> Unit
) {
}

我们还缺少最后一个更改(目前是这样)。我们需要在 MainActivity 中包含 MainNavigation 可组合组件作为内容:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            WhatsPacktTheme {
                val navHostController =
                    rememberNavController()
                MainNavigation(navController =
                    navHostController)
            }
        }
    }
}

如我们所见,我们添加了 navHostController,这是通过 rememberNav Controller() 创建的。这用于在重组之间记住导航状态。在这里,navHostController 管理应用程序中不同可组合组件之间的导航。然后,使用 navHostController 调用 MainNavigation 可组合组件。

到目前为止,我们已经选择了并创建了应用模块结构,选择了依赖注入框架,添加了所需的依赖项,并结构化了导航,定义了我们的屏幕如何被访问。现在,是我们开始构建构建此应用所需的每个屏幕的时候了。

构建主屏幕

现在我们已经准备好了应用的主要结构,是时候开始构建主屏幕了。

让我们分析一下我们的主屏幕将包含哪些组件:

图 1.9:ConversationsList 屏幕截图

图 1.9:ConversationsList 屏幕截图

如您所见,我们将包括以下内容:

  • 一个顶部栏

  • 一个标签栏,用于导航到主要部分(请注意,本书将仅涵盖聊天部分的开发;我们不会涵盖状态和通话部分)

  • 包含当前对话的列表(我们将在本章后面完成)

  • 一个用于创建新聊天的浮动按钮

让我们从主屏幕开始。

将 Scaffold 添加到主屏幕

之前,我们创建了一个第一个屏幕的空版本(ConversationsListScreen),如下所示:

package com.packt.feature.conversations.ui
import androidx.compose.runtime.Composable
@Composable
fun ConversationsListScreen(
    onNewConversationClick: () -> Unit,
    onConversationClick: (chatId: String) -> Unit
) {
// We will add here the ConversactionsListScreen components
}

现在,是时候开始工作在这个屏幕上了。我们将要添加的第一个组件是 Scaffold,您可以使用它轻松组织应用布局,并在不同屏幕之间保持一致的外观和感觉。

这里是 Scaffold 的主要组件的简要概述:

  • topBar:一个用于放置顶部应用栏的槽位,通常用于显示应用的标题和导航图标。您可以使用 TopAppBar 组合组件来创建顶部应用栏。

  • bottomBar:一个用于放置底部应用栏的槽位,通常用于操作、导航标签或底部导航栏。您可以使用 BottomAppBarTabRow 组合组件来创建底部应用栏。

  • drawerContent:一个用于放置导航抽屉的槽位,这是一个显示应用导航选项的面板。您可以使用 DrawerModalDrawer 组合组件来创建导航抽屉。

  • floatingActionButton:一个用于放置浮动操作按钮的槽位,这是一个悬浮在内容上方的圆形按钮,代表屏幕的主要操作。您可以使用 FloatingActionButton 组合组件来创建浮动操作按钮。

  • 内容:一个用于放置屏幕主要内容的槽位,可以是任何可以显示应用数据或 UI 元素的组合组件。

在我们的案例中,我们将使用 topBarbottomBarTabRow(用于在标签之间导航)、floatingActionButton(用于创建新的聊天),以及内容区域,我们将在这里放置我们的主要内容——在我们的案例中,是聊天列表。

让我们在 ConversationsListScreen 中创建 Scaffold 组合组件。我们将添加所有想要包含的组件的修饰符,但暂时将它们留空:

@Composable
fun ConversationsListScreen(
    onNewConversationClick: () -> Unit,
    onConversationClick: (chatId: String) -> Unit
) {
    Scaffold(
        topBar = { /* TopAppBar code */ },
        bottomBar = { /* TabRow code */ },
        floatingActionButton =
            { /* FloatingActionButton code */ }
    ) {
        /* Content code */
    }
}

我们创建的 Scaffold 组合组件包括 topBarbottomBarfloatingAction Button 和屏幕主要区域的内容。我们将继续实现这些组件中的每一个。

现在,根据您的 Android Studio 版本,您可能会看到以下错误:

图 1.10:内容填充参数错误

图 1.10:内容填充参数错误

这是因为Scaffold可组合组件向内容 Lambda 提供了填充参数。在放置内部组件时,我们需要考虑这个填充,因为如果不考虑,scaffold 可能会覆盖它们。例如,在我们的案例中,我们必须考虑填充,否则我们的内容将保持在bottomBar后面。当我们构建内容时,我们将使用这个参数层。

现在,我们将向Scaffold可组合组件添加一个TopAppBar可组合组件。

TopAppBar可组合组件添加到主屏幕

TopAppBar可组合组件代表屏幕顶部的工具栏,并为应用中不同屏幕提供一致的外观和感觉。它通常显示以下元素:

  • 标题:在应用栏中显示的主要文本,通常代表应用名称或当前屏幕的标题

  • 导航图标:位于应用栏开头的一个可选图标,通常用于打开导航抽屉或导航回应用

  • 操作:一组可选的图标或按钮,位于应用栏的末尾,代表与当前屏幕相关的常见操作或设置

要添加TopAppBar可组合组件,我们必须在模块的strings.xml文件中创建conversations_list_title字符串。

然后,我们将创建一个TopAppBar可组合组件,同时将标题设置为WhatsPackt并添加带有菜单图标的IconButton。在这里,IconButton有一个onClick函数,您可以在按钮被点击时定义要执行的操作:

topBar = {
    TopAppBar(
        title = {
            Text(stringResource(
            R.string.conversations_list_title))
        },
        actions = {
            IconButton(onClick = { /* Menu action */ }) {
                Icon(Icons.Rounded.Menu,
                contentDescription = "Menu")
            }
        }
    )
},

接下来,我们将创建一个TabRow可组合组件。

TabRow可组合组件添加到主屏幕底部

TabRow可组合组件是一个水平排列的标签行,允许用户在应用的不同视图或部分之间导航。TabRow可组合组件主要由以下元素组成:

  • 标签:代表应用中不同部分或视图的Tab可组合组件集合。每个Tab可组合组件可以有一个文本标签、一个图标或两者兼而有之,以描述其内容。

  • 选中标签指示器:一个视觉指示器,突出显示当前选中的标签,使用户易于理解他们正在查看的哪个部分。

在创建TabRow可组合组件之前,我们必须提供一个列表以及它将要包含的标签:

@Composable
fun ConversationsListScreen(
    onNewConversationClick: () -> Unit,
    onConversationClick: (chatId: String) -> Unit
) {
    val tabs = listOf("Status", "Chats", "Calls")
    Scaffold(
        topBar = {
...

然后,我们可以添加TabRow

bottomBar = {
    TabRow(selectedTabIndex = 1) {
        tabs.forEachIndexed { index, tab ->
            Tab(
                text = { Text(tab) },
                selected = index == 1,
                onClick = { /* Navigation action */ }
            )
        }
    }
},

对于每一行,我们都在添加一个Tab可组合组件,其中我们指明标题(使用Text可组合组件),当标签被选中时的选中值,以及onClick操作(我们尚未实现)。

之后,我们可以通过创建一个数据类来存储Tab可组合组件的标题来使我们的代码更易于阅读:

data class ConversationsListTab(
    @StringRes val title: Int
)
fun generateTabs(): List<ConversationsListTab> {
    return listOf(
        ConversationsListTab(
            title = R.string.conversations_tab_status_title
        ),
        ConversationsListTab(
            title = R.string.conversations_tab_chats_title
        ),
        ConversationsListTab(
            title = R.string.conversations_tab_calls_title
        ),
    )
}

然后,我们可以更改我们的TabRow代码:

bottomBar = {
    TabRow(selectedTabIndex = 1) {
        tabs.forEachIndexed { index, _ ->
            Tab(
                text = { Text(stringResource(
                    tabs[index].title)) },
                selected = index == 1,
                onClick = {
                    // Navigate to every tab content
                }
            )
        }
    }
}

TabRow可组合组件通常与分页器结合使用,其中将显示内容。当点击和在不同标签之间导航时,显示的主要内容应该改变。

现在,让我们将分页器添加到我们的屏幕内容中。

添加分页器

分页器是一个 UI 组件,允许用户水平或垂直滑动多个页面或屏幕。它通常用于以类似旋转木马的方式显示屏幕或视图。

我们将使用 HorizontalPager,正如其名称所暗示的,允许用户在屏幕或可组合组件之间水平滑动。其主要优点是它不会一次性创建所有页面;它只会创建当前页面以及立即的上一页和下一页,这些页面将位于屏幕之外。一旦一个页面超出这个三页窗口,它将被移除。

为了做到这一点,我们可能需要调整我们在 ConversationsListScreen 可组合组件中的一些先前代码:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConversationsListScreen(
    onNewConversationClick: () -> Unit,
    onConversationClick: (chatId: String) -> Unit
) {
    val tabs = generateTabs()
    val selectedIndex = remember { mutableStateOf(1) }
    val pagerState = rememberPagerState(initialPage = 1)
    …
}

首先,由于 HorizontalPager 是基础 API 的一部分,并且在编写时是一个实验性 API(这意味着它可能会在未来更改其公共接口),我们需要添加 @OptIn(ExperimentalFoundationApi::class) 注解。

其次,我们添加了一个名为 pagerState 的新字段。它的职责是保持分页器的状态,包括有关页面数量、当前页面、滚动位置和滚动行为的信息。

接下来,我们将按照以下方式将 HorizontalPager 添加到内容函数中:

content = { innerPadding ->
    HorizontalPager(
    modifier = Modifier.padding(innerPadding),
    pageCount = tabs.size,
    state = pagerState
) { index ->
    when (index) {
        0 -> {
            //Status
        }
        1 -> {
            ConversationList(
                conversations = emptyList(),
                onConversationClick = onConversationClick
            )
        }
        2-> {
            // Calls
        }
    }
}
    LaunchedEffect(selectedIndex.value) {
        pagerState.animateScrollToPage(selectedIndex.value)
    }
}

在这里,我们将使用 LaunchedEffect 函数。此函数用于管理副作用,例如在可组合层次结构上下文中异步完成的任务。副作用是可以在可组合函数本身之外产生影响的操作,例如网络请求、数据库操作,或者在上一个示例中,在分页器中滚动到特定页面。

LaunchedEffect 函数接受一个键(或一组键)作为其第一个参数。当键发生变化时,效果将被重新启动,取消之前效果的任何正在进行的工作。第二个参数是一个挂起 Lambda 函数,它将在效果的协程作用域中执行。

使用 LaunchedEffect 的主要优势是它与 Compose 生命周期集成良好。当调用 LaunchedEffect 的可组合组件离开组合时,效果将被自动取消,清理任何正在进行的工作。

回到我们的代码,在我们的情况下,我们正在更改 pagerState 中的当前页面,并动画滚动到下一个选定的页面。这将在 selectedIndex.value 发生变化时触发。

下一个组件将允许用户创建一个新的聊天 - 我们将使用 FloatingActionButton 可组合组件创建此按钮。

添加 FloatingActionButton 可组合组件

FloatingActionButton可组合组件是一个表示在 UI 上方浮动的圆形按钮的 Material Design 可组合组件。它通常用于促进应用中的主要操作(例如,添加新项目、编写消息或启动新流程)。遵循 Material Design 指南(您可以在m3.material.io/中查看),我们将使用它从ConversationsListScreen创建新的聊天:

floatingActionButton = {
    FloatingActionButton(
        onClick = { onNewConversationClick() }
    ) {
        Icon(
            imageVector = Icons.Default.Add,
            contentDescription = "Add"
        )
    }
}

我们的FloatingActionButton可组合组件使用了onClick修饰符。在这里,我们将包含导航到创建聊天屏幕的代码。在这个按钮内部,我们包含了一个Icon可组合组件,我们将其用作Icons.Default预定义图像之一。

到目前为止,我们的对话列表屏幕应该看起来类似于这个:

图 1.11:带有顶部栏、标签栏和浮动操作按钮的对话列表屏幕

图 1.11:带有顶部栏、标签栏和浮动操作按钮的对话列表屏幕

这样,我们就创建了一个包含所有帮助用户导航的元素的Scaffold可组合组件。现在,我们准备好完成屏幕的最后一步(也是最重要的一步):创建现有对话的列表。为此,我们将开始创建一个对话项。

创建对话列表

在本节中,我们将创建展示对话列表所需的所有组件。我们将从 UI 数据模型开始,它将代表应用在列表中将要显示的信息,Conversation可组合组件,它将绘制列表中的每个项目,最后是列表本身的可组合组件。

模拟对话

首先,我们将模拟我们将在对话列表组件中使用的实体:Conversation模型。

作为对话模型的一部分,我们希望显示其他参与者的头像(我们只是进行一对一的对话)、他们的名字、最后一条消息的第一行、接收消息的时间以及表示未读消息数量的数字。

考虑到这些信息,我们将开始创建一个数据类来保存我们需要的数据:

data class Conversation(
    val id: String,
    val name: String,
    val message: String,
    val timestamp: String,
    val unreadCount: Int,
    val avatar: String
)

由于头像可以在整个应用中重复使用,我们将首先创建它。我们可以将其包含在:common:framework模块中,以便它可见并可从其他功能模块中重复使用。

Jetpack Compose 默认不包含从 URL 异步加载图像的支持,但有许多第三方库可以帮助我们完成这项任务。最受欢迎的选项是 Coil 和 Glide,在性能、缓存和图像加载方面相当相似。我们将仅为了简单起见并因为它是 Kotlin 优先(而 Glide 是用 Java 编写的)而使用 Coil。

和往常一样,我们需要在我们的模块的build.gradle文件中包含依赖项:

dependencies {
...
implementation "io.coil-kt:coil-compose:${latest_version}"
...
}

到目前为止,我们已经准备好创建我们的 Avatar 可组合:

@Composable
fun Avatar(
    modifier: Modifier = Modifier,
    imageUrl: String,
    size: Dp,
    contentDescription: String? = "User avatar"
) {
    AsyncImage(
        model = imageUrl,
        contentDescription = contentDescription,
        modifier = modifier
            .size(size)
            .clip(CircleShape),
        contentScale = ContentScale.Crop
    )
}

在这里,我们正在使用 AsyncImage 创建头像,该头像将加载由 URL 提供的图像。此图像将被修改为圆形形状。此外,我们应在使用此可组合时传递图像的大小(我们选择了 50 密度无关像素)。

现在,我们可以创建 ConversationItem

import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.packt.feature.conversations.ui.model.Conversation
import com.packt.framework.ui.Avatar
@Composable
fun ConversationItem(conversation: Conversation) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Avatar(
            imageUrl = conversation.avatar,
            size = 50.dp,
            contentDescription =
                "${conversation.name}'s avatar"
        )
        Spacer(modifier = Modifier.width(8.dp))
        Column {
            Text(
                text = conversation.name,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.fillMaxWidth(0.7f)
            )
            Text(text = conversation.message)
        }
        Spacer(modifier = Modifier.width(8.dp))
        Column(horizontalAlignment = Alignment.End) {
            Text(text = conversation.timestamp)
            if (conversation.unreadCount > 0) {
                Text(
                    text =
                    conversation.unreadCount.toString(),
                    color = MaterialTheme.colors.primary,
                    modifier = Modifier.padding(top = 4.dp)
                )
            }
        }
    }
}

让我们更仔细地看看这个可组合做了什么:

  • ConversationItem 可组合接受以下参数:名称、消息、时间戳、头像 URL,以及一个具有默认值 0 的可选 未读消息

  • 使用 Row 布局来水平排列内容。它具有 fillMaxWidth() 修饰符,以占用父级的全部宽度,并具有 8 密度无关像素的填充。

  • Avatar 可组合用于显示头像。我们已经知道它是如何工作的——唯一需要指出的是,我们希望它的大小为 50 密度无关像素。

  • 添加一个宽度为 8 密度像素的 Spacer 可组合,以在头像和文本内容之间提供一些空间。

  • 使用 Column 布局来垂直排列名称和消息文本。Column 布局具有 Modifier.weight() 修饰符,确保它占据头像和时间戳之间的所有可用空间。

  • Column 布局内部,使用 Text 可组合以粗体字体重量和 16 尺度无关像素的字体大小显示名称。另一个 Text 可组合用于显示消息,最多一行,并带有省略号溢出。

  • 在主 Row 布局中添加另一个 Column 布局来垂直排列时间戳和未读消息徽章。Column 布局的 horizontalAlignment 值为 Alignment.End,以将其子项对齐到可用空间的末尾。

  • 在这个第二个 Column 布局内部,使用 Text 可组合以 12 尺度无关像素的字体大小和灰色显示时间。

  • 条件语句检查是否有任何未读消息(即 conversation.unreadMessages > 0)。如果有未读消息,未读消息计数将显示一个带有圆形背景的文本,该背景使用 drawBehind 修饰符绘制。

现在我们有了 ConversationItem 可组合,是时候完成这个屏幕了。让我们创建 ConversationList 可组合!

创建 ConversationList 可组合

作为此屏幕的最后一步,我们将创建对话列表:

@Composable
fun ConversationList(conversations: List<Conversation>) {
    LazyColumn {
        items(conversations) { conversation ->
            ConversationItem(
                conversation = conversation
            )
        }
    }
}

ConversationList 可组合接受一个 Conversation 对象列表,并使用 LazyColumn 高效地显示它们。items 函数用于遍历对话列表,并为每个对话渲染 ConversationItem

最后,我们将此列表包含在 HorizontalPager 逻辑中,在我们的 ConversationsListScreen 可组合中:

HorizontalPager(
    modifier = Modifier.padding(innerPadding),
    pageCount = tabs.size,
    state = pagerState
) { index ->
    when (index) {
        0 -> {
            //Status
        }
        1 -> {
            ConversationList(
                conversations = emptyList(), // Leaving the
                                                list empty
                                                for now
                onConversationClick = onConversationClick
            )
        }
        2-> {
            // Calls
        }
    }
}

如果我们想测试它,我们可以伪造对话的数据:

fun generateFakeConversations(): List<Conversation> {
    return listOf(
        Conversation(
            id = "1",
            name = "John Doe",
            message = "Hey, how are you?",
            timestamp = "10:30",
            avatar = "https://i.pravatar.cc/150?u=1",
            unreadCount = 2
        ),
        Conversation(
            id = "2",
            name = "Jane Smith",
            message = "Looking forward to the party!",
            timestamp = "11:15",
            avatar = "https://i.pravatar.cc/150?u=2"
        ),
//Add more conversations here

注意,在这里,我正在使用一个随机头像生成器,只是为了使其尽可能接近我们将这个 UI 与真实对话连接时的样子。

以下截图显示了我们的应用在更多对话中的样子:

图 1.12:ConversationsList 屏幕完成

图 1.12:ConversationsList 屏幕完成

现在,让我们切换到聊天屏幕,也称为消息列表。与显示所有对话的对话列表不同,消息列表将显示我们与一个用户(单个聊天屏幕)的对话列表。

构建消息列表

在本节中,我们将创建创建聊天屏幕和两个用户可能交换的消息所需的 UI 模型。然后,我们将创建Message可组合组件,最后是屏幕的其余部分,包括消息列表。

模拟 Chat 和 Message 模型

考虑到我们在聊天屏幕上需要显示的信息,我们将需要两个数据模型:一个用于与对话相关的静态数据(例如,我们正在与之交谈的用户的姓名、他们的头像等),以及每个消息一个数据模型。这将作为Chat模型的模型:

data class Chat(
    val id: String,
    val name: String,
    val avatar: String
)

在这种情况下,我们需要聊天 ID、我们正在与之交谈的人的姓名和他们的头像地址。

关于Message模型,我们将创建以下类:

data class Message(
    val id: String,
    val senderName: String,
    val senderAvatar: String,
    val timestamp: String,
    val isMine: Boolean,
    val messageContent: MessageContent
)
sealed class MessageContent {
    data class TextMessage(val message: String) :
        MessageContent()
    data class ImageMessage(val imageUrl: String,
        val contentDescription: String) : MessageContent()
}

在这种情况下,我们需要发送者的姓名、他们的头像、时间戳、消息是否是我的(我们将考虑这一点,以便我们可以将消息排列在左边或右边),以及消息的内容。

由于我们的应用将有两种类型的内容(消息和图片),我们需要两种不同类型的MessageContent。这就是为什么它被建模为一个密封类。我们有两个数据类,包含每种类型消息内容所需的数据。

现在,我们需要将这些模型转换为一些可组合组件。

创建 MessageItem 可组合组件

MessageItem可组合组件将绘制我们聊天中的每条消息。

首先,我们将创建一个Row布局。我们将根据消息的作者设置行内容的排列:

@Composable
fun MessageItem(message: Message) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = if (message.isMine)
        Arrangement.End else Arrangement.Start
    ) {
...
}
}

然后,在这个行内,我们将放置消息的其余组件。我们将从头像开始;如果消息不是来自用户,我们将只显示头像:

if (!message.isMine) {
    Avatar(
        imageUrl = message.senderAvatar,
        size = 40.dp,
        contentDescription = "${message.senderName}'s
                              avatar"
    )
    Spacer(modifier = Modifier.width(8.dp))
}

然后,我们将添加一个Column布局,以便我们可以安排剩余的消息信息:

Column {
    if (message.isMine) {
        Spacer(modifier = Modifier.height(8.dp))
    } else {
        Text(
            text = message.senderName,
            fontWeight = FontWeight.Bold
        )
    }
    when (val content = message.messageContent) {
        is MessageContent.TextMessage -> {
            Surface(
                shape = RoundedCornerShape(8.dp),
                color = if (message.isMine)
                MaterialTheme.colors.primarySurface else
                MaterialTheme.colors.secondary
            ) {
                Text(
                    text = content.message,
                    modifier = Modifier.padding(8.dp),
                    color = if (message.isMine)
                    MaterialTheme.colors.onPrimary else
                    Color.White
                )
            }
        }
        is MessageContent.ImageMessage -> {
            AsyncImage(
                model = content.imageUrl,
                contentDescription =
                content.contentDescription,
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape),
                contentScale = ContentScale.Crop
            )
        }
    }
    Text(
        text = message.timestamp,
        fontSize = 12.sp
    )
}

消息将包含以下信息:

  • 发送者姓名(如果作者不是当前用户):为了知道消息是否来自当前用户,应用将检查消息的作者是当前用户使用if (message.isMine)。如果是肯定的,我们将添加一个Space可组合组件;如果消息作者不是当前用户,我们将显示Text可组合组件和他们的名字。

  • 内容:如果内容是文本,则应用将显示包含文本的气泡。气泡的颜色将取决于消息的发送者是否是当前用户以及消息创建的时间。将在消息底部显示创建日期和时间的戳记。

现在我们有了MessageItem,是时候创建聊天屏幕的其余部分了。

添加 TopAppBar 和 BottomRow 可组合组件

正如我们对对话列表所做的那样,我们将添加Scaffold结构和其TopAppBarBottomRow可组合组件到这个屏幕:

@Composable
fun ChatScreen(
    chatId: String?,
    onBack: () -> Unit
) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(stringResource(
                    R.string.chat_title, "Alice"))
                }
            )
        },
        bottomBar = {
            SendMessageBox()
        }
    ) { paddingValues->
        ListOfMessages(paddingValues = paddingValues)
    }
}

注意,我们正在硬编码聊天标题。这只是为了预览目的;我们将在下一章中纠正这一点。

在底部栏的情况下,我们正在添加一个新的可组合组件,该组件将包含Textfield和发送消息所需的发送按钮。这就是这个可组合组件的外观:

@Composable
fun SendMessageBox() {
    Box(
        modifier = Modifier
            .defaultMinSize()
            .padding(top = 0.dp, start = 16.dp,
                end = 16.dp,
            bottom = 16.dp)
            .fillMaxWidth()
    ) {
        var text by remember { mutableStateOf("") }
        OutlinedTextField(
            value = text,
            onValueChange = { newText -> text = newText },
            modifier = Modifier
                .fillMaxWidth(0.85f)
                .align(Alignment.CenterStart)
                .height(56.dp),
        )
        IconButton(
            modifier = Modifier
                .align(Alignment.CenterEnd)
                .height(56.dp),
            onClick = {
                // Send message here
                text = ""
            }
        ) {
            Icon(
                imageVector = Icons.Default.Send,
                tint = MaterialTheme.colors.primary,
                contentDescription = "Send message"
            )
        }
    }
}

在这里,我们创建了一个Box可组合组件来相应地排列子可组合组件(左边的文本字段和右边的Send按钮)。然后,我们定义了一个名为text的属性来存储文本字段的变化,并使用remember代理来记住其重组之间的最后一个值。

如前述代码块所示,此可组合组件的主要组件如下:

  • OutlinedTextField:用于编写消息。它将从文本属性中获取其值,并在文本字段值每次更改时修改它。

  • IconButton:用于发送消息。它的onClick参数目前没有任何作用(除了重启text属性值)。我们将在下一章中进行配置。

有了这个,我们的聊天屏幕几乎准备好了。我们最后需要做的是添加消息列表。

添加消息列表

之前,我们曾将消息列表作为可组合组件添加到Scaffold可组合组件的content参数中。这个可组合组件将如下所示:

@Composable
fun ListOfMessages(paddingValues: PaddingValues) {
    Box(modifier = Modifier
        .fillMaxSize()
        .padding(paddingValues)) {
        Row(modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
        ) {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize(),
                verticalArrangement =
                    Arrangement.spacedBy(8.dp),
            ) {
                items(getFakeMessages()) { message ->
                    MessageItem(message = message)
                }
            }
        }
    }
}

有了这个,我们添加了LazyColumn,它将显示列表——列表中的每个项目都是一个MessageItem可组合组件。

由于我们还没有将其连接到任何类型的数据源,我们正在使用一个函数来生成一个仅用于预览目的的假消息列表:

fun getFakeMessages(): List<Message> {
    return listOf(
        Message(
            id = "1",
            senderName = "Alice",
            senderAvatar =
                "https://i.pravatar.cc/300?img=1",
            isMine = false,
            timestamp = "10:00",
            messageContent = MessageContent.TextMessage(
                message = "Hi, how are you?"
            )
        ),
        Message(
            id = "2",
            senderName = "Lucy",
            senderAvatar =
                "https://i.pravatar.cc/300?img=2",
            isMine = true,
            timestamp = "10:01",
            messageContent = MessageContent.TextMessage(
                message = "I'm good, thank you! And you?"
            )
        ),
)
}

如果你想添加更多消息,可以通过将它们添加到getFakeMessages()函数内部创建的列表中来实现。

最后,我们应该有一个看起来像这样的屏幕:

图 1.13:聊天屏幕 UI 完成

图 1.13:聊天屏幕 UI 完成

有了这个,我们现在已经完成了用户界面。我们将在接下来的两章中继续开发这个应用!

摘要

在本章的第一部分,我们开始了我们的第一个项目,WhatsPackt,一个消息应用。

我们完成了构建此应用的一些初始任务,例如组织模块、准备依赖注入和导航、构建主屏幕、创建对话列表和构建消息列表。

在整个过程中,我们学习了模块化以及组织模块的各种方法。我们还了解了用于管理依赖注入的流行库,如何初始化它们,以及如何设置 Compose 导航。此外,我们还熟悉了使用 Jetpack Compose 来创建我们的用户界面。

随着我们继续前进,是时候给我们的聊天添加一些活力和生命力了。在下一章中,我们将探讨如何检索和发送消息,并将它们集成到我们最近创建的用户界面中。

第二章:设置 WhatsPackt 的消息功能

在上一章中,我们创建了所需的 WhatsPackt 消息应用的结构和 UI。

任何消息应用的核心功能之一是能够促进两个用户之间的 1:1 对话,因此在本章中,我们将深入研究将我们的消息应用连接到后端服务器使用 WebSocket 的过程,处理ViewModel实例内的消息,以及管理同步、错误处理和推送通知。

我们将首先探索WebSocket,这是一种强大的技术,它允许客户端和服务器之间进行双向通信,为你的应用中的实时消息提供坚实的基础。你将学习如何建立 WebSocket 连接,发送消息,并处理来自服务器的接收到的消息。

接下来,我们将演示如何在你的ViewModel中接收消息。我们将讨论更新 UI、管理消息存储和处理用户交互的最佳实践,确保为用户提供流畅和响应式的消息体验。

本章还将涵盖同步和错误处理的必要方面。你将学习如何管理消息投递状态,处理间歇性连接问题,并优雅地从错误中恢复,从而实现一个弹性可靠的消息系统。

最后,我们将深入探讨推送通知这一主题,这对于在应用不在前台时提醒用户新消息至关重要。

到本章结束时,你将全面了解创建支持使用 WebSocket 和推送通知的 1:1 对话的现代消息应用所涉及的关键组件和技术。

因此,在本章中,我们将涵盖以下主题:

  • 使用 WebSocket 连接

  • 在我们的ViewModel中接收消息

  • 处理同步和错误

  • 添加推送通知

  • 用 Firestore 替换 WebSocket

技术要求

如前一章所述,你需要安装 Android Studio(或你偏好的其他编辑器)。

我们还假设你已经跟随了上一章的内容。如果你还没有,你可以从这里下载上一章的完整代码:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-1/WhatsPackt.

本章中完成的部分代码也可以在此链接中找到:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-2/WhatsPackt.

使用 WebSocket 连接

正如所述,WebSockets 是一种强大的技术,它使客户端和服务器之间实现双向通信。在本节中,我们将使用 WebSocket 连接与我们的服务器连接,以获取和发送消息。但在我们这样做之前,了解替代方案以及选择 WebSockets 作为我们的消息应用的理由是至关重要的。

为什么选择 WebSockets?

实现客户端和服务器之间实时通信有几种选择,包括以下几种:

  • 长轮询: 这是指客户端向服务器发送请求,服务器在新的数据可用之前保持请求。一旦服务器用新数据响应,客户端发送另一个请求,然后这个过程重复。

  • 服务器发送事件(SSE): SSE 是一种单向通信方法,其中服务器通过单个 HTTP 连接向客户端推送更新。

  • 实时云数据库(例如,Firebase Firestore):实时云数据库提供了一个易于使用、可扩展的解决方案,用于实时数据同步。它们在数据发生变化时自动向客户端推送更新,这使得它们适用于消息应用。

  • WebSockets: WebSockets 提供客户端和服务器之间通过单一、持久连接的全双工、双向通信。它们在各个平台上得到广泛支持,是消息应用中实时通信的理想选择。

考虑这些替代方案,我们选择在我们的消息应用中使用 WebSockets,因为它们提供了以下优势:

  • 双向通信: WebSockets 允许客户端和服务器之间同时进行数据传输,从而实现更快的消息交换和更响应式的用户体验。

  • 低延迟: 与长轮询、SSE 和一些实时云数据库不同,WebSockets 提供低延迟通信,这对于实时消息应用至关重要。

  • 资源的高效利用: WebSockets 为每个客户端维护一个单一连接,与长轮询相比,这减少了客户端和服务器上的开销。

  • 灵活性和控制性: 实现自定义 WebSocket 通信允许对消息基础设施有更精细的控制,避免了由实时云数据库强加的潜在限制或约束。

当然,WebSockets 也有其缺点,我们必须考虑,如下所示:

  • 电池和数据使用: 维持持久连接可能导致电池消耗增加和数据使用量增加,这可能会成为移动用户的担忧。

  • 复杂性: 实现 WebSocket 通信通常比使用 RESTful 服务更复杂。您必须处理各种场景,例如在网络变化时进行重新连接,这在移动环境中很常见。

  • 可扩展性: 如果您的应用程序扩展到大量用户,维护所有用户的 WebSocket 连接可能会在服务器端消耗大量资源。

虽然有一些缺点,但使用 WebSocket 的优势——如实时双向通信和与传统 HTTP 轮询相比的更低开销——显著超过了这些问题,使其成为交互式应用程序的一个强大选择。

让我们开始学习如何集成 WebSocket。

集成 WebSocket

在 Android 应用程序中,有多个库可用于集成 WebSocket。以下是一些流行的选项:

  • OkHttp:一个流行的 Android 和 Java 应用程序 HTTP 客户端,也支持 WebSocket 通信

  • Scarlet:一个基于 OkHttp 的 Kotlin 和 Java 应用程序的声明式 WebSocket 库

  • Ktor:一个基于 Kotlin 的现代框架,用于构建异步服务器和客户端,包括 WebSocket 支持

对于我们的应用程序,我们将使用 Ktor,因为它易于使用,对 Kotlin 有原生支持,并且有广泛的文档。

什么是 Ktor?

Ktor 因其基于协程的架构而脱颖而出,这允许进行非阻塞的异步操作,使其特别适合网络通信等 I/O 密集型任务。它轻量级且模块化,允许开发者选择和选择他们需要的功能,从而避免不必要的功能的开销。

该框架建立在协程之上,这是 Kotlin 中的一个特性,可以使你的代码更简洁、更易读,并通过允许函数在稍后时间暂停和恢复来简化异步编程。这提供了一种处理并发的方式,与传统的回调机制相比,语法更直接、更易于表达。

Ktor 多功能,支持服务器端和客户端开发。在服务器端,它可以用来构建健壮且可扩展的 Web 应用程序和服务。在客户端,它提供了一个多平台 HTTP 客户端,可以在 Android 上使用,允许无缝与 Web 服务交互。

Ktor 的 WebSocket 客户端允许轻松设置和管理 WebSocket 连接,处理诸如连接生命周期、错误处理和信息处理等复杂性。它的领域特定语言DSL)提供了一种简洁且易于表达的方式来定义 WebSocket 交互的行为,使代码更易于阅读和维护。

集成 WebSocket 与 Ktor

要在 Android 应用程序中集成 Ktor,请按照以下步骤操作:

  1. 在我们应用程序的 :feature:chat 模块的 build.gradle 文件中,为 WebSocket 客户端添加以下 Ktor 依赖项。确保将 $ktor_version 替换为最新版本(对于本书中的示例,我们使用版本 2.2.4):

    dependencies {
        implementation "io.ktor:ktor-client-
            websockets:2.2.4"
        implementation "io.ktor:ktor-client-okhttp:2.2.4"
    }
    

    每个依赖项都有其独特的作用:

    • io.ktor:ktor-client-websockets: 这个依赖项提供了管理我们应用程序中 WebSocket 连接所需的功能。它包括对打开、向 WebSocket 服务器发送消息和从 WebSocket 服务器接收消息的高级抽象,以无缝的方式促进实时数据交换。通过使用这个库,我们可以轻松实现 WebSocket 通信,无需手动处理复杂的底层协议和握手。

    • io.ktor:ktor-client-okhttp: 虽然 Ktor 是一个多平台框架,但它需要一个引擎来处理网络请求。这个依赖项将 OkHttp 集成为我们应用程序中处理 HTTP 请求和响应的底层引擎。OkHttp 支持 WebSocket,以及其强大的 HTTP 客户端功能,提供高效的网络操作、连接池和强大的接口,用于发送和拦截请求。

    一起,这些依赖项使我们的应用程序能够利用 WebSocket 进行实时通信,并利用 OkHttp 的高效网络功能。这种组合对于需要维护持久连接和管理高频数据交换的应用程序特别强大,例如消息应用或实时数据流。

  2. 在您的AndroidManifest.xml文件中,添加访问互联网所需的权限,因为我们需要它来连接 WebSocket 和接收/发送消息:

    <uses-permission android:name=
        "android.permission.INTERNET" />
    

我们现在已经在项目中包含了这个库。由于我们将使用 Ktor 与 Kotlin Flow,在深入 WebSocket 实现之前,让我们先介绍它。

了解 Kotlin Flow

Flow 是 Kotlin 协程库的一部分,它是一种可以按顺序发出多个值的类型,与只返回单个值的挂起函数相反。Flow 建立在协程的基础概念之上,提供了一种声明式的方式来处理异步数据流。

与 Kotlin 中的序列不同,序列是同步和阻塞的,Flow 是异步和非阻塞的。这使得 Flow 非常适合处理可以异步观察和收集的连续数据流,例如 WebSocket 的实时消息。

当将 Flow 与 Ktor WebSockets 集成时,我们可以创建一个强大的组合,其中 WebSocket 消息作为数据流发出,可以使用所有 Flow 操作符进行处理。它允许以干净、响应式的方式处理 WebSocket 的传入和传出消息。

例如,在聊天应用程序中,来自 WebSocket 的传入消息可以表示为字符串流。应用程序可以收集这个流来相应地更新 UI。同样,生成传出消息的用户操作可以收集并通过 WebSocket 连接发送。

Flow API 非常简单易用。例如,想象我们有一个发出三个字符串的流:

fun main() = runBlocking {
    // Define a simple flow that emits three strings
    val helloFlow = flow {
        emit("Hello")
        emit("from")
        emit("Flow!")
    }
    // Collect and print each value emitted by the flow
    helloFlow.collect { value ->
        println(value)
    }
}

在此代码块中,helloFlow是通过flow构建器定义的,依次发出三个字符串。

注意

除了 flow 之外,还有几个其他的构建器,例如 flowOf,它从一个值集中创建一个流,或者 toFlow(),它从一个集合中创建一个流。

然后在 helloFlow 上调用 collect() 函数。它作为一个订阅者,对每个发出的值做出反应,并打印出来。

如果你运行这段代码,你应该看到以下输出:

Hello
from
Flow!

现在我们对 Kotlin Flow 有点熟悉了,我们准备进行下一步:使用 Ktor 和 Flow 构建我们的 WebSocket 实现。由于它将是提供消息给我们的应用的数据源之一,我们将称之为 WebsocketDataSource

实现 WebSocketDataSource

要实现 WebSocket 数据源,我们首先将创建一个 HttpClient 实例。HttpClient 是一个 Ktor 类,允许你进行 HTTP 请求并管理网络连接。在 WebSocket 的情况下,它负责在客户端和服务器之间建立和维护连接。

要创建一个具有 WebSocket 支持的 HttpClient 实例,我们将在 feature.chat.data.network 包中创建一个名为 WebSocketClient 的新文件(你需要创建数据和网络包,因为它们还不存在)并包含以下代码:

object WebsocketClient {
    val client = HttpClient(OkHttp) {
        install(WebSockets)
    }
}

在这里,我们使用 OkHttp 引擎创建一个 HttpClient 实例,然后安装 WebSockets 插件以启用 WebSocket 支持。

注意

在 Ktor 中,插件(也称为功能)是扩展 Ktor 应用程序功能的功能模块化组件。插件可以安装在客户端和服务器端以提供额外的功能,例如身份验证、日志记录、序列化或自定义行为。Ktor 的基于插件的架构鼓励轻量级和模块化的方法,允许你只将必要的组件包含在应用程序中。

然后,我们将创建我们的 MessagesSocketDataSource 类(在同一个包中)。

要开始创建我们的 WebSocket,我们需要一个 WebSocketSession 实例。WebSocketSession 代表客户端和服务器之间单一的 WebSocket 连接,提供发送和接收消息的方法,以及管理连接的生命周期。在我们的实现中,当我们调用 connect() 方法时,将创建一个 WebSocketSession 实例,如下所示:

class MessagesSocketDataSource @Inject constructor(
    private val httpClient: HttpClient,
) {
    private lateinit var webSocketSession:
        DefaultClientWebSocketSession
    suspend fun connect(url: String): Flow<Message>{
        return httpClient.webSocketSession { url(url) }
            .apply { webSocketSession = this }
            .incoming
            .receiveAsFlow()
            .map{ frame ->
                webSocketSession.handleMessage(frame) }
            .filterNotNull()
            .map { it.toDomain() }
    }
//...
}

让我们分解一下这段代码将要做什么:

  • suspend fun connect(url: String): Flowconnect 函数被定义为接受一个类型为 Stringurl 参数并返回一个 Flow 实例的挂起(suspend)函数。Flow 是 Kotlin 中用于以响应式方式处理数据的冷异步流(冷流是只有当有消费者连接时才会发出消息的流)。

  • httpClient.webSocketSession { url(url) }:这一行使用 httpClient 通过调用 webSocketSession 函数并传递一个设置会话 URL 为提供的 URL 的 lambda 表达式来创建一个 WebSocket 会话。

  • .apply { webSocketSession = this }:这一行使用 webSocketSession 属性中的 apply 函数存储新创建的 WebSocket 会话。我们还需要将其存储起来,因为我们稍后会需要这个会话来发送消息。

  • .incoming:这一行访问 webSocketSessionincoming 属性。incoming 属性是一个通道,它从 WebSocket 服务器接收 Frame 对象。

  • .receiveAsFlow():这一行将传入的通道转换为 Flow 实例,以便可以使用 Flow API 进行处理。

  • .map { frame -> webSocketSession.handleMessage(frame) }:这一行将每个传入的 Frame 对象映射到调用 handleMessage 函数的结果。我们将在稍后定义 handleMessage 函数。

  • .filterNotNull****():这一行从流中过滤掉任何 null 值,确保只有非 null 值被进一步处理。

  • .map { it.toDomain() }:这一行将每个非 null 值映射到调用 toDomain() 函数的结果。这个函数将当前数据相关对象映射到我们很快将创建的域 Message 模型。

在处理和消息之前,我们还想向我们的 WebSocket 数据源添加两个额外的函数:

  • 我们还想要一个函数来发送消息,因为我们希望我们的用户能够向他们的 WhatsPackt 朋友发送消息。

  • 我们还想要一个函数来断开 WebSocket 连接,因为当它不再使用时,我们应该从服务器断开连接。

我们可以像这样添加它们:

suspend fun sendMessage(message: String) {
    webSocketSession.send(Frame.Text(message))
}
suspend fun disconnect() {
    webSocketSession.close(CloseReason(
        CloseReason.Codes.NORMAL, "Disconnect"))
}

当 WebSocket 连接关闭时,它会伴随着一个 CloseReason 类,该类包含一个代码和一个可选的描述性文本。代码表示连接关闭的原因,例如正常关闭、协议错误或不支持的数据。在我们的实现中,我们使用 CloseReason 类以正常关闭关闭 WebSocketSession

一些常见的 CloseReason 代码包括以下内容:

  • CloseReason.Codes.NORMAL:表示连接的正常关闭。这是当用户不再使用聊天界面时将提供的理由。

  • CloseReason.Codes.GOING_AWAY:表示服务器正在离开或关闭。

  • CloseReason.Codes.PROTOCOL_ERROR:表示 WebSocket 协议中发生了错误。

  • CloseReason.Codes.UNSUPPORTED_DATA:表示接收到的数据类型不受支持。

现在我们知道了如何关闭我们的 WebSocket 连接,我们需要定义 handleMessages 扩展函数来处理连接存活期间的所有消息:

private suspend fun
DefaultClientWebSocketSession.handleMessage(frame: Frame):
WebsocketMessageModel? {
    return when (frame) {
        is Frame.Text -> converter?.deserialize(frame)
        is Frame.Close -> {
            disconnect()
            null
        }
        else -> null
    }
}

在 WebSocket 协议中,数据以离散的单位(称为帧)进行传输。Ktor 提供了一个 Frame 类来表示这些单位,每个帧类型都有一个不同的子类,例如 Frame.TextFrame.BinaryFrame.PingFrame.Close

在我们的例子中,我们只处理 Frame.TextFrame.Close 消息。为了接收 Frame.Close 消息,我们将关闭 WebSocket(目前是这样 – 未来,我们可能在这里进行重试或向用户反馈问题)。然后,为了接收 Frame.Text 消息,我们将进行 deserialize 描述的这个转换。

我们可以在 WebSocket 中配置一个转换器,使我们能够轻松反序列化我们的消息。首先,我们需要向我们的 build.gradle 文件中添加新的依赖项:

implementation("io.ktor:ktor-serialization-kotlinx-json:2.2.4)

然后,我们准备在 WebSocket 插件中设置 contentConverter

object WebsocketClient {
    val client = HttpClient(OkHttp) {
        install(WebSockets) {
            contentConverter =
               KotlinxWebsocketSerializationConverter(Json)
        }
    }
}

在此情况下,我们正在配置 JSON 格式的 kotlinx.serialization 转换器(也有其他标准的转换器可用,例如 XML、Protobuf 和 CBOR)。

此外,我们必须为那些我们希望由转换器反序列化的数据类添加 @Serializable 注解。在我们的例子中,我们将创建一个 WebsocketMessageModel 类,如下所示:

@Serializable
class WebsocketMessageModel(
    val id: String,
    val message: String,
    val senderName: String,
    val senderAvatar: String,
    val timestamp: String,
    val isMine: Boolean,
    val messageType: String,
    val messageDescription: String
)

我们流程链中的最后一步是将 WebsocketMessageModel 类转换为领域。由于我们还没有领域模型,我们应该首先创建它:

data class Message(
    val id: String,
    val senderName: String,
    val senderAvatar: String,
    val timestamp: String,
    val isMine: Boolean,
    val contentType: ContentType,
    val content: String,
    val contentDescription: String
) {
    enum class ContentType {
        TEXT, IMAGE
    }
}

现在,我们可以将映射器实现为 WebsocketMessageModel 类的一个函数:

@Serializable
class WebsocketMessageModel(
    val id: String,
    val message: String,
    val senderName: String,
    val senderAvatar: String,
    val timestamp: String,
    val isMine: Boolean,
    val messageType: String,
    val messageDescription: String
) {
    companion object {
        const val TYPE_TEXT = "TEXT"
        const val TYPE_IMAGE = "IMAGE"
    }
    fun toDomain(): Message {
        return Message(
            id = id,
            content = message,
            senderAvatar = senderAvatar,
            senderName = senderName,
            timestamp = timestamp,
            isMine = isMine,
            contentDescription = messageDescription,
            contentType = toContentType()
        )
    }
    fun toContentType(): Message.ContentType {
        return when(messageType) {
            TYPE_IMAGE -> Message.ContentType.IMAGE
            else -> Message.ContentType.TEXT
        }
    }
}

在这里,我们添加了 toDomain() 函数,该函数将当前的 WebsocketMessageModel 类映射到 Message 模型。请注意,数据模型中的几乎所有字段都与我们的领域 Message 模型中的字段相似。一个主要的例外是 messageType 字段,我们必须将其转换为领域 Message 模型中使用的枚举。为了简化这种转换,我们使用了 toContentType() 函数,该函数专门将 messageTypeString 对象转换为 ContentType 枚举。

我们还需要将领域 Message 对象转换为 WebsocketMessageModel 类。为此,我们需要向 WebsocketMessageModel 类中添加一个新函数:

companion object {
    const val TYPE_TEXT = "TEXT"
    const val TYPE_IMAGE = "IMAGE"
    fun fromDomain(message: Message): WebsocketMessageModel {
        return WebsocketMessageModel(
            id = message.id,
            message = message.content,
            senderAvatar = message.senderAvatar,
            senderName = message.senderName,
            timestamp = message.timestamp,
            isMine = message.isMine,
            messageType = message.fromContentType(),
            messageDescription = message.contentDescription
        )
    }
}

这里,我们将 Message 领域对象转换为 WebsocketMessageModel 类。

然后,在 send 函数中,我们将按以下步骤进行:

suspend fun sendMessage(message: Message) {
    val websocketMessage =
        WebsocketMessageModel.fromDomain(message)
    webSocketSession.converter?
        .serialize(websocketMessage)?.let
    {
        webSocketSession.send(it)
    }
}

通过对 sendMessage 函数的这些更改,我们现在接收一个领域模型对象,将其转换为 WebsocketMessageModel,并将其最终序列化为 Frame 对象并通过我们的 WebSocket 发送。

下一步是将此组件(MessagesWebsocketDataSource)与 ViewModel 连接起来,该 ViewModel 将负责向视图提供视图状态,以便它可以相应地渲染。

在我们的 ViewModel 中接收消息

我们的应用程序现在可以接收和发送 WebSocket 消息。现在,我们需要让它们达到我们在上一章中创建的 UI。我们将在本节中这样做,但首先,我们需要考虑实现该功能所需架构和组件。

理解 Clean Architecture 的实现

在上一章中,我们对应用程序进行了模块化,并讨论了使用基于清洁架构的结构来组织我们的通用和功能模块。我们已经创建了该架构的第一个组件MessagesWebsocketDataSource,但理解这种组织背后的原因以及每个组件在架构中扮演的角色是很重要的。

关于为什么以及如何将清洁架构原则应用于 Android 应用程序,有大量的书籍、文章和视频,甚至包括谷歌的官方文档。在这里,我们将提供简短的描述,然后将其分解为其各个层。

清洁架构是一种促进代码组织成具有明确责任层的架构模式,使应用程序更加模块化、可维护、可测试和可扩展。使用清洁架构的关键好处如下:

  • 关注点分离(SoC):清洁架构将代码组织成具有特定责任的独立层,确保每一层处理应用程序的单独方面。这种 SoC 导致代码库更加模块化和可维护,使其更容易理解、修改和扩展。

  • 可测试性:通过将不同的关注点分离成独立的层,可以更容易地在隔离状态下测试每一层。这允许开发者编写全面的单元和集成测试,确保应用程序的行为正确,并且更不容易出现错误。

  • 可重用性:清洁架构的模块化结构通过鼓励创建可以轻松在不同部分的应用程序或不同项目之间共享的组件来促进可重用性。这减少了代码重复,并提高了整体开发过程的效率。

  • 灵活性:清洁架构通过解耦应用程序的各个层,使得独立更改或更新这些层中的任何一个都更容易,而不会影响其他层。这为重构、修改应用程序或适应新需求提供了更多灵活性。

  • 可扩展性:清洁架构的模块化特性使得在应用程序复杂度或规模增长时更容易扩展。通过将代码组织成定义良好的层和组件,开发者可以更容易地添加新功能、更新现有功能或提高性能,而不会引入意外的副作用或使代码库难以管理。

  • 更容易协作:清洁架构通过提供清晰的代码组织结构和指南,帮助团队更有效地工作。这使得开发者更容易理解代码库,找到他们需要的组件,并更高效地贡献到项目中。

  • 面向未来:通过遵循 Clean Architecture 的原则,你确保了应用建立在坚实的基础之上,可以随着时间的推移而演变和适应。这使得它更能抵御技术、需求或团队成员的变化,从而提高了项目的长期可行性。

总结来说,在项目中使用 Clean Architecture 可以导致更组织化、更易于维护和可扩展的代码库。它提高了应用程序的整体质量,减少了技术债务,并使团队更有效地协作变得更容易。

现在,带着清晰认识到 Clean Architecture 的好处,让我们深入探讨其具体细节。以下是我们将在每一层中包含的代码层和组件:

  • 表示层

    • 视图:这包括 UI 组件,如ActivityFragmentView,在我们的案例中还有Composable组件。视图负责显示数据和捕获用户输入。

    • 视图模型视图模型作为视图组件和数据层之间的桥梁。它处理 UI 逻辑,暴露LiveDataStateFlow对象以进行数据绑定,并与用例类进行通信。

  • 领域层

    • 用例:这一层包含业务逻辑,并协调数据层和表示层之间的数据流。用例实现封装了可以在应用中执行的具体操作,例如发送消息、获取聊天历史或更新用户设置。
  • 数据层

    • 仓库仓库组件负责管理数据流并提供一个干净的 API 来从不同的源(本地数据库、远程 API 等)请求数据。它抽象了底层数据源并处理缓存、同步和数据合并。

    • 数据源:这一层包含访问特定数据源的实现,例如本地数据库(使用 Room 或其他对象关系映射器(ORM))和远程 API(使用 Retrofit 或其他网络库,正如我们案例中使用的 Ktor)。

在下面的图中,我们可以看到不同层之间的关系以及每一层典型的组件:

图 2.1:Android 中的 Clean Architecture 及其每层的典型组件

图 2.1:Android 中的 Clean Architecture 及其每层的典型组件

在对 Clean Architecture 的好处和结构有了清晰理解之后,现在让我们将这些原则付诸实践。

创建我们的 Clean Architecture 组件

我们已经开始构建数据层组件,其中我们创建了MessagesWebsocket 数据源组件。现在,是我们构建 Clean Architecture 的其余层和组件以到达表示层的时候了。

最后,我们的应用 Clean Architecture 的层和组件应该看起来是这样的:

图 2.2:我们将根据 Clean Architecture 原则构建的项目中的层和组件

图 2.2:我们将根据 Clean Architecture 原则构建的项目中的层和组件

我们已经构建了MessagesWebsocketDataSource组件,下一个组件是仓库。仓库组件将仅与MessagesWebsocketDataSource(目前如此;我们将在下一章中为它制定更大的计划)。我们将称之为MessagesRepository。让我们开始构建它:

class MessagesRepository @Inject constructor(
    private val dataSource: MessagesSocketDataSource
) {
    suspend fun getMessages(): Flow<Message> {
        return dataSource.connect()
    }
    suspend fun sendMessage(message: Message) {
        dataSource.sendMessage(message)
    }
    suspend fun disconnect() {
        dataSource.disconnect()
    }
}

MessagesRepository将只有一个依赖项(MessagesSocketDataSource)并实现连接到消息(getMessages函数)、发送消息(sendMessage函数)和从 WebSocket 断开连接(disconnect函数)的功能。

现在,我们需要对MessagesRepository进行一点修改:我们需要在领域层创建一个具有MessagesRepository功能的接口。在领域层为仓库创建接口并在数据层实现它是一种遵循 SOLID 原则中的依赖倒置原则DIP)的技术。

注意

DIP 是 OOP 和设计模式中的 SOLID 原则之一。DIP 指出,高级模块不应依赖于低级模块,两者都应依赖于抽象。同样,抽象不应依赖于细节;细节应依赖于抽象。DIP 背后的主要思想是在软件系统中解耦模块、类或组件,促进灵活性、可重用性和可维护性。通过依赖于抽象而不是具体实现,系统变得更加适应变化,并且更容易进行测试和维护。

让我们创建我们的IMessagesRepository接口:

interface IMessagesRepository {
    suspend fun getMessages(): Flow<Message>
    suspend fun sendMessage(message: Message)
    suspend fun disconnect()
}

然后,我们将修改我们的MessagesRepository类以实现此接口,并在其函数中添加重写:

class MessagesRepository @Inject constructor(
    private val dataSource: MessagesSocketDataSource
): IMessagesRepository {
    override suspend fun getMessages(): Flow<Message> {
        return dataSource.connect()
    }
    override suspend fun sendMessage(message: Message) {
        dataSource.sendMessage(message)
    }
    override suspend fun disconnect() {
        dataSource.disconnect()
    }
}

现在,我们将继续我们的旅程,进入表示层,实现领域层。

领域层不是强制性的,但强烈推荐。虽然您可以消除领域层并直接在ViewModel实例中使用仓库,但这样做会将层的职责混合在一起,可能导致更复杂且难以维护的代码。在某些情况下,不实现它可能被认为是合理的;例如,如果您正在创建UseCase层以维护一个干净且可扩展的架构。

跟随UseCase实例作为我们业务逻辑中不同的函数/职责。因此,在我们的案例中,我们将创建三个UseCase实例:一个用于检索消息,一个用于发送消息,一个用于断开连接或停止消息检索。

注意

SRP(单一职责原则)是 OOP 和设计模式中的 SOLID 原则之一。它指出,一个类、模块或函数应该只有一个改变的理由,这意味着它应该只有一个职责。该原则旨在通过鼓励开发者将代码分解成更小、更专注的组件来处理单个任务或应用程序的某个方面,从而促进 SoC(分离关注点)。这导致代码库更加模块化、易于维护和易于理解。

首先,我们将实现RetrieveMessages用例:

class RetrieveMessages @Inject constructor(
    private val repository: IMessagesRepository
) {
    suspend operator fun invoke(): Flow<Message> {
        return repository.getMessages()
    }
}

在这里,我们只有一个依赖项:仓库。请注意,我们正在使用其接口声明它。这是相关的,因为我们之前详细说明过,领域不应该了解任何关于数据层的信息。

RetrieveMessages将有一个函数,该函数将返回一个包含Message对象的Flow实例。为此,它将返回repository.getMessages()。不需要映射或修改,因为这个函数已经返回了一个Message对象的Flow实例。

第二,我们将实现SendMessage用例:

class SendMessage @Inject constructor(
    private val repository: IMessagesRepository
) {
    suspend operator fun invoke(message: Message) {
        repository.sendMessage(message)
    }
}

再次强调,这个用例将仅依赖于IMessagesRepository接口。它将调用其sendMessage函数。

最后,我们将编写DisconnectMessages用例:

class DisconnectMessages @Inject constructor(
    private val repository: IMessagesRepository
) {
    suspend operator fun invoke() {
        repository.disconnect()
    }
}

与之前实现的用例一样,DisconnectMessages用例依赖于IMessagesRepository接口,并将调用其disconnect函数。

我们现在完成了领域层。现在,是时候实现将连接到ChatScreen组件的ViewModel组件了,使用ChatViewModel

实现ChatViewModel

在 Android 中,ViewModel是作为ViewModel组件的一部分引入的架构组件。ViewModel组件的作用是保存和处理 UI 组件(如ActivityFragmentComposable组件)所需的数据,同时正确处理配置更改(如设备旋转)并生存 UI 组件的生命周期。

我们的ChatViewModel类将负责处理ChatScreen组件(我们在第一章中之前构建)所需的数据。这些数据将来自我们刚刚创建的用例。因此,首先,我们的ChatViewModel类将具有这些用例作为依赖项:

@HiltViewModel
class ChatViewModel @Inject constructor(
    private val retrieveMessages: RetrieveMessages,
    private val sendMessage: SendMessage,
    private val disconnectMessages: DisconnectMessages
) : ViewModel() {
// ....
}

然后,我们需要一个属性来保存状态。这个属性需要从视图中可观察,但只读(这样视图就不能修改它)。我们将通过创建两个不同的属性来解决此问题。第一个属性是_messages

private val _messages =
MutableStateFlow<List<Message>>(emptyList())

这行代码创建了一个包含Message对象列表的私有可变状态流。我们将使用它来在ViewModel内部管理和更新消息。

第二个属性将是messages

val messages: StateFlow<List<Message>> = _messages

这行代码将私有可变状态流公开为只读状态流。这允许 UI 组件观察消息,但不能直接修改它们。

现在,我们需要实现loadAndUpdateMessages函数,该函数将调用RetrieveMessages用例:

private var messageCollectionJob: Job? = null
fun loadAndUpdateMessages() {
    messageCollectionJob =
    viewModelScope.launch(Dispatchers.IO) {
        retrieveMessages()
            .map { it.toUI() }
            .collect { message ->
                withContext(Dispatchers.Main) {
                    _messages.value = _messages.value +
                    message
                }
            }
    }
}

在前面的代码块中,可以看到我们需要声明一个messageCollectionJob变量。该变量用于在ViewModel被清除时取消messages集合作业。

loadAndUpdateMessages函数负责获取和更新消息。它使用Dispatchers.IO上下文启动一个协程以执行网络或磁盘操作。

在协程内部,调用retrieveMessages函数,并将结果消息映射到Message UI 对象,然后使用collect函数进行收集。

对于每个收集到的消息,通过将协程上下文切换到Dispatchers.Main,使用新消息更新_messages state流。

接下来,为了使映射更易于阅读,我们将创建两个扩展函数:

private fun DomainMessage.toUI(): Message {
    return Message(
        id = id,
        senderName = senderName,
        senderAvatar = senderAvatar,
        timestamp = timestamp,
        isMine = isMine,
        messageContent = getMessageContent()
    )
}
private fun DomainMessage.getMessageContent():
MessageContent {
    return when (contentType) {
        DomainMessage.ContentType.TEXT ->
            MessageContent.TextMessage(content)
        DomainMessage.ContentType.IMAGE ->
            MessageContent.ImageMessage(content,
            contentDescription)
    }
}

因此,在检索和映射消息时,我们只需调用以下内容:

retrieveMessages()
    .map { it.toUI() }

然后,我们继续处理messages集合作业。

接着,我们应该添加一个发送新消息的函数。基本思路是在Dispatchers.IO上下文中启动协程以发送消息。由于这是一个网络操作,建议使用 I/O 分派器,并将我们从用户那里获取的String对象映射到域对象,如下面的代码块所示:

fun onSendMessage(messageText: String) {
    viewModelScope.launch(Dispatchers.IO) {
        val message = Message(messageText) // We will add
                                              here the rest
                                              of the fields
        sendMessage(message)
    }
}

注意,为了创建域对象,我们将缺少一些信息,因为例如,我们没有方法获取发送消息所必需的senderImagesenderName属性。因此,这个函数现在无法编译,但我们将解决这个问题在下文中。

最后,我们可以使用onCleared函数断开与消息检索的连接:

override fun onCleared() {
    messageCollectionJob?.cancel()
    viewModelScope.launch(Dispatchers.IO) {
        disconnectMessages()
    }
}

ViewModel不再使用并将由系统回收时,将调用此函数。这涉及到取消messageCollectionJob变量(如果它不是null),有效地停止messages收集协程。同时,在Dispatchers.IO上下文中启动一个新的协程来执行disconnectMessages函数。这保证了与消息源断开连接时进行的任何必要清理都得到妥善处理。

这就是ChatViewModel组件目前的样子:

import com.packt.feature.chat.domain.models.Message as
DomainMessage
// We are using this import with an alias to make it easier
   to identify the Message class from the domain layer
@HiltViewModel
class ChatViewModel @Inject constructor(
    private val retrieveMessages: RetrieveMessages,
    private val sendMessage: SendMessage,
    private val disconnectMessages: DisconnectMessages
) : ViewModel() {
    private val _messages =
        MutableStateFlow<List<Message>>(emptyList())
    val messages: StateFlow<List<Message>> = _messages
    private var messageCollectionJob: Job? = null
    fun loadAndUpdateMessages() {
        messageCollectionJob =
        viewModelScope.launch(Dispatchers.IO) {
            retrieveMessages()
                .map { it.toUI() }
                .collect { message ->
                    withContext(Dispatchers.Main) {
                        _messages.value = _messages.value +
                        message
                }
            }
        }
    }
    private fun DomainMessage.toUI(): Message {
        return Message(
            id = id,
            senderName = senderName,
            senderAvatar = senderAvatar,
            timestamp = timestamp,
            isMine = isMine,
            messageContent = getMessageContent()
        )
    }
    private fun DomainMessage.getMessageContent():
    MessageContent {
        return when (contentType) {
            DomainMessage.ContentType.TEXT ->
                MessageContent.TextMessage(content)
            DomainMessage.ContentType.IMAGE ->
                MessageContent.ImageMessage(content,
                contentDescription)
        }
    }
    fun onSendMessage(messageText: String) {
        viewModelScope.launch(Dispatchers.IO) {
            val message = Message(messageText)
            sendMessage(message)
        }
    }
    override fun onCleared() {
        messageCollectionJob?.cancel()
        viewModelScope.launch(Dispatchers.IO) {
            disconnectMessages()
        }
    }
}

现在我们已经准备好了ChatViewModel组件,我们需要将其连接到视图。我们将对ChatScreen组件进行必要的更改,以便它连接到我们的ChatViewModel组件。作为第一步,我们已经将ViewModel添加到参数中:

@Composable
fun ChatScreen(
    viewModel: ChatViewModel = hiltViewModel(),
    chatId: String?,
    onBack: () -> Unit
) {
}

然后,我们还将添加一个LaunchEffect可组合组件,用于启动消息的加载:

LaunchedEffect(Unit) {
    viewModel.loadAndUpdateMessages()
}

接下来,SendMessageBox可组合组件接受一个 lambda 参数,我们将使用ViewModel函数发送消息:

SendMessageBox { viewModel.onSendMessage(it) }

之后,我们在SendMessageBox组合组件定义中添加以下新参数,并在其IconButtononClick属性中调用它:

@Composable
fun SendMessageBox(sendMessage: (String)->Unit) {
    Box(modifier = Modifier
        .defaultMinSize()
        .padding(top = 0.dp, start = 16.dp, end = 16.dp,
            bottom = 16.dp)
        .fillMaxWidth()
    ) {
        var text by remember { mutableStateOf("") }
        OutlinedTextField(
            value = text,
            onValueChange = { newText -> text = newText },
            modifier = Modifier
                .fillMaxWidth(0.85f)
                .align(Alignment.CenterStart)
                .height(56.dp),
        )
        IconButton(
            modifier = Modifier
                .align(Alignment.CenterEnd)
                .height(56.dp),
            onClick = {
                sendMessage(text)
                text = ""
            }
        ) {
            Icon(
                imageVector = Icons.Default.Send,
                tint = MaterialTheme.colors.primary,
                contentDescription = "Send message"
            )
        }
    }
}

最后,我们将messages属性注入到ListOfMessages组合组件中:

ListOfMessages(paddingValues = paddingValues, messages = messages)

当然,这也需要在组合定义和代码中进行更改:

@Composable
fun ListOfMessages(messages: List<Message>, paddingValues: PaddingValues) {
    Box(modifier = Modifier
        .fillMaxSize()
        .padding(paddingValues)) {
        Row(modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
        ) {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize(),
                verticalArrangement =
                    Arrangement.spacedBy(8.dp),
            ) {
                items(messages) { message ->
                    MessageItem(message = message)
                }
            }
        }
    }
}

我们将不再使用构建ListOfMessages组合组件时使用的getFakeMessages()函数,而是使用我们现在通过属性获得的messages列表。

有了这些,我们几乎涵盖了所有内容,但仍有一些挑战需要解决。例如,我们没有显示聊天成员正确头像和名字的必要信息,也没有发送消息所需填充的必要属性的信息。虽然我们连接到 WebSocket 后会收到新消息,但如何获取历史消息的问题仍然存在。我们将在下一节中解决这些问题,以及其他与错误处理和同步相关的问题。

处理同步和错误

要使聊天消息功能完整,我们仍有一些问题需要考虑:获取历史消息和接收者信息以及处理可能的错误。我们将在本节中讨论这些问题。

获取聊天屏幕初始化数据

除了将通过数据源接收或发送的消息之外,我们还需要获取一些其他信息。这包括以下内容:

  • 在 WebSocket 连接之前发送和接收的消息(尽管不是所有消息,因为对话可能有大量消息,收集/加载所有信息将花费很长时间;相反,我们应该优先获取一定数量的最新消息)

  • 接收者信息,例如他们的名字或头像 URL

有几种方法可以解决这个问题——例如,当 WebSocket 连接建立时,我们可以有一种包含所有这些信息的不同类型的消息,或者我们可以有一个特定的 API 调用来检索这些信息。因为我们已经使用 Ktor WebSocket 实现了聊天功能,所以我们将使用它来实现一个 API 调用以检索这些信息。

当我们构建WebsocketMessagesDataSource时,我们必须提供一个HttpClient实例。通常,这些客户端在同一个应用程序中是共享的,但我们应该创建一个新的实例来用于我们的 API 请求。为此,我们需要添加一个新的依赖项:

implementation "io.ktor:ktor-client-content-negotiation:
$ktor_version"

然后,我们可以这样创建客户端(我们可以在定义 WebSocket 客户端的同一文件中这样做):

object RestClient {
    val client = HttpClient{
        install(ContentNegotiation) {
            json()
        }
    }
}

接下来,我们将创建一个ChatRoomDataSource类,该类将负责处理这些数据检索:

class ChatRoomDataSource @Inject constructor(
    private val client: HttpClient,
    private val url: String
) {
    suspend fun getInitialChatRoom(id: String):
    ChatRoomModel {
        return client.get(url.format(id)).body()
    }
}

如此所见,我们将把客户端和 URL 作为依赖项注入。然后,在getInitialChatRoom函数中,我们将调用client.get(url)函数以向端点发起请求。

使用 Ktor 客户端,你可以使用各种 HTTP 方法。以下是一些常见方法的列表:

  • GET:从指定的端点检索数据。要在 Ktor 中使用此方法,你可以调用 get 函数:

    val response: HttpResponse =
    client.get("https://api.example.com/data")
    
  • POST:向指定的端点发送数据,通常用于创建新资源。要在 Ktor 中使用此方法,你可以调用 post 函数:

    val response: HttpResponse =
    client.post("https://api.example.com/data") {
    body = yourData }
    
  • PUT:向指定的端点发送数据,通常用于更新现有资源。要在 Ktor 中使用此方法,你可以调用 put 函数:

    val response: HttpResponse =
    client.put("https://api.example.com/data") {
    body = yourUpdatedData }
    
  • DELETE:删除指定的资源。要在 Ktor 中使用此方法,你可以调用 delete 函数:

    val response: HttpResponse =
    client.delete("https://api.example.com/data/ID")
    
  • PATCH:对资源应用部分修改。要在 Ktor 中使用此方法,你可以调用 patch 函数:

    val response: HttpResponse =
    client.patch("https://api.example.com/data") {
    body = yourPartialData }
    

在我们的 getInitialChatRoom 函数中,我们使用 client.get(URL) 函数(注意我们必须以可以替换 ChatRoom ID 的格式提供 URL)。我们还需要返回一个新的模型,ChatRoomModel

@kotlinx.serialization.Serializable
data class ChatRoomModel(
    val id: String,
    val senderName: String,
    val senderAvatar: String,
    val lastMessages: List<WebsocketMessageModel>
)

现在,为了提供 ChatRoomDataSource 所需的依赖项,我们必须以以下方式设置我们的 ChatModule 类:

@InstallIn(SingletonComponent::class)
@Module
abstract class ChatModule {
    companion object {
        const val WEBSOCKET_URL =
            "ws://whatspackt.com/chat/%s"
        const val WEBSOCKET_URL_NAME = "WEBSOCKET_URL"
        const val WEBSOCKET_CLIENT = "WEBSOCKET_CLIENT"
        const val API_CHAT_ROOM_URL =
            "http://whatspackt.com/chats/%s"
        const val API_CHAT_ROOM_URL_NAME = "CHATROOM_URL"
        const val API_CLIENT = "API_CLIENT"
    }
    @Provides
    @Named(WEBSOCKET_CLIENT)
    fun providesWebsocketHttpClient(): HttpClient {
        return WebsocketClient.client
    }
    @Provides
    @Named(WEBSOCKET_URL_NAME)
    fun providesWebsocketURL(): String {
        return WEBSOCKET_URL
    }
    @Binds
    abstract fun providesMessagesRepository(
        messagesRepository: MessagesRepository
    ): IMessagesRepository
    @Provides
    @Named(API_CLIENT)
    fun providesAPIHttpClient(): HttpClient {
        return RestClient.client
    }
}

由于 providesWebsocketClientprovidesApiHttpClient 函数都返回相同的类型(HttpClient),我们需要使它们可识别,以便我们可以向 Hilt 指明它应该为 WebsocketDataSource 提供哪个依赖项,以及哪个依赖项用于 ChatRoomDataSource。这就是我们使用限定符的原因。

注意

使用限定符允许 依赖注入DI)框架在存在多个相同类型的实例时确定要注入的正确依赖项实例。这确保了提供正确的实例,防止了依赖项管理中的冲突或歧义。

在下一个代码块中,我们使用 WEBSOCKET_CLIENT 常量作为 WebSocket HttpClient 实例的限定符,以及 API_CLIENT 作为 REST API HttpClient 实例的限定符:

@Provides
@Named(WEBSOCKET_CLIENT)
fun providesWebsocketHttpClient(): HttpClient {
    return WebsocketClient.client
}
@Provides
@Named(API_CLIENT)
fun providesAPIHttpClient(): HttpClient {
    return RestClient.client
}

我们还应该使用限定符来提供 WebSocket 和 API 的 URL。此外,重要的是要注意,这些 URL 值现在是由 ChatModule 中的伴随对象提供的,以简化流程,但更好的方法是将它们定义为 Gradle 文件的一部分。这样,我们就可以根据构建变体(发布、调试、测试等)或风味来覆盖它们。

关于限定符,我们还需要在依赖项的消费者中指明应该注入哪一个。这将通过受影响依赖项中的 @Named 注解来完成,如下所示:

class ChatRoomDataSource @Inject constructor(
    @Named(API_CLIENT) private val client: HttpClient,
    @Named(API_CHAT_ROOM_URL_NAME) private val url: String
) {
    suspend fun getInitialChatRoom(id: String):
    ChatRoomModel {
        return client.get(url.format(id)).body()
    }
}

此外,我们还需要修改 MessagesSocketDataSource 中的构造函数,以便 Hilt 知道它需要注入哪一个:

class MessagesSocketDataSource @Inject constructor(
    @Named(WEBSOCKET_CLIENT) private val httpClient:
        HttpClient,
    @Named(WEBSOCKET_URL_NAME) private val websocketUrl:
        String
) { ... }

现在我们已经为依赖项正确注入做好了准备,是时候实现 ChatRoomRepository 组件了。我们将以与实现 MessagesRepository 组件类似的方式来实现它。

首先,我们想在我们的领域包中创建一个接口:

package com.packt.feature.chat.domain
import com.packt.feature.chat.domain.models.ChatRoom
interface IChatRoomRepository {
    suspend fun getInitialChatRoom(id: String): ChatRoom
}

然后,我们将在data.repository包中创建实际实现:

package com.packt.feature.chat.data.network.repository
import com.packt.feature.chat.data.network.datasource
.ChatRoomDataSource
import com.packt.feature.chat.domain.IChatRoomRepository
import com.packt.feature.chat.domain.models.ChatRoom
import javax.inject.Inject
class ChatRoomRepository @Inject constructor(
    private val dataSource: ChatRoomDataSource
): IChatRoomRepository {
    override suspend fun getInitialChatRoom(id: String):
    ChatRoom {
        val chatRoomApiModel =
            dataSource.getInitialChatRoom(id)
        return chatRoomApiModel.toDomain()
    }
}

这里,我们从数据源获取初始聊天室信息,然后我们将获取到的数据模型映射到领域模型。

当然,除非我们创建领域模型ChatRoom,否则这不会工作:

package com.packt.feature.chat.domain.models
data class ChatRoom(
    val id: String,
    val senderName: String,
    val senderAvatar: String,
    val lastMessages: List<Message>
)

然后,我们应该从ChatRoomModel创建映射:

@Serializable
data class ChatRoomModel(
    val id: String,
    val senderName: String,
    val senderAvatar: String,
    val lastMessages: List<WebsocketMessageModel>
) {
    fun toDomain(): ChatRoom {
        return ChatRoom(
            id = id,
            senderName = senderName,
            senderAvatar = senderAvatar,
            lastMessages = lastMessages.map { it.toDomain() }
        )
    }
}

这里,我们刚刚添加了toDomain()函数,它将数据对象(ChatRoomModel)映射到领域对象(ChatRoom)。

现在,我们需要将仓库接口绑定到其实际实现。为此,我们应该在我们的 Hilt 模块中添加一个绑定声明:

@Binds
abstract fun providesChatRoomRepository(
    chatRoomRepository: ChatRoomRepository
): IChatRoomRepository

在这里,我们告诉 Hilt,每次它需要提供IChatRoomRepository依赖项时,应该提供ChatRoomRepository

现在,我们已经准备好了数据源和仓库。我们需要实现一个新的用例,其责任是提供此初始信息:

package com.packt.feature.chat.domain.usecases
import com.packt.feature.chat.domain.IChatRoomRepository
import com.packt.feature.chat.domain.models.ChatRoom
import javax.inject.Inject
class GetInitialChatRoomInformation @Inject constructor(
    private val repository: IChatRoomRepository
) {
    suspend operator fun invoke(id: String): ChatRoom {
        return repository.getInitialChatRoom(id)
    }
}

这里,我们将调用仓库的getInitialChatRoom()函数,以在ChatRoom模型中获取它。

我们现在到达了目的地:ViewModel。我们需要将GetInitial ChatRoomInformation作为ViewModel的依赖项,在初始化时获取此信息,并使其对 UI 可用以观察它:

@HiltViewModel
class ChatViewModel @Inject constructor(
    private val retrieveMessages: RetrieveMessages,
    private val sendMessage: SendMessage,
    private val disconnectMessages: DisconnectMessages,
    private val getInitialChatRoomInformation:
        GetInitialChatRoomInformation
) : ViewModel() {...}

接下来,我们需要创建一个新的StateFlow实例,以便由 UI 消费。由于它将几乎包含所有 UI 的状态(除了消息;我们稍后会讨论这一点),我们将称之为uiState

private val _uiState = MutableStateFlow(Chat())
val uiState: StateFlow<Chat> = _uiState

现在,我们将添加一个在视图初始化时调用的新函数:

fun loadChatInformation(id: String) {
    messageCollectionJob =
    viewModelScope.launch(Dispatchers.IO) {
        val chatRoom = getInitialChatRoomInformation(id)
        withContext(Dispatchers.Main) {
            _uiState.value = chatRoom.toUI()
            _messages.value = chatRoom.lastMessages.map {
                it.toUI()}
            updateMessages()
        }
    }
}

这里,我们使用messagesCollectionJob(我们可以将其名称改为更通用的名称,因为现在它将被用于messages收集作业和初始数据检索)。

然后,我们检索初始聊天室信息,更新uiState值,并将我们接收到的消息设置为messages StateFlow对象中的第一条消息(这样聊天会显示旧消息)。

最后,我们调用updateMessages()函数,在这里我们将连接到 WebSocket 并开始获取异步消息。

注意,我们还需要一个Chat模型,它将成为我们的uiState实例;这个模型很重要,因为它将是 UI 消费以配置的对象。添加方式如下:

data class Chat(
    val id: String? = null,
    val name: String? = null,
    val avatar: String? = null
)
fun ChatRoom.toUI() = run {
    Chat(
        id = id,
        name = senderName,
        avatar = senderAvatar
    )
}

现在,我们需要从我们的屏幕组合组件监听这个uiState实例,并相应地更新 UI:

@Composable
fun ChatScreen(
    viewModel: ChatViewModel = hiltViewModel(),
    chatId: String?,
    onBack: () -> Unit
) {
    val messages by viewModel.messages.collectAsState()
    val uiState by viewModel.uiState.collectAsState()
    LaunchedEffect(Unit) {
        viewModel.loadChatInformation(chatId.orEmpty())
    }
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                   Text(stringResource(R.string.chat_title,
                   uiState.name.orEmpty()))
                }
            )
        },
        bottomBar = {
            SendMessageBox { viewModel.onSendMessage(it) }
        }
    ) { paddingValues->
        ListOfMessages(paddingValues = paddingValues,
        messages = messages)
    }
}

这里,我们可以看到我们正在调用loadChatInformation函数,一旦Composable组件启动。然后,一旦获取到这些信息,我们将在TopAppBar组件中显示聊天参与者的名称,从聊天初始化中获取这些信息。同时,消息列表将更新为最后一条消息。

通常,我们希望将所有的 uiState 属性封装在一个单一的观察值中,作为 Jetpack Compose 优势之一,它将在检测到与 Composable 组件相关的值发生变化时处理重新组合。在这种情况下,遵循的标准是将它们分开,因为实际上,这两个值的变化频率差异很大:

  • uiState 属性对于相同的聊天不会改变

  • 消息 列表很可能会以很高的频率改变(每次我们发送和接收消息时)

在本节中,我们已经设置了我们的聊天初始化,包括架构所需的所有组件,从数据源到 ViewModel 的更改。现在,是我们处理可能遇到的可能错误并给我们的聊天屏幕提供一些弹性的时候了。

处理 WebSocket 中的错误

错误并不罕见,尤其是在像 WebSocket 这样的长期连接中,在一个如此敏感的环境中,如移动环境,处理这些错误非常重要,否则,我们的用户可能无法继续发送或接收消息,在最坏的情况下,可能发生致命错误导致应用程序崩溃。

我们可以控制这些错误的方式有很多。其中之一是让每一层负责其范围内可能发生的错误,并且只有当应用程序无法从这些错误中恢复时,才将错误传播到 UI(或用户知识)。

这里,我们可能会有几个错误:

  • 可恢复的错误连接错误将通过重试来处理

  • 解析错误可能无法恢复,因为多次重试不会改变应用程序或后端格式化消息的方式(对于这些类型的错误,我们除了在部署应用程序之前检测它们或拥有用于检测它们的分析工具之外,无能为力)

在本节中,我们将重点关注 MessagesSocketDataSource。如果我们看一下我们的 connect 函数,我们可以看到它可能有一些故障点(例如,在初始化会话或处理收到的消息时)。解决这个问题的最简单方法是将这些点用 try-catch 块包装起来:

suspend fun connect(): Flow<Message> {
    return flow {
        // Wrap the connection attempt with a try-catch
           block
        try {
            httpClient.webSocketSession { url(websocketUrl) }
                .apply { webSocketSession = this }
                .incoming
                .receiveAsFlow()
                .collect { frame ->
                    try {
                        // Handle errors while processing
                           the message
                        val message =
                            webSocketSession.handleMessage(
                                frame)?.toDomain()
                        if (message != null) {
                            emit(message)
                        }
                    } catch (e: Exception) {
                        // Log or handle the error
                           gracefully
                        Log.e(TAG, "Error handling
                            WebSocket frame", e)
                    }
                }
        } catch (e: Exception) {
            // Log or handle the connection error
               gracefully
            Log.e(TAG, "Error connecting to WebSocket", e)
        }
    }.retryWhen { cause, attempt ->
        // Implement a retry strategy based on the cause
           and/or the number of attempts
        if (cause is IOException && attempt < MAX_RETRIES)
        {
            delay(RETRY_DELAY)
            true
        } else {
            false
        }
    }.catch { e ->
        // Handle exceptions from the Flow
        Log.e(TAG, "Error in WebSocket Flow", e)
    }
}

我们还需要定义常量 TAG(用于在 Logcat 中记录消息),MAX_RETRIES(我们将要使用的重试次数,因为我们不能永远重试),以及 RETRY_DELAY(我们在重试之间等待的毫秒数):

companion object {
    const val TAG = "MessagesSocketDataSource"
    const val RETRY_DELAY = 30000
    const val MAX_RETRIES = 5
}

这里,我们将这些值定义为常量,所以如果 WebSocket 连接失败,我们将在 30 秒后(30000 毫秒)重试连接。如果它没有成功连接,将会重试 5 次,然后放弃。

现在用户在使用应用时正在接收消息,我们仍然需要提供一个方式在用户收到新消息但未使用应用时通知他们。我们可以通过使用推送通知来解决这个问题。

添加推送通知

推送通知是从服务器发送到用户设备的消息,即使用户没有积极使用应用。这些消息作为系统通知出现在应用之外,可以用来向用户提供更新、警报或其他相关信息。

为了发送推送通知,我们需要决定我们想要使用哪种可用的选项。最受欢迎的是 Firebase Cloud MessagingFCM),但还有更多推送通知服务,如 OneSignal、Pusher 或 Amazon Simple Notification ServiceSNS)。在我们的案例中,我们将走流行的路线并使用 FCM。

Firebase 是由 Google 提供的移动和 Web 应用程序开发平台。它提供了一套工具、服务和基础设施,旨在帮助开发者构建、改进和扩展他们的应用。其一些功能包括身份验证、推送通知、云数据库等。我们将使用它来构建本章的最后两个部分。

为了实现这一点,我们首先需要在项目中设置 Firebase。

设置 Firebase

要在我们的项目中设置 Firebase,我们需要遵循以下步骤:

  1. 前往 Firebase 控制台([console.firebase.google.com/](https://console.firebase.google.com/))并点击 添加项目。然后,按照屏幕上的说明设置您的项目。

  2. 在 Firebase 控制台中,点击 Android 图标以注册您的应用。输入您的应用包名,并且可选地提供 Google Sign-In 和其他身份验证功能的 SHA-1 指纹。点击 注册应用 以继续。

  3. 在注册我们的应用后,我们将被提示下载一个 google-services.json 文件。下载它并将其放置在我们的 Android 项目的 app 模块根目录下。

  4. 将 Firebase SDK 依赖项添加到项目中的 build.gradle 文件中,如下所示:

    classpath 'com.google.gms:google-services:
    $latest_version'
    
  5. 然后在我们将使用它的 app 模块的 build.gradle 文件中(在我们的案例中,:common:data),我们应该添加以下特定 Firebase 服务的依赖项:

    implementation platform('com.google.firebase:
        firebase-bom:$latest_version')
    implementation 'com.google.firebase:firebase-auth'
    implementation 'com.google.firebase:
        firebase-firestore'
    implementation 'com.google.firebase:
        firebase-messaging'
    

    注意,正如我们处理 Jetpack Compose 依赖项时那样,这里我们将使用 物料清单BoM)。其优势在于我们不需要指定每个依赖项的版本,因为兼容的版本将由 BoM 提供。

注意

BoM 是在依赖管理系统中使用的一种机制,用于指定和管理多个库及其传递依赖项的版本,作为一个单一实体。它有助于简化依赖管理,并确保同一生态系统或套件中的不同库之间的兼容性。

  1. 此外,为了便于使用协程来处理 Firebase 任务,我们将添加这个额外的依赖项:

    implementation 'org.jetbrains.kotlinx:
    kotlinx-coroutines-play-services:$latest_version'
    

现在,在我们能够接收推送通知之前,我们需要识别我们的用户。我们通过将他们的令牌发送到 Firebase 来做到这一点。

将 FCM 令牌发送到 Firebase

为了使用 FCM 识别我们的用户并向他们发送特定通知,我们需要使用 FCM 令牌。每个用户都被分配了一个唯一的 FCM 令牌,用于向他们的设备发送通知。此令牌应在用户登录或应用程序启动时获取和更新。

我们可以通过从FirebaseMessaging类中调用getToken()方法来获取 FCM 令牌。为此,我们首先将创建一个数据源,该数据源将包装令牌处理功能:

package com.packt.data
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
class FCMTokenDataSource @Inject constructor(
    private val firebaseMessaging: FirebaseMessaging =
    FirebaseMessaging.getInstance()
) {
    suspend fun getFcmToken(): String? {
        return try {
            FirebaseMessaging.getInstance().token.await()
        } catch (e: Exception) {
            null
        }
    }
}

在这里,我们正在注入FirebaseMessaging实例并从 Firebase 获取 FCM 令牌。

现在,我们需要将这个 FCM 存储在某个地方,以便当向我们的用户发送新消息时,我们知道哪个令牌与他们相关联。没有标准的存储方式。通常,这将在后端处理,但这本书的范围之外。但我们可以准备应用程序组件。我们将创建一个用例,该用例将作为获取并随后将 FCM 存储在后端的协调器。这个用例将需要一个存储库来完成这两个任务:获取令牌并将其存储在我们的系统中。

如往常一样,在领域层(在这种情况下,在:common:domain模块)为我们的存储库创建接口:

interface IFCMTokenRepository {
    suspend fun getFCMToken(): String
}

然后,我们将在数据层创建存储库实现(:common:data):

class FCMTokenRepository @Inject constructor(
    private val tokenDataSource: FCMTokenDataSource
) {
    suspend fun getToken(): String? {
        return tokenDataSource.getFcmToken()
    }
}

我们将使用这个存储库从 Firebase 获取令牌。如前所述,我们还需要将令牌存储在某个地方,因此我们将为该目的创建另一个存储库:

interface IInternalTokenRepository {
    suspend fun storeToken(userId: String, token: String)
}

我们将再次留空实现,因为它超出了我们的范围。这里需要理解的相关点是令牌应该被存储,以便稍后当我们的用户收到消息时,我们可以识别令牌并向相关设备发送推送通知。

在下一个代码块中,我们可以看到我们如何实现上述接口,其中您将提供存储您首选数据源的方法:

class InternalTokenRepository(): IInternalTokenRepository {
    override suspend fun storeToken(userId: String, token:
    String) {
        // Store in the data source of your choosing
    }
}

现在我们已经解决了令牌问题,我们需要准备我们的应用程序以接收推送通知。

准备应用程序接收推送通知

推送通知是在移动设备上弹出的消息。当用户没有积极使用应用程序而我们需要引起他们的注意时,它们特别有用。在本节中,我们将使我们的应用程序能够在收到新消息时接收它们。

要开始接收推送通知,我们首先需要对现有代码进行一些修改。例如,我们必须考虑如果用户点击通知会发生什么:我们可能希望它打开与消息通知相关的ChatScreen组件。让我们从这些更改开始。

要直接打开ChatScreen组件,我们需要创建一个链接,告诉系统它应该打开显示ChatScreen组件的应用程序。这个链接被称为深度链接。

深度链接是一种将用户引导到 Android 应用中特定内容或页面的链接,而不仅仅是启动应用。深度链接通过允许用户从网站、另一个应用,甚至简单的文本消息或电子邮件直接跳转到应用中的特定功能、功能或内容,从而提供更流畅的用户体验。

为了创建我们的深度链接,我们将在:common:framework模块中创建一个名为DeepLinks的对象,以组织我们在应用中将要使用的所有深度链接:

package com.packt.framework.navigation
object DeepLinks {
    const val chatRoute =
        "https://whatspackt.com/chat?chatId={chatId}"
}

然后,我们需要修改我们的NavHost组件——一旦应用接收到带有此深度 comlink 的 intent,应用应导航到ChatScreen组件。为了实现这一点,我们需要在WhatsPacktNavigation中将Deeplink实例作为ChatScreen导航图的一个选项添加:

private fun NavGraphBuilder.addChat(navController:
NavHostController) {
    composable(
        route = NavRoutes.Chat,
        arguments = listOf(
            navArgument(NavRoutes.ChatArgs.ChatId) {
                type = NavType.StringType }),
        deepLinks = listOf(
            navDeepLink {
                uriPattern = DeepLinks.chatRoute
            }
        )
    ) { backStackEntry ->
        val chatId = backStackEntry.arguments?.getString(
            NavRoutes.ChatArgs.ChatId)
        ChatScreen(chatId = chatId, onBack = {
            navController.popBackStack() })
    }
}

在这里,我们将我们DeepLinks对象中已有的深度链接模式添加为ChatScreen组件的路线选项之一。

然后,我们需要实现一个FirebaseMessagingService函数,该函数将捕获我们收到的所有推送通知,并允许我们定义一个通知将被发布并由 Android 系统处理,最终显示给用户(如果用户已授予我们的应用执行此操作的权限):

class WhatsPacktMessagingService:
FirebaseMessagingService() {
    companion object {
        const val CHANNEL_ID = "Chat_message"
        const val CHANNEL_DESCRIPTION = "Receive a
            notification when a chat message is received"
        const val CHANNEL_TITLE = "New chat message
            notification"
    }
    override fun onMessageReceived(remoteMessage:
    RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        if (remoteMessage.data.isNotEmpty()) {
            // We can extract information such as the
               sender, message content, or chat ID
            val senderName =
                remoteMessage.data["senderName"]
            val messageContent =
                remoteMessage.data["message"]
            val chatId = remoteMessage.data["chatId"]
            val messageId = remoteMessage.data["messageId"]
            // Create and show a notification for the
               received message
            if (chatId != null && messageId != null) {
                showNotification(senderName, messageId,
                messageContent, chatId)
            }
        }
    }
    private fun showNotification(senderName: String?,
    messageId: String, messageContent: String?,
    chatId: String) {
        // Implement here the notification
    }
}

在这里,我们从收到的消息中提取一些信息,例如senderNamemessageContentchatId等。理想情况下,我们可以获取我们想要在通知中显示的信息。

虽然这是一个示例,但信息结构将取决于我们与后端实现已定义的负载合同。

一旦我们提取了这些信息,我们需要显示通知:

private fun showNotification(senderName: String?,
messageId: String, messageContent: String?, chatId: String)
{
    val notificationManager = getSystemService(
       Context.NOTIFICATION_SERVICE) as NotificationManager
    // Create a notification channel
    // (if you want to support versions lower than Android
       Oreo, you will have to check the version here)
    val channel = NotificationChannel(
        CHANNEL_ID,
        CHANNEL_TITLE,
        NotificationManager.IMPORTANCE_DEFAULT
    ).apply {
        description = CHANNEL_DESCRIPTION
    }
    notificationManager.createNotificationChannel(channel)
    // Create an Intent to open the chat when the
       notification is clicked. Here is where we are going
       to use our newly created deeplink
    val deepLinkUrl =
        DeepLinks.chatRoute.replace("{chatId}", chatId)
    val intent = Intent(Intent.ACTION_VIEW,
    Uri.parse(deepLinkUrl)).apply {
        flags = Intent.FLAG_ACTIVITY_NEW_TASK or
        Intent.FLAG_ACTIVITY_CLEAR_TASK
    }
    // Create a PendingIntent for the Intent
    val pendingIntent = PendingIntent.getActivity(this, 0,
        intent, PendingIntent.FLAG_IMMUTABLE)
    // Build the notification
    val notification = NotificationCompat.Builder(this,
        CHANNEL_ID)
        .setSmallIcon(R.drawable.our_notification_icon_for_
            whatspackt)
        .setContentTitle(senderName)
        .setContentText(messageContent)
        .setContentIntent(pendingIntent)
        .setAutoCancel(true)
        .build()
    // Show the notification
    notificationManager.notify(messageId.toInt(),
        notification)
}

首先,我们创建一个NotificationChannel实例,然后是通知所需的元素(例如PendingIntent,当用户点击通知时将使用),然后是通知本身(使用NotificationCompat)。最后,我们使用NotificationManager将通知通知给系统。

注意

在 Android 8.0(API 级别 26)及更高版本中,创建一个NotificationChannel实例是必要的,因为它为用户提供了对应用通知的更好控制。每个NotificationChannel实例代表应用可以显示的通知的独特类别,用户可以独立修改每个通道的设置。这使得用户可以根据他们的偏好自定义应用通知的行为。

例如,用户可以为每个通道设置重要性级别、启用/禁用声音或设置自定义振动模式。他们还可以阻止整个通道,这样他们就不会再收到该特定类别的通知。

当你创建一个 NotificationChannel 实例时,你需要设置一个重要性级别,这决定了系统如何向用户展示该渠道的通知。重要性级别从高(紧急并有声音)到低(无声音或视觉干扰)不等。

最后一步是将我们的服务添加到 AndroidManifest.xml 文件中的 application 标签内:

<application
    android:allowBackup = "true"
    android:dataExtractionRules =
        "@xml/data_extraction_rules"
    android:fullBackupContent = "@xml/backup_rules"
    android:icon = "@mipmap/ic_launcher"
    android:label = "@string/app_name"
    android:supportsRtl = "true"
    android:theme = "@style/Theme.WhatsPackt"
    tools:targetApi = "31">
    <activity
        android:name = ".MainActivity"
        android:exported = "true"
        android:label = "@string/app_name"
        android:theme = "@style/Theme.WhatsPackt">
        <intent-filter>
            <action android:name=
                "android.intent.action.MAIN" />
            <category android:name =
                "android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
    <service
        android:name =
            "com.packt.data.WhatsPacktMessagingService"
        android:exported = "false">
        <intent-filter>
            <action android:name =
                "com.google.firebase.MESSAGING_EVENT" />
        </intent-filter>
    </service>
</application>

有了这些,我们的应用程序就准备好接收推送通知了。

在下一节中,我们将看到在完成所有使我们的代码可扩展和松耦合的工作之后,我们可以轻松地使用 Firebase 而不是 WebSocket 来发送和接收消息。

用 Firestore 替换 Websocket

正如我们在上一节中看到的,Firebase 是一个强大的产品,它简化了我们应用程序后端的实现。现在,我们将看到我们如何使用它来简化聊天消息功能。

什么是 Firestore?

Firestore,更正式地称为 Cloud Firestore,是由 Firebase 提供的一个灵活、可扩展的实时 NoSQL 数据库。Firestore 设计用于存储和同步客户端应用程序的数据,使其成为构建现代、数据驱动应用程序的理想选择。

其中最重要的功能之一是实时数据同步。Firestore 会自动在所有连接的客户端之间实时同步数据,确保您的应用程序数据始终保持最新。这对于需要实时协作或实时更新的应用程序特别有用,例如我们的聊天应用。

重要的是要注意,作为一个 NoSQL 数据库,我们首先需要定义数据结构。我们如何构建我们的文档?好吧,让我们从那开始。

聊天数据结构

要在 Firestore NoSQL 中处理聊天消息,我们可以使用以下结构:

  • 创建一个名为 chats 的集合。这个集合中的每个文档将代表用户之间的聊天室或对话。文档 ID 可以由 Firestore 自动生成,或者使用自定义方法(例如,用户 ID 的组合)创建。在这里,我们可以包括我们需要的对话的常见数据(例如我们的 ChatRoom 模型),如用户的姓名、头像等...

  • 对于每个聊天文档,创建一个名为 messages 的子集合。这个子集合将存储该聊天室或对话中的单个消息。

  • messages 子集合中的每个文档将代表一条单独的消息。消息文档的结构可能包括 senderId(发送者 ID)、senderName(发送者姓名)、content(内容)和 timestamp(时间戳)等字段。

随后,我们的结构将如下所示:

chats (collection)
  |
  └── chatId1 (document)
        |
        ├── users (subcollection)
        │   |
        │   ├── userId1 (document)
        │   │   ├── userId: "user1"
        │   │   ├── avatarUrl:
                      "https://example.com/avatar1.jpg"
        │   │   └── name: "John Doe"
        │   │
        │   └── userId2 (document)
        │       ├── userId: "user2"
        │       ├── avatarUrl:
                      "https://example.com/avatar2.jpg"
        │       └── name: "Jane Smith"
        │
        └── messages (subcollection)
              |
              ├── messageId1 (document)
              │   ├── senderId: "user1"
              │   ├── senderName: "John Doe"
              │   ├── content: "Hello, how are you?"
              │   └── timestamp: 1648749123
              |
              └── messageId2 (document)
                    ├── senderId: "user2"
                    ├── senderName: "Jane Smith"
                    ├── content: "I'm doing great! How
                                 about you?"
                    └── timestamp: 1648749156

一个重要的方面是,理想情况下,我们应该设置身份验证来识别我们的用户。我们将在 第七章 中学习如何构建它,但到目前为止,我们假设我们的用户将在 Firebase 中进行身份验证。

假设我们的聊天将由认证用户使用,我们可以限制和限制对聊天集合的修改访问权限,仅限于已经认证的用户。为了实现这一点,我们可以在 Firestore 中定义一组规则,使用 Firebase 控制台。以下是一个示例:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Allow authenticated users to create chat documents,
       but not modify or delete them
    match /chats/{chatId} {
      allow create: if request.auth != null;
      allow read, update, delete: if false;
    }
    // Allow chat participants to read the chat's user data
    match /chats/{chatId}/users/{userId} {
      allow read: if request.auth != null &&
        request.auth.uid in resource.data.userId;
      allow write: if false;
    }
    // Allow authenticated users to create/modify messages
       in a chat they are participating in
    match /chats/{chatId}/messages/{messageId} {
      // Get chat participants
      function isChatParticipant() {
        let chatUsersDoc = get(
            /databases/$(database)/documents/chats/
                $(chatId)/users/$(request.auth.uid));
        return chatUsersDoc.exists();
      }
      // Check if the sender is the authenticated user
      function isSender() {
        return request.auth != null && request.auth.uid ==
          request.resource.data.senderId;
      }
      allow create: if isChatParticipant() && isSender();
      allow read: if isChatParticipant();
      allow update, delete: if false;
    }
  }
}

现在我们已经定义了这些规则,我们可以切换到我们的 Android 应用代码并创建一个 FirestoreMessagesDataSource 类。

创建一个 FirestoreMessagesDataSource 类

创建 FirestoreMessagesDataSource 类的第一步是创建我们将用于序列化文档的模型。这个模型必须包括我们在设计 Message 文档结构时包含的相同字段:

import com.google.firebase.Timestamp
import com.google.firebase.firestore.PropertyName
import com.packt.feature.chat.domain.models.Message
import java.text.SimpleDateFormat
import java.util.*
data class FirestoreMessageModel(
    @Transient
    val id: String = "",
    @get:PropertyName("senderId")
    @set:PropertyName("senderId")
    var senderId: String = "",
    @get:PropertyName("senderName")
    @set:PropertyName("senderName")
    var senderName: String = "",
    @get:PropertyName("senderAvatar")
    @set:PropertyName("senderAvatar")
    var senderAvatar: String = "",
    @get:PropertyName("content")
    @set:PropertyName("content")
    var content: String = "",
    @get:PropertyName("timestamp")
    @set:PropertyName("timestamp")
    var timestamp: Timestamp = Timestamp.now()
)

注意,我们包括了一个名为 id 的字段,它带有 @Transient 注解——这个字段将存储文档 id 值(对我们来说,这将作为消息的唯一标识,因为每条消息都有自己的文档)。我们必须放置 @Transient 注解的原因是避免在将数据写入 Firestore 时,这个 id 字段被存储在文档本身中。

现在,就像我们处理 MessagesSocketDataSource 类一样,我们需要将这个数据模型转换为域模型。我们已经有 messages 域模型了,所以在这种情况下,我们只需要实现将 FirestoreMessageModel 数据类转换为我们的 Message 域模型的函数:

fun toDomain(userId: String): Message {
    return Message(
        id = id,
        senderName = senderName,
        senderAvatar = senderAvatar,
        isMine = userId == senderId,
        contentType = Message.ContentType.TEXT,
        content = content,
        contentDescription = "",
        timestamp = timestamp.toDateString()
    )
}
private fun Timestamp.toDateString(): String {
    // Create a SimpleDateFormat instance with the desired
       format and the default Locale
    val formatter = SimpleDateFormat("dd/MM/yyyy HH:mm:ss",
        Locale.getDefault())
    // Convert the Timestamp to a Date object
    val date = toDate()
    // Format the Date object using the SimpleDateFormat
       instance
    return formatter.format(date)
}

在这个例子中,我们假设我们只会有文本消息(没有图片)以简化问题。然而,通过在 Firestore 模型中包含一个字段来指示消息类型,这可以很容易地完成。几乎所有的属性映射都是直接的,除了时间戳。在 Message 模型中,我们期望一个包含日期和时间的 String 对象,而我们从 Firestore 获取的是一个 Timestamp 对象。因此,我们使用 Timestamp.toDateString() 扩展从 Timestamp 对象中获取格式化的 String 对象。

此外,因为我们还想要发送消息,所以我们需要将域 Message 对象转换为数据对象:

companion object {
    fun fromDomain(message: Message): FirestoreMessageModel
    {
        return FirestoreMessageModel(
            id = "",
            senderName = message.senderName,
            senderAvatar = message.senderAvatar,
            content = message.content
        )
    }
}

注意,我们并没有设置时间戳(它将在对象创建时自动生成),并且 id 字段没有实际的值(因为它不会被存储在 Firestore 中)。

现在,我们可以继续进行 FirestoreMessagesDataSource 的实现。首先,我们定义类及其依赖项:

class FirestoreMessagesDataSource @Inject constructor(
    private val firestore: FirebaseFirestore =
        FirebaseFirestore.getInstance()
) {

然后,我们将添加一个 getMessages 函数,以获取聊天消息:

    fun getMessages(chatId: String, userId: String):
    Flow<Message> = callbackFlow {

在这个函数内部,我们将获取指定聊天中的 messages 子集合的引用:

        val chatRef =
            firestore.collection("chats").document(chatId)
                .collection("messages")

现在,我们将创建一个查询以按时间戳(升序)获取消息:

        val query = chatRef.orderBy("timestamp",
            Query.Direction.ASCENDING)

在下一步中,我们向查询添加一个快照监听器以监听实时更新。每当消息中的文档被添加时,我们都会在那里获取已更改文档的快照,以便我们可以通过流将其发射给连接的消费者(在我们的案例中,是 MessagesRepository):

        val listenerRegistration =
        query.addSnapshotListener { snapshot, exception ->
            // If there's an exception, close the Flow with
               the exception
            if (exception != null) {
                close(exception)
                return@addSnapshotListener
            }

在通过流发送新消息之前,我们需要将它们映射到它们的领域对应物并提供它们的 ID。此外,userId 将被需要以确定是新用户编写了新消息,还是其他用户在对话中编写了消息:

            val messages = snapshot?.documents?.mapNotNull
            { doc ->
                val message =
                    doc.toObject(FirestoreMessageModel::
                    class.java)
                message?.copy(id = doc.id) // Copy the
                                              message with
                                              the document
                                              ID
            } ?: emptyList()
            val domainMessages = messages.map {
                it.toDomain(userId) }

最后,我们可以将消息列表发送到 Flow

            domainMessages.forEach {
                try {
                    trySend(it).isSuccess
                } catch (e: Exception) {
                    close(e)
                }
            }
        }

如果不再需要 Flow,我们应该移除快照监听器:

        awaitClose { listenerRegistration.remove() }
    }

我们还需要添加一个发送消息的功能。要发送消息,我们只需将其添加到具有相关对话 chatId 值的文档中的 messages 集合:

    fun sendMessage(chatId: String, message: Message) {
        val chatRef =
            firestore.collection("chats").document(chatId)
                .collection("messages")
        chatRef.add(FirestoreMessageModel
            .fromDomain(message))
    }
}

接下来,我们需要用 FirestoreMessagesDataSource 替换 MessagesRepository 中的先前 MessagesSocketDataSource 实例:

class MessagesRepository @Inject constructor(
    //private val dataSource: MessagesSocketDataSource
    private val dataSource: FirestoreMessagesDataSource
): IMessagesRepository {
    override suspend fun getMessages(chatId: String,
    userId: String): Flow<Message> {
        return dataSource.getMessages(chatId, userId)
    }
    override suspend fun sendMessage(chatId: String,
    message: Message) {
        dataSource.sendMessage(chatId, message)
    }
    override suspend fun disconnect() {
        // do nothing, Firestore data source is
           disconnected as soon as the flow has no
           subscribers
    }
}

经过一些小的修改,我们将集成这个新的提供者。好事是,因为我们一直在遵循 Clean Architecture 的工作方式,并且层与层之间存在映射,所以我们不需要在其他层中进行任何更改;例如,在 UsecasesViewModel 或 UI(除了在调用 getMessagessendMessage 方法时提供 chatId 值和 userId 值之外)。

我们也可以让两个数据源在同一个应用中共同存在(一个作为另一个的备用),因为仓库的作用是作为某个实体(在这种情况下,是消息)的不同数据源的协调者。我们将在下一章中了解更多关于这一点,因为我们将想要为我们的消息添加本地存储。

摘要

在本章中,我们探讨了为 Android 构建消息应用的各个方面。我们讨论了发送和接收消息的不同方法,例如使用 Ktor 的 WebSockets 或 Firebase Firestore。我们还介绍了如何使用 Clean Architecture 原则来构建应用,包括数据、领域和展示的独立层,以确保代码库的井然有序和易于维护,并展示了如何容易地引入更改(例如,消息提供者的更改),如果我们的架构组件很好地解耦。

然后,我们深入探讨了使用 Kotlin coroutines 和 Flow 处理连接错误和同步问题,实现了错误处理和重试机制,以确保无缝的用户体验。此外,我们还探讨了推送通知在消息应用中的重要性,并展示了如何使用 FCM 实现其功能,从在项目中设置 FCM 到处理传入的通知。

到本章结束时,你应该对构建一个健壮的实时消息应用所需的组件和技术有一个全面的理解。

现在,让我们继续学习如何优化我们的 WhatsPackt 应用,以便我们可以备份消息。

第三章:备份您的 WhatsPackt 消息

在任何聊天应用中,数据处理都是一个重要的问题——我们需要确保发送和接收的消息被正确存储,在需要时能够快速检索,并且对潜在的损失具有弹性,如设备故障或意外删除等不可预见的情况。这需要一个强大的数据持久化策略。我们还需要考虑性能和用户体验,这需要有效的缓存机制,并确保在数据丢失或用户更换设备时我们有备份。

在本章中,我们将首先向您介绍 Room,这是一个持久化库,它提供了一个在 SQLite 之上的抽象层,使得在 Android 中处理数据库变得更加容易。您将了解其架构和组件,以及如何使用它来存储和检索聊天对话和消息。

接下来,我们将处理创建一个缓存机制,协调 Room 在本地使用以及使用 API 从后端获取数据。

接下来,我们将让您熟悉 Firebase 存储。您将学习如何设置它,了解其优势,以及如何确保存储在其中的数据安全。然后我们将使用 Firebase 存储来创建聊天对话的备份,这对于任何聊天应用来说都是一个基本功能。

最后,我们将探讨如何使用WorkManager API,它使得即使在应用退出或设备重启的情况下也能轻松安排可延迟的异步任务。您将了解它如何用于安排聊天备份,以及如何将这些备份上传到Amazon Simple Storage ServiceAmazon S3),确保数据安全。

因此,在本章中,我们将涵盖以下主题:

  • 理解 Room

  • 在 WhatsPackt 中实现 Room

  • 了解 Firebase 存储

  • 安排WorkManager发送备份

  • 使用 Amazon S3 进行存储

技术要求

如前一章所述,您需要已安装 Android Studio(或您偏好的其他编辑器)。

我们还假设您已经跟随了前一章的内容。您可以从这里下载本章的完整代码:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-3

理解 Room

在 Android 开发中,最基本的一项任务就是管理您应用的数据在本地数据库中的存储。Android Jetpack 的一部分,Room持久化库,是在 Android 中自带的一个流行数据库 SQLite 之上的抽象层。Room 提供了更健壮的数据库访问,同时利用了 SQLite 的全部功能。

Room 的关键特性

在 Room 之前,开发者主要直接使用SQLite或其他对象关系映射(ORM)库。虽然 SQLite 功能强大,但使用起来可能很繁琐,因为它需要编写大量的样板代码。此外,SQL 查询中的错误通常直到运行时才会被发现,这可能导致崩溃。

Room 通过提供一个比标准 SQLite 更简单、更健壮的 API 来解决这些问题,用于管理本地数据存储。以下是它的一些关键特性:

  • SQL 查询的编译时验证:Room 在编译时验证您的 SQL 查询,而不是在运行时。这意味着如果您的查询中存在错误,您将在编译应用时立即知道,而不是在将应用发布给用户之后。这导致更健壮和可靠的代码。

  • 减少样板代码:使用 Room,您不需要编写太多代码来执行简单的数据库操作。这导致代码更清晰、更易于阅读。

  • 与其他架构组件的集成:Room 被设计成与其他Android 架构组件(AAC)库组件无缝集成,例如LiveDataViewModel。这意味着您可以创建一个结构良好、健壮的应用,遵循 Android 开发的最佳实践。

  • 易于迁移路径:Room 提供了强大的迁移支持,包括迁移路径和测试。随着您的应用数据需求的发展,Room 使您能够轻松地调整数据库结构以满足这些需求。

  • 支持复杂查询:尽管简化了与 SQLite 的交互,但 Room 仍然允许您在需要更多灵活性和功能时执行复杂的 SQL 查询。

如您所见,Room 提供了一种高效且简化的方法来管理您应用的本地区域数据。它是一个强大的工具,可以使您的 Android 开发体验更加愉快和高效。

Room 的架构和组件

房间架构基于三个主要组件:

  • 数据库

  • 实体

  • 数据访问对象(DAO

在这里,您可以看到每个 Room 组件如何与整个应用交互:

图 3.1:Room 架构图

图 3.1:Room 架构图

理解这些组件对于有效地使用 Room 至关重要,因此让我们更深入地探讨它们。

数据库

Room 中的Database类是一个高级类,作为您应用持久数据的主体访问点。它是一个抽象类,您在其中为应用中的每个@Dao注解定义一个抽象方法。当您创建Database类的实例时,Room 会生成这些 DAO 方法的实现代码(DAO 将在稍后详细介绍)。

Database类使用@Database注解,指定其包含的实体和数据库版本。如果您修改数据库模式,您需要更新版本号并定义一个迁移策略,如下例所示:

@Database(entities = [Message::class, Conversation::class],
    version = 1)
abstract class ChatAppDatabase : RoomDatabase() {
    abstract fun messageDao(): MessageDao
    abstract fun conversationDao(): ConversationDao
}

在这里,我们定义了一个包含两个实体MessageConversationChatAppDatabase Room Database类。我们还定义了访问我们的 DAO 的抽象方法 - messageDao()conversationDao()@Database注解中的entities参数接受数据库中所有实体的数组,而version参数用于数据库迁移目的。

实体

实体在 Room 中表示数据库中的表。每个实体对应一个表,每个实体的实例代表表中的一行。Room 使用实体中的类字段来定义表中的列。

您可以通过在数据类上标注@Entity来声明一个实体。每个@Entity类代表数据库中的一个表,并且您可以定义表名。如果您没有定义表名,Room 将使用类名作为表名,如下例所示:

@Entity(tableName = "messages")
data class Message(
    @PrimaryKey val id: String,
    @ColumnInfo(name = "conversation_id") val
        conversationId: String,
    // ...
)

在这里,Message是一个实体,代表我们数据库中的"messages"表。Message的每个实例将代表"messages"表中的一行。Message类中的每个属性代表表中的一列。@PrimaryKey注解用于表示主键,而@ColumnInfo注解用于指定数据库中的列名。如果没有指定,Room 将使用变量名作为列名。

DAO

DAO 是定义您想要执行的所有数据库操作的接口。对于每个 DAO,您可以定义不同的操作方法,例如插入、删除和查询。

您应该使用@Dao注解一个接口,然后使用相应的操作注解每个方法,例如@Insert@Delete@Update@Query用于自定义查询。然后,Room 将在编译时自动生成执行这些操作所需的代码。以下是一个示例:

@Dao
interface MessageDao {
    @Insert
    fun insert(message: Message)
    @Query("SELECT * FROM messages WHERE conversation_id =
        :conversationId")
    fun getMessagesForConversation(conversationId: String):
        List<Message>
}

在这个MessageDao接口中,我们定义了两个方法 - insert()用于将Message对象插入到我们的数据库中,以及getMessagesForConversation()从我们的数据库中检索与特定对话相关的所有消息。@Insert注解是一个方便的注解,用于将实体插入到表中。@Query注解允许我们编写 SQL 查询以执行复杂的读写操作。

理解这些组件将使我们能够有效地利用 Room 的强大功能。以下几节将指导您在 WhatsPackt 应用中实现 Room 的过程,从在 Android Studio 中设置它开始,到创建实体和 DAO。

在 WhatsPackt 中实现 Room

在本节中,您将指导我们在我们的聊天应用中实现 Room 的实际步骤。我们将从在 Android Studio 中设置 Room 开始,然后创建实体和 DAO,最终使用这些组件与我们的数据库进行交互。

添加依赖项

要开始使用 Room,我们首先需要在项目中包含必要的依赖项。打开你的 build.gradle 文件,并在 dependencies 下添加以下依赖项:

dependencies {
    implementation "androidx.room:room-runtime:2.3.0"
    kapt "androidx.room:room-compiler:2.3.0"
    implementation "androidx.room:room-ktx:2.3.0"
    // optional - Test helpers
    testImplementation "androidx.room:room-testing:2.3.0"
}

room-runtime 依赖项包括 Room 的核心库,而 room-compiler 依赖项是 Room 注解处理能力的必需品。Room 的 Kotlin 扩展和协程支持由 room-ktx 提供,而 room-testing 提供了用于测试 Room 设置的有用类。

添加这些行后,同步你的项目。你可以通过 build.gradle 文件来完成:

图 3.2:当 Android Studio 检测到 Gradle 文件有任何更改时出现的“同步现在”选项

图 3.2:当 Android Studio 检测到 Gradle 文件有任何更改时出现的“同步现在”选项

我们现在准备好创建我们的数据库。

创建数据库

如前所述,Database 组件是我们应用程序数据的主要访问点。因此,让我们创建一个 ChatAppDatabase 类:

@Database(entities = [Message::class, Conversation::class],
version = 1)
abstract class ChatAppDatabase : RoomDatabase() {
    abstract fun messageDao(): MessageDao
    abstract fun conversationDao(): ConversationDao
    companion object {
        @Volatile
        private var INSTANCE: ChatAppDatabase? = null
        fun getDatabase(context: Context): ChatAppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    ChatAppDatabase::class.java,
                    "chat_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

@Database 注解将这个类标记为 Room 数据库。它接受两个参数:

  • entities 是一个被 @Entity 注解的类数组,代表数据库中的表。在这种情况下,MessageConversation 类是 ChatAppDatabase 的实体。

  • version 是数据库版本。如果你对数据库模式进行了更改,你需要增加这个版本号并定义一个迁移策略。

接下来,abstract fun messageDao(): MessageDaoabstract fun conversationDao(): ConversationDao 是返回相应 DAO 的抽象方法。它们没有方法体,因为 Room 会生成它们的实现。

然后,我们通过使用 @Volatile 注解声明一个伴随对象来持有 ChatAppDatabase 的单例实例。这个注解意味着 INSTANCE 可以被多个线程同时访问,但总是处于一致的状态,这意味着一个线程对 INSTANCE 的更改会立即对所有其他线程可见。INSTANCE 被标记为可空,因为它可能不会立即初始化。

getDatabase() 函数中,我们正在实现创建类单例实例的常见模式,以线程安全的方式。这个模式确保 ChatAppDatabase 只会创建一个实例。

我们使用 ?: 操作符来检查 INSTANCE 是否不是 null,如果是,则进入同步块。这个块确保一次只有一个线程可以进入这段代码,防止在多个线程从多个线程并发调用该函数时创建多个 ChatAppDatabase 实例。

在同步块内,我们调用 Room.databaseBuilder() 来创建 ChatAppDatabase 的新实例。我们提供应用程序上下文以避免内存泄漏,数据库的类以及数据库的名称。

最后,我们调用 build() 来创建 ChatAppDatabase 实例。

在创建新实例后,我们将其分配给INSTANCE以缓存它,然后返回实例。下次调用getDatabase时,它将返回缓存的数据库实例而不是创建一个新的实例。这很重要,因为创建 Room 数据库实例是一个昂贵的操作,拥有多个实例将会浪费资源。

这种结构对于创建一个数据库实例至关重要,这将使我们能够存储消息和对话。

下一步是创建实体类。

创建实体类

我们将要创建的第一个实体类是Message类:

@Entity(
    tableName = "messages",
    foreignKeys = [
        ForeignKey(
            entity = Conversation::class,
            parentColumns = arrayOf("id"),
            childColumns = arrayOf("conversation_id"),
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [
        Index(value = ["conversation_id"])
    ]
)
data class Message(
    @PrimaryKey(name = "id") val id: Int,
    @ColumnInfo(name = "conversation_id") val
        conversationId: Int,
    @ColumnInfo(name = "sender") val sender: String,
    @ColumnInfo(name = "content") val content: String,
    @ColumnInfo(name = "timestamp") val timestamp: Long
)

在这段代码中,我们在注解中包含了相当多的指令,所以让我们逐一解释它们。

@Entity注解告诉 Room 将这个类作为数据库中的表处理。它带有可选参数,其中一些在这里使用:

  • tableName: 这设置了表在数据库中的名称。在这种情况下,我们的表将命名为"messages"

  • foreignKeys: 这设置了与另一个表的外键关系。一个ForeignKey实例接受四个主要参数:

    • entity: 这表示与该实体有关系的父表类。在这种情况下,它是Conversation::class

    • parentColumns: 这指定了父实体中外键引用的列。在这里,它是Conversationid字段。

    • childColumns: 这指定了子实体中包含外键的列。在这里,它是Message中的conversation_id字段。

    • onDelete: 这表示如果父表中的引用行被删除,将采取的操作。在这里,使用ForeignKey.CASCADE,这意味着如果删除Conversation实例,所有具有conversation_id值引用对话 ID 的消息也将被删除。

  • indices: 这用于在conversation_id上创建索引以加快查询速度。索引以额外的磁盘空间和较慢的写入速度为代价加快数据检索速度。索引在这里特别有用,因为我们经常会执行与特定对话相关的操作,对conversation_id进行索引将使这些操作更高效。

然后,我们还添加了注解到类的属性中:

  • @PrimaryKey: 这个注解表示id字段是Message表的唯一键。主键唯一标识表中的每一行。这里我们可以使用autoGenerate = true,这意味着这个字段将为每一行新行自动填充一个递增的整数。

  • @ColumnInfo(name = "column_name"): 这个注解允许你在数据库中指定自定义列名。如果没有指定,Room 将使用变量名作为列名。

现在,让我们创建一个Conversation实体:

@Entity(
    tableName = "conversations",
)
class Conversation(
    @PrimaryKey
    @ColumnInfo(name = "id") val id: String,
    @ColumnInfo(name = "last_message_time") val
        lastMessageTime: Long
)

Conversation实体非常简单——我们只需在数据库中存储Conversation ID 和对话中最后一条消息的时间。

现在我们已经创建并定义了我们的实体,是时候创建 DAO 以获取和更新数据了。

创建 DAO

DAO 是一个接口,它作为应用程序代码和数据库之间的通信层。它定义了我们在数据库中的实体可能执行的所有操作的函数。

让我们从Message实体的 DAO 开始:

@Dao
interface MessageDao {
    @Query("SELECT * FROM messages WHERE conversation_id =
        :conversationId ORDER BY timestamp ASC")
    fun getMessagesInConversation(conversationId: Int):
        Flow<List<Message>>
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertMessage(message: Message): Long
    @Delete
    suspend fun deleteMessage(message: Message)
}

分析代码,我们有以下内容:

  • @Dao:这个注解用于标识接口作为 DAO。

  • @Query:这个注解用于指定复杂数据检索任务的 SQL 语句。

  • @Insert:这个注解用于定义一个方法,将它的参数插入到数据库中。OnConflictStrategy.REPLACE意味着如果已经存在具有相同主键的消息,它将被新的消息替换。

  • @Delete:这个注解用于定义一个方法,从数据库中删除它的参数。

现在,让我们为Conversation实体创建一个 DAO:

@Dao
interface ConversationDao {
    @Query("SELECT * FROM conversations ORDER BY
        last_message_time DESC")
    fun getAllConversations(): Flow<List<Conversation>>
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertConversation(conversation:
        Conversation): Long
    @Delete
    suspend fun deleteConversation(conversation:
        Conversation)
}

注解在这里的作用与在MessageDao中一样。在这里,我们正在按最后一条消息的时间顺序检索所有对话,并且我们有插入和删除对话的方法。

现在,我们需要为其他应用组件提供这些 DAO,以便它们可以被注入。考虑到这一点,我们将创建以下模块:

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext:
    Context): ChatAppDatabase {
        return ChatAppDatabase.getDatabase(appContext)
    }
    @Provides
    fun provideMessageDao(database: ChatAppDatabase):
    MessageDao {
        return database.messageDao()
    }
    @Provides
    fun provideConversationDao(database: ChatAppDatabase):
    ConversationDao {
        return database.conversationDao()
    }
}

由于我们已经在之前的章节中介绍了 Hilt 模块的创建,我们不会再次遍历所有代码。相反,以下是代码的关键部分:

  • 我们使用@Singleton来表示应该只创建一个对象实例,并将其作为依赖项提供。

  • 我们想要使用的Context可能会让我们感到困惑或提供一个不适合这种情况的Context。使用@ApplicationContext限定符将确保注入的Context将是预期的(在这种情况下是Application Context)。

现在,就像我们在上一章中为 API 或 WebSocket 所做的那样,我们将创建一个数据源来连接数据库:LocalMessagesDataSource

创建一个 LocalMessagesDataSource 数据源

我们需要创建一个LocalMessagesDataSource数据源,它将封装 DAO 并暴露我们应用需要的特定数据库操作。这样,如果我们将来决定更改数据库,我们只需在这里更改(而不是在其他消费者中更改)。这个类将在更高层次的抽象中作为 DAO,简化我们应用其余部分的 API,并使在测试中模拟数据库变得更加容易。

在下面的代码中,我们只是调用我们在 DAO 中已经定义的函数:

class MessagesLocalDataSource @Inject constructor(private
val messageDao: MessageDao) {
    fun getMessagesInConversation(conversationId: Int):
    Flow<List<Message>> {
        return
            messageDao.getMessagesInConversation(
                conversationId)
    }
    suspend fun insertMessage(message: Message): Long {
        return messageDao.insertMessage(message)
    }
    suspend fun deleteMessage(message: Message) {
        messageDao.deleteMessage(message)
    }
}

正如我们之前所说的,我们将使用这个数据源来封装数据库并提供额外的抽象层。

现在,是时候将这个本地数据源与远程数据源结合起来。这将迫使我们考虑缓存策略。

在 MessagesRepository 组件中处理两个数据源

到目前为止,我们只有一个数据源(WebSocket 数据源),但我们希望用户即使在短时间内没有连接也能检索到他们的消息。这就是我们刚刚创建数据库并使其准备就绪的原因。

由于我们的用例是为 WebSocket 提供后备,以便用户可以继续检查他们的消息,我们将遵循一种策略,其中主要的事实来源将继续是 WebSocket,但我们将在应用数据库中存储消息的副本。此外,我们不想让这个数据库的记录无限增长,所以我们为每场对话设置了 100 条消息的上限。

负责组合两个数据源的组件是 MessagesRepository,我们在上一章中已经实现,使其连接到 WebsocketDataSource。现在让我们修改它以包含两个数据源,并协调数据检索和本地存储:

class MessagesRepository @Inject constructor(
    private val dataSource: MessagesSocketDataSource,
    private val localDataSource: DatabaseDataSource
): IMessagesRepository {

接下来,我们将修改 getMessages() 方法,以包含将来自 MessagesSocketDataSource(远程数据源)的信息存储到 DatabaseDataSource(本地数据源)的逻辑:

override suspend fun getMessages(chatId: String, userId:
String): Flow<Message> {
        return flow {
            try {
                dataSource.connect().collect { message ->
                    localDataSource.insertMessage(message)
                    emit(message)
                    manageDatabaseSize()
                }
            } catch (e: Exception) {
                localDataSource.getMessagesInConversation(
                chatId.toInt()).collect {
                    it.forEach { message -> emit(message) }
                }
            }
        }
    }

如所见,我们已经连接到套接字数据源,但我们把这个动作包裹在一个 try-catch 块中。所以,如果一切顺利,我们将把每条新消息存储到我们的数据库中,然后将其在流程中发出。

同时,我们调用 manageDatabaseSize(),这将检查并保持数据库的大小在我们设定的限制之下(每场对话最多 100 条消息)。如果套接字失败,我们将直接从数据库中检索消息。

现在,我们还将修改 sendMessage 方法,其中我们将存储每条新发送的消息:

    override suspend fun sendMessage(chatId: String,
    message: Message) {
        dataSource.sendMessage(message)
        localDataSource.insertMessage(message)
    }

断开连接将与之前保持一致,因为我们不需要做任何与新的数据源相关的事情:

    override suspend fun disconnect() {
        dataSource.disconnect()
    }

最后,这是我们将实施的机制,以保持数据库的大小在商定的每场对话消息数量之下:

    private suspend fun manageDatabaseSize() {
        val messages =
            localDataSource.getMessagesInConversation(
                chatId.toInt()).first()
        if (messages.size > 100) {
            // Delete the oldest messages until we have 100
               left
            messages.sortedBy { it.timestamp
            }.take(messages.size - 100).forEach {
                localDataSource.deleteMessage(it)
            }
        }
    }
}

我们将获取与对话相关的所有消息,并检查其大小是否超过 100。然后,我们将根据它们的时间戳对它们进行排序,并移除最旧的那些。

现在,我们已经将 Room 数据库集成到我们的应用中。即使我们失去连接,我们最后的消息也将可用。在下一节中,让我们看看我们如何也将这些消息的备份发送到云端存储。为此,我们将使用 Firebase 存储。

了解 Firebase 存储

Firebase 存储,也称为 Firebase 云存储,是一个为 Google 规模构建的强大对象存储服务。它使开发者能够存储和检索用户生成的内容,如照片、视频或其他形式用户数据。Firebase 存储由 Google Cloud StorageGCS)支持,使其对任何大小的数据都稳健且可扩展,从小型文本文件到大型视频文件。

这里是 Firebase 存储的一些关键特性和功能:

  • 用户生成内容:Firebase Storage 允许你的用户直接从他们的设备上传自己的内容。这可能包括从个人资料图片到博客文章的任何内容。

  • 与 Firebase 和 Google Cloud 的集成:Firebase Storage 与 Firebase 生态系统的其余部分无缝集成,包括 Firebase 身份验证和 Firebase 安全规则。它也是更大 Google Cloud 生态系统的一部分,这为使用 Google Cloud 的高级功能,如 Cloud Functions,提供了可能性。

  • 安全性:Firebase Storage 提供了强大的安全功能。使用 Firebase 安全规则,你可以控制谁可以访问哪些数据。你可以根据用户的认证状态、身份和声明、数据模式和元数据来限制访问。

  • 可扩展性:Firebase Storage 被设计用来处理大量的上传、下载和数据存储。它能够根据你的用户基础和流量自动扩展,这意味着你不需要担心容量规划。

  • 离线功能:Firebase Cloud Storage 的软件开发工具包SDKs)为你的 Firebase 应用添加了 Google 安全,无论网络质量如何,都可以用于文件的上传和下载。你可以用它来暂停、恢复和取消传输。

  • 丰富媒体:Firebase Storage 支持丰富媒体内容。这意味着你可以用它来存储图片、音频、视频,甚至是其他二进制数据。

  • 强一致性:Firebase Storage 保证强一致性,这意味着一旦上传或下载完成,数据将立即从所有 Google Cloud Storage 位置可用,并且后续的读取将返回最新更新的数据。

在我们的上下文中,一个消息应用,Firebase Storage 可以用来存储和检索消息历史或备份、共享文件,甚至在对话中存储多媒体内容。这可以作为可靠的备份解决方案或跨多个设备同步聊天历史的方法。然而,你需要确保你处理隐私和安全问题,特别是由于聊天对话可能包含敏感数据。

如何使用 Firebase Storage

在 Firebase Storage 中,数据以对象的形式存储在分层结构中。Firebase Storage 中对象的完整路径包括项目 ID 和对象在存储桶中的位置。

注意

在云存储的上下文中,一个是一个基本容器,用于存储数据。它是数据组织层次结构中的主要父容器。云存储中的所有数据都存储在桶中。桶的概念被许多云存储系统使用,包括 GCS、Amazon S3 和 Firebase Storage。这些系统通常允许你在存储空间中创建一个或多个桶,然后将数据作为对象或文件上传到这些桶中。每个桶在云存储系统中都有一个唯一的名称,它包含数据对象或文件,每个对象或文件都由一个键或名称标识。

对象的位置由您指定的路径定义。此路径类似于文件系统路径,包括目录和文件名。例如,在 images/profiles/user123.jpg 路径中,imagesprofiles 是目录,而 user123.jpg 是文件名。

当您将文件上传到 Firebase 存储时,您创建一个指向您将要存储文件的位置的引用。此引用由一个 StorageReference 对象表示,您通过在指向您的 Firebase 存储 bucket 的引用上调用 child() 方法并传递路径作为参数来创建它,如下例所示:

val storageRef = Firebase.storage.reference
val fileRef =
storageRef.child("images/profiles/user123.jpg")

在这里,fileRefimages 目录中 profiles 目录内 user123.jpg 文件的引用。

您可以使用此引用执行各种操作,例如上传文件、下载文件或获取文件的 URL。这些操作中的每一个都返回一个 Task 对象,您可以使用它来监控操作进度或获取其结果。

Firebase 存储中的路径是灵活的,您可以根据应用程序的需要对其进行结构化。例如,在消息传递应用程序中,您可能将对话记录存储在 chat_logs 目录中,每个日志的文件名是聊天 ID。聊天日志的路径可能如下所示:chat_logs/chat123.txt

最后,值得注意的是,Firebase 存储使用规则来控制谁可以读取和写入您的存储 bucket。默认情况下,只有经过身份验证的用户可以读取和写入数据。您可以根据应用程序的需求自定义这些规则。

让我们开始在我们的项目中设置 Firebase 存储。

设置 Firebase 存储

要开始使用 Firebase 存储,我们首先需要将 Firebase Cloud Storage Android 库添加到我们的应用程序中。这可以通过将以下行添加到我们的模块的 build.gradle 文件中完成:

implementation 'com.google.firebase:firebase-storage-ktx'

关于聊天消息,一种方法是将聊天记录保存为 Firebase Storage 中的文本文件。每个对话可以有自己的文本文件,每条消息都是该文件中的一行。因此,我们将创建一个数据源来上传这些文件:

class StorageDataSource @Inject constructor(private val
firebaseStorage: FirebaseStorage) {
    suspend fun uploadFile(localFile: File, remotePath:
    String) {
        val storageRef =
            firebaseStorage.reference.child(remotePath)
        storageRef.putFile(localFile.toUri()).await()
    }
    suspend fun downloadFile(remotePath: String, localFile:
    File) {
        val storageRef =
            firebaseStorage.reference.child(remotePath)
        storageRef.getFile(localFile).await()
    }
}

在这里,我们将 Firebase 存储实例作为构造函数的参数添加,允许在通过 Hilt 实例化类时注入。uploadFiledownloadFile 方法使用 await() 扩展函数挂起协程,直到上传或下载操作完成。

为了能够使用 Firebase 存储实例,我们需要提供 FirebaseStorage 依赖项。为此,我们需要创建以下模块,以便 Hilt 了解如何获取它:

@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
    @Singleton
    @Provides
    fun provideFirebaseStorage(): FirebaseStorage =
        FirebaseStorage.getInstance()
}

现在,我们需要创建这些文件,然后使用此数据源上传。我们将在新创建的存储库中完成此操作:BackupRepository

BackupRepository仓库将在不同数据源(如通过 DAO 的本地数据库和远程数据源,如 Firebase 存储)与应用程序的其余部分之间充当中介。它从源检索数据,如有必要,对其进行处理,并以方便的形式将其提供给调用代码。

这是此存储库的代码:

class BackupRepository @Inject constructor(
    private val messageDao: MessageDao,
    private val conversationDao: ConversationDao,
    private val storageDataSource: StorageDataSource
) {
    private val gson = Gson()
    suspend fun backupAllConversations() {
        // Get all the conversations
        val conversations =
            conversationDao.getAllConversations()
        // Backup each conversation
        for (conversation in conversations) {
            val messages =
                messageDao.getMessagesForConversation(
                    conversation.conversationId)
            // create a JSON representation of the messages
            val messagesJson = gson.toJson(messages)
            // create a temporary file and write the JSON
               to it
            val tempFile = createTempFile("messages",
                ".json")
            tempFile.writeText(messagesJson)
            // upload the file to Firebase Storage
            val remotePath =
               "conversations/${conversation.conversationId
               }/messages.json"
            storageDataSource.uploadFile(tempFile,
               remotePath)
            // delete the local file
            tempFile.delete()
        }
    }
    private fun createTempFile(prefix: String, suffix:
    String): File {
        // specify the directory where the temporary file
           will be created
        val tempDir =
            File(System.getProperty("java.io.tmpdir"))
        // create a temporary file with the specified
           prefix and suffix
        return File.createTempFile(prefix, suffix, tempDir)
    }
}

如代码所示,它使用ConversationDao从本地数据库中检索所有对话。每个对话代表一个独特的聊天线程。

然后,对于每个对话,它使用MessageDao检索相关的消息,使用 Gson 库将消息转换为 JSON 字符串,将此 JSON 字符串写入临时文件,然后通过StorageDataSource将文件上传到 Firebase 存储。

一旦 Firebase 存储的上传完成,它将删除本地临时文件以清理设备上的存储空间。

BackupRepository处理所有数据检索、处理和存储的细节。应用程序的其他部分不需要知道数据是如何存储或检索的。它们只需与BackupRepository交互,该仓库为这些操作提供了一个简单的接口。这使得代码更容易维护、理解和测试。

最后,我们将创建UploadMessagesUseCase,它将负责执行上传操作。

创建UploadMessagesUseCase

UploadMessagesUseCase的责任将是使用BackupRepository执行备份。由于大部分逻辑已经在仓库中,代码将更简单,看起来像这样:

class UploadMessagesUseCase @Inject constructor(
    private val backupRepository: BackupRepository
) {
    suspend operator fun invoke() {
        backupRepository.backupAllConversations()
    }
}

现在,我们已经准备好检索和上传这些备份。由于这可能是一个耗时且资源消耗的任务,所以想法是定期执行,每周或每天一次。这就是WorkManager派上用场的地方。

安排WorkManager发送备份

WorkManager是用于需要保证和高效执行的任务的推荐工具。

WorkManager使用基于以下标准的底层作业调度服务:

  • 它使用JobScheduler为 API 23 及以上的设备

  • 对于 API 14 至 22 的设备,它使用BroadcastReceiver(用于系统广播)和AlarmManager的组合。

  • 如果应用程序包含可选的WorkManager依赖项 Firebase JobDispatcher,并且设备上可用的 Google Play 服务,WorkManager将使用 Firebase JobDispatcher

WorkManager根据设备 API 级别和包含的依赖项选择安排后台任务的最佳方式。要使用WorkManager,我们首先需要了解如何创建WorkerWorkRequest实例。

介绍Worker

创建一个Worker类(如果你使用 Kotlin 协程,则为CoroutineWorker),并重写doWork()方法来定义任务应该执行的操作。

doWork() 方法是放置需要在后台执行代码的地方。这是你定义需要执行的操作的地方,例如从服务器获取数据、上传文件、处理图像等等。

每个 Worker 实例有最多 10 分钟的时间来完成其执行并返回一个 Result 实例。Result 实例可以是三种类型之一:

  • Result.success(): 表示工作成功完成。你可以选择返回一个 Data 对象,该对象可以用作此工作的输出数据。

  • Result.failure(): 表示工作失败。你可以选择返回一个 Data 对象来描述失败。

  • Result.retry(): 表示工作失败,应该根据其重试策略在另一时间尝试。

Worker 的一个独特特性是,当 Worker 实例正在运行且应用进入后台时,Worker 实例可以继续运行,而如果设备在 Worker 实例运行时重启,任务可以在设备恢复后继续。这确保了即使在你的应用进程不存在的情况下,工作也会在创建 WorkRequest 实例时指定的约束下执行。

下面是一个基本的 Worker 类的示例:

class ExampleWorker(appContext: Context, workerParams:
WorkerParameters)
    : Worker(appContext, workerParams) {
    override fun doWork(): Result {
        // Code to execute in the background
        return Result.success()
    }
}

在示例中,我们扩展了 Worker 类并重写了 doWork() 方法来指定要执行的任务。在这种情况下,我们只是返回成功的结果,但实际工作的代码将放在 // Code to execute in the background 注释所在的位置。

要使我们的 Worker 实例工作,我们需要另一个组件:WorkRequest。让我们看看我们如何配置和使用它。

配置 WorkRequest 组件

WorkRequest 是一个定义单个工作单元的类。它封装了你的 Worker 类,以及工作运行必须满足的任何约束以及它需要的任何输入数据。

有两种具体的 WorkRequest 实现你可以使用:

  • OneTimeWorkRequest: 如其名所示,这代表一个一次性作业。它只会执行一次。

  • PeriodicWorkRequest: 这用于执行周期性任务的重复作业。可以定义的最小重复间隔是 15 分钟。这个限制在官方文档中有进一步的讨论:developer.android.com/reference/androidx/work/PeriodicWorkRequest.

WorkRequest 有几个选项可以设置工作执行的条件以及安排多个工作按特定顺序运行:

  • 约束WorkRequest实例可以设置Constraints对象,这允许您指定工作必须满足的条件才能有资格运行。例如,您可能需要设备处于空闲或充电状态,或者具有某种类型的网络连接。我们将在接下来的几段中详细了解这些条件。

  • 输入数据:您可以使用setInputData()方法将输入数据附加到WorkRequest实例,为您的Worker实例提供完成工作所需的所有信息。

  • 回退标准:您可以设置WorkRequest实例的回退标准,以控制工作失败时的重试时间。

  • 标签:您还可以向您的WorkRequest实例添加标签,这将使跟踪、观察或取消特定工作组的任务变得更加容易。

  • 工作链式化WorkManager允许您创建依赖的工作链。这意味着您可以确保某些工作以特定的顺序执行。您可以创建复杂的链,以特定的顺序运行一系列WorkRequest对象。

WorkManager提供了几种类型的约束,您可以在WorkRequest对象上设置这些约束,以指定任务应在何时运行。这是通过使用Constraints.Builder类来完成的。以下是您可以设置的可用约束:

  • 网络类型setRequiredNetworkType):此约束指定必须可用的网络类型,以便工作可以运行。选项包括NetworkType.NOT_REQUIREDNetworkType.CONNECTEDNetworkType.UNMETEREDNetworkType.NOT_ROAMINGNetworkType.METERED

  • 电池电量不高setRequiresBatteryNotLow):如果此约束设置为true,则工作仅在电池电量不高时运行。

  • 设备空闲setRequiresDeviceIdle):如果此约束设置为true,则工作仅在设备处于空闲模式时运行。这通常是在用户一段时间内未与设备交互时。

  • 存储空间不高setRequiresStorageNotLow):如果设置为true,则工作仅在存储空间不高时运行。

  • 设备充电setRequiresCharging):如果设置为true,则工作仅在设备充电时运行。

下面是一个配置WorkRequest实例的示例:

val constraints = Constraints.Builder()
    .setRequiresCharging(true)
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()
val workRequest = OneTimeWorkRequestBuilder<MyWorker>()
    .setConstraints(constraints)
    .addTag("myWorkTag")
    .build()

在此示例中,MyWorker仅在设备充电、连接到网络且电池电量不高时运行。它还将有一个标签,这将使我们能够轻松识别它。

这是一个流程图,展示了执行WorkerWorkRequest实例时遵循的流程:

图 3.3:执行 WorkRequest 实例的 WorkManager 流程图

图 3.3:执行 WorkRequest 实例的 WorkManager 流程图

我们现在有了构建自己的Worker实例和配置WorkRequest实例以检索和上传备份的工具。那么,让我们实际创建它们。

创建我们的 Worker 实例

首先,为了支持WorkManager API,我们需要在我们的代码中包含相关的依赖项:

dependencies {
    implementation "androidx.work:work-runtime-ktx:$2.9.0"
    // Hilt AndroidX WorkManager integration
    implementation 'androidx.hilt:hilt-work:$2.44
    ...
}

正如我们之前看到的,Worker类将在应用未被使用时执行后台可运行的任务。换句话说,它是一个可以在特定条件下安排运行的工作单元。在我们的案例中,我们刚刚创建了相应的逻辑(在UploadMessagesUseCase中),因此我们的Worker类需要访问这个类。

这也是为什么我们将开始添加HiltWorker,这是由 Hilt 的androidx.hilt扩展库提供的注解。这个注解告诉 Hilt 它应该创建一个可注入的Worker实例(即,Hilt 应该管理这个Worker实例的依赖项)。

这里是我们Worker类的完整代码:

@HiltWorker
class UploadMessagesWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    private val uploadMessagesUseCase:
        UploadMessagesUseCase
) : CoroutineWorker(appContext, workerParams) {
    override suspend fun doWork(): Result = coroutineScope
    {
        try {
            uploadMessagesUseCase.execute()
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < MAX_RETRIES) {
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }
    companion object {
        private const val MAX_RETRIES = 3
    }
}

我们还使用了一个新的注解:AssistedInject。现在,AssistedInject是 Dagger Hilt 的一个特性,它有助于处理需要注入一些依赖项但同时在运行时还需要提供一些参数的场景。在这里,构造函数的appContextworkerParams参数在运行时提供(当Worker实例由WorkManager创建时),而uploadMessagesUseCase是一个应该被注入的依赖项。

doWork()函数是定义这个Worker实例应该执行的工作的地方。这个函数是一个挂起函数,并在协程作用域内运行。这意味着它可以执行长时间运行的操作,如网络请求或数据库操作,而不会阻塞主线程。

doWork()中,调用uploadMessagesUseCase.execute()来执行上传消息的实际工作。如果此操作成功,则返回Result.success()。如果抛出Exception错误,并且runAttemptCount小于MAX_RETRIES,则返回Result.retry(),这意味着应该重试工作。如果runAttemptCount等于或超过MAX_RETRIES,则返回Result.failure(),这意味着不应该重试工作。

由于我们希望它只重试三次,我们使用了runAttemptCount,这是由ListenableWorkerCoroutineWorker的父类)提供的属性,它跟踪工作尝试了多少次。

最后,MAX_RETRIES是一个定义最大重试次数的常量。在这个例子中,它被设置为3

总结来说,这个Worker实例通过调用uploadMessagesUseCase.execute()来上传消息,并且在失败的情况下可以重试操作最多三次。这个Worker实例的实际依赖项(UploadMessagesUseCase)是通过WorkRequest类提供的。

设置WorkRequest

WorkRequest类的情况下,我们需要考虑我们希望消息备份的频率;例如,我们可以每周备份一次。此外,我们将配置WorkRequest类,使其仅在用户有 Wi-Fi 连接时被调用。以下是我们的做法:

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .build()
val uploadMessagesRequest =
PeriodicWorkRequestBuilder<UploadMessagesWorker>(7,
TimeUnit.DAYS)
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.LINEAR,
        PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS,
        TimeUnit.MILLISECONDS)
    .build()
WorkManager.getInstance(this).enqueue(
    uploadMessagesRequest)

我们使用PeriodicWorkRequestBuilder创建一个WorkRequest实例,该实例每周运行一次UploadMessagesWorkerWorkRequest实例有一个约束条件,要求有一个非计费的网络连接(Wi-Fi)。它还指定了一个线性退避策略用于重试——这意味着每次重试尝试都会延迟固定的时间,并且随着后续重试的进行而线性增加。

enqueue()方法安排WorkRequest实例运行。如果满足约束条件,并且队列中没有其他工作在其前面,它将立即开始运行。否则,它将等待直到满足约束条件,并且它是队列中的WorkRequest实例的轮次。

请注意,由于操作系统限制,PeriodicWorkRequest实例可能不会在周期结束时正好运行;它可能会有一些延迟,但至少会在该时间段内运行一次。

我们可以从应用中的任何地方调用此代码并将WorkRequest实例入队,但要确保它被安排,最方便的地方是在我们启动应用时,在WhatsPacktApplication.onCreate方法中:

@HiltAndroidApp
class WhatsPacktApp: Application() {
    override fun onCreate() {
        super.onCreate()
        //Include WorkRequest initialization here
}
}

在所有这些准备就绪之后,我们的应用可以定期备份消息,我们的工作可能就已经完成了。然而,为了探索不同的方法,让我们看看如果我们需要集成另一个存储提供商会发生什么——例如,Amazon S3。

使用 Amazon S3 进行存储

Amazon S3是一个可扩展、高速、基于网络的云存储服务,旨在为Amazon Web ServicesAWS)上的数据和应用提供在线备份和存档。它是 Firebase Storage 的一个知名替代品。

下面是 Amazon S3 一些关键功能和能力的简要概述:

  • 存储:Amazon S3 可以存储任何数量的数据,并可以从网络上的任何地方访问它。它提供了几乎无限的存储空间。

  • 持久性和可用性:Amazon S3 被设计为 99.999999999%(11 个 9)的持久性,并在多个地理上分离的数据中心存储数据的冗余副本。它还提供在给定一年内的 99.99%对象可用性。

  • 安全性:Amazon S3 提供了高级安全功能,如静态数据和传输中的加密,以及使用 AWS 身份和访问管理IAM)、访问控制列表ACLs)和存储桶策略对资源的细粒度访问控制。

  • 可扩展性:Amazon S3 被设计用于扩展存储、请求和用户,以支持无限数量的网络规模应用程序。

  • 性能:AWS 存储确保在您添加或删除文件时,您可以立即读取文件的最新版本。如果您覆盖文件或删除它,这些更改可能需要一些时间才能在所有地方完全更新。

  • 集成:Amazon S3 与其他 AWS 服务很好地集成,例如 AWS CloudTrail 用于日志记录和监控、Amazon CloudFront 用于内容分发、AWS Lambda 用于无服务器计算等。

  • 管理功能:S3 提供了用于管理任务的功能,例如组织数据和配置精细的访问控制以满足特定的业务、组织和合规性要求。

  • 数据传输:S3 传输加速允许在您的客户端和 Amazon S3 存储桶之间快速、轻松且安全地在长距离上传输文件。

  • 存储类别:Amazon S3 为不同类型的存储需求提供了几种存储类别,例如 S3 Standard 用于频繁访问数据的通用存储,S3 Intelligent-Tiering 用于访问模式未知或变化的存储,S3 Standard-IA 用于长期但很少访问的数据,以及 S3 Glacier 用于长期归档和数字保存。

  • 本地查询功能:S3 Select 允许应用程序通过使用简单的 SQL 表达式从对象中检索仅部分数据。

这些功能使 Amazon S3 成为各种用例的强大且灵活的选择,从 Web 应用程序到备份和恢复、归档、企业应用程序、物联网设备和大数据分析。

要实现基于 Amazon S3 的存储解决方案,我们首先需要将 AWS SDK 集成到我们的应用程序中。

集成 AWS S3 SDK

我们可以通过在build.gradle文件中添加以下依赖项将 AWS S3 SDK 集成到我们的 Android 项目中:

implementation 'com.amazonaws:aws-android-sdk-s3:
$latest_version'
implementation 'com.amazonaws:aws-android-sdk-
cognitoidentityprovider:$latest_version'

我们在这里添加了 AWS SDK 和用于使用 Amazon Cognito 的依赖项。

我们还需要向 SDK 提供我们的 AWS 凭证(访问密钥 ID 和秘密访问密钥)。对于移动应用程序,建议使用 Amazon Cognito 进行凭证管理。

设置 Amazon Cognito

Amazon Cognito是一项提供用户注册和登录服务以及移动和 Web 应用程序访问控制的服务。当您为用户池使用 Amazon Cognito 时,您可以选择在 AWS 服务(如用于文件存储的 Amazon S3)中安全地存储您的数据,而无需在应用程序代码中嵌入 AWS 密钥,这是一个重大的安全风险。

下面是在我们的 Android 应用程序中设置 Amazon Cognito 的说明:

  1. 首先,转到 Amazon Cognito 控制台:console.aws.amazon.com/cognito/home

  2. 从那里,点击身份池,然后点击创建新的 身份池

  3. 认证部分下检查访客访问,然后点击下一步

  4. 选择创建新的 IAM 角色,为其创建一个名称,然后点击下一步

  5. 然后为身份池创建一个新的名称并点击下一步

  6. 查看摘要(如图 3.4),然后点击创建 身份池

图 3.4:新的身份池配置

图 3.4:新的身份池配置

注意

在这里,我们正在启用对未认证身份的访问。您还可以选择仅对认证身份提供访问权限,但请注意,您将不得不在 Amazon Cognito 中创建每个用户。尽管如此,这种方法比使用 S3 SDK 在我们的应用代码中存储密钥更安全。

  1. 接下来,在我们的应用中,我们需要获取 AWS 凭证提供者。为此,我们将使用我们的 IdentityPoolId 类初始化 CognitoCachingCredentialsProvider,在配置的区域:

    val credentialsProvider =
    CognitoCachingCredentialsProvider(
        applicationContext,
        "IdentityPoolId", // Identity Pool ID
        Regions.US_EAST_1 // Region
    )
    
  2. 现在,我们可以在创建 AWS 服务客户端时使用凭证提供者实例。例如,要与其配合使用 Amazon S3,请使用以下代码:

    val s3 = AmazonS3Client(credentialsProvider)
    

现在,是时候创建一个新的存储提供者了。

创建 AWS S3 存储提供者并将其集成到我们的代码中

现在,我们需要做与 Firebase Storage 相同的事情,但使用 AWS SDK:创建一个提供者。这个提供者将是 AWSS3Provider,并用于处理文件上传到 AWS S3。它将接受一个 Context 对象和一个 CognitoCachingCredentialsProvider 对象作为构造函数参数。

这就是我们可以这样实现的方式:

class AWSS3Provider(
    private val context: Context,
    private val credentialsProvider:
        CognitoCachingCredentialsProvider
) {
    suspend fun uploadFile(bucketName: String, objectKey:
    String, filePath: String) {
        withContext(Dispatchers.IO) {
            val transferUtility = TransferUtility.builder()
                .context(context)
                .awsConfiguration(AWSMobileClient
                    .getInstance().configuration)
                .s3Client(AmazonS3Client(
                    credentialsProvider))
                .build()
            val uploadObserver = transferUtility.upload(
                bucketName,
                objectKey,
                File(filePath)
            )
            uploadObserver.setTransferListener(object :
            TransferListener {
                override fun onStateChanged(id: Int, state:
                TransferState) {
                    if (TransferState.COMPLETED == state) {
                        // The file has been uploaded
                           successfully
                    }
                }
                override fun onProgressChanged(id: Int,
                bytesCurrent: Long, bytesTotal: Long) {
                    val progress = (bytesCurrent.toDouble()
                        / bytesTotal.toDouble() * 100.0)
                    Log.d("Upload Progress", "$progress%")
                }
                override fun onError(id: Int, ex:
                Exception) {
                    throw ex
                }
            })
        }
    }
}

uploadFile 函数是一个挂起函数,这意味着它可以从任何协程作用域中调用。withContext(Dispatchers.IO) 函数用于将协程上下文切换到 I/O 分派器,该分派器针对 I/O 相关任务进行了优化,例如网络调用或磁盘操作。

让我们深入探讨 uploadFile 函数,这是这个类的核心。

TransferUtility 类简化了将文件上传到/从 Amazon S3 的过程。在这里,我们正在构建一个 TransferUtility 实例,向其提供 Android 上下文、AWS 配置以及使用提供的 CognitoCachingCredentialsProvider 类初始化的 AmazonS3Client 实例。

使用 transferUtility.upload() 方法将文件上传到 S3 中指定的存储桶。我们提供存储桶的名称(bucketName)、存储新对象的键(objectKey)以及我们想要上传的文件(File(filePath))。此函数返回一个 UploadObserver 实例。

UploadObserver 用于监控上传进度。

我们将 TransferListener附加到观察者,以便在上传状态改变、上传进度或发生错误时获取回调。

当传输状态改变时,会调用 onStateChanged() 方法。如果状态是 TransferState.COMPLETED,这意味着文件已成功上传。

当传输的字节数增加时,会调用 onProgressChanged() 方法。在这里,我们计算进度作为百分比并记录它。

如果在传输过程中发生错误,将调用 onError() 方法。当发生这种情况时,我们将抛出一个错误,由消费者或此提供者处理。

uploadFile 函数是在协程内部调用的,由于实际的上传操作是一个网络 I/O 操作,因此它被包装在 withContext(Dispatchers.IO) 中。这确保了操作不会阻塞主线程,因为 I/O 分派器使用一个针对磁盘和网络 I/O 优化的单独线程池。

现在,我们需要创建一个数据源来将我们的 BackupRepository 实例连接到这个新的提供者。最佳做法是通过实现 IStorageDataSource,这是一个适用于两个数据源的通用接口。这样,你就可以在不改变其余代码的情况下交换底层实现(Firebase Storage、AWS S3 等)。(这是 依赖倒置原则DIP)的应用,它是 面向对象OO)设计的 SOLID 原则之一,有助于使你的代码更加灵活且易于维护。)

这就是我们实现 S3StorageDataSource 的方法:

class S3StorageDataSource @Inject constructor(
    private val awsS3Provider: AWSS3Provider
) : IStorageDataSource {
    override suspend fun uploadFile(remotePath: String,
    file: File) {
        awsS3Provider.uploadFile(BUCKET_NAME, remotePath,
        file.absolutePath)
    }
    companion object {
        private const val BUCKET_NAME = "our-bucket-name"
    }
}

在此代码中,我们正在实现 uploadFile 函数,调用 awsProvider.uploadFile 函数,该函数将文件上传到名为 our-bucket-name 的存储桶。

这个新的 S3StorageDataSource 类可以通过 Hilt 以类似的方式提供,就像之前的 FirebaseStorageDataSource 类一样:

@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
    @Provides
    @Singleton
    fun provideStorageDataSource(awsS3Provider:
    AWSS3Provider): IStorageDataSource {
        return S3StorageDataSource(awsS3Provider)
    }
}

在这里,我们创建一个 @Module 注解,它包括一个 @Provides@Binds 方法用于 IStorageDataSource,Hilt 将根据你的配置注入正确的实现。如果你想从 Firebase Storage 切换到 AWS S3,你需要修改这个模块以提供 S3StorageDataSource 而不是 FirebaseStorageDataSource

最后,我们需要将其集成到我们的 BackupRepository 类中。这就像替换 StorageDataSource 依赖项为 IStorageDataSource 依赖项一样简单:

class BackupRepository @Inject constructor(
    private val messageDao: MessageDao,
    private val conversationDao: ConversationDao,
    private val storageDataSource: IStorageDataSource
) {
    // The rest of the class as it was before
...
}

就这样。根据我们在 Hilt 模块中提供的什么来满足 IStorageDataSource 依赖项,它将使用 Firebase Storage 或 AWS S3。

随着这个变化,我们完成了本章,也完成了在 WhatsPackt 应用中的工作!

图 3.5:WhatsPackt 的最终外观

图 3.5:WhatsPackt 的最终外观

摘要

在本章中,我们致力于为我们的用户创建良好的离线体验(使用 Room 在本地数据库中存储消息)并提供一种机制来存储消息备份,以防出现故障。我们还学习了如何使用不同的提供者将我们的文件存储在云端,使用 Firebase Firestore 和 AWS S3。

现在,我们已经完成了在 WhatsPackt 应用中的工作。在下一章中,我们将开始构建一个新的应用:Packtagram。它将是一个与朋友分享照片和视频的应用,在创建过程中将提供新的和不同的挑战,例如捕获视频。这些是我们将学会克服的挑战。

第二部分:创建 Packtagram,一个照片媒体应用

在本第二部分,你将学习如何通过设置结构、开发故事和新闻源的用户界面以及实现数据检索和缓存来创建一个类似 Instagram 的社交网络应用——Packtagram。然后,你将探索集成 CameraX 以增强照片捕捉和编辑功能,包括使用机器学习生成标签。最后,你将为应用添加视频功能,学习如何捕捉、使用 FFmpeg 进行编辑,并将视频上传到 Firebase 云存储,将 Packtagram 转变为一个综合的多媒体平台。

本部分包括以下章节:

  • 第四章, 构建 Packtagram 用户界面

  • 第五章, 使用 CameraX 创建照片编辑器

  • 第六章, 为 Packtagram 添加视频和编辑功能

第四章:构建 Packtagram UI

随着我们告别令人兴奋的聊天应用世界,是时候接受另一个有趣的挑战——社交网络。在过去十年中,社交网络应用的人气呈指数级增长,成为我们日常生活的重要组成部分。这些平台改变了我们在全球范围内沟通、分享和相互互动的方式。其中,Instagram 以其简洁性、对视觉的重视以及其引人入胜的功能(如新闻源和故事)脱颖而出。

接下来的几章将致力于创建一个类似 Instagram 的社交网络应用的过程,同时利用 Android 强大的功能和特性。我们将称之为 Packtagram!

为了开始这段旅程,我们将从建立一个坚实的基础和构建我们的项目结构开始。Android 应用的结构对开发便利性和应用随时间扩展的伸缩性有重大影响。本章将涵盖项目结构方面的各个方面,例如定义文件层次结构、分离模块以及选择适合我们需求的正确架构模式。

一旦我们的项目结构稳健且可扩展,我们将过渡到 UI 开发的领域。以 Instagram 为例,吸引我们注意的主要组件是其新闻源和故事。我们将深入研究实现这些关键功能的过程,重点关注它们用户友好的界面和流畅的导航流程。

在 UI 之后,我们将转向任何动态应用的核心:数据检索。我们将学习如何与服务器交互以获取数据,重点关注新闻源。

在本章的最后部分,我们将探索数据缓存的领域。社交媒体应用通常涉及大量的数据传输,为了提供无缝且高效的用户体验,有效的数据管理策略,包括缓存,是必要的。我们将探讨如何本地存储故事和新闻条目,从而减少网络调用并提高应用性能。

本章将涵盖以下主题:

  • 设置 Packtagram 的模块和依赖项

  • 创建故事屏幕

  • 创建新闻源屏幕及其组件

  • 使用 Retrofit 和 Moshi 获取新闻源信息

  • 在新闻源中实现分页

技术要求

如前一章所述,您需要安装 Android Studio(或您偏好的其他编辑器)。

在本章中,我们将开始一个新的项目,因此没有必要下载您在上一章中做出的更改。

尽管如此,您可以通过本书的 GitHub 仓库获取我们将在此章节中构建的完整代码:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-4

设置 Packtagram 的模块和依赖项

为了设置我们的应用程序结构,我们将创建一个新的项目。我们可以通过遵循我们在第一章中介绍的同一条指令来完成这项工作,但在这里我们将引入一个变化:我们的 Gradle 文件将使用 Kotlin 编写,并且我们还将使用版本目录。

设置版本目录

版本目录是 Gradle 7.0 中引入的一个功能,用于在项目中集中声明依赖项。此功能提供了一种有组织的方式来管理依赖项,使得控制和管理项目不同模块中库的不同版本变得更加容易。

使用版本目录,你将在libs.versions.toml中定义所有依赖项及其版本。此文件位于你的项目 Gradle 文件夹中。

版本目录提供了几个好处:

  • 它通过提供一个单一的地方来定义和更新依赖项,简化了依赖项管理。

  • 它最小化了由于模块间依赖项版本差异引起的错误。

  • 它通过将每个依赖项的声明集中在一个独特的文件中,消除了在构建脚本中单独声明每个依赖项的需求,从而提高了构建脚本的可读性。

要使用版本目录,在 Android Studio 中,填写新项目的详细信息,包括名称 - 这里,我选择了Packtagram。对于构建配置语言字段,选择Kotlin DSL (build.gradle.kts)

图 4.1:在 Android Studio Jellyfish (2023.3.1)中创建新项目

图 4.1:在 Android Studio Jellyfish (2023.3.1)中创建新项目

使用此选项,Android Studio 将自动创建一个文件来指定版本。此文件称为libs.versions.toml,其默认内容如下所示:

[versions]
agp = "8.1.0-beta01"
org-jetbrains-kotlin-android = "1.8.10"
core-ktx = "1.9.0"
...
[libraries]
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
...
[plugins]
com-android-application = { id = "com.android.application", version.ref = "agp" }
org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "org-jetbrains-kotlin-android" }
[bundles]

如以下代码(以及 Android Studio 在项目中生成的libs.version.toml文件)所示,该文件由几个部分组成:

  • 版本:本节包含将在你的项目中使用的依赖项的版本。你只需为每个版本号分配一个引用名称。这对于集中版本管理非常有用,尤其是在多个地方使用同一版本的库时。

  • :在这个块中,你通过为它们分配别名并将它们链接到版本块中定义的正确版本来定义你的实际依赖项。然后,你可以在整个项目中使用此别名来引用依赖项。

  • 捆绑包:捆绑包是一组通常一起使用的依赖项。通过创建一个捆绑包,你可以在构建脚本中使用单个别名包含多个依赖项。这可以简化你的构建脚本,并使它们更容易阅读和管理。

  • 插件: 本节定义了项目中使用的 Gradle 插件。类似于库,每个插件都会被赋予一个别名,并链接到 versions 块中的版本号。这个特性使得管理插件与其他依赖项一样简单。

现在,如果我们打开应用模块的 gradle.build.kts 文件,我们将看到版本目录声明是如何使用的。例如,在这里,我们可以看到插件是如何现在被应用的:

plugins {
    alias(libs.plugins.com.android.application)
    alias(libs.plugins.org.jetbrains.kotlin.android)
}

这里使用的术语 alias 是指在 libs.versions.toml 文件中指定的预定义插件依赖项。

在这里,我们可以看到依赖项是如何声明的:

dependencies {
    implementation(libs.core.ktx)
    implementation(libs.lifecycle.runtime.ktx)
    implementation(libs.activity.compose)
    implementation(platform(libs.compose.bom))
    implementation(libs.ui)
    implementation(libs.ui.graphics)
    implementation(libs.ui.tooling.preview)
    implementation(libs.material3)
    ...
}

如您所见,每个依赖项都通过我们在版本目录文件(libs.versions.toml)中给它们命名的名称来引用。现在同步和包含所有项目依赖项到模块中变得更加容易。

说到模块化,现在是时候使用模块化来结构化我们的应用了。我们已经在 第一章 中学习了如何模块化我们的应用的不同策略,所以这是一个复习该信息的好时机。

模块化我们的应用

在这种情况下,我们将 Packtagram 分割成几个功能模块,每个模块封装了不同的功能:

  • 新闻源模块: 新闻源模块专注于主要源,是用户查看和与关注者发布的帖子互动的地方。我们将隔离这个功能,因为它是最核心的用户体验,可能是用户首先看到的屏幕。此模块将需要处理帖子的渲染、管理点赞和评论以及刷新源。

  • 故事模块: 我们将故事功能分离到自己的模块中,因为它是一个独特的用户体验,需要特定的 UI 元素和数据处理。故事模块需要管理不同用户故事的渲染方式,跟踪视图状态,以及管理故事创建。

  • 个人资料模块: 用户资料是 Instagram 体验的核心部分,因此我们将此功能放在个人资料模块中。此模块将处理显示用户信息、管理特定于用户的帖子以及编辑个人资料详情。

  • 搜索模块: 搜索功能足够复杂,足以证明其作为一个模块的合理性。此模块将处理用户查询、显示搜索结果以及管理与搜索结果的交互。

  • 消息模块: 直接消息是 Instagram 中的一个独立功能,因此我们也将它隔离到一个专门的模块中。此模块将管理创建和显示聊天、发送和接收消息以及新消息的通知。

  • 核心模块: 此模块将包含共享工具、网络接口和其他在应用程序中使用的通用组件。这防止了代码重复,并为管理共享资源提供了一个中心点。

通过选择这种模块化策略,我们有效地将我们的应用程序分解为逻辑组件,这些组件可以独立地进行开发、测试和调试。这也很好地符合关注点分离的理念,确保我们的应用程序的每个部分都有一个清晰、单一的目的。在接下来的章节中,我们将详细探讨这些模块,逐一构建功能,最终完成我们的社交网络应用程序。

因此,让我们按照在第一章中提供的相同说明来创建模块。我们的模块结构将如下所示:

图 4.2:Packtagram 的模块结构

图 4.2:Packtagram 的模块结构

如我们所见,我们应该有一个名为:app的模块(在创建项目时已创建),一个名为:core的模块用于核心功能,以及一个名为:feature的模块,其中包含所有功能模块(:messaging:newsfeed:profile:search:stories)。

作为本项目的一部分,我们将重点关注newsfeedstories模块(我们已经知道如何创建消息功能,因为这在过去的三个章节中已经涵盖,所以不需要再次介绍)。

对于功能模块,我们将使用与在 WhatsPackt 项目中遵循的相同方法内部构建它们:按层组织代码和依赖项。例如,我们可以这样构建:newsfeed模块:

图 4.3:功能模块的内部结构

图 4.3:功能模块的内部结构

在这里,我们可以看到我们已经创建了四个内部包:

  • 数据层:这是我们放置数据层逻辑的地方,包括从后端和数据源获取信息的组件

  • 依赖注入指令:这是我们放置定义依赖注入指令所需逻辑的地方

  • 领域层:这是我们放置领域逻辑的地方,包括仓库和用例

  • 用户界面:这是我们放置所有与用户界面相关的逻辑的地方,包括 ViewModels、composables 和其他 Android View 组件

我们将实现必要的组件,这些组件将构成本章节和以下章节中模块的一部分。

作为我们模块结构的一部分,我们包含了一个专门用于依赖注入的内部模块。在之前的 WhatsPackt 项目中,我们使用了 Dagger Hilt 框架进行依赖注入。然而,在这个项目中,我们将采取不同的方法,使用 Koin。

了解 Koin

Koin 在第一章中简要提到,但让我们在这里了解其主要特性:

  • 简单性:它提供了一个简单的设置过程,并且易于学习

  • 效率:它轻量级,因为它不依赖于反射

  • 以 Kotlin 为中心:专门为 Kotlin 设计,它利用 Kotlin 特定的功能,如扩展函数、领域特定语言DSL)和属性委托

  • 作用域管理:它有明确的方式来管理注入实例的生命周期

  • 集成:它提供了与流行框架(如 ViewModel、Coroutines 等)的无缝集成

  • 测试:它包括允许依赖项被模拟或覆盖的工具,以简化测试

  • DSL 配置:Koin 使用更易读和简洁的配置形式

让我们为这个项目准备 Koin,这样我们就可以在接下来的章节中使用它。

设置 Koin

要开始设置 Koin,我们需要将必要的依赖项添加到我们的版本目录中。为此,您将必要的 Koin 依赖项添加到 libs.versions.toml 文件中。请确保使用 Koin 的最新版本,并将 latest-version 替换为实际版本号:

[versions]
...
koin = "latest-version"
[libraries]
...
koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-androidx-navigation = { group = "io.insert-koin", name = "koin-androidx-navigation", version.ref = "koin" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
koin-test = { group = "io.insert-koin", name = "koin-test", version.ref = "koin" }
koin-test-junit4 = { group = "io.insert-koin", name = "koin-test-junit4", version.ref = "koin" }

如我们所见,我们在 versions 块中添加了 koin 版本,并在 libraries 块中添加了我们可能需要的包。

现在,我们需要将依赖项添加到我们的模块的 Gradle 文件中。为此,将以下行添加到依赖 Lambda:

dependencies {
   …
    implementation(libs.koin.core)
    implementation(libs.koin.android)
    implementation(libs.koin.androidx.compose)
    implementation(libs.koin.androidx.navigation)
    ...
}

添加这些依赖项将允许我们在模块中使用 Koin。作为一个开始,您应该将它们添加到 :app:feature:newsfeed:feature:stories 模块中,这些是我们将在本章节和接下来的两个章节中要处理的模块。

接下来,我们需要创建我们的 Application 类。Koin 通常在您的 Application 类中初始化。由于我们还没有一个,我们将作为 :app 模块的一部分创建一个,并添加以下代码:

class PacktagramApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@PacktagramApplication)
            modules(appModule)
        }
    }
}

startKoin 块中,我们指定了我们要使用 Android 日志记录器(androidLogger())。androidLogger() 函数是 Koin API 的一部分,并配置 Koin 使用 Android 的原生日志机制。本质上,它使 Koin 能够将日志打印到 Logcat。

当您使用 androidLogger() 初始化 Koin 时,您将能够在调试应用程序时在 Logcat 中看到有关 Koin 行为和操作的重要信息。这包括有关正在创建哪些依赖项、在创建依赖项过程中是否发生错误、作用域的生命周期等详细信息。

之后,我们将 Android 上下文(androidContext(this@MyApplication))提供给框架,以防我们需要它来创建任何依赖项。

下一条是 modules(appModule)。这个函数是您列出包含项目依赖项和提供它们指令的地方。一开始,我们只有 appModule,我们可以这样创建它:

import org.koin.dsl.module
val appModule = module {
...
}

module 块内部,一旦我们开始构建它们,我们应该定义我们的依赖项。以下是一个示例:

val exampleModule = module {
    single { MyDataSource(get()) }
    single { MyRepository(get()) }
    factory { MyUseCase(get()) }
    viewModel { MyViewModel(get()) }
}

Koin 中的module函数用于定义一个模块,在该模块中,你指定如何创建你的各种依赖项。在模块内部,你可以使用singlefactoryviewModel等函数来创建依赖项的实例。

下面是对这些函数的分解:

  • single: 这个函数创建指定类型的单例对象。一旦这个对象被创建,每次需要此类对象时都将提供相同的实例。例如,single { MyDataSource(get()) }定义了如何创建MyDataSource的单例实例。大括号内的get()函数是一个 Koin 函数,用于获取创建MyDataSource实例所需的任何依赖项。

  • factory: 当你希望在每次需要依赖项时创建一个新实例,而不是重用相同的实例时,使用此函数。例如,factory { MyUseCase(get()) }每次请求MyUseCase实例时都会创建一个新的MyUseCase对象。

  • viewModel: 这个函数用于创建ViewModel类的实例。它类似于single,但专门用于 Android 的ViewModel实例。所有ViewModel实例都与活动或片段的生命周期相关联,并且可以在配置更改(如屏幕旋转)中存活。例如,viewModel { MyViewModel(get()) }定义了如何创建MyViewModel的实例。

  • bind: 这个函数与singlefactoryscoped一起使用,为这个类提供额外的接口。例如,如果MyImplementation类实现了MyInterface,你可以输入以下内容:

    single { MyImplementation() } bind MyInterface::class
    

    你可以在定义中看到的get()函数是一个 Koin 函数,它自动获取所需的依赖项。例如,如果MyDataSource依赖于一个MyApi实例,那么get()将获取那个MyApi实例,前提是它已经在 Koin 模块中定义过。

返回到我们的项目,我们现在将appModule留空 - 一旦我们开始创建新的组件,我们就会完成它。

说到组件,让我们从我们需要显示故事屏幕的 UI 开始。

创建故事屏幕

在本节中,我们将专注于开发我们故事功能中创建和编辑新故事的功能。我们将从编写一个名为StoryEditorScreen的可组合组件及其相应的ViewModel开始,命名为StoryEditorViewModel。尽管这个ViewModel最初的功能有限,但我们在后续章节中会对其进行扩展。

让我们开始创建我们的ViewModel,如下所示:

class StoryEditorViewModel: ViewModel() {
    private val _isEditing = MutableStateFlow(true)
    val isEditing: StateFlow<Boolean> = _isEditing
}

在前面的代码中,我们声明了StoryEditorViewModel并添加了一个属性,该属性将指示我们的屏幕是否处于编辑模式。编辑模式将在用户拍照或录制视频并想要添加更多组件时使用。

现在,我们需要注意这个 ViewModel 的依赖注入,因为它必须可以从我们即将创建的屏幕中访问。我们可以在 :feature:story 中创建 storyModule 以能够提供它,如下所示:

val storyModule = module {
    viewModel<StoryEditorViewModel>()
}

在这里,我们只是告诉 Koin 它需要在需要的地方提供 StoryEditorViewModel

我们还需要将这个新模块添加到 PacktagramApplication Koin 初始化中:

class PacktagramApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@PacktagramApplication)
            modules(appModule, storyModule)
        }
    }
}

modules(appModule, storyModule) 行中,我们包含了 storyModule 以提供我们在 stories 功能中需要的所有依赖项。

现在,我们准备好开始使用 Jetpack Compose 的魔法并创建 StoryEditorScreen。这个屏幕将 viewModel 作为依赖项,并处理 TopAppBar 和一个新的组合器 StoryContent,它将包含故事创建和编辑的主要功能。我们可以如下创建 StoryEditorScreen

@Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StoryEditorScreen(
    viewModel: StoryEditorViewModel = koinViewModel()
) {
    val isEditing = viewModel.isEditing.collectAsState()
    Column(modifier = Modifier.fillMaxSize()) {
        if (isEditing.value) {
            TopAppBar(title = { Text(text = "Create Story") })
        }
        StoryContent(isEditing.value)
    }
}

如我们所见,StoryEditorScreen 组合器接收 StoryEditorViewModel 作为参数,它为这个屏幕提供数据和功能。这个 ViewModel 由 Koin 通过 koinViewModel 函数提供。

接下来,isEditing 是从 ViewModelisEditing 状态流中派生出来的状态。这个状态将表示用户是否正在编辑故事。collectAsState() 函数从状态流中收集最新的值并将其表示为 Compose 中的状态。每当 isEditing 状态流发出新的值时,UI 将重新组合以反映新的状态。

StoryEditorScreen 内部,有一个 Column 组合器占据了屏幕的最大尺寸。Column 组合器允许我们垂直排列其子元素。在这个 Column 中,有一个检查 isEditing 状态的条件。如果 isEditing 为真,将显示 TopAppBar,这是一个表示 Material Design App Bar 的组合器,通常放置在屏幕顶部 – 这个 App Bar 只会在用户处于编辑状态时显示。

StoryContent 组合器随后被包含在 Column 中,位于 isEditing 条件之外。这意味着无论用户是否处于编辑模式,StoryContent 总是会显示。isEditing 状态被传递给 StoryContent 以告知其当前的编辑状态。现在让我们来处理这个组合器。

这个组合器应该有一个背景,这将是用戶想要包含在故事中的图片或视频,并将占据屏幕上的所有空间。通过这样做,屏幕上的选项将根据我们是在捕获媒体还是编辑媒体而有所不同。以下是这个组合器的代码:

@Composable
fun StoryContent(
    isEditing: Boolean = false,
    modifier: Modifier = Modifier
) {
    Box(modifier = Modifier.fillMaxSize().padding(20.dp)) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
        ) {
            Button(
                onClick = { /*Handle back*/},
                modifier =
                    Modifier.align(Alignment.TopStart)
            ) {
                Image(
                    painter = painterResource(id =
                        R.drawable.ic_arrow_back),
                    contentDescription = "Back button")
            }
            if (isEditing) {
                Button(
                    onClick =
                        { /* Handle create caption */ },
                    modifier =
                        Modifier.align(Alignment.TopEnd)
                ) {
                    Image(
                        painter = painterResource(id =
                            R.drawable.ic_caption),
                        contentDescription = "Create label"
                    )
                }
            }
    }
        Image(
            painter = painterResource(id =
                R.drawable.ic_default_image),
            modifier = Modifier.fillMaxSize(),
            contentDescription = "Default image"
        )
        Row(
            modifier = Modifier
                .wrapContentHeight()
                .align(Alignment.BottomCenter)
        ) {
            if (isEditing) {
                Button(
                    onClick =
                        { /* Handle create caption */ }
                ) {
                    Text(stringResource(id =
                        R.string.share_story))
                }
            } else {
                OutlinedButton(
                    onClick =
                        { /* Handle take a photo */ },
                    modifier= Modifier.size(50.dp),
                    shape = CircleShape,
                    border= BorderStroke(4.dp,
                        MaterialTheme.colorScheme.primary),
                    contentPadding = PaddingValues(0.dp),
                    colors =
                       ButtonDefaults.outlinedButtonColors(
                       contentColor =
                       MaterialTheme.colorScheme.primary)
                ) {
                }
            }
        }
    }
}

让我们分解这段代码:

  • 最外层的 Box 是主要容器,它占据其父容器的最大尺寸并添加了 20.dp 的填充。

  • Box 的第一个子元素是另一个设置为占据完整宽度和包裹内容高度的 Box

  • 在此Box内部,有一个对齐到其父Box顶部左角的按钮组件。此按钮用于处理后退导航操作。在此按钮内部是一个带有箭头图标的Image组件。

注意

术语“开始”和“结束”用于布局定位,以确保更好地支持从左到右(LTR)和从右到左(RTL)的语言。当你在布局中使用“开始”和“结束”属性时,Android 会根据当前区域的文本方向自动调整方向。在 LTR 语言,如英语中,“开始”映射到“左”,“结束”映射到“右”,而在 RTL 语言,如阿拉伯语中,“开始”映射到“右”,“结束”映射到“左”。这种方法简化了为多种语言和文本方向本地化应用程序的过程。

  • 如果isEditing标志为真,则会在Box中添加一个额外的按钮。此按钮与父Box的顶部端点(在 LTR 布局中为右侧)对齐,允许用户为他们的故事创建标题。按钮使用标题图标的图像来传达其功能。

  • 最外层Box的下一个子元素是Image,它显示默认图像。此Image占据Box的最大尺寸,这意味着此图像将是此屏幕的主要焦点。

  • 最外层Box的最后一个子元素是一个对齐到Box底部中心的Row。此Row包含两个不同的按钮,这些按钮根据isEditing标志有条件地显示。

  • 如果isEditing为假,则显示OutlinedButton。此按钮设计得像一个带边框的圆形按钮,允许用户拍照。请注意,拍照的实际实现不包括在提供的代码中,应在onClick函数中处理。

  • 如果isEditing为真,则会出现一个按钮组件。此按钮标记为分享故事,旨在允许用户分享创建的故事。如您所见,它正在使用stringResource,其键为R.string.share_story,因此我们应该将其添加到string.xml。同样,分享功能的实际实现应在onClick函数中处理。

使用前面的代码,当屏幕处于编辑模式时,它应该看起来像这样:

图 4.4:编辑模式下的故事屏幕

图 4.4:编辑模式下的故事屏幕

否则,当它不在编辑模式时,它将看起来像这样:

图 4.5:非编辑模式下的故事屏幕

图 4.5:非编辑模式下的故事屏幕

如我们所见,从视图中有条件地添加或删除可组合项既简单又直观。

有了这些,我们就完成了故事屏幕,直到我们在下一章中添加更多功能来捕捉照片和视频。让我们继续处理新闻源用户界面。

创建新闻源屏幕及其组件

新闻源是 Packtagram 应用程序的主屏幕,用户将在这里看到他们朋友的最新帖子。它使用多个组件进行结构化:

  • 标题栏:这是用户可以访问消息功能的地方

  • 帖子列表:我们应用中显示的帖子列表

  • 底部栏:用于在应用中导航到不同的部分

我们将开始通过创建 MainScreen 组合组件来构建我们的新闻源屏幕。在这里,我们将定义 Packtagram 应用程序主视图的用户界面。

这个 MainScreen 组合组件将有一个 Scaffold 组合组件作为其主要组件。在这里,我们将定义标题栏和底部栏,以及不同的导航选项:

@Composable
fun MainScreen(
    modifier: Modifier = Modifier,
){
    val tabs = generateTabs()
    val selectedIndex = remember { mutableStateOf(0) }
    val pagerState = rememberPagerState(initialPage = 0)

在这里,我们开始使用组合声明和我们需要处理 bottomBar 导航中将要使用的选项的属性。

现在,是时候添加 Scaffold 组合组件了。这是我们添加 titlebottomBar 的地方。让我们从 title 开始:

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(stringResource(R.string.app_name))
                },
                actions = {
                    IconButton(onClick =
                    { /* Menu action */ }) {
                        Icon(Icons.Rounded.Send,
                        contentDescription = "Messages")
                    }
                }
            )
        },

通过这样,我们已经创建了 Scaffold 组合组件并添加了 TopAppBar。我们在 第一章 中使用了它,但重要的是要记住,容器通常用于包含屏幕的标题以及与屏幕上下文相关的任何操作。在这里,TopAppBar 接收两个重要的 Lambda:

  • 标题:这是定义应用栏标题的地方。在这种情况下,它显示一个 Text 组合组件,该组件获取字符串资源——即应用名称(Packtagram)。

  • 操作:这是定义将出现在应用栏右侧的操作的地方。操作通常用图标表示,并用于执行与当前屏幕相关的功能。在这种情况下,有一个带有信封图标的单个 IconButton(点击时将用户导航到消息屏幕)。

下一步是添加 BottomBar

        bottomBar = {
            TabRow(selectedTabIndex = selectedIndex.value)
            {
                tabs.forEachIndexed { index, _ ->
                    Tab(
                        icon = { Icon(tabs[index].icon,
                            contentDescription = null) },
                        selected = index ==
                            selectedIndex.value,
                        onClick = {
                            selectedIndex.value = index
                        }
                    )
                }
            }
        },

在这里,BottomBar 通常放置应用的导航控件。在本例中,使用的是 TabRow,它是一个 Tab 组合组件的容器。TabRow 的主要 Lambda 用于生成 Tab 元素。它遍历 tabs 中的每个 TabItem(这是由 generateTabs() 生成的 TabItem 对象列表),并为每个对象创建一个 Tab 元素。Tab 元素会提供一个来自 TabItem 的图标,无论它是否被选中(基于其索引是否与 selectedIndex.value 匹配),以及一个 onClick 函数,该函数将 selectedIndex.value 设置为点击的 Tab 的索引。

现在,我们需要向 Scaffold 组合组件添加内容:

        content = { innerPadding ->
            HorizontalPager(
                modifier = Modifier.padding(innerPadding),
                pageCount = tabs.size,
                state = pagerState
            ) { index ->
                when (index) {
                    0 -> {
                        NewsFeed()
                    }
                    1 -> {
                        //Search
                    }
                    2-> {
                        // New publication
                    }
                    3-> {
                        // Reels
                    }
                    4-> {
                        // Profile
                    }
                }
            }
            LaunchedEffect(selectedIndex.value) {
                pagerState.animateScrollToPage(
                selectedIndex.value)
            }
        },
    )
}

content 部分是放置应用主要内容的地方。在这种情况下,内容是一个 HorizontalPager 组合组件,其页面与底部栏中的标签相对应。

HorizontalPager 中的主要 Lambda 用于生成每一页。页面的内容由提供给 Lambda 的索引决定:当 index0 时,显示 NewsFeed(),并为其他导航选项留下占位符。

content 部分内部还有一个 Lambda:LaunchedEffect 块。这本质上是一个副作用,当 selectedIndex.value 发生变化时执行。在这种情况下,它触发一个动画,将 HorizontalPager 滚动到与选定索引对应的页面。

现在 MainScreen 已经准备好了,我们可以着手处理 NewsFeed 列表。

创建 NewsFeed 列表

首先,我们需要创建我们将在 NewsFeed 组合器中使用的 ViewModel 类。我们将称之为 NewsFeedViewModel 并添加以下代码:

class NewsFeedViewModel : ViewModel() {
    private val _posts =
        MutableStateFlow<List<Post>>(emptyList())
    val posts: StateFlow<List<Post>> get() = _posts
}

这里,我们正在初始化 NewsFeedViewModel。目前,我们只有一个公共属性。我们将使用它来收集信息,以便在用户界面中渲染帖子。

现在,是时候处理这个 NewsFeedViewModel 的依赖注入了。我们为每个应用模块创建一个依赖注入模块。所以在这种情况下,因为我们正在处理新闻源模块,我们将创建一个新的依赖注入模块来提供 NewsFeedViewModel

val newsFeedModule = module {
    viewModel<NewsFeedViewModel>()
}

然后,我们将将其添加到 PacktagramApplication 中的模块列表中:

class PacktagramApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
...
            modules(
                appModule,
                storyModule,
                newsFeedModule
            )
        }
    }
}

在这里,我们已将 newsFeedModule 添加到 PacktagramApplication 中现有的模块列表中。

现在,我们需要创建 NewsFeed 组合器,它将包括帖子列表:

@Composable
fun NewsFeed(
    modifier: Modifier = Modifier,
    viewModel: NewsFeedViewModel = koinViewModel()
) {
    LazyColumn{
        itemsIndexed(viewModel.posts){ _, post ->
            PostItem(post = post)
        }
    }
}

这里,我们可以使用 LazyColumn 来渲染帖子列表。正如我们所看到的,我们需要一个 PostItem 组合器来绘制每个列表项。我们将在下一节中构建它。

创建 PostItem 组合器

我们的 PostItem 组合器将包括渲染帖子所需的所有组件。我们需要以下内容:

  • 带有作者图片和名字的标题栏

  • 媒体内容(最初是一张图片,但这也可能是视频)

  • 带有多个操作(如点赞、分享等)的操作栏

  • 显示点赞数量的标签

  • 作者撰写的标题

  • 帖子发布的时间戳

根据那些要求,我们的 PostItem 组合器将看起来像这样:

@Composable
fun PostItem(
    post: Post
){
    Column{
        Spacer(modifier = Modifier.height(2.dp))
        TitleBar(post = post)
        MediaContent(post = post)
        ActionsBar()
        LikesCount(post = post)
        Caption(post = post)
        Spacer(modifier = Modifier.height(2.dp))
        CommentsCount(post = post)
        Spacer(modifier = Modifier.height(4.dp))
        TimeStamp(post = post)
        Spacer(modifier = Modifier.height(10.dp))
    }
}

如您所见,它非常易于阅读,几乎是自我解释的。我们将创建一个 Column 组合器,并将我们需要的每个组合器垂直放置,根据需要留出一些空间。

现在,让我们创建我们需要的组合器。我们将按顺序开始,首先是 TitleBar

@Composable
fun TitleBar(
    modifier: Modifier = Modifier,
    post: Post
){
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(56.dp)
        ,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Spacer(modifier = modifier.width(5.dp))
        Image(
            modifier = modifier
                .size(40.dp)
                .weight(1f),
            painter = painterResource(id =
                post.user.image),
            contentDescription =
                "User ${post.user.name} avatar"
        )
        Text(
            text = post.user.name,
            modifier = modifier
                .weight(8f)
                .padding(start = 10.dp),
            fontWeight = FontWeight.Bold
        )
        IconButton(onClick = { /* Menu options */}) {
            Icon(
                Icons.Outlined.MoreVert,
                "More options"
            )
        }
    }
}

这个组合器的基是一个 Row 组合器,因为它将子项按水平顺序排列。verticalAlignment 参数设置为 Alignment.CenterVertically,以使行中的项目垂直居中。

这里是对所使用的子组合器的描述:

  • 间隔符:这用于在界面上提供一些空间。在这里,它为行的开始提供了 5.dp 的宽度。

  • 图像:这是一个用于显示用户个人资料的图像可组合项。图像来源是从传入的Post对象中获取的。

  • 文本:这显示用户的姓名,并从传入的Post对象中获取姓名。fontWeight参数设置为FontWeight.Bold,使文本加粗。

  • IconButton:这是一个带有图标的按钮。onClick参数是一个 Lambda 函数,当按钮被点击时会被调用。在这种情况下,该函数为空,但这是你放置处理按钮点击代码的地方。内部的Icon元素用于显示更多选项图标。

现在TitleBar已经准备好了,是时候考虑MediaContent可组合项了,它将显示用户发布的内 容:

@Composable
fun MediaContent (
    modifier: Modifier = Modifier,
    post: Post
){
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(300.dp),
        contentAlignment = Alignment.Center,
        ) {
        Image(
            modifier = Modifier
                .fillMaxSize(),
            painter = rememberImagePainter(post.image),
            contentDescription = null
        )
    }
}

上述代码生成一个包含图像的框,因为此可组合项用于显示帖子的主要图像内容。这些是主要组件:

  • Box:这是一个布局可组合项,堆叠其子元素。在这种情况下,它用于容纳一个Image组件。contentAlignment参数设置为Alignment.Center,以在框中居中图像,并对其应用了一个修改器,以填充最大宽度并设置高度为300.dp

  • 图像:这是一个用于显示图像的Image可组合项。图像来源是从传入的Post对象中获取的。修改器用于确保图像填充Box可组合项的最大尺寸。在这里,rememberImagePainter用于从源(如 URL 或本地文件)加载和显示图像,并且它在重组之间被记住。

现在我们已经完成了MediaContent可组合项,我们将考虑ActionBar,它将提供渲染操作按钮的指令:

@Composable
fun ActionsBar(
    modifier: Modifier = Modifier,
){
    Column(
        modifier = modifier
            .fillMaxWidth()
            .height(40.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Row(
            modifier = modifier
                .fillMaxSize()
        ) {
            Row(
                modifier = modifier
                    .fillMaxHeight()
                    .weight(1f)
                ,
                verticalAlignment =
                    Alignment.CenterVertically,
            ) {
                IconButton(onClick = { }) {
                    Icon(
                        imageVector =
                            Icons.Outlined.Favorite,
                        contentDescription = "like",
                        modifier = modifier
                    )
                }
                IconButton(onClick = { }) {
                    Icon(
                        imageVector = Icons.Outlined.Edit,
                        contentDescription = "comment",
                        modifier = modifier
                    )
                }
                IconButton(onClick = { }) {
                    Icon(
                        imageVector = Icons.Outlined.Share,
                        contentDescription = "share",
                        modifier = modifier
                    )
                }
                Row(
                    modifier = modifier
                        .fillMaxHeight()
                        .weight(1f)
                ) {
                }
                Row(
                    modifier = modifier
                        .fillMaxHeight()
                        .weight(1f),
                    verticalAlignment =
                        Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.End
                ) {
                    IconButton(onClick = { }) {
                        Icon(
                            imageVector =
                                Icons.Outlined.Star,
                            contentDescription =
                                "bookmark",
                            )
                    }
                }
            }
        }
    }
}

上述代码生成一个 UI,表示帖子下的操作按钮,类似于 Instagram 上的那些,你可以点赞、评论、分享和收藏帖子。以下是函数每个部分的作用:

  • :这创建了一个列,可以在其中垂直放置其他 UI 元素。horizontalAlignment参数设置为Alignment.CenterHorizontally,这将在列中水平居中元素。

  • :这创建了一个行,其中可以水平放置其他 UI 元素。它填充了父元素的最大尺寸,即

  • 前三组IconButton可组合项位于一个Row可组合项中,用于点赞评论分享操作。每个IconButton都接受一个 Lambda 用于onClick事件,目前这个函数不做任何事情。

  • 然后还有两个额外的Row可组合项,两者都带有fillMaxHeight().weight(1f),看起来像是占位符,可能是为了将来添加额外的图标。

  • 最终的 Row 组合器包含一个用于 Bookmark 操作的 IconButton 组合器。它将 verticalAlignment 设置为 Alignment.CenterVertically,将 horizontalArrangement 设置为 Arrangement.End 以在垂直方向上居中图标,并在水平方向上位于可用空间的末尾(在 LTR 布局中为右侧)。

  • 图标: 每个 图标 显示一个图像,并具有一个用于无障碍目的的 contentDescription 组合。可以使用 modifier 参数来调整图标的布局或其他视觉属性。

在配置了 ActionsBar 组合器以提供具有一系列交互按钮的灵活 UI 布局后,我们的下一个重点是点赞数。实现起来非常简单:

@Composable
fun LikesCount(
    modifier: Modifier = Modifier,
    post: Post
){
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(30.dp)
            .padding(horizontal = 10.dp)
        ,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = post.likesCount.toString().plus(
                «likes"),
            fontWeight = FontWeight.Bold,
            fontSize = 16.sp
        )
    }
}

LikesCount 函数是一个 Composable 函数,用于创建一个行来显示帖子收到的点赞数。以下是函数的每个部分所做的工作:

  • : 这创建了一个行,可以在其中水平放置其他 UI 元素。它使用提供的修饰符来填充父容器的最大宽度,将其高度设置为 30.dp 并在水平方向上添加 10.dp 的填充。verticalAlignment 参数设置为 Alignment.CenterVertically,这将在行中垂直居中元素。

  • 文本: 这创建了一个显示帖子的点赞数的文本元素。它从 Post 对象中获取 likesCount 字段,将其转换为字符串,并在末尾添加单词 likes。它还设置了字体粗细为粗体,字体大小为 16.sp

下一个组合器是标题,这是用户添加到帖子的文本:

@Composable
fun Caption(
    modifier: Modifier = Modifier,
    post: Post
){
    Row(
        modifier = modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .padding(horizontal = 10.dp)
        ,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = buildAnnotatedString {
                val boldStyle = SpanStyle(
                    fontWeight = Bold,
                    fontSize = 14.sp
                )
                val normalStyle = SpanStyle(
                    fontWeight = FontWeight.Normal,
                    fontSize = 14.sp
                )
                pushStyle(boldStyle)
                append(post.user.name)
                append(" ")
                if (post.caption.isNotEmpty()){
                    pushStyle(normalStyle)
                    append(post.caption)
                }
            }
        )
    }
}

这里是函数每个部分所做的工作:

  • : 这创建了一个行,可以在其中水平放置其他 UI 元素。它使用提供的修饰符来填充父容器的最大宽度,将其高度包裹为其内容,并在水平方向上添加 10.dp 的填充。verticalAlignment 参数设置为 Alignment.CenterVertically,这将在行中垂直居中元素。

  • 文本: 这创建了一个显示帖子的标题的文本元素,标题前有用户名。使用 buildAnnotatedString 函数构建具有不同文本样式的字符串。多亏了这一点,用户名以粗体字体样式呈现,而标题则以正常字体样式呈现。

在完成 Caption 组合器后,让我们来处理 CommentsCount 组合器:

@Composable
fun CommentsCount(
    modifier: Modifier = Modifier,
    post: Post
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .padding(horizontal = 10.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = stringResource(R.string.comment_count,
                post.commentsCount),
            fontWeight = FontWeight.Normal,
            fontSize = 14.sp
        )
    }
}

CommentsCount 组合器创建了一个布局来显示帖子的评论数。以下是函数的每个部分所做的工作:

  • 行(Row):这将在其中放置其他 UI 元素的行中创建一个行。它使用提供的修饰符来填充父容器最大宽度,将其高度包裹到内容中,并在水平方向上添加10.dp的内边距。verticalAlignment参数设置为Alignment.CenterVertically,使行中的元素垂直居中。

  • 文本(Text):这创建了一个显示评论数量的文本元素。使用stringResource函数获取字符串资源,这是一个格式字符串,它接受一个数字并将其插入到正确的位置,形成一个表示“阅读 3 条评论”的字符串。然后,该字符串格式用Post对象中的评论数量填充。

现在我们已经完成了CommentsCount可组合组件的实现,我们将创建TimeStamp可组合组件:

fun TimeStamp(
    modifier: Modifier = Modifier,
    post: Post
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .padding(horizontal = 10.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = "${post.timeStamp} hours ago ",
            fontSize = 10.sp,
            fontWeight = FontWeight.Light
        )
    }
}

TimeStamp函数是一个创建用于显示帖子时间戳的布局的可组合组件。以下是函数的每个部分所做的工作:

  • 行(Row):这将在其中放置其他 UI 元素的行中创建一个行。它使用提供的Modifier值来填充父容器最大宽度,将其高度包裹到内容中,并在水平方向上添加10.dp的内边距。verticalAlignment参数设置为Alignment.CenterVertically,使行中的元素垂直居中。

  • 文本(Text):这创建了一个显示时间戳的文本元素。此函数的text参数设置为包含Post对象中的timeStamp属性的字符串。fontSizefontWeight参数分别设置字体大小为10.spFontWeight.Light

使用这个可组合组件,我们已经完成了Post可组合组件。如果我们用假数据预览,我们会看到以下屏幕:

图 4.6:新闻源屏幕

图 4.6:新闻源屏幕

现在,是时候实现一种从后端获取所需信息的方法了。

使用 Retrofit 和 Moshi 检索新闻源信息

在本节中,我们将准备我们的应用程序,使其能够从后端检索新闻源信息。为此,我们需要创建一个处理对后端服务调用请求的 HTTP 客户端。由于我们在第一个项目中使用了ktor,我们将对此采取不同的方法,并使用Retrofit

Retrofit 是一个适用于 Android 和 Java(与 Kotlin 完全兼容)的类型安全 HTTP 客户端。Retrofit 通过将 API 转换为 Kotlin 或 Java 接口,使连接到 REST Web 服务变得简单。以下是它的一些主要功能:

  • 易于使用:Retrofit 将您的 HTTP API 转换为 Kotlin 或 Java 接口。您只需使用注解定义 API 的 URL 和方法(GETPOST等)。Retrofit 将自动将 HTTP 响应转换为数据对象。

  • 类型转换:默认情况下,Retrofit 只能将 HTTP 体反序列化为 OkHttp 的ResponseBody类型,并且它只能接受@BodyRequestBody类型。可以通过添加转换器来支持其他类型。例如,可以使用 JSON 转换器自动将 API 的响应转换为 Kotlin 或 Java 对象。

  • HTTP 方法注解:你可以使用注解来描述 HTTP 方法,如GETPOSTDELETEUPDATE等。你还可以使用其他注解,如HeadersBodyFieldPath等,使你的请求完全符合需求。

  • URL 参数替换和查询参数支持:使用注解向请求添加参数。例如,你可以通过在 URL 中设置特定值来添加路径参数,或者你可以在 URL 末尾添加查询参数。

  • 同步和异步调用:Retrofit 支持同步(阻塞)调用和异步(非阻塞)调用。对于 Android,异步调用更为重要,因为不建议在主线程上进行网络操作。

  • 协程和 RxJava 支持:Retrofit 提供了开箱即用的协程和 RxJava 支持。这使得与这些流行的库处理异步操作变得容易。

  • 拦截器:Retrofit 还允许你使用 OkHttp 的拦截器。你可以为每个请求添加头信息,或者为了调试目的记录请求和响应数据。

我们还需要使用转换器将后端响应解析为对象。我们将使用Moshigithub.com/square/moshi)来完成这项工作。Moshi 是一个现代的 Android 和 Java JSON 库,也是由 Square 构建的。它旨在易于使用且高效,其设计灵感来源于备受推崇的 Gson 库,但它在几个设计方面寻求改进。以下是其主要功能:

  • 易于使用:Moshi 提供了简单的toJson()fromJson()方法,用于将 Java 和 Kotlin 对象转换为 JSON 以及反向转换。

  • 内置和自定义转换器:Moshi 内置了对许多常见类型的转换支持,并能编码这些类型的任何对象图。对于其他类,你可以编写自定义转换器,称为适配器,来定义这些类型如何转换为 JSON 以及从 JSON 转换回来。

  • Kotlin 支持:Moshi 支持 Kotlin,并提供moshi-kotlin-codegen模块,该模块利用注解处理自动生成 Kotlin 类的适配器。

  • 空值安全:Moshi 处理 JSON 输入中的null值,并且可以配置为允许或禁止 Java 或 Kotlin 对象中的null值。

  • 基于注解:与 Retrofit 类似,Moshi 使用注解来表示某些字段的特殊行为(例如,自定义名称和瞬态值)。

  • 容错性: Moshi 具有容错性,当它在 JSON 数据中遇到未知属性或不兼容类型时,不会使整个操作失败。当处理可能偶尔改变的 API 时,这可能是有益的。

  • 高效: Moshi 被设计为在操作中高效,最小化对象分配和垃圾回收开销。

既然我们知道了使用 Retrofit 与 Moshi 的优势,让我们开始将它们集成到我们的项目中。

添加 Retrofit 和 Moshi 依赖

要使用 Retrofit 和 Moshi 库,我们需要配置它们的依赖项。首先,我们将它们添加到版本目录文件中:

[versions]
...
retrofit = "2.9.0"
moshi = "1.12.0"
coroutines = "1.5.1"
moshi-converter = "0.8.0"
...
[libraries]
...
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref="retrofit"}
retrofitMoshiConverter = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref="retrofit"}
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
moshiKotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
moshiKotlinCodegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
moshiKotlinCodegen = { group = "com.squareup.retrofit2", name = "retrofit-kotlinx-serialization-converter", version.ref = "moshi-converter" }
coroutinesCore = {  group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
coroutinesAndroid = {  group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
...
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "org-jetbrains-kotlin-android" }

然后,我们将这些依赖项包含在我们的模块的 build.gradle.kts 文件中,使它们在我们的模块中可用:

dependencies {
    implementation(libs.retrofit)
    implementation(libs.retrofitMoshiConverter)
    implementation(libs.moshiConverter)
    implementation(libs.moshi)
    implementation(libs.moshiKotlin)
    kapt(libs.moshiKotlinCodegen)
    implementation(libs.coroutinesCore)
    implementation(libs.coroutinesAndroid)
...
}

添加这些依赖项后,我们应该准备好使用这两个库。让我们开始创建我们的数据源,以便我们可以获取新闻源的数据。

创建新闻源的数据源

在这一点上,我们准备创建我们的数据源。我们将在 :feature:newsfeed 模块中完成这项工作。首先,我们需要创建一个接口来定义我们的 API 端点,使用 Retrofit。我们可以使用 @GET@POST 等来定义我们想要进行的 HTTP 请求类型:

interface NewsFeedService {
    @GET("feed")
    suspend fun getNewsFeed(): List<PostApiData>
}

这是一个用于将 HTTP API 转换为 Kotlin 接口的 Retrofit 库的接口。它还定义了您的 API 的端点:

  • 接口 NewsFeedService: 这是在声明一个名为 NewsFeedService 的新接口。

  • @GET("feed"): 这是一个描述 HTTP GET 请求的注解。参数 "feed" 是请求将被发送的端点。因此,这个请求的完整 URL 将类似于 packtagram.com/feed,如果您的 Retrofit 客户端的基本 URL 是 packtagram.com/

  • 挂起函数 getNewsFeed(): List: 这是在声明一个名为 getNewsFeed 的函数,它预期返回一个 Post 对象的列表。关键字 suspend 表示这个函数是一个挂起函数,这是一种可以在稍后暂停和恢复的函数类型。这将在稍后从协程中调用。

因此,总的来说,当调用 getFeed 时,它将向 packtagram.com/feed URL 发送 GET 请求,并期望接收一个 PostApiData 对象的 JSON 数组,然后这些对象在您的 Kotlin 代码中被解析成 Post 对象的列表。

要查看包含预期字段的 JSON 文件的示例,请查看 api.mockfly.dev/mocks/09e4e43e-7992-4dd7-b99f-e168667a240e/feed

现在,我们需要从这个接口生成一个客户端。为此,我们将使用 Retrofit 构建器:

object RetrofitInstance {
    private const val BASE_URL = "https://packtagram.com/"
    fun getNewsFeedApi(): NewsFeedService = run {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(
                MoshiConverterFactory.create())
            .build()
            .create(NewsFeedService::class.java)
    }
}

在这里,我们正在创建一个名为 getNewsFeedApi() 的函数,该函数将构建 NewsFeedService 客户端。为此,我们需要一个 BASE_URL 函数,我们可以将其硬编码在此文件中。建议将此信息存储在配置文件中,这样我们就可以轻松地更改它,例如,如果我们需要不同 buildTypes 的应用程序。

我们还在使用 .addConverterFactory (MoshiConverterFactory.create()) 函数添加 Moshi 转换器。这将允许 Retrofit 使用 Moshi 反序列化后端响应。

现在,我们需要创建 NewsFeedRemoteDataSource

class NewsFeedRemoteDataSource(private val api:
NewsFeedService) {
    suspend fun getNewsFeed(): List<PostApiData> {
        return api.getNewsFeed()
    }
}

如我们所见,我们已经创建了一个 NewsFeedRemoteDataSource 可组合组件。在这里,我们将有一个名为 getNewsFeed() 的函数。此函数将调用 NewsFeedService 来获取新闻源。

现在,让我们为这个新闻源创建仓库。

创建仓库

下一步是定义将使用不同数据源(目前我们只有一个,NewsFeedRemoteDataSource)来协调信息收集和存储的仓库。它还将信息映射到新层:域层。

首先,我们将定义其接口作为域层的一部分:

interface NewsFeedRepository {
    suspend fun getNewsFeed():List<Post>
}

第二步,我们将实现其功能作为数据层的一部分:

class NewsFeedRepositoryImpl(
    private val remoteDataSource: NewsFeedRemoteDataSource
): NewsFeedRepository {
    override suspend fun getNewsFeed(): List<Post> {
        return remoteDataSource
            .getNewsFeed()
            .map { it.toDomain() }
    }
}

如我们所见,目前它将只有一个 getNewsFeed() 函数,该函数将获取 Post 对象的列表。它将从远程数据源获取新闻源并将 PostApiData 对象映射到 Post 对象。

现在,让我们创建一个用例来获取这些数据。

创建 GetTheNewsFeedUseCase

随着我们通过层,下一步将是创建所需的用例。在这种情况下,我们将创建一个用于获取新闻源的用例——即 GetTheNewsFeedUseCase

class GetTheNewsFeedUseCase(
    private val repository: NewsFeedRepository
) {
    suspend operator fun invoke(): List<Post> {
        return repository.getNewsFeed()
    }
}

在这里,我们正在使用 repositoryinvoke 函数中获取新闻源。

在继续之前,我们需要创建我们将在数据和域层中使用的数据类。在域层的案例中,我们将创建 Post 数据类:

data class Post(
    val id: String,
    val user: UserData,
    val imageUrl: String,
    val caption: String,
    val likesCount: Int,
    val commentsCount: Int,
    val timeStamp: Long
) {
    data class UserData(
        val id: String,
        val name: String,
        val imageUrl: String
    )
}

在这里,我们正在声明在域层中 Post 对象所需的所有字段。

在数据层的案例中,我们将创建所需的 PostApiData 数据类和映射函数,我们将将其映射到域对象:

data class PostApiData(
    @Json(name = "id")
    val id: String,
    @Json(name = "author")
    val user: UserApiData,
    @Json(name = "image_url")
    val imageUrl: String,
    @Json(name = "caption")
    val caption: String,
    @Json(name = "likes_count")
    val likesCount: Int,
    @Json(name = "comments_count")
    val commentsCount: Int,
    @Json(name = "timestamp")
    val timeStamp: Long
) {
    data class UserApiData(
        @Json(name = "id")
        val id: String,
        @Json(name = "name")
        val name: String,
        @Json(name = "image_url")
        val imageUrl: String
    ) {
        fun toDomain(): Post.UserData {
            return Post.UserData(
                id = id,
                name = name,
                imageUrl = imageUrl
            )
        }
    }
    fun toDomain(): Post {
        return Post(
            id = id,
            user = user.toDomain(),
            imageUrl = imageUrl,
            caption = caption,
            likesCount = likesCount,
            commentsCount = commentsCount,
            timeStamp = timeStamp
        )
    }
}

在这里,我们应该包括后端响应将返回到我们的应用程序的字段。请注意,我们正在使用 @Json(name = "") 注解在属性中指定后端将返回的 JSON 字段名称。

在跳转到 ViewModel 中的用例之前,我们必须整理我们刚刚创建的所有组件的依赖注入。我们将在 newsFeedModule 中这样做:

val newsFeedModule = module {
    single { RetrofitInstance.getNewsFeedApi() }
    single { NewsFeedRemoteDataSource(get()) }
    single<NewsFeedRepository> {
        NewsFeedRepositoryImpl(get()) }
    factory { GetTheNewsFeedUseCase(get()) }
    viewModel<NewsFeedViewModel>()
}

现在,是时候将此用例集成到 NewsFeedViewModel 中了。

将用例集成到 ViewModel 中

对于 ViewModel,我们需要创建一个新的函数来获取帖子。我们将称其为 loadPosts()

    init {
        loadPosts()
    }
    private fun loadPosts() {
        viewModelScope.launch {
            val newPosts = getTheNewsFeedUseCase()
            _posts.value = newPosts
        }
    }

在这里,我们正在加载帖子,一旦应用程序显示视图并且 ViewModel 被创建。

posts 属性的更改已经被我们的 NewsFeed 组合式消费,因此当它收到任何帖子时,它将更新 UI。

我们也可以在这里添加一些错误处理,但由于我们已经在第一个项目中处理了该主题,我们将在这里保留它。

现在,一开始就加载所有现有帖子并不现实(也不高效)。就像我们在消息项目中做的那样,我们需要分页,以便我们可以逐渐获取帖子,跟随用户的滚动。我们将在下一节中看到这一点。

在新闻源中实现分页

为了在我们的应用程序中实现分页,我们将首先修改 Retrofit 服务。通常,您需要向您的 API 端点添加参数来控制您请求的数据的“页”。例如,我们可能有一个 pageNumber 参数和一个 pageSize 参数(尽管这将取决于您后端端点的设计)。

首先,让我们调整 NewsFeedService,使其包括我们刚才提到的两个参数:

interface NewsFeedService {
    @GET("/feed")
    suspend fun getNewsFeed(
        @Query("pageNumber") pageNumber: Int,
        @Query("pageSize") pageSize: Int
    ): List<PostApiData>
}

现在,我们需要更改数据源函数的签名,使其包括这些字段。在数据源中,我们将更改以下函数:

    suspend fun getNewsFeed(pageNumber: Int, pageSize:
    Int): List<PostApiData> {
        return api.getNewsFeed(pageNumber, pageSize)
    }

在存储库中,我们将处理存储当前页和保持所需页面大小(这也可以是某个地方的一个常量):

class NewsFeedRepositoryImpl(
    private val remoteDataSource:
    NewsFeedRemoteDataSource): NewsFeedRepository
{
    private var currentPage = 0
    private val pageSize = 20 // Or whatever page size we
                                 prefer
    override suspend fun getNewsFeed(): List<Post> {
        return remoteDataSource
            .getNewsFeed(currentPage, pageSize)
            .map { it.toDomain() }
            .also { currentPage++ }
    }
    fun resetPagination() {
        currentPage = 0
    }
}

在这里,我们存储当前页,以便当我们调用数据源时,我们可以指定我们是否想要下一页。我们还添加了一个名为 resetPagination() 的函数,该函数将重置当前页,以便我们可以重新开始。

接下来,当用户导航到顶部并想要获取出版物列表的第一页时,我们将使用 resetPagination()UseCase 中:

    suspend operator fun invoke(fromTheBeginning: Boolean):
    List<Post> {
        if (fromTheBeginning) {
            repository.resetPagination()
        }
        return repository.getNewsFeed()
    }

下一步是处理何时加载下一页和加载下一批帖子。为此,我们需要修改我们的 NewsFeed 组合式和 NewsFeedViewModel

首先,我们将实现 NewsFeedViewModel 部分:

init {
        loadInitialPosts()
    }
    private fun loadInitialPosts() {
        viewModelScope.launch {
            val newPosts = withContext(dispatcher) {
                getTheNewsFeedUseCase(fromTheBeginning =
                    true)
            }
            _posts.value = newPosts
        }
    }
    fun loadMorePosts() {
        viewModelScope.launch {
            val newPosts = withContext(dispatcher) {
                getTheNewsFeedUseCase(fromTheBeginning =
                    false)
            }
            val updatedPosts = (_posts.value +
                newPosts).takeLast(60)
            _posts.value = updatedPosts
        }
    }

在这里,我们将初始函数重命名为 loadInitialPosts(),以便它表明它将加载第一篇帖子。然后,我们创建了一个名为 loadMorePosts() 的新函数,该函数将加载新页面。它将把它添加到现有的帖子列表中。

现在,我们需要对 NewsFeed 组合式进行一些修改,以便它在需要新页面时调用 ViewModel。为此,我们需要创建一个 LazyListState 扩展,我们将在用户到达列表末尾时调用它:

fun LazyListState.OnBottomReached(
    loadMore : () -> Unit
){
    val shouldLoadMore = remember {
        derivedStateOf {
            val lastItemInView =
                layoutInfo.visibleItemsInfo.lastOrNull()
                    ?: return@derivedStateOf true
            lastItemInView.index ==
                layoutInfo.totalItemsCount - 1
        }
    }
    LaunchedEffect(shouldLoadMore){
        snapshotFlow { shouldLoadMore.value }
            .collect {
                if (it) loadMore()
            }
    }
}

此扩展函数观察 LazyColumnLazyRow 的滚动状态。当用户滚动到列表底部时,它将调用提供的 loadMore 函数来加载更多项目。这种模式在实现“无限滚动”或“分页”时很常见,这正是我们目前正在实现的。

现在,我们需要在我们的 LazyColumn 布局中使用它。为此,我们需要在 NewsFeed 组合式中记住 LazyListState

@Composable
fun NewsFeed(
    modifier: Modifier = Modifier,
    viewModel: NewsFeedViewModel = koinViewModel()
) {
    val posts = viewModel.posts.collectAsState()
    val listState = rememberLazyListState()
    LazyColumn{
        itemsIndexed(posts){ _, post ->
            PostItem(post = post)
        }
    }
    listState.OnBottomReached {
        viewModel.loadMorePosts()
    }
}

随着这一变化,每次用户到达列表底部时,我们都会调用函数来加载更多帖子并获取下一页。

现在我们已经完成了分页功能的实现,当用户在浏览新闻源时,用户体验将更加高效和流畅。

摘要

在本章中,我们主要专注于对 Packtagram 应用进行结构和模块化,同时增强其可维护性。利用 Jetpack Compose,我们设计了界面中一些将在下一章中继续工作的功能组件和屏幕。

此外,本章还深入探讨了将开发的 UI 连接到后端的技术细节,这对于数据管理和操作处理至关重要。我们实现了 Retrofit 用于网络操作和 Moshi 用于 JSON 解析,架起了用户界面和数据源之间的桥梁。此外,我们引入了分页的概念,以有效地管理大量数据集。通过这样做,我们确保了数据加载更加平滑,响应时间更快,整体提升了应用性能,显著改善了用户体验。

在下一章中,我们将深入探讨我们应用的照片功能。我们将使用一个名为 CameraX 的令人难以置信的库,并利用其一些功能。我们还将学习如何使用 ML Kit 将机器学习应用于相机预览。

第五章:使用 CameraX 创建照片编辑器

在智能手机时代,拍照和分享照片已经成为第二本能,像 Instagram 这样的平台向我们展示了单张照片的强大力量。对于这类应用程序,不仅仅是拍一张照片;它还涉及到增强和个性化图像,以讲述一个故事。但你有没有想过那些应用内相机按钮和滤镜背后的东西?

进入 CameraX,这是 Android 在所有与相机相关事务中的首选工具。这个工具不仅使拍照变得无缝,而且也是编辑和精炼照片的桥梁。在本章中,我们将亲身体验 CameraX,了解它如何改变 Packtagram 的摄影体验。我们还将为用户设计一个互动空间,让他们调整和增强他们的照片,增加个人风格。而且,作为点睛之笔?我们将深入研究一些智能技术,教我们的应用程序识别照片主题并建议相关的标签。

在我们之前的工作基础上——为我们的 Instagram 风格应用程序制作屏幕和动态内容——我们现在正在深入探索应用程序的功能。借助 CameraX、直观的编辑工具和一些巧妙的功能,我们准备提升我们的照片分享游戏。

在本章中,我们将涵盖以下主题:

  • 了解 CameraX

  • 将 CameraX 集成到我们的 Packtagram 应用程序中

  • 添加照片编辑功能

  • 使用机器学习ML)对照片进行分类并生成标签

技术要求

如前一章所述,您需要安装 Android Studio(或您偏好的其他编辑器)。

您可以在本书的 GitHub 存储库中找到本章将使用的完整代码:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-5

了解 CameraX

自从 Android 平台诞生以来,相机在定义智能手机功能集方面发挥了关键作用。从捕捉瞬间到实现增强现实体验,相机已经从单纯的硬件组件发展成为一个强大的开发工具。然而,这种演变并非没有复杂性。

Android 中相机库的演变

自从 Android 的第一个版本以来,开发者通过 Camera API 与相机硬件进行交互;这是 Android 首次尝试赋予开发者利用内置相机功能的能力。

随着设备的普及和如更先进的摄影硬件等功能的增长,对更强大的 API 的需求变得明显。因此,Camera2 API 在 API 级别 21(Lollipop)中被引入。虽然这提供了对相机功能的更细粒度控制,并支持新硬件的扩展功能,但其陡峭的学习曲线使得许多人在复杂性和性能开销方面发现相机开发具有挑战性。

由于 Camera2 的复杂性以及不同设备之间相机硬件的差异,开发者发现为最终用户提供一致的相机体验变得越来越困难。这种碎片化,加上 Camera2 的复杂性,使得一个更简洁、更易于开发者使用的解决方案变得至关重要。

进入 CameraX。

CameraX 的重要性和优势

CameraX 是 Android 为相机应用程序开发提供的现代解决方案,其开发的主要目标是简化流程,同时减少设备之间的碎片化。以下是它迅速成为不可或缺的原因:

  • 跨设备一致性:CameraX 抽象了不同设备特定相机行为之间的差异,确保大多数功能在广泛的设备上都能保持一致。

  • 生命周期感知:繁琐的生命周期管理时代已经过去。CameraX 与 Android 的生命周期库集成,这意味着更少的样板代码,更多关注核心相机功能。

  • 基于用例的方法:开发者现在可以专注于特定的用例,例如图像预览、图像捕获和图像分析,而不是处理低级任务。这使得开发更快,且错误更少。

  • 增强功能的扩展:通过 CameraX 扩展 API,开发者可以访问设备特定的功能,如人像模式、HDR 等,进一步丰富相机体验。

  • 向后兼容性:CameraX 与运行 Android 5.0(API 级别 21)及更高版本的设备兼容,确保其覆盖范围比 Camera2 更广。

  • 性能和质量:CameraX 提供了开箱即用的优化性能,无需额外调整即可提供高质量的图像和视频。

总结来说,CameraX 不仅简化了相机应用程序的开发,还弥合了由硬件差异造成的差距。随着我们深入本章,你会逐渐欣赏到 CameraX 带来的细微差别和功能,为在 Android 上构建强大、一致且高质量的相机应用程序奠定基础。

现在,让我们开始使用 CameraX 并在我们的项目中配置其依赖项。

设置 CameraX

要设置 CameraX,我们需要将必要的依赖项添加到我们的版本目录文件 libs.versions.toml 中,如下所示:

[versions]
...
camerax = "1.2.1"
accompanist = "0.31.1-alpha"
[libraries]
...
cameraCore = { module = "androidx.camera:camera-core", version.ref = "camerax" }
cameraCamera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
cameraView = { module = "androidx.camera:camera-view", version.ref = "camerax" }
cameraExtensions = { module = "androidx.camera:camera-extensions", version.ref = "camerax" }
accompanist = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist"}

在此代码块中,我们添加了使用 CameraX 所需的依赖项,以及一个名为 Accompanist 的库。

Accompanist 是一系列设计用来补充 Jetpack Compose 的扩展库。它通过提供特定用例的实用工具以及简化 Compose 与其他 Android 功能的集成来填补空白。Accompanist 的功能包括图像加载集成、有用的组件(如 ViewPager)、管理系统 UI 嵌入的工具、Compose 导航增强和权限处理。要了解更多信息并扩展这些信息,请参阅官方文档:google.github.io/accompanist/

在我们的案例中,我们将使用它来简化检查和请求用户相机权限的过程。

关于使用 CameraX 的依赖项,我们将添加以下内容:

  • cameraCore: 这个依赖提供了 CameraX 的核心功能,包括管理相机设备、配置捕获会话和从相机接收帧的能力。它是所有其他 CameraX 依赖的基础。

  • cameraCamera2: 这个依赖提供了 CameraX 的 Camera2 实现,这是在 Android 设备上访问相机最强大和最灵活的方式。它提供了对相机硬件的低级访问,并允许自定义捕获配置和处理管道。

  • cameraView: 这个依赖提供了一个预构建的视图组件,它可以与 CameraX 集成,简化显示相机预览帧的过程。它负责视图的布局和设置,这样你就可以专注于捕捉和处理相机数据。

  • cameraExtensions: 这个依赖提供了一组针对 CameraX 的扩展,增加了额外的功能,例如支持焦点峰值、图像稳定和全景捕获。它还包括用于在相机帧上处理 ML 模型的扩展。

注意

之前代码中的版本是在编写本书时最新的稳定版本,但在你阅读本书时可能会有新的版本。

在将这些依赖项添加到版本目录后,我们需要将它们添加到:feature:stories模块的build.gradle.kts文件中,如下所示:

    implementation(libs.cameraCore)
    implementation(libs.cameraCamera2)
    implementation(libs.cameraView)
    implementation(libs.cameraExtensions)
    implementation(libs.androidx.camera.lifecycle)
    implementation(libs.accompanist)

现在我们项目已经准备好使用 CameraX,让我们更深入地了解这个库。

了解 CameraX 的核心概念

在本节中,我们将了解 CameraX 的一些重要概念。

视图生命周期

CameraX,一个 Jetpack 支持库,简化了 Android 设备上的相机开发,并且由于其生命周期感知特性,它可以无缝地与 Jetpack Compose 集成,使开发者能够创建健壮且高效的相机应用程序。CameraX 设计哲学的核心是其对 Android 生命周期的内在支持,消除了管理相机资源的复杂性。CameraX 根据生命周期事件自动处理相机的启动、停止和资源释放,从而简化了开发过程。

Jetpack Compose,Android 的声明式 UI 工具包,也深深植根于生命周期概念。可组合组件天生具有生命周期状态,如 onActiveonDispose,这些状态在它们存在于 UI 层级中时会触发。结合 CameraX 和 Compose 的力量,为在可组合 UI 组件中管理摄像头的生命周期提供了一种协调的方法。

图像分析

CameraX 不仅仅只是捕获图像。通过 图像分析,开发者可以实时处理实时摄像头流。这对于条形码扫描、人脸检测或甚至应用实时滤镜等特性来说非常完美。以下是一个示例:

@Composable
fun CameraPreviewWithImageAnalysis() {
    val cameraProvider = rememberCameraProvider()
    val preview = remember { Preview.Builder().build() }
    val text = remember { mutableStateOf("Analyzing...") }
    val imageAnalyzer = ImageAnalysis.Builder()
        .setAnalyzer { image ->
            // Process the image data here
            text.value = "Detected image to analyze..."
        }
        .build()
    LaunchedEffect(cameraProvider) {
        val useCaseBinding = UseCaseBinding.Builder()
            .addUseCases(preview, imageAnalyzer)
            .build()
        val camera =
            cameraProvider.bindToLifecycle(useCaseBinding)
        camera.close()
    }
    Box(modifier = Modifier.fillMaxSize()) {
        Preview(preview)
        Text(text.value)
    }
}

上述代码定义了一个名为 CameraPreviewWithImageAnalysis 的可组合函数,用于显示摄像头预览并分析实时摄像头流,利用 Jetpack Compose 和 CameraX 来实现这一点。

首先,使用 rememberCameraProvider 函数检索摄像头提供程序实例,该实例负责管理摄像头的生命周期并提供对摄像头控制的访问。然后,使用 Preview.Builder 创建了一个 Preview 实例来定义摄像头预览表面。此预览将在屏幕上显示实时摄像头流。

之后,使用 ImageAnalysis.Builder 创建了一个 ImageAnalysis 实例,用于处理实时摄像头流。setAnalyzer 方法用于指定一个分析器函数,每当有新的图像帧可用时,该函数将被调用。

使用 LaunchedEffect 块启动一个协程,将摄像头预览和图像分析器绑定到摄像头的生命周期。bindToLifecycle 方法用于将用例连接到摄像头的生命周期,确保它们在应用启动和停止时自动开始和停止。

使用 mutableStateOf 变量文本来存储分析当前状态。文本变量在分析器函数内更新,以反映图像分析的结果。

最后,使用 Box 可组合组件来布局摄像头预览和文本。fillMaxSize 修改器用于使 Box 占据整个屏幕。Preview 可组合组件放置在 Box 内以显示摄像头预览。Text 可组合组件也放置在 Box 内以显示当前分析状态。

这是一个应用图像分析的基本示例,但已经存在一些图像分析器,例如 BarcodeScanner。以下代码基于上一个示例,添加了此分析器:

@Composable
fun BarcodeScannerPreview() {
    val cameraProvider = rememberCameraProvider()
    val preview = remember { Preview.Builder().build() }
    val barcodeText = remember { mutableStateOf("") }
    val barcodeScanner = BarcodeScanner.Builder()
        .setBarcodeFormats(BarcodeScannerOptions.
            BarcodeFormat.ALL_FORMATS)
        .build()
    LaunchedEffect(cameraProvider) {
        val imageAnalyzer = ImageAnalysis.Builder()
            .setAnalyzer { image ->
                val rotation =
                    image.imageInfo.rotationDegrees
                val imageProxy =
                    InputImage.fromMediaImage(image.image,
                        rotation)
                barcodeScanner.processImage(imageProxy)
                    .addOnSuccessListener { barcodes ->
                        if (barcodes.isNotEmpty()) {
                            val barcode = barcodes[0]
                            barcodeText.value =
                                barcode.displayValue
                        } else {
                            barcodeText.value = "No barcode
                                detected"
                        }
                    }
                    .addOnFailureListener { e ->
                        barcodeText.value = "Barcode
                            scanning failed: ${e.message}"
                    }
            }
            .build()
        val useCaseBinding = UseCaseBinding.Builder()
            .addUseCases(preview, imageAnalyzer)
            .build()
        val camera =
            cameraProvider.bindToLifecycle(useCaseBinding)
        camera.close()
    }
    Box(modifier = Modifier.fillMaxSize()) {
        Preview(preview)
        Text(barcodeText.value)
    }
}

与上一个示例类似,此代码定义了一个名为 BarcodeScannerPreview 的可组合函数,用于显示摄像头预览并分析实时摄像头流中的条形码。然而,此代码特别关注条形码扫描,并使用 ML Kit 的 BarcodeScanner 库来实现此功能。

首先,与上一个示例中的用法相同,使用rememberCameraProviderPreview函数来检索摄像头提供程序实例并创建一个用于显示实时摄像头流的预览实例。

然后,使用BarcodeScanner.Builder创建了一个BarcodeScanner实例,指定要检测的条形码格式。在这种情况下,使用BarcodeScannerOptions.BarcodeFormat.ALL_FORMATS指定了所有条形码格式。

在此之后,使用ImageAnalysis.Builder创建了一个ImageAnalysis实例,并定义了分析函数以处理每个图像帧。首先,分析函数从imageInfo对象中检索图像旋转。然后,它将ImageProxy实例转换为与 ML Kit 的BarcodeScanner兼容的InputImage格式。

InputImage实例上调用BarcodeScanner.processImage方法以检测条形码。在这里,使用OnSuccessListener来处理成功的条形码检测,而使用OnFailureListener来处理条形码扫描过程中发生的任何错误。

如果检测到条形码,则提取第一个条形码的displayValue值并将其存储在可变状态变量barcodeText中。此变量用于更新文本字段以包含检测到的条形码信息。

通过这种方式,我们已经创建了我们第一个图像分析器以获取条形码信息。让我们继续到下一个功能:CameraSelector

CameraSelector

在处理摄像头时,并不总是只关注一个摄像头——许多现代设备都配备了多个摄像头镜头。这就是CameraSelector发挥作用的地方,它允许开发者以编程方式在前后摄像头之间进行选择。无论是构建自拍应用还是更标准的照片应用,CameraSelector都能确保一致的行为。让我们看看我们如何允许用户选择他们想要使用的摄像头:

@Composable
fun CameraSelectorExample() {
    val cameraProvider = rememberCameraProvider()
    val preview = remember { Preview.Builder().build() }
    val isUsingFrontCamera = remember {
        mutableStateOf(true) }
    val cameraSelector = remember {
        if (isUsingFrontCamera.value) {
            CameraSelector.DEFAULT_FRONT_CAMERA
        } else {
            CameraSelector.DEFAULT_BACK_CAMERA
        }
    }
    val imageAnalyzer = ImageAnalysis.Builder()
        .setAnalyzer { image ->
            // Process the image data here
        }
        .build()
    LaunchedEffect(cameraProvider) {
        val useCaseBinding = UseCaseBinding.Builder()
            .addUseCases(preview, imageAnalyzer)
            .build()
        val camera =
            cameraProvider.bindToLifecycle(useCaseBinding)
        camera.close()
    }
    Box(modifier = Modifier.fillMaxSize()) {
        Preview(preview)
        Column {
            Button(onClick = {
                isUsingFrontCamera.value =
                    !isUsingFrontCamera.value
            }) {
                Text("Switch Camera")
            }
        }
    }
}

上述代码将显示相机预览和一个按钮。点击按钮将在前后摄像头之间切换。使用可变状态变量isUsingFrontCamera来跟踪当前正在使用的摄像头。然后,每当isUsingFrontCamera变量发生变化时,更新cameraSelector。相机预览会自动更新以反映新的摄像头选择。

还可以为用户提供更多对摄像头功能的控制。因此,让我们来谈谈CameraControls

CameraControls

一个全面的摄像头体验不仅仅是捕捉或分析图像。它还关乎控制。通过CameraControls,开发者可以访问一系列功能,允许他们操纵摄像头流。从放大主题并调整焦点以获得清晰的照片到切换手电筒以拍摄夜间快照,CameraControls确保用户始终获得完美的拍摄效果。

下面是一个如何使用CameraControls来缩放、调整焦点和切换手电筒的示例,从代码的第一部分开始:

@Composable
fun CameraControlsExample() {
    val cameraProvider = rememberCameraProvider()
    val preview = remember { Preview.Builder().build() }
    val zoomLevel = remember { mutableStateOf(1.0f) }
    val focusPoint = remember { mutableStateOf(0.5f, 0.5f) }
    val isTorchEnabled = remember { mutableStateOf(false) }
    val imageAnalyzer = ImageAnalysis.Builder()
        .setAnalyzer { image ->
            // Process the image data here
        }
        .build()

在前面的代码中,我们正在定义rememberCameraProvider函数,该函数用于检索相机提供程序实例。它管理相机的生命周期并提供对相机控制器的访问。然后,使用Preview.Builder()创建一个Preview实例,该实例定义了实时相机流将显示的表面。

使用三个mutableStateOf变量来存储缩放级别、焦点和手电筒状态:

  • zoomLevel:此变量存储当前的缩放级别,范围从 1.0f(无缩放)到 5.0f(最大缩放)

  • focusPoint:此变量存储当前的焦点,表示为预览框架内的坐标对(x, y)

  • isTorchEnabled:此变量存储当前的手电筒状态,指示手电筒是否启用或禁用

让我们继续下一部分的代码:

    LaunchedEffect(cameraProvider) {
        val cameraControl =
            cameraProvider.getCameraControl(preview)
        cameraControl.setZoomRatio(zoomLevel.value)
        cameraControl.setFocusPoint(focusPoint.value)
        cameraControl.enableTorch(isTorchEnabled.value)
        val useCaseBinding = UseCaseBinding.Builder()
            .addUseCases(preview, imageAnalyzer)
            .build()
        val camera =
            cameraProvider.bindToLifecycle(useCaseBinding)
        camera.close()
    }

在这里,cameraControl.getCameraControl(preview)方法检索与预览关联的CameraControl实例。此实例提供了访问各种相机控制的功能:

  • cameraControl.setZoomRatio(zoomLevel.value):此控制使用存储在zoomLevel变量中的值设置缩放级别

  • cameraControl.setFocusPoint(focusPoint.value):此控制使用存储在focusPoint变量中的坐标设置焦点

  • cameraControl.enableTorch(isTorchEnabled.value):此控制根据存储在isTorchEnabled变量中的值启用或禁用手电筒

现在,让我们继续到最后一段代码:

    Box(modifier = Modifier.fillMaxSize()) {
        Preview(preview)
        Column {
            Slider(
                value = zoomLevel.value,
                onValueChange = { zoomLevel.value = it },
                valueRange = 1.0f..5.0f,
                steps = 10
            ) {
                Text("Zoom")
            }
            Button(onClick = {
                val newFocusPoint = if (focusPoint.value ==
                0.5f) {
                    0.1f to 0.1f
                } else {
                    0.5f to 0.5f
                }
                focusPoint.value = newFocusPoint
                cameraControl.setFocusPoint(newFocusPoint)
            }) {
                Text("Adjust Focus")
            }
            Button(onClick = {
                isTorchEnabled.value =
                    !isTorchEnabled.value
                cameraControl.enableTorch(
                    isTorchEnabled.value)
            }) {
                Text("Toggle Torch")
            }
        }
    }
}

在这个最后的代码块中,控件在Column布局中被配置和使用:

  • 使用滑动条组件来调整缩放级别。valueRange属性定义了缩放级别的范围(1.0f 到 5.0f),而onValueChange回调将选定的缩放级别更新到zoomLevel变量。

  • 按钮组件触发焦点位置的变化。点击时,它将在两个预定义的位置(0.5f 到 0.5f 和 0.1f 到 0.1f)之间更新focusPoint变量。

  • 另一个按钮组件用于切换手电筒状态。点击时,它会更新isTorchEnabled变量,并调用cameraControl.enableTorch来相应地设置手电筒。

总之,CameraX 为在 Android 上开发高质量的相机应用提供了一个强大且灵活的平台。它提供了一个简化的 API、流畅的使用案例和一套全面的特性,使其成为构建现代以相机为中心的应用的理想选择。现在,我们已准备好在我们的应用中使用它。

将 CameraX 集成到我们的 Packtagram 应用中

现在我们对 CameraX 有了更多的了解,让我们开始将其集成到我们的应用中。首先,我们需要处理相机权限,为用户提供接受权限的方式。然后,我们将设置相机预览并添加相机捕获功能到我们的代码中。

使用 Accompanist 设置权限检查器

有几种方法可以检查摄像头权限是否已被授予,如果没有,则请求它们:我们可以手动完成或使用库。在这种情况下,我们将使用 Accompanist 库,正如我们在本章开头所介绍的。

在运行时请求任何权限之前,在应用的AndroidManifest.xml文件中声明相同的权限是基本要求。这种声明通知 Android 操作系统应用的目的。对于摄像头权限,您需要在<manifest>标签内添加以下行:

<uses-permission android:name="android.permission.CAMERA" />

虽然清单通知系统应用的需求,但运行时权限是关于寻求用户的明确同意。确保在访问受保护的功能或用户数据时,始终具备这两者。

现在,让我们进入权限检查器代码。我们的目标是创建一个可重用的可组合函数,可以优雅地处理摄像头权限。它应该能够请求权限,处理用户决策,并在必要时解释为什么应用需要这个权限。

首先,我们需要导入所需的库:

import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraPermissionRequester(onPermissionGranted: () -> Unit) {
    // ... code ...
}

在这里,@OptIn 注解表明我们正在使用 Accompanist 权限库中的实验性 API。

现在,在CameraPermissionRequester内部,我们需要添加以下内容:

val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)

这里,rememberPermissionState 是一个辅助函数,它回忆起摄像头权限的当前状态。它提供有关权限是否已授予、我们是否已经询问过用户或是否应该显示理由等信息。

拥有权限状态后,我们可以创建一个响应此状态的 UI 流程:

  • 权限已授予:如果权限已经授予,用户可以直接使用摄像头。

  • 显示理由:有时,如果用户拒绝某个权限,解释应用为什么需要这个权限是有帮助的。这就是理由发挥作用的地方。

  • 尚未请求权限:如果应用尚未请求权限,我们希望提供一个按钮来启动请求。

  • 未提供理由拒绝权限:在某些情况下,用户拒绝权限并选择不再被询问。如果他们改变主意,引导他们到应用设置是一个好习惯。

让我们学习如何处理所有这些可能的流程。首先,我们将创建一个新的可组合函数,称为 CameraPermissionRequesteronPermissionGranted 回调用于处理摄像头权限已被授予的情况:

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraPermissionRequester(onPermissionGranted:
@Composable () -> Unit) {

接下来,我们将检索 cameraPermissionState

    // Camera permission state
    val cameraPermissionState = rememberPermissionState(
        android.Manifest.permission.CAMERA
    )

rememberPermissionState(permission) 函数用于检索指定权限的当前状态。在本例中,我们正在检查CAMERA权限的状态,这是访问设备摄像头的必要条件。结果存储在cameraPermissionState变量中。

现在,让我们评估它可能具有的不同值:

    if (cameraPermissionState.status.isGranted) {
        OnPermissionGranted.invoke()

在之前的代码块中,我们开始评估cameraPermissionState对象的status.isGranted属性,该属性指示权限是否已被授予。如果是真的,这意味着权限可用,我们可以调用onPermissionGranted回调来继续使用相机功能。

如果是假的,这意味着权限尚未被授予,因此我们必须向用户传达这种情况,并给他们提供授予权限的选项:

    } else {
                Surface(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                        .padding(top = 24.dp),
                    color =
                      MaterialTheme.colorScheme.background,
        ) {
            Column(
                modifier = Modifier.padding(16.dp),
                verticalArrangement =
                    Arrangement.spacedBy(12.dp),
                horizontalAlignment =
                    Alignment.CenterHorizontally
            ) {
                val textToShow = if
                (cameraPermissionState.shouldShowRationale)
                {
                    "The camera and record audio are
                     important for this app. Please grant
                     the permissions."
                } else {
                    "Camera permission is required for this
                     feature to be available. Please grant
                     the permission."
                }
                Text(
                    text = textToShow,
                    style =
                    MaterialTheme.typography.bodyLarge.copy
                    (
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Medium
                    ),
                    color =
                    MaterialTheme.colorScheme.onBackground
                )
                Button(
                    onClick = { cameraPermissionState
                        .launchMultiplePermissionRequest()
                        },
                    colors = ButtonDefaults.buttonColors(
                        containerColor =
                        MaterialTheme.colorScheme.primary,
                        contentColor =
                        MaterialTheme.colorScheme.onPrimary
                    ),
                    contentPadding = PaddingValues(12.dp)
                ) {
                    Text("Request Permission",
                        fontSize = 14.sp,
                            fontWeight = FontWeight.Bold)
                }
            }
        }
    }
}

在之前的代码块中,我们显示了一条消息,解释了请求权限的原因,并提供了一个Button组件来启动权限请求过程。按钮的onClick处理程序触发cameraPermissionState对象的launchPermissionRequest()方法,提示用户授予权限。

launchPermissionRequest()方法打开一个系统对话框,请求用户授予CAMERA权限。对话框提供了清晰的说明并解释了为什么需要此权限。

如果我们现在运行这段代码,我们应该看到两个屏幕。首先,我们会看到我们的屏幕上显示请求权限的消息(左侧)。一旦我们点击请求权限,我们会看到系统提示接受权限(右侧):

图 5.1:在我们的应用中请求相机权限(左侧)和系统提示授予捕获和录制权限(右侧)

图 5.1:在我们的应用中请求相机权限(左侧)和系统提示授予捕获和录制权限(右侧)

一旦权限被授予,CameraPreview就可以开始工作了。我们将使用onPermissionGranted回调来显示它。

创建我们自己的 CameraPreview

以下CameraPreview组合函数旨在优雅地将 CameraX 集成到 Jetpack Compose 生态系统。在撰写本文时,还没有官方的组合实现用于 CameraX 预览,因此我们将使用AndroidView

@Composable
@Composable
fun CameraPreview(cameraController:
LifecycleCameraController, modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context ->
            PreviewView(context).apply {
                implementationMode =
                  PreviewView.ImplementationMode.COMPATIBLE
            }
        },
        modifier = modifier,
        update = { previewView ->
            previewView.controller = cameraController
        }
    )
}

这个组合函数接受两个参数:cameraController,它是一个LifecycleCameraController实例,用于控制相机,以及一个可选的修饰符,用于指定布局选项。

在函数内部,使用了一个AndroidView组合来连接传统的 Android 视图与 Jetpack Compose UI 框架。AndroidView的工厂参数是一个 Lambda,它提供上下文并返回一个PreviewView对象。PreviewView对象是一个标准的 Android 视图,用于显示相机流。它配置了implementationMode设置为COMPATIBLE,以确保与不同设备和场景的兼容性(CameraX 最相关的功能之一)。

AndroidView的修饰符参数设置为传递的修饰符,以允许布局被定制。update参数是另一个 Lambda,它被调用以在PreviewView上执行更新。在这种情况下,它将cameraController分配给PreviewView的控制器属性,将摄像头预览链接到LifecycleCameraController

现在,让我们将预览集成到我们现有的代码中。在StoryContent可组合组件中,我们将包含以下代码,其中我们期望有摄像头图像:

    CameraPermissionRequester {
        Box(contentAlignment = Alignment.BottomCenter,
        modifier = Modifier.fillMaxSize()) {
            CameraPreview(
                cameraController = cameraController,
                modifier = Modifier.fillMaxSize()
            )
        }
    }

这样,我们应该准备好使用摄像头了!在这个阶段,我们已经学习了如何集成CameraPreview、检查权限以及显示摄像头图像流。现在,让我们添加保存照片的可能性!

添加照片保存功能

捕获功能是每个使用摄像头的应用程序的基本功能。我们需要在我们的现有代码中添加一些逻辑来处理捕获存储。让我们从一个用例(我们将在这里放置我们的领域逻辑)开始,以存储捕获的图像。

创建SaveCaptureUseCase

SaveCaptureUseCase的主要责任将是获取位图对象(我们将用于照片的格式)并将其保存为设备相册中的图像文件。此外,它将处理基于 Android 版本的不同方法,因为根据版本的不同,媒体存储的访问方式也不同。

例如,我们需要获取我们将存储图像的 URI(设备存储中的路径)。如果用户的 Android 版本比 9.0 新,位置将不同于之前的版本。以下代码块显示了获取相应路径的检查将看起来像什么:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            MediaStore.Images.Media.getContentUri(
                MediaStore.VOLUME_EXTERNAL_PRIMARY)
        } else {
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI
        }
}

在这里,我们正在评估版本是否为主版本或等于 Android 9.0,并使用MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)获取 URI。如果版本不符合这些要求,我们从MediaStore.Images.Media.EXTERNAL_CONTENT_URI获取 URI。我们应该考虑所有这些不同的情况,以便我们的用例能够正确处理不同的 Android 版本。

现在,让我们创建SaveCaptureUse类:

class SaveCaptureUseCase(private val context: Context) {
}

然后,我们可以创建这个用例的主函数save(),它将负责保存捕获的内容:

    suspend fun save(capturePhotoBitmap: Bitmap):
    Result<Uri> = withContext(Dispatchers.IO) {
        val resolver: ContentResolver =
            context.applicationContext.contentResolver
        val imageCollection = getImageCollectionUri()
        val nowTimestamp = System.currentTimeMillis()
        val imageContentValues =
            createContentValues(nowTimestamp)
        val imageMediaStoreUri: Uri? =
            resolver.insert(imageCollection,
                imageContentValues)
        return@withContext imageMediaStoreUri?.let { uri ->
            saveBitmapToUri(resolver, uri,
                capturePhotoBitmap, imageContentValues)
        } ?: Result.failure(Exception("Couldn't create file
                                       for gallery"))
    }

在这个代码块中,我们开始创建保存函数。由于它被标记为suspend函数,保存函数被设计为在协程上下文中调用。它使用withContext(Dispatchers.IO)来确保所有 I/O 操作都在后台线程上执行。这对于保持 UI 响应性至关重要,因为 I/O 操作可能很耗时。

接下来,我们声明ContextResolver。这个解析器用于与MediaStore交互,它是 Android 的媒体文件中央存储库。

然后,函数将调用getImageCollectionUri(),这是一个辅助函数,根据 Android 版本提供适当的MediaStore URI。此 URI 是图像将被保存的位置。我们将在下一个实现此函数。

之后,捕获当前系统时间(nowTimestamp),并调用createContentValues (nowTimestamp)以准备图像的元数据。此元数据存储在ContentValues对象中,包括图像的显示名称、MIME类型和时间戳等详细信息。

函数随后尝试使用解析的 URI 和准备好的元数据将新记录插入到MediaStore中。insert方法返回一个指向新创建记录的 URI。如果此操作成功,则返回非空 URI,表示新图像记录在MediaStore中的位置。

最后,如果 URI 不为空,则使用解析器、URI、要保存的位图和图像元数据调用saveBitmapToUri函数。此函数处理将位图数据写入 URI 指向的位置的实际过程。我们很快就会实现它。

关于错误处理,我们的save函数使用 Kotlin 的Result类进行结构化错误处理。如果 MediaStore 中的插入成功且位图正确保存,则函数返回Result.success(Unit)。如果在任何点上出现失败(例如,URI 为空,表示插入失败),则函数返回Result.failure,封装一个适当的错误消息的异常。

现在,让我们实现getImageCollectionUri()函数,该函数将根据 Android 版本返回正确的 URI:

    private fun getImageCollectionUri(): Uri =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
        {
            MediaStore.Images.Media.getContentUri(
                MediaStore.VOLUME_EXTERNAL_PRIMARY)
        } else {
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI
        }

然后,我们可以创建createContentValues函数:

private fun createContentValues(timestamp: Long):
ContentValues = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME,
            "$FILE_NAME_PREFIX${System.currentTimeMillis()}
                .jpg")
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpg")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
        {
            put(MediaStore.MediaColumns.DATE_TAKEN,
                timestamp)
            put(MediaStore.MediaColumns.RELATIVE_PATH,
                "${Environment.DIRECTORY_DCIM}/Packtagram")
            put(MediaStore.MediaColumns.IS_PENDING, 1)
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
        {
            put(MediaStore.Images.Media.DATE_ADDED,
                timestamp)
            put(MediaStore.Images.Media.DATE_MODIFIED,
                timestamp)
            put(MediaStore.Images.Media.AUTHOR,
                AUTHOR_NAME)
            put(MediaStore.Images.Media.DESCRIPTION,
                DESCRIPTION)
        }
    }

createContentValues函数旨在在通过MediaStore将图像文件保存到设备的相册之前准备图像文件的元数据。此方法对于确保保存的图像具有正确的和必要的信息至关重要。因此,让我们分解其功能:

  • 首先,函数初始化一个ContentValues对象。在这里,ContentValues是一个键值对,在 Android 中用于存储一组ContentResolver可以处理的值。它通常用于将数据传递给 Android 的内容提供者。

  • 接下来,设置MediaStore中图像的显示名称。我们将使用预定义的FILE_NAME_PREFIX常量,并将其与当前时间戳连接,然后附加.jpg扩展名,确保每个保存的图像都有一个唯一的名称。

  • 然后,将图像的MIME类型设置为image/jpg。此信息由MediaStore和其他应用程序用于理解图像的文件格式。

  • 我们必须根据设备的 Android 版本以不同的方式存储它:

    • 对于 Android Q(API 级别 29)及以上版本,我们必须执行以下操作:

      • 我们需要添加图像存储时的时间戳,并使用MediaStore.MediaColumns.DATE_TAKEN键。

      • 我们必须使用createContentValues函数指定图像文件的相对路径,使用put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DCIM}/Packtagram")指向数字相机图像DCIM)文件夹内的一个目录。这有助于在特定子目录中组织保存的图像,使其更容易找到。

      • 我们需要更新ContentValues实例并将IS_PENDING设置为1(true),表示文件创建正在进行中。这是通知系统和其他应用程序文件尚未完全写入,并且应在状态恢复之前不应访问文件的一种方式。

    • 对于 Android R(API 级别 30)及以上版本,我们的函数应该添加更多元数据,包括添加日期、修改日期、作者姓名和描述。这是新版本 Android 中增强的元数据管理的一部分,允许存储与媒体文件相关的更详细的信息。

现在我们正在处理存储文件所需的 URI,以及创建文件所需的值和元数据,让我们继续进行保存操作。为此,我们将创建一个新的私有函数saveBitmapToUri,如下所示:

    private fun saveBitmapToUri(
        resolver: ContentResolver,
        uri: Uri,
        bitmap: Bitmap,
        contentValues: ContentValues
    ): Result<Uri> = kotlin.runCatching {
       resolver.openOutputStream(uri).use { outputStream ->
           checkNotNull(outputStream) { "Couldn't create
               file for gallery, MediaStore output stream
                   is null»}`
           bitmap.compress(Bitmap.CompressFormat.JPEG,
               IMAGE_QUALITY, outputStream)
        }

函数首先尝试为给定的 URI 打开OutputStream。这个流是位图数据将被写入的地方。在这里,使用Resolver.openOutputStream(uri)来获取流,而use块确保在操作完成后正确关闭此流,遵循资源管理的最佳实践。

use块内部,该函数会检查outputStream是否为null,如果是,则会抛出一个带有描述性信息的异常。如果流有效,则将位图压缩并写入此流。压缩格式设置为 JPEG,质量由IMAGE_QUALITY常量确定。

现在,如果图像保存成功,我们必须更新并返回结果。如果发生错误,我们必须返回一个错误:

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
        {
            contentValues.clear()
            contentValues.put(
                MediaStore.MediaColumns.IS_PENDING, 0)
            resolver.update(uri, contentValues, null, null)
        }
        return Result.success(Unit)
    }.getOrElse { exception ->
        exception.message?.let(::println)
        resolver.delete(uri, null, null)
        return Result.failure(exception)
    }
}

对于运行 Android Q(API 级别 29)或更高版本的设备,在图像保存后,该函数会更新MediaStore条目以指示图像不再挂起。这是通过清除现有的contentValues,将IS_PENDING设置为0(false),然后使用这些新值更新MediaStore条目来完成的。这一步对于使图像可供用户和其他应用程序使用至关重要。

整个操作被包裹在一个runCatching块中,这是一个 Kotlin 构造,用于简化异常处理。此块捕获在OutputStream操作或MediaStore更新期间发生的任何异常。如果发生异常,则会记录,并且函数会尝试从MediaStore中删除可能已损坏或不完整的文件。这种清理对于防止存储空间被不可用的文件杂乱无章地占用至关重要。

该函数返回 Result<Uri>,表示操作的成功或失败。在成功的情况下,返回 Result.success(uri)。在发生异常的情况下,返回 Result.failure(exception),封装异常详细信息。

剩下的唯一事情就是添加在开发这些类期间将使用的参数。为了简单起见,我们将它们添加为常量,但它们也可以提供给类:

companion object {
    private const val IMAGE_QUALITY = 100
    private const val FILE_NAME_PREFIX = "YourImageName"
    private const val AUTHOR_NAME = "Your Name"
    private const val DESCRIPTION = "Your description"
}

下一步是将此用例集成到 StoryEditorViewModel 中。

在 StoryEditorViewModel 中集成 SaveCaptureUseCase

在这里,我们需要在 StoryEditorViewModel 中创建一个新的属性和函数来存储捕获的图片:

class StoryEditorViewModel(
    private val saveCaptureUseCase: SaveCaptureUseCase
): ViewModel() {
    private val _isEditing = MutableStateFlow(false)
    val isEditing: StateFlow<Boolean> = _isEditing
    private val _imageCaptured: MutableStateFlow<Uri> =
        MutableStateFlow(Uri.EMPTY)
    val imageCaptured: StateFlow<Uri> = _imageCaptured
    fun storePhotoInGallery(bitmap: Bitmap) {
        viewModelScope.launch {
            val imageUri =
                saveCaptureUseCase.save(bitmap).getOrNull()
            if (imageUri != null) {
                _imageCaptured.value = imageUri
                _isEditing.value = true
            }
    }
}

在这个 storePhotoInGallery 函数中,我们只是启动一个协程来调用 saveCaptureUseCase.save 方法。然后,一旦我们获得了 URI,我们检查它是否不是 null 并更新 imageCaptured 属性。

最后,我们准备好将此功能添加到 UI 中。

将捕获功能添加到 StoryContent

要将捕获功能添加到 StoryContent,我们需要向 StoryContent 可组合函数添加一个 Lambda,以便每次使用 StoryContent 时,捕获处理将被委派。例如,在我们的案例中,我们将调用已实现的 storePhotoInGallery 函数从 StoryEditorViewModel

@Composable
fun StoryContent(
    isEditing: Boolean = false,
    onImageCaptured: (Bitmap) -> Any,
    modifier: Modifier = Modifier,
) { ... }

接下来,让我们集成从我们的相机捕获所需的代码:

fun capturePhoto(
        context: Context,
        cameraController: LifecycleCameraController,
        onPhotoCaptured: (Bitmap) -> Unit,
        onError: (Exception) -> Unit
    ) {

我们在之前的代码块中使用以下参数:

  • context:我们将使用它来获取 MainExecutor 的 Android 上下文。

  • cameraController:来自 CameraXLifecycleCameraController 对象,用于控制摄像头的生命周期和操作。

  • onPhotoCaptured:当照片成功捕获并处理时将被调用的回调函数。它接受一个 Bitmap 作为其参数。

  • onError:一个回调函数,用于处理拍照过程中发生的任何错误。

让我们继续定义必要的属性:

val mainExecutor: Executor =
ContextCompat.getMainExecutor(context)

这里,我们将检索 MainExecutor。这个执行器用于在 Android 主线程上运行任务,这对于 UI 更新和某些 CameraX 操作至关重要。它对于 CameraController 是必需的。

接下来,我们将执行拍照动作:

        cameraController.takePicture(mainExecutor,
        @ExperimentalGetImage object :
        ImageCapture.OnImageCapturedCallback() {
            override fun onCaptureSuccess(image:
            ImageProxy) {
                try {
                    CoroutineScope(Dispatchers.IO).launch {
                        val correctedBitmap: Bitmap? =
                            image
                                ?.image
                                ?.toBitmap()
                                ?.rotateBitmap(image
                                    .imageInfo
                                    .rotationDegrees)
                        correctedBitmap?.let {
                            withContext(Dispatchers.Main) {
                                onPhotoCaptured(
                                    correctedBitmap)
                            }
                        }
                        image.close()
                    }
                } catch (e: Exception) {
                    onError(e)
                } finally {
                    image.close()
                }
            }
            override fun onError(exception:
            ImageCaptureException) {
                Log.e("CameraContent", "Error capturing
                    image", exception)
                onError(exception)
            }
        })
    }

这里,我们调用 cameraController.takePicture 方法。我们需要提供执行器和 ImageCapture.OnImageCapturedCallback 类。这个类提供了在成功捕获图像或发生错误时的回调方法。

在成功的情况下,我们将从主分发器切换到 onPhotoCaptured Lambda。或者,如果有任何错误,我们将通过 onError(exception: ImageCaptureException) 回调接收它们。然后,我们将错误传递给作为 capturePhoto() 函数参数接收的 onError 回调函数。

现在,让我们将捕获功能与我们的 UI 链接起来。在我们的StoryContent可组合组件中,我们已经有了一个用于捕获的按钮OutlinedButton,所以让我们看看我们如何从它调用这个捕获函数:

OutlinedButton(
                    onClick = { capturePhoto(
                        context = localContext,
                        cameraController =
                            cameraController,
                        onPhotoCaptured = {
                            onImageCaptured(it) },
                        onError = { /* Show error */ }
                            )
                    },
                    modifier = Modifier.size(50.dp),
                    shape = CircleShape,
                    border = BorderStroke(4.dp,
                        MaterialTheme.colorScheme.primary),
                    contentPadding = PaddingValues(0.dp),
                    colors =
                        ButtonDefaults.outlinedButtonColors
                            (contentColor =
                                MaterialTheme.colorScheme
                                    .primary)
                ) {
                }

如我们所见,我们是从onClick按钮调用capturePhoto函数。

这样,我们就准备好捕捉我们的照片了:

图 5.2:带有捕获按钮的图像预览

图 5.2:带有捕获按钮的图像预览

通过这种方式,我们创建了一个用例,以便我们可以存储我们的照片并将功能与我们的现有 UI 链接起来。我们的用户也可以捕捉并存储他们的照片。接下来,让我们看看我们是否可以启用它们,以便我们可以编辑它们的一些方面。

添加照片编辑功能

我们可以为用户启用多个操作来编辑和修改他们的图片:我们可以允许他们裁剪、调整大小和旋转图片,以及调整亮度和对比度,应用过滤器,或添加文字覆盖。

作为本章的一部分,我们将实现两个操作:黑白过滤器和文字覆盖。

添加过滤器

在现有图片上创建过滤器就像修改包含图片的位图值一样简单。有几个著名的过滤器,如棕褐色、复古和黑白。作为一个例子,我们将实现黑白过滤器,如下所示:

@Composable
fun BlackAndWhiteFilter(
    imageUri: Uri,
    modifier: Modifier = Modifier
) {
    var isBlackAndWhiteEnabled by remember {
    mutableStateOf(false) }
    val localContext = LocalContext.current
    Box(modifier = modifier.fillMaxSize()) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            getBitmapFromUri(localContext, imageUri)?.let {
                val imageBitMap = it.asImageBitmap()
                val colorFilter = if
                (isBlackAndWhiteEnabled) {
                    val colorMatrix = ColorMatrix().apply {
                        setToSaturation(0f) }
                    ColorFilter.colorMatrix(colorMatrix)
                } else {
                    null
                }
                val (offsetX, offsetY) =
                    getCanvasImageOffset(imageBitMap)
                val scaleFactor =
                    getCanvasImageScale(imageBitMap)
                with(drawContext.canvas) {
                    save()
                    translate(offsetX, offsetY)
                    scale(scaleFactor, scaleFactor)
                    drawImage(
                        image = imageBitMap,
                        topLeft =
                            androidx.compose.ui.geometry
                                .Offset.Zero,
                        colorFilter = colorFilter
                    )
                    restore()
                }
            }
        }
        Button(
            onClick = { isBlackAndWhiteEnabled =
                !isBlackAndWhiteEnabled },
            modifier = Modifier.padding(16.dp)
        ) {
            Text("Apply Black and White Filter")
        }
    }
}

此函数首先接受imageUri,它表示要显示的图片的 URI,以及一个可选的修饰符参数来定制布局。

在函数内部,使用remembermutableStateOf声明了一个名为isBlackAndWhiteEnabled的状态变量,该变量跟踪是否应用了黑白过滤器。在这里,LocalContext.current提供了从 URI 加载图片所需的上下文。

使用Box可组合组件来包含整个布局,确保内容填充可用空间。在Box内部,使用Canvas可组合组件来绘制图像。将Canvas修饰符设置为填充可用大小。

Canvas可组合组件使用getBitmapFromUri函数将图片作为Bitmap加载,然后使用asImageBitmap扩展函数将其转换为ImageBitmap。如果isBlackAndWhiteEnabled状态为真,则应用一个饱和度为零的ColorMatrix值来创建黑白ColorFilter。否则,不应用颜色过滤器。

getCanvasImageOffsetgetCanvasImageScale函数用于计算将图像居中和缩放所需的偏移量和缩放因子。with(drawContext.canvas)块用于绘制图像。在此块内,调用saverestore以保存和恢复画布状态,确保变换不会影响后续的绘图操作。translate函数应用计算出的偏移量,而scale函数应用缩放因子,以将整个Canvas填满图像。最后,drawImage函数使用可选的颜色过滤器在画布上绘制图像。

Canvas下方,一个Button composable 被放置在Box内。此按钮用于在点击时切换isBlackAndWhiteEnabled状态。按钮的onClick Lambda 更新状态变量,按钮的文本设置为应用黑白过滤器。按钮的修饰参数包括填充,以确保它不会放置在屏幕边缘。

现在我们已经构建了第一个过滤器,让我们学习如何实现文本叠加。

添加文本叠加

添加文本叠加是典型的图像编辑功能,允许我们标记其他人,给图像添加标签,或添加伴随的书面信息。让我们看看我们如何为用户提供此功能。

首先,我们将创建一个包含TextImage组件状态的 composable。当用户更新文本时,此状态将更新。以下是代码:

@Composable
fun ImageWithTextOverlay(capturedBitmap: Bitmap) {
    var textOverlay = remember { mutableStateOf("Add your
        text here") }
    var showTextField = remember { mutableStateOf(false) }
    Box(modifier = Modifier.fillMaxSize()) {
        Image(
            bitmap = capturedBitmap.asImageBitmap(),
            contentDescription = "Captured Image",
            modifier = Modifier.matchParentSize()
        )
        if (showTextField) {
            TextField(
                value = textOverlay,
                onValueChange = { textOverlay = it },
                modifier = Modifier
                    .align(Alignment.Center)
                    .padding(16.dp)
            )
        }
        Text(
            text = textOverlay,
            color = Color.White,
            fontSize = 24.sp,
            modifier = Modifier.align(Alignment.Center)
        )
        FloatingActionButton(
            onClick = { showTextField = !showTextField },
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp)
        ) {
            Icon(Icons.Default.Edit, contentDescription =
                "Edit Text")
        }
    }
}

此示例定义了一个名为ImageWithTextOverlay的 composable 函数。它接受一个名为capturedBitmap的位图对象,该对象表示将带有文本叠加显示的捕获图像。

函数首先定义了两件状态:

  • 首先,我们有textOverlay,它包含将在图像上显示的文本。它最初设置为默认值在此处添加您的文本

  • 然后,我们有一个showTextField布尔值,它确定文本编辑字段(TextField)是否可见。它最初设置为false

在函数内部,我们使用Box composable 作为容器。Box composable 允许我们堆叠其子组件,并将大小设置为填充最大可用空间。这创建了一个可以在图像上叠加文本的区域。

Box composable 的第一个子项是一个Image composable,它负责显示捕获的照片。照片作为位图传递给此函数,我们确保它填充整个父容器,确保图像占据整个屏幕空间。

接下来,我们检查showTextField的状态。如果它是true,我们在屏幕中心显示TextField。此TextField允许用户输入或编辑将叠加到图像上的文本。由于 Jetpack Compose 提供的双向绑定,用户键入时,textOverlay中的文本会实时更新。

无论 showTextField 的状态如何,我们总是显示一个 Text 可组合组件。这个组件负责在图像上渲染叠加文本。我们为这个文本设置白色和合理的字体大小,确保它在各种背景上都是可见的。

最后,在 Box 可组合组件的底部角落放置 FloatingActionButton。当按钮被点击时,它会切换 TextField 的可见性,使用户能够在查看叠加文本和编辑文本之间切换。按钮的设计直观,带有编辑图标,向用户传达其功能。

现在,假设我们想要允许用户在图像中随时移动文本。让我们实现一些拖放魔法。我们将从更新 ImageWithTextOverlay 可组合函数开始:

@Composable
fun ImageWithTextOverlay(capturedBitmap: Bitmap) {
    var textOverlay = remember { mutableStateOf("Your text
        here") }
    var showTextField = remember { mutableStateOf(false) }
    var textPosition by remember {
        mutableStateOf(Offset.Zero) }

在这个更新版本的 ImageWithTextOverlay 可组合函数中,我们引入了一个交互功能,允许用户将文本叠加拖放到图像上的任何位置。为了实现这一点,我们添加了一个新的状态变量 textPosition,初始化为 Offset.Zero。这个状态保存文本叠加在屏幕上的当前位置。现在,我们必须创建一个新的可组合函数 DraggableText 来处理文本显示及其可拖动功能。

让我们将这个 DraggableText 添加到现有的代码中:

    val imageModifier = Modifier.fillMaxSize()
    Box(modifier = Modifier.fillMaxSize()) {
        Image(
            bitmap = capturedBitmap.asImageBitmap(),
            contentDescription = "Captured Image",
            modifier = imageModifier
        )
        if (showTextField) {
            TextField(
                value = textOverlay,
                onValueChange = { textOverlay = it },
                modifier = Modifier
                    .align(Alignment.Center)
                    .padding(16.dp)
            )
        }
        DraggableText(
            text = textOverlay,
            position = textPosition,
            onPositionChange = { newPosition ->
                textPosition = newPosition },
            modifier = Modifier
                .offset { IntOffset(textPosition.x.toInt(),
                    textPosition.y.toInt()) }
                .align(Alignment.Center)
        )
        FloatingActionButton(
            onClick = { showTextField = !showTextField },
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp)
        ) {
            Icon(Icons.Default.Edit, contentDescription =
                "Edit Text")
        }
    }
}

在这里,通过 TextField 编辑文本的现有功能相同。当用户想要编辑文本时,TextField 字段会出现,由一个浮动操作按钮提供便利。这个按钮切换 TextField 的可见性,使用户能够在编辑文本和调整其位置之间无缝切换。

现在,我们准备好创建 DraggableText 可组合组件:

@Composable
fun DraggableText(
    text: String,
    position: Offset,
    onPositionChange: (Offset) -> Unit,
    modifier: Modifier = Modifier
) {

DraggableText 可组合组件接受多个参数,包括要显示的文本、其当前位置以及一个回调函数 onPositionChange,该函数更新此位置。在 DraggableText 中,我们利用 Text 可组合组件上的可拖动修饰符。这个修饰符至关重要,因为它允许文本在屏幕上移动。当用户拖动文本时,拖动偏移量会更新,进而更新主 ImageWithTextOverlay 函数中的 textPosition 状态。

最后,定义所需的变量和用于显示文本的 Text 可组合组件:

    var dragOffset = remember { mutableStateOf(position) }
    Text(
        text = text,
        color = Color.White,
        fontSize = 24.sp,
        modifier = modifier
            .offset {
                IntOffset(dragOffset.value.x.roundToInt(),
                    dragOffset.value.y.roundToInt()) }
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consume()
                    dragOffset.value =
                        Offset((dragOffset.value.x +
                            dragAmount.x),
                                (dragOffset.value.y +
                                    dragAmount.y))
                    onPositionChange(dragOffset.value)
                }
            }
            .background(
                color = Color.Black.copy(alpha = 0.5f),
                shape = RoundedCornerShape(8.dp)
            )
    )

我们首先初始化一个状态来保存当前的拖动偏移量。这个状态将跟踪文本被拖动时的位置。

接下来,我们定义 Text 可组合组件来显示我们的可拖动文本。为了控制文本的位置,我们使用偏移修饰符,该修饰符根据当前的拖动偏移量定位文本。

pointerInput修饰符允许我们处理文本元素上的拖拽手势。在detectDragGestures块中,我们通过将拖拽量添加到当前偏移量来更新拖拽偏移量,每次用户拖拽文本时都会这样做。手势变化被消耗以指示拖拽事件已被处理,并且我们调用一个函数来处理位置变化时所需的任何附加操作。

有了这些,以下是我们已经创建的两个滤镜:

图 5.3:黑白滤镜组件(左)和文本叠加(右)

图 5.3:黑白滤镜组件(左)和文本叠加(右)

到目前为止,我们已经为用户实现了一些酷炫的功能,例如黑白滤镜和添加字幕的可能性。那么,为什么不利用机器学习来构建出色的功能呢?我们将在下一节中探讨这一点。

使用机器学习对照片进行分类并生成标签

机器学习是人工智能AI)的一个分支,专注于构建可以从数据中学习和基于数据做出决策的系统。与遵循明确编程指令的传统软件不同,机器学习算法使用统计技术使计算机能够通过经验提高任务性能。机器学习的基本前提是开发能够接收输入数据并使用统计分析来预测或对数据的一些方面做出决策的算法。

机器学习是一个巨大的领域,超出了本书的范围,但我们仍然可以使用现成的库做一些有趣的事情。例如,ML Kit是 Google 为移动开发者提供的强大机器学习解决方案,它提供了一系列现成的 API,用于各种机器学习任务,包括设备端和基于云的任务。这些功能被设计成易于集成到移动应用程序中,便于使用机器学习而无需在该领域有深入的专业知识。以下是 ML Kit 提供的关键功能概述:

  • 图像标注:在图像中识别对象、位置、活动、动物种类、产品等。

  • 文本识别:从图像中提取文本。这可以用于光学字符识别OCR)应用,例如扫描文档、名片或任何打印或手写的文本。

  • 人脸检测:在图像中检测人脸,包括眼睛和鼻子等关键面部特征,以及微笑或头部倾斜等特征。这在照片标记和面部识别等应用中非常有用。

  • 条码扫描:读取和扫描条码和二维码。它支持包括 UPC、EAN、Code 39 在内的各种格式。

  • 目标检测和跟踪:在图像或视频流中识别和跟踪对象。这个功能在实时视频分析等场景中非常有用。

你可以在developers.google.com/ml-kit了解更多关于 ML Kit 的功能。

作为示例,我们将创建识别和标记照片中元素的逻辑,这些元素将来可用于对图像进行分类或创建自动标签。我们首先将相应的依赖项添加到 libs.versions.toml

[versions]
...
ml-labeling = "17.0.5"
[libraries]
...
mlKitLabeling= { group = "com.google.mlkit", name = "image-labeling", version.ref="ml-labeling"}

然后,我们将将这些依赖项添加到模块的 build.gradle 文件中。这是创建此功能的地方 (feature:stories):

    implementation(libs.mlKitLabeling)

现在,我们可以创建实际的代码。我们将利用 CameraX 的图像分析功能,在将结果写入图像之前使用 MLKitLabeling 分析预览。为此,我们将创建一个新的预览组合器,专门用于此功能:

@Composable
fun CameraPreviewWithImageLabeler(cameraController: LifecycleCameraController, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    var labels by remember {
        mutableStateOf<List<String>>(emptyList()) }
    val cameraProviderFuture = remember {
        ProcessCameraProvider.getInstance(context) }
    val previewView = remember { PreviewView(context) }
    val imageAnalysis = remember {
        ImageAnalysis.Builder()
            .setTargetResolution(Size(1280, 720))
            .setBackpressureStrategy(
                ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
            .build()
    }
    DisposableEffect(Unit) {
        val cameraProvider = cameraProviderFuture.get()
        val preview = Preview.Builder().build().also {
            it.setSurfaceProvider(
                previewView.surfaceProvider)
        }
        val cameraSelector =
            CameraSelector.DEFAULT_BACK_CAMERA
        cameraProvider.bindToLifecycle(
            context as LifecycleOwner, cameraSelector,
                preview, imageAnalysis)
        onDispose {
            cameraProvider.unbindAll()
        }
    }
    imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor
    (context)) { imageProxy ->
        processImageProxyForLabeling(imageProxy) {
        detectedLabels ->
            labels = detectedLabels
        }
    }
    Box(modifier = modifier) {
        AndroidView(
            factory = { previewView },
            modifier = modifier
        )
        Canvas(modifier = Modifier.fillMaxSize()) {
            drawIntoCanvas { canvas ->
                val paint = android.graphics.Paint().apply
                {
                    color = android.graphics.Color.RED
                    textSize = 60f
                }
                labels.forEachIndexed { index, label ->
                    canvas.nativeCanvas.drawText(label,
                        10f, 100f + index * 70f, paint)
                }
            }
        }
    }
}

此函数的开始与我们的现有 CameraPreview 组合器非常相似。在定义相机提供者之后,配置一个 ImageAnalysis 实例,目标分辨率为 1,280x720 像素,并将背压策略设置为 STRATEGY_KEEP_ONLY_LATEST 以处理最新的图像帧。

imageAnalysis.setAnalyzer 方法设置一个分析器,使用 ML Kit 的图像标签器处理图像帧。调用 processImageProxyForLabeling 函数来处理每个图像帧。检测到的标签传递给一个 Lambda 函数,该函数更新 labels 状态变量。我们很快就会看到如何实现这个函数。

最后,使用 Box 组合器将 PreviewViewCanvas 组合器叠加。Canvas 组合器用于在相机预览上绘制检测到的标签。drawIntoCanvas 方法访问原生的 canvas 进行绘制。配置一个 Paint 对象,颜色为红色,文本大小为 60 像素。forEachIndexed 方法遍历标签列表,在画布上指定位置绘制每个标签。

现在,让我们学习如何实现图像分析器:

@OptIn(ExperimentalGetImage::class)
private fun processImageProxyForLabeling(imageProxy:
ImageProxy, onLabelsDetected: (List<String>) -> Unit) {
    val mediaImage = imageProxy.image
    if (mediaImage != null) {
        val image = InputImage.fromMediaImage(mediaImage,
            imageProxy.imageInfo.rotationDegrees)
        val labeler =
        ImageLabeling.getClient(
            ImageLabelerOptions.DEFAULT_OPTIONS)
        labeler.process(image)
            .addOnSuccessListener { labels ->
                val labelNames = labels.map { it.text }
                onLabelsDetected(labelNames)
            }
            .addOnFailureListener { e ->
                e.printStackTrace()
            }
            .addOnCompleteListener {
                imageProxy.close()
            }
    }
}

此函数接受 ImageProxy 对象和一个回调函数 onLabelsDetected 作为参数,其中回调函数使用检测到的标签列表调用。

在函数内部,mediaImageImageProxy 对象中提取出来。如果 mediaImage 不是 null,则使用 InputImage.fromMediaImage 方法将其转换为 InputImage,这需要从 imageProxy 获取媒体图像和旋转角度。

通过调用 ImageLabeling.getClient 并传入 ImageLabelerOptions.DEFAULT_OPTIONS 来获取图像标签器的实例。这设置了具有适合通用图像标签的默认配置选项的标签器。

labeler.process 方法异步处理 InputImage。之后,处理结果由两个监听器处理:

  • addOnSuccessListener中,如果处理成功,函数将接收一个标签列表。列表中的每个标签代表图像中识别出的元素,并附带一个置信度分数。函数遍历这些标签,记录识别出的元素(label.text)及其置信度分数(label.confidence)。在未来的迭代中,我们可以使用这些信息来自动创建图像上的自动覆盖,或告知用户哪些可能是最佳的图片标签。

  • 在图像处理过程中出现任何故障时,将调用addOnFailureListener,该监听器会记录错误。这种错误处理对于诊断 ML 过程中可能发生的问题至关重要,例如输入图像问题或 ML Kit 处理管道中的内部错误。

现在,如果我们用CameraPreview可组合组件替换为CameraPreviewImageLabeler可组合组件,我们应该能看到图像分析的结果:

图 5.4:在实时预览中进行的 ML 标记

图 5.4:在实时预览中进行的 ML标记

如果您想了解更多关于 ML Kit 库可以做什么的信息,请查看developer.android.com/ml

摘要

在本章中,我们首先熟悉了 CameraX,它是 Android Jetpack 套件的关键组件。我们学习了如何在应用中设置 CameraX,同时启用实时相机预览和图像捕获等功能。

接下来,我们深入探讨了使用 CameraX 捕获图像的实际实现。此外,我们介绍了基本的图像编辑功能,指导您创建过滤器并添加文本覆盖的过程。这些技能对于增强摄影应用的交互性和用户体验至关重要。

最后,我们展示了 Google 的 ML Kit 的集成,演示了如何将高级 ML 功能添加到应用中。我们探讨了如何使用 ML Kit 识别图像中的元素,如物体。这次体验突出了这些技术在增强摄影应用功能方面的实际应用。

到目前为止,您应该已经获得了使用 CameraX 和 ML Kit 构建功能丰富摄影应用的有价值见解和实用技能。

在下一章中,我们将学习如何为 Packtagram 应用捕获和编辑视频,为这些图像注入生命。

第六章:为 Packtagram 添加视频和编辑功能

在已经掌握了捕捉令人惊叹的照片和用 CameraX 应用迷人滤镜的技艺之后,现在是时候将我们的 Packtagram 应用提升到新的高度了。现在,我们将开始一项激动人心的全新冒险:深入视频的世界。

视频不仅仅是移动的图片;它们是强大的叙事工具,为我们的应用注入生命。它们创造动态交互,保持用户的参与度,并为他们提供一个表达创造力的画布。在本章中,我们将引导你通过将视频功能集成到你的应用中的过程,就像为我们在构建的 Instagram-like 体验中添加一个新维度一样。

我们将首先探讨如何使用 CameraX 库(它是你已熟练掌握的用于照片捕捉技能的扩展)捕捉高质量的视频。然后,我们将深入快速前进运动图像专家组FFmpeg)的世界,这是一个用于视频处理的强大库,为你的视频添加创意层次——从传达信息的简单字幕到改变视觉情绪的复杂滤镜。

你将学习如何不仅捕捉和编辑视频,而且高效地将它们上传到 Firebase 存储,确保你的应用能够无缝处理大文件并提供流畅的用户体验。

到本章结束时,你将为你的应用添加一个显著的功能,使其不仅仅是一个照片分享平台,而是一个全面的多媒体体验。

为了实现这一点,在本章中,我们将涵盖以下主题:

  • 为我们的应用添加视频功能

  • 了解 FFmpeg

  • 使用 FFmpeg 为视频添加字幕

  • 使用 FFmpeg 为视频添加滤镜

  • 上传视频

技术要求

如前一章所述,你需要安装 Android Studio(或你偏好的其他编辑器)。

你可以在本书的 GitHub 仓库中找到本章我们将使用的完整代码:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-6

为我们的应用添加视频功能

在本节中,我们将扩展我们的 Android 应用功能,使其包括通过 CameraX 的视频捕捉能力。这个强大的库不仅简化了捕捉照片的过程,还提供了一种高效记录视频的方法。我们将首先调整现有的 CameraX 设置,该设置是为捕捉照片设计的,使其也能处理视频录制。目标是提供无缝集成,保持 CameraX 的简单性和健壮性。

首先,我们需要设置视频录制的预览。在上一章中,我们创建了一个CameraPreview可组合组件。我们将在这里重用相同的组件:

@Composable
fun CameraPreview(cameraController:
LifecycleCameraController, modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context ->
            PreviewView(context).apply {
                implementationMode =
                  PreviewView.ImplementationMode.COMPATIBLE
            }
        },
        modifier = modifier,
        update = { previewView ->
            previewView.controller = cameraController
        }
    )
}

现在,我们需要创建一个新的按钮组件,用于从预览中记录图像和声音(而不是仅仅捕获图像):

@Composable
fun CaptureVideoButton(
    cameraController: LifecycleCameraController,
    onRecordingFinished: (String) -> Unit,
) {
    val context = LocalContext.current
    val recording = remember {
        mutableStateOf<Recording?>(null) }
    IconButton(
        onClick = {
            cameraController.setEnabledUseCases(
                LifecycleCameraController.VIDEO_CAPTURE)
            if (recording.value == null) {
                recording.value =
                    startRecording(cameraController,
                        context, onRecordingFinished)
            } else {
                stopRecording(recording.value)
                recording.value = null
            }
        },
        modifier = Modifier
            .size(60.dp)
            .padding(8.dp),
    ) {
        Icon(
            painter = if (recording.value == null)
                painterResource(id =
                    R.drawable.ic_videocam) else
                        painterResource(id =
                            R.drawable.ic_stop),
            contentDescription = "Capture video",
            tint = MaterialTheme.colorScheme.onPrimary
        )
    }
}

在这里,我们正在创建一个新的名为CaptureVideoButton的组件。它与CaptureButton组件类似,但有一些修改。例如,现在我们需要创建一个可变录制。CameraX 中的Recording类负责管理一个活动的视频录制会话。它封装了启动、暂停、恢复和停止录制所需的状态和操作。在我们的代码中,recording变量将用于管理当前的录制会话。

一旦用户点击按钮,我们将配置视频捕获用例cameraController.setEnabledUseCases(LifecycleCameraController.VIDEO_CAPTURE),以便cameraController可以开始并管理视频录制过程,确保摄像头正确设置以捕获高质量视频,并启用录制会话顺利进行的必要配置和资源。然后,如果没有已经开始录制,我们将启动一个新的录制。如果已经启动,我们将停止它。

在开始录制之前,按钮的图标将显示一个摄像头,如果录制已经开始,则显示一个停止按钮,以提示用户点击它来停止录制。

要完成此录制功能,我们需要实现startRecording函数:

@SuppressLint("MissingPermission")
private fun startRecording(
    cameraController: LifecycleCameraController,
    context: Context,
    onRecordingFinished: (String) -> Unit
): Recording {
    val videoFile = File(context.filesDir,
        "video_${System.currentTimeMillis()}.mp4")
    val outputOptions =
        FileOutputOptions.Builder(videoFile).build()
    val audioConfig = AudioConfig.create(true)
    val executor = Executors.newSingleThreadExecutor()
    return cameraController.startRecording(
        outputOptions,
        audioConfig,
        executor
    ) { recordEvent ->
        when (recordEvent) {
            is VideoRecordEvent.Finalize -> {
                if (recordEvent.hasError()) {
                    Log.e("CaptureVideoButton",
                        "Video recording error:
                            ${recordEvent.error}")
                } else {
                    onRecordingFinished(
                        videoFile.absolutePath)
                }
            }
        }
    }
}

此功能使用@SuppressLint("MissingPermission")注解标记,表示假设必要的运行时权限,如访问摄像头和麦克风,已经获得授权。我们将以与照片捕获相同的方式处理这些权限,因此在此处使用注解是安全的,因为权限已经获得授权。

函数首先定义视频录制的位置和文件名。它使用File类创建一个指向video_${System.currentTimeMillis()}.mp4文件的引用,该文件存储在外部存储的应用特定目录中。这种文件存储方法的优势在于它不需要额外的权限,并确保存储的数据对应用程序是私有的。

接下来,代码使用之前定义的文件设置FileOutputOptions。这一步至关重要,因为它配置了记录的视频数据将如何写入文件系统。FileOutputOptions类是 CameraX 库的一部分,提供了一个直观的 API 来高效地设置这些参数 - 例如,它允许我们使用ContentResolver指定视频位置(你可以在developer.android.com/reference/androidx/camera/video/FileOutputOptions找到有关FileOutputOptions的更多信息)。接下来,创建音频配置,在这种情况下,使用AudioConfig.create(true)允许音频。

然后,使用Executors.newSingleThreadExecutor()创建了一个执行器,这有助于在后台线程中执行任务,从而保持 UI 线程不被阻塞并保持响应。定义了这些参数(fileOutputOptionsAudioConfigExecutor)之后,我们可以执行cameraController.startRecording函数,这将启动录制。

此外,使用Consumer<VideoRecordEvent>接口定义了一个事件监听器。此监听器使用when语句来处理不同类型的VideoRecordEvent,例如VideoRecordEvent.Finalize,这表示录制的完成。事件监听器还检查录制过程中的错误,确保健壮的错误处理。

然后,返回一个Recording对象,代表正在进行的录制会话。这个录制对象对于下一步至关重要。

现在,让我们实现stopRecording函数:

fun stopRecording(recording: Recording?) {
    recording?.stop()
}

在这个简洁明了的函数中,我们只有一行代码,但它做了一些基本的事情。该函数接受一个单一参数recording,这是我们来自 CameraX 库的Recording类的实例。

此函数的核心操作是在recording对象上调用stop()方法。当此方法被调用时,它告诉recording实例终止当前的视频录制会话。这涉及到停止捕获视频帧并最终化正在录制的视频文件。然后,视频文件被保存到录制完成后指定的位置。

现在,我们将新按钮包含到我们之前为捕获功能构建的CaptureModeContent中:

@Composable
private fun CaptureModeContent(
    cameraController: LifecycleCameraController,
    onImageCaptured: (Bitmap) -> Any,
    onVideoCaptured: (String) -> Any
) {
    Box(modifier = Modifier.fillMaxSize()) {
        CameraPermissionRequester {
            Box(
                contentAlignment = Alignment.BottomCenter,
                modifier = Modifier.fillMaxSize()
            ) {
                CameraPreview(...)
                Row {
                    CaptureButton(...)
                    CaptureVideoButton(
                       cameraController =
                           cameraController,
                       onRecordingFinished = { videoPath ->
                           onVideoCaptured(videoPath)
                       }
                    )
                }
            }
        }
    }
}

在这里,我们添加了一个Row可组合组件来显示两个按钮水平并排。我们还添加了一个新的 Lambda(onVideoCaptured),我们将使用它来传递录制完成后视频文件的路径。

经过这些更改,我们应该能够看到新实现的按钮:

图 6.1:当视频未录制时,视频捕获按钮已集成到 StoryContent 屏幕中

图 6.1:当视频未录制时,视频捕获按钮已集成到 StoryContent 屏幕中

当我们点击视频捕获按钮时,我们应该看到其图标变为停止符号:

图 6.2:正在进行的视频录制

图 6.2:正在进行的视频录制

并且,有了这个,我们就准备好使用 CameraX 录制视频了!现在,是我们学习如何修改或编辑录制视频的时候了。考虑到这些方面,让我向您介绍 FFmpeg 库。

了解 FFmpeg

FFmpeg是一个开源的多媒体框架,已成为音频和视频处理领域的基石。以其多功能性和强大功能而闻名,FFmpeg 提供了一套全面的库和工具,用于处理视频、音频和其他多媒体文件和流。在核心上,FFmpeg 是一个命令行工具,使用户能够将媒体文件从一种格式转换为另一种格式,操纵视频和音频记录,并执行一系列其他多媒体处理任务。

注意

您可以在这里找到官方 FFmpeg 文档:ffmpeg.org/

在以下子节中,我们将学习 FFmpeg 的组成部分、其关键特性以及如何将其强大的库集成到我们的 Android 应用程序中。

FFmpeg 的组件

FFmpeg 项目由几个组件组成,每个组件在多媒体处理中扮演着特定的角色:

  • libavcodec:一个包含音频/视频编解码器的库

  • libavformat:这个库处理容器格式,管理多媒体流的复用和解复用方面

  • libavutil:一个提供各种辅助函数和数据结构的实用库

  • libavfilter:用于应用各种音频和视频过滤器

  • libswscale:专门处理图像缩放和颜色格式转换

这些组件共同为处理各种多媒体处理任务提供了一个坚实的基础。

FFmpeg 的关键特性

FFmpeg 因其广泛的功能而脱颖而出。其关键特性包括以下内容:

  • 格式支持:FFmpeg 支持大量的音频和视频格式,包括编码和解码,使其在多媒体处理方面具有极高的灵活性

  • 转换:它能够以高效率在多种格式之间转换媒体文件,这一特性在各种应用程序和服务中得到广泛应用

  • 流媒体:FFmpeg 在流媒体能力方面表现出色,允许实时捕获、编码和流式传输音频和视频

  • 过滤:凭借其强大的过滤能力,用户可以对他们的媒体应用各种转换、叠加和效果

将 mobile-ffmpeg 集成到我们的项目中

在 Android 开发的背景下,FFmpeg 可以作为视频编辑功能(如应用过滤器、转码或甚至添加字幕)的强大工具。然而,在使用 C++代码的同时将 FFmpeg 集成到 Android 应用程序中,需要使用mobile-ffmpeg

mobile-ffmpeg是专门为移动平台(如 Android 和 iOS)设计的 FFmpeg 的专用端口。它提供了预构建的二进制文件、针对移动特定的 API 和针对移动硬件限制的优化。这使得将 FFmpeg 的强大功能集成到移动应用程序中变得更容易,允许开发者以更少的复杂性利用高级多媒体处理功能。

要将 mobile-ffmpeg 库集成到我们的项目中,我们首先需要打开我们的 libs.versions.toml 文件。在那里,我们将添加版本和库组以及名称:

[versions]
...
mobileffmpeg = "4.4"
[libraries]
...
mobileffmpeg = { group = "com.arthenica", name = "mobile-ffmpeg-full", version.ref = "mobileffmpeg" }

在这里,我们刚刚将最新的 mobile-ffmpeg 版本和库引用添加到我们的版本目录中。

和往常一样,为了在任何模块中使用它,我们将在 build.gradle.kts 文件中添加依赖项:

dependencies {
    ....
    implementation(libs.mobileffmpeg)
}

一旦它被添加到我们的依赖项中,我们就需要同步我们的 Gradle 文件,以便它可以在我们的代码中使用。但首先,让我们了解 FFmpeg 的工作原理以及如何使用它。

理解 FFmpeg 命令行语法

正如我们所看到的,FFmpeg 是一个强大的多媒体框架,能够解码、编码、转码、复用(例如,将音频和视频合并到单个文件中)、解复用(在不同的文件中分离音频和视频)、流式传输、过滤和播放几乎任何类型的媒体文件。理解其命令行语法对于有效的视频处理至关重要,尤其是在 Android 环境中。

请记住,我们不会在终端中执行这些命令,但 mobile-ffmpeg 库使用相同的语法,允许我们通过一个名为 FFmpeg.execute() 的函数来执行它们,正如我们现在将要看到的。

在其核心,一个 FFmpeg 命令遵循一个基本结构:

FFmpeg.execute("[global_options] {[input_file_options] [flags] input_url} ... {[output_file_options] output_url} ...")

让我们更详细地看看这个语法的组成部分:

  • global_options: 这些是可以应用于整个命令的设置,例如配置日志级别或覆盖默认配置。

  • input_file_options: 这些是专门影响输入文件选项,例如格式、编解码器或帧率。

  • input_url: 输入文件的路径。

  • output_file_options: 这些与输入文件选项类似,但影响输出文件,例如格式、编解码器或比特率。

  • output_url: 输出文件的路径。

  • options/flags: 这些以破折号(-)开头,并修改 FFmpeg 处理文件的方式。最常用的选项和标志如下:

    • -i: 指定输入文件

    • -c: 表示编解码器;使用 -c:v 用于视频,-c:a 用于音频

    • -b: 设置比特率;-b:v 用于视频,-b:a 用于音频

    • -s: 定义帧大小(分辨率)

    • -r: 设置帧率

    • -f: 表示格式

让我们看看如何使用这个语法来完成一些基本操作。

基本转换

将视频文件从一种格式转换为另一种格式是视频编辑中的基本任务。例如,将 MP4 文件转换为 AVI 文件可以这样做:

FFmpeg.execute("-i input.mp4 output.avi")

此命令告诉 FFmpeg 将 input.mp4 转换为 output.avi,使用编解码器和质量的默认设置(这里使用默认值,因为我们没有指定任何设置)。

指定编解码器

编解码器是用于编码(压缩)或解码(解压缩)视频和音频流的算法。在 FFmpeg 中,您可以指定文件的视频和音频组件的不同编解码器:

  • 视频编解码器:视频编解码器处理文件中的视觉数据。选择正确的视频编解码器会影响视频的质量、大小以及与不同播放器和设备的兼容性。

  • 音频编解码器:音频编解码器处理声音组件。它决定了音频质量、文件大小以及与音频播放系统的兼容性。

在 FFmpeg 中指定编解码器,使用-c标志后跟一个冒号,然后是v表示视频或a表示音频,接着指定编解码器的名称:

ffmpeg -i input.file -c:v [video_codec] -c:a [audio_codec] output.file

例如,要指定 H.264 和 AAC 编解码器,可以运行以下命令:

ffmpeg -i input.mp4 -c:v libx264 -c:a aac output.mp4

让我们了解这个命令的值代表什么:

  • -i: 这表示下一个参数将是输入文件。

  • input.mp4: 这是输入文件的路径。

  • -c:v libx264: 这个值将视频编解码器设置为libx264,这是一个流行的 H.264 视频编码编解码器。它以其效率和与大多数视频平台的兼容性而闻名。

  • -c:a aac: 这个值将音频编解码器设置为aac(代表高级音频编码),在较低的比特率下提供良好的音频质量,使其非常适合网络视频。

  • output.mp4: 表示输出文件的路径。

注意,高质量编解码器通常会导致更大的文件大小——质量和文件大小的平衡在特定用例中可能是关键。

此外,重要的是要知道,某些编解码器需要商业使用许可(例如,H.264),而其他则是开源和免费的(例如,VP9 和 Opus)。

调整视频质量

在视频处理中,管理输出视频质量是最关键的因素之一。质量通常直接受比特率的影响。比特率每秒比特数bps)衡量,表示播放 1 秒视频或音频所编码的数据量。较高的比特率通常意味着更好的质量,但也意味着更大的文件大小。

比特率有两种类型:

  • 恒定比特率CBR):在整个文件中以一致的比特率进行编码,导致可预测的文件大小,但可能质量会有所变化

  • 变量比特率VBR):这会根据视频每一部分的复杂性调整比特率,更有效地平衡质量和文件大小

要在 FFmpeg 中调整比特率,我们可以使用-b:v标志来指定视频比特率,使用-b:a来指定音频比特率:

ffmpeg -i input.file -b:v [video_bitrate] -b:a [audio_bitrate] output.file

例如,要设置标准定义视频并具有适中的质量,我们可以运行以下命令:

ffmpeg -i input.mp4 -b:v 1500k -b:a 128k output.mp4

让我们看看这个命令的值代表什么:

  • -i: 这表示下一个参数将是输入文件

  • input.mp4: 这是输入文件的路径

  • -b:v 1500k: 将视频比特率设置为 1,500 kbps,这对于标准定义内容是合适的

  • -b:a 128k: 将音频比特率设置为 128 kbps,提供不错的音频质量,而不会导致文件大小过大

  • output.mp4: 表示输出文件的路径

值得注意的是,较低的比特率可能会导致明显的压缩伪影,尤其是在快速移动或复杂的场景中。另一方面,较高的比特率提供更好的质量,但代价是文件大小更大,这可能会成为在线流媒体或有限存储的问题。

调整视频大小

在视频编辑中,调整或缩放视频是一个常见任务,无论是为了适应不同的屏幕尺寸、减小文件大小还是符合特定的分辨率要求。FFmpeg 提供了强大的工具轻松调整视频大小,但了解这些更改的影响对于保持质量至关重要。

但什么是视频分辨率和宽高比?

  • 分辨率:视频的分辨率是像素的维度,表示为宽度 x 高度。标准分辨率包括 480p(标清)、720p(高清)、1080p(全高清)和 4K(超高清)。

  • 宽高比:这是视频宽度和高度的比率。常见的宽高比包括 16:9(宽屏)和 4:3(传统)。

在 FFmpeg 中调整视频大小,使用-s(大小)标志。它设置分辨率:

ffmpeg -i input.file -s [width]x[height] output.file

例如,要将分辨率调整为 1080p,命令如下:

ffmpeg -i input.mp4 -s 1920x1080 output.mp4

让我们看看这个命令的值代表什么:

  • -i:表示下一个参数将是输入文件

  • input.mp4:输入文件的路径

  • -s 1920x1080:将视频调整到全高清(1080p),适合高质量演示和大型显示屏

  • output.mp4:表示输出文件的路径

在调整视频大小时有一些需要考虑的事项:

  • 根据视频将如何以及在哪里被观看来选择分辨率。例如,你应该为电视广播选择高分辨率,而对于网络或移动使用则选择较低的分辨率。

  • 较高的分辨率会导致文件更大,这可能会对存储和流媒体传输造成影响。

  • 总是考虑源视频的质量。放大低质量素材可能不会产生理想的结果。

现在我们已经熟悉了 FFmpeg 的基本功能,我们将学习高级功能。

FFmpeg 的高级语法和选项

FFmpeg 的真正力量在于其高级选项,允许对音频和视频文件进行复杂的处理和操作。本节将深入探讨这些高级功能,并提供如何利用它们完成复杂任务的见解。

使用过滤器进行增强视频和音频处理

FFmpeg 配备了丰富的视频和音频过滤器。这些过滤器可以应用于裁剪、旋转、添加水印以及调整亮度或对比度等任务。

要应用过滤器,可以使用-vf(视频过滤器)或-af(音频过滤器)选项。以下是过滤器语法的工作模式:

ffmpeg -i input.file -vf "[filter1],[filter2]" output.file

例如,想象一个场景,你需要裁剪视频并调整其颜色属性。你可以通过运行以下命令来完成:

ffmpeg -i input.mp4 -vf "crop=640:480:0:0, hue=h=60:s=1" -c:a copy output.mp4

让我们更仔细地看看这个命令的值:

  • -i:表示下一个参数将是输入文件。

  • input.mp4: 这是输入文件的路径。

  • -vf: 这代表视频滤镜,允许你将一个或多个滤镜应用到视频流中。

  • crop=640:480:0:0: 这是裁剪滤镜。它将视频裁剪到 640 像素宽和 480 像素高。末尾的0:0值指定裁剪区域的左上角的 x 和 y 坐标。在这种情况下,它设置为原始视频的左上角。因此,此滤镜实际上从左上角裁剪视频至 640x480 矩形。

  • hue=h=60:s=1: 该代码包含两部分:

    • h=60调整视频的色调。色调是允许我们在 360 度色轮上移动颜色的颜色成分。60 度的值将颜色移动 60 度。例如,蓝色可能变成绿色,红色可能变成黄色,依此类推。

    • s=1设置饱和度级别。饱和度为1意味着颜色在强度上保持不变。减小此值将使颜色去饱和,导致图像更偏向灰度。

  • -c:a: 将视频调整至全高清(1080p)。

  • output.mp4: 指示输出文件的路径。

总结来说,这个 FFmpeg 命令读取input.mp4,从左上角裁剪视频至 640x480 分辨率,将视频颜色的色调在色轮上调整 60 度,保持原始饱和度,不重新编码复制音频,并将所有这些更改保存在output.mp4中。

使用覆盖视频滤镜

FFmpeg 中的覆盖滤镜是一个多功能特性,允许用户将一个视频或图像叠加到另一个视频上。这对于添加标志、水印、字幕、画中画效果或任何其他视觉元素到视频中特别有用。

覆盖滤镜可以通过 FFmpeg 中的-filter_complex选项应用,该选项用于涉及多个输入流(如合并两个视频或向视频中添加图像)的更复杂过滤。

覆盖滤镜的基本语法如下:

ffmpeg -i main_video.mp4 -i overlay.mp4 -filter_complex "overlay=x:y" output.mp4

在这里,main_video.mp4是我们的主要视频,而overlay.mp4是我们想要叠加的视频或图像。覆盖滤镜中的xy值指定覆盖图像/视频在主视频上的位置。

例如,假设我们想在视频的右下角添加公司标志。首先,我们必须准备文件。在这种情况下,我们有以下文件:

  • 主视频文件将是video.mp4

  • 标志图像将是logo.png(最好带有透明背景)

然后,我们将确定标志的位置。标志的位置将取决于主视频的分辨率。例如,如果视频是 1920x1080(全高清),并且你希望标志距离底部和右边框 10 像素,坐标将是(x=1900, y=1060)。

考虑到这一点,我们必须执行以下命令:

ffmpeg -i video.mp4 -i logo.png -filter_complex "overlay=1900:1060" -codec:a copy output.mp4

在此命令中,我们有以下内容:

  • -i video.mp4: 指定主视频文件。

  • -i logo.png: 指定覆盖层文件(标志)。

  • -filter_complex "overlay=1900:1060": 应用覆盖层过滤器。标志位于(1900,1060),接近右下角。

  • -codec:a copy: 复制主视频中的音频,不进行重新编码。

  • output.mp4: 在视频上叠加标志的输出文件。

我们能用覆盖层过滤器做到这些吗?不,还有更多!例如,我们可以动态地移动这个覆盖层。

使用 FFmpeg 的覆盖层过滤器进行动态定位

FFmpeg 中的覆盖层过滤器不仅允许在主视频上静态放置图像或视频,还提供了动态定位功能。这个高级功能使得覆盖层可以在屏幕上移动或随时间改变其外观,为您的视频添加动态元素。

首先,让我们探索如何在屏幕上创建移动覆盖层的效果。这种技术特别适用于为标志、文本或其他图形元素添加动态效果。

在我们深入命令之前,了解 FFmpeg 如何处理运动表达式非常重要。这些表达式允许覆盖层的位置逐帧改变,从而产生运动错觉。

移动覆盖层的命令如下:

ffmpeg -i main_video.mp4 -i logo.png -filter_complex "overlay=x='t*100':y=50" output.mp4

在这个命令中,我们有以下内容:

  • x='t*100': 覆盖层的水平位置(x)从 0 开始,每秒增加 100 像素。t变量代表当前时间(秒)。

  • y=50: 垂直位置(y)固定在帧顶部的50像素处。

我们可以调整这些值,在我们的视频覆盖层中引入不同的效果。例如,如果我们创建一个完整的视频编辑器,我们可以允许用户在视频上移动一个元素,并在视频播放期间改变其位置。然后,我们可以使用 FFmpeg 将这些不同的位置映射到我们希望它们移动到的秒数。然而,我们不会这样做,因为这需要另一本书的篇幅!

如果您对此感兴趣,以下是overlay参数的文档:ffmpeg.org/ffmpeg-filters.html#overlay-1

我们还可以使用淡入和淡出效果,这些效果可以应用于我们的覆盖层。让我们看看它是如何工作的。

介绍淡入/淡出命令

为了实现淡入/淡出效果,我们将覆盖层过滤器与淡入/淡出过滤器结合使用。让我们分解这个命令,了解其结构:

ffmpeg -i main_video.mp4 -i logo.png -filter_complex "[1:v]fade=t=in:st=0:d=1,fade=t=out:st=3:d=1[logo];[0:v][logo]overlay=10:10" output.mp4

让我们了解这个命令是如何配置的:

  • [1:v]fade=t=in:st=0:d=1: 将淡入效果应用于覆盖层,从0秒开始,持续1

  • fade=t=out:st=3:d=1[logo]: 随后,从3秒开始,淡出效果持续1

  • overlay=10:10: 覆盖层放置在主视频的坐标(10,10)处。

但我们还可以使用 FFmpeg 做更多的事情,而不仅仅是使用公开的过滤器。让我们看看我们如何使用已经集成到我们项目中的mobile-ffmpeg库来改进我们正在记录的视频。

使用 mobile-ffmpeg 执行 FFmpeg 命令

集成mobile-ffmpeg后,在 Android 中执行 FFmpeg 命令变得流程化。

库的FFmpeg.execute()方法是运行 FFmpeg 命令的入口。例如,一个如-i input.mp4 -c:v libx264 output.mp4的命令,它将输入视频转换为使用 H.264 编解码器,可以在 Android 环境中无缝执行。此函数反映了 FFmpeg 的命令行语法,为熟悉 FFmpeg 命令行界面的人提供了便利。

这就是它的工作方式:

val command = "-i input.mp4 -c:v libx264 output.mp4"
val returnCode = FFmpeg.execute(command)

在前面的代码块中,我们正在构建一个包含command指令的字符串,并将其存储在command变量中。然后,我们使用FFmpeg.execute()方法来执行该命令。请注意,此执行将在当前线程中发生,这在性能方面可能是不理想的。

在 Android 中,管理性能和用户体验至关重要,尤其是在处理像视频处理这样的资源密集型任务时。mobile-ffmpeg通过提供命令的异步执行来适应这一点。利用FFmpeg.executeAsync()确保长时间操作不会阻塞主线程,从而保持应用程序的响应性。当处理复杂的转换或过滤器,例如缩放视频时,这种方法变得非常有用。

这就是我们可以使用executeAsync函数的方式:

FFmpeg.executeAsync(command) { executionId, returnCode ->
    when (returnCode) {
        Config.RETURN_CODE_SUCCESS -> {
            // Processing was successful
        }
        Config.RETURN_CODE_CANCEL -> {
            // Command execution was cancelled
        }
        else -> {
            // Command execution failed
        }
    }
}

在此示例中,executeAsync()方法以字符串格式调用FFmpeg命令。这是我们打算让 FFmpeg 执行的命令,例如转换视频文件、应用过滤器或任何其他由 FFmpeg 支持的媒体处理任务。此命令的执行在一个单独的线程中发生,防止任何阻塞应用程序的主 UI 线程。

当命令执行完成后,会触发一个 Lambda 函数。此函数的结构是为了接收两个参数:executionIdreturnCodeexecutionId参数是FFmpeg命令此特定执行实例的唯一标识符,可以用于跟踪或管理此特定操作,特别是如果我们的应用程序同时处理多个 FFmpeg 进程。

returnCode参数至关重要,因为它指示了执行FFmpeg命令的结果。不同的返回代码及其含义如下:

  • Config.RETURN_CODE_SUCCESS:这个代码表示 FFmpeg 命令成功执行,没有出现任何错误。在相应的 when 语句块中,您可能想要实现处理媒体处理任务成功完成的功能。这可能包括更新用户界面、处理或显示输出文件,或触发后续的应用程序逻辑。

  • Config.RETURN_CODE_CANCEL:这个返回代码表示 FFmpeg 命令的执行被取消。这可能发生在执行程序被终止或某些外部条件预先停止命令的情况下。处理这个返回代码的代码块可能包括通知用户取消、清理资源或为操作的重试做准备。

  • else:这个块捕获所有其他情况,通常表明在执行 FFmpeg 命令期间发生了错误。在这里,错误处理策略包括记录错误以进行诊断、通知用户失败,或在某些条件下尝试重试操作。

为了进一步细化集成,mobile-ffmpeg 允许我们处理进度和日志输出。这对于调试和提升用户体验至关重要。以下是它的工作方式:

FFmpeg.executeAsync(command, ExecuteCallback { executionId,
returnCode ->
    // Handle execution result
}, LogCallback { logMessage ->
    // Handle log message
}, StatisticsCallback { statistics ->
    // Handle progress updates
})

在这里,LogCallback 补充了我们之前描述的执行回调。FFmpeg 以其详尽的日志记录而闻名,提供了关于正在进行的操作的大量信息。这个回调中的 logMessage 参数让您可以访问这些日志,使您可以根据应用程序的需求处理它们。无论是为了调试目的显示这些日志、分析它们以进行详细的错误报告,还是简单地将它们定向到文件以进行记录,这个回调在理解和管理 FFmpeg 操作的复杂性方面发挥着关键作用。

最后但同样重要的是,StatisticsCallback 为实时监控 FFmpeg 进程打开了大门。这个回调通过 statistics 参数提供实时数据,例如当前正在处理的帧、已过时间、比特率等。利用这些数据可以显著提升用户体验,使您能够实现动态功能,如进度条、预计完成时间指示器,甚至详细报告当前操作的状态。

现在我们已经知道了如何在 Android 中执行 FFmpeg 命令,让我们开始构建一些东西。我们将从给视频添加字幕开始。

使用 FFmpeg 给视频添加字幕

在本节中,我们将创建所有需要使用 FFmpeg 添加字幕到视频所需的组件。我们将从这个新功能开始,创建一个用例,其中定义了将字幕添加到视频的业务逻辑。我们将称之为 AddCaptionToVideoUseCase,其职责是将字幕添加到视频中,并在添加后返回新的视频文件。

这就是我们构建 AddCaptionToVideoUseCase 的方法:

class AddCaptionToVideoUseCase() {
    suspend fun addCaption(videoFile: File, captionText:
    String): Result<File> = withContext(Dispatchers.IO) {
        val outputFile = File(
            videoFile.parent,
                "${videoFile.nameWithoutExtension}
                    _captioned.mp4")
        val fontFilePath =
            "/system/fonts/Roboto-Regular.ttf"
        val ffmpegCommand = arrayOf(
            "-i", videoFile.absolutePath,
            "-vf", "drawtext=fontfile=$fontFilePath:
                text='$captionText':
                    fontcolor=white:
                        fontsize=24:x=(w-text_w)/2:
                            y=(h-text_h)-10",
            "-c:a", "aac",
            "-b:a", "192k",
            outputFile.absolutePath
        )
        try {
            val executionId =
            FFmpeg.executeAsync(ffmpegCommand)
            { _, returnCode ->
                if (returnCode !=
                Config.RETURN_CODE_SUCCESS) {
                    Result.failure<AddCaptionToVideoError>(
                        AddCaptionToVideoError)
                }
            }
            // Optionally handle the executionId, e.g., for
               cancellation
            Result.success(outputFile)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}
object AddCaptionToVideoError: Throwable("There was an
error adding the caption to the video") {
    private fun readResolve(): Any = AddCaptionToVideoError
}

在前面的代码中,我们首先创建了一个挂起函数 addCaption,它专门设计用来通过协程促进异步执行。由于添加字幕涉及视频处理等密集型任务,我们应该避免在主线程中执行这类逻辑,以防止应用程序出现任何延迟或无响应。该函数接受两个参数:一个表示视频文件的 File 对象和一个包含要添加的字幕文本的 String

addCaption 函数内部,执行上下文切换到 I/O 分派器。这样做是为了优化 I/O 操作,确保文件处理工作负载得到适当的处理,而不会对主线程造成压力。函数接着创建一个 outputFile 对象。该对象代表添加字幕后生成的新的视频文件。

函数的下一部分涉及构建 FFmpeg 的命令字符串。这个命令被精心设计,以利用 FFmpeg 的 drawtext 过滤器,使得提供的字幕文本可以叠加到视频上。让我们分析一下之前代码块中使用的命令:

val command = "-i ${videoFile.absolutePath} -vf drawtext=text='$captionText':fontcolor=white:fontsize=24:x=(w-text_w)/2:y=(h-text_h)/2 -codec:a copy ${outputFile.absolutePath}"

让我们分解这个命令:

  • -i \({videoFile.absolutePath}**: 命令的这一部分指定了 FFmpeg 处理的输入文件。**-i** 标志用于 FFmpeg 中的输入文件,而 **\){videoFile.absolutePath} 会动态插入正在处理的视频文件的绝对路径。

  • -vf drawtext=text='$captionText':...: -vf(视频过滤器)标志用于应用过滤器到视频上。在这里,drawtext 过滤器用于向视频中添加文本。

  • text='\(captionText'**: 这指定了要绘制的文本。在这里,**\)captionText 是一个变量,用于存储字幕文本,它将被动态地插入到命令中。

  • fontcolor=white: 设置文本的字体颜色为白色。

  • fontsize=24: 定义用于文本的字体大小。

  • x=(w-text_w)/2: 这设置了文本的水平位置。在这里,w 代表视频的宽度,而 text_w 是文本的宽度。通过将 x 设置为 (w-text_w)/2,文本在水平方向上居中。

  • y=(h-text_h)/2: 类似地,这设置了文本的垂直位置。在这里,h 是视频的高度,而 text_h 是文本的高度。这个公式在视频内垂直居中文本。

  • -codec:a acc: 命令的这一部分指示 FFmpeg 使用 acc 作为音频流的编解码器。

  • -b:a=192k:命令的这一部分将比特率设置为 192k。

  • ${outputFile.absolutePath}:命令的最后部分指定了输出文件的路径,处理并添加了字幕的视频将保存在这里。

使用FFmpeg.executeAsync()异步执行这个 FFmpeg 命令。这个方法对于以非阻塞方式运行命令至关重要,并伴随着一个 Lambda 函数来处理执行结果。Lambda 函数评估 FFmpeg 执行中的returnCode。在非成功执行(由除RETURN_CODE_SUCCESS之外任何返回代码指示)的情况下,函数构建Result.failure,并包装一个自定义的AddCaptionToVideoError对象。这个自定义错误对象被定义为单例,提供了一个特定的错误消息,指示字幕处理过程中存在问题。

反之,成功执行命令会导致Result.success,并传递outputFile。这种处理成功和失败场景的分岔确保了健壮的错误管理和关于字幕处理过程结果的清晰反馈。

现在,我们可以在StoryEditorViewModel中使用AddCaptionToVideoUseCase

class StoryEditorViewModel(
    private val saveCaptureUseCase: SaveCaptureUseCase,
    private val addCaptionToVideoUseCase:
    AddCaptionToVideoUseCase
): ViewModel() {
  // Other variables we defined for the photo feature
    var videoFile: File? = null
  // Other code we already added for the photo feature
    fun addCaptionToVideo(captionText: String) {
        videoFile?.let { file ->
            viewModelScope.launch {
                val result =
                    addCaptionToVideoUseCase.addCaption(
                        file, captionText)
                // Handle the result of the captioning
                   process
            }
        }
    }
}

我们首先通过构造函数将AddCaptionToVideoUseCase注入到StoryEditorViewModel中。然后,我们在ViewModel中声明一个videoFile变量,它持有我们正在处理的视频——它是可空的,因为可能有时我们没有视频来显示或编辑。在videoFile中,我们应该存储已经记录的视图。

接下来,这个ViewModel的核心函数是addCaptionToVideo。这个函数以字幕文本作为输入,并使用我们拥有的视频文件。首先,它会检查videoFile是否不是null。如果我们有视频,它就会继续进行;如果没有,则什么都不发生。

addCaptionToVideo函数内部,通过在viewModelScope中启动协程,我们确保我们的字幕添加过程不会冻结 UI。这对于保持流畅的用户体验至关重要。

然后,我们用视频文件和字幕文本调用我们的用例的addCaption方法。无论这个操作返回什么——成功或失败——都会存储在结果中。

// 处理字幕处理过程的结果注释是放置我们根据结果更新 UI 的代码的地方。这可能意味着显示带有字幕的视频,显示错误消息,或者对我们应用有意义的任何其他操作。为了简单起见,我们暂时不会在这里添加它,但当我们创建类似 Netflix 的应用时,我们将在本书的最后三章中了解更多关于视频播放的内容。

但我们仍然可以在我们的视频中测试效果。我们只需使用 Android Studio 中的设备资源管理器查看内部应用文件。在那里,我们会看到两个文件——一个是原始视频,另一个是带有_captioned后缀的修改版:

图 6.3:Android Studio 中的设备资源管理器,包含视频文件

图 6.3:Android Studio 中的设备资源管理器,包含视频文件

如果我们下载带有字幕的视频文件并播放,我们应该能看到视频已经添加了字幕:

图 6.4:带有“这是字幕文本”文字的视频

图 6.4:带有“这是字幕文本”文字的视频

现在我们知道了如何将字幕应用到我们的视频上,让我们看看如何应用过滤器。

使用 FFmpeg 为视频添加过滤器

在本节中,我们将学习如何为我们的视频添加过滤器。一个视觉上影响显著的流行过滤器是“渐晕”效果——这种效果通常会使画面的边缘变暗,将观众的注意力引向图像或视频的中心,并可以为视频增添戏剧性或电影般的质感。FFmpeg 具有应用这种艺术过滤器到视频的能力,所以让我们试试吧!

正如我们对字幕所做的那样,我们将首先创建用例:AddVignetteEffectUseCaseAddVignetteEffectUseCase的主要作用是使用mobile-ffmpeg执行将渐晕效果应用到给定视频文件的业务逻辑。我们将使用特定的FFmpeg命令,如下所示:

class AddVignetteEffectUseCase() {
    suspend fun addVignetteEffect(videoFile: File):
    Result<File> = withContext(Dispatchers.IO) {
        val outputFile = File(videoFile.parent,
            "${videoFile.nameWithoutExtension}
                _vignetted.mp4")
        val command = "-i ${videoFile.absolutePath} -vf
            vignette=angle=PI/4 ${outputFile.absolutePath}"
        try {
            val executionId = FFmpeg.executeAsync(command)
            { _, returnCode ->
                if (returnCode !=
                Config.RETURN_CODE_SUCCESS) {
                    Result.failure<AddVignetteEffectError>(
                        AddVignetteEffectError)
                }
            }
            // Optionally handle the executionId, e.g., for
               cancellation
            Result.success(outputFile)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}
object AddVignetteEffectError : Throwable("There was an
error adding the vignette effect to the video") {
    private fun readResolve(): Any = AddVignetteEffectError
}

让我们逐行分析AddVignetteEffectUseCase中的代码。在这里,addVignetteEffect是一个挂起函数,意味着它被设计为与 Kotlin 的协程异步运行。在这个函数中,我们获取需要渐晕效果的视频文件,并首先定义处理后的视频的保存位置。我们保留原始视频不变,并为输出创建一个新文件。输出文件的名称保留了原始名称,但添加了_vignetted以便于追踪。

接下来,我们构建 FFmpeg 命令。这个命令告诉 FFmpeg 应用渐晕效果。让我们详细看看这个命令(已在之前的代码块中存在)是如何工作的:

val command = "-i ${videoFile.absolutePath} -vf vignette=angle=PI/4 ${outputFile.absolutePath}"

这个命令由以下部分组成:

  • -i \({videoFile.absolutePath}**:这个命令部分指定了 FFmpeg 要处理的输入文件。**-i**标志用于 FFmpeg 中的输入文件,而**\){videoFile.absolutePath}动态插入要处理的视频文件的绝对路径。简单来说,它告诉 FFmpeg,“这是我想要你处理的视频。”

  • -vf vignette=angle=PI/4:这个部分是应用渐晕效果的地方。

  • -vf代表视频过滤器,是 FFmpeg 中的一个强大功能,允许你将各种转换或效果应用到你的视频上。

  • vignette=angle=PI/4:这是渐晕效果的特定过滤器和设置。FFmpeg 中的渐晕过滤器用于应用渐晕效果,通常会使视频的边缘变暗,以聚焦于中心。angle=PI/4部分是渐晕过滤器的参数,用于控制效果的角度。这个特定的设置PI/4是为了提供一个视觉上令人愉悦的渐晕效果。这有点像是一种创意选择,平衡了微妙和影响。

  • ${outputFile.absolutePath}:命令的最后部分指定了处理后的视频的保存位置。它接受你想要保存新视频(应用了图章效果)的路径。通过将此路径放在命令中,你是在告诉 FFmpeg,“一旦你添加了效果,就将新视频保存到这里。”

当运行此命令时,我们使用FFmpeg.executeAsync。这个方法很棒,因为它在运行我们的命令时不会阻塞应用程序。该方法还有一个检查是否一切按计划进行的方式。如果命令运行成功,我们返回新图章视频的路径。但如果出现问题,我们会捕获它并返回一个错误。在这里,AddVignetteEffectError是我们抛出的自定义错误消息,如果 FFmpeg 命令没有正确执行。这是一个简单的方法,在添加图章效果时,我们可以确切地知道出了什么问题。有了这个,AddVignetteUseCase就准备好了。

现在,我们可以将这个用例集成到StoryEditorViewModel中:

class StoryEditorViewModel(
    private val saveCaptureUseCase: SaveCaptureUseCase,
    private val addCaptionToVideoUseCase:
        AddCaptionToVideoUseCase,
    private val addVignetteEffectUseCase:
        AddVignetteEffectUseCase
): ViewModel() {
...
    var videoFile: File? = null
...
    fun addVignetteFilterToVideo() {
        videoFile?.let { file ->
            viewModelScope.launch {
                val result =
                    addVignetteEffectUseCase
                        .addVignetteEffect(file)
                // Handle the result of the filter process
            }
        }
    }
}

在这里,StoryEditorViewModel被设计为接收AddVignetteEffectUseCase作为依赖项。

在这个 ViewModel 中,我们维护一个videoFile属性,它包含一个引用,指向将要应用图章效果的视频文件。这个属性的可以为空性允许存在视频文件可能无法立即可用的情况。

执行此功能的函数是addVignetteEffectToVideo。当调用此函数时,它会检查videoFile是否不为空,确保有一个有效的文件可以处理。如果可用视频文件,函数将继续在viewModelScope中启动一个协程。

在协程内部,使用视频文件作为参数调用addVignetteEffectUseCase.addVignetteEffect方法。这是图章效果应用到视频上的地方。这个操作的结果被捕获在一个名为result的变量中。这个结果可能表明效果成功应用或由于某些错误而失败。

函数内的注释部分// 处理图章效果处理的结果是我们通常处理操作结果的地方。根据图章效果是否成功应用,这部分可能包括更新 UI 以显示处理后的视频或处理过程中可能发生的任何错误。

正如我们在讨论添加字幕时提到的,我们还没有实现视频播放,但我们仍然可以在我们的视频中测试效果。就像在图 6.3中一样,我们可以看到两个文件,但这次其中一个文件有一个_vignetted后缀来表示它已经被修改:

图 6.5:Android Studio 中的设备资源管理器

图 6.5:Android Studio 中的设备资源管理器

我们可以下载并重新生成这两个视频来检查和测试过滤器:

图 6.6:应用图章过滤器效果前(左)和后(右)的视频

图 6.6:应用了(右)和未应用(左)渐晕滤镜效果的视频

现在我们已经知道如何集成 FFmpeg 并使用其命令编辑用户的视频,是时候上传这些视频,以便它们可以在他们的联系人之间共享。

上传视频

现在视频已经准备好了,是时候将其上传到任何服务,以便用户可以与他们的联系人共享。我们将使用 Firebase 存储来完成这项工作(有关如何设置 Firebase 存储的说明,请参阅第三章)。

我们将首先创建一个数据源,负责将视频上传到 Firebase 存储。我们将称之为VideoStorageDataSource

class VideoStorageDataSource {
    fun uploadVideo(videoFile: File, onSuccess: (String) ->
    Unit, onError: (Exception) -> Unit) {
        val storageReference =
            FirebaseStorage.getInstance().reference
        val videoRef = storageReference.child(
            "videos/${videoFile.name}")
        val uploadTask =
            videoRef.putFile(Uri.fromFile(videoFile))
        uploadTask.addOnSuccessListener {
        videoRef.downloadUrl.addOnSuccessListener { uri ->
            onSuccess(uri.toString())
        }
        }.addOnFailureListener { exception ->
            onError(exception)
        }
    }
}

uploadVideo函数内部,我们开始指示我们将执行 I/O 派发器的逻辑。

然后,函数的核心部分是我们使用 Firebase 存储。首先,我们使用FirebaseStorage.getInstance().reference获取存储的引用,之后我们设置一个引用,用于在 Firebase 中存储我们的视频,使用storageReference.child("videos/${videoFile.name}")

接下来,我们开始上传本身。使用putFile方法上传视频文件。这就是await()发挥作用的地方。这个await()是一个挂起函数,它耐心地等待上传完成,而不会阻塞线程。它是 Kotlin 协程魔法的一部分,是异步操作的一个变革者。

一旦上传完成,我们需要获取我们视频的 URL。因此,我们调用downloadUrl.await()。就像上传一样,await()会挂起操作,直到 Firebase 给我们提供视频的 URL。

我们还处理了错误处理。上传和 URL 检索过程被包裹在一个try-catch块中。如果在上传或获取 URL 的过程中出现任何问题,我们会捕获异常并将其包裹在Result.failure(e)中。另一方面,如果一切顺利,我们返回Result.success(downloadUrl.toString()),传递新上传视频的 URL。

接下来,我们将实现一个负责管理并将数据源连接到领域层的仓库。我们将将其接口命名为VideoRepository,实现为VideoRepositoryImpl

interface VideoRepository {
    suspend fun uploadVideo(videoFile: File):
        Result<String>
}
class VideoRepositoryImpl(private val
videoStorageDataSource: VideoStorageDataSource) :
VideoRepository {
    override suspend fun uploadVideo(videoFile: File):
    Result<String> {
        return try {
            var uploadResult: Result<String> =
                Result.failure(RuntimeException("Upload
                    failed"))
            firebaseStorageDataSource.uploadVideo(
            videoFile, { url ->
                uploadResult = Result.success(url)
            }, { exception ->
                uploadResult = Result.failure(exception)
            })
            uploadResult
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

首先,我们有我们的VideoRepository接口。这是一个简单的 Kotlin 接口,包含一个关键功能:uploadVideo

接下来,我们有VideoRepositoryImpl类,它实现了VideoRepository接口。这个类是动作发生的地方。它使用VideoStorageDataSource的实例进行初始化。

然后,uploadVideo函数遵循try-catch模式以实现健壮的错误处理。最初,它设置一个默认的uploadResult为失败。这是一种谨慎的方法,假设事情可能会出错,并且我们只有在上传成功时才会更新它。

然后,我们在videoStorageDataSource上调用uploadVideo方法,传递视频文件以及两个 Lambda 函数来处理成功和失败的情况。如果上传成功,成功 Lambda 将uploadResult更新为已上传视频的 URL。如果出现失败,失败 Lambda 将uploadResult更新为遇到的异常。

最后,我们返回uploadResult。如果一切顺利,我们将看到已上传视频的 URL。如果不顺利,我们将看到在过程中发生的错误。try-catch块确保如果在整个过程中出现任何意外的异常,我们将捕获它并将其作为失败返回。

现在,是我们实现UploadVideoUseCase的时候了:

class UploadVideoUseCase(private val videoRepository:
VideoRepository) {
    suspend fun uploadVideo(videoFile: File):
    Result<String> {
        return videoRepository.uploadVideo(videoFile)
    }
}

在这里,我们注入了VideoRepository。在uploadVideo函数中,我们调用videoRepository并传递videoFile作为参数。

最后,我们将UploadVideoUseCase包含在StoryEditorViewModel中,并从那里使用它:

class StoryEditorViewModel(
private val saveCaptureUseCase: SaveCaptureUseCase,
private val addCaptionToVideoUseCase:
    AddCaptionToVideoUseCase,
private val addVignetteEffectUseCase:
    AddVignetteEffectUseCase,
private val uploadVideoUseCase: UploadVideoUseCase
) : ViewModel() {
...
    fun uploadVideo(videoFile: File) {
        viewModelScope.launch {
            val result =
                uploadVideoUseCase.uploadVideo(videoFile)
            // Handle the result of the upload process
        }
    }
}

StoryEditorViewModel中,我们添加了一个名为uploadVideo的函数,它接受视频文件并使用uploadVideoUseCase来上传它。操作在协程中执行,以确保它不会阻塞 UI 线程。

// 处理上传过程的结果注释处是我们根据上传结果实现逻辑的地方。如果上传成功,我们可能会更新 UI 以显示视频已上传或显示视频 URL。在失败的情况下,我们会处理错误,可能通过向用户显示错误消息。

通过这次更改,我们准备好从我们的 ViewModel 上传视频。通过这样做,我们完成了这一章,以及 Packtagram 的工作!

概述

总结这一章,你显著提升了 Packtagram 应用的视频功能。

从 CameraX 开始,我们将它的用途从拍照扩展到捕捉高质量视频,但这只是个开始。随后,我们深入研究了 FFmpeg,这是一个功能极其强大的视频编辑工具。在这里,你学习了如何为视频增添创意,无论是通过讲述故事的字幕还是通过改变整体外观和感觉的滤镜。

但如果一部视频不能分享,那它又有什么伟大之处呢?我们也通过集成 Firebase Storage 解决了这个问题,以实现无缝的视频上传。这意味着你的应用现在能够平滑地处理大文件,确保用户享受无故障的体验。

通过这一章,我们完成了 Packtagram 的工作。现在,是时候学习将在最后三章中实现的项目了:一个视频播放应用,这样你就可以观看你最喜欢的电视剧和电影了!

第三部分:创建 Packtflix,一个视频媒体应用

在本最后一部分,你将学习如何创建一个名为 Packtflix 的视频流应用。你将从设置应用结构开始,使用 OAuth2 实现安全的用户身份验证,并使用 Jetpack Compose 构建动态 UI 组件来浏览电影列表和详情。然后,通过集成 ExoPlayer,创建直观的播放 UI,管理媒体控件,并添加字幕以增强可访问性,来掌握视频播放。最后,你将通过扩展视频播放功能来增强应用,包括画中画PiP)模式和媒体投射,实现无缝的多任务处理和向更大屏幕的流式传输。

本部分包括以下章节:

  • 第七章, 启动视频流应用并添加身份验证

  • 第八章, 使用 ExoPlayer 将媒体播放添加到 Packtflix

  • 第九章, 在 Packtflix 应用中扩展视频播放

第七章:开始视频流应用程序并添加身份验证

在掌握了如何创建像 WhatsApp 和 Instagram 这样的引人入胜的社交应用程序之后,现在是时候进入视频流服务领域了。本章标志着我们第三个项目的开始:一个类似 Netflix 的应用程序。让我们称它为 Packtflix。在这里,我们将探索 Android 开发的另一个方面,重点是多媒体内容交付和用户身份验证,同时继续构建吸引人的用户界面。

我们的旅程将从为我们的流媒体应用程序打下基础开始。我们将从头开始,设置一个新的项目,并介绍应用程序的结构和模块。

在设置完成后,我们将深入探讨任何应用程序最关键的一个方面:验证用户身份。在当今这个数字时代,安全和隐私比以往任何时候都更加重要,因此您将学习如何使用 OAuth2 实现强大的身份验证机制。这将确保您的应用程序用户可以安全地访问他们的账户和个人偏好。

一旦我们的用户可以登录,我们将专注于向他们展示丰富的电影选择。我们将使用 Jetpack Compose 创建动态和响应式的列表,展示可用的内容。

最后,我们将深入了解细节。您应用程序中的每部电影或电视剧都应得到其应有的关注,您将使用 Jetpack Compose 为它们创建详细的界面。这将使用户能够获得他们决定观看下一部电影所需的所有信息。

因此,本章将涵盖以下主题:

  • 创建应用程序的结构和模块

  • 构建登录界面

  • 验证应用程序的用户身份

  • 创建您的电影列表

  • 制作电影和电视剧详情界面

技术要求

如前一章所述,您需要安装 Android Studio(或您偏好的其他编辑器)。

本章我们将开始一个新的项目,因此没有必要下载前一章所做的更改。

您可以在本章节中找到我们将要构建的完整代码,它位于这个仓库中:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-7.

创建应用程序的结构和模块

在本节中,我们将通过将 Packtflix 应用程序组织成功能模块来为其打下基础。正如我们之前所看到的,通过将应用程序划分为登录、列表和播放等模块,我们可以一次专注于一个功能,而不会影响其他功能,从而加快大型项目的构建过程。此外,我们还将为我们的依赖项设置版本目录,就像我们之前所做的那样,以简化对 Jetpack Compose、Dagger Hilt 和 Kotlin 等库的管理。

让我们开始创建项目。在 Android Studio 中,选择 文件 | 新建 | 新建项目…,然后选择 空 Compose Activity。然后,在 新建项目 面板中,填写 名称包名保存位置。对于 最小 SDK 选项,我们将再次选择 API 29,因为它在撰写时保证了最佳的兼容性百分比。

图 7.1:Packtflix 的新项目配置

图 7.1:Packtflix 的新项目配置

图 7.1 中的选项是我们将在 Android Studio Iguana(版本 2023.2.1)中看到的,尽管它可能因版本不同而有所变化。例如,在其他之前的 Android Studio 版本中,我们还可以选择是否为依赖项使用版本目录。

现在,版本目录默认创建,因此我们将在项目中获得一个 libs.versions.toml 文件,其内容如下:

[versions]
agp = "8.3.0-alpha18"
kotlin = "1.9.0"
coreKtx = "1.12.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.7.0"
activityCompose = "1.8.2"
composeBom = "2023.08.00"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

此代码向版本目录添加了基本的依赖项,以使用 Kotlin、Android 和 Jetpack Compose 构建应用程序。

下一步将是创建所需的模块。在这里,我们将创建三个功能模块:

  • :feature:login: 我们将使用此模块来包含登录功能

  • :feature:list: 在此模块中,我们将包括列表界面以及详情界面

  • :feature:playback: 在此模块中,我们将托管所有播放功能

我们还将创建以下通用模块:

  • :app: 此模块将包含我们应用程序的入口点

  • :common: 此模块将包含在多个模块中需要的通用功能

要创建这些模块,使用 文件 | 新建 | 新建模块… 选项,就像我们在前面的项目中做的那样。最终的项目结构应该看起来像这样:

图 7.2:项目模块结构

图 7.2:项目模块结构

现在我们已经创建了我们的模块结构,是时候设置依赖注入框架了。

设置依赖注入框架

正如我们在前面的章节中看到的,可扩展性、性能优化和可测试性的需求使得在 Android 中使用依赖注入框架几乎成为必须。在这种情况下,我们将再次使用 Hilt(想了解更多,请参阅第一章,在那里我们对框架进行了全面审查并揭示了其主要优势)。

让我们从向我们的版本目录添加依赖项开始。打开我们的 libs.versions.toml 文件,并在 versionslibrariesplugins 块中添加 Hilt 依赖项,如下所示:

[versions]
// ...
hiltVersion = "2.50"
[libraries]
// ...
androidxHilt = { module = "com.google.dagger:hilt-android", name = "hilt", version.ref = "hiltVersion" }
hiltCompiler = { module = "com.google.dagger:hilt-android-compiler", name = "hilt-compiler", version.ref = "hiltVersion" }
[plugins]
// ...
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" }

然后,我们将插件添加到项目级别的 build.gradle.kts

plugins {
    ...
    alias(libs.plugins.hilt) apply false
}

接下来,在每一个模块的 build.gradle.kts 文件中,我们将必须应用插件并添加 Hilt 依赖项:

plugins {
//...
    alias(libs.plugins.hilt)
}
dependencies {
//...
    implementation(libs.androidxHilt)
    kapt(libs.hiltCompiler)
}

现在,在 :app 模块中,我们可以创建 PacktflixApp 类,它将是 Hilt 配置的入口点:

@HiltAndroidApp
class PacktflixApp: Application() {
}

通过这个注解,我们正在启用 Hilt 在底层生成必要的组件,这些组件将在我们的应用程序中用于依赖注入。

最后,我们应该在 AndroidManifest.xml 中包含 PacktflixApp,这样我们的应用程序就会使用它而不是默认的 Application 类:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android =
"http://schemas.android.com/apk/res/android"
    xmlns:tools = "http://schemas.android.com/tools">
    <application
        android:name = ".PacktflixApp"
        ...>
....
    </application>
</manifest>

现在,我们已经准备好开始构建我们的新项目了。第一步将是构建登录界面,因为我们希望用户使用他们的凭据进行身份验证。让我们开始工作吧!

构建登录界面

要构建登录界面,我们将开始创建一个 LoginScreen 可组合组件,使用 Jetpack Compose。我们必须包括应用程序的标志、输入电子邮件和密码的字段以及一个 登录 按钮。我们还可以包括一个文本来显示当用户尝试登录时是否有任何错误。

这个登录界面将有四种状态(IdleLoadingSuccessError),所以让我们开始建模整体的 ViewState

sealed class LoginState {
    object Idle : LoginState()
    object Loading : LoginState()
    object Success : LoginState()
    data class Error(val message: String?) : LoginState()
}

现在,让我们创建 LoginScreen 可组合组件:

@Composable
fun LoginScreen() {
    val loginViewModel: LoginViewModel = hiltViewModel()
    val loginState =
        loginViewModel.loginState.collectAsState().value
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var errorMessage by remember { mutableStateOf("") }
//...
}

我们通过 hiltViewModel() 获取 LoginViewModel 来开始可组合函数,这是通过 hiltViewModel() 访问的。这个 ViewModel 组件管理登录逻辑并通过 StateFlow 流暴露当前的登录状态。collectAsState().value 调用将异步的登录状态流转换为可组合友好的状态,当登录状态改变时触发重新组合。

该函数使用 remember { mutableStateOf("") } 来维护用户输入的电子邮件和密码在可组合组件的生命周期内的状态。这个状态是可变的和响应式的,意味着任何对输入字段(由 onValueChange 处理)的更改都会自动更新相应的变量,从而更新 UI。

让我们继续下一部分的可组合组件,这将包括应用程序的名称、emailpassword 字段以及 登录 按钮:

Surface(color = Color.Black, modifier =
Modifier.fillMaxSize()) {
        Column(
            horizontalAlignment =
                Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
            modifier = Modifier
                .padding(16.dp)
        ) {
            if (loginState is LoginState.Error) {
                Text(
                    text = loginState.message ?:
                        "Unknown error",
                    color = Color.Red,
                    modifier = Modifier
                        .padding(bottom = 16.dp)
                )
            }
            Text(
                text = "PACKTFLIX",
                color = Color.Red,
                fontSize = 36.sp,
                modifier = Modifier.padding(bottom = 32.dp)
            )
            OutlinedTextField(
                value = email,
                onValueChange = { email = it },
                label = { Text("Email") },
                colors = OutlinedTextFieldDefaults.colors(
                    focusedContainerColor =
                        Color.Transparent,
                    focusedTextColor = Color.White,
                    focusedBorderColor = Color.Gray,
                    unfocusedBorderColor = Color.Gray
                ),
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.height(8.dp))
            OutlinedTextField(
                value = password,
                onValueChange = { password = it },
                label = { Text("Password") },
                visualTransformation =
                    PasswordVisualTransformation(),
                colors = OutlinedTextFieldDefaults.colors(
                    focusedTextColor = Color.White,
                    focusedContainerColor =
                        Color.Transparent,
                    focusedBorderColor = Color.Gray,
                    unfocusedBorderColor = Color.Gray
                ),
                keyboardActions = KeyboardActions(
                    onDone = { loginViewModel.login(
                        email, password) }
                ),
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.height(24.dp))
            Button(
                onClick = { loginViewModel.login(email,
                    password) },
                colors = ButtonDefaults.buttonColors(
                    containerColor = Color.Gray)
            ) {
                Text("Sign In", color = Color.White)
            }
            Spacer(modifier = Modifier.height(24.dp))
            if (loginState is LoginState.Loading) {
                CircularProgressIndicator()
            }
        }
    }
    LaunchedEffect(loginState) {
        when (loginState) {
            is LoginState.Success -> {
                // Navigate to next screen or show success
                   message
            }
            is LoginState.Error -> {
                errorMessage = loginState.message ?:
                    "An error occurred"
            }
            else -> Unit // Handle other states if
                            necessary
        }
    }

UI 会根据当前的登录状态动态调整。例如,如果登录状态是 LoginState.Error,函数将渲染一个 Text 可组合组件来显示错误信息。这种条件渲染对于向用户提供反馈至关重要,例如指示登录失败或显示加载指示器(CircularProgressIndicator),当登录过程正在进行时。这种 UI 开发方法声明式,UI 的结构和内容直接映射到应用程序的状态。

OutlinedTextField 可组合组件用于捕获 emailpassword 的用户输入,然后当用户点击 password 字段(通过 KeyboardActions)时,这些输入被用来启动登录过程(loginViewModel.login(email, password))。这展示了如何在可组合组件中处理用户动作和输入,触发 ViewModel 动作,最终导致状态变化。

最后,LaunchedEffect块监听登录状态的变化以执行副作用,例如在登录成功后进行导航或更新错误信息状态。这种模式将副作用与 UI 逻辑分离,确保导航或显示吐司等效果仅在响应状态变化时发生,而不是直接由用户操作的结果。

现在,让我们开始工作在LoginViewModel上:

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val loginUseCase: DoLoginUseCase
) : ViewModel() {
    private val _loginState =
        MutableStateFlow<LoginState>(LoginState.Idle)
    val loginState: StateFlow<LoginState> = _loginState
    fun login(email: String, password: String) {
        viewModelScope.launch {
            _loginState.value = LoginState.Loading
            val result = loginUseCase.doLogin(email,
                password)
            _loginState.value = when {
                result.isFailure -> LoginState.Error(
                    result.exceptionOrNull()?.message)
                else -> LoginState.Success
            }
        }
    }
}

在这里,我们开始处理依赖注入相关的内容:使用@HiltViewModel表示 Hilt 将负责LoginViewModel的实例化和提供。@Inject构造函数表示 Hilt 将向这个ViewModel实例注入必要的依赖,在这种情况下,是一个名为DoLoginUseCase的使用案例的实现(我们将在后面实现这个使用案例)。

ViewModel实例使用MutableStateFlow<LoginState>来管理登录状态。在这里,_loginState是一个私有的、可变的 state flow,它持有登录过程的当前状态,可以是IdleLoadingSuccessError之一。不可变的loginState属性将这个状态以只读的StateFlow的形式暴露给 UI 层,确保状态更新能够安全且高效地传达给 UI。

登录函数体现了这个ViewModel类的核心功能。它通过将_loginState设置为Loading来启动登录过程,表示登录操作已经开始。然后,它继续调用提供的loginUseCase上的doLogin方法,并传递用户的电子邮件和密码。

在尝试登录后,函数评估结果。如果登录尝试失败(result.isFailure),_loginState会被更新为Error状态,并带有异常信息,提供登录失败的原因。如果登录成功,_loginState会被设置为Success,表示登录过程成功。这种条件处理确保 UI 能够适当地对登录过程的不同结果做出反应。

登录过程在viewModelScope中启动,这是一个与ViewModel生命周期绑定的协程作用域。这确保了如果ViewModel实例被清除(通常是在相关的 UI 组件被销毁时),任何正在进行的登录操作都会自动取消,从而防止内存泄漏和不必要的操作。

有了这个,我们的登录界面就准备好了。最后一步是设置 Hilt 模块并将MainActivity的内容设置为显示LoginScreen可组合界面:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PacktflixTheme {
                LoginScreen()
            }
        }
    }
}

如果我们现在运行应用,我们应该看到以下屏幕:

图 7.3:Packtflix 登录界面

图 7.3:Packtflix 登录界面

现在我们已经完成了我们的 UI,下一步将是验证用户。让我们学习如何做到这一点。

验证应用的用户

在移动应用程序中,身份验证在保护用户数据和个人信息免受未经授权的访问方面发挥着关键作用。由于移动设备通常作为通向各种服务的个人网关,并存储大量敏感数据,因此确保这些数据得到安全管理和访问比以往任何时候都更重要。验证用户的最受欢迎的方法之一是 OAuth2。

OAuth2是一个授权框架,允许第三方服务代表用户交换网络资源。它使用户能够授予网站或应用程序访问其他网站上的其信息的权限,而无需提供他们的密码。这对于提供使用 Google、Facebook 或其他社交媒体账户登录等功能特别有用。

以下列出了 OAuth2 最重要的功能:

  • 安全性:它允许用户授权应用程序在不共享其凭据的情况下访问不同服务器上的资源,通常是通过使用涉及用户同意和安全令牌交换的过程授予的访问令牌。

    一个 OAuth 令牌是代表授予应用程序的授权的凭证,允许它代表用户访问特定资源。这些令牌可以采用各种格式,如不透明令牌或JSON Web TokensJWTs)。不透明令牌是没有任何特定结构的简单字符串,而 JWTs 是由三个部分组成的结构化令牌——一个头部、一个负载和一个签名——所有这些都在 Base64 中进行编码。

  • 可扩展性:它允许将用户身份验证委托给托管用户账户的服务,通过将安全身份验证和基础设施可扩展性的技术复杂性卸载到专用服务中。这些服务通常由负责确保和扩展身份验证过程的复杂且资源密集型任务的特定团队管理。

  • 灵活性:它支持多种流程(基于授权类型,这将确定身份验证过程必须遵循的流程),适用于不同类型的客户端,包括移动应用、网站和服务器端应用程序。

  • 用户体验:它使用户的登录体验更加顺畅,因为用户可以使用现有的账户登录到新的服务,而无需创建新的凭据。

在本质上,OAuth2 为在移动应用程序中实现身份验证提供了一种安全且高效的方法。它利用现有的用户账户,简化了用户的登录过程,并将管理用户凭据和会话的复杂性卸载到第三方服务中,从而提高了安全性和用户体验。

让我们在我们的应用中添加这个功能,首先添加所需的模型。

创建用户模型

首先,我们将定义一个简单的用户模型,它将保存我们在成功身份验证后收到的用户信息:

data class User(
    val id: String,
    val name: String,
    val email: String,
    // Add other fields as necessary
)

在这段代码中,我们定义了存储用户信息所需的基本字段(根据您应用程序的要求,这些字段可能会有所不同)。

然后,为了构建我们将发送到后端以获取认证令牌的登录请求,我们需要另一个数据类来存储凭证:

data class LoginRequest(val email: String, val password:
String)

在这里,我们包括了emailpassword字段,这些字段是登录用户所必需的。

一旦这个请求到达后端,如果凭证正确,后端将返回一个授权令牌,我们的应用程序将将其存储在安全的地方,并用于验证后续对后端的 API 调用。我们需要另一个模型来存储这个令牌信息:

data class AuthToken(val token: String)

现在,让我们设置 Retrofit 来获取这个授权令牌。

使用 Retrofit 获取授权令牌

为了获取授权令牌,我们需要我们的应用程序在用户提供他们的凭证时请求它。为了将这个请求发送到后端,我们将使用 Retrofit。我们已经在第四章中使用了 Retrofit,所以让我们跳过介绍,直接从设置 Retrofit 将用于发送 HTTP 请求的接口开始:

interface AuthService {
    @POST("auth/login")
    suspend fun login(@Body loginRequest: LoginRequest):
        Response<AuthToken>
}

这段代码定义了一个名为AuthService的接口,具有一个独特的登录功能。我们将传递一个包含请求所需数据的LoginRequest对象,然后获取一个AuthToken响应。

让我们构建这些模型。首先,我们将构建LoginRequest模型:

data class LoginRequest(val email: String, val password:
String)

在这个模型中,我们将用户的凭证——他们的电子邮件和密码——发送到后端。

然后,如果登录成功,后端应该返回一个包含授权令牌的响应。我们将按照以下结构来构建这个响应:

data class AuthToken(val token: String)

这个AuthToken模型将包括上述授权令牌。请注意,通常这些令牌有一个时间窗口,因此必须在它们过期之前更新。为了简单起见,我们将假设这个令牌不会过期。

现在,让我们创建我们的远程数据源以检索授权令牌:

class LoginRemoteDataSource(
    private val authService: AuthService
) {
    suspend fun login(email: String, password: String):
    Result<String> {
        return authService.login(
            LoginRequest(
                email = email,
                password = password
            )
        ).run {
            val token = this.body()?.token
            if (this.isSuccessful && token != null) {
                Result.success(token)
            } else {
                Result.failure(getError(this))
            }
        }
    }
}

在这里,我们定义了LoginRemoteDataSource类,该类将作为数据源层,通过交互远程认证服务来处理登录功能。这个类将有一个单一的依赖项,即authService,它是一个接口(可能是 Retrofit 或类似的网络库),负责处理与认证相关的网络请求。在这个类中的主要功能login是一个挂起函数,它接受两个参数,emailpassword,这些参数用于构建一个LoginRequest对象。然后,这个对象被传递给authService.login方法,启动一个网络请求以登录用户。

在接收到 authService.login 的响应后,执行 run 块来处理响应。在这个块内部,检查响应以确定请求是否成功(isSuccessful)以及响应体是否包含非 null 令牌。如果这两个条件都满足,则返回 Result.success(token),将令牌封装在成功的结果中。这表明登录成功,并向调用者提供令牌。相反,如果任一条件不满足——即请求失败或令牌为空——则通过调用 Result.failure(getError(this)) 返回失败结果。getError 函数将分析 Response<AuthToken> 对象以确定失败的性质,并返回一个描述错误的适当 Throwable 对象。

到目前为止,让我们构建 getError() 函数:

    private fun getError(response: Response<AuthToken>):
    Throwable {
        return when (response.code()) {
            401 -> LoginException.AuthenticationException(
                "Invalid email or password.")
            403 -> LoginException.AccessDeniedException(
                "Access denied.")
            404 -> LoginException.NotFoundException(
                "Login endpoint not found.")
            in 500..599 -> LoginException.ServerException(
                "Server error: ${response.message()}.")
            else -> LoginException.HttpException(
                response.code(),
                "HTTP error: ${response.code()}
                    ${response.message()}."
            )
        }
    }

在这个 getError 函数中,我们将响应的状态码的可能值映射到不同的错误。如果我们愿意,我们可以在以后处理这些错误,并相应地向用户显示消息。

让我们也定义那些错误,我们将它们定义为 LoginException 密封类的一部分,这是 Kotlin 中的一种特殊类型类,它将继承层次结构限制为特定的一组子类,提供详尽的 when 表达式,并确保处理了所有可能的错误类型:

sealed class LoginException(loginErrorMessage: String, val
code: Int? = null) : Exception(loginErrorMessage) {
    class AuthenticationException(message: String) :
        LoginException(message)
    class AccessDeniedException(message: String) :
        LoginException(message)
    class NotFoundException(message: String) :
        LoginException(message)
    class ServerException(message: String) :
        LoginException(message)
    class HttpException(code: Int, message: String) :
        LoginException(message, code)
}

现在我们已经有了我们的 LoginRemoteDataSource 组件,是时候定义如何存储令牌了。

使用 DataStore 存储令牌

由谷歌推出,DataStore 是一种数据存储解决方案,它提供了一种高效、安全且异步的方式来持久化小块数据。它使用 Kotlin 协程和流式传输来异步存储数据,确保 UI 线程安全并提高性能。

DataStore 提供了几个功能,使其成为 Android 应用程序中首选的数据存储选项:

  • 默认异步:DataStore 操作默认使用 Kotlin 协程异步执行,防止阻塞主线程并提高应用性能。

  • 安全和一致:使用内置的事务性数据 API,DataStore 确保数据一致性和完整性,即使在写入操作期间应用程序进程被杀死也是如此。

  • 类型安全:DataStore 提供了两种实现:偏好 DataStore,它存储和检索键值对,以及 Proto DataStore,它允许使用 Protocol Buffers 存储类型安全的对象。

  • 安全性:DataStore 可以与加密机制集成,以安全地存储敏感信息。DataStore 可以与加密库(如 Tink)结合使用,在保存之前加密数据,使其成为处理用户凭据、令牌和其他敏感信息的更安全选项。

为什么我们会使用 DataStore 而不是 Room(我们之前用于我们的 WhatsPackt 消息传递项目)?虽然两者都是健壮的数据持久化库,但它们服务于不同的目的,并且具有不同的用例:

  • 适用用例:DataStore 设计用于存储小型数据集合,例如设置、偏好或应用程序状态。它在处理结构简单的轻量级任务方面表现出色。RoomDatabase 是一个 SQLite 抽象,可以显著减少使用 SQLite 所需的样板代码量。它旨在满足更复杂的数据存储需求,例如存储大型数据集、关系数据或当我们需要执行复杂查询时。

  • 性能和复杂性:DataStore 提供了一个更简单的 API 用于数据存储,设置最少,使其非常适合简单的任务。其性能针对小型数据集和简单的数据结构进行了优化。RoomDatabase 作为数据库,更适合复杂查询和大型数据集。它涉及更多的设置,比 DataStore 更重,但提供了更多功能和能力,用于全面的数据管理。

  • 数据安全:DataStore,尤其是与 Proto DataStore 结合使用时,可以轻松集成加密机制以安全地存储数据,使其成为敏感信息的更安全选项。RoomDatabase 支持 SQLite 加密,但集成加密需要额外的设置,可能还需要第三方库。

由于我们只需要存储一个小的值(令牌)并且考虑到其安全特性,DataStore 是最佳选择。

因此,要开始使用它,首先,我们需要在我们的版本目录中设置 DataStore 依赖项及其版本:

[versions]
datastore = "1.0.0"
[libraries]
datastore = { module = "androidx.datastore:datastore-
preferences", version.ref = "datastore" }

然后,需要将其添加到我们的模块的 gradle.build.kts 文件中:

dependencies {
 ...
    implementation(libs.datastore)
}

使用此代码,我们只将其添加到需要使用依赖项的模块中 - 初始情况下,这将是 :feature:login 模块。

现在,我们可以开始使用 DataStore 库了。我们将构建一个 LoginLocalDataSource 组件,该组件将负责在 DataStore 中存储和检索令牌:

val Context.dataStore by preferencesDataStore(name = "user_preferences")
class LoginLocalDataSource(private val context: Context) {
    companion object {
        val TOKEN_KEY = stringPreferencesKey("auth_token")
    }
    suspend fun saveAuthToken(token: String) {
        context.dataStore.edit { preferences ->
            preferences[TOKEN_KEY] = token
        }
    }
    suspend fun getAuthToken(): Result<String> {
        val preferences = context.dataStore.data.first()
        val token = preferences[TOKEN_KEY]
        return if (token != null) {
            Result.success(token)
        } else {
            Result.failure(TokenNotFoundError())
        }
    }
}
class TokenNotFoundError : Throwable("Auth token not
found")

LoginLocalDataSource 中,首先,我们利用 Kotlin 的属性委托功能来初始化 DataStore。通过定义 val Context.dataStorepreferencesDataStore(name: "user_preferences"),我们确保 DataStore 的单例实例是懒加载的,并且与应用程序的上下文相关联。这种方法优化了资源使用并简化了后续的数据操作。

LoginLocalDataSource 中,我们定义一个伴生对象来持有 TOKEN_KEY,这是一个用于从 DataStore 存储和检索认证令牌的键。此键使用 stringPreferencesKey("auth_token") 定义,表示我们打算存储的数据类型 - 在这种情况下,是一个 String 类型。

saveAuthToken函数中,我们通过调用edit并传递一个 lambda 表达式,将提供的令牌赋值给TOKEN_KEY,在 DataStore 上执行写操作。这个操作是原子的且线程安全的,确保了数据的一致性。

为了检索认证令牌,getAuthToken还采用了挂起语义以促进异步执行。它将 DataStore 的数据作为流访问,立即使用.data.first()获取第一个发出的值。此操作挂起协程,有效地使数据检索感觉是同步的,同时保持异步执行的好处。然后函数检查令牌是否存在,并将其包装在Result<String>中返回,提供了一种简单处理成功和失败的方式。如果没有令牌,它返回带有自定义TokenNotFoundErrorResult.failure,提供精确的错误处理。

现在,是时候实现LoginRepository了,它负责协调远程和本地数据源。我们将像往常一样,在领域层创建一个接口,在数据层创建实现。这是因为领域层不应该有任何来自数据层的显式依赖,以尊重整洁架构。因此,我们定义接口如下:

interface LoginRepository {
    suspend fun getToken(): Result<String>
    suspend fun loginWithCredentials(email: String,
        password: String): Result<Unit>
}

在这里,该接口将有两个功能:一个用于获取令牌以便在其他地方使用(例如,对于获取后用于验证用户的后端请求)以及另一个用于执行登录并存储新获得的认证令牌。

现在,让我们来实现仓库:

class LoginRepositoryImpl(
    private val localDataSource: LoginLocalDataSource,
    private val remoteDataSource: LoginRemoteDataSource
): LoginRepository {
    override suspend fun getToken(): Result<String> {
        return localDataSource.getAuthToken()
    }
    override suspend fun loginWithCredentials(email:
    String, password: String): Result<Unit> {
        return remoteDataSource.login(email, password)
            .fold(
                onSuccess = {
                    localDataSource.saveAuthToken(it)
                    Result.success(Unit)
                },
                onFailure = {
                    Result.failure(it)
                }
            )
    }
}

LoginRepositoryImpl类是LoginRepository接口的实现,它作为应用程序数据源和其用例或视图模型之间的中介。这个类抽象了数据检索和存储的细节,为认证过程提供了一个统一的 API。它依赖于两个主要的数据源:localDataSource用于本地数据存储和检索,以及remoteDataSource用于处理与用户认证相关的网络请求。

getToken函数中,仓库直接委托调用localDataSource.getAuthToken(),从本地存储中获取认证令牌。此方法返回一个Result<String>对象,以类型安全的方式封装操作的结果。令牌检索对于检查用户的认证状态或进行需要令牌的后续认证 API 调用至关重要。

loginWithCredentials 函数实现了使用电子邮件和密码验证用户的流程。它首先尝试通过 remoteDataSource.login(email, password) 方法进行登录。在折叠的 onSuccess 分支指示登录成功后,它使用 localDataSource.saveAuthToken(it) 保存收到的授权令牌,然后通过 Result.success(Unit) 信号表示登录过程的完成。相反,如果远程登录尝试失败(onFailure),它将错误传播为 Result.failure(it),允许调用代码适当地处理错误。这种设计有效地在本地和远程数据处理之间分离了关注点,确保仓库是应用程序中所有与认证相关的数据流唯一真相的来源。

现在,我们可以构建一个用例来执行登录,消耗这个 LoginRepository 组件:

interface DoLoginUseCase {
    suspend fun doLogin(email: String, password: String):
        Result<Unit>
}
class DoLogin(
    private val loginRepository: LoginRepository
) : DoLoginUseCase {
    override suspend fun doLogin(email: String, password:
    String): Result<Unit> {
        return loginRepository.loginWithCredentials(email,
            password)
    }
}

DoLogin 类实现了 DoLoginUseCase 接口,封装了通过电子邮件和密码验证用户所需的逻辑。通过将认证过程委托给 loginRepository,它调用 loginRepository.loginWithCredentials(email, password) 来执行实际的登录操作。DoLogin 用例将用户认证过程简化为单个方法调用,确保登录执行的细节封装在仓库中,从而促进关注点的分离,使代码更容易维护和测试。

现在,我们已经准备好使用登录功能。接下来,让我们使用这些令牌来验证应用请求。

在请求中发送授权令牌

要完成用户认证:在请求中发送授权令牌,还有一件事我们必须做。我们获取这个认证令牌的原因是将其用于应用将要发送到后端的请求中,这样将保证请求是由哪个用户生成的真实性。为了在每次请求中包含令牌,我们将利用 Retrofit 拦截器。

Retrofit 的 拦截器 是 OkHttp(Retrofit 所使用的底层 HTTP 客户端)提供的一种强大机制,允许您拦截和操作请求和响应链。拦截器可以修改请求和响应,或在请求发送到服务器之前或响应被客户端接收之后执行诸如记录、添加头、处理认证等操作。

拦截器可以大致分为两种类型:

  • 应用程序拦截器:这些拦截器在调用服务器时仅被调用一次。它们不需要担心网络特定的细节,如重试和重定向。应用程序拦截器非常适合添加公共头到所有请求、记录请求和响应体以供调试目的或管理应用程序级缓存等任务。

  • 网络拦截器:这些拦截器可以在网络级别监控数据。它们可以观察和操作来自和发送到服务器的请求和响应,包括作为网络调用过程一部分发生的任何重试和重定向。

要将认证令牌添加到所有发出的请求中,我们将选择一个应用程序拦截器。在这个场景中,我们将选择应用程序拦截器,因为它们设计在应用层操作,直接在请求发送之前修改请求,并在收到响应后处理响应。这使得它们非常适合添加应包含在每个请求中的头信息,例如认证令牌。

因此,让我们编写我们的拦截器:

class AuthInterceptor(private val loginRepository:
LoginRepository) : Interceptor {
    override fun intercept(chain: Interceptor.Chain):
    Response {
        val originalRequest = chain.request()
        val token = runBlocking {
            loginRepository.getToken().getOrNull() }
        val requestWithToken = originalRequest.newBuilder()
            .apply {
                if (token != null) {
                    header("Authorization",
                        "Bearer $token")
                }
            }
            .build()
        return chain.proceed(requestWithToken)
    }
}

这个类在丰富发出的 HTTP 请求的认证细节方面发挥着关键作用。它通过集成 LoginRepository 来实现这一点,从中检索当前用户的授权令牌。在拦截请求时,拦截器获取用户的认证:授权令牌,通过使用 runBlocking(一种允许将基于协程的异步令牌检索无缝集成到拦截器期望的同步流程中的机制)同步发送请求中的令牌。

如果存在令牌,它将作为 Authorization 头附加到请求中,遵循广泛接受的载体令牌格式(载体令牌格式是一种安全方案,其中客户端在请求头中发送令牌以进行身份验证,前缀为单词 Bearer 后跟一个空格和令牌本身),从而确保请求携带服务器进行认证所需的所有凭证。

在拦截器中使用 runBlocking 是一种实用方法,以适应 intercept() 方法的同步性质,允许令牌立即可用。然而,确保令牌检索操作高效且非阻塞至关重要,以避免性能瓶颈——理想情况下,通过从本地缓存或存储中获取令牌。

最后,在函数的末尾,我们返回 chain.proceed(requestwithToken),这将允许 Retrofit 继续处理请求,包括拦截器更改(在这种情况下,添加认证头)。

现在,当我们构建 Retrofit 客户端时,应将 AuthInterceptor 作为拦截器包含在内:

    @Provides
    @Singleton
    fun provideRetrofit(
        moshi: Moshi,
        authInterceptor: AuthInterceptor
    ): Retrofit {
        val okHttpClient = OkHttpClient.Builder()
            .addInterceptor(authInterceptor)
            .build()
        return Retrofit.Builder()
            .baseUrl("https://your.api.url/") // Replace
                                                 with your
                                                 actual
                                                 base URL
            .addConverterFactory(
                MoshiConverterFactory.create(moshi))
            .client(okHttpClient)
            .build()
    }

在这里,我们可以看到如何将我们创建的拦截器集成到我们的网络层设置中,具体是在 Retrofit 配置中。

在函数内部,创建并配置了一个 OkHttpClient 实例,通过 addInterceptor 方法包含 authInterceptor 实例。这种设置确保了该客户端发出的每个 HTTP 请求都将首先通过 authInterceptor,允许它在请求发送之前按需修改请求。

在配置OkHttpClient实例之后,构建 Retrofit 实例。配置的OkHttpClient实例被设置为 Retrofit 的客户端,将 HTTP 客户端及其拦截器链接到 Retrofit 实例。现在,使用此 Retrofit 实例的所有请求都将包含头部的身份验证令牌(如果存在)。

之后,我们处理了应用的身份验证:从获取令牌到存储令牌,并在每个请求中提供此令牌。”现在,是时候构建主屏幕了:电影和电视剧列表。

创建你的电影列表

我们 Packtflix 应用的一个目标是为用户提供自由探索和享受广泛的电影(或电视剧)范围,确保他们保持对我们应用的兴趣。为了实现这一点,我们必须以最吸引人的方式展示我们的电影目录。因此,在本节中,我们将专注于构建一个电影(或系列!)目录屏幕。

要开始构建我们流媒体应用的经典主屏幕,我们首先需要创建我们将用来表示信息的模型。

构建模型

首先,构建Movie模型:

data class Movie(
    val id: Int,
    val title: String,
    val imageUrl: String,
)

这是将表示电影的模型 – 它包括电影标识(id)、标题以及电影图片的 URL。

通常,流媒体应用中的电影是按类型排列的,所以让我们也创建一个Genre模型:

data class Genre(
    val name: String,
    val movies: List<Movie>
)

在这里,我们定义了类型的名称(需要将其渲染到屏幕上)以及包含在该类型中的电影列表。

最后,我们需要一个MoviesViewState类来表示电影列表屏幕状态:

data class MoviesViewState(
    val genres: List<Genre>
)

在这个MoviesViewState类中,我们只包含一个属性genres,它将存储我们希望在流媒体应用列表中显示的类型列表。

现在,我们已准备好开始创建MoviesScreen可组合组件。

构建 MoviesScreen 可组合组件

要构建MoviesScreen可组合组件,请输入以下代码:

@Composable
fun MoviesScreen(moviesViewState: MoviesViewState =
sampleMoviesScreen()) {
    Scaffold(
        containerColor = Color.Black,
        topBar = { PacktflixTopBar() },
        bottomBar = { PacktflixBottomBar() }
    ) { innerPadding ->
        GenreList(
            genres = moviesViewState.genres,
            modifier = Modifier.padding(innerPadding)
        )
    }
}

如我们所见,我们已经创建了我们的MoviesScreen可组合组件以及其内部的Scaffold。作为ScaffoldtopBar组件,我们包括了一个新的可组合组件PackflixTopBar,然后作为bottomBar组件,我们包括另一个新的可组合组件PacktflixBottomBar。最后,在Scaffold的内容中,我们显示了一个GenreList可组合组件。

现在,让我们构建这三个可组合组件:PacktflixTopBarPacktflixBottomBarGenreList

PacktflixTopBar

这里是如何创建PacktflixTopBar可组合组件的:

@Composable
fun PacktflixTopBar() {
    TopAppBar(
        title = {
            Text(
                text = "PACKTFLIX",
                color = Color.Red,
                fontSize = 48.sp,
                modifier = Modifier.padding(bottom = 32.dp)
            )
        },
        actions = {
            IconButton(onClick =
            { /* Handle profile action */ }) {
                Icon(
                    painter = painterResource(id =
                        R.drawable.ic_profile),
                    contentDescription = "Profile"
                )
            }
            IconButton(onClick = { /* Handle more action */ }) {
                Icon(
                    painter = painterResource(id =
                        R.drawable.ic_more),
                    contentDescription = "More"
                )
            }
        },
    )
}

TopAppBar内部,有一个显示屏幕上PACKTFLIX文本的标题 – 文本将以红色显示,具有大字体和一些填充以创建一些空间。

IconButton composable that contains an icon, and each icon gets its image from a resource file.

这就是TopAppBar的外观:

图 7.4:MoviesScreen 中的顶部栏

图 7.4:MoviesScreen 中的顶部栏

让我们继续构建底栏。

PacktflixBottomBar

现在,让我们构建PacktflixBottomBar可组合组件:

@Composable
fun PacktflixBottomBar() {
    NavigationBar (
        containerColor = Color.Black,
        contentColor = Color.White,
    ) {
        NavigationBarItem(
            icon = { Icon(Icons.Filled.Home,
                contentDescription = "Home") },
            selected = false,
            onClick = { /* Handle Home navigation */ }
        )
        NavigationBarItem(
            icon = { Icon(Icons.Filled.Search,
                contentDescription = "Search") },
            selected = false,
            onClick = { /* Handle Search navigation */ }
        )
        NavigationBarItem(
            icon = { Icon(Icons.Filled.ArrowDropDown,
                contentDescription = "Downloads") },
            selected = false,
            onClick = { /* Handle Downloads navigation */ }
        )
        NavigationBarItem(
            icon = { Icon(Icons.Filled.MoreVert,
                contentDescription = "More") },
            selected = false,
            onClick = { /* Handle More navigation */ }
        )
    }
}

这个导航栏采用光滑的黑色背景,白色图标照亮,提供了鲜明而时尚的对比。我们还引入了四个导航项,每个项都由一个独特的图标表示。我们选择了来自 Material Icons 收集的图标,为 主页搜索下载更多 功能分配了具体且直观的符号。

对于 NavigationBarItem 内的每个导航项,我们都设置了一个图标以及一个 onClick 监听器。最初,所有这些项都没有被选中(selected = false),以表明它们的选中状态将通过用户交互或未来要实现的具体逻辑动态管理。这些部分的实现超出了本书的范围。

我们还将每个图标与 contentDescription 配对。这种方法通过为屏幕阅读器提供每个按钮功能的简洁说明,增强了应用程序的无障碍性。

一旦完成,PacktflixBottomBar 将看起来是这样的:

图 7.5:MoviesScreen 中的底部栏

图 7.5:MoviesScreen 中的底部栏

现在,让我们继续下一步,通过实现电影列表来完成这个屏幕。

GenreList

现在,让我们开始构建 GenreList 组合器。通常,流媒体应用程序中电影屏幕的内容由一系列类别组成,其中每个类别包含一系列电影。让我们使用我们之前定义的 Genre 模型,并创建这个列表的列表。我们将开始创建一个由行组成的垂直列表,其中每行将显示每个 Genre 实例的内容:

@Composable
fun GenreList(genres: List<Genre>, modifier: Modifier =
Modifier) {
    LazyColumn(modifier = modifier) {
        items(genres.size) { index ->
            GenreRow(genre = genres[index])
        }
    }
}

为了有效地显示 GenreList 组合器,我们使用了 LazyColumn,这是因为它能够懒加载渲染项目——这意味着它只绘制屏幕上可见的项目,从而提高了性能,尤其是在长列表中。

LazyColumn 内部,我们遍历类别列表。对于每个类别,我们调用 items,指定我们的类别列表的大小以确定它应该准备显示的项目数量。

然后,对于每个项目(或在我们的上下文中,每个类别),我们调用 GenreRow,这是一个我们将稍后定义的自定义组合器函数。这个函数负责渲染列表中的单行,它代表一个类别。我们通过将 genres[index] 索引到我们的类别列表中,将每个类别传递给 GenreRow

现在,让我们构建我们刚才提到的 GenreRow 组合器:

@Composable
fun GenreRow(genre: Genre) {
    Column(modifier = Modifier.fillMaxWidth()) {
        Text(text = genre.name, style =
            MaterialTheme.typography.headlineSmall)
        LazyRow {
            items(genre.movies.size) { index ->
                MovieCard(movie = genre.movies[index])
            }
        }
    }
}

我们从一个垂直容器 Column 开始,该容器跨越整个屏幕的宽度。在这个容器的顶部,我们放置了该类别的名称,使用大号、易读的文本。这使得用户清楚地知道他们正在查看哪个类别。

在类别名称下方,我们设置了一个水平滚动区域,LazyRow,其中填充了电影卡片。每张卡片代表该类别的电影,用户可以水平滚动浏览它们。

对于该类别的每部电影,我们将创建一个 MovieCard 组合器,它将显示电影的缩略图和名称:

@Composable
fun MovieCard(movie: Movie) {
    Card(
        modifier = Modifier
            .padding(8.dp)
            .size(120.dp, 180.dp)
    ) {
        Image(
            painter = rememberAsyncImagePainter(model =
                movie.imageUrl),
            contentDescription = movie.title,
            contentScale = ContentScale.Crop
        )
    }
}

我们首先使用一个提供 Material Design 卡片布局的Card可组合组件。这个卡片被赋予了特定的尺寸和填充,以确保它在整个应用中看起来整洁且统一。具体来说,我们将每个卡片设置为120dp宽和180dp高,周围填充8dp。这个尺寸对于显示电影海报来说非常理想,既不会占用太多屏幕空间,也不会显得过于拥挤。

在卡片内部,我们放置一个Image可组合组件来显示电影的海报。为了从 URL 加载图片(在这个案例中是电影的海报),我们使用rememberAsyncImagePainter,这是一个处理异步图像加载和缓存的便捷函数。这意味着我们的应用可以高效地从互联网上获取电影海报,并在它们可用时显示,而不会阻塞 UI 线程。

图片被设置为裁剪以适应卡片尺寸,确保即使原始图片的宽高比与卡片尺寸不完全匹配,海报的最重要视觉部分仍然可见。这种裁剪也保持了所有电影卡片的一致外观。

最后,我们为图片添加contentDescription,使用电影标题,以使我们的列表尽可能易于访问。

使用这个组件,我们已经完成了我们的电影屏幕(或剧集屏幕——您只需更改标题和内容即可!)现在我们可以使用@Preview注解和提供流派列表来测试它。

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MoviesScreenUI(moviesViewState = sampleMoviesScreen())
}

在这里,我们使用 Jetpack Compose 的预览功能来查看我们的列表将呈现什么样子。我们需要创建一些示例内容,这正是sampleMoviesScreen()函数为我们所做的事情。例如,我们可以创建以下假的电影列表:

fun sampleMoviesScreen(): MoviesViewState {
    return MoviesViewState(
        genres = listOf(
            Genre(
                name = "Comedy",
                movies = listOf(
                    Movie(
                        id = 1,
                        title = "The Hangover",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/b/b9/Hangoverposter09.jpg"
                    ),
                    Movie(
                        id = 2,
                        title = "Superbad",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/8/8b/Superbad_Poster.png"
                    ),
                    Movie(
                        id = 3,
                        title = "Step Brothers",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/d/d9/StepbrothersMP08.jpg"
                    ),
                    Movie(
                        id = 4,
                        title = "Anchorman",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/6/64/Movie_poster_Anchorman_The_Legend_of_Ron_Burgundy.jpg"
                    )
                )
            ),
            Genre(
                name = "Mystery",
                movies = listOf(
                    Movie(
                        id = 1,
                        title = "Se7en",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/6/68/Seven_%28movie%29_poster.jpg"
                    ),
                    Movie(
                        id = 2,
                        title = "Zodiac",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/3/3a/Zodiac2007Poster.jpg"
                    ),
                    Movie(
                        id = 3,
                        title = "Gone Girl",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/0/05/Gone_Girl_Poster.jpg"
                    ),
                    Movie(
                        id = 4,
                        title = "Shutter Island",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/7/76/Shutterislandposter.jpg"
                    )
                )
            ),
            Genre(
                name = "Documentary",
                movies = listOf(
                    Movie(
                        id = 1,
                        title = "March of the Penguins",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/1/19/March_of_the_penguins_poster.jpg"
                    ),
                    Movie(
                        id = 2,
                        title = "Bowling for Columbine",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/e/e7/Bowling_for_columbine.jpg"
                    ),
                    Movie(
                        id = 3,
                        title = "Blackfish",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/b/bd/BLACKFISH_Film_Poster.jpg"
                    ),
                    Movie(
                        id = 4,
                        title = "An Inconvenient Truth",
                        imageUrl = "https://upload.wikimedia.org/wikipedia/en/1/19/An_Inconvenient_Truth_Film_Poster.jpg"
                    )
                )
            )
        )

在这里,我们创建假数据以使MoviesScreen的测试更容易。请注意,提供的 URL 不是实际的图片 URL,因此您需要将它们替换为实际的电影海报。

完成后,我们的列表屏幕应该看起来像这样:

图 7.6:电影列表屏幕

图 7.6:电影列表屏幕

现在我们有了我们的流派和电影列表,让我们构建电影(或剧集)详情页面。

制作电影和剧集详细屏幕

在本节中,我们将创建详细屏幕,这是当用户从列表中点击电影或剧集时将显示的屏幕。该屏幕将包括剧情简介、演员阵容、上映年份等信息。

在构建必要的可组合组件之前,我们需要考虑我们需要哪些模型。让我们开始创建它们。

创建详细模型

为了定义模型,我们需要考虑在详细屏幕中想要展示的数据。由于我们希望为电影和剧集创建相同的模型,我们将构建一个ItemDetail模型,如下所示:

data class ItemDetail(
    val type: Type,
    val title: String,
    val imageUrl: String,
    val rating: String,
    val year: String,
    val cast: List<String>,
    val description: String,
    val creators: List<String>,
    val episodes: List<Episode>,
    val movieUrl: String
) {
    enum class Type {
        MOVIE, SERIES
    }
}

如果ItemDetail代表一个流媒体剧集项目,我们也应该定义Episode模型:

data class Episode(
    val title: String,
    val imageUrl: String,
    val duration: String,
    val episodeUrl: String
)

现在我们已经准备好了模型,我们可以开始构建DetailScreen可组合组件。

构建详细屏幕

正如我们在其他场合所做的那样,我们首先构建我们想要的屏幕结构:

@Composable
fun ItemDetailScreen(item: ItemDetail =
createFakeItemDetail()) {
    val scrollState = rememberScrollState()
    Column(
        verticalArrangement = Arrangement.Top,
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
            .padding(all = 8.dp)
            .verticalScroll(scrollState)
    ) {
        ItemBannerImage(item.imageUrl)
        ItemTitleAndMetadata(item.title, item.isHD,
            item.year, item.duration)
        ItemActions(item.movieUrl)
        Text(text = item.description, color = Color.Gray)
        CastAndCreatorsList(item.cast, item.creators)
        AdditionalMovieDetails(item)
    }
}

ItemDetailScreen 中,所有包含的可组合组件都显示在一个垂直的 Column 中,这使得我们可以在添加新的可组合组件时逐步构建 UI。

现在,让我们开始构建所有这些可组合组件,从 ItemBannerImage 开始:

@Composable
fun ItemBannerImage(imageUrl: String) {
    Box(modifier = Modifier.fillMaxWidth()) {
        Image(
            painter = rememberAsyncImagePainter(model =
                imageUrl),
            contentDescription = "Movie Banner",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .height(200.dp)
                .fillMaxWidth()
        )
        IconButton(
            onClick = {
                /* TODO: Handle back action */
            },
            modifier = Modifier
                .align(Alignment.TopStart)
                .padding(top = 32.dp, start = 16.dp)
        ) {
            Icon(
                imageVector = Icons.Default.ArrowBack,
                contentDescription = "Back",
                tint = Color.White
            )
        }
    }
}

这个可组合组件在屏幕顶部显示横幅图片,将其拉伸以填充屏幕宽度。它使用一个带有 Modifier 参数的 Box 可组合组件,确保它占据屏幕的全宽,并使用 Image 可组合组件通过 rememberAsyncImagePainter 函数从给定的 URL 加载图片。图片设置为 200 dp 高,并自动调整其宽度以适应屏幕,确保它被正确裁剪到分配的空间中。

在图片上方,有一个 IconButton 可组合组件,它旨在充当 返回 按钮。我们将这个按钮放置在左上角并添加一些填充。在这个按钮内部,有一个形状像箭头指向回的图标,暗示按下它应该带您回到上一个屏幕。这个图标是白色的,以确保它在横幅图片上可见。

现在,让我们构建 ItemTitleAndMetadata 可组合组件:

@Composable
fun ItemTitleAndMetadata(
    title: String,
    isHD: Boolean,
    year: String,
    duration: String
) {
    Column {
        Text(
            text = title,
            style = MaterialTheme.typography.bodyMedium,
            fontWeight = FontWeight.Bold,
            color = Color.White
        )
        Row(verticalAlignment = Alignment.CenterVertically)
        {
            if (isHD) {
                Box(
                    modifier = Modifier
                        .border(BorderStroke(1.dp,
                            Color.White), shape =
                                RoundedCornerShape(4.dp))
                        .padding(horizontal = 6.dp,
                            vertical = 2.dp)
                ) {
                    Text(
                        text = "HD",
                        style =
                        MaterialTheme.typography.bodySmall,
                        color = Color.White
                    )
                }
                Spacer(modifier = Modifier.width(8.dp))
            }
            Text(
                text = year,
                style =
                    MaterialTheme.typography.bodyMedium,
                color = Color.Gray
            )
        }
        Text(
            text = duration,
            style = MaterialTheme.typography.bodyMedium,
            color = Color.Gray
        )
    }
}

我们首先创建一个 Column 布局,因为我们希望细节垂直堆叠。

在这个列中,我们将显示项目的标题。我们在这里选择 bodyMedium 从 Material 主题,确保它与应用程序的整体设计很好地匹配。

接下来,我们将我们的 HD 指示符和发布年份对齐在一行中,垂直居中以确保它们完美对齐。我们包含一个条件检查 - 只有当 isHDtrue 时,我们才显示 HD 徽章。我们给这个徽章一个白色边框和一些填充,使其在背景上更加突出。

在一个小间隔之后,这个间隔在 HD 徽章和年份之间增加了一些空间,我们放置年份的文本。它的样式设计得比标题不那么突出,使用中等灰色颜色。

最后,在行下方,我们将显示项目的持续时间。它也是中等灰色,与年份匹配,并使用相同的 bodyMedium 风格以保持一致性。

下一步是创建 ItemActions 可组合组件:

@Composable
fun ItemActions(
    itemUrl: String
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
    ) {
        ActionButton(
            icon = Icons.Filled.PlayArrow,
            label = "Play",
            onClick = { /* TODO: Handle play action */ }
        )
        ActionButton(
            icon = Icons.Default.Add,
            label = "My List",
            onClick = {
                /* TODO: Handle add to list action */ }
        )
    }
}
@Composable
fun ActionButton(icon: ImageVector, label: String, onClick:
() -> Unit) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.clickable(onClick = onClick)
    ) {
        Icon(
            imageVector = icon,
            contentDescription = label
        )
        Text(text = label)
    }
}

我们首先以列格式排列这个函数,以便我们的操作按钮垂直堆叠 - 这个列将占据可用宽度,并且周围都有填充,以从屏幕边缘留出一些空间。

在这个列内部,我们放置两个操作按钮:一个用于播放项目,另一个用于将项目添加到用户的个人列表中。为了创建这些按钮,我们使用 ActionButton 可组合函数,它将图标和标签整齐地捆绑在一起,形成一个可点击的区域。对于 播放 操作,我们使用播放箭头图标,对于添加到列表,我们使用 添加 图标。

注意

我们在代码中留下了占位符,以便可以编写播放和添加到列表的动作。在下一章中,我们将实现播放按钮;然而,我将留给你自己添加添加到列表的功能。为此,一个解决方案是在按下添加到列表按钮时调用一个端点,以便后端可以将其存储在用户列表中(当然,假设我们有一个处理此功能的后端)。你可以参考第四章,了解我们如何将 Packtagram 与新闻动态连接起来。

现在,让我们继续下一个可组合组件,CastAndCreatorsList

@Composable
fun CastAndCreatorsList(cast: List<String>, creators:
List<String>) {
    Column(modifier = Modifier.fillMaxWidth()) {
        Text(
            text = "Cast",
            style = MaterialTheme.typography.titleSmall,
            color = Color.White,
            modifier = Modifier.padding(horizontal = 16.dp,
                vertical = 8.dp)
        )
        LazyRow(
            contentPadding = PaddingValues(horizontal =
                16.dp),
            horizontalArrangement =
                Arrangement.spacedBy(8.dp)
        ) {
            items(cast) { actorName ->
                Text(
                    text = actorName,
                    style =
                       MaterialTheme.typography.bodyMedium,
                    color = Color.White,
                    modifier = Modifier.background(
                        color = Color.DarkGray,
                        shape = RoundedCornerShape(4.dp)
                    ).padding(horizontal = 8.dp,
                        vertical = 4.dp)
                )
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        Text(
            text = "Created by",
            style = MaterialTheme.typography.titleMedium,
            color = Color.White,
            modifier = Modifier.padding(horizontal = 16.dp,
                vertical = 8.dp)
        )
        LazyRow(
            contentPadding = PaddingValues(horizontal =
                16.dp),
            horizontalArrangement =
                Arrangement.spacedBy(8.dp)
        ) {
            items(creators) { creatorName ->
                Text(
                    text = creatorName,
                    style =
                       MaterialTheme.typography.bodyMedium,
                    color = Color.White,
                    modifier = Modifier.background(
                        color = Color.DarkGray,
                        shape = RoundedCornerShape(4.dp)
                    ).padding(horizontal = 8.dp,
                        vertical = 4.dp)
                )
            }
        }
    }
}

我们从Column开始,它将垂直堆叠我们的元素。我们希望它占据可用的全部宽度,所以我们使用Modifier.fillMaxWidth()

然后,我们在顶部放置一个标记为Cast的标题。我们使用MaterialTheme.typography.titleSmall来使这个文本突出,并将其颜色设置为白色。为了给它留出一些空间,我们在其周围添加填充。

接下来,我们引入一个LazyRow可组合组件来显示演员列表中的每个演员的名字,使用一个Text可组合组件。我们通过应用MaterialTheme.typography.bodyMedium并将文字颜色设置为白色来使名字在背景上突出。为了进一步区分每个名字,我们使用RoundedCornerShape(4.dp)给它们一个类似标签的外观,背景为深灰色,边缘为圆角。此外,我们在文字周围添加填充,以确保它不触及其灰色背景的边缘,从而增强可读性和视觉吸引力。

然后,我们使用一个Spacer可组合组件将演员与创作者分开。这只是在两个部分之间添加一点垂直空间,这样它们就不会相互碰撞。

对于创作者,设置几乎相同。我们有一个标记为"Created by"的标题,其样式与Cast标题类似,但使用titleMedium稍大一些。然后,我们在另一个LazyRow中列出创作者,给他们与演员相同的样式文本标签。

现在,是时候处理屏幕上的最后一个可组合组件,AdditionalMovieDetails

@Composable
fun AdditionalMovieDetails(item: ItemDetail) {
    Column(modifier = Modifier.fillMaxWidth()) {
        // Assuming item.episodes is a list of episodes
           with their details
        item.episodes.forEach { episode ->
            EpisodeItem(episode = episode)
        }
    }
}
@Composable
fun EpisodeItem(episode: Episode) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clickable {
                /* TODO: Handle episode playback */ }
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        // Episode image
        Image(
            painter = rememberAsyncImagePainter(model =
                episode.imageUrl),
            contentDescription = "Episode Thumbnail",
            modifier = Modifier
                .size(width = 120.dp, height = 68.dp)
                .clip(RoundedCornerShape(4.dp)),
            contentScale = ContentScale.Crop
        )
        // Space between image and text details
        Spacer(modifier = Modifier.width(16.dp))
        // Episode title and duration
        Column {
            Text(
                text = episode.title,
                style =
                    MaterialTheme.typography.bodyMedium,
                color = Color.White
            )
            Text(
                text = "Duration: ${episode.duration}",
                style = MaterialTheme.typography.bodySmall,
                color = Color.Gray
            )
        }
    }
    Divider(color = Color.Gray, thickness = 0.5.dp)
}

AdditionalMovieDetails中,我们设置了一个列,它扩展到其父容器最大宽度。在这个列中,我们遍历item.episodes列表中的每个剧集,并为每个剧集调用EpisodeItem可组合组件来渲染该剧集的详细信息。

接下来,我们转向EpisodeItem可组合函数,这是放置每个剧集信息的地方。我们创建了一行,它横跨整个宽度,可以点击——这就是我们想要添加点击播放剧集代码的地方。我们还添加了一些填充来增加间距。

在这一行中,首先是剧集的图片。我们使用rememberAsyncImagePainter从剧集 URL 加载图片,并确保它被很好地圆角裁剪以适应特定的大小。这张图片将作为剧集的缩略图。

在图片旁边,我们添加了一个空白区域,为剧集的文字细节提供一些呼吸空间。随后是一个包含两段文字的列:剧集的标题,它更加突出,以及在其下方,剧集的时长,以较小的颜色和不太突出的方式呈现。

最后,在每个剧集项目之后,我们画一条细灰线,作为分隔符,以在视觉上区分各个剧集。这是一种常见的布局模式,有助于用户区分不同的内容。

使用这个组合组件,我们已经完成了详情屏幕和这一章。我们的详情屏幕应该看起来像这样:

图 7.7:详情屏幕

图 7.7:详情屏幕

在下一章中,我们将通过实现播放功能,让那些电影和电视剧变得生动起来。

摘要

随着这一章的结束,我们为 Packtflix,我们的视频流应用,奠定了坚实的基础。我们首先构思了项目的结构和模块,为有序和可扩展的应用程序奠定了基础。这种结构对我们未来的旅程至关重要,随着我们添加更多功能,复杂性将会增长。

我们随后创建了登录屏幕,然后进入了用户认证的世界。通过整合 OAuth2,我们为 Packtflix 配备了一个安全的认证系统,尊重用户隐私,防止未经授权的访问,确保用户能够在一个值得信赖的环境中享受他们喜爱的内容。

我们的进展继续,我们制作了一个用户界面来显示精心挑选的电影列表,利用 Jetpack Compose 的力量创建了一个动态和吸引人的体验。这种在呈现内容时的细致入微将把新用户转变为 Packtflix 的忠实粉丝。

在下一章中,我们将学习如何实现播放功能,这样我们的用户不仅能看到电影和电视剧信息,还能播放他们的视频。

第八章:使用 ExoPlayer 将媒体播放添加到 Packtflix

在 Android 开发的旅程中,能够创建丰富、引人入胜的多媒体应用是一项至关重要的技能,它将优秀的应用与良好的应用区分开来。随着我们进一步探索创建类似 Netflix 的应用,我们将从浏览电影列表和详情的基础结构和用户界面过渡到多媒体体验的核心:视频播放。本章致力于挖掘我们应用中视频内容潜力,这一功能将显著提升用户参与度和满意度。在这里,我们将进入 Android 媒体播放的世界,重点关注功能强大且多才多艺的库——ExoPlayer。

ExoPlayer 在 Android 生态系统中脱颖而出,作为一个健壮的开源库,它为标准的 Android MediaPlayer API 提供了替代方案。它提供了广泛的定制选项,并支持广泛的媒体格式,包括那些 Android 本地不支持格式。我们的探索将从 Android 媒体选项概述开始,为为什么 ExoPlayer 是现代寻求提供卓越媒体播放体验的 Android 应用首选库奠定基础。

在介绍媒体选项之后,我们将学习 ExoPlayer 的基础知识,包括其架构、关键组件以及如何在 Android 应用程序中集成。这些基础知识将为我们应对视频播放的实际实现做好准备。本章将指导你创建一个响应式、直观的视频播放用户界面,以满足当今用户的期望。

旅程将继续,通过实际示例和详细指导,使用 ExoPlayer 播放视频。这包括管理播放控制、调整视频质量和处理各种媒体源。此外,在认识到可访问性和全球覆盖的重要性时,你将学习如何为你的视频添加字幕,确保你的内容能够被更广泛的观众所访问。

到本章结束时,你将掌握 Android 视频播放的基本知识,并具备用高质量视频内容丰富你应用的技能,为你的用户提供沉浸式体验。

在本章中,我们将涵盖以下主题:

  • 检查 Android 的媒体选项

  • 检查 Android 的媒体选项

  • 创建视频播放用户界面

  • 使用 ExoPlayer 播放视频

  • 为视频播放器添加字幕

技术要求

如前一章所述,你需要安装 Android Studio(或你偏好的其他编辑器)。

我们将继续在第七章中开始的同一个项目中工作。您可以在本书的 GitHub 仓库中找到我们将在本章构建的完整代码:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-8

检查 Android 的媒体选项

Android 作为一款多功能的移动操作系统,为各种类型的媒体提供了全面的支持,包括但不限于音频文件(如 MP3、WAV 和 OGG)和视频内容(如 MP4、WebM 和 MKV)。这种广泛的支持使开发者能够将各种媒体类型集成到他们的应用程序中,以满足不同的用户偏好和使用场景。从利用视频教程进行学习的教育应用,到流式传输电影和音乐的娱乐平台,媒体播放是现代移动应用的核心,推动用户参与度和满意度。

为了开始我们的旅程,让我们看看在 Android 生态系统中我们有哪些选项,以便我们可以选择最合适的选项来构建我们应用的视频播放功能。我们将从 MediaPlayer API 和 VideoView 开始,然后再考虑 ExoPlayer。

了解 MediaPlayer API

MediaPlayer API 是一个强大且灵活的类,它允许 Android 开发者以高度控制的方式处理音频和视频播放。该 API 设计得易于使用,同时能够满足复杂的媒体播放需求。

它的主要功能如下:

  • 多功能的媒体源支持:MediaPlayer 可以从各种来源播放媒体,包括本地文件(如设备存储或 SD 卡)、原始资源(包含在应用中)和网络流(HTTP/HTTPS)。

  • 播放控制:它提供了对媒体播放的全面控制,包括播放、暂停、停止、倒退和快进选项,以及寻求特定时间戳的能力。

  • 音量控制:Android 中的 MediaPlayer API 允许开发者通过编程方式调整音频播放的音量。这是通过如 setVolume(float leftVolume, float rightVolume) 这样的方法实现的,它独立控制左右扬声器的音量级别。这一特性对于创建能够根据特定用户设置、环境条件或应用场景动态调整播放音量的应用程序至关重要。例如,一个应用可能会在夜间自动降低音量,或者在嘈杂的环境中提高音量以增强用户体验。

  • 事件处理:MediaPlayer 提供了可以用于响应媒体生命周期事件的监听器,例如完成、准备、错误处理和缓冲更新。

  • 音频焦点管理:对于播放音频的应用程序来说至关重要,MediaPlayer 可以处理音频焦点,以确保在多个应用程序可能同时播放声音时提供流畅的用户体验。

如我们所见,MediaPlayer 提供了我们需要的简单音频和视频处理的基本功能,因此它可能是以下情况的良好解决方案:

  • 音乐播放器:MediaPlayer 非常适合播放音乐或播客文件的应用程序,无论是存储在本地还是通过互联网流式传输。

  • 视频播放器:虽然与 VideoView 相比,MediaPlayer 需要更多的设置来播放视频,但它非常适合需要控制渲染和播放的自定义视频播放器应用程序。

  • 游戏音效:对于需要播放短音效的游戏,MediaPlayer 可以因其简单性和处理各种音频格式的能力而被使用。

下面是一个使用 MediaPlayer 重新生成音频文件的示例:

@Composable
fun AudioPlayerComposable() {
    val context = LocalContext.current
    val mediaPlayer = remember { MediaPlayer.create(
        context, R.raw.my_audio_file) }
    // Observe lifecycle to release MediaPlayer
    ObserveLifecycle(owner = ProcessLifecycleOwner.get()) {
        onExit = {
            mediaPlayer.release()
        }
    }
    Column(modifier = Modifier.padding(16.dp)) {
        Button(onClick = {
            if (!mediaPlayer.isPlaying) {
                mediaPlayer.start()
            }
        }) {
            Text("Play")
        }
        Button(onClick = {
            if (mediaPlayer.isPlaying) {
                mediaPlayer.pause() // Use pause or stop
                                       based on your need
            }
        }) {
            Text("Stop")
        }
    }
}
@Composable
fun ObserveLifecycle(owner: LifecycleOwner, onExit: () ->
Unit) {
    // Use DisposableEffect to manage lifecycle
    DisposableEffect(owner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_DESTROY) {
                onExit()
            }
        }
        owner.lifecycle.addObserver(observer)
        onDispose {
            owner.lifecycle.removeObserver(observer)
        }
    }
}

在本例中,MediaPlayer.create()remember 块中使用,以确保媒体播放器只实例化一次,并在组合的重组过程中保持此实例。然后,使用 ObserveLifecycle 组合函数来观察整个应用程序的生命周期(这里为了简单起见使用 ProcessLifecycleOwner)。此函数确保在应用程序销毁时调用 mediaPlayer.release() 以释放资源,尽管你可能需要根据需要将其适配到更具体的生命周期事件。

UI 由两个按钮组成,用于播放和停止功能。播放按钮的 onClick 逻辑在开始播放之前检查媒体是否当前未播放。这样做是为了避免在播放过程中按下按钮时重新启动音频和视频。同样,停止按钮暂停播放。

本例演示了如何在管理媒体播放器生命周期的同时将 MediaPlayer 与 Jetpack Compose 集成,并提供一个简单的 UI 来控制播放。您可以在官方文档中找到更多示例:developer.android.com/media/platform/mediaplayer

尽管我们的示例说明了如何提供播放控制 UI,但我们仍然需要显示视频,以便用户可以观看。这就是 VideoView 发挥作用的地方。

了解 VideoView

VideoView 是 Android 中的一个高级 UI 组件,它封装了 MediaPlayer 和 SurfaceView 的功能,以提供一种方便播放视频文件的方式。它通过管理底层的媒体播放机制简化了视频播放过程,使其非常适合需要直接视频播放而不需要精细控制媒体管道的场景。

注意

SurfaceView 是 Android 框架中的一个专用组件,它为应用视图层次结构提供了一个专门的绘图表面。与绘制在由 UI 线程管理的单个画布上的标准视图不同,SurfaceView 可以在单独的线程中独立渲染。这允许更高效的重新绘制,特别是对于视频播放或动态图形等要求较高的内容。SurfaceView 在您需要频繁更新视图或渲染过程计算密集时特别有用,因为它在绘图时不会阻塞用户交互。

让我们探索一些 VideoView 的功能,以便我们能够欣赏它提供的实际好处:

  • 简单性:VideoView 简化了视频播放的实现。您只需几行代码就可以开始播放视频,自动处理视频文件的准备和播放。

  • 控制集成:它可以轻松集成媒体控件(使用 MediaController),使用户能够播放、暂停和搜索视频。

  • 格式支持:VideoView 支持 Android 的 MediaPlayer 支持的多种视频格式,包括 MP4、3GP 等,具体取决于设备和平台版本。

  • 布局灵活性:作为一个视图,VideoView 可以放置在您应用布局的任何位置,可以根据需要调整大小和样式,就像任何其他 UI 组件一样。

理解 VideoView 的功能为其实际应用奠定了基础。现在,让我们精确地找出 VideoView 的亮点。以下是使用 VideoView 在您的应用中的最佳场景:

  • 简单视频播放:当您需要播放视频而不需要高级播放功能,如自适应流式传输时,VideoView 是一个简单而有效的选择。自适应流式传输,如HTTP 实时流HLS)和HTTP 动态自适应流DASH),允许根据网络条件以不同的质量传输视频。HLS 在网页上的直播和点播流以及根据观众的网络速度动态调整视频质量方面得到广泛应用。同样,DASH 是一个灵活的标准,它允许在互联网上以高质量流式传输媒体内容。

  • 本地和网络视频:它适用于播放存储在设备上的本地视频或通过网络流式传输的视频。

  • 嵌入式视频内容:VideoView 非常适合需要在其 UI 中直接嵌入视频内容的应用程序,例如教程应用、视频播放器或带有视频流的社会媒体应用。

现在我们已经了解了其功能和推荐的使用案例,让我们通过一个示例来了解它是如何工作的。在这个例子中,我们使用的是androidx.media:media库的 1.7.0 版本:

@Composable
fun VideoPlayer(modifier: Modifier = Modifier, videoUrl:
String) {
    val context = LocalContext.current
    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            VideoView(ctx).apply {
                val mediaController = MediaController(ctx)
                setMediaController(mediaController)
                mediaController.setAnchorView(this)
                setVideoURI(Uri.parse(videoUrl))
                start() // Auto-start playback
            }
        }
    )
}

在这里,我们首先声明一个名为VideoPlayer的可组合组件。这个可组合组件接受一个videoUrl字符串作为参数。这指定了要播放的视频的位置。

在函数内部,使用LocalContext.current从 Compose 环境中获取当前上下文。然后,使用AndroidView可组合项来弥合传统 Android UI 组件和 Compose 世界之间的差距。它接受一个工厂 Lambda 表达式,其中通过上下文实例化VideoView

接下来,创建MediaController并将其通过setMediaController()VideoView关联,提供标准媒体控件,如播放、暂停和搜索,以增强用户与视频播放的交互。

使用setAnchorView(this)将媒体控制器锚定到VideoView上,确保控制界面在视频视图方面正确显示。传递给函数的视频 URL 被解析为一个Uri组件,并通过setVideoURI()设置在VideoView上,指向播放器的内容。

最后,在设置完成并且视频准备就绪后,通过在VideoView上调用start()方法来自动启动视频播放。

在本节中,我们简要了解了 MediaPlayer API 和 VideoView 的工作原理及其功能。现在,是时候关注皇冠上的宝石:ExoPlayer。

理解 ExoPlayer 的基础知识

ExoPlayer在 Android 的基本 MediaPlayer 之上取得了重大进步,提供了 MediaPlayer 无法比拟的灵活性、定制性和对高级流媒体格式的支持。这种优越性使得 ExoPlayer 成为需要强大、功能丰富媒体播放能力的开发者的首选选择。

ExoPlayer 最吸引人的优势之一是其适应性。与相对静态的 MediaPlayer 不同,ExoPlayer 可以轻松地适应和扩展以满足特定应用需求。其模块化架构允许开发者仅包含他们需要的组件,从而减少应用程序的整体大小。此外,ExoPlayer 的定制选项还扩展到其用户界面,能够创建与应用程序设计无缝集成的自定义控件和布局。这种适应性确保了开发者可以打造与他们的应用程序品牌和用户界面指南完美对齐的独特媒体播放体验。

在流媒体领域,ExoPlayer 的优势变得更加明显。它提供了对现代流媒体协议(如 HLS 和 DASH)的即插即用支持。这些自适应流媒体协议对于在互联网上高效传输内容至关重要,可以根据用户的当前网络条件实时调整流的质量。这确保了即使在网络速度波动的情况下,也能提供最佳的观看体验,最小化缓冲和播放中断。

与之相比,MediaPlayer 对此类流式传输协议的支持有限,通常需要开发者实现额外的解决方案或工作区来达到类似的功能。使用 ExoPlayer,开发者可以直接访问这些高级功能,简化开发过程并提升最终用户体验。

如我们所见,由于其灵活性和广泛格式支持,ExoPlayer 的功能非常优越,这也是我们将在项目中使用它的原因。另一方面,由于它更复杂,我们将在开始使用它实现视频播放器之前需要更多地了解它。

好吧,让我们就做这件事,并分解 ExoPlayer 的架构。

探索 ExoPlayer 的架构

ExoPlayer 的架构旨在既灵活又可扩展,使其能够处理广泛的媒体播放场景。ExoPlayer 有几个核心组件协同工作,以提供强大而高效的媒体播放体验。理解这些组件对于在应用中充分利用 ExoPlayer 的全部功能至关重要。让我们在这里看看它们。

ExoPlayer 实例 – 中心媒体播放引擎

ExoPlayer 实例本身充当媒体播放的中心枢纽,协调播放过程中涉及的各个组件之间的交互,管理播放状态,并协调媒体内容的获取、解码和渲染。与作为黑盒操作的 Android 的 MediaPlayer 不同,ExoPlayer 为开发者提供了对播放和播放管道的详细控制,使他们能够进行精细调整以满足应用程序的特定需求。

下面是一个如何初始化 ExoPlayer 并准备播放媒体项的简单示例:

val context = ... // Your context here
val player = ExoPlayer.Builder(context).build().apply {
    // Media item to be played
    val mediaItem =
        MediaItem.fromUri("http://example.com/media.mp3")
    // Set the media item to be played
    setMediaItem(mediaItem)
    // Prepare the player
    prepare()
    // Start playback
    playWhenReady = true
}

该过程从创建一个 ExoPlayer 实例开始,利用上下文感知的构建器模式确保播放器配置了其操作的环境。在其实例化之后,通过 URI 指定一个媒体项,该 URI 可以指向本地资源或远程媒体文件。然后,将此媒体项与 ExoPlayer 实例关联起来,表明它应该准备播放的内容。

一旦设置了媒体项,播放器将通过调用 prepare() 方法进入准备阶段。在此阶段,ExoPlayer 分析媒体内容,设置必要的缓冲区和解码资源,以确保流畅的播放。

该过程的最后一步是设置播放器的 playWhenReady 属性为 true,这是一个在播放器完全准备就绪后触发播放的命令。此属性提供了灵活性,允许开发者控制播放何时开始。这可以是准备完成后立即开始,也可以基于其他条件或用户交互进行延迟。

MediaItem – 资源来源

在 ExoPlayer 中,MediaItem封装了有关媒体源的信息,例如其 URI、元数据和与播放相关的任何配置。它是一个多功能且至关重要的组件,告诉 ExoPlayer 要加载和播放什么内容。这些是 MediaItem 的关键功能:

  • 媒体源指定:MediaItem 的主要功能是指定要播放的媒体的位置。这可以是文件路径、URL 或内容 URI 等多种格式之一。

  • 媒体配置:MediaItem 不仅允许指定媒体源,还允许详细配置播放。这包括设置 DRM 配置、指定字幕以及通过元数据定义自定义属性。

  • 自适应流:对于自适应流内容(如 DASH 和 HLS),MediaItem 可以为 ExoPlayer 提供必要的信息,以便根据网络条件动态调整流的品质。这些信息包括各种流段的 URL、可用的品质级别和编解码器等元数据。

  • 播放选项:开发者可以使用 MediaItem 配置特定的播放选项,例如起始和结束位置、循环等。这些选项提供了对媒体播放的精细控制。

在实际操作中,一旦创建并配置了 MediaItem,它就会被传递给 ExoPlayer 实例,以便进行播放准备。您可以为简单的播放场景加载单个 MediaItem,或者通过加载多个 MediaItem 来管理播放列表。让我们看一个简短的例子:

val mediaItem =
    MediaItem.fromUri("https://example.com/video.mp4")
player.setMediaItem(mediaItem)
player.prepare() // Prepares the player with the provided
                    MediaItem
player.playWhenReady = true // Starts playback as soon as
                               preparation is complete

在这个例子中,我们从一个 URL 创建mediaItem并准备它由 ExoPlayer 实例播放。

轨道选择器 – 管理媒体轨道

TrackSelector实例是 ExoPlayer 的一个关键组件,负责选择要播放的具体轨道。一个视频可能包含多种语言的多个音频轨道、多个视频品质或各种字幕轨道,而TrackSelector会根据设备的性能、用户偏好和网络条件,决定这些轨道中哪一个最适合当前的播放环境。这个选择过程对于自适应流场景至关重要,因为单个视频可能以多个品质级别编码并存储在服务器上。

这里是一个使用示例:

val trackSelector =
    DefaultTrackSelector(context).apply {
        setParameters(buildUponParameters()
            .setPreferredAudioLanguage("en")
}
val player = ExoPlayer.Builder(context)
    .setTrackSelector(trackSelector)
    .build()

该过程从创建一个DefaultTrackSelector实例开始。DefaultTrackSelector实例是ExoPlayer的一个组件,它根据各种标准(如用户的设备性能和轨道属性)决定从媒体中播放哪些轨道(音频、视频或文本)。在这个例子中,轨道选择器被配置为优先选择英语音频轨道。这种偏好是通过修改轨道选择器的参数来设置的,表示如果媒体包含多种语言的多个音频轨道,则如果可用,应选择英语轨道。

配置完轨道选择器后,它被用于构建 ExoPlayer 实例。在这里,ExoPlayer.Builder 在构建播放器时提供了应用程序上下文和自定义的轨道选择器。这确保了当 ExoPlayer 实例准备和播放媒体时,它使用 DefaultTrackSelector 中定义的逻辑进行轨道选择。本质上,这种设置允许在播放期间对选择哪个音频轨道有更多的控制,基于预定义的标准(在这种情况下,是语言偏好)。

这种配置 ExoPlayer 的方法特别适用于处理包含针对不同受众群体的多个轨道的媒体的应用程序,或者在应用程序需要遵守用户偏好或设置(如语言选择选项)的场景中。通过自定义轨道选择器,开发者可以确保媒体播放体验针对用户的特定需求和偏好进行了优化,从而提高整体可用性和满意度。

LoadControl – 处理缓冲和加载

LoadControl 组件负责监控缓冲和加载媒体资源的策略。高效的缓冲对于流畅的播放至关重要,尤其是在网络条件可能大范围变化的流媒体场景中。LoadControl 组件决定在任何给定时间需要缓冲多少媒体数据,在减少初始加载时间和最小化播放中断的可能性之间取得平衡。我们可以自定义缓冲策略以满足特定需求,例如优先考虑快速启动时间或确保无缝播放。

以下是一个创建自定义 LoadControl 组件以修改缓冲策略的示例:

val loadControl = DefaultLoadControl.Builder().apply {
    // Set minimum buffer duration to 2 minutes
    setBufferDurationsMs(
        minBufferMs = 2 * 60 * 1000,
        maxBufferMs =
            DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
        bufferForPlaybackMs =
            DefaultLoadControl
            .DEFAULT_BUFFER_FOR_PLAYBACK_MS,
        bufferForPlaybackAfterRebufferMs =
            DefaultLoadControl
            .DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
    )
}.build()
val player = ExoPlayer.Builder(context)
    .setLoadControl(loadControl)
    .build()
// Continue setting up the player as before

示例开始于使用 Builder 创建 DefaultLoadControl 实例。在这里,DefaultLoadControl 是 ExoPlayer 提供的 LoadControl 接口的一个实现,它旨在根据我将解释的各种参数来管理媒体缓冲。

在构建器上调用 setBufferDurationsMs 方法来指定自定义缓冲持续时间。具体来说,它将最小缓冲持续时间(minBufferMs)设置为 2 分钟(120,000 毫秒)。这意味着 ExoPlayer 将尝试在开始播放之前至少缓冲 2 分钟的媒体,这有助于确保在变化多端的网络条件下播放流畅。

其他参数(maxBufferMsbufferForPlaybackMsbufferForPlaybackAfterRebufferMs)被设置为它们的默认值,这些默认值在 DefaultLoadControl 中预定义。这些参数分别控制最大缓冲区大小、必须缓冲的最小媒体量以开始播放,以及重新缓冲后必须缓冲的最小媒体量。

注意

如果你想了解更多关于上述选项的信息,你可以在DefaultLoadControl.Builder的文档中找到所有详细信息:developer.android.com/reference/androidx/media3/exoplayer/DefaultLoadControl.Builder

配置缓冲持续时间后,调用build()方法来创建具有指定设置的DefaultLoadControl实例。

然后,通过ExoPlayer.BuildersetLoadControl方法将此自定义LoadControl组件设置在新的ExoPlayer实例上。这一步将自定义缓冲策略与播放器集成,意味着播放器将在播放期间使用指定的缓冲持续时间。

最后,在ExoPlayer.Builder上调用build方法来创建配置了自定义LoadControl组件的ExoPlayer实例。

渲染器 – 将媒体渲染到输出

渲染器是输出媒体到适当目的地的组件,例如将视频帧渲染到屏幕或将音频样本渲染到扬声器。ExoPlayer 使用不同的渲染器处理不同类型的轨道,允许并行处理和渲染音频、视频和文本轨道。这种分离使得 ExoPlayer 能够高效地支持广泛的媒体类型和格式。此外,开发者可以实现自定义渲染器来处理非标准媒体类型或在播放前对媒体应用特殊处理。

为了说明这一点,考虑以下示例,其中使用自定义渲染器将灰度过滤器应用于视频内容:

class GrayscaleVideoRenderer(
    eventHandler: Handler,
    videoListener: VideoRendererEventListener,
    maxDroppedFrameCountToNotify: Int
) : SimpleDecoderVideoRenderer(eventHandler, videoListener, maxDroppedFrameCountToNotify) {
    override fun onOutputFormatChanged(format: Format,
    outputMediaFormat: MediaFormat?) {
        super.onOutputFormatChanged(format,
            outputMediaFormat)
        // Setup to modify the color format to grayscale
    }
    override fun renderOutputBufferToSurface(buffer:
    OutputBuffer, surface: Surface,
    presentationTimeUs: Long) {
        // Apply grayscale effect to the buffer before
           rendering to the surface
    }
}

GrayscaleVideoRenderer类扩展了 ExoPlayer 的SimpleDecoderVideoRenderer,在播放期间将灰度效果应用于视频帧。这种定制允许它不仅解码和显示视频,还能实时将每个帧转换为灰度,增强视觉呈现以适应风格选择或无障碍性。

在初始化时,此渲染器接受一个Handler组件用于线程安全的事件分发,一个VideoRendererEventListener组件用于管理视频事件,以及一个整数,用于设置通知关于丢失帧的阈值。这种设置有助于保持播放的流畅和响应。

它重写了onOutputFormatChanged方法,在这里它为视频格式变化做准备。这是设置灰度处理调整的地方。renderOutputBufferToSurface方法是将灰度效果应用于每个视频帧,在它们被渲染到屏幕之前。

现在我们已经熟悉了 ExoPlayer 最重要的组件,让我们将其集成到我们的项目中。

将 ExoPlayer 集成到我们的项目中

要集成 ExoPlayer,我们必须在我们的版本目录中包含必要的库依赖项:

[versions]
...
exoPlayer = "1.2.1"
[libraries]
...
exoPlayer-core = { module = "androidx.media3:media3-exoplayer", version.ref = "exoPlayer" }
exoPlayer-ui = { module = " androidx.media3:media3-ui", version.ref = "exoPlayer" }

与我们包含的每个依赖项一样,我们必须将它们添加到我们将要使用的模块的build.gradle文件中。在这种情况下,我们将将其添加到build.gradle文件中的:feature:playback

dependencies {
    implementation(libs.exoPlayer.core)
    implementation(libs.exoPlayer.ui)
}

使用这两个依赖项,我们拥有了使用 ExoPlayer 所需的所有组件:

  • androidx.media3:media3-exoplayer:这是 Media3 库中 ExoPlayer 的核心模块。它包括媒体播放功能所需的必需类和接口。此模块为媒体播放提供基本组件,包括 ExoPlayer 接口、媒体源处理和播放控制逻辑。它是 Media3 中媒体播放的骨架,提供高性能、低级别的媒体播放功能。

  • androidx.media3:media3-ui:此模块为 Media3 库中的媒体播放提供用户界面组件。它包括预构建的 UI 组件,如PlayerView(一个显示视频内容和播放控制的视图)以及其他用于控制媒体播放的 UI 元素。如果需要,这些组件可以被自定义或替换为自定义实现。此模块帮助开发者快速将 ExoPlayer 与用于媒体播放的功能性 UI 集成。

现在,我们已经准备好了。在下一节中,我们将构建我们的播放 UI 并将其与 ExoPlayer 连接。

创建视频播放用户界面

在本节中,我们将构建视频播放 UI,并关注基本要素:标题栏、关闭按钮、播放/暂停、快进和快退按钮、进度条和时间指示器。我们将首先创建PlaybackScreen可组合组件,这是此新屏幕的主要可组合组件,之后我们将添加使它功能化的附加组件。

构建PlaybackScreen及其可组合组件

让我们开始构建PlaybackScreen可组合组件:

@Composable
fun PlaybackScreen() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
    ) {
        TopMediaRow(Modifier.align(Alignment.TopCenter))
        PlayPauseButton(Modifier.align(Alignment.Center))
        ProgressBarWithTime(Modifier
            .align(Alignment.BottomCenter))
    }
}

我们首先声明一个填充整个屏幕并设置其背景为黑色的Box容器,模仿视频播放界面通常首选的暗黑模式。在这个Box容器内,我们放置了构成我们播放 UI 的三个关键组件:顶部媒体行、播放/暂停按钮和带有时间指示器的进度条。

在这里,TopMediaRow位于屏幕顶部中央,可能包含标题栏和关闭按钮。然后,PlayPauseButton放置在屏幕中央,使用户能够通过简单的点击开始或暂停播放。最后,ProgressBarWithTime对齐在底部中央,使用户能够看到视频播放了多少以及还剩下多少。每个这些组件都使用Modifier.align方法在Box容器内对齐,确保它们在 UI 中的位置正好是我们想要的。

现在我们已经构建了屏幕的基础,包括每个需要的可组合组件,是时候构建它们了。我们将从TopMediaRow可组合组件开始:

@Composable
fun TopMediaRow(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier.fillMaxWidth().padding(20.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(text = "S1:E1 - Pilot", color = Color.White)
        Icon(imageVector = Icons.Default.Close,
            contentDescription = "Close",
                tint = Color.White)
    }
}

在这个TopMediaRow可组合函数中,我们正在设计视频播放 UI 的顶部部分,该部分专门用于显示剧集信息和关闭按钮。此函数使用Row布局在其屏幕上水平排列其元素。应用于此Row布局的修饰符确保它扩展以填充其父容器的最大宽度,并在其边缘应用 20 密度无关像素dp)的填充,以实现整洁、无杂乱的外观。

Row布局中,我们使用两个主要组件:TextIcon

  • Text组件显示剧集信息,例如S1:E1 ‘Pilot’,以白色显示,使其在视频播放屏幕典型的深色背景上易于可见。

  • Icon组件使用默认的“关闭”符号,其色调也设置为白色,以保持一致性和可见性。horizontalArrangement属性设置为Arrangement.SpaceBetween,以确保文本和图标位于行的两端,而verticalAlignment保持它们在行内垂直居中。

现在,让我们转到下一行,其中包含PlayPauseButton可组合元素:

@Composable
fun PlayPauseButton(modifier: Modifier = Modifier) {
    Row(
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically,
        modifier = modifier
    ) {
        IconButton(
            modifier = Modifier.padding(20.dp),
            onClick = { /* Rewind action */ })
        {
            Icon(
                modifier = Modifier
                    .height(80.dp)
                    .width(80.dp),
                imageVector = Icons.Default.ArrowBack,
                contentDescription = "Rewind 10s",
                tint = Color.White)
        }
        IconButton(
            modifier = Modifier
                .padding(20.dp),
            onClick = { /* Play/Pause action */ }
        ) {
            Icon(
            modifier = Modifier
                .height(80.dp)
                .width(80.dp),
            imageVector = Icons.Default.PlayArrow,
            contentDescription = "Play/Pause",
            tint = Color.White)
        }
        IconButton(
            modifier = Modifier
                .padding(20.dp),
            onClick = { /* Fast-forward action */ }) {
            Icon(
                modifier = Modifier
                    .height(80.dp)
                    .width(80.dp),
                imageVector = Icons.Default.ArrowForward,
                contentDescription = "Fast-forward 10s",
                tint = Color.White)
        }
    }
}

PlayPauseButton可组合函数将为视频播放提供中央控制机制,并在单个直观界面上集成倒退、播放/暂停和快进操作。此函数使用Row布局水平对齐其子元素——每个控制动作的按钮——使它们在水平和垂直方向上居中。

每个按钮都是使用IconButton组件创建的。这些按钮之间有 20 dp 的填充,以确保它们可以舒适地触摸,而不会意外按下。倒退、播放/暂停和快进图标的大小统一为 80 dp x 80 dp,足够大,易于触摸和视觉识别。

每个IconButton中的Icon组件被特别选择以直观地表示它们各自的操作:一个指向后方的箭头表示倒退,一个播放/暂停的箭头,以及一个指向前方的箭头表示快进,每个操作都伴随着内容描述,以提高可访问性。onClick参数中的占位符注释表明每个按钮的功能——通过 10 秒倒退视频、在播放和暂停之间切换,以及通过 10 秒快进——将在何处实现。

最后,我们还有一个需要构建的可组合元素,即ProgressBarWithTime可组合元素:

@Composable
fun ProgressBarWithTime(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .padding(horizontal = 16.dp, vertical = 8.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
            val progress = remember { mutableStateOf(0.3f)
                } // Dummy progress
            val formattedTime = "22:49" // Dummy time
            Row(
                modifier = Modifier.fillMaxWidth(),
                verticalAlignment =
                    Alignment.CenterVertically
            ) {
                Slider(
                    value = progress.value,
                    onValueChange =
                        { progress.value = it },
                    modifier = Modifier.weight(1f)
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text(text = formattedTime,
                    color = Color.White)
            }
    }
}

这个可组合元素被包裹在一个Row布局中,它占据最大可用宽度(以适应视频长度)并调整其高度以紧密包裹内容,确保边缘有足够的填充,从而实现平衡的布局。

核心功能围绕两个元素展开:

  • 滑动条组件代表视频的进度。它使用一个初始化为0.3(30%进度)的可变状态来模拟视频播放的当前位置。这个状态是可交互调整的,允许用户在视频中搜索。onValueChange事件更新进度状态,反映用户的输入。为了在视觉上将进度条与时间指示器分开,并确保布局保持直观,我们在这些元素之间插入了一个空格,以保持清晰的区分。

  • 滑动条组件旁边,文本组件以白色显示当前播放时间(目前设置为22:49,直到我们集成播放功能)。显示时间是为了向用户提供关于视频播放进度或剩余时间的精确信息,通过提供对视频播放的精确控制来增强用户体验。

虽然我们的播放界面看起来已经完整,但在集成播放功能本身之前,我们仍有一件事需要关注。当我们观看视频时,我们不希望所有这些控件都占据屏幕,使得观看内容变得困难。通常情况下,当用户没有与屏幕交互时,控件会自动消失。因此,让我们实现这个更改。

在播放内容时使控件消失

我们知道如果播放控件一段时间内没有被使用,它们应该消失。最简单的方法是有一个值来指示控件是否可见,并且当屏幕空闲一段时间后,我们将修改其值为false。让我们在PlaybackScreen可组合组件中进行以下修改:

@Composable
fun PlaybackScreen() {
    val isControlsVisible = remember { mutableStateOf(true) }
    val coroutineScope = rememberCoroutineScope()
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        // Reset the visibility timer on
                           user interaction
                        isControlsVisible.value = true
                        coroutineScope.launch {
                            delay(15000) // 15 seconds
                                            delay
                            isControlsVisible.value = false
                        }
                    }
                )
            }
    ) {
        if (isControlsVisible.value) {
            TopMediaRow(Modifier.align(Alignment.TopCenter))
            PlayPauseButton(Modifier.align(
                Alignment.Center))
            ProgressBarWithTime(Modifier.align(
                Alignment.BottomCenter))
        }
    }
}

这些修改的核心思想是跟踪用户交互并使用计时器来确定何时隐藏控件。最初,如前所述,我们将引入一个状态来管理控件的可视性。这个状态可能是一个布尔值,根据用户交互和没有交互的时间流逝在可见和不可见(truefalse)之间切换。

为了检测用户交互,我们可以将包含我们的播放界面组件的Box布局包裹在Modifier.pointerInput Lambda 中。在这个 Lambda 内部,我们可以监听触摸输入事件,每次检测到触摸时,我们可以重置计时器——一个使用LaunchedEffect启动的协程,其键是可见状态,可能负责处理这一点。这个协程将在检测到 15 秒的无操作(没有触摸事件)后,将控件的可视状态设置为false,从而有效地隐藏它们。为了确保当用户再次与屏幕交互时控件重新出现,相同的触摸输入检测机制将可视状态设置回true,并且协程将重新开始倒计时。

集成此功能需要修改PlaybackScreen组合函数,以便它包括对可见性的状态处理,并可以修改TopMediaRowPlayPauseButtonProgressBarWithTime函数,使它们能够接受并响应可见状态。这意味着这些组件只有在状态指示它们应该可见时才会被渲染。

一旦完成,我们的播放 UI 应看起来像这样:

图 8.1:完成播放 UI(显示控件)

图 8.1:完成播放 UI(显示控件)

当控件隐藏时,它应仅显示视频内容(目前尚未可见,因为它尚未实现):

图 8.2:完成播放 UI(隐藏控件)

图 8.2:完成播放 UI(隐藏控件)

在本节中,我们创建了一个用于显示视频的 UI。在下一节中,我们将集成 ExoPlayer,以便我们的应用可以开始播放视频。

使用 ExoPlayer 播放视频

在本节中,我们将充分利用 ExoPlayer 的强大功能,以便将其集成到我们新创建的视频播放 UI 中。让我们学习如何实现这一点。

创建 PlaybackActivity

我们将首先创建一个名为PlaybackActivity的新Activity来提供此功能:

class PlaybackActivity: ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PlaybackScreen()
        }
    }
}

这个PlaybackActivity活动将在其内容中显示我们已创建的PlaybackScreen()

我们还希望我们的播放 UI 始终以横屏模式显示。为此,我们将在AndroidManifest.xml文件中配置此活动,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android =
"http://schemas.android.com/apk/res/android">
    <application>
        <activity android:name =
        "com.packt.playback.presentation.PlaybackActivity"
        android:screenOrientation="landscape"/>
    </application>
</manifest>

在这里,我们声明PlaybackActivity,使其具有强制横屏屏幕方向。这将确保它将以横屏模式渲染,无论用户手持手机的方向如何。

创建 PlaybackViewModel

现在,我们需要创建播放器,这是负责管理媒体播放的组件。我们将创建PlaybackViewModel来处理ExoPlayer实例以及视图与视频播放器交互和观看媒体所需的所有逻辑。

首先,我们将构建PlaybackViewModel中播放器的基本设置逻辑:

@HiltViewModel
class PlaybackViewModel @Inject constructor(): ViewModel()
{
    lateinit var player: ExoPlayer
    @OptIn(UnstableApi::class)
    private fun preparePlayerWithMediaSource(exoPlayer:
    ExoPlayer) {
        val mediaUrl = "https://example.com/media.mp4"
        val mediaSource = ProgressiveMediaSource.Factory(
            DefaultHttpDataSource.Factory())
            .createMediaSource(MediaItem.fromUri(mediaUrl))
        exoPlayer.setMediaSource(mediaSource)
        exoPlayer.prepare()
    }
    fun setupPlayer(context: Context) {
        player = ExoPlayer.Builder(context).build().also {
        exoPlayer ->
            preparePlayerWithMediaSource(exoPlayer)
        }
    }
    override fun onCleared() {
        super.onCleared()
        player.release()
        progressUpdateJob?.cancel()
    }
}

这是我们的PlaybackViewModel组合组件的开始,它被设计用来管理 Android 应用的媒体播放功能。

这个ViewModel的核心组件是ExoPlayer实例,它存储在一个名为player的属性中。这个player属性负责所有媒体播放操作。然而,当ViewModel首次创建时,播放器尚未初始化;它使用lateinit声明,意味着它将在稍后初始化,但在任何其他组件需要访问它之前。

setupPlayer 函数是公开的,并预期使用一个 Context 对象来调用,该对象提供了访问特定应用程序资源和类的权限。在这个函数内部,使用 ExoPlayer.Builder 创建 ExoPlayer 的实例。这个设置过程包括在构建器上调用 build() 方法,该方法返回一个完全配置的 ExoPlayer 实例。在创建这个实例后,立即执行 also 块,使用新创建的播放器调用 preparePlayerWithMediaSource 方法。

preparePlayerWithMediaSource 方法是设置实际媒体源的地方。它接受一个 ExoPlayer 实例作为参数,并将其配置为播放特定的媒体文件。媒体文件的 URL 定义为 example.com/media.mp4。为了播放此媒体,创建了一个 ProgressiveMediaSource,它适合通过 HTTP 播放常规媒体文件,如 MP4。然后使用 setMediaSource 方法将此媒体源附加到 ExoPlayer 实例,并调用 prepare() 准备播放器。值得注意的是,此方法被标记为私有,意味着它仅打算在 PlaybackViewModel 类中使用。@OptIn(UnstableApi::class) 注解表示此方法使用尚未稳定的 API,将来可能会更改。

最后,onCleared 方法覆盖了 ViewModel 生命周期回调,当 ViewModel 即将销毁时被调用。此方法通过调用 player.release() 释放 ExoPlayer 实例,确保释放资源并防止内存泄漏。

现在,我们将在 PlaybackScreen 中添加渲染媒体内容的视图并将其连接到播放器:

@Composable
fun PlaybackScreen() {
    val viewModel: PlaybackViewModel = hiltViewModel()
    val isControlsVisible = remember { mutableStateOf(true) }
    val coroutineScope = rememberCoroutineScope()
    viewModel.setupPlayer(LocalContext.current)
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        isControlsVisible.value = true
                        coroutineScope.launch {
                            delay(15000) // 15 seconds
                                            delay
                            isControlsVisible.value = false
                        }
                    }
                )
            }
    ) {
        VideoPlayerComposable(
            modifier = Modifier.matchParentSize(),
            player = viewModel.player
        )
        if (isControlsVisible.value) {
            ...
        }
    }
}

PlaybackScreen 组合组件中,我们使用 hiltViewModel() 获取 PlaybackViewModel 的实例。这个 ViewModel 对于管理媒体播放的生命周期和应用程序内的交互至关重要。

一旦 ViewModel 准备就绪,我们调用 viewModel.setupPlayer(LocalContext.current) 来初始化 ExoPlayer。这个设置至关重要,因为它使用适当的 Android 上下文准备播放器,使其能够有效地加载和播放媒体文件。确保 ExoPlayer 使用当前上下文初始化有助于有效地管理资源,这对于流畅的播放至关重要。

负责显示视频的 UI 组件是 VideoPlayerComposable。我们将从 ViewModel 中初始化的播放器传递给这个组合组件,它被放置在一个 Box 布局中。这个布局被配置为填充其父级的最大尺寸,并设置黑色背景以强调视频内容。Box 布局还处理用户交互,监听触摸手势以切换播放控制器的可见性。当检测到触摸时,它会使控制器可见,并启动一个协程,如果在 15 秒内没有进一步的交互,则再次隐藏这些控制器。

Box 布局内部,条件逻辑检查 isControlsVisible 的值。如果为 true,则播放控制器将渲染在视频上方。这允许用户与视频交互,例如暂停、跳过或调整音量,但仅在他们选择显示控件时。

最后,我们将探讨如何实现 VideoPlayerComposable,以便我们能够有效地利用播放器渲染视频,同时动态响应用户对播放控制器的交互。

让我们看看我们如何实现这个新的可组合项。不幸的是,在撰写本文时,该库没有提供 Jetpack Compose 选项来显示播放器,因此我们需要在 AndroidView 可组合项内部创建一个,如下所示:

@Composable
fun VideoPlayerComposable(
    modifier: Modifier = Modifier,
    player: ExoPlayer
) {
    AndroidView(
        factory = { ctx ->
            PlayerView(ctx).apply {
                layoutParams = ViewGroup.LayoutParams(
                    MATCH_PARENT, MATCH_PARENT)
                setPlayer(player)
                useController = false
            }
        },
        modifier = modifier,
        update = { view ->
            view.player = player
        }
    )
}

VideoPlayerComposable 函数接受两个参数:

  • Modifier 实例允许你在 UI 的其他地方使用此可组合项时自定义其布局或外观。

  • ExoPlayer 实例是处理视频内容实际播放的媒体播放器。

AndroidView 可组合项内部,工厂 Lambda 是创建传统 Android 视图的地方——在本例中是 PlayerView。在这里,PlayerView 是由 ExoPlayer 库提供的用于显示视频内容和播放控制器的视图。在这里,它使用应用程序上下文 (ctx) 进行初始化。

创建 PlayerView 后,一些属性被设置在其上:

  • 在这里,layoutParams 被设置为 MATCH_PARENT,用于宽度和高度,使 PlayerView 填充分配给它的整个空间。这确保视频将占用尽可能多的空间,通常是整个屏幕或父容器。

  • 然后,setPlayer(player) 将传递的 ExoPlayer 实例附加到 PlayerView 上。这种连接允许在 ExoPlayer 中加载的视频显示在这个视图中。

  • 最后,useController 被设置为 false,表示不会使用 PlayerView 提供的默认播放控制器(如播放、暂停和进度条)。我们将在下一部分实现自己的控件。

最后,AndroidView 的更新 Lambda 是你可以根据可组合项的状态或属性的变化来更新 PlayerView 属性的地方。

经过这些更改,我们的播放器已经准备好通过 ViewPlayer 开始渲染媒体。但我们还有工作要做。我们需要将已经开发的控件绑定到播放器控件上,并保持视频的时间和进度条更新。

将控件与 ExoPlayer 连接

让我们从修改 PlayPauseButton 可组合项开始。在这种情况下,我们需要将控制函数与 ViewModel 绑定:

@Composable
fun PlayPauseButton(
    isPlaying: Boolean,
    onRewind: () -> Unit,
    onPlayPause: () -> Unit,
    onFastForward: () -> Unit,
    modifier: Modifier = Modifier,
    ) {
    Row(
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically,
        modifier = modifier
    ) {
        IconButton(
            onClick = onRewind,
            modifier = Modifier.padding(20.dp)
        ) {
            Icon(
                modifier = Modifier
                    .height(80.dp)
                    .width(80.dp),
                imageVector = Icons.Default.ArrowBack,
                contentDescription = "Rewind 10s",
                tint = Color.White
            )
        }
        IconButton(
            onClick = onPlayPause,
            modifier = Modifier.padding(20.dp)
        ) {
            Icon(
                modifier = Modifier
                    .height(80.dp)
                    .width(80.dp),
                imageVector = if (isPlaying)
                    Icons.Default.Close else
                    Icons.Default.PlayArrow,
                contentDescription = if (isPlaying) "Pause"
                    else "Play",
                tint = Color.White
            )
        }
        IconButton(
            onClick = onFastForward,
            modifier = Modifier.padding(20.dp)
        ) {
            Icon(
                modifier = Modifier
                    .height(80.dp)
                    .width(80.dp),
                imageVector = Icons.Default.ArrowForward,
                contentDescription = "Fast-forward 10s",
                tint = Color.White
            )
        }
    }
}

现在,PlayPauseButton 可组合项接受几个参数,每个参数在 UI 组件中都有特定的用途:

  • isPlaying(布尔值):此参数指示视频的当前播放状态。它用于确定在播放/暂停按钮上显示哪个图标——当视频暂停时显示播放图标,当视频正在播放时显示暂停图标。这使用户能够从用户的角度进行直观的控制交互。

  • onRewind(Lambda 函数):这是一个在用户按下倒退按钮时触发的回调函数。它应该包含视频倒退时发生的逻辑,例如通过固定量向后移动播放位置。

  • onPlayPause(Lambda 函数):当按下播放/暂停按钮时执行此函数。它根据当前状态处理在播放和暂停之间切换视频,使用户能够无缝控制视频播放。

  • onFastForward(Lambda 函数):类似于 onRewind,当按下快进按钮时激活此回调。它控制快进视频的逻辑,通过预定的间隔向前移动播放位置。

  • modifier(修饰符):此参数允许自定义可组合组件内按钮行的外观和布局。正如我们之前所看到的,可以使用它来应用填充、定义对齐方式和设置尺寸。

现在我们已经添加了这些新参数,我们需要从父可组合组件中传递它们。以下是包含并调用此可组合组件所需参数的方法:

val isPlaying = viewModel.isPlaying.collectAsState()
PlayPauseButton(
    isPlaying = isPlaying.value,
    onRewind = { viewModel.rewind() },
    onFastForward = {viewModel.fastForward() },
    onPlayPause = {viewModel.togglePlayPause() },
    modifier = Modifier.align(Alignment.Center)
)

如我们所见,我们已经将每个 Lambda 参数绑定到(尚未实现的)ViewModel 函数上,并且我们提供了一个 isPlaying 状态来反映播放器的当前播放状态。

现在,让我们在 ViewModel 中实现这些函数:

private val _isPlaying = MutableStateFlow<Boolean>(false)
val isPlaying: MutableStateFlow<Boolean> = _isPlaying
fun setupPlayer(context: Context) {
    player = ExoPlayer.Builder(context).build().also {
    exoPlayer ->
        preparePlayerWithMediaSource(exoPlayer)
        exoPlayer.addListener(object : Player.Listener {
            override fun onIsPlayingChanged(isPlaying:
            Boolean) {
                _isPlaying.value = isPlaying
            }
            override fun onPlaybackStateChanged(
            playbackState: Int) {
                super.onPlaybackStateChanged(playbackState)
            }
            override fun onPositionDiscontinuity(
            oldPosition: Player.PositionInfo, newPosition:
            Player.PositionInfo, reason: Int) {
                super.onPositionDiscontinuity(oldPosition,
                    newPosition, reason)
            }
            override fun onTimelineChanged(timeline:
            Timeline, reason: Int) {
                super.onTimelineChanged(timeline, reason)
            }
        })
    }
}
fun togglePlayPause() {
    if (player.isPlaying) {
        player.pause()
    } else {
        player.play()
    }
}
fun rewind() {
    val newPosition =
        (player.currentPosition - 10000).coerceAtLeast(0)
    player.seekTo(newPosition)
}
fun fastForward() {
    val newPosition =
        (player.currentPosition + 10000)
            .coerceAtMost(player.duration)
    player.seekTo(newPosition)
}

首先,我们定义了一个私有可变状态流 _isPlaying,用于跟踪视频是否正在播放。这个相同的状态流被公开作为名为 isPlayingMutableStateFlow 组件。在这种情况下,isPlaying 作为播放状态的单一真相来源,允许我们的 UI 组件根据视频是否播放或暂停进行响应式更新。

我们已经实现的 setupPlayer 函数初始化了 ExoPlayer 实例。现在,它还附加了一个监听器来响应播放事件。添加的监听器覆盖了多个方法,但最重要的是,使用 onIsPlayingChanged 来根据播放器的状态更新 _isPlaying.value

我们还包含了从可组合组件中已经调用的操作播放功能:

  • togglePlayPause:此方法检查播放器是否正在播放,并在播放和暂停之间切换。此方法直接控制播放器的状态,成为用户与播放交互的主要方式。

  • 倒退快进:这些选项基于当前播放位置计算新位置并跳转到该位置。倒退函数将播放位置向后移动 10 秒,而快进函数将播放位置向前移动 10 秒。这些方法增强了用户对视频的控制,允许在内容中快速导航。

现在,让我们连接下一个(也是最后一个)可组合的组件,ProgressBarWithTime

@Composable
fun ProgressBarWithTime(
    currentPosition: Long,
    duration: Long,
    onSeek: (Long) -> Unit,
    modifier: Modifier = Modifier,
) {
    val progress =
        if (duration > 0) currentPosition.toFloat() /
            duration else 0f
    val formattedTime =
        "${formatTime(currentPosition)} /
            ${formatTime(duration)}"
    Row(
        modifier = modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .padding(horizontal = 16.dp, vertical = 8.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Slider(
            value = progress,
            onValueChange = { newValue ->
                val newPosition =
                    (newValue * duration).toLong()
                onSeek(newPosition)
            },
            modifier = Modifier.weight(1f),
            valueRange = 0f..1f
        )
        Spacer(modifier = Modifier.width(8.dp))
        Text(text = formattedTime, color = Color.White)
    }
}

函数现在接受三个新参数:currentPositionduration 分别表示当前播放位置和视频总长度(以毫秒为单位),以及一个定义当用户搜索到新位置时要执行的操作的 onSeek Lambda 函数。

progress 变量计算视频的进度,表示为介于 0 和 1 之间的浮点数。这是通过将 currentPosition 除以 duration 来实现的,duration 指定了已播放视频的比例。如果持续时间是 0(为了避免除以零),进度设置为 0f,表示没有进度。

formattedTime 字符串通过使用自定义格式化函数 formatTime()(我们将在下面看到)将毫秒转换为更易读的格式(HH:MM:SS)来提供用户友好的当前位置和视频总持续时间显示。

最后,滑块进度现在绑定到进度值,并且其 onValueChange 事件连接到调用 onSeek 并传递新位置,当用户与其交互时。这允许用户通过移动滑块来搜索视频,onSeek 函数相应地更新视频播放位置。

关于前面提到的 formatTime 函数,它将按以下方式工作:

fun formatTime(millis: Long): String {
    val totalSeconds = millis / 1000
    val hours = totalSeconds / 3600
    val minutes = (totalSeconds % 3600) / 60
    val seconds = totalSeconds % 60
    return if (hours > 0) {
        String.format("%02d:%02d:%02d", hours, minutes,
            seconds)
    } else {
        String.format("%02d:%02d", minutes, seconds)
    }
}

函数的输入是 millis,它表示以毫秒为单位的时间长度。这是在编程中表示时间的一种常见方式,因为它很精确。然而,毫秒对人类来说并不友好,所以函数中的第一步是将毫秒转换为总秒数,通过除以 1000 来实现。我们这样做是因为一秒钟有 1,000 毫秒。

一旦得到总秒数,函数计算小时、分钟和秒。它将总秒数除以 3600(一小时中的秒数)来得到小时。然后,使用模运算符(%)从那个除法中得到的余数用于通过除以 60(因为一分钟有 60 秒)来计算分钟。最后,分钟计算的余数给你秒数。

最后的部分是函数格式化时间字符串的地方。如果持续时间包括小时(即,如果持续时间超过 60 分钟),它将使用 String.format() 将时间格式化为 HH:MM:SS。这种方法用于创建带有占位符(%02d)的格式化字符串,用于小时、分钟和秒。以下是格式的分解:

  • % 符号表示格式说明符的开始。

  • 0 表示如果数字的位数少于指定位数,则应使用前导零进行填充。

  • 2 表示数字至少应有两个数字长。

  • d 代表“十进制”,指定占位符用于整数。

因此,%02d 确保数字至少有两个数字长,并在必要时用零填充。

回到可组合组件,我们还需要修改在 PlaybackScreen 中调用 ProgressBarWithTime 的位置:

val currentPosition =
    viewModel.currentPosition.collectAsState()
val duration = viewModel.duration.collectAsState()
ProgressBarWithTime(
    currentPosition = currentPosition.value,
    duration = duration.value,
    onSeek = { newPosition ->
        viewModel.seekTo(newPosition)
    },
    modifier = Modifier.align(Alignment.BottomCenter)
)

如我们所见,我们将 seekTo Lambda 参数绑定到一个(尚未实现)ViewModel 函数上,并且我们还在提供 durationcurrentPosition 状态。

现在,让我们修改 PlaybackViewModel 以便我们可以实现与进度条相关的待定函数。

在 PlaybackViewModel 中实现视频控制

使进度条正常工作的最后一步是修改 PlaybackViewModel。我们可以添加控制进度条所需的功能,如下所示:

private val _currentPosition = MutableStateFlow<Long>(0L)
val currentPosition: StateFlow<Long> = _currentPosition
private val _duration = MutableStateFlow<Long>(0L)
val duration: MutableStateFlow<Long> = _duration
private var progressUpdateJob: Job? = null
fun setupPlayer(context: Context) {
    player = ExoPlayer.Builder(context).build().also {
    exoPlayer ->
        preparePlayerWithMediaSource(exoPlayer)
        exoPlayer.addListener(object : Player.Listener {
            override fun onIsPlayingChanged(isPlaying:
            Boolean) {
                _isPlaying.value = isPlaying
                if (isPlaying) {
                    startPeriodicProgressUpdate()
                } else {
                    progressUpdateJob?.cancel()
                }
            }
            override fun onPlaybackStateChanged
            (playbackState: Int) {
                super.onPlaybackStateChanged(playbackState)
                if (playbackState == Player.STATE_READY ||
                playbackState == Player.STATE_BUFFERING) {
                    _duration.value = exoPlayer.duration
                }
            }
            override fun onPositionDiscontinuity(
            oldPosition: Player.PositionInfo, newPosition:
            Player.PositionInfo, reason: Int) {
                super.onPositionDiscontinuity(oldPosition,
                    newPosition, reason)
                _currentPosition.value =
                    newPosition.positionMs
            }
            override fun onTimelineChanged(timeline:
            Timeline, reason: Int) {
                super.onTimelineChanged(timeline, reason)
                if (!timeline.isEmpty) {
                    _duration.value = exoPlayer.duration
                }
            }
        })
    }
}
private fun startPeriodicProgressUpdate() {
    progressUpdateJob?.cancel()
    progressUpdateJob = viewModelScope.launch {
        while (coroutineContext.isActive) {
            val currentPosition = player.currentPosition
            _currentPosition.value = currentPosition
            delay(1000)
        }
    }
}
fun seekTo(position: Long) {
    if (::player.isInitialized && position >= 0 &&
    position <= player.duration) {
        player.seekTo(position)
    }
}
override fun onCleared() {
    super.onCleared()
    player.release()
    progressUpdateJob?.cancel()
}

因此,我们已声明名为 _currentPosition_duration 的私有可变状态流,分别用于跟踪当前播放位置和视频的总时长。这些作为只读 StateFlows 暴露给应用程序的其他部分,确保 UI 组件可以观察这些值并对变化做出反应,但不能直接修改它们。

setupPlayer 函数中的监听器也已修改,包括保持两个状态(_currentPosition_duration)的功能。对监听器回调进行了以下修改:

  • onIsPlayingChanged:此更新 _isPlaying 状态并控制作业的启动和停止,该作业定期更新当前播放位置。这对于使 UI 与实际播放保持同步是必不可少的。

  • onPlaybackStateChanged:此检查播放器是否准备就绪或正在缓冲,并使用视频的总时长更新 _duration 状态。这对于设置进度条是必要的。

  • onPositionDiscontinuityonTimelineChanged:这些确保视频播放位置或时间线(如快进或切换到另一视频)的变化能够正确更新 _currentPosition_duration

然后,新的函数 startPeriodicProgressUpdate 启动一个协程,定期使用播放器的当前位置更新 _currentPosition 状态。这个循环每秒运行一次,为 UI 提供近实时的播放位置更新。这对于使进度条在视频播放时平滑移动至关重要。

基于此功能,seekTo 函数允许视频跳转到新的位置。它在调用 ExoPlayer 实例的 seekTo 之前检查位置是否在视频的范围内,从而让用户可以通过进度条跳转到视频的不同部分。

最后,onCleared方法已被修改,以便在需要释放资源的情况下取消新的progressUpdateJob可组合组件。

经过这些更改后,我们的视频播放器已经准备好了。我们只需修改PlaybackViewModel中的硬编码媒体 URL(val mediaUrl = "https://example.com/media.mp4"),以便我们可以提供一个实际视频的 URL,并让魔法发生!在这个时候,我们应该看到提供的视频的播放。

在本章的最后部分,我们将通过学习如何添加字幕来进一步增强我们的视频播放器的功能。

向视频播放器添加字幕

在本节中,我们将向我们的视频播放器添加字幕。字幕对于使视频对每个人可访问至关重要,但它们在嘈杂的环境中观看视频或需要降低音量时也非常有用。在本节中,我们将学习如何在视频的同时加载和显示字幕,并处理各种格式,确保它们与我们的内容完美同步。

要添加字幕,请按照以下步骤操作:

  1. 为您的视频文件创建一个 MediaSource,就像在 ExoPlayer 中播放任何视频一样。我们在上一节中做了这件事。

  2. 为您的字幕文件创建一个 MediaSource。这通常涉及使用SingleSampleMediaSource来处理单个字幕文件或类似方法来处理不同格式。

  3. 使用MergingMediaSource来合并视频和字幕源。然后将此合并源传递给 ExoPlayer 实例进行播放。

  4. 使用合并的源初始化 ExoPlayer;它将处理视频和字幕的播放。

ExoPlayer 支持广泛的字幕格式,以便满足各种用例和标准。以下是一些最受欢迎的格式:

  • WebVTT.vtt):一个广泛使用的 HTML5 视频字幕格式,被许多网络浏览器和平台支持:

    • 优点:WebVTT 在大多数现代网络浏览器中得到广泛支持,使其成为在线流媒体服务的理想选择。它提供了样式、定位和提示设置选项,允许自定义观看体验。

    • 缺点:与 SRT 等更简单的格式相比,WebVTT 的附加功能可能会使其创建和编辑变得更加复杂。此外,不同的平台和浏览器可能对样式和格式提示的解释不同,导致展示不一致。

  • SubRip.srt):一种结构简单且被广泛媒体播放器支持的常见字幕格式:

    • 优点:SRT 文件的结构简单,使其易于创建、编辑和调试。它也几乎被所有媒体播放器支持,使其适用于离线和在线视频播放的通用格式。

    • 缺点:它提供基本的文本格式化,这限制了自定义字幕外观的能力。

为了让您了解这种格式的外观,以下是一个 SubRip(.srt)文件内容的示例:

1
00:00:01,000 --> 00:00:03,000
Hello, welcome to our video!
2
00:00:05,000 --> 00:00:08,000
Today, we'll be discussing how to create a simple SRT file.
3
00:00:10,000 --> 00:00:12,000
Let's get started.
4
00:00:15,000 --> 00:00:20,000
Subtitles primarily enhance accessibility and also can be very helpful for understanding dialogue, especially in noisy environments.
5
00:00:22,500 --> 00:00:25,000
And that's all there is to it!

每个块都从序列号开始(例如,1、2、3 等等),接着是下一行的时序范围(开始时间 --> 结束时间),然后是副标题的文本。这段文本可以是一行或多行,并且后面跟着一个空行来表示副标题条目的结束。这种格式可以用任何文本编辑器进行编辑,并保存为.srt扩展名。

现在我们对如何在 ExoPlayer 中添加字幕有了更多的了解,让我们将它们默认添加到我们已实现的播放功能中。我们只需要更改PlaybackViewModel中播放器设置的逻辑:

@OptIn(UnstableApi::class)
private fun preparePlayerWithMediaSource(exoPlayer:
ExoPlayer) {
        val mediaUrl = "https://example.com/media.mp4"
        val subtitleUrl =
            "https://example.com/subtitles.srt"
        val videoMediaSource =
            ProgressiveMediaSource.Factory(
                DefaultHttpDataSource.Factory()
        ).createMediaSource(MediaItem.fromUri(mediaUrl))
        val subtitleSource =
            MediaItem.SubtitleConfiguration.Builder(
                Uri.parse(subtitleUrl)).build()
        val subtitleMediaSource =
            SingleSampleMediaSource.Factory(
                DefaultHttpDataSource.Factory()
        ).createMediaSource(subtitleSource, C.TIME_UNSET)
        val mergedSource =
            MergingMediaSource(videoMediaSource,
                subtitleMediaSource)
        exoPlayer.setMediaSource(mergedSource)
        exoPlayer.prepare()
}

在前面的代码中,我们修改了已经存在的preparePlayerWithMediaSource函数。我们首先添加了一个带有字幕 URL 的新媒体。

然后,我们为字幕创建了MediaSource,并从字幕 URL(subtitleUrl)创建了一个MediaItem.SubtitleConfiguration对象。此配置指定了字幕应该如何加载和显示。

然后,为字幕配置创建了一个SingleSampleMediaSource。在这里,使用SingleSampleMediaSource是因为字幕文件通常是单一的内容,而不是流式内容。这里的createMediaSource方法与视频方法略有不同;它接受字幕配置和一个持续时间参数,该参数设置为C.TIME_UNSET,表示持续时间未知或应从内容本身确定。

一旦创建了视频和字幕源,它们就通过MergingMediaSource合并成一个单一的源。这个合并源告诉ExoPlayer以字幕叠加的方式播放视频。

最后,通过setMediaSource将合并的源设置在ExoPlayer实例上,并调用prepare()。这个动作导致ExoPlayer加载媒体并准备播放。当视频播放时,根据文件中定义的正确时间显示指定的 SRT 文件中的字幕。

下图显示了添加的字幕:

图 8.3:带字幕的播放

图 8.3:带字幕的播放

这样,我们的播放器就准备好播放视频了。通过包含字幕,它为我们的用户提供了更易于访问的体验。

摘要

在本章中,我们处理了在 Android 应用中添加视频播放的基本要素,同时关注功能强大的 ExoPlayer 库。我们首先比较了 Android 中的媒体选项,然后很快意识到由于它的灵活性和广泛格式支持,ExoPlayer 的优越性。这为我们学习 ExoPlayer 如何适应应用以及如何使用它来流畅地播放视频奠定了基础。

我们首先讲解了如何构建一个用户友好的视频播放界面,涵盖了从设置 ExoPlayer 到管理播放控制的所有内容。最后,我们探讨了添加字幕以使您的视频对更广泛的观众可访问,并突出了 ExoPlayer 增强包容性的能力。

现在您已经对使用 ExoPlayer 进行视频播放有了扎实的掌握,您就可以通过添加画中画模式和媒体投射功能来提升您的应用了。

第九章:在您的 Packtflix 应用中扩展视频播放

您是否曾希望用户在切换应用或关闭屏幕时仍能继续享受他们最喜欢的视频?本章深入探讨了 Android 上扩展视频播放的世界,为您带来创建更具吸引力和灵活性的用户体验的技能。

我们将探索两个关键功能:MediaRouter 和 Cast SDK,它们使用户能够将视频播放传输到更大的屏幕,例如带有 Google Chromecast 的电视。

到本章结束时,您将深入了解 PiP 功能,并解锁我们 Android 应用中扩展视频播放的潜力。

因此,在本章中,我们将涵盖以下主题:

  • 了解 PiP API

  • 使用 PiP 在后台继续播放

  • 了解 MediaRouter

  • 连接到 Google Chromecast 设备

技术要求

如前一章所述,您需要安装 Android Studio(或您偏好的其他编辑器)。

我们将遵循在 第七章 中开始的项目,并加入我们在 第八章 中所做的更改。

您可以在本仓库中找到本章将要构建的完整代码:github.com/PacktPublishing/Thriving-in-Android-Development-using-Kotlin/tree/main/Chapter-9.

了解 PiP API

我们扩展视频播放之旅的第一步是了解 PiP API,它使我们能够使用 PiP 模式。PiP 模式允许用户最小化您的应用,并在可调整大小和可移动的迷你播放器中继续观看视频。这种功能通过提供灵活性和便利性来增强用户体验。

本节将为您提供在应用中有效利用 PiP 的知识。我们将涵盖最重要的方面,例如理解 PiP 要求、学习如何以编程方式进入和退出 PiP 模式,并回顾一些不同的监听事件。那么,让我们开始吧。

PiP 要求

在 PiP 方面,并非所有设备都相同。在我们深入探讨令人兴奋的功能之前,让我们通过理解 PiP 模式的需求和兼容性方面来确保用户体验的流畅。

关于需求,有两个变量需要考虑:

  • 最低 Android 版本:PiP 模式依赖于 Android 8.0(奥利奥)中引入的特定 API。针对运行旧版 Android 的设备不仅会阻止 PiP 功能,还可能导致崩溃或意外行为。

    要检查用户的设备是否与 PiP 兼容,我们可以实现以下代码:

    val minApiLevel = Build.VERSION_CODES.O
    if (android.os.Build.VERSION.SDK_INT < minApiLevel) {
      // PiP not supported on this device
      return false
    }
    

    此代码确保我们的应用优雅地处理无法使用 PiP 模式的设备。首先,我们定义了 PiP 所需的最低 Android 版本(通常是 Android 8.0 或奥利奥)。然后,我们检查设备的当前版本。如果它低于最低版本,代码会识别 PiP 功能不可用,并通过(可能通过返回false)向应用发出信号,防止应用尝试使用在不兼容设备上会引起问题的 PiP 功能。

    这允许您优雅地处理 PiP 不可用的情况,并可能为旧设备上的用户提供替代功能(例如,我们可以提供让他们将播放发送到另一台设备的功能)。

  • 屏幕尺寸要求:虽然 PiP 模式可以在各种屏幕尺寸上技术上实现,但较小的显示屏可能无法提供最佳的用户体验。想象一下在 4 英寸屏幕的手机上尝试在微小的 PiP 窗口中看电影!因此,考虑屏幕尺寸限制是至关重要的。

既然我们已经确定了需求,让我们探索一下激动人心的部分:在我们的应用中启动 PiP 模式。

以编程方式进入和退出 PiP 模式

正如我们所知,PiP 模式为用户提供在切换应用或关闭屏幕时继续在迷你窗口中播放视频的便利性。为此,我们将使用Activity类中可用的enterPictureInPictureMode()方法:

activity.enterPictureInPictureMode()

调用此方法允许您在您的活动中以编程方式触发 PiP 模式,系统将处理调整视频播放器窗口大小并将其置于其他应用之上。需要注意的是,您通常只在用户明确请求时调用此方法,例如在您的应用 UI 中轻触专用的 PiP 按钮。

虽然进入 PiP 模式是通过编程方式触发的,但退出主要是用户驱动的。用户可以通过滑动迷你播放器或轻触系统提供的指定关闭按钮来退出 PiP 模式。然而,作为开发者,我们仍然可以在确保平滑过渡回全屏体验方面发挥作用。当 PiP 模式退出时,系统会在您的活动中触发特定的回调。以下是我们可以如何利用这些回调:

override fun onPictureInPictureExited() {
  super.onPictureInPictureExited()
  // Any logic that we want to add when the user comes back
     to the full screen experience in our app
}

每当用户关闭 PiP 迷你屏幕时,此函数都会被调用。这不是我们可以用来处理 PiP 状态变化的唯一函数;监听器提供了各种事件,以使我们的应用了解 PiP 窗口的变化。这些事件允许我们做出反应并相应地更新我们的应用行为,确保无缝的用户体验:

  • OnPictureInPictureEntered(): 当用户成功进入 PiP 模式时,此事件会被触发。您可以利用这个机会更新 UI 元素以反映 PiP 状态(例如,隐藏不必要的控件)或对 PiP 播放进行任何必要的优化(例如,调整视频质量)。

  • OnPictureInPictureExited():如前所述,此事件表示用户退出 PiP 模式。在这里,你可以清理与 PiP 窗口相关的资源或更新 UI 以反映全屏播放的返回。

  • OnPictureInPictureUiStateChanged():此事件在 PiP 窗口发生任何变化时触发,例如调整大小或移动它。你可能使用此事件根据新的 PiP 窗口尺寸调整 UI 布局或根据调整大小可能导致的性能变化更新视频播放。

通过有效地处理 PiP 事件和监听器回调,你可以使你的应用与变化的 PiP 窗口状态保持同步。现在,让我们看看如何将其集成到我们的现有项目中。

使用 PiP 在后台继续播放

在我们可以在项目中使用 PiP 之前的第一步是,我们必须在AndroidManifest.xml文件中声明对其的支持。这一步对于通知 Android 系统我们的PlaybackActivity类能够以 PiP 模式运行至关重要。我们这样做:

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android =
        "http://schemas.android.com/apk/res/android">
    <application>
        <activity
            android:name = "com.packt.playback.presentation
                .PlaybackActivity"
            android:supportsPictureInPicture="true"
            android:resizeableActivity="true"
            android:screenOrientation="landscape"/>
    </application>
</manifest>

对于 PiP 而言,我们清单中的关键属性是android:supportsPicture InPicture="true",它明确声明你的活动支持 PiP 模式。

resizeableActivity属性,虽然与活动可调整大小的能力相关,但在针对 API 级别 24 或更高版本时,默认设置为true。这意味着如果你的应用针对 API 级别 24+,你不需要显式设置resizeableActivity="true"以使 PiP 模式工作,因为系统已经默认认为所有活动都是可调整大小的,以支持多窗口模式。

然而,显式设置resizeableActivity="true"是一种良好的实践,特别是如果你的应用旨在利用多窗口功能,而不仅仅是 PiP,或者如果你想确保在不同 Android 版本和设备上的兼容性。它对于文档目的也很有用,使任何阅读你的AndroidManifest.xml文件的人都能清楚地知道你的活动旨在支持可调整的行为,包括 PiP。

实现 PiP

既然我们已经明确选择在我们的Activity类中使用 PiP 功能,让我们来实现它。我们将重写onUserLeaveHint()回调,该回调在用户按下主页按钮或切换到另一个应用时触发:

override fun onUserLeaveHint() {
    super.onUserLeaveHint()
    val aspectRatio = Rational(16, 9)
    val params = PictureInPictureParams.Builder()
        .setAspectRatio(aspectRatio)
        .build()
    enterPictureInPictureMode(params)
}

正如我们所说的,我们正在重写onUserLeaveHint()现有函数。在这里,我们仍然必须包含对super.onUserLeaveHint()的调用,因为它确保Activity类在执行自定义行为之前正确处理 Android 框架定义的任何附加底层操作。

在此方法中,通过使用Rational类,将 PiP 窗口的宽高比定义为16:9,这是视频内容的常见选择。这个宽高比至关重要,因为它决定了 PiP 窗口宽度和高度之间的比例关系,确保视频在无扭曲的情况下保持其预期的外观。

要应用这个宽高比,使用PictureInPictureParams.Builder类构建一个配置对象。通过在构建器上调用setAspectRatio(aspectRatio),将之前定义的宽高比应用于此配置。

setAspectRatio(Rational)设置 PiP 窗口的首选宽高比时,意味着系统在显示 PiP 窗口时会尝试维持这个宽高比,但根据设备和屏幕尺寸的限制,这并不总是可能的。Android 11(API 级别 30)引入了setMaxAspectRatio(Rational)setMinAspectRatio(Rational)来定义最大和最小的宽高比。此外,setMaxSize(int, int)允许设置 PiP 窗口的最大尺寸,从而提供了对 PiP 窗口在不同设备上显示方式的更多控制。

注意

还有一些其他的PictureInPictureParams.Builder选项可以应用。有关这些选项的更多信息,请参阅文档:developer.android.com/reference/android/app/PictureInPictureParams.Builder

然后,build()方法将这些配置编译成一个PictureInPictureParams对象,该对象封装了进入 PiP 模式所需的所有设置。

最后,调用enterPictureInPictureMode(params)方法,向系统发出信号,使用指定的参数将当前的Activity类转换为 PiP 模式。

现在我们已经集成了这个功能,当我们处于播放屏幕并离开应用程序时,我们仍然应该在 PiP 屏幕上看到视频:

图 9.1:使用 PiP 功能进行播放

图 9.1:使用 PiP 功能进行播放

Android 中的PictureInPictureParams.Builder类提供了一种可定制的配置方法,用于配置应用程序进入 PiP 模式时的行为和外观。除了使用setAspectRatio()设置宽高比,如我们在前面的指令中所做的那样,还有其他几个选项可用于定制 PiP 体验:

  • 操作:通过使用setActions(List),开发者可以指定用户在 PiP 模式下可以执行的操作列表。这些操作以RemoteAction对象的形式表示,可以包括播放、暂停或跳过等操作。这些操作在 PiP 窗口中作为按钮出现,为用户提供交互元素,而无需返回到完整的应用程序界面。

  • 自动进入/退出:通过setAutoEnterEnabled(boolean)setAutoExitEnabled(boolean)(在后续的 Android 版本中引入),开发者可以控制应用程序是否应根据某些条件(如媒体播放状态)自动进入或退出 PiP 模式。

  • 无缝调整大小:通过调用 setSeamlessResizeEnabled(boolean),可以启用或禁用 PiP 窗口的无缝调整大小。这个选项在后来的 Android 版本中可用,有助于使进入和退出 PiP 模式的视觉转换更加平滑。

  • 源矩形提示setSourceRectHint(Rect) 允许开发者建议 PiP 模式进入时应该尝试对齐的屏幕上的首选区域。这可以根据应用程序的 UI 布局指导系统,确定 PiP 窗口理想的位置。

让我们使用这些选项来添加动作,以便用户可以在 PiP 视图中在播放和暂停之间切换。但首先,让我们先来一点理论。

理解如何向 PiP 模式添加动作

将动作集成到 PiP 模式通过允许在 PiP 窗口中直接控制应用程序功能而不离开 PiP 窗口,从而增强了用户交互。通过使用 setActions(List<RemoteAction>) 方法,您可以创建一个更加沉浸式和用户友好的体验,提供如播放、暂停或直接在 PiP 叠加层中跳过的控制功能。这种能力在媒体应用程序中尤其有价值,因为用户通常需要在不干扰当前屏幕活动的情况下管理播放。

在不久的将来,我们将学习如何有效地创建和管理这些 RemoteAction 对象,确保我们的应用程序 PiP 模式既功能性强又引人入胜,补充现有的 PiP 功能。但让我们进一步探讨这些概念。

每个 RemoteAction 对象代表 PiP 窗口中的一个可操作元素,例如用于播放、暂停或跳过的按钮。要创建这些动作,我们需要指定一个图标、一个标题、一个定义用户与按钮交互时采取的动作的 PendingIntent 对象,以及用于辅助功能的描述。

在这里,PendingIntent 对象的使用至关重要,因为它允许动作在调用时触发应用程序中的特定行为。Android 中的 Intent 对象就像一条消息,可以表示广泛的事件,包括系统启动完成、网络变化或应用程序定义的自定义事件。通常,这些意图被指向应用程序内的 BroadcastReceiver 实例。

Android 中的 BroadcastReceiver 实例是一个基本组件,它使应用程序能够监听并响应来自其他应用程序或系统本身的广播消息。当广播一个与 BroadcastReceiver 实例的过滤器匹配的意图时,BroadcastReceiver 实例的 onReceive() 方法会被调用,允许应用程序在事件发生时执行逻辑。这种机制为应用程序提供了一种强大的方式来响应全局系统事件或应用程序间通信,而无需在前景运行,使 BroadcastReceiver 实例成为 Android 中事件驱动编程的关键工具。

在我们的情况下,这个BroadcastReceiver实例负责监听和处理由 PiP 动作发送的广播意图。例如,当与播放动作关联的PendingIntent对象被广播时,你的应用中的相应接收器捕获这个意图并触发媒体播放。

需要一个BroadcastReceiver实例的原因是 PiP 动作意图与你的应用中直接方法调用解耦。由于这些动作发生在常规 UI 流程之外,使用广播机制允许你的应用异步响应这些动作并执行必要的操作,例如更新媒体播放状态。这种设置确保你的应用可以有效地处理 PiP 控制,即使在用户从 PiP 窗口与应用交互时也能提供无缝体验。

现在我们知道了如何创建RemoteAction对象,让我们将我们的学习应用到我们的项目中。

向 PiP 模式添加动作

让我们先创建我们的BroadcastReceiver子类。这个类将扩展BroadcastReceiver并重写onReceive()方法,在那里你将定义你的应用应该如何对 PiP 动作Intent对象做出反应:

class PiPActionReceiver(private val togglePlayPause: () -> Unit) : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent:
    Intent?) {
        when (intent?.action) {
            ACTION_TOGGLE_PLAY -> {
                togglePlayPause()
            }
        }
    }
    companion object {
        const val ACTION_TOGGLE_PLAY =
            "com.packflix.action.TOGGLE_PLAY"
    }
}

onReceive方法中,会检查Intent动作是否与ACTION_TOGGLE_PLAY动作匹配。如果匹配,将执行播放/暂停切换逻辑。在这种情况下,我们将执行一个回调,因为播放或暂停回放的逻辑可能位于此接收器之外。

接下来,我们需要注册BroadcastReceiver实例,以便它可以接收Intent对象。这可以通过两种方式完成:

  • Manifest 声明:在AndroidManifest.xml文件中注册适合于即使应用未运行也应接收的动作。然而,对于 PiP 动作,在处理 PiP 模式的 activity 或 service 中进行动态注册通常更为合适。

  • 动态注册:由于 PiP 动作与我们的应用处于 PiP 模式时特别相关,因此在我们PlaybackActivity类中动态注册BroadcastReceiver实例可以提供更多控制,并且与上下文相关。

我们将使用动态注册来注册BroadcastReceiver实例。在我们的PlaybackActivity类中,实现将如下所示:

private lateinit var pipActionReceiver: PiPActionReceiver
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    pipActionReceiver = PiPActionReceiver {
        //TODO handle there the play/pause logic
    }
    val filter =
        IntentFilter(PiPActionReceiver.ACTION_TOGGLE_PLAY)
    if (Build.VERSION.SDK_INT >=
    Build.VERSION_CODES.TIRAMISU) {
        registerReceiver(pipActionReceiver, filter,
            RECEIVER_NOT_EXPORTED)
    } else {
        registerReceiver(pipActionReceiver, filter)
    }
    setContent {
        PlaybackScreen()
    }
}

首先,我们将声明一个名为pipActionReceiverBroadcastReceiver变量。这个接收器不会立即初始化(它被声明为lateinit var),因为它将在我们 activity 的onCreate方法中设置。

onCreate方法中,我们将初始化BroadcastReceiver变量。pipActionReceiver变量被实例化并分配了一个 lambda 函数作为其参数。这个函数旨在包含处理播放/暂停动作的逻辑。

然后,我们将注册 BroadcastReceiver 变量,指示它将监听的 Intent 过滤信号。注册方法取决于 SDK 版本:

  • 对于 Tiramisu (Android 13,API 级别 33) 及以上版本的 SDK,您使用带有附加标志的 registerReceiver 方法,即 RECEIVER_NOT_EXPORTED,以增强安全性,确保您的接收器不会意外地被其他应用访问。

  • 对于早期版本,您将注册接收器而不使用此标志。这确保了向后兼容性,同时遵守在新设备上应用安全性的最佳实践。

现在,让我们创建一个将触发启动 BroadcastReceiver 实例所需的 Intent 操作的动作:

private fun getIntentForTogglePlayPauseAction():
RemoteAction {
    val icon: Icon = Icon.createWithResource(this,
        R.drawable.baseline_play_arrow_24)
    val intent =
    Intent(PiPActionReceiver.ACTION_TOGGLE_PLAY).let {
   intent ->
        PendingIntent.getBroadcast(this, 0, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or
                PendingIntent.FLAG_IMMUTABLE)
    }
    return RemoteAction(icon, "Toggle Play", "Play or pause
        the video", intent)
}

在此代码中,我们创建了一个 RemoteAction 方法。方法内部的第一行从可绘制资源 (R.drawable.baseline_play_arrow_24) 创建了一个 Icon 对象。此图标向用户直观地表示切换播放/暂停操作。

然后,使用 PiPActionReceiver.ACTION_TOGGLE_PLAY 操作创建一个新的 Intent 对象。此 Intent 对象设计为在用户调用 RemoteAction 方法时广播。let 块被用来直接链式创建一个包装此 Intent 对象的 PendingIntent 对象,使其可以在应用程序上下文之外执行。

调用 PendingIntent.getBroadcast 方法来创建一个广播 Intent 对象的 PendingIntent 对象。此 PendingIntent 对象配置了 PendingIntent.FLAG_UPDATE_CURRENT 以确保如果挂起的 Intent 对象已经存在,它将被重用,但其额外数据将被更新。PendingIntent.FLAG_IMMUTABLE 用于安全目的,将 Intent 对象标记为不可变,以防止创建后进行更改。

最后,实例化并返回一个 RemoteAction 对象。此对象接受先前创建的图标、标题(PendingIntent 对象)作为参数。标题和内容描述应简明扼要,但足以向用户说明操作的目的,符合无障碍标准。

现在,我们需要将此操作配置为我们的 PiP 配置的参数。我们将按以下方式修改现有配置:

override fun onUserLeaveHint() {
    super.onUserLeaveHint()
    val aspectRatio = Rational(16, 9)
    val params = PictureInPictureParams.Builder()
        .setAspectRatio(aspectRatio)
        .setActions(listOf(
            getIntentForTogglePlayPauseAction()))
        .build()
    enterPictureInPictureMode(params)
}

在这里,我们使用 setActions() 函数添加一个包含新操作的列表。

最后一步是处理在播放和暂停之间有效切换的逻辑。我们已经在 ViewModel 组件中实现了此功能,所以我们只需在 Activity 类中注入 PlaybackViewModel 组件并调用 togglePlayPause() 函数:

@AndroidEntryPoint
class PlaybackActivity: ComponentActivity() {
    private val viewModel: PlaybackViewModel by
    viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        pipActionReceiver = PiPActionReceiver {
            viewModel.togglePlayPause()
        }
        ...
    }
}

如我们所见,我们正在注入 PlaybackViewModel,然后,当接收器检测到用户发送了带有播放/暂停操作的广播时,viewModel.toggle PlayPause() 函数将被调用。

如果我们使用这些更改执行代码,我们应该在我们的 PiP UI 中看到 播放 按钮:

图 9.2:带有一些操作的 PiP 视图

图 9.2:带有一些操作的 PiP 视图

在实现了 PiP 模式后,让我们继续使用MediaRouter API 连接到其他设备进行媒体播放,该 API 允许您的应用程序将内容投放到智能电视或 Chromecast 等设备。我们将介绍如何使用MediaRouter来识别兼容设备并管理对它们的媒体流式传输,从而增强应用程序的功能。

了解 MediaRouter

MediaRouter是 Android 开发中的一个关键组件,特别是对于处理多媒体内容的应用程序。它作为运行您的应用程序的设备和外部设备(如 Google Chromecast、智能电视和多种支持媒体路由功能的扬声器)之间的桥梁。

MediaRouter的核心功能是促进多媒体内容的流式传输——无论是音频、视频还是图像——从用户的当前设备传输到另一个提供更好或更合适播放体验的设备。它智能地发现可用的媒体路由,并允许应用程序连接到它们,从而将多媒体功能扩展到用户主要设备的限制之外。

Android 的MediaRouter API 提供了一个框架,开发人员可以利用它来搜索和与本地网络上注册的媒体路由提供者进行交互。这些提供者代表能够进行媒体播放的设备或服务。使用MediaRouter,应用程序不仅可以动态发现这些路由,还可以向用户提供一个简化的界面来选择他们首选的播放设备,同时无缝管理跨设备的连接和播放状态。

在 Android 应用程序中使用MediaRouter为增强用户的媒体消费体验提供了无数可能性。以下是一些典型的用例:

  • 将视频投放到更大的屏幕上MediaRouter最常用的功能之一就是将移动设备上的视频投放到更大的显示设备上,例如智能电视或带有 Chromecast 的显示器。这对于在更大屏幕上观看电影、电视剧或用户生成的内容,以获得更沉浸式的观看体验尤其有吸引力。

  • 将音乐流式传输到外部扬声器MediaRouter允许应用程序将音乐流式传输到外部扬声器,增强音频体验。这对于派对、锻炼或简单地提升手机或平板电脑内置扬声器的音乐播放质量非常理想。

  • 在共享屏幕上显示图像:应用程序可以使用MediaRouter将图像发送到智能电视或连接的显示器,非常适合与一群人分享照片、进行演示或以更高分辨率查看艺术品。

  • 游戏:利用将屏幕内容投放到更大显示器的功能,游戏应用可以利用MediaRouter在电视上提供类似游戏机的游戏体验,同时使用移动设备作为控制器。

  • 健身和教育:对于专注于健身或教育的应用,将教学视频或锻炼计划投射到电视上,可以让用户更舒适、更有效地跟随。

在这些用例中,MediaRouter 通过利用连接设备的强大功能,显著增强了应用的功能,从而为用户提供更灵活和丰富的媒体播放体验。通过其全面的 API,开发者可以创建不仅限于移动设备小屏幕的应用程序,而是能够在家庭网络中的任何兼容设备上呈现内容的程序。

设置 MediaRouter

MediaRouter 集成到我们的 Android 应用中涉及几个关键的设置步骤,包括将必要的依赖项添加到项目中,并确保您已设置正确的权限。

首先,我们需要在我们的 libs.versions.toml 文件中包含 MediaRouter 库依赖项。这个库提供了发现和与媒体路由提供者交互所需的类和接口:

[versions]
...
mediarouter = "1.7.0"
google-cast = "21.4.0"
[libraries]
...
media-router = { group = "androidx.mediarouter", name="mediarouter", version.ref="mediarouter"}
google-cast = { group = "com.google.android.gms", name="play-services-cast-framework", version.ref="google-cast"}

由于我们计划支持投射到 Chromecast 设备或其他 Google Cast 兼容设备,我们需要 play-services-cast-framework 库。这个库促进了与 Google Cast 设备的集成,并扩展了 MediaRouter 的功能。

下一步将是将其添加到我们的 build.gradle 模块中:

    implementation(libs.media.router)
    implementation(libs.google.cast)

现在,为了使 MediaRouter 能够发现和与本地网络上的设备交互,我们必须在应用的 AndroidManifest.xml 文件中声明必要的权限:

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

我们在此包括以下权限:

  • 互联网权限:由于 MediaRouter 可能会使用网络与媒体路由提供者通信,您的应用需要权限来访问互联网。我们已经在之前的章节中需要声明此权限,因此不应是新的。

  • 网络状态权限:这些权限是应用监控网络连接变化所必需的,这对于在网络中发现设备至关重要。

  • 本地网络权限(Android 12 及以上):从 Android 12(API 级别 31)开始,如果您的应用针对 API 级别 31 或更高版本,并且需要发现本地网络上的设备,您还必须声明此权限。

  • 推送通知:对于 Android 12 及以上版本,为了访问本地网络进行设备发现,必须拥有此权限。

在添加必要的依赖项和权限后,我们的项目就准备好使用 MediaRouter 来发现媒体路由提供者并启用媒体流到外部设备。

发现媒体路由

一旦您的应用设置了必要的 MediaRouter 依赖项和权限,下一步就是发现可用的媒体路由。这涉及到识别您的应用可以流式传输媒体的外部设备或服务。Android 的 MediaRouter 框架通过提供发现媒体路由并将其呈现给用户的工具来简化这一过程。

了解 MediaRouteProvider

MediaRouteProvider 是一个将媒体路由发布到 MediaRouter 的组件。它作为您的应用与外部设备或服务(如扬声器、电视或其他 Cast 启用设备)之间的桥梁。使用 MediaRouteProvider 有两种选择:

  • 默认 MediaRouteProvider 实现:对于大多数用例,尤其是在与 Google Cast 设备集成时,Android 提供了一个默认的 MediaRouteProvider 实现,因此您不需要实现自己的。Google Cast 框架自动发现兼容设备并将它们作为媒体路由提供。

  • 自定义 MediaRouteProvider 实现:如果您需要发现自定义协议或 Google Cast 未涵盖的特定类型的媒体路由设备,您可以通过扩展 MediaRouteProvider 类来实现自己的 MediaRouteProvider 实例。这涉及到定义发现逻辑并向 MediaRouter 发布路由。

然而,创建自定义的 MediaRouteProvider 实现超出了基本媒体路由的范围,并需要深入了解您针对的具体硬件或协议。如果您想了解更多信息,请参阅创建自定义 MediaRouteProvider 实现的官方文档:developer.android.com/media/routing/mediarouteprovider

我们将使用默认的 MediaRouteProvider 实现。

使用 MediaRouter 类

MediaRouter 类是您与媒体路由交互的主要工具。以下是您如何使用它来发现和监控可用媒体路由的示例。

我们将首先定义一个 MediaRouteSelector 实例,并允许它开始发现其他设备以发送媒体。我们将使用 LaunchedEffect 将发现过程与可组合组件的生命周期绑定:

@Composable
fun MediaRouteDiscoveryOptions(mediaRouter: MediaRouter) {
    val context = LocalContext.current
    val routeSelector = remember {
        MediaRouteSelector.Builder()
            .addControlCategory(
                MediaControlIntent.CATEGORY_REMOTE_PLAYBACK
            )
            .build()
    }
    val mediaRoutes = remember {
    mutableStateListOf<MediaRouter.RouteInfo>() }
    DisposableEffect(mediaRouter) {
        mediaRouter.addCallback(routeSelector, callback,
            MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
        onDispose {
            mediaRouter.removeCallback(callback)
        }
    }
}

这个可组合函数接受一个 MediaRouter 实例作为参数,突出了它对该框架发现媒体路由的依赖性。

函数首先使用 LocalContext.current 获取当前的 Context 值,然后创建一个 MediaRouterSelector 实例。此选择器专门配置为筛选支持实时视频内容的路由。使用 remember 确保在可组合组件的重构过程中保留 MediaRouteSelector 实例,通过防止不必要的重新初始化来优化性能。

然后,我们添加了一个 DisposableEffect 可组合组件,它封装了与可组合组件生命周期一致的启动和停止媒体路由发现逻辑。通过将 MediaRouter 作为键传递给 DisposableEffect,当可组合组件首次组合到 UI 中时,封装的代码块将在协程中执行,当可组合组件被移除时,协程将被取消,从而有效地管理发现过程的生命周期。在这个块中,调用 MediaRouteraddCallback 方法来注册一个带有活动扫描标志的回调,启动与 routeSelector 设置的标准匹配的媒体路由的主动扫描。DisposableEffect 中的 onDispose 块充当清理机制,当可组合组件被销毁时,回调将从 MediaRouter 中注销,确保资源得到释放,后台处理最小化。

现在,我们将创建一个回调,它包含在之前描述的 addCallback 函数中:

val callback = remember {
    object : MediaRouter.Callback() {
        override fun onRouteAdded(router: MediaRouter,
        route: MediaRouter.RouteInfo) {
            mediaRoutes.add(route)
        }
        override fun onRouteRemoved(router: MediaRouter,
        route: MediaRouter.RouteInfo) {
            mediaRoutes.remove(route)
        }
    }
}

我们正在实例化一个 MediaRouter.Callback 监听器,使用 remember 来避免每次应用 UI 更新时都需要重新创建它。

这个监听器,MediaRouter.Callback,通过其 onRouteAddedonRouteRemoved 方法有两个主要任务。当一个新设备可用于媒体传输时,onRouteAdded 被调用,并且应用将这个新路由添加到名为 mediaRoutes 的列表中。这个列表对于应用知道任何时刻可用的设备至关重要。另一方面,当一个设备离线或断开连接时,onRouteRemoved 被调用,应用将此路由从列表中删除,确保列表保持最新。

实际上,这种设置允许应用动态调整可用的媒体传输设备的变化。

为了给用户提供一个方便的方式来选择这些可用的设备,我们需要集成一个为此目的设计的按钮。MediaRouter API 提供了一个显示可传输设备的现成按钮。尽管这个按钮是一个 Android 视图而不是可组合组件,我们仍然可以使用 AndroidView 可组合组件来使用它。以下是我们可以如何做到这一点:

AndroidView(
    factory = { ctx ->
        MediaRouteButton(ctx).apply {
            setRouteSelector(routeSelector)
        }
    },
    modifier = Modifier
        .wrapContentWidth()
        .wrapContentHeight()
)

现在,我们只需使用我们的回放屏幕中的 MediaRouteDiscoveryOptions 可组合组件:

@Composable
fun TopMediaRow(mediaRouter: MediaRouter, modifier:
Modifier = Modifier) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(20.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(text = "S1:E1 \"Pilot\"", color = Color.White)
        MediaRouteDiscoveryOptions(mediaRouter =
            mediaRouter)
    }
}

在这里,我们已经将 MediaRouteDiscoveryOptions 可组合组件添加到我们现有的 TopMediaRow 函数中。

当调用 TopMediaRow 函数时,我们将传递之前获得的 mediaRouter 实例,使用 LocalContext

TopMediaRow(
    mediaRouter =
        MediaRouter.getInstance(LocalContext.current),
    modifier = Modifier.align(Alignment.TopCenter))

现在,我们将看到 PlaybackScreen 可组合组件。如果我们点击它,MediaRouter 将会自动搜索设备:

图 9.3:MediaRouter 搜索设备

图 9.3:MediaRouter 搜索设备

如果它找不到任何设备,它将显示一条消息,鼓励用户检查连接:

图 9.4:MediaRouter 功能,提示用户检查设备连接

图 9.4:MediaRouter 功能,提示用户检查设备连接

在使用 MediaRouter API 发现可用的媒体路由后,下一步是连接到选定的设备进行媒体播放。这涉及两个主要操作:选择一个媒体路由,然后建立到该路由的连接。以下是你可以如何处理这个流程。

当使用 MediaRouteButton 内置的媒体路由选择器时,连接到设备的流程得到了简化。MediaRouteButton 会自动根据 MediaRouteSelector 实例中定义的标准显示可用的媒体路由。用户可以从 MediaRouteButton 展示的 UI 中直接选择他们偏好的设备。

一旦用户从对话框中选择了一条路由,MediaRouter 框架就会根据路由的能力和你在 MediaRouteSelector 实例中指定的媒体类型自动管理到该设备的连接。在你的应用程序代码中不需要额外的手动连接管理。

在选择了路由并建立了连接后,你可以通过选定的路由控制媒体播放。这通常涉及使用适合你应用程序的媒体内容和所选路由能力的媒体控制 API。我们将在下一节学习如何为 Google Cast 设备投射媒体播放。

连接到 Google Chromecast 设备

Google Cast 是由 Google 开发的一项强大技术,它允许用户将智能手机、平板电脑或电脑上的音频和视频内容无线流式传输到支持 Cast 的设备。这项技术嵌入到各种设备中,包括 Chromecast 拓展器、智能电视和扬声器,使其能够服务于广泛的用户群体。在核心上,Google Cast 通过在移动设备或电脑上的支持 Cast 的应用程序和支持 Cast 的接收设备之间建立连接来工作。一旦建立连接,媒体就可以在接收设备上播放,有效地将其转变为被投射内容的远程屏幕或扬声器。

Google Cast 的功能不仅限于从互联网流式传输媒体。它还允许从发送设备屏幕镜像内容,扩展其用途到演示、教育内容等。Google Cast 通过 Wi-Fi 运行,确保高质量流式传输性能,无需物理电缆或适配器。

我们已经完成了一些步骤:我们已经包含了库,并且正在检测允许投射的设备。现在,我们需要建立一个投射会话。这个会话促进了你的应用程序和所选 Cast 设备之间的连接,使得可以在大屏幕上进行媒体控制和播放。这个过程依赖于有效地使用 CastContext 和熟练地管理 Cast 会话事件。

CastContext 在你的应用程序中启动和管理 Cast 会话中起着核心作用,提供了连接到所选 Cast 设备所需的 API。以下是初始化连接的方法。

首先,我们需要确保你在应用程序中初始化了 CastContext。这通常在 Application 子类或你的主活动中完成。我们将在 PlaybackActivity 类中初始化它:

val castContext = CastContext.getSharedInstance(context)

然后,我们需要选择一个设备。我们已实现了 MediaRouterbutton,它将自动处理选择。一旦选择了一个设备,Cast SDK 将自动启动到该设备的连接。这个过程对开发者来说是抽象的,但监听会话事件对于有效地管理连接至关重要。

Cast SDK 为会话事件(如启动、结束、恢复和挂起)提供回调。处理这些事件允许你的应用程序对会话状态的变化做出反应,例如,在会话结束时更新 UI 或暂停媒体播放。

要监听这些会话事件,我们必须实现 SessionManagerListener:

private val sessionManagerListener = object : SessionManagerListener<CastSession> {
    override fun onSessionStarted(session: CastSession,
    sessionId: String) {
        castSession = session
        updateUIForCastSession(true)
    }
    override fun onSessionEnded(p0: CastSession, p1: Int) {
        castSession = null
        updateUIForCastSession(false)
    }
    override fun onSessionResumed(session: CastSession, p1:
    Boolean) {
        castSession = session
        updateUIForCastSession(true)
    }
    override fun onSessionStarting(p0: CastSession) {}
    override fun onSessionStartFailed(
        p0: CastSession, p1: Int) {}
    override fun onSessionResuming(session: CastSession,
        p1: String) {}
    override fun onSessionResumeFailed(session:
        CastSession, p1: Int) { }
    override fun onSessionEnding(session: CastSession) {}
    override fun onSessionSuspended(p0: CastSession,
        p1: Int) {}
}

在这里,我们正在实现我们的 SessionManagerListener<CastSession> 接口,这对于管理 Google Cast 会话至关重要。这个监听器旨在对与 Cast 会话生命周期相关的各种事件做出反应,包括其开始、结束、恢复和失败情况。让我们更深入地看看这个实现:

  • onSessionStarted: 当一个新的 Cast 会话成功启动时,此回调被调用。在这里,session 参数,它是一个 CastSession 的实例,代表了新建立的会话。该方法将全局 castSession 变量设置为该实例,有效地标志着会话的开始。随后,它调用 updateUIForCastSession(true),这是一个将实现的方法,用于更新应用程序的 UI 以反映传输已经开始。

  • onSessionEnded: 当现有的 Cast 会话结束时触发,此方法通过将其设置为 null 来清除 castSession 变量,表示不再有活跃的 Cast 会话。它还调用 updateUIForCastSession(false) 来调整 UI,向用户发出信号,表示传输已停止。

  • onSessionResumed: 与 onSessionStarted 类似,当先前挂起的 Cast 会话恢复时,此回调被调用。它使用当前会话更新 castSession 并调用 updateUIForCastSession(true) 以在 UI 中反映传输的恢复。

  • onSessionStartingonSessionResuming: 指示会话正在启动或恢复,但尚未完成。在我们的情况下,这些回调中不采取任何操作。

  • onSessionStartFailedonSessionResumeFailed: 当尝试启动或恢复会话失败时被调用。同样,在我们的情况下没有指定任何操作,但这些是处理错误(例如,通知用户或尝试重新启动会话)的适当位置。

  • onSessionEndingonSessionSuspended:这些回调在会话正在结束或暂停过程中被触发。与开始和恢复事件一样,在这些情况下不采取任何特定操作。

一旦我们实现了我们的监听器,我们需要使用 castContext.sessionManager 来注册它:

override fun onStart() {
    super.onStart()
    castContext.sessionManager.addSessionManagerListener(
        sessionManagerListener, CastSession::class.java)
}
override fun onStop() {
    super.onStop()
    castContext.sessionManager.removeSessionManagerListener
        (sessionManagerListener, CastSession::class.java)
}

在这里,我们是在 Activity 类启动时注册监听器,并在它停止时移除它。这样,我们确保只有在 Activity 类处于启动状态时,监听器才会被保留。

现在,让我们实现 updateUIForCastSession 函数:

private fun updateUIForCastSession(isCasting: Boolean) {
    viewModel.setCastingState(isCasting)
}

在这里,我们正在调用一个新函数,我们将在 ViewModel 组件中包含它,称为 setCastingState。我们传递一个布尔值作为参数,指示应用程序是否正在传输。

在我们的 PlaybackViewModel 组件中,我们将引入以下更改。我们将开始添加一个新的属性,isCasting

private val _isCasting = MutableStateFlow<Boolean>(false)
val isCasting: MutableStateFlow<Boolean> = _isCasting

然后,当调用 setCastingState 函数时,我们将更改其值:

fun setCastingState(isCasting: Boolean) {
    _isCasting.value = isCasting
}

然后,我们将在我们的 PlaybackScreen 组合组件中使用它:

@Composable
fun PlaybackScreen() {
    ...
    val isCasting = viewModel.isCasting.collectAsState()
    Box(
        ...
    ) {
        if (isCasting.value) {
            NowCastingView()
        } else {
            //VideoPlayerComposable and the rest of the UI...
        }
    }
}

在我们现有的 PlaybackScreen 组合组件中,我们添加了一个新的属性,isCasting。这个属性用于选择屏幕是否显示 正在传输 消息或完整的播放用户界面。

接下来,我们将构建一个新的 NowCastingView 组合组件:

@Composable
fun NowCastingView() {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "Now Casting",
                style =
                    MaterialTheme.typography.headlineMedium
            )
        }
    }
}

这个组合组件只是放置并显示一个包含 正在传输 内容的文本,只是为了让用户意识到媒体内容目前正在传输到另一个设备。

我们必须做的一件事是:在远程设备上加载媒体。我们将修改 SessionManagerListener 接口中的 onSessionStarted 回调,包括调用一个新函数来加载媒体:

override fun onSessionStarted(session: CastSession,
sessionId: String) {
    castSession = session
    updateUIForCastSession(true)
    loadMedia(session)
}

最后,我们将如下实现这个函数:

private fun loadMedia(castSession: CastSession) {
    val mediaInfo = MediaInfo.Builder(viewModel.mediaUrl)
        .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
        .setContentType("video/mp4")
        .build()
    val mediaLoadOptions = MediaLoadOptions
        .Builder()
        .setAutoplay(true)
        .setPlayPosition(0)
        .build()
    castSession.remoteMediaClient?.load(mediaInfo,
        mediaLoadOptions)
}

函数首先构建一个 MediaInfo 对象,它封装了关于要播放的媒体文件的所有必要细节。利用 MediaInfo.Builder 模式,它从 viewModel.mediaUrl 指定媒体的 URL,这是媒体文件的存储位置,Cast 兼容设备将从中流式传输。然后,构建器将流类型设置为 MediaInfo.STREAM_TYPE_BUFFERED,表示内容是预先录制的,可以在播放前进行缓冲,这对于不进行实时流式传输的视频内容来说非常理想。此外,内容类型被设置为 "video/mp4",定义了文件的 MIME 类型(多用途互联网邮件扩展,不仅用于电子邮件,还用于网页浏览器和应用程序来正确解释和显示内容)作为 MP4 视频。

在创建MediaInfo对象之后,函数继续通过MediaLoadOptions对象配置额外的播放选项。设置的选项包括setAutoplay(true),该命令指示 Cast 设备在加载后自动开始播放媒体,以及setPlayPosition(0),确保播放从媒体文件的开始处开始,以简化操作。对此的一个改进可能是从ViewModel组件获取当前的播放位置,以便如果播放已经开始,视频可以继续在相同的时间点播放。

loadMedia函数的最后一步是在castSession变量的remoteMediaClient实例上调用load方法。这个方法调用实际上是向启用 Cast 的设备发送媒体加载和播放命令的地方。remoteMediaClient充当中间人,将命令从应用传输到接收器。通过将MediaInfo对象和MediaLoadOptions传递给此方法,应用指定了要播放的内容以及如何播放,从而有效地启动视频内容到 Cast 设备的流式传输。

现在,我们的应用已经准备好开始向 Google Cast 设备进行投屏。随着这一点,我们完成了这一章节,并学习了在 Android 和其他连接设备上播放的广泛可能性。

摘要

在本章中,我们探讨了在 Android 上扩展视频播放的基本知识,重点是让我们的应用在更多上下文中播放视频。我们涵盖了两个主要领域:PiP 模式和媒体投屏,两者都旨在让我们的用户保持与内容的连接,无论他们是在设备上多任务处理还是想要在大屏幕上享受视频。

从 PiP 模式开始,我们探讨了如何在用户离开应用时,让视频在一个小窗口中继续播放。本节详细介绍了从修改应用清单到实现 PiP 模式的所有内容,确保用户在使用其他应用时不需要暂停他们的观看体验。

接下来,我们将焦点转向媒体投屏,特别是针对 Google Chromecast 等设备的MediaRouter和 Cast SDK。在这里,你学习了如何让用户从他们的移动设备向电视发送视频。我们讨论了使用MediaRouteButton进行轻松的设备发现和连接,以及如何为希望对投屏过程有更多控制的用户创建自定义 UI。

到本章结束时,你应该已经了解了如何实现 PiP 模式以支持应用内多任务处理,并设置外部设备的投屏。这些技能对于创建提供灵活且用户友好的视频播放体验的 Android 应用至关重要。无论是保持视频在屏幕角落运行,还是在大电视上分享喜欢的电影,你的应用现在可以满足各种用户需求,增强与视频内容的整体互动。

就这样,我们的旅程走到了尽头,我们为三种类型的应用构建了关键功能:一个即时通讯应用、一个社交平台和一个视频应用。每个项目都旨在加深您的 Android 和 Kotlin 开发技能,并激发您思考如何将这些想法应用到您自己的工作中。

感谢您阅读这本书。我希望它不仅拓宽了您的知识面,也为您的项目激发了新的想法。凭借您所学的工具和技术,您已经做好了准备,去提升您的职业生涯并开始构建您自己的创新应用。祝您在移动开发领域取得成功——走出去,创造伟大事物吧!

posted @ 2025-10-08 11:37  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报