InfoQ

Article: Beyond CLEAN and MVP: Architecting an Offline-first Reactive Data Layer in Android

8.5内容质量
Article: Beyond CLEAN and MVP: Architecting an Offline-first Reactive Data Layer in Android

TL;DR · AI 摘要

RDLA 架构通过分离数据定义与实现,优化了 Android 离线优先的响应式数据层设计。

核心要点

  • RDLA 架构通过分离数据定义与实现,减少了传统 Clean 架构的冗余代码。
  • 使用 Kotlin Flow 冷流实现 UI 层的单向响应式数据总线。
  • 通过 Asynchronous Mutation Queue 实现离线数据修改的即时 UI 更新。

结构提纲

按章节快速跳转。

  1. 移动应用需要在不可预测的环境中运行,传统架构难以满足离线优先和实时更新的需求。

  2. ·RDLA 架构概述

    RDLA 是一种为移动平台优化的架构,旨在连接响应式 UI 框架与存储限制。

  3. RDLA 通过分离数据定义与实现,减少冗余代码并提升开发效率。

  4. 使用 Kotlin Flow 冷流构建单向响应式数据总线,实现 UI 层的实时更新。

  5. 通过异步突变队列实现离线数据修改的即时 UI 更新与后台同步。

  6. 通过 TestExtensions 接口实现单元测试,验证数据库回退逻辑。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • RDLA 架构
    • 核心机制
      • 分离数据定义与实现
      • 减少冗余代码
    • 实现方式
      • Kotlin Flow 冷流
      • Asynchronous Mutation Queue
    • 测试与验证
      • TestExtensions 接口
      • Robolectric 单元测试

金句 / Highlights

值得收藏与分享的关键句。

#Android#Jetpack Compose#Kotlin Flow#RDLA#响应式架构
打开原文

超越 CLEAN 和 MVP:在 Android 中构建以离线优先的响应式数据层 - InfoQ

InfoQ 首页 文章 超越 CLEAN 和 MVP:在 Android 中构建以离线优先的响应式数据层

移动

InfoQ AI 工程师认证(7月25日):AI 演示已经成功。现在你必须让它变得可靠。

超越 CLEAN 和 MVP:在 Android 中构建以离线优先的响应式数据层

6月24日,2026年 16分钟阅读

作者:

  • Mervyn Anthony

审阅者:

  • Sergio De Simone

#### 为 InfoQ 撰写文章

激发你的好奇心。

每月帮助55万名全球

高级开发人员

保持领先。

联系我们

收听这篇文章 -

0:00

音频准备播放

你的浏览器不支持音频元素。

正常

1.25x

1.5x

喜欢

新下拉阅读列表

  • 阅读列表

关键要点

  • 响应式数据层架构(RDLA)通过强制公共 API 数据定义与私有实现来源之间的严格分离,消除了传统 Clean 架构的“样板税”。
  • 开发人员可以通过使用冷 Kotlin Flow 流来创建一个单向的、响应式的数据总线,从而绕过基于拉取的 MVP 模式限制,用于 UI 层。
  • 对于以离线优先的医疗物联网,将用户修改写入本地异步突变队列,可以实现 UI 的即时更新,同时将实际的网络同步放在后台进行。
  • 通过 Android Jetpack WorkManager 将网络调用约束与 UI 解耦,即使用户终止了应用程序,也能确保关键数据负载成功。
  • 实现一个内部的 TestExtensions 接口,允许工程师使用 Robolectric 运行解耦的单元测试,验证数据库回退逻辑,而无需依赖脆弱的 SQLite 模拟。

前言

移动应用程序在高度不可预测的环境中运行。用户期望应用程序能够立即加载、离线工作、实时更新,并在间歇性蜂窝连接的情况下无缝保存数据。

虽然 Model-View-Presenter(MVP)和 CLEAN 架构等模式为关注点分离提供了良好的起点,但当应用于移动平台独特的响应式需求时,它们常常不足或引入不必要的样板代码。

