免登录无广告漫画阅读器开发实践:书架、书源与渲染架构 📅 发布时间:2026/9/5 16:02:54 👁 浏览次数: 手机上看漫画很多人并不缺一个能装漫画的 App缺的是清爽的阅读体验。官方客户端往往包含登录态、会员、插屏广告不同平台之间的内容又互相隔离用户很容易把注意力花在账号和提醒上。搜索引擎里经常出现的“免费、无广告、免登录、全网聚合”类漫画阅读 APP多数是包壳或版权风险极高的第三方整合版装上之后能看多久、数据是否安全都很难保证。与其长期依赖这类来路不明的工具不如换一个思路按“本地书架 可配置书源 漫画阅读器渲染层”的方式自己实现一款免登录、无内置广告、界面干净的漫画阅读 APP 原型。这篇内容会从一个最小可运行的 Android 项目讲起把漫画阅读 APP 的架构边界、存储设计、网络来源抽象、图片渲染优化等关键点解释清楚。这里不提供任何盗版资源聚合方案只讨论如何在版权合规的前提下把工程能力做到干净、可靠、可扩展。本文字量较大前置知识要求是熟悉 Kotlin 基础语法、Android 四大组件基本使用并且至少能创建一个 Compose 空项目。如果你已经写过一些 Android 界面只是还没有把“书架、书源、阅读器、缓存”这些模块串起来那这篇会比较合适。1. 先别急着写代码把漫画阅读 App 的真实结构拆开漫画类应用的难点从来不是“显示一张图片”而是如何处理好局部缓存、阅读进度、来源差异、翻页手感、大图内存控制等一连串问题。只有先理解整体模块再开始编码后面每一步才不会被细节带偏。1.1 漫画阅读 App 不只是图片浏览器从功能层面看一个完整的漫画阅读 App 至少包含五层模块模块职责关键问题书架数据层保存收藏漫画、阅读进度、章节列表数据模型、持久化方式、更新策略来源解析层从不同来源拿到漫画元数据、章节列表、图片地址接口稳定性、来源差异、字段映射网络与缓存层请求图片、缓存封面和大图、处理失败重试超时、断点、磁盘占用、OOM阅读器渲染层单页阅读、双页、Webtoon 纵向滑动、缩放翻页渲染容器、手势冲突、预加载设置与合规层书源配置、备份导出、日志清理用户数据安全、内容版权约束很多初级项目把所有逻辑都写在 Activity 和 Adapter 里短时间能跑起来一旦需要支持第二个书源代码会非常难维护。原因就是来源解析和书架存储耦合得太紧。正确做法是把每一层拆开让上层界面只依赖抽象接口不关心数据来自本地 ZIP、远程 JSON 还是某个网站的 HTML 页面。1.2 “免费、无广告、免登录”对应哪些工程决策标题中常出现的这几个词并不只是一个营销包装它们分别对应一组工程决策免费应用本身可以不上架收费分销渠道也可以做成开源项目或本地工具。无广告启动时不加载第三方广告 SDK不申请读取广告标识符页面里不插入开屏、插屏广告。免登录不引入账号体系所有书架和进度都保存在本地数据库中不需要手机号、不需要第三方授权。画质超清图片不做强制压缩阅读器支持原图加载、双指缩放、双页模式。这些需求本身是正当的。很多第三方打包 App 的问题不在于“免费”或“无广告”而在于把未授权内容嵌进应用同时内置统计和推送 SDK用户根本不知道数据去了哪里。自己做阅读器时完全可以保留“无登录、无广告、本地优先”的产品形态但内容来源必须由使用者自己确认并承担相应义务。1.3 为什么把“全网聚合”做成统一模型而不是内置固定源如果直接把某个网站写在 Activity 中最快一天就能出效果。问题在于来源网址一旦改版、封 IP、关站或修改参数代码就立刻失效。对普通用户来说第三方“聚合版”随时会崩原因就在这里。更可靠的做法是定义一套来源抽象interface MangaSource { val id: String suspend fun search(keyword: String): ListComicMeta suspend fun loadChapters(comicId: String): ListChapter suspend fun loadChapterImages(comicId: String, chapterId: String): ListChapterImage }界面只需要面向MangaSource编程。新增来源时只要新增一个实现类并在设置页里让用户配置来源信息即可。这里特别要强调实际项目中用户添加的任何来源都必须确认其内容有合法授权。文章不提供任何用于抓取未授权站点的解析模板。2. 环境准备与项目骨架技术文章最怕环境不一致导致示例跑不起来。下面以我写本文时使用的版本为例实际操作时建议先打开 Android Studio 看一下自己默认生成的版本再决定是否修改。2.1 开发环境版本建议组件版本说明Android Studio2024.2.x 及以上版本越新Compose 模板越完整JDK17Android Gradle Plugin 8.x 默认要求Kotlin2.0.21需要与 Compose 编译器保持兼容Gradle8.9通常由 Gradle Wrapper 自动提供AGP8.7.2Android Gradle PluginminSdk23Android 6.0 以上targetSdk35Android 15 及以上Compose BOM2024.12.01统一 Compose 依赖版本Room2.6.1本地数据库OkHttp4.12.0网络请求Coil2.7.0图片加载Compose 集成方便kotlinx.serialization1.7.3JSON 解析下表用于快速对照不建议把某个版本当成不可变的唯一标准。创建新项目时Android Studio 自动生成的依赖版本往往更可靠。2.2 创建空白 Compose 工程并加入依赖新建项目时选择 “Empty Activity”模板启用后在app/build.gradle.kts中追加关键插件和依赖。plugins { id(com.android.application) id(org.jetbrains.kotlin.android) id(org.jetbrains.kotlin.plugin.serialization) version 2.0.21 id(com.google.devtools.ksp) version 2.0.21-1.0.27 } android { namespace com.example.comicreader compileSdk 35 defaultConfig { applicationId com.example.comicreader minSdk 23 targetSdk 35 versionCode 1 versionName 1.0 } buildFeatures { compose true } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget 17 } } dependencies { implementation(platform(androidx.compose:compose-bom:2024.12.01)) implementation(androidx.compose.ui:ui) implementation(androidx.compose.material3:material3) implementation(androidx.activity:activity-compose:1.9.3) implementation(androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7) implementation(androidx.room:room-runtime:2.6.1) implementation(androidx.room:room-ktx:2.6.1) ksp(androidx.room:room-compiler:2.6.1) implementation(com.squareup.okhttp3:okhttp:4.12.0) implementation(io.coil-kt:coil-compose:2.7.0) implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3) }注意如果项目使用 Java 17Room 的 KSP 注解处理器版本必须与 Kotlin 版本匹配。Kotlin 升级后KSP 版本也要同步升级否则会直接编译报错。2.3 AndroidManifest 与网络明文策略漫画阅读器必然需要网络权限和本地文件访问权限。最稳妥的方式是使用系统文件选择器不申请整个存储空间的读取权限。uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE /AndroidManifest.xml中 application 节点内建议加入网络明文判断application android:allowBackuptrue android:usesCleartextTrafficfalse android:themestyle/Theme.ComicReader activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application实际开发中本地局域网书源或测试服务器可能使用http://地址。Android 9 开始默认禁止明文请求。如果只是本地联调可以把android:usesCleartextTraffic临时设为true。生产环境不应该全局打开建议使用network_security_config.xml缩小范围只允许特定域名走明文。注意不要为了省事把网络限制全部关掉再上架。全局明文流量不仅影响安全性也会在应用市场审核时成为明显问题。3. 书架设计本地优先、免登录的数据底座免登录不代表没有数据模型。相反书架是漫画 App 所有功能的入口。把书架的数据结构设计好以后无论接入多少个来源阅读进度和收藏历史都能稳定工作。3.1 用 Room 保存漫画、章节和阅读进度推荐把实体拆成三张表漫画表保存来源信息和封面章节表保存某一部作品下的所有章节阅读进度表保存最近阅读位置。Entity( tableName comics, primaryKeys [sourceId, comicId] ) data class ComicEntity( val sourceId: String, val comicId: String, val title: String, val author: String , val coverUrl: String , val intro: String , val lastChapterTitle: String , val updatedAt: Long System.currentTimeMillis() ) Entity( tableName chapters, primaryKeys [sourceId, comicId, chapterId] ) data class ChapterEntity( val sourceId: String, val comicId: String, val chapterId: String, val title: String, val order: Int, val updatedAt: Long System.currentTimeMillis() ) Entity(tableName reading_progress) data class ReadingProgressEntity( PrimaryKey val compositeId: String, val sourceId: String, val comicId: String, val chapterId: String, val pageIndex: Int 0, val updatedAt: Long System.currentTimeMillis() )用sourceId comicId作为联合主键是为了避免不同来源出现相同 ID 时互相覆盖。阅读进度表里的compositeId可以用$sourceId/$comicId拼出来这样每次读取进度时不需要走复合查询。有两点需要关注updatedAt用于书架排序用户最近打开的漫画排在最前面。ChapterEntity.order必须使用稳定的整数排序。很多来源的章节名是“第1话”“第10话”直接按字符串排序会出现“第10话”排在“第2话”前面的问题。3.2 用 Flow 驱动书架 UIRoom 天然支持返回FlowListT。书架 UI 只要收集这个 Flow数据库变化后界面会自动刷新。Dao interface ComicDao { Insert(onConflict OnConflictStrategy.REPLACE) suspend fun upsertComic(comic: ComicEntity) Insert(onConflict OnConflictStrategy.REPLACE) suspend fun upsertChapters(chapters: ListChapterEntity) Query( SELECT * FROM comics ORDER BY updatedAt DESC ) fun observeAllComics(): FlowListComicEntity Query( SELECT * FROM chapters WHERE sourceId :sourceId AND comicId :comicId ORDER BY order ASC ) fun observeChapters(sourceId: String, comicId: String): FlowListChapterEntity Query( SELECT * FROM reading_progress WHERE compositeId :compositeId ) fun observeProgress(compositeId: String): FlowReadingProgressEntity? }免登录的架构下书架数据默认只存在于应用私有目录。即使不采集用户信息也要在 UI 上提供备份和导出入口否则用户换手机就丢失全部阅读记录。3.3 导入本地 CBZ 或 ZIP 文件CBZ 是漫画领域常见打包格式本质是一个 ZIP 压缩包里面按顺序存放图片。Android 端不需要额外引第三方库直接用java.util.zip.ZipFile就可以。fun listImagesFromCbz(zipFile: File): ListZipEntry { return ZipFile(zipFile).use { zip - zip.entries() .asSequence() .filter { !it.isDirectory } .filter { entry - val name entry.name.lowercase() name.endsWith(.jpg) || name.endsWith(.jpeg) || name.endsWith(.png) || name.endsWith(.webp) } .sortedBy { it.name } .toList() } }这里有一个容易被忽略的坑ZIP 中文件名可能包含../或绝对路径。如果直接把 entry 名称拼到输出目录后解压会造成路径穿越。安全做法是把 entry 名称归一化去掉路径分隔符只取文件名并拒绝包含..的条目。fun safeExtractEntryName(entry: ZipEntry): String? { val rawName entry.name.replace(\\, /) if (rawName.startsWith(/) || rawName.contains(..)) { return null } return rawName.substringAfterLast(/) }用系统文件选择器选择 CBZ 文件时建议把文件复制到应用私有缓存目录再做解压或读取。不要长期持有content://的内容流因为部分文件管理器的授权访问可能在应用重启后失效。4. 把书源做成一等公民来源抽象与配置化真正的漫画阅读器很难只依赖一个来源。目录接口、封面字段、章节列表、图片地址格式都不一样。最直接的错误做法是在页面里写死“来源 A 的请求逻辑”。一旦接入来源 B整个页面都要跟着改。4.1 设计统一的元数据模型在来源抽象层之上定义一套统一的漫画元数据和章节数据模型Serializable data class ComicMeta( val sourceId: String, val comicId: String, val title: String, val author: String , val coverUrl: String , val intro: String ) Serializable data class Chapter( val comicId: String, val chapterId: String, val title: String, val order: Int ) Serializable data class ChapterImage( val chapterId: String, val pageIndex: Int, val imageUrl: String )有了这套模型后所有来源解析结果最终都要转换成上面三种对象。这样书架、阅读器、缓存模块就能完全忽略来源差异。4.2 定义一个演示用的 JSON 书源配置为了让示例可运行不需要抓取任何真实网站可以使用一个本地 Mock 服务或自建授权服务。先定义来源配置结构Serializable data class JsonSourceConfig( val id: String, val name: String, val baseUrl: String , val searchPath: String /search?q{keyword}, val chapterPath: String /comic/{comicId}/chapters, val imagePath: String /chapter/{comicId}/{chapterId}/images )对应的请求返回 JSON 可以自行规范。以搜索接口为例统一约定返回结构{ code: 0, message: ok, data: { items: [ { id: demo-001, title: 示例漫画 001, author: 作者A, cover: https://example.com/covers/001.jpg, intro: 这是一本示例漫画 } ] } }解析这一结构的 Kotlin 代码可以用kotlinx.serialization也可以用标准库中的org.json.JSONObject。对小型阅读器项目来说JSON 结构不复杂时JSONObject反而更直观。suspend fun parseSearchResult(sourceId: String, raw: String): ListComicMeta { return withContext(Dispatchers.IO) { val root JSONObject(raw) if (root.optInt(code, -1) ! 0) { error(搜索接口异常: ${root.optString(message)}) } val items root.getJSONObject(data).getJSONArray(items) buildList { repeat(items.length()) { index - val item items.getJSONObject(index) add( ComicMeta( sourceId sourceId, comicId item.getString(id), title item.getString(title), author item.optString(author, ), coverUrl item.optString(cover, ), intro item.optString(intro, ) ) ) } } } }JSONObject的好处是修改字段不影响编译缺点是缺少编译期类型检查。如果接口字段经常变化建议最终切换成kotlinx.serialization的Serializable数据类并用Json { ignoreUnknownKeys true }解析。4.3 网络请求层封装网络请求层要做三件事拼接 URL、发起 GET 请求、读取字符串响应。真正的工程还要加超时、重试、日志和异常包装。class HttpBookClient( private val client: OkHttpClient, private val connectTimeoutSeconds: Long 10, private val readTimeoutSeconds: Long 15 ) { private val httpClient: OkHttpClient client.newBuilder() .connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS) .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) .build() suspend fun getJson(url: String): String withContext(Dispatchers.IO) { val request Request.Builder() .url(url) .header(User-Agent, ComicReaderSample/1.0) .build() httpClient.newCall(request).execute().use { response - if (!response.isSuccessful) { error(HTTP ${response.code}: $url) } response.body?.string() ?: error(response body empty) } } }将withContext(Dispatchers.IO)放在网络层内部可以保证调用方即使从 UI 线程调用也不会直接卡 UI。不过更推荐在 ViewModel 中使用协程调用网络层让网络调度和生命周期解耦。4.4 使用 ViewModel 组织搜索流程一个简单但规范的流程是用户在搜索框输入关键字ViewModel 调用searchUI 观察StateFlow。class SearchViewModel( private val source: MangaSource ) : ViewModel() { private val _uiState MutableStateFlowSearchUiState(SearchUiState.Idle) val uiState: StateFlowSearchUiState _uiState.asStateFlow() fun search(keyword: String) { viewModelScope.launch { _uiState.value SearchUiState.Loading _uiState.value try { val result source.search(keyword) SearchUiState.Success(result) } catch (e: Exception) { SearchUiState.Error(e.message ?: unknown error) } } } } sealed interface SearchUiState { data object Idle : SearchUiState data object Loading : SearchUiState data class Success(val comics: ListComicMeta) : SearchUiState data class Error(val message: String) : SearchUiState }这种封装看起来多写了几行代码但对排查问题帮助很大。把 Loading、Success、Error 三种状态显式建模后界面不会出现“搜索失败但页面空白”的尴尬情况。5. 漫画阅读器渲染层页面容器、缩放与缓存策略书架和来源只是把数据送到门口真正决定用户体验的是打开章节后图片如何展示、如何翻页、如何缓存。5.1 单页翻页/横向翻页选择哪种容器漫画阅读常见有两种主模式日漫风格从右往左或从左往右单页翻页适合横屏阅读。Webtoon 风格纵向连续滑动适合手机竖屏阅读长条图。在 Compose 中横向分页可以直接使用HorizontalPager纵向阅读可以直接使用LazyColumn。下面以横向分页为例Composable fun MangaPager( pages: ListChapterImage, initialPage: Int, modifier: Modifier Modifier ) { val pagerState rememberPagerState( initialPage initialPage, pageCount { pages.size } ) HorizontalPager( state pagerState, modifier modifier.fillMaxSize() ) { pageIndex - val imageUrl pages[pageIndex].imageUrl MangaPageView(imageUrl imageUrl) } }这里的关键点是把ChapterImage中的pageIndex与PagerState.currentPage对应起来。不要依赖列表元素的角标去恢复阅读位置应该直接把pageIndex保存到 Room下次进入时用rememberPagerState(initialPage savedPage)定位。5.2 图片缩放与手势冲突处理漫画大图常需要双指缩放。必须避免缩放手势和翻页手势互相打架否则用户会非常痛苦。Composable fun MangaPageView( imageUrl: String, modifier: Modifier Modifier ) { var scale by remember { mutableStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } val context LocalContext.current AsyncImage( model ImageRequest.Builder(context) .data(imageUrl) .crossfade(true) .build(), contentDescription null, contentScale ContentScale.Fit, modifier modifier .fillMaxSize() .pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ - val newScale (scale * zoom).coerceIn(1f, 5f) scale newScale offset pan } } .graphicsLayer { scaleX scale scaleY scale translationX offset.x translationY offset.y } ) }这个实现只是最小示例实际项目还需要当缩放倍数等于 1 时把 offset 重置为零。当缩放倍数大于 1 时禁止 Pager 拦截左右滑动。双页模式下左右两页各自维护独立的缩放状态。处理旋转屏幕或切换全屏时的状态恢复。比较常见的处理方式是监听pagerState的拖拽事件在图片scale 1f时不做翻页在图片scale 1f时把翻页事件交还给 Pager。可以用pointerInput的awaitEachGesture手写事件分发也可以根据场景选择缩放和翻页的手势优先级。5.3 大图内存与磁盘缓存策略漫画图片通常比普通列表封面大很多。如果不加限制地把图片塞进内存低端机很容易 OOM。Coil 默认会按照目标 View 尺寸加载裁剪后的图片但如果页面容器是全屏仍需关注单张图片的内存占用。建议统一设置一个 Coil 的ImageLoaderval imageLoader ImageLoader.Builder(context) .memoryCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED) .crossfade(false) .respectCacheHeaders(false) .build() CompositionLocalProvider( LocalImageLoader provides imageLoader ) { AppContent() }respectCacheHeaders(false)对漫画阅读器比较友好因为不少图片服务器没有返回正确的缓存响应头。Coil 默认会根据 HTTP 缓存头决定是否复用磁盘缓存关闭该选项后可以明显提高重复阅读时的加载速度。注意不要为了“看得更清”就无条件用ImageRequest.size(Size.ORIGINAL)加载原图。超大长图建议使用专业的大图缩放控件或把图片切成瓦片加载。普通页面原图配合ContentScale.Fit可以满足大多数场景。6. 免登录不等于不做数据安全与备份很多漫画聚合工具强调“免登录”是为了降低用户使用门槛。但从工程角度应用不登录时更容易出现问题用户不知道数据存在哪里也没有任何服务端账号可恢复。对自研阅读器来说免登录只意味着不引入账号体系不影响应用做本地备份、导出、加密和恢复。6.1 本地数据的安全边界Room 数据库默认保存在应用私有目录其他应用无法直接读取。但这并不代表完全没有风险。设备 root 后应用私有目录可能被读取。android:allowBackuptrue时系统备份工具会把数据库和缓存一并备份。日志中不要打印完整请求路径或用户搜索记录。如果后续要保存本地账号 Token 或加密书源配置建议使用 Android Keystore 或引入 SQLCipher。至少在文档和设置页写明书架数据保存在本机不会上传到任意服务器。6.2 书架备份与导出的实现思路哪怕不登录也可以提供一个“生成备份文件”的功能。备份内容一般包含书架信息和阅读进度不包括已加载的图片缓存。{ export_version: 1, export_time: 1735689600000, comics: [ { sourceId: demo-json, comicId: demo-001, title: 示例漫画 001, lastChapterTitle: 第1话 } ], progress: [ { compositeId: demo-json/demo-001, chapterId: c1, pageIndex: 5 } ] }导入时先校验export_version和字段完整性再分批写入 Room。因为导入过程可能涉及大量数据建议在后台协程中执行并显示进度条。6.3 本地工具同样要守住内容边界免登录、无广告、本地优先这些产品特点都不等于可以绕过内容授权。技术上可以解析很多网页但把未授权作品提供给公众访问属于明确的版权风险。自用阅读器使用有授权的测试源或导入自己制作的图片打包文件是更稳妥的练习路径。开发者的合理做法是在应用内提供“添加来源”设置但来源列表不预置任何未授权站点。对搜索结果增加来源标识。在 README 或用户协议中明确内容责任归属。发布到应用市场前确认自己没有通过应用分发盗版内容。7. 完整运行从本地 CBZ 到网络书源的验证链路把模块拆完后需要有一个最小闭环能够判断工程是否正确。这里建议分两步验证先跑通本地 CBZ再跑通远程 JSON 书源。7.1 准备一个测试用 CBZ 文件在电脑上准备一个测试目录放入四张图片然后打成 ZIP 并重命名为.cbz。mkdir -p /tmp/comic_sample cp page_01.jpg /tmp/comic_sample/001.jpg cp page_02.jpg /tmp/comic_sample/002.jpg cp page_03.jpg /tmp/comic_sample/003.jpg cd /tmp/comic_sample zip -r ../comic_sample.cbz .Android 端点击“导入本地文件”时使用系统OpenDocument文件选择器选择这个 CBZ 文件。导入成功后书架应出现封面和标题如果无法识别需要检查文件名结构是否符合常见 CBZ 规范。7.2 启动本地 Mock JSON 书源网络书源验证不需要真实网站。可以在电脑上启动一个简单的 JSON 服务from flask import Flask, jsonify app Flask(__name__) app.route(/search) def search(): return jsonify({ code: 0, message: ok, data: { items: [ {id: demo-001, title: 示例漫画, author: demo, cover: http://10.0.2.2:8080/cover.jpg, intro: demo} ] } }) if __name__ __main__: app.run(host0.0.0.0, port8080)Android 模拟器访问宿主机使用的是10.0.2.2不是127.0.0.1。真机调试需要把地址改成电脑的局域网 IP并确保同一 Wi-Fi 下防火墙允许访问 8080 端口。7.3 验证搜索、章节加载和阅读进度恢复验证时要关注的不是“能不能打开”而是数据流转是否完整。建议按照以下链路检查搜索关键字确认ComicMeta列表正确展示。点击漫画进入详情页确认章节列表按order排序。点击某一章节确认图片 URL 列表返回正确。翻到第 3 页后退出再次进入同一章节确认能恢复到第 3 页。杀掉应用进程后再次启动确认书架排序仍正常。第 4、5 步最容易出问题。因为PagerState恢复通常只在内存中如果页面不读取 Room 中的进度或进度表没有在退出时写入重启后就丢位置。8. 常见问题排查现象、原因与处理顺序独立实现阅读器时很大一部分时间会花在排查和适配问题上。下面整理实际开发中最常见的几类问题。8.1 本地 CBZ 导入后空白或乱码错误现象常见原因处理方案导入后没有封面第一个图片不是封面或文件名太乱自定义封面逻辑取第一张图片或 meta.json 中的字段页面顺序错乱按字符串文件名排序导致10.jpg排在2.jpg前用自然排序 Comparator或读取 ZIP 中的 page 字段排中文文件名乱码ZIP 内部文件名编码不是 UTF-8尝试设置ZipFile(file, Charset.forName(GBK))解压失败或路径穿越entry 名称包含..和绝对路径检查文件名后再解压拒绝非法路径推荐做法本地 CBZ 导入只保留图片文件名和排序信息不把整包内容长期保留在应用私有存储中。书架数据库只保存 JSON 元数据阅读时再从原文件读取。8.2 图片加载为空或直接失败常见现象是图片加载一直转圈或出现灰屏。排查顺序应该是先用系统浏览器打开图片 URL确认地址能访问。检查应用是否有INTERNET权限。如果地址是http://检查usesCleartextTraffic是否允许明文。如果地址需要RefererCoil 请求时没有带对应请求头图片服务器可能拒绝。查看 Logcat 里 Coil 是否有 404、403 或 DNS 错误日志。对需要校验来源的图片服务器可以在ImageRequest中增加请求头。但要注意把某种来源的 Referer 写死在页面层会让阅读器失去通用性。更合理的方式是在书源配置中同步配置图片请求头。8.3 书源解析不生效如果搜索返回了 JSON 但没有漫画条目优先检查接口返回的实际内容和代码中解析字段是否一致。检查项检查方式是否访问了错误 URL在 Logcat 打印完整请求 URL复制到浏览器验证JSON 结构是否变化把响应字符串保存到本地用文本工具比对字段层级字段大小写是否一致JSON 中cover但代码写成了coverUrl中文是否乱码检查 HTTP 响应头charset必要时指定编码是否被反爬拦截查看响应是否是 HTML 登录页或验证页不要一上来就怀疑代码。多数解析问题都来自接口字段变化或请求 URL 拼接错误。排查时先打印原始响应再结合响应改代码效率最高。9. 生产化落地要考虑的细节与扩展方向一个能在自己手机上跑通的阅读器 Demo距离一个能发布的应用还有很长的路。越往后越需要关注下面这些工程细节。9.1 发布前必做的检查清单下面的清单适合在准备正式版本前逐项确认[ ] 是否移除了开发用日志中的 Token、Cookie 等敏感信息。[ ] 是否确认所有来源均有合法授权并明确内容责任。[ ] 是否处理了无网络、弱网、超时、接口异常分支。[ ] 是否设置了 Coil 磁盘缓存上限并允许用户清空缓存。[ ] 是否检查了 Android 12 导出组件配置。[ ] 是否符合 targetSdk 对明文流量、通知权限、文件访问的要求。[ ] 是否准备了解析源升级方案来源地址可配置化。[ ] 是否支持书架备份和恢复。[ ] 是否对超大图做了采样或分块加载。[ ] 是否用低端机验证过 OOM 与卡顿。这份清单并不是模板而是阅读器项目最容易遗漏的十个点。实际发布前还需要结合自己的包体、隐私政策、用户协议单独确认。9.2 从本地工具走向正式产品的扩展方向如果要把这个项目继续扩大比较合理的扩展方向包括支持 WebDAV 同步书架和阅读进度