Android 应用架构指南

应用架构指南

https://developer.android.com/topic/architecture/recommendations?hl=zh-cn

应用架构

常见的架构原则

  1. 分离关注点
    将应用分离为具有明确定义的责任和边界的方法、类、文件、软件包、模块和层。
  2. 自适应布局
  3. 通过数据模型驱动界面
  • 如果 Android OS 销毁应用以释放资源,用户不会丢失数据。
  • 当网络连接不稳定或不可用时,应用会继续工作。
  1. 单一可信来源 (SSOT)
  • 将对特定类型数据的所有更改集中到一处
  • 保护数据,防止其他类型篡改此数据
  • 更易于跟踪对数据的更改,因此更容易发现 bug
  1. 单向数据流(UDF)
  • 在 单向数据传输UDF 中,状态仅朝一个方向流动,通常是从父级组件流向子级组件。修改数据的事件朝相反方向流动。

推荐的应用架构

界面层

包含两种类型的结构:

  1. 在屏幕上呈现数据的界面元素。
  2. 用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel)

数据层

应用的数据层包含业务逻辑。
数据层由多个存储库组成,其中每个存储库都可以包含零到多个数据源。
每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。数据源类是应用与数据操作系统之间的桥梁。

网域层

网域层负责封装复杂的业务逻辑,或者由多个视图模型重复使用的简单业务逻辑。
网域层是可选的,因为并非所有应用都有这类需求。
网域层中的类通常称为“用例”或“交互方”。 每个用例都负责一项功能。

管理组件之间的依赖关系

依赖注入 (DI)
服务定位器

常见的最佳做法

不要将数据存储在应用组件中。
减少对 Android 类的依赖。
在应用的各个模块之间设定明确的职责界限。
尽量少公开每个模块中的代码。
专注于应用的独特核心,以使其从其他应用中脱颖而出。
使用规范布局和应用设计模式。
在配置更改后保留界面状态。
设计可重复使用且可组合的界面组件。
考虑如何使应用的每个部分可独立测试。
类型负责其并发政策。
保留尽可能多的相关数据和最新数据。

架构的优势

有关 Android 架构的建议

分层架构
界面层
ViewModel
生命周期
处理依赖关系
测试
模型
命名惯例

应用基础知识

应用组件

Activity
服务
广播接收器
content provider

界面层庫

界面层

定义界面状态

界面状态类命名惯例功能 + UiState

使用单向数据流管理状态

状态向下流动、事件向上流动的这种模式称为单向数据流 (UDF)。
单向数据流 (UDF),这是一种架构模式,有助于强制实施这种健康的职责分离。

状态容器
状态容器是负责提供界面状态以及提供生成该状态所需的逻辑的类。

逻辑类型

  • 业务逻辑
    业务逻辑通常位于网域层或数据层中,但绝不能位于界面层中。
  • 界面行为逻辑(即界面逻辑)
    决定着如何在屏幕上显示状态变化。

公开界面状态

创建 UiState 流的一种常用方法是,公开具有 private set 的 mutableStateOf 属性,使状态在 ViewModel 内保持可变,但对于界面而言是只读的。

其他注意事项

  • 使用单个界面状态对象来处理彼此相关的状态。
  • 界面状态:单个数据流还是多个数据流?
    • 不相关的数据类型:呈现界面所需的某些状态可能是完全相互独立的。
    • UiState diffing:UiState 对象中的字段越多,数据流就越有可能因为其中一个字段被更新而发出。可能必须要使用 Flow API 方法(例如 distinctUntilChanged())来缓解这个问题

使用界面状态

使用数据流时,最好通过适当的协程作用域和 collectAsStateWithLifecycle API 来处理生命周期问题

  • 显示正在执行的操作
  • 在屏幕上显示错误

线程处理和并发

导航3

Paging

动画

界面事件

“界面事件”是指应由界面或 ViewModel 在界面层处理的操作。最常见的事件类型是“用户事件”。

  • 业务逻辑是指如何处理状态更改,例如付款或存储用户偏好设置
  • 界面行为逻辑(即界面逻辑)是界面可以直接处理的界面行为逻辑,例如导航逻辑或如何向用户显示消息。界面会处理此逻辑。

处理用户事件

用户事件函数和事件处理脚本的命名惯例

  • 形参名称: on + Verb + Target(例如 onExpandClicked 或 onValueChange)。
  • Lambda 表达式:在调用可组合项时,lambda 通常只是相应事件的实现。

处理 ViewModel 事件

ViewModel 事件应始终会引发界面状态更新。

导航事件

dropUnlessResumed

状态容器和界面状态

界面状态生成流水线的元素

界面状态

界面状态是描述界面的属性。界面状态有两种类型:

  • 屏幕界面状态 是需要在屏幕上显示的内容。
  • 界面元素状态 是指界面元素的固有属性,这些属性会影响界面元素的呈现方式。
逻辑
  • 业务逻辑决定着应用数据的产品要求的实现。
  • 界面逻辑决定着如何在屏幕上显示界面状态。

Android 生命周期以及界面状态和逻辑的类型

界面层包含两个部分:

  • 一部分依赖于界面生命周期:
    界面层的这一部分用于处理应用的数据生成层(数据层或网域层),由业务逻辑定义。
  • 另一部分不依赖于界面生命周期:
    界面层的这一部分用于处理界面逻辑,受生命周期或配置更改的直接影响。例如运行时权限,以及获取依赖于配置的资源(例如本地化字符串)。
界面状态生成流水线
  • 由界面本身生成和管理的界面状态。
  • 界面逻辑 → 界面。
  • 业务逻辑 → 界面。
  • 业务逻辑 → 界面逻辑 → 界面。

状态容器及其责任

状态容器的类型

