模块化Jetpack Compose架构

本文译自「Modular Jetpack Compose: Don't Leave Your Architecture to Code Review」,原文链接medium.com/proandroidd...,由Kanan Yusubov发布于2026年10月2日。

每个 Android 架构图在最初看起来都很简洁。功能用方框表示,箭头向下,README 文件中还会附上"功能之间不得相互依赖"的说明。

然后,截止日期到了。有人需要在结账页面上显示用户头像,个人资料功能已经有了用户存储库,只需导入一次即可。构建已通过,审核工作正在进行,但方向却发生了转变。六个月后,任何人都无法在不破坏结账流程的情况下更改个人资料功能。

文件夹和命名规则描述了一种架构,但它们并不强制执行。仅存在于 README 文件中的规则只是建议。

本文选取模块化 Compose 应用通常遵循的六条规则,并将每条规则从 README 文件移至构建过程中:

  1. 只有应用程序可以依赖于某个实现。 由 Gradle 检查强制执行。
  2. 规则 2:模块的内部实现保持私有。 由 Kotlin 内部机制强制执行。
  3. 规则 3:只有功能可以构建自己的路由。 由内部路由和启动器强制执行。
  4. 规则 4:每个依赖项都有定义。 由 Koin 编译器插件强制执行。
  5. 规则 5:所有失败都会被处理。 由密封类型强制执行。
  6. 规则 6:每个功能和路由都会被注册。 由持续集成 (CI) 中运行的源代码检查强制执行。

如果某项规则无法强制执行,那么最后一部分规定,正确的方法就是最简单的方法。

结构:每项功能都由两个模块组成

每个功能都会变成一对 Gradle 模块:

  • -api 定义了功能可以做什么。仅限接口。
  • -impl 定义了实现方式。包括屏幕、视图模型、存储库、DTO 和布线。
bash 复制代码
app/                          the only module that sees implementations
features/
  posts/posts-api             PostsApi, PostsLauncher
  posts/posts-impl            screens, view models, data, Koin module
  counter/counter-api
  counter/counter-impl        depends on posts-api, never posts-impl
core/
  designsystem/               tokens, theme, components
  statemanager/               AppStateViewModel
  router/router-api | impl    Navigation 3
  network/network-api | impl  Ktor
  logger/logger-api | impl    Kermit

依赖关系仅向下指向:应用 → 功能 → 核心。功能之间仅通过 API 进行通信。当 checkout 需要用户头像时,它依赖于 profile-api 并通过接口进行请求。它永远不会访问 UserRepository,因此 profile 可以自由地重写它。

规则 1:只有应用程序可以依赖于实现。

这条规则是其他一切的基础。它使应用程序成为组合的根源:它是唯一知道每个接口背后对应哪个实现的地方。其他所有模块都是根据契约编写的。

约定插件中的几行代码会在项目声明每个依赖项时进行检查:

kotlin 复制代码
internal fun Project.applyArchitectureGuard() {
    val self = path
    configurations.configureEach {
        val configurationName = name
        dependencies.withType(ProjectDependency::class.java).configureEach {
            val target = path
            val violation = when {
                target == self -> null // a module's own test classpaths
                target == ":app" -> "nothing may depend on :app"
                target.endsWith("-impl") && self != ":app" ->
                    "only :app may depend on an -impl module; depend on its -api instead"
                else -> null
            }
            if (violation != null) {
                throw GradleException(
                    "Architecture rule broken in $self ($configurationName -> $target): $violation.",
                )
            }
        }
    }
}

将 implementation(project(":features:posts:posts-impl")) 添加到计数器特性中,构建会在配置期间停止,甚至在编译单个文件之前就停止了:

ruby 复制代码
Architecture rule broken in :features:counter:counter-impl
(implementation -> :features:posts:posts-impl):
only :app may depend on an -impl module; depend on its -api instead.

信息会说明哪里出了问题以及应该如何解决。这样就无需进行回顾对话了。

由于该检查在 configureEach 函数中运行,因此它涵盖了所有配置:implementation、api、testImplementation 以及插件后续添加的任何配置。而且由于该规则针对的是模块路径,因此无需维护任何列表。

每个模块都会自动获得守卫,无需请求

只有当每个模块都启用架构保护时,它才能发挥作用;而二十个模块如果都使用相同的构建配置,就会出现差异。因此,共享的构建配置以约定插件的形式存在于 build-logic/ 目录下,Android 库插件和应用程序插件都会调用 applyArchitectureGuard() 方法。