本文介绍了响应式数据层架构(RDLA)——一种具体且针对移动优化的模式,专门设计用于弥合响应式 UI 框架(如 Jetpack Compose)与移动存储限制之间的差距。通过对齐这两个边界,RDLA 使开发人员能够构建强大、以离线优先和响应式的数据层。

#### 相关赞助商

  • 为什么 API 不能信任客户端——以及如何弥合这一差距

#### 相关赞助商

测试。保护。重复。Guardsquare 将移动应用测试和保护相结合,提供最大安全性,而不会牺牲性能。请求报价。

与基于稳定网络协议的传统 REST API 架构不同,移动数据采集经常涉及硬件 API(如蓝牙低功耗),这些 API 依赖于跨 Binder 线程执行的深度嵌套、异步回调。如果没有可靠的架构来序列化操作,并将本地缓存视为唯一的真实来源,这些系统会迅速退化为状态同步错误和连接不稳定的问题。例如,基于蓝牙低功耗设备的应用程序会触发臭名昭著的“GATT 竞争条件”,导致底层蓝牙控制器以错误的顺序处理命令,甚至完全丢弃命令(通常导致未良好记录的 GATT 状态 133 或 129 错误)。

RDLA 通过将本地缓存视为确定性的 UI 缓冲区,并利用 Kotlin 协程和 suspendCancellableCoroutine 桥接来序列化物理硬件操作,解决了这些挑战。这将混乱的多线程异步事件转化为确定性的同步数据流。

借鉴为高度监管的消费类医疗设备开发的架构模式,我们将使用一个健康指标跟踪系统(具体跟踪心率记录)来探索 RDLA 的拓扑结构,将其与传统模式进行比较,并演示其在 Kotlin 中的实现。

传统模式的局限性

在深入探讨 RDLA 之前,让我们分析为什么传统模式在现代 Android 开发中可能不足。

1. MVP:基于拉取的瓶颈

在经典的模型-视图-演示者(MVP)模式中,通信是基于过程和拉取的:

  • 演示者向模型请求数据。
  • 模型获取数据并通过回调返回。
  • 演示者将数据推送到视图。

这适用于简单的应用程序,但在响应式编程环境中会失败。如果后台同步工作线程更新了数据库,演示者除非轮询数据库或依赖复杂的事件总线,否则不会知道这一变化。MVP 缺乏一种原生机制,无法自动向下传播状态变化。

2. CLEAN 架构:移动开发中的不匹配

CLEAN 架构非常适合将业务逻辑与框架分离。然而,当未加修改地应用于移动开发时,它引入了两个明显的问题:

  • 模板税(通过型用例):对于简单的读取操作,经典的 CLEAN 强制你创建一个仅调用仓库方法的用例类。在数据库密集型应用中,有数十张表时,这会导致大量琐碎的“通过型”类,增加维护开销,但并未增加商业价值。
  • 平台无关性与移动现实之间的冲突:CLEAN 被设计为与数据库和框架无关。虽然这在企业后端系统中运行良好,但并未解决移动特定的限制。它对处理本地-远程数据同步、离线状态传播或 SQLite 性能限制(如数据库编译边界)没有提供任何指导。
  • 本地缓存作为唯一数据源:UI 仅从本地数据库读取数据,网络仅用于填充该数据库。
  • 封装缓存与同步:检查缓存过期、合并本地编辑和触发后台获取的逻辑完全隐藏在 Repository 的实现中。

架构拓扑

RDLA 将你的数据包拆分为三个独立的模块:API、实现(Implementation)和数据库(共享存储)。

图 1:RDLA 架构拓扑和模块边界(图片来源:作者创建)

[点击此处查看上方图片的全尺寸版本]

RDLA 在架构中的位置:与 Clean Architecture 和 MVVM 的契合

RDLA 并不是 MVVM 或 Clean Architecture 的替代方案。相反,它是数据层(以及部分领域层)的一个专门化、以移动端优先的实现,它可以与它们集成。它优化了这些模式之间的接口,以消除常见的移动端特定痛点。

