简介:这份面向Android应用开发者的第三方库资料包,围绕日常开发中高频使用的视图注入、网络请求、图片加载、依赖注入、事件通信、响应式编程与数据持久化等场景,整理了Butter Knife、Gson、Retrofit、OkHttp、Glide、Dagger 2、EventBus、RxJava、GreenDao、Room、LiveData/ViewModel、LeakCanary、Timber等库的相关内容,适合初中级开发者查漏补缺,也可供有经验者做选型对比。压缩包共2000个文件,以xml布局与资源、png图片、json配置、java源码、class字节码以及jar/aar依赖包为主,整体约22.67MB,便于按库检索和本地查阅。已有1349人学习下载,说明其在Android学习群体中具有一定参考价值。读者可从中了解各库解决的问题、典型使用方式和适用边界,减少重复实现基础功能的成本,并为项目集成与技术选型提供依据。
1. 选错一个图片库,我在低端机适配会上被按着复盘
去年做一个以图片流为主的项目,图省事选了当时社区呼声最高的那个加载库,功能确实全,变换、动画、进度回调一应俱全。上线前在一批 3GB 内存的老机器上跑列表,滑到第三十屏开始明显掉帧,内存峰值冲到四百多兆,监控曲线像心电图。换成另一个更轻的方案,同一套列表、同样的图源,掉帧率降了七成,代价只是少了几个高级变换。这件事之后,我把 Android 第三方库的选型从「看谁 star 多」改成「看谁能在下一次升级里活下来」,这份清单就是那段时间一份份对比、一次次回滚攒出来的。如果你正在给项目挑库,或者刚接手一个依赖几十个第三方库的历史工程,下面这些判断依据和踩坑记录,能帮你省掉我加班的那几个凌晨。
2. 网络层和图片层最费神:用四个维度把候选库筛到只剩一个
2.1 先立评判维度,再谈选谁
挑库最容易犯的错,是打开搜索页看排行榜。排行榜衡量的是「有多少人用过」,不是「适不适合你这个工程」。我现在固定用四个维度打分,每个维度 1 到 3 分,四项里只要有一项是 1 分,这个库基本就不进主链路了——那个 1 分项,往往就是半年后返工的起点。
第一个维度是包体积增量。做法很土但有效:在引入之前打一个 release 包,用构建工具自带的包分析器记下 dex 方法数、so 体积和资源占用;引入之后再打一个,两包对比。有些库主打功能大而全,代价是几 MB 的 native 库,对安装包敏感的渠道来说这笔账必须先算清楚。我见过一个项目因为三个不同厂商的 SDK 各自带了一份同类的 so,光这一项就多出近十兆。
第二个维度是线程模型和协程的贴合度。现在新写的代码基本都是挂起函数加 Flow,如果一个库的主 API 还是回调加 Handler,你就得在中间套一层适配,这层适配代码没人维护,出了问题是典型的黑匣子。判断方法很简单:看它的核心接口有没有 suspend 版本,看它内部是不是自己 new 线程池。
第三个维度是维护活跃度和破坏性变更频率。不是看最近一次提交时间那么简单,要看它的版本号节奏——如果一个库每隔两个小版本就改一次包名或者改一次初始化方式,你的升级成本会指数级上升。反过来,长期不发布新版本也未必是好事,可能意味着它跟不上新的系统行为变更。
第四个维度是可替换性,也就是有没有一层稳定的抽象。这条最容易被忽略,却最能救命。我的习惯是:业务代码不直接依赖具体库的类型,中间隔一个自己定义的接口,库只做实现。换库的时候改一个实现类,而不是改三百个调用点。
提示:四个维度打分只是辅助决策,真正要盯的是那个 1 分项。分数可以妥协,短板不能。
2.2 网络层三选一:Retrofit、Ktor 与裸用 OkHttp
网络层是绝大多数 App 的骨架,选型错了后面全是补丁。这三个候选不是同一个抽象层级的东西,放在一起比才有意义。
| 维度 | Retrofit | Ktor Client | 裸用 OkHttp |
|---|---|---|---|
| API 风格 | 声明式接口 | 声明式接口加 DSL | 手写 Request 构造 |
| 协程支持 | 原生 suspend | 原生 suspend | 需自行封装 |
| 包体积增量 | 中等 | 偏大(含引擎与插件) | 最小 |
| 多平台能力 | 有限 | 好 | 有限 |
| 学习成本 | 低 | 中 | 高 |
| 适合场景 | 单平台业务 App | 需要多端共享网络层 | 接口极少的工具或 SDK |
结论我给得很直接:单平台业务 App 用 Retrofit,需要多端共享网络层用 Ktor Client,只调三五个接口的小工具或者要嵌进别人 App 的 SDK 直接裸用 OkHttp。中间那种「既想轻又想全」的犹豫最耗时间,不如按接口数量划线:超过十个接口就别手写了。
// 声明式接口:返回类型直接写业务模型,调度交给框架 interface UserApi { // suspend 让调用方直接 await,不用回调套回调 @GET("v1/user/{id}") suspend fun getUser(@Path("id") id: Long): UserDto // @Body 走序列化转换器,请求体不需要自己拼 JSON @POST("v1/login") suspend fun login(@Body body: LoginRequest): TokenDto }这段代码的关键在suspend:加了它,框架会用协程调度器执行请求,调用侧写val user = api.getUser(1)就够了,异常通过 try/catch 捕获。@Path会自动做 URL 编码,别自己拼字符串,中文和斜杠会翻车。转换器只配一个,别同时挂两套序列化方案,否则类型解析的顺序问题能查一下午。
底层客户端我一般只留一个单例,超时按业务分:普通接口连接 10 秒、读 15 秒,上传下载单独建一个客户端放宽到分钟级。拦截器数量控制在三个以内——一个加公共头,一个打日志(只在 debug 变体启用),一个做统一错误码映射。拦截器越多,请求链路越像迷宫。
2.3 图片层三选一:Glide、Coil 与 Fresco
图片库的差异比网络库大得多,因为它直接吃内存。
| 维度 | Glide | Coil | Fresco |
|---|---|---|---|
| 内存策略 | 自建 Bitmap 池复用 | 依赖底层客户端加内存缓存 | 堆外匿名共享内存 |
| 协程与 Compose | 需扩展适配 | 原生支持 | 支持较弱 |
| 包体积增量 | 中 | 小 | 大 |
| 复杂变换能力 | 强 | 够用 | 中 |
| 适合场景 | 老项目、重变换 | 新项目、声明式 UI | 图片极重且内存吃紧 |
新项目我直接选 Coil,API 是挂起函数风格,和现在的写法一致,包体积也小。老项目如果 Glide 已经跑稳、用了大量自定义变换,别为了「新」去动它,迁移的收益抵不上回归测试的成本。Fresco 只在图片体量极大、堆内存频繁告警的场景才值得考虑,它的堆外方案确实能压住峰值,但接入成本和包体积的代价也是实打实的。
真正决定图片性能的不是库,是尺寸。列表里的缩略图必须按控件尺寸降采样,不给目标尺寸,库就会按原图解码,一张四千万像素的图能把内存直接打穿。列表快速滑动时暂停加载、停止时恢复,这个开关一定要接上。还有onTrimMemory回调里清缓存,很多项目压根没写。
2.4 序列化:Gson 到 kotlinx.serialization 的迁移判断
序列化库选错的代价不在性能,在空安全。反射型方案会绕过构造器直接填字段,Int字段遇到 null 就变成 0,String遇到 null 就变成 null,线上出现的那些「用户昵称显示 null」多半来自这里。
| 维度 | Gson | Moshi | kotlinx.serialization |
|---|---|---|---|
| 空安全 | 反射填充,风险高 | 支持代码生成 | 编译期校验最严 |
| 默认值 | 不生效 | 支持 | 支持 |
| 混淆成本 | 需 keep 模型类 | 生成后可裁剪 | 无需额外规则 |
| 多平台 | 不支持 | 支持 | 支持 |
| 迁移成本 | 基准 | 中 | 中 |
我的判断标准是:接口字段多、后端不规范、历史包袱重,先用 Moshi 过渡,它的代码生成能保住空安全又不要求一次性改完;新模块直接上 kotlinx.serialization,编译期就能把类型不匹配的问题拦住,混淆规则也省了。迁移别一次全切,按模块来,先切调用链最干净的那个,跑一周线上再推下一个。
3. 架构三件套怎么配:注入、存储、事件流各留一个口子
3.1 依赖注入:Hilt 与 Koin 的分界线在哪
依赖注入这件事,小项目觉得多余,大项目离不开。Hilt 和 Koin 代表两条完全不同的路线,一个是编译期生成,一个是运行期查找。
| 维度 | Hilt | Koin |
|---|---|---|
| 校验时机 | 编译期,写错直接构建失败 | 运行期,第一次取实例才报错 |
| 构建耗时 | 明显增加 | 几乎无影响 |
| 多模块支持 | 强,模块边界清晰 | 需要手动组织模块定义 |
| 学习成本 | 高,注解和限定符多 | 低,DSL 直白 |
| 适合场景 | 中大型、多人协作 | 小项目、快速原型 |
我的分界线是模块数:三个模块以上、有明确的 feature 分层、团队超过三个人,就上 Hilt,编译期把注入错误拦下来这件事,在多人协作里价值极高,省下的调试时间远超构建多等的那几十秒。反过来,一个两周要交付的独立小项目,用 Koin 几行 DSL 就配完了,没必要为几个依赖关系引入注解处理器。
用 Hilt 最容易踩的坑是模块边界。功能模块里定义的绑定,应用模块拿不到,必须通过接口下沉到基础模块。所以项目初期就要把「哪些是跨模块契约」想清楚,等模块多了再补,改动面会非常大。Koin 的坑则在运行期:定义写错不会报错,直到某个页面打开才崩,所以配 Koin 的项目必须有一层启动自检,把所有模块定义在 debug 构建里先解析一遍。
3.2 本地存储:Room、DataStore 与 MMKV 各管一摊
存储选型的核心是分清你要存什么,而不是哪个库更流行。
| 维度 | Room | DataStore | MMKV |
|---|---|---|---|
| 数据形态 | 结构化表 | 键值(含类型化对象) | 键值 |
| 查询能力 | 完整 SQL | 无 | 无 |
| 线程模型 | 挂起函数与 Flow | 挂起函数与 Flow | 同步读写 |
| 迁移支持 | 版本迁移脚本 | 需自行处理 | 无结构迁移 |
| 适合场景 | 列表、关联查询 | 配置项、开关 | 高频小数据 |
结论很清楚:需要按条件查、需要联表、需要分页排序的,用 Room;存用户偏好、主题、开关这类配置,用 DataStore,它天然返回 Flow,改了值界面自动刷新;高频读写的小块数据比如登录态、最后浏览位置,用 MMKV,同步读写够快。
三种都用不冲突,关键是别混用。我见过把用户列表塞进键值存储再用 JSON 反序列化的写法,为了查一个字段要全量解析一遍,数据量一上来就是灾难。也见过拿数据库存单个布尔开关的,为了一个值建表建迁移,纯属给自己加活。
Room 这边最需要注意的是迁移。生产环境一定要写显式迁移脚本,别开破坏性重建,用户的本地数据说没就没。每加一个字段就写一个版本迁移,测试环境再加一条覆盖全版本的迁移测试,这条投入我从来不打折。
3.3 事件总线:把 EventBus 换到 SharedFlow 的实际路径
事件总线在小项目里很爽,全局发一个事件到处能收,但它的隐式耦合会在项目变大后集中爆发:谁发的、谁收的、生命周期对不对,全靠人脑记。我现在的默认做法是用 SharedFlow 做作用域内的单向事件流。
// 仓库层持有一个热流,只暴露只读视图,防止外部随意发射 private val _events = MutableSharedFlow<UiEvent>( replay = 0, // 不做粘性,避免新订阅者收到历史事件 extraBufferCapacity = 8, // 缓冲,防止发射方被挂起 onBufferOverflow = BufferOverflow.DROP_OLDEST ) val events: SharedFlow<UiEvent> = _events.asSharedFlow() // 发射用 tryEmit,不阻塞调用线程 fun notifyRefresh() { _events.tryEmit(UiEvent.Refresh) }replay = 0是关键参数,设成 1 就变成粘性事件,页面重建时会重新收到一次,容易出现「刷新执行两遍」。extraBufferCapacity给一点缓冲,超过就丢最旧的,宁可丢事件也不要卡住发射方。收集侧必须在有生命周期的协程作用域里,页面销毁自动取消,这一点比老式总线的手动反注册可靠得多。
迁移时不用一次全拆。先把事件按业务域拆成几个独立的流,一个域一个流,别搞一个全局大流;再逐个把消费方从注册监听改成收集流。什么时候算迁移完?当你搜代码里已经没有全局单例的总线对象时,就差不多了。
4. 避坑与排查:五条翻车记录和一套固定动作
4.1 五条翻车记录:现象、原因、解决
第一条:debug 包一切正常,release 包一进页面就崩。
现象是抛类找不到或者方法不存在的异常,堆栈里全是被混淆后的短类名。原因是混淆规则没跟上,某几个库靠反射找类、找注解,代码裁剪阶段把「看起来没人调用」的类删掉了。解决办法是给每个引入的库补 keep 规则,序列化模型类、注解类、被反射调用的类都要保;开构建产物分析看一眼到底删了什么,比猜快得多。补完规则一定要打 release 包真机验证,debug 变体不会跑这套流程,别用 debug 的结论骗自己。
第二条:多个库带不同版本的同一个传递依赖,运行时报重复类。
现象是构建通过、安装成功,运行到某个功能点直接崩,日志里出现同一路径两个来源。原因是 A 库依赖了某协程库 1.4,B 库依赖了 1.6,打包时都进了 dex。解决办法是先跑依赖树把冲突点定位出来,用平台约束或者 BOM 统一版本,实在统一不了的做依赖排除,然后显式声明你需要的那个版本。别用强制覆盖了事,覆盖后如果 API 不兼容,问题会推迟到运行时才暴露。
第三条:列表滑到一半内存告警,监控里图片缓存一路涨。
现象是只在特定机型出现,图越多的页面越明显。原因是没有按目标尺寸解码,加上缓存策略用了默认值,原图被完整读进内存还长期持有。解决办法是三件事一起做:所有列表图片都指定目标宽高、给缓存设置上限并按内存等级清理、页面不可见时暂停加载。改完再看峰值,通常能降一半以上。这条属于典型的血泪经验,光换个库解决不了。
第四条:冷启动时间莫名多了四百毫秒,代码没动过。
现象是新增一个统计或推送 SDK 之后,启动耗时曲线整体上移。原因是这类 SDK 喜欢用启动自动初始化的机制,把自己的初始化塞进应用启动流程,还不在你的代码里。解决办法是查合并后的清单文件,确认是哪个组件在自动初始化,按官方方式关掉自动初始化,改成首帧渲染之后再手动初始化。不是所有 SDK 都能关,不能关的就评估要不要换。
第五条:升级了某个库的新大版本,编译全绿,运行时偶发闪退。
现象是概率性的,可能几十次才崩一次,堆栈指向框架内部。原因是破坏性变更里有一条改了线程模型,原来的回调现在在子线程执行,而界面更新在主线程。解决办法是升级前先读完变更说明,重点看线程、生命周期、默认值这三类改动;升级后不要只看编译结果,把涉及该库的所有页面手动过一遍,复杂项目直接补一套回归清单。
4.2 出问题后的固定排查顺序
踩坑踩多了就形成肌肉记忆,我现在遇到依赖问题按固定顺序走,不做跳跃。
第一步永远是看依赖树,别猜。跑一次编译期依赖报告,把输出重定向到文件,按坐标搜冲突版本。这一步能解决七成的重复类和版本不一致问题,而且成本最低。
# 导出 release 变体的完整依赖树,用于对比版本冲突 ./gradlew :app:dependencies --configuration releaseRuntimeClasspath > deps.txt # 定位某个具体依赖被谁引入,以及最终解析到哪个版本 ./gradlew :app:dependencyInsight \ --configuration releaseRuntimeClasspath \ --dependency kotlinx-coroutines-core第一个命令输出的是整棵树,重点看那些被标记为版本冲突的条目,箭头指向的就是最终生效版本。第二个命令更有针对性,给定一个库名,它会告诉你哪些上游把它带进来、每一条路径请求的版本是多少、为什么最后选了某个版本。查冲突时优先用第二个,输出短、信息密度高。
第二步看产物。把 release 包拖进包分析器,看 dex 里到底有没有那个类,看 so 目录下每个架构的体积分布。有些问题在依赖树里看不出来,因为打包阶段有额外的裁剪和合并动作。
第三步才是运行时验证。打开严格模式的线程与资源检测,跑一遍核心链路,把主线程磁盘读写、未关闭的流、泄漏的实例都揪出来。这一步能发现那些「功能正常但迟早出事」的隐患。
注意:依赖问题不要靠反复清理缓存和重启 IDE 解决。构建缓存只是放大器,不是根因,清了能跑通大概率是增量编译掩盖了冲突。
5. 依赖治理:把「库多到失控」变成可量化的清单
5.1 版本对齐:版本目录加 BOM,先把地基压平
库一多,版本号散落在各个模块的构建脚本里,改一个版本要全局搜索替换,还容易漏。第一步是把所有版本号收拢到一个版本目录文件里,坐标和版本分离。
# gradle/libs.versions.toml:所有版本集中在一处,模块只引用别名 [versions] coroutines = "1.7.3" # 协程统一版本,全项目唯一来源 retrofit = "2.9.0" [libraries] coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }这样改版本只改一处,模块里写implementation(libs.coroutines.core),版本冲突从源头少了一大半。对于官方提供物料清单的库族,优先用物料清单约束版本,它保证同一族里所有组件互相兼容,不用你一个个对。没有物料清单的,就在构建脚本里加版本约束块,把关键依赖的版本钉死,让传递依赖乖乖就范。这一步做完,依赖树会短一截,后续排查也轻松。
5.2 体积与裁剪:R8 规则和 so 过滤的实操
体积治理的核心思路是「能裁就裁,能只打一个架构就不打四个」。代码侧开代码压缩和资源压缩,把用不到的类、资源、语言包全部剔掉;native 侧用架构过滤,只保留主流架构,其余走兼容方案。
// 只保留两个主流 ABI,其余架构由兼容包兜底 android { defaultConfig { ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } } buildTypes { release { isMinifyEnabled = true // 开启代码压缩与混淆 isShrinkResources = true // 开启无用资源裁剪,依赖混淆结果 proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } }isShrinkResources依赖isMinifyEnabled,两个必须一起开,只开资源裁剪不会生效。混淆规则文件里按库分组写注释,谁加的规则、为什么加,写清楚,否则半年后没人敢删。每条规则都要能对应到一个具体的运行时问题,写不出理由的规则大概率是历史遗留,删掉之前跑一遍回归。
还有个细节:很多库自带消费者规则,会自动合并进来,不需要你手写。所以遇到崩溃先确认这个库有没有自带规则,别急着自己写一条宽泛的通配规则,那等于把整个包都保下来,体积白白变大。
5.3 定期体检:三步把隐形依赖挖出来
依赖治理不是一次性动作,我一般每个版本周期做一次体检,三步走。
第一步,导出依赖清单,按引入来源分组,看有没有同一个功能被两个库重复实现——两个网络客户端、两个图片加载器、两个日志框架,这类重复最占体积也最容易出诡异问题。第二步,看每个库的最近使用位置,搜一遍调用点,超过半年没人碰的库列进待删清单,下个版本安排移除。第三步,回归体积曲线,把每个版本的安装包大小记下来,一旦某个版本突然涨了几百 K,立刻定位是哪个改动带进来的,这时候查最省力。
这套动作花不了半天,但能避免「项目做了三年,没人说得清依赖了多少个第三方库」这种局面。等到不得不治理的时候,成本已经是平时的十倍。
6. 落到一张最小清单:新项目我会这么配
把上面所有判断收拢成一张表,这是我现在的默认配置,新项目直接照抄,遇到特殊情况再逐项调整。
| 层 | 默认选择 | 什么情况下换 |
|---|---|---|
| 网络 | 声明式接口库加单一客户端 | 多端共享网络层换多平台方案;接口少于十个裸用客户端 |
| 图片 | 轻量协程图片库 | 老项目已稳定运行且重度使用自定义变换则不迁 |
| 序列化 | 编译期序列化 | 历史接口字段混乱先用代码生成方案过渡 |
| 注入 | 编译期注入框架 | 单模块小项目或原型用轻量运行期方案 |
| 存储 | 数据库加类型化键值 | 高频小块数据补充同步键值存储 |
| 事件 | 作用域内的热流 | 跨进程或跨模块解耦再考虑总线方案 |
配套三条硬规则,写进团队规范里:业务代码不直接依赖具体库的类型,中间必须隔一层自己定义的接口;任何新库引入前先打两个包对比体积,增量超过阈值的必须给出理由;每个版本周期做一次依赖体检,把没人用的库清出去。
回过头看,我踩过最大的坑不是选错了某个库,而是没有及时换掉它。当时想着「先用着,以后再说」,结果这个库的 API 渗透进了二十多个页面,再想换就得排一个迭代的工期,还得全量回归。从那以后我每次引入第三方库之前,都会强制走一遍「抽象层加体积对比加退出方案」这三步,哪怕是个只有两个方法的小工具库,也要先想清楚怎么把它摘出去。这份清单你可以直接拿去对,也可以只挑其中一层先用起来。希望帮到你。
本文还有配套的精品资源,点击获取