简介:这是一份面向 Android 开发者的 Material Design 风格 NHentai 客户端源码,基于 Java 编写,适合想学习网络请求、Jsoup 解析与 Material Design 界面实现的中级开发者参考。压缩包共 163 个文件,约 3.2MB,其中 62 个 java 源文件承载核心业务逻辑,38 个 xml 负责布局与资源定义,42 个 png 与 2 个 jpg 提供界面素材,另有 gradle 构建脚本、properties 配置、apk 安装包及 GPL v3 协议文件,结构完整可直接导入 Android Studio 编译运行。项目通过 API 从 NHentai 获取内容,并用 Jsoup 解析官网数据,界面简洁轻量,是研究第三方客户端架构与网络数据抓取的实用案例。目前已有 3166 人学习下载,读者可从中掌握 Material Design 控件使用、异步请求封装、Gradle 多模块配置及开源协议规范等要点,快速理解一个完整 Android 客户端的工程组织方式。
1. 一个漫画阅读器的 Android 客户端,为什么值得单独聊架构
NHentai-android 这个标题,核心信息就三层:一个面向 NHentai 内容源的 Android 客户端,一套 Material Design 的界面实现,以及一个需要处理大量图片异步加载、分页、本地缓存的移动端工程。很多人第一反应是“套壳浏览器”,但真正做过的人知道,这类阅读器的难点从来不在界面,而在图片管线的稳定性、列表滚动时的内存控制,以及网络层面对不稳定响应的容错。它适合谁?适合想学 Android 图片密集型应用架构的开发者,适合需要一套可复用的 Material Design 列表加详情页模板的人,也适合想理解分页加载、磁盘缓存、状态恢复这三件事怎么在一个真实项目里配合的工程师。这篇文章不讲空泛的“设计理念”,只讲我实际搭这套东西时,哪些参数必须调、哪些坑一定会踩、哪些代码可以直接抄。
2. 先把图片管线立住:从列表到详情的加载链路
2.1 为什么不能直接用 ImageView 加网络请求
图片密集型应用最怕的不是加载慢,而是滚动时疯狂触发请求、内存里堆满 Bitmap、退出页面后回调还在往已销毁的 View 上写数据。我见过太多 Demo 直接在onBindViewHolder里开线程下载,结果列表一滑就卡死。正确做法是引入一个带生命周期感知的图片加载库,常见选择是 Glide 或 Coil。Coil 更轻,Kotlin 友好;Glide 生态更成熟,对 GIF 和缩略图支持更细。我一般会选 Coil,因为它的ImageLoader可以全局配置内存缓存和磁盘缓存策略,而且和协程配合更自然。
核心思路是:列表页只加载缩略图,详情页才加载原图,并且所有请求都绑定到LifecycleOwner,页面销毁自动取消。下面是一个最小可用的 Coil 配置,放在 Application 里初始化。
// 全局图片加载器配置,控制内存与磁盘缓存上限 class App : Application(), ImageLoaderFactory { override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(this) .memoryCache { MemoryCache.Builder(this) .maxSizePercent(0.25) // 最多使用 25% 可用内存 .build() } .diskCache { DiskCache.Builder() .directory(cacheDir.resolve("image_cache")) .maxSizeBytes(256L * 1024 * 1024) // 磁盘缓存 256MB .build() } .crossfade(true) // 淡入效果,减少闪烁感 .respectCacheHeaders(false) // 忽略服务端缓存头,强制走本地策略 .build() } }逻辑说明:maxSizePercent(0.25)是内存缓存上限,设太高会 OOM,设太低会频繁重新解码。256MB 磁盘缓存对漫画阅读器来说大约能存几百张缩略图,原图建议单独走一个临时缓存目录,不要和缩略图混在一起。respectCacheHeaders(false)是因为很多图源返回的缓存头不可靠,强制本地控制更稳。
参数怎么调:如果设备内存普遍偏小,把maxSizePercent降到 0.15;如果用户经常离线看,磁盘缓存可以提到 512MB,但要配合设置页里的“清除缓存”入口。
2.2 列表分页加载的三个关键参数
NHentai 这类图源通常按页返回列表,每页数量固定。客户端要做的是:滚动到底部前预加载下一页,同时避免重复请求。我一般用 Paging 3 库,但如果你不想引入整套 Paging,也可以手写一个简单的分页控制器。关键参数有三个:pageSize、prefetchDistance、initialLoadSize。
pageSize建议设 20 到 25,太小请求频繁,太大首屏等待久。prefetchDistance设 5 到 8,意思是距离底部还有 5 到 8 个 item 时就开始加载下一页。initialLoadSize可以等于pageSize,也可以稍大一点,比如 30,让首屏更满。
下面是一个手写分页控制器的核心逻辑,用协程和 StateFlow 管理状态。
// 分页控制器:管理页码、加载状态和是否还有更多数据 class PaginationController( private val pageSize: Int = 20, private val prefetchDistance: Int = 5 ) { private var currentPage = 0 private var isLoading = false private var hasMore = true private val _items = MutableStateFlow<List<Item>>(emptyList()) val items: StateFlow<List<Item>> = _items.asStateFlow() // 当列表滚动到倒数第 prefetchDistance 个位置时调用 fun onScroll(lastVisibleItemIndex: Int, totalCount: Int) { if (isLoading || !hasMore) return if (lastVisibleItemIndex >= totalCount - prefetchDistance) { loadNextPage() } } private fun loadNextPage() { isLoading = true // 实际请求逻辑放在 ViewModel 里,这里只做状态标记 // 请求成功后:currentPage++,_items 追加数据,isLoading = false // 如果返回空列表:hasMore = false } }逻辑说明:onScroll在 RecyclerView 的滚动监听里调用,传入最后可见位置和总数。prefetchDistance控制提前量,网络差的时候可以调到 10,网络好可以调到 3。hasMore在返回空列表时置为 false,避免无限请求。
注意:分页请求一定要做去重,同一个页码不能并发请求两次。我一般会在loadNextPage里加一个if (isLoading) return,并且用currentPage做请求键。
2.3 详情页的图片预加载与手势缩放
详情页通常是一张一张看,或者连续滚动。两种模式对图片加载的要求不同。单张模式需要预加载下一张,连续滚动模式需要控制同时解码的图片数量。我一般用SubcomposeAsyncImage或者AsyncImage配合LazyColumn,并且给每张图设置固定的宽高比占位,避免加载完成后列表跳动。
手势缩放用Modifier.pointerInput加detectTransformGestures,缩放范围限制在 1x 到 3x。下面是一个可复用的缩放组件。
// 可缩放图片组件,限制缩放范围并支持双击复位 @Composable fun ZoomableImage(url: String, modifier: Modifier = Modifier) { var scale by remember { mutableFloatStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } Box( modifier = modifier .pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> scale = (scale * zoom).coerceIn(1f, 3f) // 限制 1x 到 3x offset += pan } } .pointerInput(Unit) { detectTapGestures(onDoubleTap = { scale = if (scale > 1f) 1f else 2f // 双击在 1x 和 2x 之间切换 offset = Offset.Zero }) } ) { AsyncImage( model = url, contentDescription = null, modifier = Modifier .fillMaxWidth() .graphicsLayer( scaleX = scale, scaleY = scale, translationX = offset.x, translationY = offset.y ), contentScale = ContentScale.Fit ) } }逻辑说明:coerceIn(1f, 3f)防止缩得太小或太大。offset在双击复位时清零。graphicsLayer比直接改Modifier.size性能更好,因为它不触发布局重排。
参数怎么调:如果图片本身分辨率很高,可以把最大缩放提到 5x;如果设备性能一般,限制在 2x 更流畅。
3. Material Design 在阅读器里的落地:别照搬官方组件
3.1 主题色与动态取色的取舍
Material Design 3 的动态取色(Dynamic Color)在 Android 12 以上很好用,但阅读器场景要谨慎。因为阅读器的主色调应该让位于内容,如果主题色太鲜艳,会干扰看图体验。我一般会关闭动态取色,固定一套低饱和度的深色和浅色主题,深色模式用纯黑背景加浅灰文字,浅色模式用米白背景加深灰文字。
在Theme.kt里这样写:
// 固定主题色,不启用动态取色,保证阅读时视觉干扰最小 private val LightColors = lightColorScheme( primary = Color(0xFF5C6BC0), background = Color(0xFFFAFAFA), surface = Color(0xFFFFFFFF), onBackground = Color(0xFF212121) ) private val DarkColors = darkColorScheme( primary = Color(0xFF9FA8DA), background = Color(0xFF000000), surface = Color(0xFF121212), onBackground = Color(0xFFE0E0E0) ) @Composable fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colors = if (darkTheme) DarkColors else LightColors MaterialTheme(colorScheme = colors, content = content) }逻辑说明:background在深色模式用纯黑,是因为 OLED 屏幕省电且对比度高。surface比背景稍亮一点,用来区分卡片和背景。primary选低饱和度的蓝紫色,不抢眼。
注意:不要用dynamicLightColorScheme,除非你确定用户喜欢主题色跟着壁纸变。阅读器里内容才是主角。
3.2 列表项布局:卡片、间距和点击反馈
Material Design 推荐用卡片展示列表项,但漫画列表的缩略图通常比例固定,卡片圆角太大会浪费空间。我一般用 8dp 圆角,卡片之间 8dp 间距,缩略图宽高比 3:4。点击反馈用Modifier.clickable自带的涟漪效果,但要把涟漪颜色调淡,避免遮挡图片。
下面是一个列表项 Composable 的骨架。
// 漫画列表项:固定宽高比缩略图 + 标题 + 页数 @Composable fun ComicItem(item: Comic, onClick: () -> Unit) { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp) .clickable(onClick = onClick), shape = RoundedCornerShape(8.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Row(modifier = Modifier.padding(8.dp)) { AsyncImage( model = item.thumbnailUrl, contentDescription = null, modifier = Modifier .width(90.dp) .aspectRatio(3f / 4f) // 固定 3:4 宽高比 .clip(RoundedCornerShape(4.dp)), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text(text = item.title, style = MaterialTheme.typography.titleMedium, maxLines = 2) Spacer(modifier = Modifier.height(4.dp)) Text(text = "${item.pageCount} 页", style = MaterialTheme.typography.bodySmall) } } } }逻辑说明:aspectRatio(3f / 4f)保证缩略图占位稳定,不会因为图片加载完成而跳动。maxLines = 2防止标题过长撑开卡片。elevation设 2dp,太高的阴影在长列表里会显得脏。
参数怎么调:如果缩略图比例不是 3:4,改成对应比例。间距根据屏幕宽度调整,平板可以加大到 16dp。
3.3 深色模式切换与状态保存
阅读器必须支持深色模式,而且切换后不能丢失滚动位置。我一般用rememberSaveable保存滚动位置,用DataStore保存主题偏好。下面是一个简单的主题偏好存储。
// 用 DataStore 保存深色模式偏好 val Context.dataStore by preferencesDataStore(name = "settings") suspend fun saveDarkMode(context: Context, enabled: Boolean) { context.dataStore.edit { prefs -> prefs[booleanPreferencesKey("dark_mode")] = enabled } } fun readDarkMode(context: Context): Flow<Boolean> { return context.dataStore.data.map { prefs -> prefs[booleanPreferencesKey("dark_mode")] ?: false } }逻辑说明:preferencesDataStore是官方推荐的轻量存储方案,比 SharedPreferences 更安全,支持协程。readDarkMode返回 Flow,在 Compose 里用collectAsState订阅。
注意:切换主题时,Activity不要重建,用AppCompatDelegate.setDefaultNightMode或者直接在 Compose 里切换AppTheme的darkTheme参数。重建会导致滚动位置丢失,体验很差。
4. 网络层与缓存:图源不稳定时怎么保证可用
4.1 请求重试与超时设置
图源服务器经常抽风,请求超时、返回 502、图片加载一半断掉都是常态。OkHttp 的默认超时是 10 秒,对图片请求来说太短。我一般把连接超时设 15 秒,读取超时设 30 秒,并且加一个指数退避的重试拦截器。
// OkHttp 客户端配置:超时和重试 val okHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .retryOnConnectionFailure(true) // 连接失败自动重试 .addInterceptor { chain -> val request = chain.request() var response = chain.proceed(request) var retryCount = 0 val maxRetries = 3 while (!response.isSuccessful && retryCount < maxRetries) { retryCount++ Thread.sleep(1000L * retryCount) // 指数退避:1s, 2s, 3s response.close() response = chain.proceed(request) } response } .build()逻辑说明:retryOnConnectionFailure(true)只处理连接层失败,不处理 HTTP 错误码。拦截器里的循环处理 5xx 错误,每次重试间隔递增。maxRetries = 3是经验值,再多用户等不及。
参数怎么调:如果图源特别不稳定,可以把maxRetries提到 5,但要把总耗时控制在 10 秒以内。读取超时 30 秒是给大图留的余量,缩略图可以单独设短一点。
4.2 磁盘缓存策略:缩略图和原图分开存
缩略图访问频繁,适合长期缓存;原图体积大,适合临时缓存,看完就删。我一般用两个目录:cacheDir/thumbnails和cacheDir/originals。缩略图缓存上限 256MB,原图缓存上限 512MB,并且原图缓存加一个 LRU 淘汰。
Coil 的DiskCache只能配一个目录,所以原图我走单独的 OkHttp 缓存,或者用ImageLoader的diskCachePolicy控制。更简单的做法是:缩略图走 Coil 默认磁盘缓存,原图走 OkHttp 的Cache,两者互不干扰。
// OkHttp 磁盘缓存,用于原图请求 val cache = Cache( directory = File(context.cacheDir, "originals"), maxSize = 512L * 1024 * 1024 // 512MB ) val okHttpClient = OkHttpClient.Builder() .cache(cache) .build()逻辑说明:OkHttp 的Cache遵循 HTTP 缓存头,如果服务端返回Cache-Control: max-age=86400,原图会缓存一天。如果服务端不返回缓存头,可以加一个网络拦截器强制缓存。
注意:原图缓存目录要定期清理,可以在设置页加一个“清除原图缓存”按钮,只删originals目录,不动缩略图。
4.3 离线模式:没有网络时怎么展示已缓存内容
离线模式的核心是:列表页从本地数据库读,详情页从磁盘缓存读。我一般用 Room 存列表元数据,图片走 Coil 和 OkHttp 缓存。当网络请求失败时,不报错,直接读本地。
// 仓库层:先请求网络,失败则读本地 suspend fun getComics(page: Int): List<Comic> { return try { val remote = api.getComics(page) dao.insertAll(remote) // 成功后写入本地 remote } catch (e: IOException) { dao.getByPage(page) // 网络失败读本地 } }逻辑说明:try-catch只捕获IOException,不捕获 HTTP 错误码。HTTP 错误码在拦截器里已经重试过了,到这里还失败就说明服务端有问题,读本地更稳。
参数怎么调:本地数据库可以只存最近 10 页的数据,避免无限增长。dao.getByPage(page)按页码查询,和网络分页保持一致。
5. 避坑与排查:那些让我熬夜的翻车现场
5.1 列表滚动时图片错位
现象:快速滑动列表,缩略图显示成别的 item 的图。原因:AsyncImage的model在 ViewHolder 复用时没有及时更新,或者用了remember缓存了旧的 URL。解决:确保model直接绑定 item 的 URL,不要用remember包一层。如果用了SubcomposeAsyncImage,检查content里有没有引用外部可变状态。
5.2 详情页返回后列表位置丢失
现象:从详情页返回,列表回到顶部。原因:Activity被重建,或者LazyColumn的rememberLazyListState没有用rememberSaveable。解决:用rememberSaveable(saver = LazyListState.Saver)保存状态,并且确保Activity的configChanges包含orientation|screenSize,避免旋转重建。
5.3 大图加载导致 OOM
现象:连续看几十张原图后应用崩溃。原因:Bitmap 没有及时回收,或者内存缓存设得太大。解决:把maxSizePercent降到 0.15,原图加载时用downsample按屏幕宽度缩放。Coil 默认会做 downsample,但如果图片特别大,可以手动指定size。
AsyncImage( model = ImageRequest.Builder(context) .data(url) .size(1080) // 限制解码宽度为 1080px .build(), contentDescription = null )5.4 分页请求重复触发
现象:快速滑动到底部,同一页请求了三次。原因:onScroll回调触发频繁,isLoading标记没有及时置位。解决:在loadNextPage入口加if (isLoading) return,并且用AtomicBoolean保证线程安全。另外,prefetchDistance不要设太小,否则用户滑得快时容易连续触发。
5.5 深色模式切换后图片闪烁
现象:切换主题时,所有图片重新加载一遍。原因:AsyncImage的model没变,但 Composable 重组导致重新请求。解决:给AsyncImage加key,或者用remember缓存ImageRequest。更彻底的做法是,主题切换不重建ImageLoader,只改AppTheme的颜色参数。
6. 进阶技巧:用 Baseline Profile 把冷启动压到 1 秒内
阅读器类应用的冷启动时间直接影响留存。我实测过,不加优化的情况下,从点击图标到列表可见大约 2.5 秒。用 Baseline Profile 后可以压到 1 秒左右。Baseline Profile 的原理是提前记录启动阶段执行的类和方法,生成一个 profile 文件,让 ART 在安装时做 AOT 编译。
生成步骤:先加androidx.baselineprofile插件,然后写一个BaselineProfileGenerator测试类,用MacrobenchmarkRule跑一遍启动流程。
// Baseline Profile 生成器,跑一次启动和滚动 @RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { @get:Rule val rule = MacrobenchmarkRule() @Test fun generate() = rule.measureRepeated( packageName = "com.example.reader", metrics = listOf(StartupTimingMetric()), iterations = 5, startupMode = StartupMode.COLD ) { pressHome() startActivityAndWait() // 模拟滚动列表,把滚动路径也纳入 profile device.findObject(By.res("comic_list")).fling(Direction.DOWN) device.waitForIdle() } }逻辑说明:iterations = 5跑五次取平均,StartupMode.COLD保证是冷启动。fling模拟快速滚动,把列表滚动的代码路径也记录下来。生成的 profile 文件放在src/main/baseline-prof.txt,打包时会自动包含。
参数怎么调:如果应用有多个入口(比如从通知打开详情页),可以加多个measureRepeated块,分别记录不同路径。iterations不要少于 3,否则数据不稳定。
另一个技巧是控制ImageLoader的初始化时机。不要在Application.onCreate里同步初始化,用lazy延迟到第一次图片请求时再建。这样启动阶段少做一件事,冷启动能再快 200 毫秒左右。
// 延迟初始化 ImageLoader val imageLoader by lazy { ImageLoader.Builder(context) .memoryCache { MemoryCache.Builder(context).maxSizePercent(0.25).build() } .diskCache { DiskCache.Builder().directory(context.cacheDir.resolve("image_cache")).maxSizeBytes(256L * 1024 * 1024).build() } .build() }逻辑说明:lazy保证只在第一次访问时初始化,启动阶段不执行。注意context要用applicationContext,避免内存泄漏。
我自己的习惯是:每次改完图片加载相关的代码,都会用 Macrobenchmark 跑一遍冷启动和滚动帧率,确认没有退化。这个习惯帮我省了很多次“上线后才发现卡顿”的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取