#### RDLA 如何与 Clean Architecture 集成

Clean Architecture 专注于依赖规则:代码依赖只能指向内层,即核心业务逻辑(实体和用例)。RDLA 严格遵守这一规则,但针对移动端的限制进行了优化实现:

图 2:RDLA 模块与 Clean Architecture 层的映射(图片来源:作者创建)

  • API 模块(实体):RDLA 的 API 模块直接对应 Clean 的最内层实体层。它只包含纯 Kotlin 数据模型(如 HeartRateRecord)和 Repository 接口。它没有任何平台、数据库或网络依赖。
  • Repository 实现(用例):在经典的 Clean 实现中,开发者通常为每个数据库读取操作编写一个用例类(例如 GetHeartRateRecordsUseCase)。RDLA 通过允许表示层直接观察 Repository 的响应式流,消除了简单 CRUD 操作的冗余代码。然而,对于涉及多个领域(例如基于睡眠和心肺记录计算心率变异性)的复杂业务逻辑,你仍然需要创建一个标准的 Clean 用例类,该类依赖于 RDLA 的 Repository API。
  • 数据源接口(接口适配器):私有的 LocalDataSource 和 RemoteDataSource 接口位于实现模块中,作为保护 Repository 免受具体数据库和网络引擎影响的边界(Clean 的接口适配器)。
  • Room 数据库 & Retrofit 客户端(框架与驱动):具体的实现(RoomLocalDataSource、RetrofitRemoteDataSource)位于独立的数据库和网络模块中。框架细节(如 Room 注解或序列化库)完全封装在这一外层边界中。

#### RDLA 如何驱动 MVVM(单向数据流)

在传统的 MVVM 中,ViewModel 通常充当一个主动的管理者,从仓库中拉取数据并管理其生命周期。这种以命令方式管理的数据流容易出现同步错误。

RDLA 通过将 Model 转换为响应式数据总线,改变了这一点,从而实现严格的单向数据流(UDF):

图 3:RDLA 中的单向数据流(UDF)响应式循环(图片来源:作者创建)

  • ViewModel 作为转换器,而非同步器:在 RDLA 中,ViewModel 是一个被动的转换器,而不是通过启动协程按需获取数据并手动更新状态持有者。它观察仓库的 Flow,并使用 stateIn 操作符直接将其转换为 UI 可消费的 StateFlow。
  • 自动 UI 同步:当后台同步工作者或离线修改更新 Room 数据库时,数据库会自动发出新的数据集。这一变化会直接通过仓库和 ViewModel 传播到 UI。ViewModel 无需轮询或协调刷新。
  • 清晰的状态分离:RDLA 允许 ViewModel 清晰地将持久状态(通过由 Room 支持的 StateFlow 处理)与短暂事件(通过 SharedFlow 处理一次性通知,如连接中断或错误)分开。

RDLA 实战:健康指标跟踪系统

为了说明这种架构,我们将构建一个用于跟踪心率测量值的数据层。

1. API 模块(公共)

API 模块是唯一对 UI 层可见的包。它包含纯领域模型和仓库接口。

#### 领域模型(HeartRateRecord.kt)

这是一个标准的 Kotlin 数据类,没有任何数据库或序列化注解。

code
package com.example.healthtracker.data.heartrate.api.model

import java.time.Instant

data class HeartRateRecord(
    val id: String,
    val bpm: Int,
    val timestamp: Instant
)

#### 仓库接口(HeartRateRepository.kt)

该接口定义了公共契约。它暴露了冷 Flow 流。

code
package com.example.healthtracker.data.heartrate.api

import com.example.healthtracker.data.heartrate.api.model.HeartRateRecord
import kotlinx.coroutines.flow.Flow
import java.time.Instant

interface HeartRateRepository {
    /**
     * 返回心率记录的响应式流。
     * 如果本地缓存过期,将在后台触发网络刷新。
     */
    fun observeHeartRates(start: Instant, end: Instant): Flow<List<HeartRateRecord>>

