简介NHBooks 是一套基于 Java 开发的 Android 客户端源码采用 Material Design 设计规范面向希望研究 Android 应用结构、界面布局与网络数据解析的开发者。项目通过 API 与 Jsoup 解析获取内容提供轻量浏览界面可作为 Material Design 风格客户端、Android 工程组织及 HTML 解析实践的参考案例。压缩包共 163 个文件约 3.2MB包含 62 个 Java 源码、38 个 XML 布局与配置、42 个 PNG 图片资源以及 4 个 Gradle 构建脚本和 APK 等文件目录覆盖源码、资源、构建配置与可安装包便于整体阅读和编译研究。已有 3189 人学习说明其在 Android 客户端学习场景中具有一定参考价值。读者可从中了解界面资源组织、网络请求与解析流程、Gradle 构建配置及成品打包方式需注意内容不适合未成年人使用时应遵守当地法律法规。1. 一个用 Java 写透的 Material Design 客户端先看清它能干什么界面丑、滚动卡、翻页白屏——这是大部分人第一次跑这类图文浏览客户端时的三个印象而这份工程恰好把这三件事当作主线来解。它是纯 Java 写的没有 Kotlin、没有 Compose全套 Activity Fragment MVPUI 层按 Material Design 规范铺开底部导航、可折叠标题栏、卡片式列表、沉浸式状态栏一样不少。对拿它练手的开发者来说价值不在于「又一个看图 App」而在于它把老派 Java 工程里最容易散架的四块——网络层、分页列表、图片缓存、主题切换——用一套能读通的代码串了起来。想补 Android 基础架构、又不想被 Kotlin 协程和 Jetpack 全家桶绕晕的人拿它当拆解样本正合适。2. 工程骨架与依赖分层从 build.gradle 到包结构的一次摸底一个 Android 工程好不好改看两处就知道依赖清单干不干净包结构有没有边界。这份工程在这两点上都属于「老派但规矩」的类型——依赖没有乱塞包按功能切不按类型切。2.1 Gradle 依赖清单与版本约束打开 app 模块的 build.gradle依赖大致可以分成四组网络、图片、UI 组件、调试。这种老工程不会用 Version Catalog全靠手写版本号所以第一件事是看清哪些版本互相咬死。android { compileSdkVersion 28 defaultConfig { applicationId com.example.galleryclient minSdkVersion 21 targetSdkVersion 28 versionCode 1 versionName 1.0 } compileOptions { // 老工程常见的坑不开这个Lambda 和部分三方库会直接编译失败 sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { // 网络三件套 implementation com.squareup.retrofit2:retrofit:2.6.2 implementation com.squareup.retrofit2:converter-gson:2.6.2 implementation com.squareup.okhttp3:logging-interceptor:3.12.1 // HTML 兜底解析接口挂掉时用得上 implementation org.jsoup:jsoup:1.12.1 // 图片 implementation com.github.bumptech.glide:glide:4.9.0 annotationProcessor com.github.bumptech.glide:compiler:4.9.0 // Material 组件与列表 implementation com.google.android.material:material:1.0.0 implementation androidx.recyclerview:recyclerview:1.0.0 implementation androidx.cardview:cardview:1.0.0 // 视图绑定与事件 implementation com.jakewharton:butterknife:10.2.0 annotationProcessor com.jakewharton:butterknife-compiler:10.2.0 implementation org.greenrobot:eventbus:3.1.1 }这段清单里有两个点值得单独说。一是compileOptions里的 Java 8 支持很多人在 Android Studio 4.x 上直接打开老工程第一屏就是 Lambda 报红原因就在这里。二是 ButterKnife 这类注解处理器必须同时写implementation和annotationProcessor两行只写一行会在运行时拿到空 View报 NullPointerException 却不告诉你为什么。依赖顺序本身不影响编译但影响你排查问题的顺序。我一般的习惯是先确认网络三件套版本一致Retrofit 和 converter 必须是同一版本号再确认 Glide 的 compiler 和主库版本一致最后才看 UI 库。因为这三组里任何一组版本错位报错信息都会指向一个跟真实原因无关的类。另外注意minSdkVersion 21。这个值决定了你能不能直接用MaterialComponents主题的部分属性也决定了图片加载库要不要开兼容模式。改这个值之前先想清楚降下去容易降下去之后再补兼容代码就是另一回事了。2.2 包结构与 MVP 的分层边界包结构是判断一个工程「能不能二次开发」的最快方式。这份工程按功能切包每个功能包里再按 MVP 分三层整体长这样com.example.galleryclient ├── base // BaseActivity、BasePresenter、BaseView ├── data // 数据层 │ ├── api // Retrofit 接口定义 │ ├── model // 实体类 │ ├── parser // Jsoup 解析器兜底通道 │ └── repository // 仓库统一收口网络与本地缓存 ├── ui │ ├── main // 主界面 底部导航 │ ├── list // 列表页 │ ├── detail // 详情页 │ └── settings // 设置页 ├── widget // 自定义 View、适配器基类 └── util // 工具类这个结构里最关键的是data/repository这一层。很多新手写 MVP 会把 Retrofit 调用直接塞进 Presenter结果是换一个数据源就要改一遍业务代码。加了 Repository 之后Presenter 只认接口不认实现网络通道和本地缓存通道可以互换甚至可以在 Repository 里做「先读缓存再请求网络」的组合逻辑Presenter 完全无感。base包里通常放三个东西BaseActivity 负责统一的状态栏和权限处理BasePresenter 持有 View 的弱引用防止内存泄漏BaseView 定义 loading / error / empty 三个状态的通用回调。这三件套写好了后面每个页面能省掉大量重复代码。2.3 Application 里的全局初始化Application 是这类工程的「总开关」几乎所有全局配置都从这里出发。看一个工程的 Application 类基本能推断出它的运行时行为。public class App extends Application { private static App instance; private OkHttpClient okHttpClient; Override public void onCreate() { super.onCreate(); instance this; // 1. 全局异常兜底方便定位线上崩溃 Thread.setDefaultUncaughtExceptionHandler((t, e) - { Log.e(App, uncaught in t.getName(), e); }); // 2. OkHttp 单例避免每个 Retrofit 实例各建一套连接池 okHttpClient new OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .build(); // 3. 主题模式在启动时就要定下来晚于 setContentView 就来不及了 ThemeManager.applySavedTheme(this); } public static App get() { return instance; } public OkHttpClient httpClient() { return okHttpClient; } }这里的顺序不能乱。异常兜底放最前是为了让后面所有初始化出错时都能被捕获到OkHttp 单例必须在任何 Retrofit 构建之前完成否则你会在日志里看到一堆独立的连接池在互相抢资源主题应用必须早于任何 Activity 的setContentView晚一步就会出现「启动瞬间白屏再变黑」的闪烁。ThemeManager.applySavedTheme是个自定义类内部读 SharedPreferences 里存的模式标记再调用AppCompatDelegate.setDefaultNightMode。这行代码看起来平平无奇但它和后面的 Activity 重建逻辑强耦合第 4 章会展开讲。3. 网络与数据链路一次列表请求从 OkHttp 到 RecyclerView 的完整路径列表页是这个客户端的主战场也是最能看出工程质量的地方。一次翻页请求要经过拦截器、接口定义、Repository 分发、Presenter 状态机、适配器增量刷新五个环节任何一环处理不当用户看到的就是卡顿或者重复数据。3.1 Retrofit 接口与 OkHttp 拦截器接口定义本身没什么花活关键是拦截器这一层。这类客户端通常要伪装请求头、要处理分页参数、要在调试时打印完整报文。public interface GalleryApi { // 列表接口page 从 1 开始 GET(api/list) CallGalleryListResponse getList( Query(page) int page, Query(limit) int limit ); // 详情接口 GET(api/detail) CallGalleryDetailResponse getDetail(Query(id) String id); }public class HeaderInterceptor implements Interceptor { private final String userAgent; public HeaderInterceptor(String userAgent) { this.userAgent userAgent; } Override public Response intercept(Chain chain) throws IOException { Request origin chain.request(); Request wrapped origin.newBuilder() // 固定 UA服务端按 UA 返回不同结构时非常关键 .header(User-Agent, userAgent) .header(Accept, application/json) // 加一个请求时间戳排查缓存命中问题时有用 .header(X-Request-Time, String.valueOf(System.currentTimeMillis())) .build(); return chain.proceed(wrapped); } }HeaderInterceptor里写死 UA 是有意为之。很多服务端会按 UA 判断返回 HTML 还是 JSONUA 不对就会拿到一坨 HTMLGson 解析直接抛JsonSyntaxException而报错信息只会告诉你「Expected BEGIN_OBJECT but was STRING」完全不提 UA 的事。这类问题我在早期排查上花过不少时间。构建 Retrofit 实例时把这些拦截器按顺序挂上去Retrofit retrofit new Retrofit.Builder() .baseUrl(https://api.example.com/) .client(App.get().httpClient().newBuilder() .addInterceptor(new HeaderInterceptor(UA)) .addInterceptor(new HttpLoggingInterceptor() .setLevel(HttpLoggingInterceptor.Level.BODY)) .build()) .addConverterFactory(GsonConverterFactory.create()) .build();顺序上 HeaderInterceptor 要放在日志拦截器之前这样日志里打印出来的就是最终发出去的报文否则你看到的是「加了头之前」的样子排查时会被误导。日志级别用BODY只适合调试上线前记得收回到NONE不然大图片的 base64 会把日志刷爆。3.2 分页加载与适配器的增量刷新分页列表的经典翻车点是「重复触发」。用户手指一抖onScrolled连续回调好几次每次都判定为「到底了」于是同一页请求发了三遍列表里出现三份一样的数据。解决办法是给 Presenter 加一个闸门public class ListPresenter extends BasePresenterListView { private int currentPage 0; private boolean isLoading false; private boolean hasMore true; public void loadNextPage() { // 三重闸门正在加载、没有更多、View 已销毁任一成立就直接返回 if (isLoading || !hasMore || !isViewAttached()) { return; } isLoading true; currentPage; repository.fetchList(currentPage, 20, new Repository.CallbackGalleryListResponse() { Override public void onSuccess(GalleryListResponse data) { isLoading false; if (data.getItems() null || data.getItems().isEmpty()) { hasMore false; getView().showNoMore(); return; } getView().appendItems(data.getItems()); // 服务端返回的总页数才是权威判断别用「本页是否满 20 条」猜 if (currentPage data.getTotalPages()) { hasMore false; } } Override public void onError(Throwable t) { isLoading false; currentPage--; // 失败要回退页码否则会漏掉一页 getView().showError(t.getMessage()); } }); } }isLoading这个标志位的赋值时机很讲究必须在发起请求之前置 true在回调里置 false。如果放到回调里才置 true那么请求在途期间的所有重复调用都会穿过去。currentPage--这一行也别省失败回退页码是保证「重试能补上」的前提少了它用户点重试就会跳过整整一页内容。适配器这一侧增量刷新要用notifyItemRangeInserted而不是notifyDataSetChanged。前者只会重绘新增区间后者会让整个列表重新测量布局几百条数据的时候卡顿肉眼可见。public void appendItems(ListGalleryItem newItems) { int start items.size(); items.addAll(newItems); notifyItemRangeInserted(start, newItems.size()); }参数start是插入起点newItems.size()是插入数量两个值必须和实际 addAll 的范围严格一致否则会出现「列表跳动」或者「IndexOutOfBounds」这类玄学问题。3.3 Glide 图片加载与三级缓存图文客户端的流畅度八成取决于图片加载。Glide 本身已经做了内存、磁盘、网络三级缓存但默认配置不一定适合这个场景——列表里的缩略图和详情页的大图尺寸差异很大用同一套缓存策略会互相挤占。// 列表缩略图 Glide.with(holder.image) .load(item.getThumbUrl()) .placeholder(R.drawable.placeholder) .error(R.drawable.broken) .override(360, 480) // 固定解码尺寸避免大图占内存 .diskCacheStrategy(DiskCacheStrategy.DATA) .into(holder.image); // 详情页原图 Glide.with(context) .load(detail.getOriginUrl()) .thumbnail(0.25f) // 先出 25% 缩略图再换成原图 .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .into(imageView);override(360, 480)是关键。不加这行Glide 会按 ImageView 的实际尺寸解码而列表项在滚动过程中尺寸可能还没测量完成导致解码出来的图偏大几十张图铺进去很快就把内存撑到 OOM。thumbnail(0.25f)则是详情页的体验优化先加载低分辨率版本秒出再异步替换成原图用户感知到的就是「图一瞬间就有了」。还有一个容易被忽略的点Glide 的with()传什么对象决定了请求的生命周期。列表里传 ImageView 没问题但在 Activity 销毁时如果有请求在途没有正确绑定就会泄漏。规范做法是统一用Glide.with(fragment)或者Glide.with(activity)让 Glide 自己在合适的时机清理。4. Material Design 落地主题、可折叠标题栏与夜间模式UI 层是这个项目的门面。Material Design 在 2019 年前后的落地方式跟现在不一样那时候还没有 Material You 那套动态取色全靠手写 colorPrimary、colorAccent 和主题继承。4.1 主题色、字体与夜间模式切换主题定义在res/values/styles.xml和res/values-night/styles.xml两份文件里同名主题不同配色。!-- values/styles.xml -- style nameAppTheme parentTheme.MaterialComponents.Light.NoActionBar item namecolorPrimarycolor/primary/item item namecolorPrimaryDarkcolor/primary_dark/item item namecolorAccentcolor/accent/item item nameandroid:windowBackgroundcolor/bg_light/item item nameandroid:statusBarColorandroid:color/transparent/item /style !-- values-night/styles.xml -- style nameAppTheme parentTheme.MaterialComponents.NoActionBar item namecolorPrimarycolor/primary_night/item item namecolorPrimaryDarkcolor/primary_night/item item namecolorAccentcolor/accent_night/item item nameandroid:windowBackgroundcolor/bg_dark/item item nameandroid:statusBarColorandroid:color/transparent/item /style两份文件必须用同一个 style name系统会按当前模式自动挑。statusBarColor设成透明是配合后面沉浸式的但如果不在 Activity 里手动处理透明状态栏会让内容顶到屏幕最上面与状态栏图标重叠。夜间模式切换的核心是这一句AppCompatDelegate.setDefaultNightMode( isNight ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO);调用之后当前 Activity 会自动重建onCreate再走一遍加载的就是另一份 styles。这带来一个必须处理的问题重建会丢失页面状态。列表滚动位置、已加载的数据、输入框里的文字全都回到初始值。常见做法是在onSaveInstanceState里存滚动位置重建后在数据回填完成时用layoutManager.scrollToPositionWithOffset恢复。数据本身一般不用存因为 Repository 层有内存缓存重建后重新请求会直接命中。这个细节不处理用户切一次夜间模式就回到列表顶部体验会非常割裂。4.2 CoordinatorLayout 的滚动联动详情页的头部是典型的 Material 结构一张大图做背景标题叠在上面往上滚的时候标题栏收起只留一个小标题栏。androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_widthmatch_parent android:layout_heightmatch_parent com.google.android.material.appbar.AppBarLayout android:idid/appbar android:layout_widthmatch_parent android:layout_heightwrap_content com.google.android.material.appbar.CollapsingToolbarLayout android:layout_widthmatch_parent android:layout_height280dp app:layout_scrollFlagsscroll|exitUntilCollapsed app:contentScrim?attr/colorPrimary ImageView android:idid/header_image android:layout_widthmatch_parent android:layout_heightmatch_parent android:scaleTypecenterCrop app:layout_collapseModeparallax app:layout_collapseParallaxMultiplier0.6 / androidx.appcompat.widget.Toolbar android:idid/toolbar android:layout_widthmatch_parent android:layout_height?attr/actionBarSize app:layout_collapseModepin / /com.google.android.material.appbar.CollapsingToolbarLayout /com.google.android.material.appbar.AppBarLayout androidx.core.widget.NestedScrollView android:layout_widthmatch_parent android:layout_heightmatch_parent app:layout_behaviorstring/appbar_scrolling_view_behavior !-- 正文内容 -- /androidx.core.widget.NestedScrollView /androidx.coordinatorlayout.widget.CoordinatorLayout三个属性决定最终效果。layout_scrollFlagsscroll|exitUntilCollapsed表示 AppBar 跟随滚动移出、但保留折叠后的最小高度layout_collapseModeparallax让头图以较慢速度上移产生景深感parallaxMultiplier控制这个比例0.6 是我试过比较舒服的值再大会显得头图「粘」在屏幕上layout_collapseModepin让 Toolbar 始终钉在顶部。最容易翻车的是那个app:layout_behaviorstring/appbar_scrolling_view_behavior。少了它内容区不会跟着 AppBar 联动滚动时两者各滚各的视觉上就是两层内容叠在一起乱动。这个属性特别容易被复制粘贴时漏掉因为漏了不报错只是「效果不对」。4.3 详情页转场与沉浸式状态栏从列表点进详情如果直接startActivity视觉上是硬切。加上共享元素转场图片会从列表位置「飞」到详情位置。// 列表页发起 ActivityOptionsCompat options ActivityOptionsCompat.makeSceneTransitionAnimation( activity, holder.image, ViewCompat.getTransitionName(holder.image) // 两端 transitionName 必须一致 ); ActivityCompat.startActivity(activity, intent, options.toBundle());两个 Activity 里的 ImageView 都要设android:transitionName而且值必须相同。不同 Item 之间用同一个 transitionName 会有问题吗在这个场景里不会因为同一时刻只有一个 Item 在参与转场。沉浸式状态栏则是另一套逻辑getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); getWindow().setStatusBarColor(Color.TRANSPARENT);设置LAYOUT_FULLSCREEN之后内容会延伸到状态栏下方此时如果顶部是深色图片状态栏图标白色能看清如果是浅色内容图标就糊了。处理方式是给根布局加android:fitsSystemWindowstrue让内容整体下移或者在代码里用WindowInsets动态加 padding后者更灵活但要注意兼容 API 21 以下——这份工程的minSdkVersion是 21刚好在边界上。5. 避坑与排查跑起来之后最容易翻车的五个点从 clone 到跑通中间通常会有几次「明明代码没问题但就是不对」的时刻。下面这五条是我在同类工程上反复遇到的。5.1 编译期与依赖相关现象打开工程后 Gradle sync 成功但一编译就报Cannot resolve symbol R或者 ButterKnife 的BindView全部失效。原因annotationProcessor没配对或者 AndroidX 与 support 库混用。老工程经常出现一半依赖是androidx.*、一半是android.support.*的情况编译器不会直接告诉你冲突只会让资源索引生成失败。解决在gradle.properties里加android.useAndroidXtrue和android.enableJetifiertrue然后把所有com.android.support开头的依赖统一换成对应的androidx包。换完之后 Clean Project 再 Rebuild不要只点 Run。现象Duplicate class com.google.common.util.concurrent.ListenableFuture之类的重复类报错。原因某个三方库传递依赖了一个旧版本的 guava和你显式引入的版本打架。解决用./gradlew app:dependencies把依赖树打出来找到重复的那一支在build.gradle里加exclude group: com.google.guava或者用resolutionStrategy.force强制统一版本。5.2 运行时与渲染相关现象列表滑动时图片会「串位」A 项的图跑到 B 项上。原因Glide 的请求绑定的是 ImageView 实例RecyclerView 复用 ViewHolder 时旧的请求还没结束就被新数据覆盖回调回来时贴到了错误的条目上。解决在onBindViewHolder里任何if分支导致不加载图片时必须显式调用Glide.with(holder.image).clear(holder.image)把上一个请求清掉。只设置 placeholder 是不够的placeholder 只在请求开始前显示请求回来还是会覆盖。现象Jsoup 解析在某些设备上抛NetworkOnMainThreadException。原因Jsoup 的Jsoup.connect(url).get()是同步阻塞调用被直接写在了回调或者点击事件里。解决包一层线程调度Executors.newSingleThreadExecutor()或者 RxJava 的subscribeOn(Schedulers.io())都行解析完成后再切回主线程更新 UI。注意线程池别每次调用都新建用一个单例否则并发解析时会出现多个连接同时打同一个地址。现象targetSdk 升到 28 之后所有 http:// 开头的图片都加载不出来日志里也没有明显报错。原因Android 9 起默认禁止明文流量。解决在AndroidManifest.xml的 application 节点加android:usesCleartextTraffictrue或者更规范一点配一个network_security_config.xml白名单只对需要的域名放开。后者更安全但要注意配置文件里写的是域名不是 IP写错了不会报错只会静默失效。现象分页加载到第三页之后列表开始重复出现前面的内容。原因服务端页码从 0 开始客户端从 1 开始或者失败回退时页码没处理好导致某一页被请求了两次而两次返回的数据都追加进去了。解决在 Presenter 里维护一个已加载页码的 Set追加之前先判断这一页是否已经加载过同时把currentPage的初始值和自增时机在注释里写清楚这种「差一」的问题看代码是看不出来的只能靠文档约束。6. 二次开发从改一个页面到加一套功能真正开始改这个工程建议从最小的切口进先换一套配色和字体跑通一次完整的「改样式—编译—看效果」循环熟悉资源索引和主题继承的关系再去改列表项布局把卡片换成双列瀑布流这一步会逼着你理解LayoutManager和ItemDecoration的配合最后才动网络层和数据层。改列表布局时最省事的路径是替换LinearLayoutManager为StaggeredGridLayoutManager同时给每个 Item 的图片高度设成不定值StaggeredGridLayoutManager lm new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL); lm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_NONE); // 防止滚动时头部跳动 recyclerView.setLayoutManager(lm);GAP_HANDLING_NONE这个参数值得记一下默认策略会在滚动过程中不断重排以填补空隙视觉上就是列表「抖一下」关掉之后稳定很多代价是首屏可能出现少量空白。加新功能时我一般按这个顺序推进每一步都能独立验证步骤改动位置验证方式加一个筛选入口主界面 Toolbar 菜单点开能弹出选中后回调到 Presenter把筛选条件带进请求GalleryApi 加 Query 参数抓日志确认参数拼进 URLRepository 支持条件缓存缓存 key 加入筛选值切换条件后不串数据列表支持条件刷新清空 items 后重新加载第一页不再触发重复请求最容易出错的是第三步。缓存 key 如果只用了页码切换筛选条件后就会命中上一个条件的数据表现出来就是「切了筛选但列表没变」。这类 bug 在开发阶段如果不刻意验证很容易带到用户手里。还有一个我踩过的坑这个工程用了 EventBus 做跨页面通信设置页改完主题后post一个事件主界面收到后重建。问题在于 EventBus 的订阅者如果在onStop之后没有unregister事件会积压下次进来一次性触发好几次重建界面闪成一片。从那以后我每次写onStart里的register都会强制在onStop里配一个对应的unregister宁可多写一行也不留后悔药。这类工程改动量不大但每一个都值得跑一遍完整回归——毕竟它最大的价值是让你在一份能跑的代码上把 Android 那套老架构真正摸熟。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?