Android图书类App骨架:Material+异步加载+离线缓存

发布时间:2026/10/23 16:23:18
Android图书类App骨架:Material+异步加载+离线缓存

简介:这是一款基于Java开发的NHentai安卓客户端,遵循Material Design设计规范,面向Android开发者及对开源客户端架构感兴趣的技术学习者,提供轻量、美观且易用的本子浏览体验。资源包共163个文件,涵盖62个Java源码文件(实现核心业务逻辑与UI交互)、42个PNG图标资源(支撑Material风格界面)、38个XML布局与配置文件(定义页面结构与主题样式),以及Gradle构建脚本、APK安装包等关键工程文件,整体压缩包仅3.2MB,结构清晰、便于二次开发与学习研究。已有3182人下载学习,适合希望深入理解Android Material组件集成、Jsoup网络解析、API对接及开源项目工程组织方式的中高级开发者。读者可直接运行app-release.apk体验完整功能,并通过源码快速掌握从网络请求、数据解析到UI渲染的全链路实现细节。

1. 这不是个“看图工具”,而是一套可复用的 Android 图书馆客户端骨架:Material Design + 异步加载 + 离线缓存三位一体

某开发者在接手一个内部知识库 App 时,发现团队反复踩同一个坑:首页瀑布流卡顿、搜索结果页返回后状态丢失、夜间模式切换时图片闪烁、离线场景下直接白屏。他翻遍 GitHub 上几十个开源阅读类项目,最后把NHentai-android拆开跑了一遍——不是为了“看什么”,而是抄它的页面生命周期管理策略、图片请求与缓存协同机制、RecyclerView 多类型 Item 的解耦封装。这个项目表面是“NHentai 客户端”,实则是用真实业务压力锤炼出来的 Android 图书/画册类应用参考实现:它完整覆盖了从网络请求(Retrofit)、异步调度(RxJava)、UI 层解耦(MVP)、图片加载(Glide + 自定义 DiskCache)、离线存储(Room + 文件系统混合)到 Material 组件落地(BottomAppBar + NavigationRail + Dynamic Color 适配)的全链路。如果你正在开发一个需要支持海量缩略图加载、多级分类导航、主题色动态适配、且必须离线可用的 Android 应用,这份代码比任何教程都更接近生产现场。它不教你怎么写 Hello World,但会告诉你:当用户快速滑动 200+ 封面卡片时,Glide 的thumbnail()怎么配、RequestOptions里哪几个参数不能动、onDetachedFromWindow()里必须 cancel 哪些 request——这些才是真正在一线写代码的人每天要掰开揉碎处理的问题。

2. 项目结构与技术栈:从 Gradle 配置到模块职责划分的硬核拆解

2.1 目录结构:6 个 module 的分工逻辑与依赖流向

整个项目采用标准的 Android 多 module 结构,共 6 个子模块,彼此边界清晰,无循环依赖:

app/ # 主入口,仅含 Application 类、启动 Activity 和基础 Theme core/ # 全局基础能力:网络拦截器、通用 BasePresenter/BaseView、日志工具、资源工具类 data/ # 数据层:Retrofit Service 接口、Room Entity/DAO、远程数据源(RemoteDataSource)、本地数据源(LocalDataSource)、Repository 实现 domain/ # 业务逻辑层:UseCase(如 GetBookListUseCase)、实体类(Book, Tag, Page)、纯 Kotlin 数据类,无 Android SDK 依赖 presentation/ # 表示层:Activity/Fragment、Adapter、ViewModel(注意:此处用的是 MVP,非 MVVM)、View 接口定义 ui/ # UI 组件层:自定义 View(如 CircleProgressView)、Material 扩展(BottomAppBarBehavior 重写)、主题色工具类(DynamicColorHelper)

提示:domain模块是真正的“纯业务”层,所有 UseCase 都在此定义,且只依赖data模块的接口(而非具体实现),这为后续替换数据源(比如把 Room 换成 SQLiteDirect 或 Mock 数据)留出空间。我一般会强制要求新成员先读懂GetBookListUseCase的输入输出契约,再去看data层怎么实现它。