    /**
     * 上传新的心率记录。直到服务器确认后才会挂起。
     */
    suspend fun upsertHeartRates(records: List<HeartRateRecord>)
}

2. 实现模块(私有)

实现模块中的所有内容都标记为内部,以防止泄露。UI 层永远不能直接访问这些类。

#### 缓存包装器(Cached.kt)

为了在不污染我们的领域模型的情况下管理缓存过期,我们在实现层中将模型包装在 Cached 容器中:

code
package com.example.healthtracker.data.core.caching

import java.time.Instant

data class Cached<out T>(
    val value: T,
    val insertionTime: Instant
)

#### 仓库协调器(HeartRateFetchAndStoreRepository.kt)

此类协调本地和远程数据源。它检查缓存过期并触发后台获取。

code
package com.example.healthtracker.data.heartrate.impl

import com.example.healthtracker.data.heartrate.api.HeartRateRepository
import com.example.healthtracker.data.heartrate.api.model.HeartRateRecord
import com.example.healthtracker.data.heartrate.impl.local.HeartRateLocalDataSource
import com.example.healthtracker.data.heartrate.impl.remote.HeartRateRemoteDataSource
import com.example.healthtracker.data.core.caching.Cached
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import java.time.Duration
import java.time.Instant
import kotlin.coroutines.CoroutineContext

internal class HeartRateFetchAndStoreRepository(
    private val localDS: HeartRateLocalDataSource,
    private val remoteDS: HeartRateRemoteDataSource,
    private val appScope: CoroutineScope,
    private val lightweightContext: CoroutineContext,
    private val cacheTtl: Duration = Duration.ofMinutes(10)
) : HeartRateRepository {

    override fun observeHeartRates(start: Instant, end: Instant): Flow<List<HeartRateRecord>> {
        return localDS.readHeartRates(start, end)
            .onStart {
                // Asynchronous background refresh execution
                appScope.launch { triggerRefreshIfNeeded(start, end) }
            }
            .map { cachedList -> cachedList.map { it.value } }
            .flowOn(lightweightContext)
    }

    private suspend fun triggerRefreshIfNeeded(start: Instant, end: Instant) {
        val cachedData = localDS.readHeartRatesOnce(start, end)
        if (cachedData.isEmpty() || isStale(cachedData)) {
            try {
                val remoteData = remoteDS.fetchHeartRates(start, end)
                localDS.writeHeartRates(remoteData)
            } catch (e: Exception) {
                // Fail silently; UI continues displaying cachedData
            }
        }
    }

    private fun isStale(data: List<Cached<HeartRateRecord>>): Boolean {
        val oldestAllowed = Instant.now().minus(cacheTtl)
        return data.any { it.insertionTime.isBefore(oldestAllowed) }
    }

    override suspend fun upsertHeartRates(records: List<HeartRateRecord>) {
        // Synchronous Mutation: Server write must succeed before updating DB
        val serverConfirmed = remoteDS.uploadHeartRates(records)
        localDS.writeHeartRates(serverConfirmed)
    }
}

通过在应用作用域的协程作用域(appScope)中运行 triggerRefreshIfNeeded,我们确保即使用户离开当前屏幕(这会取消 ViewModel 的作用域),数据库更新也能成功完成。

3. 本地存储模块(Room)

在实际的移动应用中,相关功能通常会共享一个数据库。RDLA 引入了事务组来处理这种情况。例如,心率和血压数据被归入 Cardio 事务组,共享一个 Room 数据库实例。

图4:Cardio 事务组下 Room 本地存储模块的包层次结构(图片来源:作者创建)

#### 本地数据源接口(HeartRateLocalDataSource.kt)

code
interface HeartRateLocalDataSource {
    fun readHeartRates(start: Instant, end: Instant): Flow<List<Cached<HeartRateRecord>>>
    suspend fun readHeartRatesOnce(start: Instant, end: Instant): List<Cached<HeartRateRecord>>
    suspend fun writeHeartRates(records: List<HeartRateRecord>)
}