1. 业务逻辑状态容器

业务逻辑状态容器通常使用 ViewModel 实例来实现
请勿将 ViewModel 实例向下传递到其他可组合函数。这样做会导致可组合函数与 ViewModel 类型形成耦合,从而降低其可重用性,而且会更难以测试和预览。
ViewModel 在 Android 开发中的优势使其适用于提供对业务逻辑的访问权限以及准备要在屏幕上呈现的应用数据。

2. 界面逻辑状态容器

界面逻辑状态容器通常使用普通类实现。
当界面逻辑足够复杂,可以移出界面时,会使用普通类状态容器。否则,界面逻辑可以在界面中以内嵌方式实现。

3. 为状态容器选择 ViewModel 和普通类

您应根据离使用界面状态的位置最近的状态容器生成界面状态。
大多数应用会选择执行内嵌在界面本身中的界面逻辑,而这些逻辑原本可以放在普通类状态容器中。这适用于简单的情况,但在其他情况下,您可以通过将逻辑拉取到普通类状态容器中来提高可读性。

4. 状态容器可组合

状态容器可以依赖于另一个状态容器,前提是依赖项的生命周期与状态容器相同或更短。

界面状态生成

状态始终存在,而事件则不时发生。

界面状态生成流水线

输入——状态容器——输出

每个状态生成流水线都是输入和输出的组合,并且必须满足以下条件:

  • 可感知生命周期
    如果界面不可见或未处于活动状态,除非明确要求,否则状态生成流水线不得消耗任何资源。
  • 易于使用
    界面必须能够轻松呈现生成的界面状态。

输入

使用一次性 API 作为状态变化来源

MutableStateFlow、mutableStateOf

  • 通过异步调用更改界面状态
  • 通过后台线程更改界面状态
    使用 withContext 方法可在其他并发上下文中运行协程。
    使用 MutableStateFlow 时,照常使用 update 方法。
    使用 Snapshot.withMutableSnapshot 方法来保证在并发上下文中对 State 进行原子更新。
使用流 API 作为状态变化来源

将所有来源的输出聚合为一个紧密的整体,可以通过 combine 函数来实现此目的。

使用一次性的流 API 作为状态变化来源

如果 状态生成流水线 依赖 一次性调用 和 数据流 作为状态变化来源,则 数据流 就是决定性的约束条件。因此,应将一次性调用转换为 数据流 API,或将其输出传输到数据流中并恢复处理。
可使用 snapshotFlow API 将 Compose State 转换为数据流。

輸出

尽可能延迟状态生成流水线的初始化,以节省系统资源。

  • Flow API 通过 stateIn 方法中的 started 实参實現。
  • 定义幂等 initialize 函数

生命週期感知控件

生命周期

  • 使用数据流收集生命周期状态
    val lifecycleState by lifecycleOwner.lifecycle.currentStateFlow.collectAsState()
    val currentLifecycleState = lifecycleOwner.lifecycle.currentStateAsState()
  • 在生命周期事件发生时运行代码
    LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {}
    LifecycleStartEffect
    LifecycleResumeEffect
  • 访问 LifecycleOwner
    val lifecycleOwner = LocalLifecycleOwner.current
  • 创建自定义 LifecycleOwner
    借助 rememberLifecycleOwner() API,您可以创建并记住自定义 LifecycleOwner
  • 有关生命周期感知型组件的最佳实践
    将数据逻辑放在 ViewModel 类中。
    使用 Kotlin 协程管理长时间运行的任务和其他可以异步运行的操作。
    请改用 collectAsStateWithLifecycle 将数据流高效转换为界面状态。
  • 生命周期感知型组件的用例
  • 安全地处理 ON_STOP 事件

ViewModel

關於ViewModel
ViewModel 的优势
  • 持久保留界面状态
    範圍:可以使用 rememberViewModelStoreOwner API 将 ViewModel 直接限定到可组合项。然后,ViewModel 的作用域将限定为 Lifecycle 的 ViewModelStoreOwner。它会一直保留在内存中,直到其 ViewModelStoreOwner 永久消失
    SavedStateHandle:借助 SavedStateHandle,您不仅可以在更改配置后持久保留数据,还可以在进程终止后持久保留数据。
  • 对业务逻辑的访问权限
    进程终止后持久保留数据
实现 ViewModel
ViewModel 的生命周期

ViewModel 会一直保留在内存中,直到其作用域 ViewModelStoreOwner 消失
使用自定义作用域(而不是 viewModelScope)
由于 ViewModel 的生命周期可能比 ViewModelStoreOwner 更长,因此 ViewModel 不应保留任何对与生命周期相关的 API(例如 Context 或 Resources)的引用,以免发生内存泄漏。
请勿将 ViewModel 传递给其他类、函数或其他界面组件。 应该使其尽可能靠近您的 activity、屏幕级可组合函数或 Navigation 目的地。

创建具有依赖项的 ViewModel
包含 CreationExtras 的 ViewModel

如果 ViewModel 类在其构造函数中接收依赖项,请提供用于实现 ViewModelProvider.Factory 接口的工厂
ViewModelProvider.NewInstanceFactory.VIEW_MODEL_KEY
ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
SavedStateHandleSupport.DEFAULT_ARGS_KEY
SavedStateHandleSupport.SAVED_STATE_REGISTRY_OWNER_KEY
SavedStateHandleSupport.VIEW_MODEL_STORE_OWNER_KEY
CreationExtras.createSavedStateHandle()

val Factory: ViewModelProvider.Factory = viewModelFactory {initializer {...}}

ViewModel 作用域 API

