Google 的架构组件示例仓库,Learning Path
Google 的架构组件示例仓库,Learning Path
一个被低估的官方资源
去年重构公司一个老项目的时候,我在 GitHub 上翻 Android 官方示例,偶然点进了 android/architecture-components-samples 这个仓库。当时我的第一反应是:这仓库名字起得也太朴素了,star 数才两万多,跟那些动辄五万十万的网红库完全没法比。但仔细翻了几天之后,我发现这大概是 Google 官方维护的最被低估的学习资源之一,尤其是它后来整合进 android/nowinandroid 之前的那些独立示例模块。
这个仓库的全名叫 android/architecture-components-samples,现在已经被标记为 archived 状态,官方推荐迁移到 nowinandroid 项目。但 archived 不代表没价值,恰恰相反,那些独立的示例模块比 nowinandroid 这种"全家桶"项目更适合深度学习。nowinandroid 是个完整的生产级应用,代码量太大,依赖关系复杂,新手进去直接懵。而老的架构组件示例仓库把每个技术点拆成了独立模块:Navigation、WorkManager、Room、Paging、ViewModel、LiveData,甚至包括 Hilt 和 Kotlin Coroutines 的集成方式,每个模块都能独立编译运行。
我当时最需要的是 Navigation 组件的深度理解。公司项目用的是单 Activity + 多 Fragment 的架构,但导航逻辑写得一团糟,Fragment 回退栈管理全靠手动 transaction,bug 层出不穷。架构组件示例仓库里有个 NavigationAdvancedSample,这个模块解决了一个非常具体的痛点:BottomNavigationView 配合多个导航图(navigation graph)时,如何保持每个 tab 的独立回退栈。这个示例的代码量不大,核心逻辑集中在 NavigationExtensions.kt 这个文件里,大概两百行左右。它通过自定义的 BottomNavigationView.setupWithNavController 扩展函数,实现了多个 NavHostFragment 的懒加载和状态保存。
我直接把这套代码搬进了公司项目,但踩了第一个坑:这个示例基于的是 Navigation 2.3.x 版本,而当时我用的已经是 2.5.x。2.4.0 版本引入了一个重大变化——NavHostFragment 的创建方式从显式在布局文件中声明,改为可以通过 FragmentContainerView 的 android:name 属性配合 app:navGraph 来声明式配置。示例代码里的动态创建方式在新版本里虽然还能跑,但会触发一个 lint 警告,提示你改用新的 NavHostFragment.create API。更隐蔽的问题是,2.4.0 之后 FragmentNavigator 的内部实现改了,自定义的 Navigator 如果直接 copy 旧代码,在特定场景下会抛出 IllegalStateException: Fragment no longer exists 的异常。这个异常在 GitHub issue #296 里有详细讨论,根本原因是 FragmentManager 的状态保存和恢复时机变了。
WorkManager 示例里的线程模型陷阱
另一个让我花了不少时间的模块是 WorkManagerSample。这个示例展示了周期性任务、链式任务、约束条件设置等基础用法,但真正的价值在于它包含了一个 CoroutineWorker 的实现示例。Android 的 WorkManager 从 2.1.0 开始支持 Kotlin Coroutines,CoroutineWorker 取代了原来的 ListenableWorker 成为异步任务的推荐基类。
示例代码里有个 BlurWorker 的实现,用 doWork() 的 suspend 函数来做图片模糊处理。看起来很简单,但我实际测试时发现了一个性能问题:在 Android 12(API 31)的设备上,这个 worker 的执行时间比预期长了将近三倍。抓 trace 之后发现,WorkManager 默认把 CoroutineWorker 调度在 Dispatchers.Default 上,而这个 dispatcher 的线程池大小是 CPU 核心数,最小为 2。图片模糊处理是 CPU 密集型任务,但 doWork() 函数里还包含了文件 IO 操作——从 URI 读取原始图片,处理完再写入输出文件。IO 操作阻塞了 Dispatchers.Default 的线程,导致同一个进程里的其他 worker 也被拖慢。
解决方案是显式指定 dispatcher,但这个细节在官方文档里写得很隐晦。CoroutineWorker 的 doWork() 函数虽然是个 suspend 函数,但它的执行环境由 WorkManager 内部控制,不是你随便 withContext 就能解决的。正确的做法是在 worker 内部用 withContext(Dispatchers.IO) 包裹 IO 操作,但示例代码里并没有展示这个最佳实践。我后来翻到了 WorkManager 2.7.0 的源码,CoroutineWorker 的实现里确实硬编码了 Dispatchers.Default:
final override suspend fun doWork(): Result {
return withContext(Dispatchers.Default) {
doWork()
}
}这里的 doWork() 调用的是子类的实现。所以子类里的 IO 操作如果不切换 dispatcher,就会占着 CPU 线程池干等磁盘。这个坑在 WorkManagerSample 里没有体现,因为示例用的图片很小,IO 时间可以忽略。但生产环境里遇到大文件或者慢存储,问题就暴露出来了。
还有一个版本相关的坑:WorkManager 2.6.0 开始强制要求 targetSdkVersion 为 31 或更高时,必须适配 Android 12 的精确闹钟权限(SCHEDULE_EXACT_ALARM)。如果你的应用 targetSdkVersion 是 31,但用户运行在 Android 12 设备上且拒绝了闹钟权限,WorkManager 的 setExactAndAllowWhileIdle 类型的任务会直接崩溃。这个在示例代码里也没有处理,因为示例的 targetSdkVersion 当时还是 30。
Room 的迁移策略:从示例到生产
PersistenceContentProviderSample 这个模块是我觉得最有教学价值的,虽然它的名字听起来有点过时——ContentProvider 在现代 Android 开发里已经很少直接使用了。但这个模块真正展示的是 Room 的数据库迁移(Migration)机制,以及如何通过 RoomDatabase.Callback 在数据库创建时预填充数据。
示例里定义了两个版本的 Migration 对象,从版本 1 到 2 是添加新列,从版本 2 到 3 是新建表并迁移数据。代码很标准,但我注意到一个细节:示例用的是 Migration(1, 2) 和 Migration(2, 3) 的显式声明,而不是自动迁移(Auto Migration)。Room 从 2.4.0 开始支持 @AutoMigration 注解,可以自动生成简单的迁移逻辑。但示例仓库最后一次更新是在 2.4.0 发布之前,所以没展示这个新特性。
这里有个实际的选择问题:自动迁移确实省代码,但它的局限很大。只能处理列添加、列删除、表重命名这种标准操作,一旦涉及数据转换(比如把 Int 类型的状态码转成 String 类型的枚举名),就必须手写 Migration。更麻烦的是,自动迁移生成的 SQL 语句在编译期不会验证,运行时如果出问题,崩溃信息很难定位。我遇到过的情况是,自动迁移把 NOT NULL 约束丢了,导致旧数据迁移后出现 null 值,触发 Cursor 读取时的 NullPointerException。
示例代码里的手动迁移写法虽然啰嗦,但可控性高。特别是它展示了 database.execSQL() 的用法,以及如何在迁移事务里做复杂的数据转换。我后来在公司项目里处理一个用户表重构时,直接参考了这个模式:先创建新表,用 INSERT INTO ... SELECT ... 把旧数据转过去,再删旧表,最后重命名。整个操作包在一个事务里,要么全成功要么全回滚。Room 的 Migration 接口保证了这个原子性,但很多人不知道的是,如果迁移过程中抛出异常,Room 不会自动回滚已经执行的部分 SQL——它依赖 SQLite 的事务机制,而 SQLite 的 DDL 语句(CREATE、DROP、ALTER)在某些版本里是不支持回滚的。Android 自带的 SQLite 从 3.7.11 开始支持 ALTER TABLE 的回滚,但如果你的应用还跑着 Android 4.x 的设备,这个假设就不成立。
Paging 3 的实战落差
PagingSample 和 PagingWithNetworkSample 是两个独立的模块,分别展示了 Paging 3 在本地数据库和远程数据源场景下的用法。Paging 3 相比 Paging 2 是个彻底的重写,API 完全不兼容,但官方示例的更新速度没有跟上版本迭代。
PagingWithNetworkSample 用的是 Reddit API 做演示,实现了一个简单的帖子列表。它的架构是经典的 Repository 模式:ViewModel 持有 Pager 对象,通过 PagingDataAdapter 提交给 RecyclerView。RemoteMediator 负责处理网络分页和本地缓存的协调。这个示例在 2021 年的时候是标准写法,但放到今天有几个明显的问题。
第一个问题是 LoadStateAdapter 的缺失。示例里的列表没有加载状态指示器,用户拉到最后一页时没有任何反馈。Paging 3 提供了 ConcatAdapter 配合 LoadStateAdapter 的方案,但示例代码里没有展示。这个不是 API 版本的问题,纯粹是示例不够完整。我在实际项目中补上这个之后,发现 LoadStateAdapter 和 PagingDataAdapter 的提交时机有个微妙的竞态条件:如果用户在加载第一页时就快速滚动到底部,LoadState.NotLoading 和 LoadState.Loading 的切换可能导致 ConcatAdapter 的 item count 计算出错,触发 IndexOutOfBoundsException。这个 bug 在 Paging 3.1.0 之前很频繁,3.1.1 里做了修复,但示例仓库没有更新到展示这个版本的处理。
第二个问题是错误处理。示例里的 RemoteMediator 在 load() 函数里直接返回 MediatorResult.Error(exception),但没有展示如何在 UI 层消费这个错误。实际生产环境里,网络错误需要区分是暂时性的(可以重试)还是永久性的(需要提示用户)。Paging 3 的 LoadState.Error 只携带了 Throwable,没有内置的重试计数或退避策略。我后来自己实现了一个基于 ExponentialBackoff 的重试机制,但这不是 Paging 库本身提供的,示例代码里也没有参考。
第三个问题更隐蔽:示例用的 Reddit API 是公开的,没有认证机制,所以 RemoteMediator 里直接构造了 HttpURLConnection。但现代 Android 项目基本都用 Retrofit + OkHttp,而 OkHttp 的拦截器链和 Paging 的 RemoteMediator 有个配合问题。如果你在拦截器里做统一的错误处理(比如 token 过期刷新),这个异步操作和 RemoteMediator 的同步 load() 函数怎么协调?示例完全没有涉及这个场景。我当时的解决方案是把 token 刷新逻辑放到 Repository 层,用 runBlocking 在 RemoteMediator 里等待,但这显然不是最佳实践。后来看到 Paging 3.2.0 的 alpha 版本里,RemoteMediator 的 load() 函数改成了 suspend 函数,这个问题才算从根本上解决。但架构组件示例仓库 archived 的时候,3.2.0 还没发布。
Hilt 和测试:示例没说完的事
HiltSample 是仓库里比较新的模块,展示了依赖注入在 Android 组件里的应用。示例覆盖了 Activity、Fragment、ViewModel、Service 的注入,以及 @HiltAndroidApp 的 Application 级初始化。但测试相关的示例非常薄弱,只有一个简单的 ExampleInstrumentedTest,用了 HiltAndroidRule 和 HiltAndroidTest 注解。
实际项目中,Hilt 的测试配置比示例复杂得多。首先是 UninstallModules 的用法,示例里没有展示如何在测试时替换生产环境的绑定。比如你的 NetworkModule 里绑定了真实的 Retrofit 实例,测试时需要换成 MockWebServer 的地址,就必须用 @UninstallModules(NetworkModule::class) 配合测试专属的 @Module。这个机制在 Hilt 2.35 之后稳定下来,但示例代码没有更新。
其次是 TestInstallIn 的引入。Hilt 2.40 新增了 @TestInstallIn 注解,可以更方便地在测试里替换绑定,而不需要 UninstallModules 那种先卸载再安装的繁琐流程。但 @TestInstallIn 有个限制:它只能替换 @InstallIn 标记的模块,对于 @EntryPoint 接入的组件无能为力。这个细节在官方文档里一笔带过,我是看了 Hilt 的源码提交记录才搞清楚的。架构组件示例仓库完全没有涉及这个高级用法。
更实际的问题是测试执行速度。Hilt 的 BytecodeInject 机制在编译期生成大量代码,一个中等规模的项目(大概 50 个注入点)的测试 APK 构建时间可能增加 30% 以上。示例项目太小,体现不出这个问题。我后来在公司 CI 环境里做了拆分,把需要 Hilt 的集成测试和纯单元测试分开跑,但这不是示例能教你的。
现在该去哪找这些资源
架构组件示例仓库虽然 archived 了,但代码还在,地址是 https://github.com/android/architecture-components-samples,可以直接访问。每个模块的 README 里有对应的 Codelab 链接,那些逐步教程比直接读代码更容易上手。Codelab 是免费的,不需要翻墙,但加载速度有时候不太稳定。
现在的官方推荐是 nowinandroid,地址 https://github.com/android/nowinandroid。这个项目用上了最新的技术栈:Compose UI、Material 3、Kotlin DSL 构建脚本、版本目录(Version Catalogs)、模块化架构(feature module 和 core module 分离)。但它的问题也很明显:太完整了,完整到学习曲线陡峭。一个刚接触 Android 的开发者,面对几十个 module、几百个文件,根本不知道从哪里开始。我的建议是,先通过 archived 的架构组件示例仓库理解每个独立技术点,再去看 nowinandroid 怎么把它们组合起来。
nowinandroid 里有个设计决策值得单独提:它用了 dataSync 这种 convention plugin 来统一模块的构建配置,而不是在每个 module 的 build.gradle.kts 里重复写依赖。这个做法在大型项目里很有必要,但增加了理解成本。你要先搞懂 Gradle 的 convention plugin 机制,才能看懂为什么 build-logic 目录里有一堆 .gradle.kts 文件。架构组件示例仓库还是传统的每个模块独立配置,对 Gradle 新手更友好。
另外,nowinandroid 的模块化拆分也不是银弹。它的 feature module 依赖 core:ui、core:data 等基础模块,但 core:data 里既包含了 Repository 实现,又包含了 Room 的 Entity 定义和 Retrofit 的 Service 接口。这在严格的分层架构里是有争议的——数据层和领域层的边界模糊了。Google 的架构指南里推荐的是 "Clean Architecture" 分层,但 nowinandroid 的实际代码并没有完全遵循。我个人觉得这种妥协是合理的,因为过度分层会增加不必要的抽象成本,但如果你是带着学习 "标准架构" 的期望去看,可能会困惑。
一些零散但实用的点
架构组件示例仓库里有个 BasicRxJavaSample 和 BasicRxJavaKotlinSample,这两个模块对比了 RxJava 2 和 Coroutines 在相同场景下的写法。现在 Coroutines 已经是主流,RxJava 的新项目很少用了,但这两个模块的历史价值在于:它们展示了 Google 官方从 RxJava 向 Coroutines 迁移的思维过程。特别是 BasicRxJavaKotlinSample 里用了 LiveData 做 UI 层的数据持有,而 Flow 的示例是在更晚的 nowinandroid 里才出现。这个演进路径本身就有学习意义——理解为什么 LiveData 在 ViewModel 层仍然有用(生命周期感知),而 Flow 更适合数据流的连续处理。
GithubBrowserSample 是仓库里最早期的模块之一,展示了 MVVM 架构配合 Dagger 2(不是 Hilt)的用法。这个模块现在看起来有点过时,但它包含了一个很有价值的细节:NetworkBoundResource 类的实现。这个抽象类封装了 "先从本地加载缓存,同时触发网络刷新,网络成功后再更新本地" 的常见模式。Room 配合 Retrofit 的项目几乎都会遇到这个需求,但官方后来没有把这个类做成库里的正式 API。nowinandroid 里对应的是 Syncable 接口和 Synchronizer 机制,更灵活但更复杂。如果你只需要一个简单的网络-本地同步策略,直接从 GithubBrowserSample 里 copy NetworkBoundResource 的代码,改改用 Flow 替代 LiveData,是个快速落地的方案。
最后说一个编译问题。架构组件示例仓库的 Gradle 版本停留在 7.x,AGP 版本是 7.2 左右。如果你用最新的 Android Studio(Giraffe 或 Hedgehog)直接打开,可能会遇到 Gradle JDK 版本不匹配的错误。Android Studio 现在默认用 JDK 17,而老项目可能要求 JDK 11。解决方式是在 gradle.properties 里指定 org.gradle.java.home,或者在 Android Studio 的 Settings 里单独给这个项目配置 JDK。这不是代码问题,但会卡住很多想直接跑示例的人。
这些示例仓库的价值不在于代码可以直接复制到生产环境——它们确实不能直接复制,版本旧、边界情况处理不足、缺少完整的错误处理和测试覆盖。但它们提供了一个经过 Google Android 团队审核的 "基准实现",你可以从这个基准出发,理解设计意图,再根据自己的项目需求做调整。比起在 Stack Overflow 上找碎片化的代码片段,或者看某些 Medium 上为了炫技而过度设计的博客,这种官方示例的可靠性要高得多。关键是你要知道它们的局限在哪,什么时候该深入源码,什么时候该转向更新的资源。