# 温故而知新
**Repository Path**: dccmmtop/oldToNew
## Basic Information
- **Project Name**: 温故而知新
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: MulanPSL-2.0
- **Default Branch**: main
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-11-04
- **Last Updated**: 2026-11-05
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
OldToNew
把你的 Git 笔记仓库,变成一本每天都能读两页的技术刊物。
Flutter 3.10 · Dart 3.13 · Android 8.0+ · 只读 · 无后端 · 数据全在本地
---
## 这是什么
我在 Gitee / GitHub 上存了一个 Markdown 笔记仓库,两百多篇。平时几乎不会打开它——网页端要点一层层目录、要等图、手机上排版还乱。
于是写了 OldToNew:给它一个仓库地址,它把整个仓库同步到手机本地,用目录树浏览,点开就是干净的阅读版式(代码高亮、图片缓存、可离线看),再按「什么时候更新的、读过没有」每天推一篇给你。
只读,不写回仓库;没有账号,没有服务端,没有埋点。数据只存在你自己手机里。
## 界面
目录树 · 每日推荐 · 阅读页
## 能做哪些事
| 模块 | 能力 |
|---|---|
| **仓库** | 支持 GitHub / Gitee;填 `owner/repo`、完整链接或 `git@host:owner/repo.git` 都行,带 `/tree//` 的浏览链接会自动拆出分支和根目录;私有仓库填只读 Token,Token 存 Android Keystore,不进数据库、不进日志 |
| **同步** | 启动、回到前台、每 5 分钟、手动刷新四个触发点;单飞(重入复用同一次任务)+ 10 秒节流 + 限流退避;按 blob SHA 做增量,只下新增和修改,删除项一并清理 |
| **目录树** | 自然排序(`第2章` 排在 `第10章` 前)、层级折叠、展开状态按仓库记忆、未读圆点、加密文件显示锁图标 |
| **阅读** | YAML front matter 自动剥离、代码块语法高亮、表格 / 引用 / 任务列表、图片懒加载与磁盘缓存、长文滚动不回弹 |
| **主题** | 5 套阅读主题:GitHub、One Dark、Dracula、Nord、Solarized,选择页带真实小样预览 |
| **推荐** | 每日一篇 + 「给我下一篇」;权重 = Git 提交时间(半衰期 14 天)+ 未读加权 − 已读降权 − 近期读过降权 + 随机扰动;7 天内不重复;推荐范围可选目录;结果带命中原因,不是黑盒 |
| **加密笔记** | 兼容 [Meld Encrypt](https://github.com/meld-cp/obsidian-encrypt) 插件:整篇加密的 `.mdenc` / `.encrypted` 和文内加密块都能就地解密,明文只在内存 |
## 快速开始
```bash
flutter pub get
dart run build_runner build # 改过 drift 表结构才需要
flutter run -d
```
打包:
```bash
flutter build apk --release --split-per-abi
```
产物在 `build/app/outputs/flutter-apk/`。最低 Android 8.0(API 26)。
首次启动会让你填仓库,两步就能进目录树;仓库是空的或 Token 无效时会给出具体原因,不会只说一句「加载失败」。
## 项目结构
```
lib/
core/ 纯 Dart,无 Flutter 依赖,可直接单测
models/ 领域模型与状态机类型
utils/ 自然排序、仓库路径、front matter、图片引用解析
crypto/ Meld Encrypt(Obsidian 插件 v3 格式)解密
tree/ 目录树构建与可见行扁平化
recommendation/ 推荐算法,权重集中在一个类里
data/
db/ drift schema(7 张表)与行↔模型映射
repositories/ 按聚合拆分的仓储
providers/ GitProvider 抽象 + GitHub / Gitee 适配器
sync/ 同步协调器(单飞 / 节流 / 退避 / 规则版本)
cache/ 正文与图片的文件缓存
security/ Token 安全存储
session/ 当前仓库会话(配置 + Token)
features/ 按页面拆分:setup / tree / reader / recommend / settings / sync
app/ 主题、依赖注入、首屏分流、生命周期宿主
doc/ 需求文档与截图
tool/ 冒烟测试与性能体检脚本(不在 flutter test 的默认扫描范围内)
```
技术选型:
| 层 | 选型 |
|---|---|
| 语言 / UI | Dart 3 + Flutter(Material 3,仅 Android) |
| 状态 | Riverpod 2 |
| 数据库 | drift(SQLite)+ 文件缓存 |
| 网络 | Dio |
| Markdown | `flutter_markdown_plus`(`flutter_markdown` 已于 2025-05 停维护)+ `highlight` |
| 安全存储 | `flutter_secure_storage`(Android Keystore) |
## 关键设计
**单飞不用锁。** Dart 是单线程事件循环,`SyncCoordinator` 只持有在途 `Future`,重入直接复用同一个 Future。手动刷新另有 10 秒节流与限流冷却两层闸门。
**同步不落半成品。** 先下载全部变动正文,再一次性事务写入索引;任一文件失败就整体放弃,旧缓存保持可用。切后台会中断任务,中断点同样不动索引。
**增量靠 blob SHA。** 先比 HEAD,未变直接返回「已是最新」,不多花请求;变了再拉递归树,按 blob SHA 只下载新增 / 修改。
**提交时间有兜底。** 单个文件的 Git 提交时间要额外一次 `commits?path=` 请求,成本高,因此每次同步最多补 20 条、剩余配额低于 5 或遇限流就停;补不到的文件用 HEAD 提交时间,仍是 Git 时间,只是粒度更粗。
**规则变了下发不出去是个真问题。** 同步器会记住「文章过滤器版本」,版本落后时强制重建一次树。这里踩过两个坑:HEAD 未变会走「已是最新」捷径,直接不拉树;以及强制重建时若仍带 ETag,服务端回 304,又被当成「没变化」——两者叠加,新支持的 `.mdenc` 在老设备上永远发现不了。现在强制重建会显式丢弃 ETag。
**加密笔记就地解,明文不落盘。** Meld Encrypt 有两种形态都要认:整篇加密(`.mdenc` / `.encrypted` 文件,打开时输密码)和文内加密块(`%%🔐γ 💡提示💡 🔐%%`,正文里渲染成一张锁定卡片,点开就地变成 Markdown)。算法按插件 v3 格式实现:PBKDF2-HMAC-SHA512(迭代次数随密文存放,220000–2000000)+ AES-256-GCM,GCM 的附加认证数据是重建的头部 JSON(`{version, kdf, cipher}`,字段顺序固定)。220000 次 PBKDF2 在真机上要数百毫秒,所以解密放在 isolate 里跑;明文只留在内存,退出阅读页即消失——和插件「落盘永远是密文」的约定一致。
只支持 v3(标记里的 `γ`)。更早的 `β` / `α` 块会明确提示回 Obsidian 另存一次升级格式,而不是假装能解。
## 国内镜像
构建慢基本都慢在下载上,项目里已经把这些源换成国内镜像,官方源保留在后面兜底:
| 下载内容 | 配置位置 | 镜像 |
|---|---|---|
| Gradle 分发包 | `android/gradle/wrapper/gradle-wrapper.properties` | 华为云 `mirrors.huaweicloud.com/gradle` |
| AGP / Kotlin / androidx 等 Maven 依赖 | `android/settings.gradle.kts`(插件)、`android/build.gradle.kts`(依赖) | 阿里云 `maven.aliyun.com/repository/{google,public,gradle-plugin}` |
| Dart 包 | 环境变量(见下) | 阿里云 pub 镜像 |
`android/gradle.properties` 里另外开了 `org.gradle.parallel` 与 `org.gradle.caching`,增量构建会复用上次的产物。
pub 镜像只能通过环境变量生效,写在项目里没有用:
```bash
export PUB_HOSTED_URL=https://mirrors.aliyun.com/dart-pub
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
```
Gradle 分发包的缓存目录名是 `distributionUrl` 的 MD5(base36),换镜像后会当成新分发重新下载一次;如果本机已有旧缓存,可以复制到新目录省掉这次下载:
```bash
cp -r ~/.gradle/wrapper/dists/gradle-9.3.1-all/<旧哈希>/. \
~/.gradle/wrapper/dists/gradle-9.3.1-all/<新哈希>/
```
## 测试
```bash
flutter test # 单测:同步状态机 / 目录树 / 推荐 / 加解密 / 主题
flutter test tool/live_smoke.dart # 真实网络冒烟(会消耗平台配额)
flutter test tool/bench_hotspots_test.dart --reporter expanded # CPU 热点体检
```
两个坑记一下:
- 环境里但凡设了 `HTTP_PROXY`,flutter_tester 连本地 WebSocket 会被代理拦掉,报 `Invalid WebSocket upgrade request`。跑测试前清掉代理:`env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy flutter test`
- 冒烟测试里的 GitHub 用例默认 `skip`(部分网络环境连不上 `api.github.com`),用 Gitee 仓库跑通同一条同步流水线。
性能体检脚本用真实笔记量的数(桌面 JIT,仅作相对参考):最大那篇 45KB 的 markdown 解析 13ms,一篇文章里 48 个代码块全部高亮 23ms,204 篇的目录树构建 0.4ms。唯一真正慢的是 PBKDF2 的 22 万次迭代(约 0.7s),它才是解锁时那一下卡顿的来源,所以单独丢进 isolate。
## 已知限制
- **单仓库**:切换仓库保留旧缓存不删除,切回来可以秒开;不支持多仓库聚合。
- **图片只预取一部分**:同步时按「每篇最多 4 张、每次最多 24 张、单张 2MB」预取,其余靠阅读时按需缓存,从未打开过的文章里的图离线仍可能缺。
- **Gitee 不返回 ETag**,条件请求只在 GitHub 生效;Gitee 的增量靠 HEAD 比对 + blob SHA,行为正确但不多省流量。
- **目录树被平台截断**(超 10 万条目)时直接报解析错误并保留旧缓存,不做分页拉取。
- 未做:清空阅读 / 推荐历史、强制全量重拉、iOS / 桌面端。
- release 构建目前用 debug 签名,自己装没问题,要分发得先配 keystore。
## 需求文档
需求与验收标准在 `doc/需求文档-v1.1.md`,实现与之对应的编号:BR-01~BR-08、BR-10~BR-20、BR-22、BR-23;FR-01~FR-21;REC-01~REC-10。
NFR 说明:NFR-11(推荐可解释性)通过页面上展示的 `reasons` 实现;NFR-12(刷新可观测性)落在本地 `SyncLogs` 表(触发源 / 耗时 / 结果 / 错误类别,仓库标识用哈希),没有远端埋点——本期没有后端。