每个 ViewModel 的作用域都限定为一个实现 ViewModelStoreOwner 接口的对象

  • ViewModel 的作用域限定为最近的 ViewModelStoreOwner
    可以将 ViewModel 的作用域限定为可组合函数、Activity 或导航图的目的地。
    借助viewModel() 函数,可以获取作用域限定为最近的 ViewModelStoreOwner 的 ViewModel 实例。

  • ViewModel 的作用域可以限定为任何 ViewModelStoreOwner
    viewModel: MyViewModel = viewModel(viewModelStoreOwner = customOwner)

  • ViewModel 的作用域限定为可组合项
    使用 rememberViewModelStoreOwner() 创建一个生命周期感知型存储区,该存储区不受配置更改的影响。 将 ViewModel 直接限定到可组合函数的调用位置。
    val scopedOwner = rememberViewModelStoreOwner()
    CompositionLocalProvider(LocalViewModelStoreOwner provides scopedOwner)

  • ViewModel 的作用域限定为 Navigation 图
    使用 getBackStackEntry() 函数获取作用域限定为某个 Navigation 图的 ViewModel 实例。
    val parentEntry = remember(backStackEntry) {
    navController.getBackStackEntry("parentNavigationRoute")
    }
    val parentViewModel: SharedViewModel = viewModel(parentEntry)

ViewModel 的已保存状态模块

保存的状态与您的任务堆栈相关联。如果任务堆栈消失,保存的状态也会消失。
在用户发起的界面状态解除情景中,不会恢复保存的状态。在系统发起的界面状态解除情景中,则会恢复。
对于业务逻辑中使用的状态,请将其存储在 ViewModel 中,并使用 SavedStateHandle 保存。
对于界面逻辑中使用的状态,请在 Compose 中使用 rememberSaveable。

使用 SavedStateHandle
  • 通过 set() 和 get() 方法
  • 通过 getStateFlow()和getMutableStateFlow()
  • KotlinX 序列化支持:@Serializable 注释数据类,使用 saved 委托
  • Compose 状态支持:by savedStateHandle.saveable
  • 保存非 Parcelable 类:使用 setSavedStateProvider() 方法提供您自己的逻辑用于将对象作为 Bundle 来保存和恢复。
测试中的 SavedStateHandle

val savedState = SavedStateHandle(mapOf("someIdArg" to testId))
viewModel = MyViewModel(savedState = savedState)

保存界面状态

  • ViewModel 对象。
  • 以下情境中的已保存状态:
    • 可组合函数:rememberSerializable 和 rememberSaveable。
    • ViewModels:SavedStateHandle。
  • 本地存储空间,以便在应用和屏幕过渡期间保持界面状态。
用户预期和系统行为
  • 用户发起的界面状态解除
  • 系统发起的界面状态解除
    您可以替换针对配置更改的默认行为,但不建议这样做。
使用 ViewModel 处理配置更改
使用已保存的状态作为备份来处理系统发起的进程终止

Compose 中的 rememberSerializable 和 rememberSaveable 以及 ViewModel 中的 SavedStateHandle 等 API 会存储一些数据

使用 SavedStateRegistry 接入已保存状态

针对复杂或大型数据使用本地持久性存储来处理进程终止

将 Kotlin 协程与生命周期感知型组件一起使用

生命周期感知型协程范围
  • ViewModelScope:
    viewModelScope.launch {}
  • 与组合相关的范围:
    LaunchedEffect 会创建一个 CoroutineScope,让您可以运行挂起函数。该作用域与可组合函数的组合生命周期相关联,而不是与宿主 Activity 的 Lifecycle 相关联。
生命周期感知型数据流收集
  • collectAsStateWithLifecycle()
    此单个函数会将 Flow 转换为 Compose State 对象,并自动为您管理生命周期订阅。默认情况下,当生命周期处于 STARTED 状态时,系统会开始收集数据;当生命周期处于 STOPPED 状态时,系统会停止收集数据。
使用 Flow 异步计算值

如果您需要异步计算值,请将 StateFlow 与 stateIn 运算符搭配使用。

Paging 库

网域层

网域层负责封装复杂的业务逻辑,或者由多个 ViewModel 重复使用的简单业务逻辑。此层是可选的。
为了使这些类保持简单轻量化,每个用例都应仅负责单个功能,且不应包含可变数据。您应在界面或数据层中处理可变的数据。

命名: 动词原形 + 名词/内容(可选)+ UseCase。

在 Kotlin 中,您可以使用 operator 修饰符定义 invoke() 函数,将用例类实例作为函数进行调用。
由于UseCase不应包含可变数据,因此您每次将用例类作为依赖项传递时,都应该创建一个新实例。
来自网域层的UseCase必须是主线程安全的;换句话说,从主线程调用它们必须是安全的。

常见任务

  • 您应将界面层中存在的可重复业务逻辑封装到UseCase类中。
  • 合并仓库

数据层庫

数据层则包含应用数据和业务逻辑。
数据层由多个仓库组成,其中每个仓库都可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据分别创建一个存储库类。
每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。
数据层公开的数据应该是不可变的,这样就可以避免数据被其他类篡改,从而避免数值不一致的风险。

公开 API

  • 对于一次性操作,公开挂起函数。
  • 如需接收关于数据随时间变化的通知,请公开数据流。

命名惯例

  • 存储库类: 数据类型 + Repository。
    例如:NewsRepository
  • 数据源类:数据类型 + 来源类型 + DataSource。
    例如:NewsRemoteDataSource 或 NewsLocalDataSource,NewsNetworkDataSource 或 NewsDiskDataSource。
  • 请勿根据实现细节来为数据源命名(例如 UserSharedPreferencesDataSource)

多层存储库

在某些涉及更复杂业务要求的情况下,存储库可能需要依赖于其他存储库。

可信来源

为了提供离线优先支持,建议使用本地数据源(例如数据库)作为可信来源。