一个功能的构建文件只包含该功能特有的内容:

kotlin 复制代码
plugins {
    id("modular.feature.impl")
}
dependencies {
    implementation(project(":features:posts:posts-api"))
    implementation(project(":core:network:network-api"))
}

modular.feature.impl 引入了 Android 库设置(以及相应的 guard)、Compose、Koin、序列化、detekt、设计系统、状态管理器、路由契约、日志契约和测试库。甚至 Android 命名空间也源自模块路径::features:posts:posts-impl 变为 com.example.modularapp.features.posts.impl。

规则二:模块内部信息应保密

Kotlin 的 internal 指的是"仅在此 Gradle 模块内部可见"。在 -impl 模块中,几乎所有东西都是内部的:路由、视图模型、仓库、DTO。

kotlin 复制代码
internal class PostsRepositoryImpl(private val client: NetworkClient) : PostsRepository

规则 1:只有应用程序可以依赖实现

该守卫机制可防止声明错误的依赖项。内部机制确保即使是允许依赖于 posts-impl(仅包含 app 模块)的模块,也只能找到实现该功能所需的几个类。

规则 2:模块的内部实现保持私有

规则 3:只有功能才能构建自己的路由

Navigation 3 采用了一种简洁明了的模型:返回栈是一个简单的键列表,NavDisplay 会显示最后一个键对应的可组合元素。前进操作会添加一个键,后退操作会移除一个键。

模块化的问题是:谁有权创建键?如果任何功能都可以构建 PostDetailsRoute(42),那么每个功能都依赖于文章如何排列其屏幕。因此,路由是私有的:

kotlin 复制代码
// posts-impl: no other module can see these
@Serializable
internal data object PostsRoute : AppRoute
@Serializable
internal data class PostDetailsRoute(val postId: Int) : AppRoute

其他功能则从其启动器获取路由,该启动器发布在其 API 中:

kotlin 复制代码
// posts-api
interface PostsApi {
    val launcher: PostsLauncher
}
interface PostsLauncher {
    fun posts(): AppRoute
    fun postDetails(postId: Int): AppRoute
}

启动器返回的是一条路径,而不是实际导航。启动器知道路径的_位置_,而调用者决定_如何操作_:推送路径、用路径替换当前屏幕,或者将其设为新的根目录。

kotlin 复制代码
// counter-impl, handling an effect from its view model
CollectEffects(viewModel.effects) { effect ->
    when (effect) {
        // Our own screen: use the route directly.
        is CounterEffect.OpenDetails -> navigator.push(CounterDetailsRoute(effect.count))
        // Another feature: only through its launcher.
        CounterEffect.OpenPosts -> navigator.push(postsLauncher.posts())
    }
}

尝试在计数器功能中写入 navigator.push(PostDetailsRoute(42)),但编译失败。该路由不可见。

每个 -impl 都贡献一个 ModuleRouter,用于将其路由映射到屏幕。它也是构建该功能视图模型的唯一位置:

kotlin 复制代码
class PostsModuleRouter internal constructor(
    private val repository: PostsRepository,
) : ModuleRouter {
    override fun PolymorphicModuleBuilder<NavKey>.registerRoutes() {
        subclass(PostsRoute::class)
        subclass(PostDetailsRoute::class)
    }
    override fun EntryProviderScope<NavKey>.entries() {
        entry<PostsRoute> {
            PostsScreen(viewModel = viewModel { PostsViewModel(repository) })
        }
        entry<PostDetailsRoute> { route ->
            PostDetailsScreen(viewModel = viewModel { PostDetailsViewModel(route.postId, repository) })
        }
    }
}

两个细节很重要。

  • **registerRoutes() 方法使得返回栈能够在进程终止后继续存在。**路由使用 @Serializable 注解,并且由于它们是私有的,因此每个功能都会注册自己的子类。应用程序会将它们合并到一个序列化器中。
  • 每个返回栈条目都拥有自己的 ViewModelStore,这是通过 Navigation 3 的视图模型装饰器实现的。条目内的 viewModel { ... } 会创建一个视图模型,该模型的生命周期与该屏幕在返回栈中停留的时间完全相同。

导航是效果,而不是调用

导航是一种效果,而非一种调用。

屏幕遵循一个状态/事件/效果循环,这是在纯 AndroidX ViewModel 中实现的:

