资讯动态

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

发布时间:2026/10/23 16:23:09 来源:尧图企业网站定制
简介这是一款基于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 不换 CoilRxJava 2.x项目选择 RxJava 而非 Kotlin Flow根本原因是历史包袱与团队熟悉度。data层所有 Repository 方法返回SingleT或Completablepresentation层 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 而非 MVVMpresentation模块中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 : CustomViewTargetImageView, Drawable(binding.ivPage) { override fun onResourceReady(resource: Drawable, transition: Transitionin 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:namecom.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule android:valueGlideModule /并确认ui模块下的GlideModule类是否被GlideModule注解且继承AppGlideModulebuild.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 ): SingleSearchResponse }替换为你公司的接口假设路径为/v1/books支持分页interface CompanyApi { GET(v1/books) fun listBooks( Query(category) category: String, // 分类 ID Query(page) page: Int, Query(size) size: Int 20 // 每页数量 ): SingleBookListResponse GET(v1/books/{id}) fun getBookDetail(Path(id) bookId: Long): SingleBookDetailResponse }接着修改data/src/main/java/com/example/nhentai/repository/BookRepositoryImpl.kt中的getBookList()方法override fun getBookList(params: GetBookListParams): SingleListBook { 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).findViewByIdMaterialToolbar(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()返回FutureTargetFilejob.get()是阻塞调用所以必须放在Schedulers.io()。实际项目中建议用WorkManager替代避免前台线程等待。从那以后我每次接手新项目只要涉及图片列表、离线、主题定制都会先拉下这个仓库删掉NHentai相关包名和 API Key然后按上述三步走改接口、换主题、加缓存。它不是一个成品 App而是一份经过千次滑动、百次断网、数十次主题切换验证过的 Android 客户端工程脚手架。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