线程处理

调用数据源和存储库应该具有主线程安全性(即从主线程调用是安全的)。

生命周期

表示业务模式

分离模型类,并让存储库仅公开层次结构的其他层所需的数据。
我们建议您在数据源接收的数据与应用其余部分所需的数据不符时,创建新模型。

数据操作类型

  • 面向界面的操作
    面向界面的操作通常由界面层触发,并且遵循调用方的生命周期。
  • 面向应用的操作
    这些操作通常遵循 Application 类或数据层的生命周期。
  • 面向业务的操作
    对于面向业务的操作,建议使用 WorkManager。

公开错误

  • Kotlin 的内置错误处理机制
  • 使用 try/catch 块
  • 数据流中,可以使用 catch 运算符

常见任务

构建离线优先应用

离线优先应用中的模型数据

对于需要使用网络资源的每个存储库,离线优先应用至少有 2 个数据源:

  • 本地数据源
  • 网络数据源
  • 公开资源
    最好将 AuthorEntity 和 NetworkAuthor 都留在数据层内部,公开第三种类型供外部层使用。

读取

在离线优先应用中,从存储库读取数据应直接从本地数据源读取。所有更新均应先写入本地数据源,本地数据源会更新其使用方,因为它可观察。

错误处理策略

  • 本地数据源出錯
    尽量减少从本地数据源读取数据时出现的错误。为防止读取器出错,请对读取器从中收集数据的 Flow 使用 catch 操作符。
    如需采用更具弹性的方法,请考虑使用 LCE(加载内容错误)解决方案。
  • 网络数据源出錯
    应用需要采用启发法来重试提取数据。
    • 指数退避算法
    • 网络连接监控

写入

读取离线优先应用中数据的建议方式是使用可观察类型,
而写入 API 的等效方式是异步 API,例如挂起函数。

写入策略

可以考虑采取三种策略。具体选择哪种策略取决于要写入的数据类型以及应用的要求

  • 仅在线写入
    此策略通常用于必须近乎实时地在线执行的写入事务,例如银行转账。
  • 加入队列的写入
    如果您有想要写入的对象,请将其插入队列。当应用恢复在线状态时,使用指数退避算法排空队列。在 Android 上,排空离线队列是一项持久性工作,通常委托给 WorkManager。
  • 延迟写入
    先写入本地数据源,然后将写入请求加入队列,以便尽快通知网络数据源。
    当数据对应用至关重要时,此方法是正确的选择。

同步和解决冲突

应用与网络数据源同步主要有两种方式:

  • 基于拉取的同步
  • 基于推送的同步
  • 混合同步

冲突解决

对于移动应用,常见的方法是“最后写入内容生效”。

离线优先应用中的 WorkManager

見後面

DataStore

正确使用 DataStore:

  • 请勿在同一进程中为给定文件创建多个 DataStore 实例
  • DataStore<T>的泛型类型必须不可变。
  • 切勿对同一个文件混用 SingleProcessDataStore 和 MultiProcessDataStore。

在多进程代码中使用 DataStore

为了能够在不同进程中使用 DataStore,您需要使用 MultiProcessDataStoreFactory 为应用和服务代码构造 DataStore 对象

WorkManager

https://developer.android.com/develop/background-work/background-tasks?hl=zh-cn
見後面后台任务

应用启动

androidx.startup:startup-runtime:1.2.0

<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <!-- This entry makes ExampleLoggerInitializer discoverable. -->
    <meta-data  android:name="com.example.ExampleLoggerInitializer"
          android:value="androidx.startup" />
</provider>

Android 应用模块化指南

Android 中的依赖项注入

Android 中实现依赖项注入的方式主要有两种:

  • 构造函数注入
  • 字段注入(或 setter 注入)

手动依赖项注入

使用 Hilt 实现依赖项注入

  • @HiltAndroidApp

  • @HiltViewModel

  • @AndroidEntryPoint:
    Hilt 可以为带有 @AndroidEntryPoint 注解的其他 Android 类提供依赖项

  • @Inject :
    从组件获取依赖项,请使用 @Inject 注解执行字段注入

  • @ApplicationContext

  • @ActivityContext

可以通过构造函数注入接口

  • 定义 Hilt 绑定: @Inject constructor()
    带有注解的构造函数的参数即是该类的依赖项

不能通过构造函数注入接口

  • @Module:
    它会向 Hilt 提供有关如何创建无法通过构造函数注入提供的类型(例如接口或第三方类)的实例的说明。

  • @InstallIn:
    还必须使用 @InstallIn 为每个模块添加注解,以告知 Hilt 每个模块将用在或安装在哪个 Android 类中。

  • @Binds:
    注解会告知 Hilt 在需要提供接口的实例时要使用哪种实现。

    • 函数返回类型会告知 Hilt 该函数提供哪个接口的实例。
    • 函数参数会告知 Hilt 要提供哪种实现。
  • @Provides:

    • 函数返回类型会告知 Hilt 函数提供哪个类型的实例。
    • 函数参数会告知 Hilt 相应类型的依赖项。
    • 函数主体会告知 Hilt 如何提供相应类型的实例。每当需要提供该类型的实例时,Hilt 都会执行函数主体。
@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {

  @Binds
  abstract fun bindAnalyticsService(
    analyticsServiceImpl: AnalyticsServiceImpl
  ): AnalyticsService
}

@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    // Potential dependencies of this type
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}

在 Hilt 不支持的类中注入依赖项

  • @EntryPoint: 创建入口点。
    希望 content provider 使用 Hilt 来获取某些依赖项,需要为所需的每个绑定类型定义一个带有 @EntryPoint 注解的接口并添加限定符。然后,添加 @InstallIn 以指定要在其中安装入口点的组件