数据库实体(HeartRateEntity.kt)
@Entity(tableName = "heart_rate_records")
data class HeartRateEntity(
    @PrimaryKey val id: String,
    val bpm: Int,
    val timestamp: Instant,
    val insertionTime: Instant
) {
    fun toModel() = HeartRateRecord(id = id, bpm = bpm, timestamp = timestamp)
}

#### Room 实现(HeartRateRoomDataSource.kt)

code
internal class HeartRateRoomDataSource(
    private val dao: HeartRateDao,
    private val lightweightContext: CoroutineContext
) : HeartRateLocalDataSource {

    override fun readHeartRates(start: Instant, end: Instant): Flow<List<Cached<HeartRateRecord>>> {
        return dao.observeHeartRates(start, end)
            .distinctUntilChanged() // 防止 Room 重复发送数据
            .map { entities ->
                entities.map { Cached(it.toModel(), it.insertionTime) }
            }
            .flowOn(lightweightContext)
    }

    override suspend fun readHeartRatesOnce(start: Instant, end: Instant): List<Cached<HeartRateRecord>> {
        return dao.getHeartRates(start, end).map { Cached(it.toModel(), it.insertionTime) }
    }

    override suspend fun writeHeartRates(records: List<HeartRateRecord>) {
        val entities = records.map { 
            HeartRateEntity(it.id, it.bpm, it.timestamp, Instant.now()) 
        }
        dao.insertOrUpdate(entities)
    }
}

提示:我们对 Room 的流应用了 distinctUntilChanged()。因为 Room 触发的是表级别的观察,所以对表的任何更新都会触发一次发送,即使查询的子集没有变化。distinctUntilChanged() 会过滤掉这些重复的事件。

4. UI 层(Compose)的使用

一个响应式数据层的强大程度取决于使用它的展示层。为了有效地将 RDLA 与 Jetpack Compose 连接起来,UI 层会使用 StateFlow 将底层的数据流聚合为一个统一的状态表示。这确保了 UI 能够准确地反映持久状态(如已连接、正在扫描或已断开连接),无论应用程序的生命周期如何。相反,短暂事件(如本地同步回滚或蓝牙连接丢失)会绕过永久状态。这些事件通过一个高度可配置的 SharedFlow 传递到 UI,该 SharedFlow 带有重播缓存,确保关键的一次性警报即使在 UI 由于方向变化而临时被销毁时也能被保留并立即传递。

