Now in Android 领域层(:core:domain)模块解析:用例(Use Case)设计与 Flow 数据流实践
本篇文章围绕 Now in Android 示例项目中的 core/domain 模块展开,讲解该模块在项目分层架构中的定位、依赖关系,以及其中三个核心用例(GetFollowableTopicsUseCase、GetRecentSearchQueriesUseCase、GetSearchContentsUseCase)的设计思路与实现细节。读完本文,你将掌握如何在 Android 多模块架构中以"用例"封装业务规则、通过 Kotlin Flow 组合多个数据源、以及如何为用例编写可验证的单元测试。
模块定位:领域层在架构中的角色
core/domain 模块位于整个应用的"数据层(data)"与"界面层(UI)"之间,负责承载不依赖任何具体框架或数据来源的业务逻辑。它通过实现"用例"(Use Case)对象,把"数据长什么样"与"业务规则是什么"彻底解耦:数据层只负责提供原始数据流,界面层只负责消费最终结果,而中间的业务判断(例如"哪些主题被用户关注了")统一收敛到领域层。
从模块构成看,core/domain 是一个标准的 Android Library 模块,其源码组织非常简洁(目录结构):
- 主源码仅包含 3 个 Use Case 类:
GetFollowableTopicsUseCase、GetRecentSearchQueriesUseCase、GetSearchContentsUseCase; - 测试源码包含 1 个测试类:
GetFollowableTopicsUseCaseTest; AndroidManifest.xml为空<manifest />(AndroidManifest.xml),印证该模块不包含任何 Activity、Service 或组件,是纯粹的代码逻辑模块。
该模块对外暴露的只是几个行为明确的类,这正是"用例驱动"架构的典型形态:每个用例对应一个可独立理解、独立测试的业务能力。
模块依赖图谱:领域层站在哪些模块之上
core/domain 的依赖关系在 core/domain/README.md 中通过 Mermaid 依赖图给出,属于 :core 组中的 android-library。从图中可以提取出两个关键依赖边:
:core:domain --> :core:data:领域层依赖数据层,使用数据层暴露的 Repository 接口获取数据;:core:domain --> :core:model:领域层依赖模型层,使用纯 Kotlin 的领域模型(如FollowableTopic、UserSearchResult)作为输入输出类型。
这种依赖方向保证了依赖单向流动:上层(feature/UI)依赖领域层,领域层依赖数据层与模型层,数据层又向下依赖 database、datastore、network 等基础设施模块,而 :core:model 作为最底层的纯 JVM 模型模块被广泛复用。用图例(Graph legend)中的术语来说,:core:domain 属于"android-library"(浅蓝色节点),而 :core:model 与 :core:common 属于更轻量的"jvm-library"(浅紫色节点)。
从源码印证,领域层确实只依赖两个外部包:core.data.repository(数据层仓库接口)与 core.model.data(模型层数据类型),没有任何 Android 框架或第三方 SDK 的直接引用。
用例一:获取可关注主题列表
GetFollowableTopicsUseCase(源码)解决的核心业务问题是:给定所有主题与用户的关注状态,产出一份"可关注主题"列表。
构造函数与依赖注入
class GetFollowableTopicsUseCase @Inject constructor(
private val topicsRepository: TopicsRepository,
private val userDataRepository: UserDataRepository,
) {
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> = combine(
userDataRepository.userData,
topicsRepository.getTopics(),
) { userData, topics -> ... }
}
要点解析:
- 通过
@Inject注解构造注入两个仓库,交给 Hilt 管理依赖,类本身没有任何生命周期或 Android 组件依赖; - 定义了
operator fun invoke,让用例对象可以像函数一样直接调用(useCase()),是 Kotlin 中实现 Use Case 模式的惯用写法; - 返回类型是
Flow<List<FollowableTopic>>,意味着这是一个持续观察的数据流——用户关注状态或主题列表任一变化,下游都会收到最新结果。
combine 双数据源合并
用例内部通过 kotlinx.coroutines.flow.combine 同时订阅两个数据流:
userDataRepository.userData(UserDataRepository):Flow<UserData>,其中UserData(模型定义)包含followedTopics: Set<String>等用户偏好字段;topicsRepository.getTopics()(TopicsRepository):Flow<List<Topic>>,即全部可用主题。
合并逻辑把每个 Topic 包装成 FollowableTopic(模型定义),通过 topic.id in userData.followedTopics 判断其关注状态:
FollowableTopic(
topic = topic,
isFollowed = topic.id in userData.followedTopics,
)
这里 followedTopics 是 Set<String>,集合的 in 判断是 O(1) 复杂度,在主题数量较多时也能保持高效。
排序策略枚举
用例支持按字段排序,通过枚举 TopicSortField 表达:
enum class TopicSortField {
NONE, // 不排序,保持数据层原始顺序
NAME, // 按主题名称排序
}
调用方可通过 invoke(sortBy = NAME) 切换排序,未传参时默认 NONE(不排序)。从实现看,目前仅实现了 NAME 一种排序分支,其余值一律走原顺序,为后续扩展保留了空间。
单元测试验证
GetFollowableTopicsUseCaseTest(测试源码)使用 core:testing 模块提供的 TestTopicsRepository、TestUserDataRepository 假仓库,以及 MainDispatcherRule 控制协程调度器,覆盖两个场景:
- 无参调用不排序:发送测试主题并设置
{testTopics[0].id, testTopics[2].id}为已关注,断言结果顺序不变且关注标记正确(FollowableTopic(testTopics[0], true)、FollowableTopic(testTopics[1], false)等); - 按名称排序:调用
useCase(sortBy = NAME),断言输出与testTopics.sortedBy { it.name }逐一映射的结果完全一致。
测试用 kotlinx.coroutines.test.runTest + Flow.first() 取首帧数据,展示了如何在没有 Android 设备的环境下对响应式用例进行确定性验证。
用例二:获取最近搜索记录
GetRecentSearchQueriesUseCase(源码)封装了"读取最近搜索记录"的业务能力,实现非常精简:
class GetRecentSearchQueriesUseCase @Inject constructor(
private val recentSearchRepository: RecentSearchRepository,
) {
operator fun invoke(limit: Int = 10): Flow<List<RecentSearchQuery>> =
recentSearchRepository.getRecentSearchQueries(limit)
}
核心要点:
- 依赖
RecentSearchRepository(接口),该接口提供getRecentSearchQueries(limit)、insertOrReplaceRecentSearch(searchQuery)、clearRecentSearches()三个方法; - 默认参数
limit: Int = 10定义了"最多返回 10 条最近搜索"的默认业务规则,调用方也可传入自定义上限; - 返回类型
Flow<List<RecentSearchQuery>>,RecentSearchQuery(模型定义)由查询串query与查询时间queriedDate(基于kotlinx.datetime.Instant,默认取Clock.System.now())构成,数据层通过asExternalModel()扩展函数将数据库实体映射为领域模型。
该用例虽只有寥寥数行,却体现了领域层的一个关键价值:把"最近搜索默认只显示 10 条"这类业务规则显式化为代码签名,界面层无需关心实现细节。
用例三:按关键词搜索主题与新闻
GetSearchContentsUseCase(源码)是三个用例中最复杂的一个,负责将搜索命中的主题与新闻结果附加上用户个性化信息后返回给界面。
主流程
class GetSearchContentsUseCase @Inject constructor(
private val searchContentsRepository: SearchContentsRepository,
private val userDataRepository: UserDataRepository,
) {
operator fun invoke(searchQuery: String): Flow<UserSearchResult> =
searchContentsRepository.searchContents(searchQuery)
.mapToUserSearchResult(userDataRepository.userData)
}
它同时依赖两个仓库:
SearchContentsRepository(接口):提供searchContents(searchQuery)查询 FTS(全文搜索)数据,以及populateFtsData()、getSearchContentsCount()等配套能力;UserDataRepository:提供用户关注等个性化数据。
搜索结果与用户数据的合并
私有扩展函数 mapToUserSearchResult 通过 combine 将搜索结果与用户数据合并,产出 UserSearchResult(模型定义):
private fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Flow<UserSearchResult> =
combine(userDataStream) { searchResult, userData ->
UserSearchResult(
topics = searchResult.topics.map { topic ->
FollowableTopic(topic = topic, isFollowed = topic.id in userData.followedTopics)
},
newsResources = searchResult.newsResources.map { news ->
UserNewsResource(newsResource = news, userData = userData)
},
)
}
与 GetFollowableTopicsUseCase 相同的合并手法在此复用:
- 搜索命中的
Topic列表被映射为带关注状态的FollowableTopic; - 搜索命中的
NewsResource列表被映射为携带用户数据的UserNewsResource(包含收藏、已读等个性化标记)。
SearchResult(模型定义)是数据层返回的"中性"结果,UserSearchResult 则是领域层加工后的"个性化"结果——领域层在此完成了从通用数据到用户视角数据的语义转换,这比让界面层自行拼接两个数据流更内聚、更易测试。
领域层的设计模式总结
结合三个用例的实现,可以提炼出 Now in Android 在领域层沉淀的通用范式:
- 用例即函数:每个 Use Case 定义
operator fun invoke,配合构造注入,调用体验与普通函数无异,便于在 ViewModel 中直接装配; - 以 Flow 贯穿始终:所有用例返回
Flow,天然支持响应式 UI;多数据源通过combine合并,任何上游变化都会驱动下游刷新; - 模型转换收敛在领域层:
Topic→FollowableTopic、SearchResult→UserSearchResult的转换逻辑全部内聚在用例内部,数据层保持通用,界面层保持轻薄; - 默认参数承载业务规则:如
limit: Int = 10、sortBy: TopicSortField = NONE,规则以代码签名形式显式可见; - 可测试性优先:领域层只依赖仓库接口,测试时可无缝替换为
core:testing提供的假实现(如TestTopicsRepository),配合runTest即可完成纯 JVM 单元测试。
这种"薄领域层 + 响应式数据流"的设计,是 Now in Android 作为 Google 官方示例所倡导的推荐实践之一,也可作为其他多模块 Android 项目划分业务逻辑的参考模板。
延伸阅读
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.22 K638- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python440
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2.01 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python49368
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go21143
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java34551