sql 复制代码
user taps ──▶ Event ──▶ ViewModel ──▶ new State ──▶ screen redraws
                            └────────▶ Effect ────▶ screen navigates

视图模型不包含导航器或上下文。它以效果的形式描述应该发生什么,屏幕会执行该效果。效果通过缓冲通道传输,因此屏幕在后台运行时发送的效果会在屏幕恢复运行时送达,而不会丢失。

规则 4:每个依赖项都有一个定义

传统的 Koin 编译器会在运行时解析所有定义:如果缺少定义,则代码路径首次运行时会崩溃。而 Koin 的 K2 编译器插件则将这种检查移到了构建过程中。

每个 -impl 都包含一个 Koin 模块。这些定义都是函数,因此类本身完全没有依赖注入注解:

kotlin 复制代码
@Module
class PostsKoinModule {
    @Single
    fun postsApi(): PostsApi = PostsApiImpl()
    @Single
    internal fun repository(client: NetworkClient): PostsRepository = PostsRepositoryImpl(client)
    @Single(binds = [ModuleRouter::class])
    internal fun moduleRouter(repository: PostsRepository): PostsModuleRouter = PostsModuleRouter(repository)
}

将每个函数视为一个句子来理解:为了提供一个 PostsRepository,我需要一个 NetworkClient。路由器上的绑定允许应用程序使用 koin.getAll() 收集每个功能的路由器,而无需指定任何功能名称。

该应用程序会将每个模块列出一次:

kotlin 复制代码
@Module(
    includes = [        LoggerKoinModule::class,
        NetworkKoinModule::class,
        CounterKoinModule::class,
        PostsKoinModule::class,
    ],
)
internal class AppKoinModule { /* ... */ }
@KoinApplication(modules = [AppKoinModule::class])
internal object ModularKoinApplication

从列表中移除 NetworkKoinModule 后,应用程序将无法编译:

ini 复制代码
e: [Koin][KOIN-D001] Missing dependency: com.example.modularapp.core.network.NetworkClient

规则 4:每个依赖项都有定义

有三个决定值得解释。

  • 为什么使用函数而不是带注解的类? 使用组件扫描的 @Single class PostsRepositoryImpl 类虽然更简洁,但每个类都需要导入 Koin,导致代码分散在数十个文件中。而使用函数,领域代码和数据代码都保持纯 Kotlin 代码,并且每个功能的配置都集中在一个地方。
  • 为什么不从 Koin 解析视图模型? 路由通过普通的构造函数调用来构建视图模型。帖子 ID 来自路由,传递给构造函数,然后由编译器进行检查。如果从容器解析视图模型,则会将其变成运行时参数查找。

规则 5:所有失败都会被处理

Features 从不导入 Ktor。它们只看到一个接口,并且每次调用都会返回一个值,而不是抛出异常:

kotlin 复制代码
interface NetworkClient {
    suspend fun <T> get(
        path: String,
        response: DeserializationStrategy<T>,
        query: Map<String, String> = emptyMap(),
    ): AppResult<T>
    // post(...) likewise
}
sealed interface AppResult<out T> {
    data class Success<out T>(val value: T) : AppResult<T>
    data class Failure(val error: NetworkError) : AppResult<Nothing>
}

NetworkError 是一个封闭集合:无连接、超时、HTTP 状态码错误、无法解码的响应体、未知错误。Ktor 实现会捕获所有此类错误并将其转换为其中之一,但有一个例外:协程取消会被重新抛出,因此离开当前屏幕仍然会取消其请求。

每一层都使用自己的语言。存储库会将网络错误详尽地转换为该功能自身的故障类型:

kotlin 复制代码
private fun NetworkError.toFailure(): PostsFailure = when (this) {
    NetworkError.NoConnection -> PostsFailure.NoConnection
    is NetworkError.Http -> if (code == HTTP_NOT_FOUND) PostsFailure.NotFound else PostsFailure.Unavailable
    NetworkError.Timeout, is NetworkError.Unknown -> PostsFailure.Unavailable
    is NetworkError.Serialization -> PostsFailure.Malformed
}

视图模型会将这种故障状态作为值保留下来。只有屏幕层(拥有资源访问权限)才会将其转换为翻译后的句子。由于每个步骤都是一个封闭的 when 语句,没有 else 语句,因此添加新的故障类型需要每一层都处理完毕才能编译通过。不存在那种"出了点问题"的默认回退机制来掩盖未曾预料到的情况。