class ExampleContentProvider : ContentProvider() {

  @EntryPoint
  @InstallIn(SingletonComponent::class)
  interface ExampleContentProviderEntryPoint {
    fun analyticsService(): AnalyticsService
  }
  
override fun query(...): Cursor {
    val appContext = context?.applicationContext ?: throw IllegalStateException()
    val hiltEntryPoint = EntryPointAccessors.fromApplication(appContext, ExampleContentProviderEntryPoint::class.java)

    val analyticsService = hiltEntryPoint.analyticsService()
    ...
  }
}

使用 Hilt 注入 ViewModel 对象

  • @HiltViewModel
    ViewModel添加 @HiltViewModel 注解,并在 ViewModel 对象的构造函数中使用 @Inject 注解
    带有 @AndroidEntryPoint 注解的 activity 可以使用 ViewModelProvider 或 by viewModels() 获取 ViewModel 实例

将辅助注入与 ViewModel 搭配使用

  • @AssistedInject : 注解 ViewModel 构造函数
  • @Assisted: 标记动态参数
  • @AssistedFactory: 定义桥梁接口,供 Hilt 自动生成必要的 ViewModelProvider.Factory
@HiltViewModel(assistedFactory = MyViewModel.Factory::class)
class MyViewModel @AssistedInject constructor(
    @Assisted val userId: String,
    private val repository: MyRepository
) : ViewModel() {
    @AssistedFactory interface Factory {
        fun create(userId: String): MyViewModel
    }
}

后台任务

后台任务概览

在大多数情况下,您可以通过确定任务所属的类别(异步工作、任务调度 API 或前台服务)来确定适合任务使用的 API。

  • 异步工作
    常见的异步工作选项包括 Kotlin 协程和 Java 线程
  • 任务调度 API
    在大多数情况下,运行后台任务的最佳方案是使用 WorkManager,但在某些情况下,使用平台 JobScheduler API 可能更合适。
  • 前台服务
    前台服务提供了一种强大的方法,可立即运行不应中断的任务。
    通过调用 Service.startForeground() 指定该服务是前台服务。或者,使用 WorkManager 创建前台服务
  • 备选 API
    • 由用户发起的任务
      user-tasks-flowchart

    • 对事件做出响应的任务
      event-tasks-flowchart

系统对后台任务的限制

用户发起的限制

  • 唤醒锁定次数过多
  • 后台服务过多
    对接收网络活动广播的限制
  • 在不按流量计费的网络时调度工作
  • 在应用运行时监控网络连接
    对接收图片和视频广播的限制

数据传输后台任务选项

常见的后台数据传输场景

  • 使用用户发起的数据传输作业类型
  • 使用 WorkManager 进行数据传输
    JobScheduler 是调度后台工作的另一种选择
  • 使用特定 API
  • 使用更具体的前台服务类型
    • 使用 shortService 前台服务
    • 使用 connectedDevice 前台服务
    • 使用 mediaProcessing 前台服务

用户发起的数据传输

如果您需要执行可能耗时较长的数据传输,可以创建一个 JobScheduler 作业,并将其标识为用户发起的数据传输 (UIDT) 作业。

  • 调度用户发起的数据传输作业
  • 向后兼容性
    检查运行环境是否为 Android 14 或更高版本,在较低版本系统上,使用 WorkManager 的前台服务实现作为回退方案。
  • 停止 UIDT 作业
    • 由用户通过任务管理器停止
    • 由系统停止
  • 调度用户发起的数据传输作业所允许的条件
  • 用户发起的数据传输作业允许的约束

WorkManager 任务调度

關於任務調度

工作类型

  • 立即执行 (Immediate) 一次性
    OneTimeWorkRequest 和 Worker。 加急工作,请在 OneTimeWorkRequest 上调用 setExpedited()。
  • 长时间运行 (Long Running) 一次性或周期性
    任何 WorkRequest 或 Worker。在 Worker 中调用 setForeground() 以处理通知。
  • 可延期 (Deferrable) 一次性或周期性
    PeriodicWorkRequest 和 Worker。

WorkManager 的功能

  • 工作约束
  • 强大的调度功能
  • 加急工作
  • 灵活的重试策略
  • 工作链
  • 内置线程互操作性
    WorkManager 旨在用于即使在用户离开屏幕、应用退出或设备重启后仍需可靠运行的工作。
    WorkManager 不适用于如果应用进程消失即可安全终止的进程内后台工作。

入門指南

定义工作请求
WorkRequest抽象基类有两个派生:OneTimeWorkRequest 和 PeriodicWorkRequest

加速工作

setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)

如果您使用Worker,您必须实现getForegroundInfoAsync() 方法,以便 WorkManager 在必要时为您显示通知以启动 ForegroundService。

如果您使用 CoroutineWorker,则必须实现 getForegroundInfo(),然后,在 doWork() 内将其传递给 setForeground()。这样做会在 Android 12 之前的版本中创建通知。

您应该将 setForeground() 封装在 try/catch 块中,以捕获可能出现的 IllegalStateException。

前台工作
Worker 中的 getForegroundInfoAsync() 和 getForegroundInfo() 方法使 WorkManager 能够在您在 Android 12 之前调用 setExpedited() 时显示通知。

配额策略
OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST
OutOfQuotaPolicy.DROP_WORK_REQUEST

调度定期工作

使用 PeriodicWorkRequest 创建定期执行,灵活的运行间隔
约束对定期工作的影响:即使定义的重复间隔已过,PeriodicWorkRequest 也不会运行,直到满足此条件。

工作约束

  • NetworkType
  • BatteryNotLow
  • RequiresCharging
  • DeviceIdle
  • StorageNotLow
    如果在您的工作运行过程中某个约束变得不再满足,WorkManager 将停止您的 worker。当所有约束再次满足时,该工作将被重新尝试。

