关于时间分配:
1/3 用于计划
1/6 用于编码
1/4 用于构件测试和早期系统测试
1/4 用于系统测试,此时所有的构件已就绪
——《人月神话》
数据层所处的位置与职责
UI 层包含了 UI 状态 与 视图逻辑,与之对应的,数据(Data)层则由 应用数据 和 业务逻辑 两部分组成。根据 关注点分离 的原则,数据层本身只关心数据的来源与业务逻辑,它是与具体的 UI 实现 解耦 的,同样一套数据层模块,应当既可以应用于 Android,也可以应用在 iOS、H5 上面。此外,数据层也应当具备良好的单元测试兼容性。
数据层划分的依据是 业务上的数据定义,例如对于大麦网 APP来说,ShowRepository用来提供演出数据接口,PaymentsRepository则可以用作支付方式的数据源。
数据层在应用内部所处的位置如下:
数据层由数据仓库(Data Repository)和数据源(Data Source)两者组成,通常来说,数据层应当提供以下功能:
- 对 APP 内部其他模块 提供数据 ,这也是数据层最基本的职责
- 对 APP 内部其他模块 隐藏数据的真实来源
- 汇聚所有数据变更,对外部而言所看到的都是不可变的数据
- 处理多个数据源产生的数据冲突,对于多端登录的场景尤为重要
- 处理业务逻辑
数据源(Data Source)则代表了数据的真实来源,它可以是文件(File)、网络(Network)或者本地数据库(Database)。数据源将APP与操作系统之间的数据传输进行 抽象,在其中充当 桥梁 的作用。
数据层所提供的数据必须是不可变(immutable)的。这样有两点好处:其一是防止外部多处修改数据产生冲突,其二是不可变的数据对于多线程环境更加友好。
一个典型的基于依赖反转的数据源接口设计如下。
class ExampleRepository(
private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }
对于功能简单、模块数量较少的APP,一个常见做法是将
Data Source和Data Repository合二为一,尤其是对于只有一类Data Source的情况。
对外接口设计
数据层最主要的职责是对外提供数据操作,包含增查改删(Create, Read, Update, Delete) 在内。根据 是否为即时返回,可以分为两类实现:
- 瞬时操作:
- Kotlin:通过挂起函数(
suspend function)返回结果 - Java:通过回调(
Callback)通知结果 - RxJava:通过
Single、Maybe、Completable获取结果
- Kotlin:通过挂起函数(
- 耗时操作:
- Kotlin:通过
Flow返回结果 - Java:通过回调(
Callback)通知结果 - RxJava:通过
Observable、Flowable返回
- Kotlin:通过
是否为即时返回 是一种比较粗粒度的划分,Google 官方也没有明确提出多久的延迟算作“即时”,这里通常是借助经验来判断。个人认为,可以用
500ms作为门槛,<500ms的(例如内存运算)采用suspend函数,>500ms的(例如文件操作、网络、数据库读写)则使用Flow。
数据层模块命名
Repository 命名
数据类型 + Repository,例如NewsRepository,MoviesRepository,PaymentsRepository,请注意这里采用的都是复数的命名方式。
Data Source 命名
数据类型 + 本地/远程 + DataSource,例如NewsRemoteDataSource,NewsLocalDataSource。
反面教材: 用具体实现来命名 DataSource 是不可取的,这样暴露了模块内部的细节,不利于后续扩展和改写。一个典型反例就是 UserSharedPreferencesDataSource。
多层级 Repository
依据关注点分离的原则,根据软件业务逻辑的复杂程度,可以设计不同的 Repositories 之间的依赖关系,此时应当注意最小化对外暴露的接口。
线程管理
数据仓库对外暴露的接口应当是主线程安全(main-safe)的,外部包括 UI 层尽管在需要的地方调用,将线程切换的工作封装在数据仓库内部来做。
如前文所言,Kotlin 提供了较多的机制来降低线程切换的成本,常见的有 suspend function 和 flows。
业务模型设计
这其实是一个有待讨论的议题,“到底是使用原始的数据最齐全的 Model,还是使用经过裁剪的针对具体业务设计的 Model”?
| Options | 使用原始 Model | 使用裁剪Model |
|---|---|---|
| 优点 | 代码文件数量少 | 关注点分离 |
| 缺点 | 关注点未分离,返回大量冗余数据 | 要创建大量 Model 文件 |
个人倾向于使用 裁剪的 Model,针对不同的模块间接口,设计相应的 Model 类。例如一个完整的 ArticleApiModel 类,会包含文章全部元数据:
data class ArticleApiModel(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val modifications: Array<ArticleApiModel>,
val comments: Array<CommentApiModel>,
val lastModificationDate: Date,
val authorId: Long,
val authorName: String,
val authorDateOfBirth: Date,
val readTimeMin: Int
)
而对于APP而言,只需要关心文章的标题、内容、发布日期、作者等信息,其它的诸如评论、作者生日、预估阅读时长等,在 UI 层是不需要的。此时,可以将 Model 裁剪成为 Article 类,它的字段更精简,含义也更明确。
data class Article(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val authorName: String,
val readTimeMin: Int
)
通过这样裁剪,可以达到:
- 精简对象,降低内存占用。
- 充当适配器(
Adapter)功能,将原始数据源类型进行转化。 - 提供更好的
关注点分离实践。
数据的生命周期
从数据活跃的生命周期角度,可以分为三类。
- 面相 UI 的数据: 仅当用户处于某一特定页面时有效,当用户离开页面后,该数据即过期作废。典型例子是跟 ViewModel 生命周期绑定在一起的数据操作,当 VM 销毁(通常是用户退出页面)后,数据当即失效。
- 面向 APP 的数据: 数据的生命周期在 APP 进程存活期间持续存在,例如内存缓存。
- 面向业务逻辑的数据: 即使 APP 进程被杀,数据仍然有效,典型例子是通过 WorkManager 启动的后台任务,例如上传用户本地图片等。
异常传递
数据层读写发生异常时,优先采用抛出异常的处理手段。对于 suspend function 来说,使用 try/catch 进行捕获;对于 Flow 而言,则使用 catch 操作符。
另一种处理异常结果的方式是使用 Result<T> 包装类,对数据处理进行二次包装,例如可以使用密封类 Error 继承自 Result。建议读者仅在 异常传递机制 不生效的场景下,再考虑采用这种包装类的形式。
常见需求点的设计实现
数据源的设计和实现
在设计一个新闻流数据源时,考虑以下几点因素:
- 数据来自于
云端服务器 - 通过
注解实现依赖反转 - 获取新闻列表是耗时操作,需在
IO线程运行 - 考虑到未来可能替换掉网络实现&单元测试需求,将服务器
接口抽象出来
class NewsRemoteDataSource( // ===> 1. 数据来自于云端服务器
private val newsApi: NewsApi, // ===> 2. 通过注解实现依赖反转
private val ioDispatcher: CoroutineDispatcher
) {
/**
* Fetches the latest news from the network and returns the result.
* This executes on an IO-optimized thread pool, the function is main-safe.
*/
suspend fun fetchLatestNews(): List<ArticleHeadline> =
// Move the execution to an IO-optimized thread since the ApiService
// doesn't support coroutines and makes synchronous requests.
withContext(ioDispatcher) { // ===> 3. 获取新闻列表是耗时操作,需在IO线程运行
newsApi.fetchLatestNews()
}
}
// Makes news-related network synchronous requests.
interface NewsApi { // ===> 4. 考虑到未来可能替换掉网络实现&单元测试需求,将服务器接口抽象出来
fun fetchLatestNews(): List<ArticleHeadline>
}
实现网络请求的内存缓存
第二次打开页面时,即时加载缓存数据
当用户处在 APP 的使用过程中,应当构建一种内存缓存,以实现对于打开过的页面,再次进入时先展示内存缓存数据的效果。
出于读写安全的考虑,这里使用 Mutex 加锁。
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource
) {
// Mutex to make writes to cached values thread-safe.
private val latestNewsMutex = Mutex()
// Cache of the latest news got from the network.
private var latestNews: List<ArticleHeadline> = emptyList()
suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
if (refresh || latestNews.isEmpty()) {
val networkResult = newsRemoteDataSource.fetchLatestNews()
// ===> 写加锁
latestNewsMutex.withLock {
this.latestNews = networkResult
}
}
// ===> 读加锁
return latestNewsMutex.withLock { this.latestNews }
}
}
扩展网络请求的生命周期——使用外部传入的 Scope
期望当前页面退出时,仍然将处理中的网络请求在后台执行完成(注意防止内存泄漏),此时应当使用外部传入的 CoroutineScope,因为它具有更长的生命周期,是 APP 级别的数据,而非页面级别的数据。
class NewsRepository(
...,
// This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
// ===> 使用 SupervisorJob 隔断异常透传
private val externalScope: CoroutineScope
) { ... }
完整的实现如下。
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource,
private val externalScope: CoroutineScope
) {
suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
return if (refresh) {
externalScope.async { // ===> 基于外部 CoroutineScope 启动协程
newsRemoteDataSource.fetchLatestNews().also { networkResult ->
// Thread-safe write to latestNews.
latestNewsMutex.withLock {
latestNews = networkResult
}
}
}.await() // ===> await阻塞直至结果返回,
} else {
return latestNewsMutex.withLock { this.latestNews }
}
}
}
本地数据存储的选择——Database、DataStore 和 File
- 对于需要查询、引用完整性或部分更新的大型数据集 ,请将数据保存在
Room数据库中。在新闻 APP 中,新闻文章或作者可以保存在数据库中。 - 对于只需要检索和设置(无需查询或部分更新)的小型数据集,使用
DataStore。在新闻 APP 中,用户的首选日期格式或其他显示偏好设置可以保存在DataStore中。 - 对于像
JSON对象这样的数据块,使用File。
使用 DataStore 保存少量数据
关于 DataStore: 是 Google 推荐的现代数据存储解决方案,通过
协程异步实现,具备天然非阻塞特性。并且具备强类型支持,对复杂数据也可以通过Protobuf进行结构化。DataStore 是对小型数据集进行读写的优先选择。
DataStore 代码示例
val Context.dataStore by preferencesDataStore(name = "settings")
dataStore.edit { prefs ->
prefs[PreferencesKeys.DARK_MODE] = true
}
// ===> 通过 Flow 监听数据
dataStore.data
.map { prefs -> prefs[PreferencesKeys.DARK_MODE] ?: false }
.collect { mode -> updateUi(mode) }
DataStore 与 SharedPreference 之间对比
| 特性 | SharedPreferences | DataStore |
|---|---|---|
| 线程模型 | 主线程可能阻塞(ANR 风险) | 协程异步,天然非阻塞 |
| 数据一致性 | 弱一致性(apply 异步) | 事务性原子操作 |
| 类型安全 | 无 | 强类型(Preferences/Proto) |
| 复杂数据支持 | 仅基本类型 | 支持 Protobuf 结构化数据 |
| 错误处理 | 无内置机制 | 通过 Flow 的 catch 处理异常 |
| 迁移成本 | 无 | 支持从 SharedPreferences 迁移 |
使用 Room 数据库保存大量数据
使用 File 保存文件
使用 WorkManager 在进程未启动时执行后台任务
这个场景在常规产品需求中出现较少,但仍然有讨论和学习的价值。根据设备状态的不同(是否连接网络、是否充电),设计自动拉取新闻流的任务。WorkManager 就是为了实现这种异步且考虑多种条件后执行的任务而设计的,尤其是后台数据同步。
考虑这样一个场景,当用户手机在充电&已连接网络时,即使没有启动我们的新闻 APP,仍然希望可以在后台拉取最新的新闻列表并且进行缓存,这样下次用户启动应用时就可以做到秒开,且展示的是最新数据。
class RefreshLatestNewsWorker(
private val newsRepository: NewsRepository, // ===> 用于拉取数据&缓存
context: Context,
params: WorkerParameters // ===> 任务执行的条件
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
newsRepository.refreshLatestNews()
Result.success()
} catch (error: Throwable) {
Result.failure()
}
}
在设计好 RefreshLatestNewsWorker 以后,可以将它封装成 NewsTasksDataSource,交给 NewsRepository 进行管理。例如,用户可以在“个人设置”页选择是否开启空闲时更新文章的功能,如果选择“开启”,则调用 fetchNewsPeriodically 进行 WorkManager 注册,如果关闭,则调用 cancelFetchingNewsPeriodically 进行注销。
private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"
class NewsTasksDataSource(
private val workManager: WorkManager
) {
fun fetchNewsPeriodically() {
val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
REFRESH_RATE_HOURS, TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
.setRequiresCharging(true)
.build()
)
.addTag(TAG_FETCH_LATEST_NEWS)
workManager.enqueueUniquePeriodicWork(
FETCH_LATEST_NEWS_TASK,
ExistingPeriodicWorkPolicy.KEEP,
fetchNewsRequest.build()
)
}
fun cancelFetchingNewsPeriodically() {
workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
}
}
如果想查阅更多关于 WorkManager 的知识,可参考 WorkManager 使用指南。
测试
最后是对于数据层的测试部分,由于脱离了 UI 的具体实现,数据层是最容易进行 mock 测试的模块。同时,出于数据一致性、安全性、完整性的考虑,数据层也是最应该进行单元测试的模块。
由于我们在早期设计里使用了依赖注入,因此可以很方便地进行具体实现的替换,从而进行测试。
单元测试
集成测试
集成测试需要运行在真实设备上,尤其需要注意设备环境的一致性,使测试过程更加可信。
对于数据库,Room 支持创建一个仅存在于内存的数据库镜像,用于在不影响真实数据的前提下进行模拟。
对于网络请求,可以使用 WireMock、MockWebServer 这样的工具,对 HTTP、HTTPS请求进行模拟。