规则 6:每个功能和路由都会被注册

有些错误代码格式正确,编译也完全没问题,但运行时仍然会出错:

  • 某个功能的 Koin 模块从未添加到应用中。 应用中没有任何地方需要指定其类型,因此 Koin 的图看起来是完整的。但该功能只是缺少一些屏幕。
  • registerRoutes() 中缺少一条路由。 **导航正常,但在 Android 首次保存返回栈时崩溃。

Gradle 和编译器都无法直接查看这些文件,因此 Gradle 任务会读取源代码并逐一检查。./gradlew doctor 会在每次拉取请求时在 CI 中运行:

csharp 复制代码
doctor found problems:
  ✖ route-not-registered: PostDetailsRoute (posts-impl) is missing from registerRoutes().
  ✖ feature-not-registered: UserProfileKoinModule is not in the app's includes.

这是一个特意设计得非常小巧的工具。它了解项目的规范,因此可以检查通用代码检查工具无法检查的内容。

如果构建无法强制执行,就让正确的方式成为最简单的方式

并非每条规则都是错误的。对于其他规则而言,其目标是使做正确的事比做错误的事更省力。

**新功能是自动生成的,而非复制粘贴。**避免代码错误最简单的方法就是不要手动编写代码。./gradlew newFeature --name=user-profile 命令会生成以下模块:facade、launcher、route、route table、Koin 模块、view model、screen 以及一个测试用例。它还会将此功能添加到应用程序中。生成的代码可以直接通过 detekt 和 doctor 的检测。

设计系统负责实现无障碍功能。 它基于 Compose Foundation 而非 Material 构建:通过组合局部变量提供一小组标记(颜色、间距、半径、字体、动态效果),并通过一个对象读取:

kotlin 复制代码
AppText(text = post.title, style = AppTheme.typography.title)

这些标记使用 staticCompositionLocalOf。动态组合局部变量会跟踪所有读取它的位置,因此更改只会重新组合这些读取者。静态组合局部变量则跳过此跟踪,更改会重新组合整个子树。对于主题标记来说,这完全正确:它们会被所有地方读取,并且只有当整个主题更改时才会更改。

成本是多少

这并非免费,假装免费是一种欺骗。

  • 更多模块。 每个功能需要两个模块,外加一个需要保持更新的组合根目录。Gradle 的配置缓存和构建缓存保证了速度,但也增加了导航的复杂性。
  • 间接性。 打开另一个功能的页面需要通过界面和启动器,而不是直接调用。这正是设计初衷,但阅读代码时需要多一步操作。

我的经验法则是:这种方法大约适用于十个功能,或者两个团队在同一个应用上协作开发。少于十个功能,或者两个团队在同一个应用上协作开发,就足以满足需求。多于十个功能,一个严格遵守边界的构建版本所带来的价值远超其成本。

以上所有内容都包含在开源的 modular-compose-template 中,它会打开一个计数器页面,浏览来自 JSONPlaceholder 的帖子。这些页面故意设计得比较简洁;重点在于它们背后的逻辑。如果你在实际项目中尝试应用这些规则,我很想知道它们在哪些方面有所帮助,又在哪些方面遇到了阻碍。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
厮年断弦4 小时前
AI 辅助开发实录:一周做完一个 Android 本地音乐播放器,这些坑替你踩了
android
hai_android4 小时前
InputStage 责任链核心执行流程
android·android studio·android jetpack
mmsx7 小时前
Android 测绘计算工程化:七四参数、高程拟合与单位系统
android·kotlin
我是场9 小时前
我的NPI项目之-Touch的EE特性
android·arm开发
天空之城--9 小时前
Android一周动态:AI编程、Compose与车载生态新信号
android·flutter
恋猫de小郭10 小时前
Dart 4.0 要彻底移除 dart:mirrors,Augmentations 应该要来了
android·前端·flutter
Carson带你学Android12 小时前
Gemini 4 Argon 发布:对 Android 开发者意味着什么?
android·ai编程
事圆则缓12 小时前
Kotlin Flow、StateFlow、SharedFlow 全解析:冷流、热流与 Android 状态管理
android·kotlin·php
应用市场13 小时前
把旧 Pixel 变成相册备份中转站(上):Mac 到安卓的照片传输工具设计——流式上传、sha256 校验、adb forward 与 Bonjour
android·macos·adb·kotlin·swift