JmViewer
![]()
介绍
JmViewer 是一个非JMComic官方的跨平台漫画阅读器:一套 Vue 代码,同时跑在浏览器、Android 和 iOS 上。
- 项目地址:https://github.com/liweimin39/JmViewer
- 原项目:Jmcomic-webUI(纯 HTML + 原生 JS)
- 当前版本:
v1.0.2,产物为.apk/ 未签名.ipa/web-dist.zip
本项目只是一个界面,不含任何内容,所有数据都来自第三方接口;下载功能仅建议用于个人离线备份。这里只讨论实现,不讨论内容。
从 9 月 12 日的第一行 first commit 到现在,仓库一共 67 次提交,src/ 下是 40 个 .vue、27 个 .js、11 个 .css,约 1.48 万行。下面把关键设计和踩过的坑整理成一篇复盘。
一、为什么要重写
原项目是典型的「大脚本 + 全局变量」结构:所有逻辑塞在几个 JS 文件里,靠 window 上挂载对象互相调用,没有构建流程,没有模块化,样式也是全站平铺。
它能用,但有几个绕不过去的问题:
- 不可维护:改一个阅读器行为,要顺着全局状态摸一整条链路。
- 移动端体验弱:没有路由、没有组件,手势和返回键都只能硬写。
- 没有离线:图片必须在联网时实时拉取,断网就完全不可用。
- 无法复用:想同时出 Android / iOS 包,没有构建层就只能靠 WebView 套壳,体验更差。
所以目标很明确:
- 一套代码三端运行(Web / Android / iOS)
- 保留核心算法(接口签名、AES 解密、图片切片还原)
- 补齐下载队列、SQLite 离线库、离线独立阅读页
- 主题化、设置化,让阅读体验可调
二、技术选型
| 分类 | 选择 |
|---|---|
| 框架 | Vue 3(Composition API) |
| 路由 | Vue Router 5 |
| 构建 | Vite 8 |
| 原生容器 | Capacitor 8 |
| 离线数据 | @capacitor-community/sqlite |
| 本地账号 | IndexedDB |
| 配置 | localStorage |
| 加密 | crypto-js(AES-ECB / MD5) |
| 压缩 | JSZip(仅 Web 端) |
| CI/CD | GitHub Actions |
为什么不用 Flutter、React Native 或 uni-app?
- 阅读器的核心是 Web 能力:图片切片还原靠 Canvas,懒加载靠
IntersectionObserver,渲染靠 DOM。这些在原项目里已经跑通,用 WebView 容器可以直接搬过来,改成原生渲染等于重写一遍。 - Capacitor 只做桥接:文件系统、SQLite、分享、返回键都是插件调用,业务代码仍然是标准前端,调试可以直接在浏览器里做。
- 三端产物一致:Android 是 APK,iOS 打成 IPA,Web 直接丢静态服务器,同一份
dist。
实际体验上的代价也很清楚:包体本质是「原生壳 + WebView」,低版本 WebView 的兼容性必须自己兜底(后面有专门的踩坑记录)。
三、整体架构
src/
├── api/ Crypto · JmcomicApi · UserApi
├── components/ chapter / download / general / index / latest / setting / user
├── composables/ useUser · useLocalUser · useDisclaimer · useStoragePermission · useBackButton
├── router/ 路由配置
├── styles/ basic · theme · index · latest · search · categories
│ chapter · user · setting · download · offline
├── utils/ offlineDB · offlineStorage · downloadManager · zipHelper
│ localDB · dataExport · logger
├── views/ 页面(含 OfflineView / OfflineChapterView)
├── App.vue
└── main.js存储分四层,互不越界
| 层 | 用途 |
|---|---|
SQLite jmviewer_offline | 离线专辑 / 章节 / 图片清单 / 下载任务 |
| 文件系统 | 漫画图片、封面、日志、临时文件 |
IndexedDB JmViewerLocalDB | 本地账号、收藏、历史 |
| localStorage | 设置、登录态、免责声明标记 |
一个很少见但很好用的设计
App.vue 的根节点类名是 page-${route.name},于是页面级样式可以写成 .page-chapter、.page-latest,天然按路由隔离,不需要 scoped,也不会互相污染。
这个设计也埋了一个坑:离线阅读页最初的路由名不是 chapter,导致整套 .page-chapter 字号规则失效,标题退回浏览器默认的 32px,看起来「巨大」。修复方式是进入离线页时手动给 .root 追加 page-chapter 类、离开时移除,而不是复制一份样式。
四、核心实现
1. 接口签名与 AES 解密
所有请求都带两个头,key 是当前秒级时间戳:
const key = String(Math.floor(Date.now() / 1000))
const token = MD5(key + '185Hcomic3PAPP7R')
// headers: token / tokenParam = `${key},3.2.0`响应是 AES-ECB 密文,密钥由 MD5(key + 模板) 动态生成,需要按两套模板逐个尝试:
const templates = ['185Hcomic3PAPP7R', '18comicAPPContent']
// 逐个 MD5 → AES-ECB 解密 → JSON.parse,失败换下一个踩坑:部分接口会直接返回明文,例如
{"code":200,"data":"通知已讀"}原来的实现无脑走「base64 → AES 解密」,这条明文不在 base64 的 16 字节倍数范围里,直接抛错,整条「切换已读」链路全部失败,用户看到的是解密错误。
最后的处理策略是分层判定,而不是一味兜底:
- 已经是对象 → 直接返回;
- 以
{/[开头的 JSON 字符串 → 直接解析; - 合法 base64 密文 → 按模板尝试解密;
- 全部失败且内容不像密文(不是合法 base64,或长度不是 16 的倍数)→ 原样返回;
- 真正像密文却解不开 → 抛
Decryption failed,不静默吞错。
同时 UserApi 在解密结果是字符串时按 code 判定业务成功:code >= 400 抛业务错误,否则视为成功。这样 {"code":200,"data":"通知已讀"} 才不会被当成失败。
2. 图片切片还原(ImageCutter)
漫画图片是切乱拼接的,还原方式是:把原图按固定高度切成若干片,最底部一片移到顶部,其余自下而上依次拼接。
关键在切片数,它由章节 ID 和文件名决定:
if (id >= 220980 && id < 268850) return 10
const hash = MD5(id + path.substring(0, 5))
let key = hash.charCodeAt(hash.length - 1)
key = id >= 268850 && id <= 421925 ? key % 10 : key % 8
const layers = key * 2 + 2 // 2 ~ 50
return Math.min(Math.max(layers, 2), 50)几个必须记住的点:
- 必须传章节 ID,不能传专辑 ID。传错就会算错切片数,图片直接变成乱码。
id < 220980的图和.gif不需要解密。- Canvas 先铺白底,否则透明图会变黑。
- 切片高度
floor(height / sliceCount),除不尽的余数归到最底部那一片。
3. 阅读管线:懒加载 + 并发队列
ComicContent 把章节里的图片容器按每批 15 张、间隔 80ms 交给 ComicImageLoader,避免一次性创建几百个 <img> 卡死主线程。
加载器内部维护两个观察器:
- 加载观察器:
rootMargin由设置项root_margin控制(默认200px),提前预加载视口附近的图片; - 进度观察器:
threshold: 0.01,负责底部进度条和「当前第几张」。
单图加载有两种模式,可在设置里切换:
| 模式 | 流程 | 适用 |
|---|---|---|
direct | new Image() + crossOrigin → 解密 → canvas.toDataURL() | 默认,链路最短 |
blob | fetch → Blob → objectURL → 解密 → canvas.toBlob() | 规避 base64 内存膨胀、解密失败时更稳 |
两种模式失败都会降级显示原图,destroy() 时断开观察器、清空队列并 revokeObjectURL,避免内存泄漏。
4. 全屏阅读模式
原项目有一个「沉浸式阅读」:黑底满屏、只留一条底部进度条。移植时我把它做成了可配置的完整模式:
- 进入:容器从普通文档流切换成
position: fixed的 100vw × 100vh,锁定页面滚动; - 底部:百分比进度条 +
当前 / 总数+ 右侧设置按钮; - 页内设置:图片宽度滑块(50% ~ 100%)、横向阅读开关,改动即写 localStorage;
- 横向模式:
scroll-snap整屏翻页,点击左半屏 / 右半屏翻页,滚动时同步页码; - 键盘:
Esc退出,设置面板打开时先关面板; - 设置页可开启「自动全屏阅读」,点「开始阅读」直接进入。
核心 CSS 只有三段:
.comic-img-cr.fullscreen-reader {
position: fixed;
top: 0; right: 0; bottom: 0; left: 0;
width: 100vw; height: 100vh;
overflow-y: auto;
background: #000;
}
.comic-img-cr.reader-horizontal {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.reader-horizontal .comic-img {
flex: 0 0 100vw;
height: 100vh;
scroll-snap-align: start;
}5. 下载队列
downloadManager 是一个单例,状态机为 pending / downloading / packaging / completed / failed / paused / cancelled。
- 并发上限 2,失败重试 3 次,间隔 2s;
- 断点续传:目标文件已存在且大于 100 字节就跳过,重进 App 也能接着下;
- 暂停 / 取消不是布尔标记,而是抛出
__PAUSE__/__CANCEL__异常,让正在fetch的循环立刻中止; - 任务持久化优先写 SQLite,SQLite 不可用时自动回退到
download-tasks.json; - 原生端不做 ZIP 打包,直接保存解密后的图片,规避大文件在内存里打包导致的 OOM;Web 端才走 JSZip。
6. 封面与 CORS
下载时要把封面存到本地,但直接 fetch 封面会被跨域拦住。实测只有图片服务器列表里的第一个域名返回了 Access-Control-Allow-Origin: *。
处理方式是:优先用 imgServers[0] 下载,失败再按 id % 5 回退其它域名;任务开始时重试一次;已经下载完的老漫画,启动后在后台补存封面。
7. 离线库
SQLite 只建四张表:
albums(id TEXT PRIMARY KEY, name, author, payload JSON, updated_at)
chapters(id TEXT PRIMARY KEY, album_id, idx, name, image_count, bytes, payload JSON, downloaded_at)
images(chapter_id, idx, source, file, bytes, PRIMARY KEY(chapter_id, idx))
download_tasks(id TEXT PRIMARY KEY, payload JSON, position, updated_at)文件布局按专辑 / 章节分层:
JmViewer/Comics/{albumId}/cover.jpg
JmViewer/Comics/{albumId}/chapters/{chapterId}/*.jpg|png离线阅读页只读 SQLite + 本地文件,没有在线回退:图片经 Capacitor.convertFileSrc() 直接读已解密文件,不二次解密、不写阅读历史,因此断网也能看。
Android 存在公共目录 /storage/emulated/0/JmViewer/(需要「所有文件访问」权限,可被 MT 管理器等工具直接访问);iOS 存在 App Documents,开启 UIFileSharingEnabled 后可在系统「文件」App 里看到。
8. 主题
主题系统用 CSS 变量实现::root 定义默认粉色,另外 6 套主题以 .blue-theme / .dark-theme / .orange-theme 这类类名覆盖同名变量。共 49 个基础变量 + 13 个扩展变量(表面色、强调色浅底、正文色等),切换主题只是换 <html> 上的类名。
为了避免首屏闪烁,index.html 里有一段内联脚本,在 Vue 挂载前就按 localStorage 的 app_theme 把主题类挂到 <html> 上。
五、原生能力
| 能力 | 实现 |
|---|---|
| Android 返回键 | @capacitor/app 监听,抽屉优先 → 路由后退 → 根页面 2s 内双击退出 |
| 存储权限 | 自定义插件 StoragePermissionPlugin.java(Android 11+ 引导「所有文件访问」) |
| 打开文件夹 | 优先唤醒 MT 管理器,失败降级系统文件管理器 |
| iOS 文件 App | shareddocuments:// 跳转 |
| 免责声明「拒绝」 | iOS SceneDelegate.swift 中 exit(0) |
iOS 这里有个绕不开的规则:Apple 不允许应用自行退出(审核指南 2.4.5)。因为这个项目不打算上架 App Store,所以「拒绝」按钮直接 exit(0);如果以后要上架,这里必须改成「锁定界面」的方案。
六、踩坑合集
| 问题 | 根因 | 解法 |
|---|---|---|
| Android 9 上样式错乱 | WebView ≈ Chrome 70,inset 简写和 flex gap 都不支持 | 全部改成 top/right/bottom/left,用 margin 替代 gap |
| 老机型直接白屏 | 缺少 polyfill | main.js 第一行 import 'core-js/actual',必须早于其它模块 |
{"code":200,"data":"通知已讀"} 报解密失败 | 明文被当成密文 | 按内容特征分层判定,只有真密文才抛错 |
| 封面存不下来 | 只有 imgServers[0] 带 CORS 头 | 首选该域名下载,失败再按索引回退 |
| 离线页标题巨大 | 路由名不同导致 .page-chapter 失效,回退浏览器默认 32px | 进入离线页时给 .root 手动加类 |
git diff 为空却显示 modified | CRLF / LF 行尾差异 | 用 .gitattributes 统一 * text=auto eol=lf |
| iOS 侧滑返回在 SPA 里不生效 | WKWebView 手势与 history 行为差异 | 页面内保留返回入口,不依赖侧滑 |
还有一个不太显眼的问题:Web 端没有 SQLite,如果没有降级路径,整个离线模块会崩。做法是 offlineDB.js 在 Web 上返回空实现,离线库页面显示「仅客户端可用」,业务层完全不需要判断平台。
七、构建与发布
日常工作流:
npm install
npm run dev # 开发
npm run build # 构建 Web 产物
npx cap sync android && npx cap open android
npx cap sync ios && npx cap open ios发布完全交给 GitHub Actions,.github/workflows/ 下四个工作流:
build-web.yml:构建静态站点build-android.yml:APKbuild-ios.yml:未签名 IPArelease.yml:打 tag 时构建三端产物并创建 Release,发布前会先删掉同名旧 Release
发版只需要三件事:同步版本号(build.gradle、project.pbxproj、package.json),打 tag,推送。
git tag v1.0.2
git push origin v1.0.2规则很简单:tag 里含 beta 会自动标记为预发布。Android 签名走 Secrets:ANDROID_KEYSTORE_BASE64、ANDROID_KEYSTORE_PASSWORD、ANDROID_KEY_ALIAS、ANDROID_KEY_PASSWORD。
八、已知限制
- 极低版本 WebView(Android 9 以下)仍可能有兼容问题;
- iOS 侧滑返回不生效,需要用页面内按钮;
- Web 端打包在内存中处理,超大漫画(> 100 MB)可能 OOM;
- 离线功能只在 Android / iOS 客户端可用;
- 离线阅读页刻意不写阅读历史。
如果要继续迭代,我的优先级是:把重复的解密判断抽公共模块、给核心算法补单元测试、修正 jmApi.init() 重复调用和 retryFetch 不校验 resp.ok 这两个遗留问题。
结语
这次重写最大的收获不是「又写了一个 App」,而是把原项目里那些藏在全局状态里的隐式约定,一条条显式化成了架构:四层存储各自负责什么、解密失败到底该不该抛错、离线页能不能回退在线、原生端为什么不能打包 ZIP。
代码和开发文档都在仓库里(doc/DEVELOPMENT.md 记录了每个模块的职责和约束),有类似需求的朋友可以直接拿去对照。
最后再重复一次免责声明:本项目只是一个界面,不含任何内容;请遵守当地法律法规与内容来源方的服务条款,下载功能仅用于个人离线备份,请勿二次传播或用于商业用途。