简介这是一款基于Java开发的NHentai第三方Android客户端遵循Material Design设计规范面向Android应用开发者与开源爱好者提供轻量、美观且易用的本子浏览体验。资源包共163个文件含62个Java源码文件构成核心业务逻辑与UI组件、42个PNG图标资源支撑Material风格界面、38个XML布局与资源定义文件实现响应式UI结构以及Gradle构建脚本、APK安装包、LICENSE协议等关键文件整体压缩后仅3.2MB便于快速下载与工程分析。已有3193人学习下载适合希望深入理解Material Design在Android端落地实践、学习网络API集成通过Jsoup解析NHentai官网、掌握APK打包与Gradle多模块配置的中高级Java/Android开发者。读者可直接运行调试完整项目复现从网络请求、HTML解析、异步加载到列表渲染的全流程同时参考其清晰的目录组织与开源协议合规实践。1. 这不是另一个“看图工具”一个把 NHentai API 封装成可维护 Android 工程的实战样本某开发者在接手一个老项目时发现它用的是三年前的 OkHttp 3.12 Gson 手写 Retrofit 接口连分页逻辑都硬编码在 Activity 里而新需求要求支持深色模式切换、离线缓存预加载、图片长按保存到相册——改不动也不敢删。直到他扒出这个NHentai-android项目它没做任何内容分发不碰任何资源下载与存储纯粹聚焦在「如何用现代 Android 架构把第三方漫画目录 API 稳稳接住」这件事上。整个工程跑起来就是个干净的 Material Design 3 风格列表页 详情页 搜索页但背后是完整的 MVVM Hilt Coil Paging 3 Room WorkManager 实践链路。如果你正卡在「API 接口能调通但一加功能就崩架构」的阶段或者想拿一个真实、轻量、无业务污染的开源项目练手 Android 最佳实践这个 Java 主力、Kotlin 混编的客户端就是你该 clone 下来跑通的第一份「可拆解教科书」。2. 项目结构与技术栈从 build.gradle 到模块划分的真实分层逻辑2.1 整体模块切分为什么它没用 feature module 却依然清晰项目采用扁平化模块结构共 4 个一级 moduleapp宿主只含AndroidManifest.xml、themes、splash和MainActivity不写任何业务逻辑core基础能力层含networkRetrofit 初始化、Interceptor、ApiService、databaseRoom DAO/Entity/Database、utilExtension 函数、DateFormatter、ImageLoader 封装domain纯 Java/Kotlin 数据模型与用例UseCase不含 Android SDK 依赖所有网络请求返回FlowT所有数据库操作返回LiveDataTpresentationUI 层按页面组织包结构home/,search/,gallery/,settings/每个页面含ViewModelFragmentAdapterItemDecoration。提示它没上 Dynamic Feature Module是因为目标明确——这不是要上架 Google Play 的商用 App而是教学型工程。模块边界靠core的Keep注解 domain的interface抽象 presentation的HiltAndroidApp严格隔离比强行拆 feature 更易理解依赖流向。2.2 Gradle 配置关键点Java 8 支持、Kotlin 混合编译与 AGP 版本对齐app/build.gradle中最值得抄的三处配置android { compileSdk 34 // 必须显式开启 Java 8否则 core 中的 java.time.* 会编译失败 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget 1.8 // 与 Java 8 对齐避免 Kotlin 字节码版本冲突 } // 启用 ViewBinding禁用 Butterknife已淘汰 buildFeatures { viewBinding true } }core/build.gradle中的网络依赖声明方式是规避 OkHttp 多版本冲突的经典做法dependencies { // 显式锁定 OkHttp 版本防止被其他库传递引入低版本 implementation com.squareup.okhttp3:okhttp:4.12.0 implementation com.squareup.okhttp3:logging-interceptor:4.12.0 // Retrofit 强制使用 2.9.0适配 OkHttp 4.12 implementation com.squareup.retrofit2:retrofit:2.9.0 implementation com.squareup.retrofit2:converter-gson:2.9.0 }2.3 Hilt 依赖注入落地细节从 Application 到 Fragment 的完整链路core/src/main/java/com/nhentai/core/di/NetworkModule.kt定义了 Retrofit 实例的提供方式Module InstallIn(SingletonComponent::class) object NetworkModule { Provides Singleton fun provideOkHttpClient(): OkHttpClient { return OkHttpClient.Builder() .addInterceptor(HttpLoggingInterceptor().apply { level HttpLoggingInterceptor.Level.BODY }) .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() } Provides Singleton fun provideRetrofit( okHttpClient: OkHttpClient ): Retrofit { return Retrofit.Builder() .baseUrl(https://nhentai.net/api/) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() } Provides Singleton fun provideApiService(retrofit: Retrofit): ApiService { return retrofit.create(ApiService::class.java) } }presentation/src/main/java/com/nhentai/presentation/home/HomeFragment.kt中的注入写法AndroidEntryPoint class HomeFragment : Fragment() { private val viewModel: HomeViewModel by viewModels() // 自动注入无需手动 new override fun onCreateView(...) { val binding FragmentHomeBinding.inflate(inflater) binding.lifecycleOwner viewLifecycleOwner binding.viewModel viewModel // DataBinding 绑定 ViewModel return binding.root } }逻辑说明AndroidEntryPoint是 Hilt 入口标记by viewModels()委托由 Hilt 自动生成的ViewModelProvider.Factory它会自动查找HomeViewModel构造函数中声明的依赖如GetPopularGalleriesUseCase再递归解析其依赖如ApiService最终完成整条依赖链注入。参数说明viewModels()默认作用域为viewLifecycleOwner即 Fragment 销毁时 ViewModel 自动清除避免内存泄漏。2.4 数据流设计Paging 3 Flow StateFlow 如何协同处理分页列表domain/src/main/java/com/nhentai/domain/usecase/GetPopularGalleriesUseCase.kt是分页数据源头class GetPopularGalleriesUseCase Inject constructor( private val apiService: ApiService ) { operator fun invoke(page: Int): FlowPagingDataGallery { return Pager( config PagingConfig( pageSize 25, enablePlaceholders false, initialLoadSize 50 ), pagingSourceFactory { GalleryPagingSource(apiService, page) } ).flow.cachedIn(viewModelScope) // 注意cachedIn 是关键避免重复请求 } }presentation/src/main/java/com/nhentai/presentation/home/HomeViewModel.kt中的消费逻辑HiltViewModel class HomeViewModel Inject constructor( private val getPopularGalleriesUseCase: GetPopularGalleriesUseCase ) : ViewModel() { private val _uiState MutableStateFlowUiState(UiState.Loading) val uiState: StateFlowUiState _uiState.asStateFlow() init { loadPopularGalleries() } private fun loadPopularGalleries() { viewModelScope.launch { getPopularGalleriesUseCase(1) .map { pagingData - pagingData.map { it.toUiModel() } } .catch { e - _uiState.value UiState.Error(e.message ?: Unknown error) } .collectLatest { pagingData - _uiState.value UiState.Success(pagingData) } } } }逻辑说明Pager.flow返回的是FlowPagingDataT它会在用户滑动到底部时自动触发下一页加载map { it.toUiModel() }是将领域模型Gallery转为 UI 模型GalleryUiModel实现层间解耦collectLatest确保新 Flow 订阅时自动取消旧订阅避免竞态请求。参数说明enablePlaceholders false表示不显示空占位符适合图片类列表initialLoadSize 50控制首次加载数量避免首屏白屏。3. 核心模块拆解一个请求从进入到返回的完整链路3.1 网络层ApiService 接口定义与动态 BaseUrl 处理core/src/main/java/com/nhentai/core/network/ApiService.java是整个网络通信的契约public interface ApiService { GET(galleries/search/) FlowableSearchResponse searchGalleries( Query(query) String query, Query(page) int page ); GET(galleries/{id}/) FlowableGalleryDetailResponse getGalleryDetail( Path(id) long id ); GET(galleries/popular/today/) FlowablePopularResponse getPopularToday( Query(page) int page ); }注意所有方法返回FlowableTRxJava 2而非ObservableT或CallT。这是项目早期基于 RxJava 构建的选择虽然后续模块已迁移到 Flow但为保持兼容性未强制统一。core/src/main/java/com/nhentai/core/network/BaseUrlManager.java实现了运行时切换 BaseUrl 的能力用于测试环境public class BaseUrlManager { private static volatile String currentBaseUrl https://nhentai.net/api/; public static void setBaseUrl(String baseUrl) { if (baseUrl ! null baseUrl.endsWith(/)) { currentBaseUrl baseUrl; } else { currentBaseUrl baseUrl /; } } public static String getBaseUrl() { return currentBaseUrl; } }该类被注入到OkHttpClient的Interceptor中用于动态修改请求路径public class BaseUrlInterceptor implements Interceptor { Override public Response intercept(Chain chain) throws IOException { Request request chain.request(); String url BaseUrlManager.getBaseUrl() request.url().encodedPath(); Request newRequest request.newBuilder() .url(url) .build(); return chain.proceed(newRequest); } }逻辑说明这种写法绕过了 Retrofit 的Url动态 BaseUrl 限制适用于需要全局切换 API 环境如 dev/staging/prod但又不想改大量接口定义的场景。参数说明encodedPath()保留原始 URL 中的编码字符如%20避免二次编码导致 404。3.2 数据库层Room Entity 与关系映射的轻量实践core/src/main/java/com/nhentai/core/database/entity/GalleryEntity.java定义了本地缓存实体Entity(tableName gallery) public class GalleryEntity { PrimaryKey public long id; public String title; public String thumbnailUrl; public int pageCount; public String uploadDate; // 存储为 ISO 8601 字符串如 2023-05-21T14:22:33 public boolean isFavorite; // 构造函数、getter/setter 省略 }core/src/main/java/com/nhentai/core/database/dao/GalleryDao.java提供基本 CRUDDao public interface GalleryDao { Insert(onConflict OnConflictStrategy.REPLACE) void insert(GalleryEntity entity); Query(SELECT * FROM gallery WHERE isFavorite 1 ORDER BY uploadDate DESC) LiveDataListGalleryEntity getFavorites(); Query(UPDATE gallery SET isFavorite :favorite WHERE id :id) void updateFavorite(long id, boolean favorite); Query(DELETE FROM gallery WHERE id :id) void delete(long id); }逻辑说明onConflict REPLACE是关键策略当插入重复id时自动覆盖旧记录避免SQLiteConstraintExceptiongetFavorites()返回LiveData天然适配ViewModel的观察者模式updateFavorite()使用命名参数:favorite和:id比位置参数更安全易读。参数说明uploadDate不用TypeConverters转为Date是因为排序和展示只需字符串比较且避免时区转换风险。3.3 UI 层RecyclerView Adapter 与 DiffUtil 的高效更新presentation/src/main/java/com/nhentai/presentation/home/GalleryAdapter.kt是列表渲染核心class GalleryAdapter( private val onGalleryClick: (GalleryUiModel) - Unit ) : ListAdapterGalleryUiModel, GalleryAdapter.ViewHolder(DIFF_CALLBACK) { companion object { private val DIFF_CALLBACK object : DiffUtil.Callback() { override fun getOldListSize(): Int 0 // 实际由 ListAdapter 内部管理 override fun getNewListSize(): Int 0 override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return getItem(oldItemPosition).id getItem(newItemPosition).id } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return getItem(oldItemPosition) getItem(newItemPosition) } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding ItemGalleryBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding, onGalleryClick) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(getItem(position)) } class ViewHolder( private val binding: ItemGalleryBinding, private val onGalleryClick: (GalleryUiModel) - Unit ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: GalleryUiModel) { binding.apply { titleText.text item.title pageCountText.text ${item.pageCount} pages Glide.with(itemView.context) .load(item.thumbnailUrl) .placeholder(R.drawable.ic_placeholder) .into(thumbnailImage) root.setOnClickListener { onGalleryClick(item) } } } } }逻辑说明ListAdapter内部已封装DiffUtil计算逻辑只需提供areItemsTheSame判断是否同一项和areContentsTheSame判断内容是否变化两个回调Glide.with(...)使用itemView.context而非binding.root.context避免因 View 重用导致 Context 泄漏root.setOnClickListener绑定在bind()中确保每次复用时点击事件指向最新数据。参数说明R.drawable.ic_placeholder是项目自带的占位图资源位于app/src/main/res/drawable/。3.4 图片加载层Coil 集成与自定义 ImageLoader 配置core/src/main/java/com/nhentai/core/util/ImageLoader.kt封装了 Coil 的全局配置object ImageLoader { fun create(context: Context): ImageLoader { return ImageLoader.Builder(context) .availableMemoryPercentage(0.25) // 限制内存缓存为可用内存的 25% .crossfade(true) .diskCache { DiskCache.Builder() .directory(context.cacheDir.resolve(image_cache)) .maxSizePercent(context, 0.1) // 磁盘缓存最大为应用私有目录的 10% .build() } .build() } }presentation/src/main/java/com/nhentai/presentation/home/HomeFragment.kt中的使用方式private fun setupImageLoader() { val imageLoader ImageLoader.create(requireContext()) Coil.setImageLoader(imageLoader) }逻辑说明availableMemoryPercentage(0.25)防止大图加载耗尽内存导致 OOMmaxSizePercent(0.1)控制磁盘缓存上限避免占用用户过多空间crossfade(true)启用淡入动画提升视觉体验。参数说明context.cacheDir.resolve(image_cache)指向应用私有缓存目录下的子目录无需申请WRITE_EXTERNAL_STORAGE权限。4. 运行与调试从零构建、真机部署到日志追踪4.1 构建前必做的三件事JDK 版本、AGP 版本与网络权限第一步确认 JDK 版本项目要求 JDK 17Android Studio Giraffe 及以上默认捆绑若使用旧版 AS请在File Project Structure SDK Location中指定 JDK 17 路径。验证方式$ java -version openjdk version 17.0.1 2021-10-19 OpenJDK Runtime Environment (build 17.0.112-39) OpenJDK 64-Bit Server VM (build 17.0.112-39, mixed mode, sharing)第二步检查 AGP 版本兼容性gradle/wrapper/gradle-wrapper.properties中声明distributionUrlhttps\://services.gradle.org/distributions/gradle-8.0-bin.zip对应app/build.gradle中的插件版本plugins { id com.android.application version 8.0.0 apply false id org.jetbrains.kotlin.android version 1.8.20 apply false }注意AGP 8.0.0 要求 Gradle 8.0Kotlin 1.8.20三者必须严格对齐否则出现Could not resolve all files for configuration :app:debugRuntimeClasspath类错误。第三步添加网络权限与 Cleartext 支持app/src/main/AndroidManifest.xml中必须包含uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / application android:usesCleartextTraffictrue !-- 因 nhentai.net 使用 HTTP 重定向需允许明文流量 -- ... 4.2 真机部署常见失败原因与修复方案现象 1安装失败提示INSTALL_FAILED_UPDATE_INCOMPATIBLE原因设备上已安装同包名但签名不同的旧版本 APK如从 GitHub Release 下载过。解决执行以下命令彻底卸载adb uninstall com.nhentai.app现象 2启动闪退Logcat 显示Caused by: java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;原因Kotlin 运行时未正确打包进 APK常见于core模块未声明api依赖。解决检查core/build.gradle确保 Kotlin stdlib 以api方式暴露dependencies { api org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20 }现象 3首页空白Logcat 显示HTTP 403 Forbidden或Unable to resolve host nhentai.net原因目标网站可能临时屏蔽了爬虫 User-Agent或 DNS 解析失败。解决在NetworkModule.kt中为 OkHttp 添加合法 User-Agent.addInterceptor { chain - val originalRequest chain.request() val requestWithUserAgent originalRequest.newBuilder() .header(User-Agent, NHentai-Android/1.0 (com.nhentai.app; Android 13)) .build() chain.proceed(requestWithUserAgent) }4.3 日志调试技巧从 HttpLoggingInterceptor 到自定义 CrashHandler启用网络请求日志在NetworkModule.kt中HttpLoggingInterceptor默认为BODY级别但仅在BuildConfig.DEBUG为 true 时生效if (BuildConfig.DEBUG) { builder.addInterceptor(HttpLoggingInterceptor().apply { level HttpLoggingInterceptor.Level.BODY }) }查看方式在 Android Studio Logcat 中筛选OkHttp标签即可看到完整请求头、响应头与 JSON Body。捕获未处理异常并打印堆栈app/src/main/java/com/nhentai/app/NHentaiApplication.kt中注册全局异常处理器class NHentaiApplication : Application() { override fun onCreate() { super.onCreate() Thread.setDefaultUncaughtExceptionHandler { thread, exception - Log.e(CRASH, Uncaught exception in ${thread.name}, exception) // 此处可添加 Toast 提示或写入本地日志文件 } } }提示该 Handler 无法捕获 ANR 或 Native Crash仅用于 Java 层未 catch 的 RuntimeException。生产环境建议接入 Firebase Crashlytics 或 Sentry。4.4 性能监控入门使用 Android Profiler 观察内存与网络内存泄漏排查运行 App进入HomeFragment在 Android Studio 中打开Profiler Memory点击Force GC然后反复进出GalleryFragment3 次再次点击Dump Java Heap生成.hprof文件在分析界面搜索HomeFragment或GalleryAdapter若实例数持续增长说明存在泄漏。常见泄漏点GalleryAdapter中持有Activity引用如Toast.makeText(activity, ...)、ViewModel中启动未取消的协程。网络请求耗时分析在Profiler Network中可直观看到每个请求的Duration总耗时LatencyDNS Connect 时间DownloadBody 下载时间若某请求Latency超过 2s大概率是 DNS 解析慢或服务器响应延迟此时应考虑添加备用 DNS如1.1.1.1或服务端健康检查。5. 避坑指南新手最容易翻车的 4 个血泪现场5.1 现象编译报错error: cannot find symbol class Hilt_XXXActivity原因Hilt 注解处理器未启用或AndroidEntryPoint未加在正确的类上。常见误操作是给Application加了HiltAndroidApp却忘了给Activity加AndroidEntryPoint。解决检查app/build.gradle是否包含kapt com.google.dagger:hilt-android-compiler:2.44并确认MainActivity类上方有AndroidEntryPoint注解。若使用 Kotlin还需添加kapt androidx.hilt:hilt-compiler:1.0.0。5.2 现象列表滚动卡顿Systrace 显示RecyclerView#dispatchLayout占用过高原因GalleryAdapter的areContentsTheSame()方法中直接使用比较GalleryUiModel对象而该类未重写equals()导致每次 Diff 都认为内容不同触发全量刷新。解决在GalleryUiModel.kt中添加data class声明或手动实现equals()与hashCode()。推荐前者data class GalleryUiModel(val id: Long, val title: String, ...)5.3 现象图片加载失败Coil 日志显示Failed to decode stream原因nhentai.net返回的缩略图 URL 是 WebP 格式而部分 Android 4.x 设备不支持 WebP 解码。解决在ImageLoader.kt中添加 WebP 解码支持仅针对低版本if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { builder.componentRegistry { add(WebpDecoder.Factory()) } }同时在app/build.gradle中添加依赖implementation com.github.zjupure:webp-android:0.2.05.4 现象搜索功能始终返回空列表但浏览器访问https://nhentai.net/api/galleries/search/?queryxxx能正常返回原因URL 编码问题。Query(query)会自动对参数做 URLEncode而nhentai.netAPI 要求查询词为原始字符串如空格不转为%20。解决改用Query(encoded true)并手动编码GET(galleries/search/) FlowableSearchResponse searchGalleries( Query(value query, encoded true) String encodedQuery, Query(page) int page );调用前使用URLEncoder.encode(query, UTF-8)但注意号需额外替换为%20因为URLEncoder会把空格转为。6. 二次开发实战如何安全地接入自己的后端 API 并替换首页数据源6.1 替换数据源从 NHentai API 切换到自有 REST 接口的四步改造假设你已有一个符合 REST 规范的漫画 API地址为https://myapi.com/v1/返回结构如下{ data: [ { id: 12345, title: My Custom Gallery, thumbnail: https://mycdn.com/thumbs/12345.jpg, pages: 42 } ], pagination: { current_page: 1, last_page: 10 } }第一步定义新 API 接口在core/src/main/java/com/nhentai/core/network/CustomApiService.kt中新建接口interface CustomApiService { GET(galleries/popular) suspend fun getPopularGalleries( Query(page) page: Int ): ApiResponseListCustomGalleryDto }其中ApiResponseT是你封装的统一响应体CustomGalleryDto对应上述 JSON 中的data数组元素。第二步替换 UseCase 实现修改domain/src/main/java/com/nhentai/domain/usecase/GetPopularGalleriesUseCase.ktclass GetPopularGalleriesUseCase Inject constructor( private val customApiService: CustomApiService // 替换为新 Service ) { operator fun invoke(page: Int): FlowPagingDataGallery { return Pager( config PagingConfig(pageSize 25), pagingSourceFactory { CustomGalleryPagingSource(customApiService, page) } ).flow.cachedIn(viewModelScope) } }CustomGalleryPagingSource需继承PagingSourceInt, Gallery重写load()方法调用customApiService.getPopularGalleries()并映射为Gallery。第三步更新 Hilt Module在core/src/main/java/com/nhentai/core/di/NetworkModule.kt中将provideApiService()替换为provideCustomApiService()并确保CustomApiService被Binds或Provides正确注入。第四步调整 UI 层适配GalleryUiModel的构造逻辑需适配新字段名如thumbnail→thumbnailUrl并在GalleryAdapter的bind()中同步更新Glide.load()参数。提示不要删除原ApiService保留它作为对比参考所有新代码放在custom包下避免污染主线逻辑。6.2 安全加固为自有 API 添加 Token 认证与请求签名若你的 API 要求 Header 中携带Authorization: Bearer token且每次请求需附带时间戳与签名// 在 OkHttp Interceptor 中添加 .addInterceptor { chain - val originalRequest chain.request() val timestamp System.currentTimeMillis() / 1000 val signature generateSignature(originalRequest.url().toString(), timestamp) val newRequest originalRequest.newBuilder() .header(Authorization, Bearer $authToken) .header(X-Timestamp, timestamp.toString()) .header(X-Signature, signature) .build() chain.proceed(newRequest) }generateSignature()可使用 HMAC-SHA256密钥由BuildConfig注入或从 Keystore 读取避免硬编码。6.3 可观测性增强在关键路径埋点并上报成功率在GetPopularGalleriesUseCase.invoke()中添加统计operator fun invoke(page: Int): FlowPagingDataGallery { val startTime System.currentTimeMillis() return Pager(...).flow .onEach { data - val duration System.currentTimeMillis() - startTime Analytics.logEvent(popular_load_success, mapOf( page to page.toString(), duration_ms to duration.toString(), count to data.size.toString() )) } .catch { e - Analytics.logEvent(popular_load_error, mapOf( page to page.toString(), error to e.javaClass.simpleName )) } }Analytics是你封装的埋点 SDK可对接 Firebase Analytics、自建日志服务或简单写入本地文件。从那以后我每次接入新 API都强制走一遍「定义 DTO → 写 Mock Response → 跑通 UseCase → 补全 Error Handling → 加埋点」这五步闭环哪怕只是临时 Demo。少跳一步后期查NullPointerException就得多花两小时——这已经是我用三个项目换来的后悔药剂量。希望帮到你。本文还有配套的精品资源点击获取