
简介这是一份面向 Android 开发学习者的 NHentai 第三方客户端源码采用 Java 编写界面遵循 Material Design 规范核心逻辑通过 API 请求并结合 Jsoup 解析官网内容适合用于研究网络访问、页面解析、列表展示、主题配色与资源适配等移动端开发环节。压缩包共 163 个文件整体约 3.2MB其中 62 个 Java 源文件承载业务与解析逻辑38 个 XML 文件用于布局和配置42 个 PNG 图片构成界面素材另有 4 个 Gradle 脚本及 APK、说明文档和 GPLv3 协议文件工程结构相对完整便于导入开发工具后对照阅读。已有 3189 人学习下载具备一定参考热度。读者可据此了解 Material Design 界面组织方式、Jsoup 抓取与解析流程、网络数据到列表渲染的衔接、图片资源管理和构建配置等要点也可结合 APK 体验成品交互使用时请自行判断内容适用性并遵守当地法律法规。1. 这份 Android 客户端源码能拿来做什么从一次列表滑动掉帧说起前段时间有人把一份用 Java 写的图库浏览 Android 客户端源码发给我丢来一句话“能跑就是一滑列表就掉帧图片还老闪。”我打开工程看了一圈Material Design 的骨架其实挺完整——DrawerLayout 侧栏、AppBarLayout 加 CollapsingToolbarLayout 的折叠标题栏、RecyclerView 的瀑布流Retrofit 的接口层也老老实实分了包。问题不在功能能不能用而在工程细节Glide 没配占位图导致图片回来那一刻整个 item 高度跳一下Adapter 每次 onBindViewHolder 都 new 一个点击监听分页回调也没做防抖滑到底能连着发三四个请求。这恰恰是这份源码值得拿来动手的原因。它不是那种只有两个页面的教学 Demo而是一个把「列表、详情、搜索、收藏」整条链路跑通的中型工程Java 代码量不大但分层、依赖、状态管理该有的都有该踩的坑也一个不落。适合已经能独立写 Activity、想搞清楚一个真实客户端怎么组织网络层和列表层的人也适合手里有个 Material Design 壳子想改成自己应用、却不知道从哪下刀的熟手。下面按「技术栈 → 网络层 → 列表层 → 避坑 → 二次开发」的顺序拆。2. 技术栈与工程骨架Java Material Design 是怎么拼起来的这个项目的技术选型属于“稳”字当头那一类语言是 Java没上 KotlinUI 走官方 Material Components 那套组件网络用 Retrofit2 OkHttp3 Gson图片加载交给 Glide。这套组合在 2019 年前后几乎是国内 Android 客户端的标准答案好处是网上资料多、遇到问题好搜坏处是版本一旦配错报错信息往往很“玄学”得靠经验判断。先把三个层次分清楚构建层由 Gradle 管依赖和编译选项结构层决定你改一个功能要动几个文件视图层决定这个客户端看起来像不像一个“正经 App”。这三层里最容易出问题的是构建层最影响二次开发效率的是结构层。2.1 依赖清单Retrofit、Glide、Material 三个绕不开的库打开模块级build.gradle真正撑起整个客户端的就三块依赖网络、图片、UI 组件。其余像 SwipeRefreshLayout、CardView 这些其实是跟着 design 库一起进来的不用单独引。下面是我整理过的精简版本版本号按仓库里实际的来写别照抄我这里的数字。apply plugin: com.android.application android { compileSdkVersion 28 defaultConfig { applicationId com.example.galleryclient minSdkVersion 21 // 21 以下 WebView 和部分 Material 组件行为不一致别再往下压 targetSdkVersion 28 versionCode 1 versionName 1.0 } compileOptions { // Java 8 的 lambda 和 stream 在这里打开Adapter 里的点击回调能省掉一堆匿名内部类 sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile(proguard-android.txt), proguard-rules.pro } } } dependencies { // UI 组件Material 的 AppBar、TabLayout、BottomSheet 都在这一个包里 implementation com.google.android.material:material:1.0.0 implementation androidx.recyclerview:recyclerview:1.0.0 implementation androidx.swiperefreshlayout:swiperefreshlayout:1.0.0 // 网络Retrofit 只负责接口描述真正发请求的是 OkHttp implementation com.squareup.retrofit2:retrofit:2.6.0 implementation com.squareup.retrofit2:converter-gson:2.6.0 implementation com.squareup.okhttp3:logging-interceptor:3.12.0 // 图片Glide 主包 注解处理器后者用来生成 GlideApp implementation com.github.bumptech.glide:glide:4.9.0 annotationProcessor com.github.bumptech.glide:compiler:4.9.0 }几个参数值得说清楚。minSdkVersion 21不是随便定的低于 21 之后 Material 的 ripple 效果和矢量图兼容性会打折扣改起来得不偿失。compileOptions里的 Java 8 开关一定要打开否则后面 Adapter 里写holder.itemView.setOnClickListener(v - ...)直接编译不过这是新手最容易卡住的第一道坎。minifyEnabled true只在 release 生效调试期建议关掉不然堆栈全是a.a.a这种排查等于看黑匣子。Glide 的annotationProcessor那行经常被漏掉。漏了不会编译报错但你在代码里写GlideApp.with(...)会提示找不到符号。如果整个项目只用了Glide.with(...)那这行确实可有可无一旦要用GlideApp做全局配置比如统一超时、统一磁盘缓存大小就必须补上。2.2 目录结构按功能分包别按类型分包这份源码的包结构是典型的“按功能切”不是那种把所有 Activity 塞一个包、所有 Adapter 塞一个包的写法。后者在项目小的时候看着整齐等你改一个详情页要翻五六个包就明白有多难受了。com.example.galleryclient ├── api // 网络层 │ ├── ApiClient.java // Retrofit 单例 │ ├── ApiConfig.java // baseUrl、超时、UA 常量 │ ├── GalleryService.java // 接口声明 │ └── SimpleCallback.java // 统一回调封装 ├── model // 数据模型与接口返回一一对应 │ ├── Gallery.java │ ├── GalleryDetail.java │ └── PageResult.java ├── ui // 界面层按页面再分子包 │ ├── list // 列表页 │ │ ├── ListActivity.java │ │ ├── GalleryAdapter.java │ │ └── ListPresenter.java │ ├── detail // 详情页 │ └── search // 搜索页 ├── widget // 自定义 View比如等比 ImageView └── util // 工具类这个结构里最值钱的是api和model两层的划分。model包下的类全部是“哑对象”只有字段和 getter/setter不写业务逻辑换个数据源直接替换就行api包把“怎么发请求”和“发给谁”分开ApiConfig里放 baseUrl 和超时常量改测试环境只动一个文件。我见过不少项目把 baseUrl 硬编码在每个接口方法上换环境要全局搜索替换那种血泪经验经历一次就够了。ui下按页面分子包每个子包里 View 和 Presenter 放一起。这样做的好处是删功能的时候直接删一个文件夹不存在“删了 Activity 忘了删 Adapter”的残留。2.3 布局骨架CoordinatorLayout AppBarLayout RecyclerViewMaterial Design 的“味道”主要来自主界面的这层嵌套。别看 XML 只有几十行它决定了滚动时标题栏是折叠还是固定、下拉刷新会不会和 AppBar 打架。androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightmatch_parent !-- 折叠标题栏向上滑动时高度从 200dp 收缩到 ?attr/actionBarSize -- 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_height200dp app:layout_scrollFlagsscroll|exitUntilCollapsed app:contentScrim?attr/colorPrimary 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 !-- 关键在 layout_behavior少了它 RecyclerView 不会跟着 AppBar 联动 -- androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:idid/refresh android:layout_widthmatch_parent android:layout_heightmatch_parent app:layout_behaviorstring/appbar_scrolling_view_behavior androidx.recyclerview.widget.RecyclerView android:idid/recycler android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipToPaddingfalse android:paddingBottom16dp / /androidx.swiperefreshlayout.widget.SwipeRefreshLayout com.google.android.material.floatingactionbutton.FloatingActionButton android:idid/fab_search android:layout_widthwrap_content android:layout_heightwrap_content android:layout_gravitybottom|end android:layout_margin16dp android:srcdrawable/ic_search / /androidx.coordinatorlayout.widget.CoordinatorLayoutapp:layout_scrollFlagsscroll|exitUntilCollapsed这行的意思是AppBar 跟着内容一起滚出去但滚到折叠高度就停住不会完全消失。layout_collapseModepin让 Toolbar 在折叠后钉在顶部保证标题一直可见。而app:layout_behaviorstring/appbar_scrolling_view_behavior是最容易被忘的一行漏掉之后标题栏纹丝不动RecyclerView 自己滚自己的看起来就是“这 App 有点怪”。android:clipToPaddingfalse配合paddingBottom是给底部留白用的不加的话最后一行会被 FAB 挡住一半。3. 网络层拆解Retrofit 的一个请求从进入到回调的完整链路网络层是这个项目里最值得细看的部分因为它把“单例管理、公共头部、错误分类、分页参数”这四件事都做了一遍。很多教学 Demo 到retrofit.create()就结束了真正上线时遇到的问题全在这四件事里。整个链路是这样的界面层调ApiClient.get().create(GalleryService.class).list(page, limit, keyword)拿到一个CallCall.enqueue()把请求丢进 OkHttp 的线程池拦截器链依次给请求加 UA、打日志Gson 把返回的 JSON 转成PageResultGallery最后回到主线程交给SimpleCallback的onSuccess或onFail。中间任何一步断了界面就是白屏或者转圈不止。3.1 单例 Retrofit 与 OkHttp 拦截器Retrofit 实例一定要复用。每次请求都new Retrofit.Builder()的写法在低端机上连续翻几页就能明显感觉到卡顿因为连接池和线程池每次都在重建。这份源码用的是双重检查锁的单例写法虽然老派但够用。public final class ApiClient { private static volatile ApiClient instance; private final Retrofit retrofit; private ApiClient() { HttpLoggingInterceptor logging new HttpLoggingInterceptor(); // BASIC 只打请求行和状态码BODY 会把整段 JSON 打进 Logcat翻页时能把日志刷爆 logging.setLevel(BuildConfig.DEBUG ? HttpLoggingInterceptor.Level.BASIC : HttpLoggingInterceptor.Level.NONE); OkHttpClient client new OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS) // 服务端会校验 UA不带或者带默认的 okhttp/x.x 会直接 403 .addInterceptor(chain - chain.proceed( chain.request().newBuilder() .header(User-Agent, ApiConfig.UA) .header(Accept, application/json) .build())) .addInterceptor(logging) .build(); retrofit new Retrofit.Builder() .baseUrl(ApiConfig.BASE_URL) // 必须以 / 结尾否则会拼出奇怪的路径 .client(client) .addConverterFactory(GsonConverterFactory.create()) .build(); } public static ApiClient get() { if (instance null) { synchronized (ApiClient.class) { if (instance null) { instance new ApiClient(); } } } return instance; } public T T create(ClassT service) { // Retrofit 内部会缓存已创建的接口代理这里重复调用不会重复生成 return retrofit.create(service); } }三个参数需要单独说明。connectTimeout(15)对应的是 TCP 握手阶段readTimeout(20)对应的是“连接建立后等数据”的阶段这两个别设成一样的值读超时通常要比连接超时长一倍左右因为服务端可能在做图片列表的聚合查询。baseUrl结尾那个斜杠是 Retrofit 的硬性要求少了它构造直接抛IllegalArgumentException报错信息还挺绕。User-Agent这一条是这套代码里最“实战”的地方。很多公开接口会按 UA 做最基础的反爬过滤不带就会返回 403而 Retrofit 默认的 403 只会给你一个Response.code() 403什么解释都没有。我第一次遇到这个现象时排查了半天以为是参数传错了最后抓包对比才发现差在一个头部。3.2 接口定义与统一响应包装接口声明本身很短麻烦的是响应结构。绝大多数分页接口返回的都是{data: [...], total: n, page: p}或者{items: [...], has_more: true}这种包装外面再套一层状态码。这里用泛型PageResultT把它收拢列表页和搜索页就能共用同一个解析逻辑。public interface GalleryService { GET(api/galleries) CallPageResultGallery list( Query(page) int page, // 从 1 开始不是 0 Query(limit) int limit, // 每页条数别超过 25再大服务端会截断 Query(q) String keyword // 为空时 Retrofit 会自动去掉这个 query ); GET(api/galleries/{id}) CallGalleryDetail detail(Path(id) String id); }public class PageResultT { SerializedName(data) private ListT data; SerializedName(total) private int total; SerializedName(page) private int page; // 服务端有时不返回这个字段反序列化后默认 false所以下面留了一个兜底方法 SerializedName(has_more) private boolean hasMore; public boolean hasNext() { if (data null) return false; // 优先信任 has_more字段缺失时退化成本页满了就还有下一页 return hasMore || data.size() 20; } }hasNext()这个兜底判断很关键。接口字段变化是常态如果硬依赖has_more而服务端某天去掉了这个字段Gson 反序列化不会报错只是所有字段都变成默认值 false结果就是列表滑到第一页底部就再也加载不动而你还在怀疑网络。用数据条数兜底能挡住这一类“静默失败”。再往上一层是统一回调。Retrofit 原生的Callback有两个方法onResponse里还得再判断isSuccessful()和body() ! null每个调用点都写一遍太啰嗦于是抽一个SimpleCallbackpublic abstract class SimpleCallbackT implements CallbackT { Override public void onResponse(NonNull CallT call, NonNull ResponseT response) { if (response.isSuccessful() response.body() ! null) { onSuccess(response.body()); } else { // 这里能拿到 HTTP 状态码403 / 404 / 500 要分开提示别一律加载失败 onFail(response.code(), HTTP response.code()); } } Override public void onFailure(NonNull CallT call, NonNull Throwable t) { if (t instanceof SocketTimeoutException) { onFail(-1, 加载超时稍后再试); } else if (t instanceof UnknownHostException) { onFail(-2, 网络不可用); } else if (t instanceof SSLException) { onFail(-3, 安全连接失败); } else { onFail(-4, t.getMessage() null ? 未知错误 : t.getMessage()); } } public abstract void onSuccess(T data); public abstract void onFail(int code, String msg); }onFailure里按异常类型分流这件事做与不做体验差很多。统一提示“网络错误”的话用户开着飞行模式和服务器 502 看到的是同一句话你也没法从用户反馈里判断问题在哪。分类之后至少能知道是本地网络环境的问题还是服务端的问题。3.3 分页参数page 与 limit 是怎么传的分页有两个细节容易翻车。第一个是起始页码。这个项目的接口从 1 开始而有些接口从 0 开始代码里page初值写错会导致第一页数据和第二页重复。第二个是limit和瀑布流列数的关系。如果列表是两列瀑布流limit设成奇数比如 21最后一行的第二格会空着视觉上很难看所以常见做法是设成列数的整数倍。private static final int PAGE_SIZE 20; // 两列瀑布流20 正好十行不会出现半行 private void loadPage(final int targetPage, boolean append) { ApiClient.get().create(GalleryService.class) .list(targetPage, PAGE_SIZE, keyword) .enqueue(new SimpleCallbackPageResultGallery() { Override public void onSuccess(PageResultGallery data) { if (data.getData() null || data.getData().isEmpty()) { hasMore false; return; } if (append) { adapter.addAll(data.getData()); } else { adapter.replaceAll(data.getData()); } currentPage targetPage; hasMore data.hasNext(); } Override public void onFail(int code, String msg) { loading false; // 失败也要复位否则后面永远拉不动 showToast(msg); } }); }append这个布尔参数区分的是“首页加载”和“上拉加载”前者要清空旧数据后者要追加。如果不区分下拉刷新之后列表会变成两倍长。参数名看起来不起眼但它挡住的是一类很典型的状态错乱。4. 列表层落地RecyclerView 适配器与 Glide 图片加载列表层是这个客户端里代码最密集的部分也是二次开发时改动最频繁的地方。RecyclerView 的机制本身不复杂但把它和 Glide、分页、点击回调组合起来之后各种边界情况就冒出来了图片错位、item 高度跳动、滑到底连续触发多次加载。这一章按“适配器 → 图片 → 状态机”的顺序拆。4.1 ViewHolder 复用与点击回调的正确位置先说一个新手常见的写法问题把setOnClickListener写在onBindViewHolder里每次都 new 一个匿名对象。功能上没错但 RecyclerView 在快速滑动时会频繁触发绑定一秒能创建几十个对象GC 压力上来了就开始掉帧。public class GalleryAdapter extends RecyclerView.AdapterGalleryAdapter.VH { public interface OnItemClick { void onClick(Gallery item, int position); } private final ListGallery data new ArrayList(); private final OnItemClick click; public GalleryAdapter(OnItemClick click) { // 回调只在构造时存一份onBindViewHolder 里直接用 this.click click; } NonNull Override public VH onCreateViewHolder(NonNull ViewGroup parent, int viewType) { View v LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_gallery, parent, false); return new VH(v); } Override public void onBindViewHolder(NonNull VH holder, int position) { Gallery item data.get(position); holder.title.setText(item.getTitle()); holder.count.setText(String.valueOf(item.getCount())); // 宽度撑满、高度按返回的宽高比算避免图片回来时 item 高度突变 holder.cover.setRatio(item.getRatio()); Glide.with(holder.cover.getContext()) .load(item.getCoverUrl()) .placeholder(R.drawable.bg_placeholder) .error(R.drawable.bg_error) .into(holder.cover); holder.itemView.setOnClickListener(v - { // 用 getAdapterPosition 而不是 position避免动画期间拿到过期下标 int pos holder.getAdapterPosition(); if (pos ! RecyclerView.NO_POSITION click ! null) { click.onClick(data.get(pos), pos); } }); } Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { final RatioImageView cover; final TextView title; final TextView count; VH(View v) { super(v); cover v.findViewById(R.id.iv_cover); title v.findViewById(R.id.tv_title); count v.findViewById(R.id.tv_count); } } }holder.getAdapterPosition()这一行是重点。onBindViewHolder传进来的position是绑定时的那一刻的值如果用户在这期间做了删除或者插入操作导致位置变化用旧值去取数据就会越界或者点错。getAdapterPosition()会返回当前实际位置NO_POSITION的判断则是防止 item 已经被移除但点击事件还在队列里。RatioImageView是这个项目里一个不到 40 行的小控件作用是在onMeasure里按比例算高度。瀑布流如果不做这一步图片加载完成前后 item 高度会突变视觉上就是列表“抖一下”。这也是前面提到的“图片老闪”的直接原因之一。4.2 Glide 三件套占位图、缩略图、磁盘缓存Glide 的默认配置能用但在图库这种图片密集型场景里不加几行配置体验会差很多。下面这个版本是我在源码基础上补过的效果比较明显。Glide.with(context) .load(url) // 占位图用纯色块而不是转圈列表里几十个转圈图标一起转反而更乱 .placeholder(R.drawable.bg_placeholder) .error(R.drawable.bg_error) // 先加载 30% 尺寸的缩略图滑到哪先看到模糊的图原图回来了再替换 .thumbnail(0.3f) // AUTOMATIC 会同时缓存原图和转换后的图列表反复上下滑不会重复请求 .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) // 列表项尺寸固定指定 override 能省掉一次测量 .override(540, 720) .into(imageView);thumbnail(0.3f)这一行对体感提升最直接。图库列表里图片普遍不小一张 800KB 的原图从下载到解码完成可能要几百毫秒这段时间用户看到的是占位色块。加了缩略图之后Glide 会先用同一个 URL 请求一个低分辨率的版本服务端支持按参数缩放的情况下几十毫秒就能出图滑动时不会有“一片空白接着突然全部出现”的割裂感。diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)和NONE的区别要清楚前者会把下载后的图存到应用私有目录下次进应用不需要重新请求后者每次都走网络。图库类应用基本没有理由用 NONE除非你担心存储占用。存储占用的问题可以通过override控制尺寸来缓解把图片尺寸压到屏幕实际需要的像素能省下不少空间。override(540, 720)里的两个数字不是随便写的。瀑布流两列的情况下每列宽度大概是屏幕的一半1080p 屏幕就是 540px高度按 3:4 算是 720px。按实际显示尺寸加载解码出来的 Bitmap 内存占用能降一大截低端机上滚动的流畅度差别肉眼可见。4.3 上拉加载与下拉刷新的状态机分页加载最容易出的问题是重复请求。用户滑到底部onScrolled回调可能连续触发好几次如果每次都发请求就会出现同一页数据加载三遍、列表里出现重复条目的情况。解决办法是用两个布尔量做闸门一个管“正在加载”一个管“还有没有下一页”。private boolean loading false; private boolean hasMore true; private int currentPage 1; private int lastVisible 0; private void setupRecyclerView() { LinearLayoutManager lm new LinearLayoutManager(this); recyclerView.setLayoutManager(lm); recyclerView.setAdapter(adapter); recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView rv, int dx, int dy) { if (dy 0) return; // 往回滑不触发加载 int total lm.getItemCount(); int last lm.findLastVisibleItemPosition(); // 距离底部还有 3 个 item 时就开始预加载滑到底刚好接上 if (!loading hasMore last total - 3) { loadPage(currentPage 1, true); } } }); refreshLayout.setOnRefreshListener(() - { // 下拉刷新时把页码和状态全复位否则新旧数据会混在一起 hasMore true; currentPage 1; loading false; loadPage(1, false); }); }dy 0这个判断挡住的是往回滑时的误触发不加的话用户往上滑也会触发加载体验很奇怪。last total - 3是预加载阈值设成 0 的话要等真正滑到底才请求中间会有明显空白设太大比如 total - 10又可能在数据少的时候一开始就连续请求两页。下拉刷新那边三个状态必须一起复位。我见过只复位currentPage但没复位hasMore的写法结果用户在一个已经加载完的列表上刷新新数据回来了但再也加载不动因为hasMore还是上一轮的 false。loading的复位点要覆盖成功和失败两条路径。前面SimpleCallback的onFail里那行loading false就是干这个的漏了之后用户网络抖动一次整个列表就永久卡住只能杀进程重进。5. 跑通前的排查清单环境、权限与真机调试常见的几处翻车把源码拉下来到真机上跑通中间隔着的坑比想象中多。这一章按“编译期 → 运行期 → 列表行为 → 打包”四个阶段列出我实际遇到过的问题每条都按现象、原因、解决三步写照着对照基本能省下大半天。5.1 编译期依赖冲突与 Java 8 语法现象gradle sync之后满屏红色波浪线提示Cannot resolve symbol GlideApp或者Default interface methods are only supported starting with Android N。原因前者是漏了annotationProcessor那一行Glide 的注解处理器没跑自然不会生成GlideApp类后者是没开 Java 8 支持代码里用了默认方法或者 lambda。解决检查dependencies里glide:compiler是否用了annotationProcessor而不是implementation检查compileOptions里的sourceCompatibility和targetCompatibility是否都是VERSION_1_8。还有一种情况是 AndroidX 和 support 库混用报错会显示This project uses AndroidX dependencies, but the android.useAndroidX property is not enabled在gradle.properties里补上android.useAndroidXtrue和android.enableJetifiertrue两行就行。5.2 运行期图片全白与 403现象应用能启动列表骨架也出来了但所有图片位置都是占位色块Logcat 里能看到HTTP 403或者Glide: Failed to load resource。原因两个独立问题叠在一起。一是AndroidManifest.xml里漏了INTERNET权限这个权限属于普通权限不写不会有任何提示只会静默失败。二是targetSdkVersion到了 28 之后默认禁止明文 HTTP 请求如果图片地址是http://开头Glide 会直接拒绝加载。解决先在 manifest 里补uses-permission android:nameandroid.permission.INTERNET /。如果是明文 HTTP 的问题要么把图片地址换成 HTTPS要么在 manifest 的application标签上加android:usesCleartextTraffictrue但后者只建议在调试期用正式包最好还是走 HTTPS。至于 403先确认ApiConfig.UA有没有被漏配再抓一次包看看请求头。5.3 列表错位与内存抖动现象快速滑动列表时某些 item 上显示的图片是别的条目的滑一会儿之后应用开始卡顿严重时直接 OOM 闪退。原因图片错位通常是没在onBindViewHolder里做“绑定校验”图片异步加载回来时对应的 ViewHolder 已经被复用给另一个条目了。OOM 则多半是图片按原尺寸解码一张 4000×6000 的图解码成 Bitmap 要占近百兆内存。解决Glide 本身有into(ImageView)的绑定机制正常用不会错位但如果代码里用了submit()或者自定义 Target就需要自己比对 URL。OOM 的问题靠override(宽, 高)把解码尺寸限制到实际显示尺寸再配合diskCacheStrategy别把原图也缓存一份。如果列表里还有视频封面之类的大图建议单独走一条低分辨率通道。5.4 打包安装混淆与 minSdk现象debug 包跑得好好的打 release 包安装后一进列表页就闪退堆栈全是NullPointerException但看不出具体是哪个类。原因minifyEnabled true开启了代码混淆和资源压缩Gson 依赖字段名做反序列化字段被混淆成a、b之后就映射不上了所有字段读出来都是 null。解决在proguard-rules.pro里保留 model 包下的类写法是-keep class com.example.galleryclient.model.** { *; }同时保留 Gson 的注解-keepattributes Signature和-keepattributes *Annotation*。另外 Retrofit 的接口声明也要 keep不然泛型信息会被擦掉PageResultGallery里的Gallery反序列化不出来。改完之后用Build Analyze APK对比一下包体如果体积几乎没变说明混淆规则可能压根没生效检查一下buildTypes是不是写在了正确的位置。6. 二次开发的切入点把收藏和阅读进度做成本地可迁移模块源码自带的收藏功能用的是SharedPreferences存一串 ID能跑但一旦用户收藏了几百条读取和排序都会变慢而且换设备之后等于全丢。如果打算长期用这个客户端我建议把它换成本地数据库顺便把阅读进度也加进去这样详情页每次打开能回到上次看到的位置。改动量不大一个实体加一个 DAO 就够用 Room 省掉手写 SQLiteOpenHelper 的样板代码。Entity(tableName favorite) public class FavoriteEntity { // 直接用上游的 id 做主键重复收藏天然去重不需要自增列再查一次 PrimaryKey NonNull private String galleryId; private String title; private String coverUrl; // 时间戳列表按 DESC 排就是最近收藏在前 private long savedAt; // 阅读进度从详情页退出时回写下次进来直接跳转 private int lastPage; // 省略 getter/setter } Dao public interface FavoriteDao { Query(SELECT * FROM favorite ORDER BY savedAt DESC) LiveDataListFavoriteEntity observeAll(); Insert(onConflict OnConflictStrategy.REPLACE) void upsert(FavoriteEntity entity); Query(UPDATE favorite SET lastPage :page WHERE galleryId :id) void updateProgress(String id, int page); Query(SELECT EXISTS(SELECT 1 FROM favorite WHERE galleryId :id)) boolean isFavorite(String id); }几个设计取舍值得说。主键用galleryId而不是自增 ID是因为收藏这个动作天然幂等同一个条目点两次不该出现两条记录OnConflictStrategy.REPLACE直接覆盖就行。LiveData返回列表收藏页的 Adapter 订阅之后自动刷新不用手动notifyDataSetChanged。阅读进度单独用一个UPDATE语句避免每次翻页都把整条记录重写一遍。改造完怎么验证我一般按这张表逐项过一遍改造点涉及文件验证方式收藏落库FavoriteDao、DetailActivity收藏后杀进程重进条目仍在且顺序正确重复收藏去重OnConflictStrategy.REPLACE同一详情页连点两次收藏SELECT COUNT(*)应为 1阅读进度回写DetailActivity.onPause翻到第 5 页退出再进应停在原位置列表实时刷新observeAll返回LiveData在详情页取消收藏返回收藏页列表应少一条数据迁移Room.databaseBuilder版本号从旧版本升级安装SharedPreferences里的旧数据能迁移过来最后一条容易被忽略。如果之前用户已经在SharedPreferences里存了收藏直接切数据库等于把历史数据清零用户会觉得“更新一次收藏全没了”。稳妥的做法是在RoomDatabase.Callback的onCreate里读一遍旧的SharedPreferences转成实体批量插入迁移完再把旧文件删掉。这段迁移代码只跑一次但它是那种“不做也不会马上出事出事就是用户流失”的类型。踩过最疼的一次是给一个类似的客户端加阅读进度忘了在onPause里判断当前页面是否真的翻过结果用户只是点进详情页看了一眼就退出进度被覆盖成第 1 页下次进来从最开始看。从那以后我每次改这类“状态回写”的逻辑都强制走一遍“进入即退出”和“翻几页再退出”两条路径确认不会误写才提交。希望这几点能帮你少绕两圈。本文还有配套的精品资源点击获取