code
@HiltViewModel
class HeartRateViewModel @Inject constructor(
    private val repository: HeartRateRepository
) : ViewModel() {

    // 持久状态流
    val uiState: StateFlow<UiState> = repository
        .observeHeartRates(Instant.now().minus(1, ChronoUnit.DAYS), Instant.now())
        .map { records -> UiState.Success(records) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = UiState.Loading
        )

    // 短暂事件流
    private val _faultEvents = MutableSharedFlow<FaultEvent>(
        replay = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val faultEvents = _faultEvents.asSharedFlow()
}

通过无缝集成 StateFlow 用于持续状态,以及 SharedFlow 用于易变的硬件事件,ViewModel 为 Compose 提供了一个坚不可摧、响应式的数据桥梁,确保 UI 永远不会显示过时或损坏的状态。

为离线修改进行设计

修改是由用户发起的更新。根据用户体验需求,修改可以同步或异步处理。

1. 同步修改

同步修改要求用户在线。如果网络请求失败,本地数据库保持不变,并立即向用户提示错误(例如尝试删除医疗日志)。

2. 异步修改(优先离线)

对于标准日志(例如在锻炼期间记录心率 BPM),即使离线,写入操作也必须立即成功。为了实现这一点,修改会被写入本地数据库队列,实时合并到活跃的 UI 数据流中,并在后台进行同步。

图 5:异步修改同步管道和操作系统后台委托(图片来源:作者创作)

#### 在本地数据源中实现修改合并

code
override fun readHeartRates(start: Instant, end: Instant): Flow<List<Cached<HeartRateRecord>>> {
    val savedRecordsFlow = dao.observeHeartRates(start, end)
    val pendingMutationsFlow = mutationDao.observePendingAddMutations()

    return savedRecordsFlow.combine(pendingMutationsFlow) { saved, mutations ->
        val mergedList = saved.map { Cached(it.toModel(), it.insertionTime) }.toMutableList()
        
        mutations.forEach { mutation ->
            if (mutation.timestamp in start..end) {
                // 在列表上叠加本地待处理的修改
                mergedList.removeAll { it.value.id == mutation.localId }
                mergedList.add(
                    Cached(
                        value = HeartRateRecord(mutation.localId, mutation.bpm, mutation.timestamp),
                        insertionTime = mutation.localCreationTime
                    )
                )
            }
        }
        mergedList.sortedByDescending { it.value.timestamp }
    }.flowOn(lightweightContext)
}

RDLA 明确地在应用作用域的协程和 Android Jetpack WorkManager 之间划分了后台执行的责任。对于即时的同步数据处理(例如将传入的健康指标保存到 Room 数据库),架构会在应用作用域的 CoroutineContext 中启动协程。这确保了即使用户迅速导航离开并取消了即时的 ViewModel 作用域,轻量级的本地数据库写入操作仍能成功完成。

然而,对于连接本地数据库与远程云服务的异步修改(或通过 BLE 推送多兆字节的 OTA 固件负载),应用作用域是不够的。Android 强大的电源管理功能(Doze 模式)和进程终止机制可能会在执行过程中终止应用程序。对于受 FDA 监管的健康指标,数据丢失是绝对不可接受的。通过 WorkManager 排队异步修改,同步请求将直接委托给操作系统服务。这确保了关键负载在严格的系统约束下执行(例如需要非计费 Wi-Fi 或足够的电池电量),并且如果连接中断,将可靠地从最后一个已知的内存偏移位置恢复。

背景同步通过 Android WorkManager 管理,使用 Hilt 注入的 CoroutineWorker。通过将网络调用约束与 UI 解耦,即使用户关闭应用,请求也能确保成功。

#### 冲突解决与回滚

乐观的本地更新提供了无缝的用户体验,但它们本质上容易引发冲突。当 WorkManager 同步工作器尝试将本地变更队列上传到服务器时,始终存在远程拒绝的风险,例如,如果记录被另一个授权设备并发修改,可能会收到 HTTP 409 冲突;如果数据未通过临床验证,可能会收到 HTTP 422 无法处理的实体。

在 RDLA 中,架构必须优雅地处理这些回滚,而不会破坏本地单一数据源的准确性。当远程 API 抛出 HTTP 异常时,工作器会捕获失败,并将本地变更实体在 Room 中标记为 FAILED,而不是启动无限重试循环。

中央仓库监控同步过程的健康状况,通过 SharedFlow 将此失败状态作为临时事件发送到 UI 层。同时,本地数据源执行数据库事务,将被拒绝的变更从队列中清除。由于 UI 层正在反应式地收集合并后的流,清除待处理变更会强制 Room 触发新的数据发射。distinctUntilChanged() 过滤器传递此更新状态,UI 会立即自动恢复到最后已知的、服务器确认的状态。

为什么 RDLA 让测试变得轻松

RDLA 最大的优势之一是它简化了单元测试。通过隔离 Room 数据库和 Retrofit 网络配置,你可以直接测试仓库逻辑。

TestExtensions 模式

由于仓库接口不应向客户端暴露数据插入方法(以防止 UI 直接修改同步状态),我们在 API 目标中引入了一个仅用于测试的接口:

图 6:TestExtensions 种子模式和作用域边界分离(图片来源:作者创建)

#### 1. 定义 Test Extensions 接口(位于 API 模块中)

code
@VisibleForTesting
interface HeartRateRepositoryTestExtensions {
    suspend fun seedLocalHeartRates(records: List<HeartRateRecord>)
    suspend fun clearLocalCache()
}

#### 2. 在仓库中实现接口(位于 Impl 模块中)

code
internal class HeartRateFetchAndStoreRepository(
    private val localDS: HeartRateLocalDataSource,
    // ...
) : HeartRateRepository, HeartRateRepositoryTestExtensions {
    
    // 仓库实现...

    override suspend fun seedLocalHeartRates(records: List<HeartRateRecord>) {
        localDS.writeHeartRates(records)
    }

    override suspend fun clearLocalCache() {
        localDS.clearAll()
    }
}

#### 3. 编写解耦的单元测试(使用 Robolectric)

code
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
@Config(application = HiltTestApplication::class)
class HeartRateRepositoryTest {

    @get:Rule val hiltRule = HiltAndroidRule(this)

    @Inject lateinit var repository: HeartRateRepository
    @Inject lateinit var testExtensions: HeartRateRepositoryTestExtensions

    @Before
    fun setUp() {
        hiltRule.inject()
    }

    @Test
    fun observeHeartRates_emitsSeededData() = runTest {
        val now = Instant.now()
        val records = listOf(HeartRateRecord("1", 72, now))
        

        // 通过测试扩展直接向数据库添加数据
        testExtensions.seedLocalHeartRates(records)

        val flow = repository.observeHeartRates(now.minusSeconds(60), now.plusSeconds(60))
        

        assertThat(flow.first()).containsExactlyElementsIn(records)
    }
}

核心测试优势

  • 无需模拟 SQLite:使用 Robolectric 和真实的 Room 数据库可以确保在测试时完全验证 SQL 查询。
  • 强大的离线验证:通过注入 FakeHeartRateRemoteDataSource,您可以配置网络故障,并断言仓库的回退逻辑可以优雅地处理离线状态。
  • 解耦数据库重构:如果您重构 SQLite 的架构(例如添加数据库字段),只要 Local Data Source 中的映射逻辑正确地将数据库实体转换为领域模型,您的仓库测试就不会中断。

结论

构建一个响应迅速、以离线优先的 Android 应用程序需要一个为响应式设计的数据层。通过应用响应式数据层架构(RDLA),您可以在公共数据 API 合约和私有、特定于框架的数据源实现之间建立一个清晰的边界。因此,您的展示层(ViewModels/Presenters)可以以纯粹的响应式方式运行,观察数据变化而不是按顺序查询它们。此外,RDLA 通过鼓励您面向接口编程并使用干净的种子模式(如 TestExtensions)来简化测试。

最终,转向 RDLA 为您的代码库提供了所需的结构,使其能够干净地扩展、优雅地处理同步挑战,并支持现代用户期望的丰富、离线优先的体验。

main wrapper for authors section

作者简介

section title

main wrapper for each author

#### Mervyn Anthony

显示更多

显示更少

#### 本内容属于移动主题

##### 相关主题:

  • 开发
  • 架构与设计
  • Android
  • 操作系统
  • 响应式编程
  • 清洁架构
  • 异步架构
  • 架构
  • 移动
  • MVP
  • 相关编辑
  • InfoQ 上的热门文章

AWS 推出 Blocks,一个专为 AI 代理构建后端的开源 TypeScript 框架 Anthropic 报告 Claude 现在处理了 95% 的内部分析查询 TSRX:一个与框架无关的 JSX 替代方案 .NET 11 预览版 5:引入基于文件的应用改进、新的 C# 特性以及一个 Blazor 验证波 Java 新闻速递:Spring 工具、Helidon、Open Liberty、TomEE、JobRunr、Hibernate、Commonhaus 如何通过 eBPF 让开发者在不干扰 Linux 内核的情况下安全地进行观察

InfoQ 新闻简报

每周二发送 InfoQ 上一周内容的汇总。加入超过 250,000 名高级开发者的社区。查看示例

我们保护您的隐私。