2.2 Gradle 配置:Kotlin DSL + 版本目录(Version Catalog)的实战写法

项目使用libs.versions.toml管理依赖版本,这是目前 Android Studio Flamingo 及以上推荐的现代方式。关键配置节如下(摘自gradle/libs.versions.toml):

[versions] agp = "8.1.4" kotlin = "1.9.0" androidx-core = "1.12.0" material = "1.10.0" retrofit = "2.9.0" rxjava = "2.2.21" glide = "4.15.1" room = "2.6.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } retrofit-runtime = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-rxjava = { group = "com.squareup.retrofit2", name = "adapter-rxjava2", version.ref = "retrofit" } glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

app/build.gradle.kts中引用方式简洁明了:

dependencies { implementation(libs.androidx.core.ktx) implementation(libs.material) implementation(libs.retrofit.runtime) implementation(libs.retrofit.rxjava) implementation(libs.glide) kapt(libs.glide.compiler) // 注意:kapt 必须用 libs.xxx 写法,不能用字符串 implementation(libs.room.runtime) kapt(libs.room.compiler) }

这种写法的好处是:全局版本一处修改,全项目生效;IDE 支持跳转到定义;Gradle Sync 时能提前校验依赖冲突。新手常犯的错误是把kapt依赖写成implementation,导致@GlideModule注解不生效,App 启动时报Glide is not initialized—— 这个坑我们后面“避坑章节”会细说。

2.3 核心技术栈选型依据:为什么是 RxJava 而不是 Flow?为什么 Glide 不换 Coil?

  • RxJava 2.x:项目选择 RxJava 而非 Kotlin Flow,根本原因是历史包袱与团队熟悉度。data层所有 Repository 方法返回Single<T>或Completable,presentation层 Presenter 订阅后分发到 View。虽然 Flow 更轻量,但 RxJava 的compose()操作符链式处理异常(如retryWhen()处理网络抖动)、observeOn()/subscribeOn()显式线程控制,在复杂异步流程(如“先查缓存 → 缓存无则请求网络 → 网络失败则弹 Toast”)中调试更直观。Flow 的catch和flowOn在嵌套协程中容易丢失上下文,对新手不友好。

  • Glide 4.15.1:未升级到 5.x 是因GlideApp的Generated API与项目自定义AppGlideModule深度绑定。项目在ui模块中重写了GlideModule,强制启用DiskCacheStrategy.ALL并自定义了DiskLruCacheWrapper的大小(512MB),同时为所有.png请求添加了override(1024, 1400)防止 OOM。Coil 虽然基于 Coroutines,但其ImageLoader的memoryCachePolicy和diskCachePolicy配置粒度不如 Glide 的RequestOptions直观,且项目中大量使用Glide.with(context).load(url).apply(options).into(imageView)模式,迁移成本高。

  • MVP 而非 MVVM:presentation模块中BookListActivity持有BookListPresenter实例,Presenter 持有BookListView接口引用。这种解耦让单元测试(JUnit + Mockito)可完全 mock View 行为,无需启动 Activity。MVVM 的ViewModel依赖LiveData,其observe()在onDestroy()后自动解注册虽省心,但LiveData的粘性事件(Sticky Event)问题在列表页刷新场景下极易引发 UI 错乱(如“下拉刷新完成后再点某个 item,却触发了上一次的点击事件”),MVP 的显式view.showLoading()/view.hideLoading()更可控。

3. 核心模块拆解:BookListActivity 到 BookDetailFragment 的完整请求链路

3.1 首页瀑布流:RecyclerView + StaggeredGridLayoutManager + 自定义 LoadMore 机制

BookListActivity使用StaggeredGridLayoutManager实现两列不等高瀑布流,关键在于BookListAdapter的onBindViewHolder如何安全加载图片:

override fun onBindViewHolder(holder: BookListViewHolder, position: Int) { val book = getItem(position) holder.bind(book) // Glide 加载:必须传入 Activity Context,避免内存泄漏 GlideApp.with(holder.itemView.context) .load(book.coverUrl) // 来自网络的封面 URL .placeholder(R.drawable.ic_book_placeholder) // 占位图 .error(R.drawable.ic_book_error) // 加载失败图 .override(400, 600) // 强制缩放到 400x600,防止大图 OOM .centerCrop() // 居中裁剪,适配 ViewHolder 的 ImageView 尺寸 .diskCacheStrategy(DiskCacheStrategy.ALL) // 磁盘缓存原始图 + 缩略图 .transition(DrawableTransitionOptions.withCrossFade()) // 淡入过渡 .into(holder.ivCover) // 关键:onViewRecycled 里必须 clear,否则滑动时 Glide 会持有 ViewHolder 引用 holder.itemView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View?) {} override fun onViewDetachedFromWindow(v: View?) { GlideApp.with(holder.itemView.context).clear(holder.ivCover) } }) }

逻辑说明:override(400, 600)是血泪经验——原图可能达 2000x3000,直接加载必崩;diskCacheStrategy.ALL确保下次打开同一本书时封面秒出;addOnAttachStateChangeListener是防内存泄漏的后悔药,因为onBindViewHolder可能被快速调用多次,而Glide.clear()必须在 View 被回收时执行,否则 Glide 内部的RequestManager会持续持有ImageView,导致 Activity 无法 GC。

3.2 分页加载:Presenter 层的 LoadMore 状态机与网络请求编排

BookListPresenter中loadMore()方法是典型的状态机:

fun loadMore() { if (isLoading || isLastPage) return // 防重复触发 isLoading = true view?.showLoadingMore() getBookListUseCase.execute( GetBookListParams( page = currentPage + 1, query = currentQuery // 支持搜索场景 ) ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doFinally { isLoading = false } .subscribe( { books -> if (books.isEmpty()) { isLastPage = true view?.showNoMore() } else { currentPage++ view?.addBooks(books) // 通知 Adapter 添加新数据 } }, { error -> view?.showError(error.message ?: "加载失败") // 注意:这里不重置 currentPage,用户可再次点击重试 } ) }

参数说明:GetBookListParams是domain层定义的纯数据类,page从 1 开始(服务端约定);currentQuery为空字符串时表示首页推荐,非空时为搜索关键词。doFinally确保无论成功失败,isLoading都会被重置,避免 UI 卡死。view?.addBooks(books)是 MVP 的核心:Presenter 不操作 UI,只通过 View 接口通知变化,BookListActivity实现该接口并调用adapter.submitList(newList)触发 DiffUtil 计算。

3.3 书籍详情页:Fragment + ViewPager2 + 自定义图片预览器

BookDetailFragment使用ViewPager2展示书籍内页,每页是一个PageFragment,其核心是Glide的SimpleTarget实现:

class PageFragment : Fragment() { private var _binding: FragmentPageBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentPageBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val pageUrl = arguments?.getString("page_url") ?: return // 使用 SimpleTarget 避免 ImageView 生命周期干扰 val target = object : CustomViewTarget<ImageView, Drawable>(binding.ivPage) { override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) { binding.ivPage.setImageDrawable(resource) binding.progressBar.visibility = View.GONE } override fun onLoadFailed(errorDrawable: Drawable?) { binding.ivPage.setImageDrawable(errorDrawable) binding.progressBar.visibility = View.GONE } override fun onLoadStarted(placeholder: Drawable?) { binding.ivPage.setImageDrawable(placeholder) binding.progressBar.visibility = View.VISIBLE } } GlideApp.with(this) .load(pageUrl) .placeholder(R.color.gray_light) .error(R.color.red_light) .diskCacheStrategy(DiskCacheStrategy.DATA) // 只缓存原始数据,不缓存变换后图 .into(target) } override fun onDestroyView() { super.onDestroyView() _binding = null // 必须 clear target,否则 Glide 持有 Fragment 引用 GlideApp.with(this).clear(target) } }

逻辑说明:CustomViewTarget是 Glide 提供的高级用法,用于将图片加载到非ImageView的 View(或需精确控制时机)。此处PageFragment的ivPage是ImageView,但用SimpleTarget可绕过ImageView的onDetachedFromWindow回调,由 Fragment 自己控制clear()时机,彻底规避内存泄漏。diskCacheStrategy.DATA是关键——内页图尺寸巨大(常超 3000px),缓存变换后图(如override(1080, 1920))会浪费磁盘空间,只缓存原始数据,每次展示时再做变换,平衡速度与空间。

4. 避坑指南:5 个真实翻车现场与救命解法

4.1 现象:App 启动闪退,Logcat 报java.lang.IllegalStateException: GeneratedAppGlideModuleImpl is not present

原因:Glide的AppGlideModule注解处理器未生效,常见于build.gradle中kapt依赖写错(如写成implementation),或AndroidManifest.xml中未声明meta-data。
解决:检查app/src/main/AndroidManifest.xml是否有:

<meta-data android:name="com.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule" android:value="GlideModule" />

并确认ui模块下的GlideModule类是否被@GlideModule注解且继承AppGlideModule,build.gradle中kapt libs.glide.compiler是否存在。

4.2 现象:夜间模式切换后,部分图片变黑或文字不可读

原因:Glide加载的图片未适配Dynamic Color,其占位图(placeholder)和错误图(error)使用了硬编码颜色(如#FF000000),而夜间模式下Context的Theme已变,但 Glide 的Drawable未重新着色。
解决:将所有placeholder/error替换为ColorDrawable并在onCreateView中动态获取:

val placeholder = ColorDrawable( ContextCompat.getColor(context, if (isNightMode()) R.color.gray_dark else R.color.gray_light) ) GlideApp.with(context).load(url).placeholder(placeholder).into(iv)

4.3 现象:快速滑动瀑布流时,部分封面图显示错乱(A 书的封面显示在 B 书位置)

原因:RecyclerView的ViewHolder复用机制下,Glide的into()是异步的,若onBindViewHolder被快速调用,前一个请求可能后返回,覆盖了新请求的结果。
解决:在onBindViewHolder开头强制Glide.clear(holder.ivCover),确保旧请求被取消:

override fun onBindViewHolder(holder: BookListViewHolder, position: Int) { GlideApp.with(holder.itemView.context).clear(holder.ivCover) // 关键! val book = getItem(position) ... }

4.4 现象:离线状态下打开已缓存的书籍,内页图片全部显示占位图

原因:Room数据库存储了书籍元数据(标题、封面 URL),但内页图片 URL 对应的文件未真正下载到本地,Glide的diskCacheStrategy默认只缓存网络请求的响应体,未主动触发下载。
解决:在BookDetailPresenter的loadBookPages()中,增加预加载逻辑:

pages.forEach { pageUrl -> GlideApp.with(context) .load(pageUrl) .downloadOnly(1024, 1400) // 同步下载到磁盘,不显示 .submit() }

并在BookDetailFragment的onViewCreated中,用Glide.with(context).downloadOnly().load(url).submit()预热,确保离线时Glide能从磁盘读取。

4.5 现象:搜索功能输入中文关键词后,返回结果为空,但服务端确认有数据

原因:Retrofit的@Query参数未进行 URL 编码,中文被截断或服务端解析失败。
解决:在data/src/main/java/com/example/nhentai/api/NHentaiApi.kt中,将@Query("q") query: String改为@Query(value = "q", encoded = true) query: String,并确保调用方传入的query已 UTF-8 编码:

val encodedQuery = URLEncoder.encode(query, "UTF-8") getBookListUseCase.execute(GetBookListParams(page = 1, query = encodedQuery))

5. 二次开发实战:如何将此项目改造成你自己的企业知识库客户端

5.1 替换数据源:从 NHentai API 切换到自有 RESTful 接口

第一步是修改data模块中的NHentaiApi接口。原接口定义:

interface NHentaiApi { @GET("api/galleries/search/") fun searchBooks( @Query("query") query: String, @Query("page") page: Int ): Single<SearchResponse> }

替换为你公司的接口(假设路径为/v1/books,支持分页):

interface CompanyApi { @GET("v1/books") fun listBooks( @Query("category") category: String, // 分类 ID @Query("page") page: Int, @Query("size") size: Int = 20 // 每页数量 ): Single<BookListResponse> @GET("v1/books/{id}") fun getBookDetail(@Path("id") bookId: Long): Single<BookDetailResponse> }

接着修改data/src/main/java/com/example/nhentai/repository/BookRepositoryImpl.kt中的getBookList()方法:

override fun getBookList(params: GetBookListParams): Single<List<Book>> { return companyApi.listBooks( category = params.categoryId, // 新增 categoryId 参数 page = params.page, size = 20 ) .map { response -> response.data.map { apiBook -> Book( id = apiBook.id, title = apiBook.title, coverUrl = apiBook.coverUrl, // 注意:你的 API 返回的字段名 pageCount = apiBook.totalPages ) } } }

关键点:params类型需同步更新。在domain/src/main/java/com/example/nhentai/usecase/GetBookListParams.kt中,将query: String改为categoryId: String,并删除query字段。这样BookListPresenter的调用处会立即报错,强迫你检查所有使用点,避免漏改。

5.2 主题定制:注入企业 Logo 与品牌色,禁用动态色

ui模块的ThemeUtils.kt是主题控制中心。原逻辑根据系统设置自动切换深色模式:

fun applyDynamicColor(context: Context) { val colorScheme = dynamicColorScheme(context) MaterialColors.applyColorScheme(context, colorScheme) }

改造为企业定制版:

fun applyCompanyTheme(context: Context) { // 强制浅色模式,禁用动态色 AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) // 加载企业主题色 val primaryColor = ContextCompat.getColor(context, R.color.company_primary) val secondaryColor = ContextCompat.getColor(context, R.color.company_secondary) // 通过 Material You 的 color scheme 构建器生成 val colorScheme = lightColorScheme( primary = primaryColor, secondary = secondaryColor, tertiary = ContextCompat.getColor(context, R.color.company_tertiary), error = ContextCompat.getColor(context, R.color.company_error) ) MaterialColors.applyColorScheme(context, colorScheme) // 注入 Logo:在 MainActivity 的 onCreate 中调用 setCompanyLogo(context) } private fun setCompanyLogo(context: Context) { val logoResId = when (BuildConfig.BUILD_TYPE) { "prod" -> R.drawable.logo_prod "staging" -> R.drawable.logo_staging else -> R.drawable.logo_dev } // 通过反射修改 Toolbar 的 logo(假设使用 MaterialToolbar) val toolbar = (context as Activity).findViewById<MaterialToolbar>(R.id.toolbar) toolbar?.logo = ContextCompat.getDrawable(context, logoResId) }

然后在app/src/main/res/values/themes.xml中,将Theme.NHentai替换为Theme.Company,并定义colorCompanyPrimary等色值。

5.3 离线增强:增加“一键缓存整本书”功能

在BookDetailFragment的菜单中添加新选项:

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.book_detail_menu, menu) // 新增缓存菜单项 menu.add(Menu.NONE, R.id.menu_cache_all, Menu.NONE, "缓存整本书") .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) }

点击事件处理:

override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.menu_cache_all -> { cacheAllPages() true } else -> super.onOptionsItemSelected(item) } } private fun cacheAllPages() { val pages = book?.pages ?: return val downloadJobs = pages.map { pageUrl -> GlideApp.with(this) .load(pageUrl) .downloadOnly(1024, 1400) .submit() } // 等待全部完成,更新 UI CompositeDisposable().apply { add( Observable.fromArray(*downloadJobs.toTypedArray()) .flatMap { job -> Observable.just(job.get()) } .toList() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { _ -> showSnackbar("缓存完成") }, { e -> showSnackbar("缓存失败: ${e.message}") } ) ) } }

注意:downloadOnly()返回FutureTarget<File>,job.get()是阻塞调用,所以必须放在Schedulers.io()。实际项目中建议用WorkManager替代,避免前台线程等待。

从那以后我每次接手新项目,只要涉及图片列表、离线、主题定制,都会先拉下这个仓库,删掉NHentai相关包名和 API Key,然后按上述三步走:改接口、换主题、加缓存。它不是一个成品 App,而是一份经过千次滑动、百次断网、数十次主题切换验证过的 Android 客户端工程脚手架。希望帮到你。

本文还有配套的精品资源,点击获取