延迟工作

如果您的工作没有约束,或者当您的工作加入队列时所有约束都已满足,系统可能会选择立即运行该工作。如果您不希望立即运行该工作,则可以指定在最短初始延迟之后开始工作。

重试和退避策略

worker 返回 Result.retry()。然后,您的工作将根据退避延迟和退避策略重新调度。

  • 退避延迟:指定第一次尝试后重试工作之前等待的最短时间。该值不得少于 10 秒
  • 退避策略:定义了后续重试尝试的退避延迟应如何随时间增加。
    WorkManager 支持两种退避策略:LINEAR 和 EXPONENTIAL。

标记工作

每个工作请求都有一个唯一标识符,该标识符可用于稍后标识该工作,以便取消工作或观察其进度。

分配输入数据

工作可能需要输入数据才能执行。

操作指南

工作状态

BLOCKED(已阻塞)。此状态适用于以序列或工作链形式编排的工作。

  • 一次性工作状态:
    ENQUEUED、 RUNNING、SUCCEEDED(成功)、FAILED(失败)、 CANCELLED(已取消)
  • 定期工作状态
    对于定期工作,只有一个终止状态,即 CANCELLED

管理工作

加入隊列:WorkManager.getInstance(requireContext()).enqueue(myWork)

UniqueWork唯一工作

它确保一次只能有一个具有特定名称的工作实例。
WorkManager.enqueueUniqueWork() 用于一次性工作
WorkManager.enqueueUniquePeriodicWork() 用于定期工作

ExistingWorkPolicy冲突解决策略
一次性工作:

  • REPLACE: 用新工作替换现有工作。此选项将取消现有工作。
  • KEEP: 保留现有工作,并忽略新工作。
  • APPEND: 将新工作附加到现有工作的末尾。此政策将导致您的新工作链接到现有工作,在现有工作完成后运行。现有工作将成为新工作的先决条件。如果现有工作变为 CANCELLED 或 FAILED 状态,新工作也会变为 CANCELLED 或 FAILED。
  • APPEND_OR_REPLACE: 数类似于 APPEND,不过它并不依赖于先决条件工作状态。即使现有工作变为 CANCELLED 或 FAILED 状态,新工作仍会运行。

定期工作:REPLACE 和 KEEP

观察您的工作

getWorkInfoById、workManager.getWorkInfosForUniqueWork("sync") 、workManager.getWorkInfosByTag("syncTag")
每种方法的 LiveData 和 Flow 变体允许您通过注册监听器来观察 WorkInfo 的变化。
使用 WorkQuery 对象对加入队列的作业进行复杂查询。

取消和停止工作

cancelWorkById、cancelUniqueWork、cancelAllWorkByTag

停止正在运行的工作器

onStopped() 回调
isStopped() 属性

链式任务

WorkManager.getInstance(myContext)
   // Candidates to run in parallel
   .beginWith(listOf(plantName1, plantName2, plantName3))
   // Dependent work (only runs after all previous work in chain)
   .then(cache)
   .then(upload)
   // Call enqueue to kick things off
   .enqueue()

输入合并器 (Input Mergers)
OverwritingInputMerger 会尝试将所有输入中的所有键添加到输出中。如果发生冲突,它会覆盖之前设置的键。
ArrayCreatingInputMerger 会尝试合并输入,并在必要时创建数组。

val cache: OneTimeWorkRequest = OneTimeWorkRequestBuilder<PlantWorker>()
   .setInputMerger(ArrayCreatingInputMerger::class)
   .setConstraints(constraints)
   .build()

支持长时间运行的工作器

  • 创建和管理长时间运行的工作器,Kotlin 开发者应使用 CoroutineWorker,setForeground()
  • 在应用清单中声明前台服务类型
<service
   android:name="androidx.work.impl.foreground.SystemForegroundService"
   android:foregroundServiceType="location|microphone"
   tools:node="merge" />
  • 在运行时指定前台服务类型
private fun createForegroundInfo(progress: String): ForegroundInfo {
   // ...
   return ForegroundInfo(NOTIFICATION_ID, notification,
           FOREGROUND_SERVICE_TYPE_LOCATION or
FOREGROUND_SERVICE_TYPE_MICROPHONE) }

观察中间工作进度

  • setProgress() 扩展函数来更新进度信息
  • 观察进度信息,请使用 getWorkInfoById 方法,并获取对 WorkInfo 的引用。
  • 通过调用 WorkInfo.getStopReason() 来记录停止原因

更新已入列的任务

  • 避免取消任务
    应避免取消现有的 WorkRequest 并重新入列新的 WorkRequest。
    取消 WorkRequest 可能导致问题
  • 希望更改已入列任务的根本属性时,直接取消 WorkRequest 而不是调用 updateWork()
  • 更新任务
    updateWork() 方法提供了一种更新现有 WorkRequest 的简单方法,无需取消并重新入列新的请求。
  • 通过代际 (Generation) 跟踪任务
    每次更新 WorkRequest 时,其代际 (generation) 都会增加 1APPEND_OR_REPLACE
  • 更新任务的策略
    workManager.enqueueUniquePeriodicWork("", ExistingPeriodicWorkPolicy.UPDATE, myUploadRequest)

線程處理

WorkManager 中的线程和并发机制

Worker

最简单的实现

  • 注意,Worker.doWork() 是一个同步调用,您需要以阻塞方式完成全部后台工作,并在该方法退出时完成任务。
  • 可以手动初始化 WorkManager,自定义 Executor

CoroutineWorker

