记录学习,分享成长

← 返回列表

JmViewer Application for Android/IOS

JmViewer

JmViewer 应用图标

介绍

JmViewer 是一个非JMComic官方的跨平台漫画阅读器:一套 Vue 代码,同时跑在浏览器、Android 和 iOS 上。

本项目只是一个界面,不含任何内容,所有数据都来自第三方接口;下载功能仅建议用于个人离线备份。这里只讨论实现,不讨论内容。

从 9 月 12 日的第一行 first commit 到现在,仓库一共 67 次提交,src/ 下是 40 个 .vue、27 个 .js、11 个 .css,约 1.48 万行。下面把关键设计和踩过的坑整理成一篇复盘。


一、为什么要重写

原项目是典型的「大脚本 + 全局变量」结构:所有逻辑塞在几个 JS 文件里,靠 window 上挂载对象互相调用,没有构建流程,没有模块化,样式也是全站平铺。

它能用,但有几个绕不过去的问题:

  1. 不可维护:改一个阅读器行为,要顺着全局状态摸一整条链路。
  2. 移动端体验弱:没有路由、没有组件,手势和返回键都只能硬写。
  3. 没有离线:图片必须在联网时实时拉取,断网就完全不可用。
  4. 无法复用:想同时出 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/CDGitHub 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 字节倍数范围里,直接抛错,整条「切换已读」链路全部失败,用户看到的是解密错误。

最后的处理策略是分层判定,而不是一味兜底:

  1. 已经是对象 → 直接返回;
  2. 以 { / [ 开头的 JSON 字符串 → 直接解析;
  3. 合法 base64 密文 → 按模板尝试解密;
  4. 全部失败且内容不像密文(不是合法 base64,或长度不是 16 的倍数)→ 原样返回;
  5. 真正像密文却解不开 → 抛 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,负责底部进度条和「当前第几张」。

单图加载有两种模式,可在设置里切换:

模式流程适用
directnew Image() + crossOrigin → 解密 → canvas.toDataURL()默认,链路最短
blobfetch → 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 文件 Appshareddocuments:// 跳转
免责声明「拒绝」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
老机型直接白屏缺少 polyfillmain.js 第一行 import 'core-js/actual',必须早于其它模块
{"code":200,"data":"通知已讀"} 报解密失败明文被当成密文按内容特征分层判定,只有真密文才抛错
封面存不下来只有 imgServers[0] 带 CORS 头首选该域名下载,失败再按索引回退
离线页标题巨大路由名不同导致 .page-chapter 失效,回退浏览器默认 32px进入离线页时给 .root 手动加类
git diff 为空却显示 modifiedCRLF / 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:APK
  • build-ios.yml:未签名 IPA
  • release.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 记录了每个模块的职责和约束),有类似需求的朋友可以直接拿去对照。

最后再重复一次免责声明:本项目只是一个界面,不含任何内容;请遵守当地法律法规与内容来源方的服务条款,下载功能仅用于个人离线备份,请勿二次传播或用于商业用途。