Kotlin 用户的推荐实现。

  • 注意,CoroutineWorker.doWork() 是一个挂起函数。与 Worker 不同,此代码不会在您 Configuration 中指定的 Executor 上运行。相反,它默认使用 Dispatchers.Default。通过提供自己的 CoroutineContext 来自定义此设置。
  • CoroutineWorker 通过取消协程并传播取消信号来自动处理停止。
  • 通过使用 RemoteCoroutineWorker将 worker 绑定到特定进程。

RxWorker

RxJava 用户的推荐实现。
提供了 WorkManager 与 RxJava 之间的互操作性。

ListenableWorker

  • Worker、CoroutineWorker 和 RxWorker 的基类。它适用于需要与基于回调的异步 API(例如 FusedLocationProviderClient)交互且不使用 RxJava 的 Java 开发人员。
  • 需要提供自定义的线程处理策略。例如,您可能需要处理基于回调的异步操作。WorkManager 通过 ListenableWorker 支持此用例。
  • ListenableWorker 仅负责发出工作开始和停止的信号,而将线程处理完全留给您自己决定。由于开始工作的信号是在主线程上调用的,因此您必须手动切换到您选择的后台线程.
  • 监听取消:工作预期停止时,ListenableWorker 的 ListenableFuture 总是会被取消。使用 CallbackToFutureAdapter 时,您只需添加一个取消监听器。
  • 通过使用 RemoteListenableWorker(ListenableWorker 的一种实现)将 Worker 绑定到特定进程。

自定义 WorkManager 配置和初始化

  1. 移除默认初始化程序
  2. 实现 Configuration.Provider
  3. 实现 Configuration.Provider.getWorkManagerConfiguration()
  4. WorkManager.getInstance(Context) 方法獲取實例

調試和測試

调试 WorkManager

  • 启用日志记录
  • 使用 adb shell dumpsys jobscheduler
  • 从 WorkManager 2.4.0+ 请求诊断信息

集成测试

單元测试

协程

特点:

  • 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。
  • 内存泄漏更少:使用 结构化并发机制 在一个作用域内执行多项操作。
  • 内置取消支持: 取消操作 会自动在运行中的整个协程层次结构内传播。
  • Jetpack 集成:许多 Jetpack 库都包含 提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。

管理长时间运行的任务

协程构建器(例如 launch)

Kotlin 使用 堆栈帧 管理要运行哪个函数以及所有局部变量。
挂起协程时,系统会复制并保存当前的 堆栈帧 以供稍后使用。
恢复时,会将堆栈帧从其保存位置复制回来,然后函数再次开始运行。
即使代码可能看起来像普通的顺序阻塞请求,协程也能确保网络请求避免阻塞主线程。

三个协程调度程序 CoroutineDispatcher

  • Dispatchers.Main
    使用此调度程序可在 Android 主线程上运行协程。此调度程序只能用于与界面交互和执行快速工作。示例包括调用 suspend 函数,运行 Android 界面框架操作,以及更新 LiveData 对象。
  • Dispatchers.IO
    此调度程序经过了专门优化,适合在主线程之外执行磁盘或网络 I/O。示例包括使用 Room 组件、从文件中读取数据或向文件中写入数据,以及运行任何网络操作。
  • Dispatchers.Default
    此调度程序经过了专门优化,适合在主线程之外执行占用大量 CPU 资源的工作。用例示例包括对列表排序和解析 JSON。

withContext()

withContext() 可让您在不引入回调的情况下控制任何代码行的线程池,因此您可以将其应用于非常小的函数,例如从数据库中读取数据或执行网络请求。一种不错的做法是使用 withContext() 来确保每个函数都是主线程安全的,这意味着,您可以从主线程调用每个函数。这样,调用方就从不需要考虑应该使用哪个线程来执行函数了。

利用一个使用线程池的调度程序(例如 Dispatchers.IO 或 Dispatchers.Default)不能保证块在同一线程上从上到下执行。在某些情况下,Kotlin 协程在 suspend 和 resume 后可能会将执行工作移交给另一个线程。这意味着,对于整个 withContext() 块,线程局部变量可能并不指向同一个值。

启动新协程两种方式

  • launch 可启动新协程而不将结果返回给调用方。任何被视为“一劳永逸”的工作都可以使用 launch 来启动。
  • async 会启动一个新的协程,并允许您使用一个名为 await 的挂起函数返回结果。

協程作用域CoroutineScope

CoroutineScope 会跟踪它使用 launch 或 async 创建的所有协程。您可以随时调用 scope.cancel() 以取消正在进行的工作(即正在运行的协程),例如,ViewModel 有 viewModelScope。不过,与调度程序不同,CoroutineScope 不运行协程。

創建協程作用域

  • CoroutineScope(Dispatchers.IO)
  • coroutineScope { }
    A failure in one child coroutine cancels the parent scope and all other siblings.
  • supervisorScope { }
    A failure in one child coroutine does not affect the parent scope or other siblings.

自定義協程作用域
val scope = CoroutineScope(Job() + Dispatchers.Main)

Job

Job 是协程的句柄。使用 launch 或 async 创建的每个协程都会返回一个 Job 实例,该实例是相应协程的唯一标识并管理其生命周期。您还可以将 Job 传递给 CoroutineScope 以进一步管理其生命周期,

CoroutineContext 使用以下元素集定义协程的行为:

  • Job:控制协程的生命周期。
  • CoroutineDispatcher:将工作分派到适当的线程。
  • CoroutineName:协程的名称,可用于调试。
  • CoroutineExceptionHandler:处理未捕获的异常。

对于在作用域内创建的新协程,系统会为新协程分配一个新的 Job 实例,而从包含作用域继承其他 CoroutineContext 元素。
请注意,将 Job 传递给 launch 或 async 不会产生任何效果,因为系统始终会向新协程分配 Job 的新实例。

测试 Kotlin 协程

runTest
一个测试中只能使用一个调度器实例,且所有 TestDispatchers 应共用该调度器。
runTest 会创建一个 TestScope,它是 CoroutineScope 的实现,将始终使用 TestDispatcher。如果未指定,TestScope 将默认创建 StandardTestDispatcher,

TestDispatcher 有两种可用的实现:StandardTestDispatcher 和 UnconfinedTestDispatcher

StandardTestDispatcher

如果新协程是在 StandardTestDispatcher 上启动的,则这些协程会在底层调度器上排队,以便在测试线程可供使用时运行。

让出测试协程方式

  • advanceUntilIdle:在调度器上运行所有其他协程,直到队列中没有任何内容。这是一个不错的默认选择,可让所有待处理的协程运行,适用于大多数测试场景。
  • advanceTimeBy:将虚拟时间提前指定时长,并运行已调度为在该虚拟时间点之前运行的所有协程。
  • runCurrent:运行已调度为在当前虚拟时间运行的协程。

UnconfinedTestDispatcher

如果新协程是在 UnconfinedTestDispatcher 上启动的,则这些协程会在当前线程上快速启动。

注入测试调度程序

在测试中,将实际调度程序替换为 TestDispatchers 的实例,以确保所有代码均在单个测试线程上运行。

设置主调度程序

在测试之外创建调度器

创建您自己的 TestScope

注入作用域

协程的最佳实践

  • 注入调度程序
    在创建新协程或调用 withContext 时,请勿对 Dispatchers 进行硬编码。

  • 挂起函数应该能够安全地从主线程调用
    如果某个类在协程中执行长期运行的阻塞操作,那么该类负责使用 withContext 将执行操作移出主线程。

  • 应在ViewModel中创建协程
    ViewModel 类应首选创建协程,而不是公开挂起函数来执行业务逻辑。

  • 不要公开可变类型
    最好向其他类公开不可变类型。这样一来,对可变类型的所有更改都会集中在一个类中,便于在出现问题时进行调试。

  • 数据层和业务层应公开挂起函数和数据流

    • 如果仅当用户查看当前屏幕时,要在这些协程中完成的工作才具有相关性,则应遵循调用方的生命周期。在大多数情况下,调用方是 ViewModel,当用户离开屏幕并且 ViewModel 被清除时,调用将被取消。在这种情况下,应使用 coroutineScope 或 supervisorScope。
    • 如果只要应用处于打开状态,要完成的工作就具有相关性,并且此工作不限于特定屏幕,那么此工作的存在时间应该比调用方的生命周期更长。对于这种情况,您应使用外部 CoroutineScope。外部 CoroutineScope 应由存在时间比当前屏幕更长的类进行创建和管理,并且可由 Application 类或作用域限定为导航图的 ViewModel 进行管理。
  • 避免使用 GlobalScope

  • 将协程设为可取消

  • 留意异常

    • 请在使用 viewModelScope 或 lifecycleScope 创建的任何协程主体中捕获相应异常。
    • 首选捕获特定类型的异常(如 IOException),而不是 Exception 或 Throwable 等一般类型。

Kotlin 数据流

数据流包含三个实体

  • 提供方:会生成添加到数据流中的数据。得益于协程,数据流还可以异步生成数据。
  • 中介(可选):可以修改发送到数据流的值,或修正数据流本身。
  • 使用方:则使用数据流中的值。

创建数据流

flow 构建器函数创建新数据流,使用 emit 函数手动将新值发送到数据流中。

  • 数据流是有序的。
  • 使用 flow 构建器时,提供方不能提供来自不同 CoroutineContext 的 emit 值。

修改数据流

中介可以利用中间运算符在不使用值的情况下修改数据流。

从数据流中进行收集

使用终端运算符可触发数据流开始监听值。

除非使用其他中间运算符指定流,否则数据流始终为冷数据并延迟执行。
这意味着,每次在数据流上调用终端运算符时,都会执行提供方代码。
在前面的示例中,拥有多个数据流收集器会导致数据源以不同的固定时间间隔多次获取最新资讯。如需在多个使用方同时收集时优化并共享数据流,请使用 shareIn 运算符。

数据流捕获异常

使用 catch 中间运算符。

在不同 CoroutineContext 中执行

如需更改数据流的 CoroutineContext,请使用中间运算符 flowOn。
flowOn 会更改上游数据流的 CoroutineContext,这表示会在 flowOn 之前(或之上)应用提供方以及任何中间运算符。下游数据流(晚于 flowOn 的中间运算符和使用方)不会受到影响,并会在 CoroutineContext 上执行以从数据流执行 collect 操作。如果有多个 flowOn 运算符,每个运算符都会更改当前位置的上游数据流。

Jetpack 库中的数据流

Flow with Room

将基于回调的 API 转换为数据流

callbackFlow 是一个数据流构建器,允许您将基于回调的 API 转换为数据流。

测试 Kotlin 数据流

创建虚构数据提供方

测试 StateFlow

StateFlow 和 SharedFlow

允许数据流以最优方式发出状态更新并向多个使用方发出值。
利用 shareIn 使冷数据流(Flow)变为热数据流

  • StateFlow
    是一个状态容器式可观察数据流,可以向其收集器发出当前状态更新和新状态更新。
    如果需要更新界面,切勿使用 launch 或 launchIn 扩展函数从界面直接收集数据流。即使 View 不可见,这些函数也会处理事件。此行为可能会导致应用崩溃。 为避免这种情况,请使用 repeatOnLifecycle API
  • SharedFlow
    SharedFlow 是 StateFlow 的可配置性极高的泛化数据流。
posted @ 2026-07-06 21:06  jokerpoker  阅读(9)  评论(0)    收藏  举报